From a7340fbd357401c2c75c22ef90eb80483fbbe8fc Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Mon, 28 Sep 2026 19:55:51 +0200 Subject: [PATCH 01/21] book: fast-parse-number reads a decimal as Number() does --- book/lib/fast-parse-number.mjs | 21 ++++++++++++++---- builder/PLAN-TOOLING-REVIEW.md | 35 ++++++++++++++++++++++++++++++ docs/Documentation/Fixes-PDFLib.md | 2 +- 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/book/lib/fast-parse-number.mjs b/book/lib/fast-parse-number.mjs index 0f202d0a..67f592eb 100644 --- a/book/lib/fast-parse-number.mjs +++ b/book/lib/fast-parse-number.mjs @@ -18,9 +18,11 @@ // // The fast path accumulates the integer directly (n = n*10 + (byte - // 0x30)). parseRawNumber additionally descends into decimal handling -// when a period appears. Both fall back to the original for: -// - Numbers with > 15 integer digits (where direct accumulation -// could exceed Number.MAX_SAFE_INTEGER and lose precision). +// when a period appears, and divides once, so a decimal reads as the +// same double Number() gives. Both fall back to the original for: +// - Numbers with > 15 digits, the fraction's included (where direct +// accumulation could exceed Number.MAX_SAFE_INTEGER and lose +// precision). // - Empty-digit cases (e.g., bare sign or lone "."), so upstream's // NumberParsingError keeps its diagnostic context. // Both fallback paths are vanishingly rare on real PDFs. @@ -129,9 +131,17 @@ if (!BaseParser.__fastParseNumberInstalled) { // Decimal part let frac = 0; let scale = 1; + let digits = intDigits; while (!bytes.done() && IsDigit[byte]) { + if (digits >= MAX_SAFE_INT_DIGITS) { + // Past 15 digits in all, the numerator below is no longer exact + // -- rewind and delegate, as for a long integer. + bytes.moveTo(start); + return origParseRawNumber.call(this); + } frac = frac * 10 + (byte - ZERO); scale *= 10; + digits++; bytes.next(); byte = bytes.peek(); } @@ -143,7 +153,10 @@ if (!BaseParser.__fastParseNumberInstalled) { return origParseRawNumber.call(this); } - const value = frac === 0 ? intPart : intPart + frac / scale; + // One division of two exact integers, which IEEE 754 rounds to the + // double nearest the decimal, as Number() does. Adding frac / scale + // to intPart would round twice: 2.28 would read as 2.2800000000000002. + const value = (intPart * scale + frac) / scale; return neg ? -value : value; }; diff --git a/builder/PLAN-TOOLING-REVIEW.md b/builder/PLAN-TOOLING-REVIEW.md index 5ee97b8c..b8d757ad 100644 --- a/builder/PLAN-TOOLING-REVIEW.md +++ b/builder/PLAN-TOOLING-REVIEW.md @@ -2526,6 +2526,36 @@ run of 28,991 bytes, inside one object stream of 500 objects; inflated, the two only in `/CreationDate` and `/ModDate`, the render times. `compare_trees`: Builder.html and Fixes/PDFLib.html online and offline, the search data, and `book.html`. +### C65c — `book: fast-parse-number reads a decimal as Number() does` + +**Found while building C66** (see Found while implementing). `fast-parse-number.mjs` read a +decimal as `intPart + frac / scale`, which rounds twice, and accumulated a fraction of any +length, so `2.28` read as `2.2800000000000002` and `-40.8933` as `-40.893299999999996`, where +stock pdf-lib's `Number()` gives the double nearest the decimal. The book is not affected +today: none of the 70,978 decimals in the object streams of the render of 2026-09-25 has the 15 +or more significant digits that a misread number is written with. + +**Change.** Divide once, `(intPart * scale + frac) / scale`: while the number has at most 15 +digits both operands are exact integers, and IEEE 754's correctly rounded division gives what +`Number()` gives. Past 15 digits, the fraction's included, rewind and delegate to the original, +as a long integer already did. Fixes-PDFLib.md's section says so. + +**Verify.** The kit's `c65c-oracle.mjs` runs stock `parseRawNumber`, HEAD's shim and the +working one over the same numbers; the book renders the same through HEAD's shims and the +working ones. + +**Landed.** As the entry says; the shim's header says the 15 digits include the fraction's. +The kit's `c65c-oracle.mjs` over 289,724 numbers (16 fixed, the rest random: a sign or none, up +to 17 integer digits, up to 19 after the period, or a bare period): HEAD's shim differs from +stock `parseRawNumber` on 1,747, `2.28`, `-40.8933` and `1.610936` among them; the working one +on none, in value (`-0` told apart), end offset and throw alike. The book rendered twice from +one `_site-pdf`, through HEAD's `book/` and `lib/` and through the working tree's: 91 s and +88 s, both `process: 1.1s`, 2,298 pages, 2,466 outline entries and 29,111,771 bytes each, +differing in one object stream and there only in `/CreationDate` and `/ModDate`. C66's gate, +not yet committed, passes over the fixture that found the defect, with its page insertion +taken out until C65d. `compare_trees`: Fixes/PDFLib.html online and offline, the search data, +and `book.html`. + *The book's pdf-lib shims (decision (c)): C66–C69.* ### C66 — `book: check_pdf_shims_equiv.mjs, the shims against stock pdf-lib` @@ -3253,6 +3283,11 @@ Defects the review did not have, found by building something this plan asks for. `FlateStream` (the kit's `c66-inflate-count.mjs`). The owner chose deletion, `pako` included. Fixed in `book: delete fast-inflate.mjs, which patched a function pdf-lib never calls`. +- **`fast-parse-number.mjs` read some decimals as a different double than stock**, found + while building C66: its gate, over a fixture holding `/Sum 2.28`, found the shimmed save + writing `2.2800000000000002`, and named the shim both ways (the only one to differ alone, and + the only one whose removal made the output match). Scheduled as C65c, at the owner's choice. + Fixed in `book: fast-parse-number reads a decimal as Number() does`. ## Open questions diff --git a/docs/Documentation/Fixes-PDFLib.md b/docs/Documentation/Fixes-PDFLib.md index b5c1fff2..72892b89 100644 --- a/docs/Documentation/Fixes-PDFLib.md +++ b/docs/Documentation/Fixes-PDFLib.md @@ -26,7 +26,7 @@ The root cause of the need for all these patches is the same: pdf-lib is designe **Problem.** `BaseParser.parseRawNumber` and `BaseParser.parseRawInt` built numeric values by appending one character at a time to a JavaScript string (`value += charFromCode(byte)`), then called `Number(value)` to convert the string back to a number. Every numeric token in a PDF --- object numbers, generation numbers, byte lengths, coordinates, font sizes, array indices --- flows through one of these paths. Each call allocated a temporary string that was immediately discarded. On the book this fired hundreds of thousands of times. -**Fix.** Direct integer accumulators: `n = n * 10 + (byte - 0x30)`, consuming each byte once. `parseRawNumber` additionally handles the decimal part with a separate accumulator and a `scale` divisor. Both implementations fall back to the original when the integer part would exceed 15 digits (preserving `Number.MAX_SAFE_INTEGER` semantics for pathological inputs) or when the input has no digits at all. +**Fix.** Direct integer accumulators: `n = n * 10 + (byte - 0x30)`, consuming each byte once. `parseRawNumber` additionally accumulates the digits after the period and divides once, `(integer * scale + fraction) / scale`: both operands are exact integers, and a single division rounds to the double nearest the decimal, as `Number` does. Adding the fraction's quotient to the integer part instead would round twice, and read `2.28` as `2.2800000000000002`. Both implementations fall back to the original when the number has more than 15 digits, the fraction's included (preserving `Number.MAX_SAFE_INTEGER` semantics for pathological inputs), or no digits at all. **Mechanism.** `BaseParser` is not re-exported from pdf-lib's public index; it is imported via `createRequire` through the CJS internal path `pdf-lib/cjs/core/parser/BaseParser.js`. Mutating `BaseParser.prototype` affects all subclasses: `PDFParser`, `PDFObjectParser`, `PDFObjectStreamParser`, and `PDFXRefStreamParser`. From c5d9725bef7b99b2bd314e6f3ad10d81ace0f775 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Mon, 28 Sep 2026 20:01:54 +0200 Subject: [PATCH 02/21] book: fast-dict-onebuf builds new pages and page trees in its buffer --- book/lib/fast-dict-onebuf.mjs | 23 +++++++++++++++++++ builder/PLAN-TOOLING-REVIEW.md | 36 ++++++++++++++++++++++++++++++ docs/Documentation/Fixes-PDFLib.md | 2 +- 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/book/lib/fast-dict-onebuf.mjs b/book/lib/fast-dict-onebuf.mjs index 888705cd..28e14d96 100644 --- a/book/lib/fast-dict-onebuf.mjs +++ b/book/lib/fast-dict-onebuf.mjs @@ -481,6 +481,29 @@ if (!PDFDict.prototype.__fastDictOnebufInstalled) { return d; }; + // pdf-lib's own versions of these two build on a Map, with `new`, and + // the methods above cannot read such a dict: addPage, insertPage and + // PDFDocument.create would fail. The entries are pdf-lib's, in its order. + PDFPageTree.withContext = function (context, parent) { + const map = new Map([ + [PDFName.of('Type'), PDFName.of('Pages')], + [PDFName.of('Kids'), context.obj([])], + [PDFName.of('Count'), context.obj(0)], + ]); + if (parent) map.set(PDFName.of('Parent'), parent); + return PDFPageTree.fromMapWithContext(map, context); + }; + + PDFPageLeaf.withContextAndParent = function (context, parent) { + const map = new Map([ + [PDFName.of('Type'), PDFName.of('Page')], + [PDFName.of('Parent'), parent], + [PDFName.of('Resources'), context.obj({})], + [PDFName.of('MediaBox'), context.obj([0, 0, 612, 792])], + ]); + return PDFPageLeaf.fromMapWithContext(map, context, false); + }; + // ---- PDFObjectParser.prototype.parseDict -------------------------- // // Each parser instance carries its own temp array (small; sized to diff --git a/builder/PLAN-TOOLING-REVIEW.md b/builder/PLAN-TOOLING-REVIEW.md index b8d757ad..81779d73 100644 --- a/builder/PLAN-TOOLING-REVIEW.md +++ b/builder/PLAN-TOOLING-REVIEW.md @@ -2556,6 +2556,37 @@ not yet committed, passes over the fixture that found the defect, with its page taken out until C65d. `compare_trees`: Fixes/PDFLib.html online and offline, the search data, and `book.html`. +### C65d — `book: fast-dict-onebuf builds new pages and page trees in its buffer` + +**Found while building C66** (see Found while implementing). `fast-dict-onebuf.mjs` replaces +`PDFDict`'s methods with ones that read a dictionary's entries from one shared buffer, and +replaces six of pdf-lib's eight factories for `PDFDict`, `PDFCatalog`, `PDFPageTree` and +`PDFPageLeaf` to build there. The other two, `PDFPageTree.withContext` and +`PDFPageLeaf.withContextAndParent`, still build on a `Map` with `new`, and the replaced methods +cannot read what they make: `insertPage` and `addPage` fail (`Expected instance of PDFArray, +but got instance of undefined`, from the new page's `/MediaBox`), and `PDFDocument.create` +would. The book never adds a page; `parallelSave` would, only for a document with none. + +**Change.** Replace the two, building pdf-lib's entries in pdf-lib's order through the shim's +own `fromMapWithContext`. Fixes-PDFLib.md's section names the eight factories. + +**Verify.** `create`, `addPage`, `insertPage` and a save give stock's bytes under the shim +alone and under every shim; C66's gate passes with its page insertion; the book renders the +same. + +**Landed.** As the entry says. pdf-lib 1.17.1 has exactly these eight static factories on the +four classes, and C65d's two are the ones `PDFDocument.create` (`PDFDocument.js:146`) and +`PDFPage.create` (`PDFPage.js:1435`) call. The kit's `c65d-oracle.mjs` runs `create`, +`addPage`, `insertPage`, `drawText`, another `addPage` and `save` in a process per shim set: +stock gives 3 pages and 1,019 bytes; HEAD's shim fails at `insertPage` alone and with every +shim; the working one gives stock's bytes (same sha256) both ways. C66's gate, not yet +committed, passes with nothing taken out of its change: 22 objects, every shim run. The book +rendered through HEAD's `book/` and `lib/` and through the working tree's: 88 s each, +`process: 1.1s` and `1.0s`, 2,298 pages, 2,466 outline entries and 29,108,192 bytes each (the +count moved with C65c's edit to Fixes-PDFLib.md), differing only in `/CreationDate` and +`/ModDate`. `compare_trees`: Fixes/PDFLib.html online and offline, the search data, and +`book.html`. + *The book's pdf-lib shims (decision (c)): C66–C69.* ### C66 — `book: check_pdf_shims_equiv.mjs, the shims against stock pdf-lib` @@ -3288,6 +3319,11 @@ Defects the review did not have, found by building something this plan asks for. writing `2.2800000000000002`, and named the shim both ways (the only one to differ alone, and the only one whose removal made the output match). Scheduled as C65c, at the owner's choice. Fixed in `book: fast-parse-number reads a decimal as Number() does`. +- **Under `fast-dict-onebuf.mjs`, pdf-lib could not add a page**, found while building C66: + the shimmed side of its gate failed at `insertPage`, and so did `fast-dict-onebuf.mjs` alone. + Two of pdf-lib's dictionary factories were left unreplaced, and the replaced methods cannot + read what they build. Scheduled as C65d, at the owner's choice. Fixed in `book: + fast-dict-onebuf builds new pages and page trees in its buffer`. ## Open questions diff --git a/docs/Documentation/Fixes-PDFLib.md b/docs/Documentation/Fixes-PDFLib.md index 72892b89..fab3e3dd 100644 --- a/docs/Documentation/Fixes-PDFLib.md +++ b/docs/Documentation/Fixes-PDFLib.md @@ -66,7 +66,7 @@ The four-byte case covers all PDFs under 4 GB; the fallback handles larger value **Problem.** Each `PDFDict` instance held its key-value pairs in a `Map`. Maps have ~200 bytes of per-instance overhead when empty and ~50 bytes per entry. On the book, ~260 000 `PDFDict` instances are created during `PDFDocument.load`. As the document grows during parse, the Maps repeatedly doubled their internal hash-table storage and discarded each previous arena to GC. -**Fix.** A single append-only Array (`main`) shared across all `PDFDict` instances for the document's lifetime. Each `PDFDict` holds one encoded integer (`d`) that packs a `start` index (23 bits) and entry-pair `length` count (16 bits) into a single JavaScript number. `main[start..start+length]` holds alternating key and value references. Mutations that add a new entry either extend the dict's range in-place when it is at the array's high-water mark, or copy the range to the tail first (copy-on-write). `PDFCatalog`, `PDFPageTree`, and `PDFPageLeaf` share the same backing array; `PDFPageLeaf`'s `normalized` and `autoNormalizeCTM` booleans are encoded in two spare bits of `d` (bits 23 and 24). `PDFObjectParser.parseDict` uses a per-parser temp array as a recursion-frame stack, committing each completed frame to `main` as a single contiguous append. +**Fix.** A single append-only Array (`main`) shared across all `PDFDict` instances for the document's lifetime. Each `PDFDict` holds one encoded integer (`d`) that packs a `start` index (23 bits) and entry-pair `length` count (16 bits) into a single JavaScript number. `main[start..start+length]` holds alternating key and value references. Mutations that add a new entry either extend the dict's range in-place when it is at the array's high-water mark, or copy the range to the tail first (copy-on-write). `PDFCatalog`, `PDFPageTree`, and `PDFPageLeaf` share the same backing array, and pdf-lib's eight factories for the four classes all build in `main`, since the replaced methods cannot read a dictionary built on a `Map`: `fromMapWithContext` on each, `PDFDict.withContext`, `PDFCatalog.withContextAndPages`, `PDFPageTree.withContext` and `PDFPageLeaf.withContextAndParent`. `PDFPageLeaf`'s `normalized` and `autoNormalizeCTM` booleans are encoded in two spare bits of `d` (bits 23 and 24). `PDFObjectParser.parseDict` uses a per-parser temp array as a recursion-frame stack, committing each completed frame to `main` as a single contiguous append. The `measure-pass.mjs` pre-pass counts total `dictSlots` in the raw PDF byte stream. Calling `setExpectedDictSlots(n)` before `PDFDocument.load` resizes `main` in-place to the exact required size via `main.length = n`, eliminating V8 growth reallocations during parse. An in-place resize is used rather than replacing the module-level binding; replacing it would invalidate V8's inline-cache slots in every closure that reads `main`, causing a parse-time deoptimisation spike. From 981dd94017f33492dce3964fc993268c85376125 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Mon, 28 Sep 2026 20:04:43 +0200 Subject: [PATCH 03/21] docs: Fixes.md stops counting the pdf-lib shims --- builder/PLAN-TOOLING-REVIEW.md | 16 ++++++++++++++++ docs/Documentation/Fixes.md | 4 ++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/builder/PLAN-TOOLING-REVIEW.md b/builder/PLAN-TOOLING-REVIEW.md index 81779d73..64a31b93 100644 --- a/builder/PLAN-TOOLING-REVIEW.md +++ b/builder/PLAN-TOOLING-REVIEW.md @@ -2587,6 +2587,19 @@ count moved with C65c's edit to Fixes-PDFLib.md), differing only in `/CreationDa `/ModDate`. `compare_trees`: Fixes/PDFLib.html online and offline, the search data, and `book.html`. +### C65e — `docs: Fixes.md stops counting the pdf-lib shims` + +**Found while building C66** (see Found while implementing). Fixes.md says twice that there +are thirteen `fast-*.mjs` shims; C65b left twelve and did not edit that page. + +**Change.** Both sentences name the shims without a count, which nothing keeps true. + +**Verify.** `compare_trees` shows the page, the search data and `book.html`, and nothing else. + +**Landed.** As the entry says; a search of the tree outside `perf/notes/` for a count of +thirteen shims finds only this plan's own entries. `compare_trees`: Fixes.html online and +offline, the search data, and `book.html`. + *The book's pdf-lib shims (decision (c)): C66–C69.* ### C66 — `book: check_pdf_shims_equiv.mjs, the shims against stock pdf-lib` @@ -3324,6 +3337,9 @@ Defects the review did not have, found by building something this plan asks for. Two of pdf-lib's dictionary factories were left unreplaced, and the replaced methods cannot read what they build. Scheduled as C65d, at the owner's choice. Fixed in `book: fast-dict-onebuf builds new pages and page trees in its buffer`. +- **Fixes.md still counted thirteen pdf-lib shims**, found while building C66: C65b deleted one + and missed that page. Scheduled as C65e, at the owner's choice. Fixed in `docs: Fixes.md + stops counting the pdf-lib shims`. ## Open questions diff --git a/docs/Documentation/Fixes.md b/docs/Documentation/Fixes.md index 47839028..b175b7ae 100644 --- a/docs/Documentation/Fixes.md +++ b/docs/Documentation/Fixes.md @@ -10,11 +10,11 @@ permalink: /Documentation/Development/Fixes # Library Patches {: .no_toc } -Two third-party libraries are modified in the tree itself. `book/lib/paged.browser.js` is a patched copy of paged.js v0.4.3 (MIT); the thirteen `fast-*.mjs` files there are side-effecting shims applied to pdf-lib's live exports before each PDF process phase. This section documents every change to those two: what the upstream behaviour was, why it was unsuitable for the build pipeline, and what was changed. +Two third-party libraries are modified in the tree itself. `book/lib/paged.browser.js` is a patched copy of paged.js v0.4.3 (MIT); the `fast-*.mjs` files there are side-effecting shims applied to pdf-lib's live exports before each PDF process phase. This section documents every change to those two: what the upstream behaviour was, why it was unsuitable for the build pipeline, and what was changed. A third library is modified, but nowhere on disk. The accessibility scan rewrites the `axe-core` bundle as it injects it --- `SOURCE_PATCHES` in `scripts/lib/axe-scan.mjs` replaces `Color2`'s six WeakMap-emulated `#private` fields with plain own properties, worth about a quarter of the scan's running time. Nothing under `node_modules/` is touched, so the substitution has to be re-proved against each axe-core upgrade rather than surviving one. Two gates do that, and both are needed: [`check_axe_patch_equiv.mjs`](Tools#check-axe-patch-equiv) compares the colour values the patched and stock bundles produce, and [`check_a11y_fingerprint.mjs`](Tools#check-a11y-fingerprint) compares the findings across the whole scan matrix. ## Sub-pages - [Paged.js Patches](Fixes/PagedJS) --- changes to `book/lib/paged.browser.js`: the synchronous execution chain, hook dispatch fast-paths, DOM lookup optimizations, layout correctness fixes, and miscellaneous headless-specific changes. -- [pdf-lib Patches](Fixes/PDFLib) --- the thirteen `fast-*.mjs` shims and `parallel-deflate.mjs` that retune pdf-lib's parser, object model, and serializer for the process phase. +- [pdf-lib Patches](Fixes/PDFLib) --- the `fast-*.mjs` shims and `parallel-deflate.mjs` that retune pdf-lib's parser, object model, and serializer for the process phase. From 576e4ccf4d3805a6a4c1d26298764c5988cea44a Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Mon, 28 Sep 2026 20:11:35 +0200 Subject: [PATCH 04/21] book: check_pdf_shims_equiv.mjs, the shims against stock pdf-lib --- .github/actions/run-gates/action.yml | 8 + WIP.md | 5 +- builder/PLAN-TOOLING-REVIEW.md | 50 ++++ docs/Documentation/Building.md | 1 + docs/Documentation/Fixes-PDFLib.md | 2 + docs/Documentation/Tools.md | 19 +- scripts/check_cli.mjs | 2 + scripts/check_pdf_shims_equiv.mjs | 432 +++++++++++++++++++++++++++ scripts/lib/pdf-shims-side.mjs | 110 +++++++ test.bat | 10 + 10 files changed, 634 insertions(+), 5 deletions(-) create mode 100644 scripts/check_pdf_shims_equiv.mjs create mode 100644 scripts/lib/pdf-shims-side.mjs diff --git a/.github/actions/run-gates/action.yml b/.github/actions/run-gates/action.yml index d64069df..d0899f3e 100644 --- a/.github/actions/run-gates/action.yml +++ b/.github/actions/run-gates/action.yml @@ -160,6 +160,14 @@ runs: - name: Verify the command-line parser and the tools' cases (check_cli.mjs) shell: bash run: node scripts/check_cli.mjs + # The book's pdf-lib shims replace pdf-lib's parser, object classes and + # writer, and nothing else compares what they write with what pdf-lib + # writes. This saves one document with each, in child processes, and + # compares the two object by object with streams inflated; a shim that + # never runs fails it too. No browser, no built tree. + - name: Verify the book's pdf-lib shims against stock (check_pdf_shims_equiv.mjs) + shell: bash + run: node scripts/check_pdf_shims_equiv.mjs # Graphviz sizes each node box from a width table; the browser paints the # label with a real font. Nothing in the build compares the two, so a # mismatch ships as text hanging outside its box on a green build -- which diff --git a/WIP.md b/WIP.md index 9407f3ee..49ec8e8b 100644 --- a/WIP.md +++ b/WIP.md @@ -451,7 +451,7 @@ Why the report separates the wedged task from the merely blocked ones, and why - `build.bat` — runs `node builder\tbdocs.mjs --src docs --check-audit-index` (which implies `--check`) and produces three trees in one pass: the online copy at `_site/`, a `file://`-browsable copy at `_site-offline/`, and the sparse pagedjs source at `_site-pdf/`. The offline pass adds ~700 ms and the PDF pass adds ~150 ms on top of the ~2 s online build. Toggle `also_build_offline` / `also_build_pdf` in `_config.yml` (or pass `--no-offline` / `--no-pdf`) to skip a sibling output. `--check` adds ~1.7 s and runs the link + integrity check over the HTML while it is still in worker memory; `build.bat --no-check` gets a plain build. - `serve.bat` — runs `tbdocs --serve`: initial build, then a long-lived process with watcher, debounced rebuilds, and SSE-driven browser auto-reload. Writes to `docs/_serve/` (disjoint from `build.bat`'s `_site*/`) and skips the offline + PDF passes — so a one-off `build.bat` for the PDF or offline mirror doesn't disturb the live preview. Ctrl+C to stop. - `check.bat` — the gates that read the built site: a freshness check that refuses a stale tree (`scripts/check_tree_fresh.mjs`), the DOT diagram fit check (`scripts/check_dot_fit.mjs`), the a11y sample-coverage check (`scripts/pick_a11y_sample.mjs --check`), then the accessibility check (`scripts/check_a11y.mjs`). The link + integrity check moved into `build.bat`. ~37 s. -- `test.bat` — the tests the *toolchain* has to pass: the publish-allowlist self-test (`scripts/check_publish_policy.mjs`), the gate-list check (`scripts/check_gate_lists.mjs`), the CI-workflow roster check (`scripts/check_ci_workflows.mjs`), the lint gate (`scripts/check_lint.mjs`), the site-search unit tests (`node --test test/search.test.mjs`), the regex-safety gate (`scripts/check_regex_safety.mjs`), the code-region gate (`scripts/check_code_regions.mjs`), the page-count drift-guard probes (`scripts/check_page_baseline.mjs`), the book-coverage probes (`scripts/check_book_coverage.mjs`), the symbol-index probes (`scripts/check_symbol_index.mjs`), the twinBASIC-scanner probes (`scripts/check_twin_parsers.mjs`), the command-line probes and cases (`scripts/check_cli.mjs`), and the axe source-patch verification (`scripts/check_axe_patch_equiv.mjs`). ~9 s. See [What belongs in test.bat rather than check.bat](WIP.Build.md#what-belongs-in-testbat-rather-than-checkbat). +- `test.bat` — the tests the *toolchain* has to pass: the publish-allowlist self-test (`scripts/check_publish_policy.mjs`), the gate-list check (`scripts/check_gate_lists.mjs`), the CI-workflow roster check (`scripts/check_ci_workflows.mjs`), the lint gate (`scripts/check_lint.mjs`), the site-search unit tests (`node --test test/search.test.mjs`), the regex-safety gate (`scripts/check_regex_safety.mjs`), the code-region gate (`scripts/check_code_regions.mjs`), the page-count drift-guard probes (`scripts/check_page_baseline.mjs`), the book-coverage probes (`scripts/check_book_coverage.mjs`), the symbol-index probes (`scripts/check_symbol_index.mjs`), the twinBASIC-scanner probes (`scripts/check_twin_parsers.mjs`), the command-line probes and cases (`scripts/check_cli.mjs`), the pdf-lib shim comparison (`scripts/check_pdf_shims_equiv.mjs`), and the axe source-patch verification (`scripts/check_axe_patch_equiv.mjs`). ~9 s. See [What belongs in test.bat rather than check.bat](WIP.Build.md#what-belongs-in-testbat-rather-than-checkbat). - `book.bat` — renders the PDF from `docs\_site-pdf\book.html` via `node book\render-book.mjs` into `docs\_pdf\twinBASIC Book.pdf`. Run `build.bat` first to populate `_site-pdf/`; `book.bat` refuses a tree older than its sources rather than rendering the previous book (see [The book refuses a stale source tree](WIP.Build.md#the-book-refuses-a-stale-source-tree)). - `examples.bat` — compiles the documentation's own twinBASIC code samples, every `tb` fence marked `check_build`, and reports the ones the compiler refuses against the line in the page they came from. Needs a twinBASIC install and Windows, so it is outside every gate and outside CI; ~120 s over the 1,129 samples marked as of 2026-09-25. Two modes need no compiler at all: `--census` classifies every fence and says how many classifiable ones are still unmarked, and `--report ` groups a saved `--propose --json` survey by diagnostic, section and unresolved name. `--propose` itself does compile. See [Compiling the reference's own code samples](#compiling-the-references-own-code-samples) and [WIP.ExamplesBuild.md](WIP.ExamplesBuild.md). @@ -469,7 +469,7 @@ build.bat && check.bat On the dev box that is ~4 s of build against ~37 s of check, of which the axe scan is ~20 s. [builder/PLAN-checks.md](builder/PLAN-checks.md) records how the link checker got folded into the build's task graph, what it cost and what it saved; the axe follow-ons are designed there but not implemented. -**If the change touched `builder/`, `scripts/`, `lib/`, `book/`, `eval/`, `wisdom/`, `test/`, the site's scripts in `docs/assets/js/`, a wrapper or a workflow, run `test.bat` as well** --- another ~9 s. Ten of its thirteen gates cannot be affected by an edit under `docs/` at all. **Three can.** `check_lint.mjs` lints the site's two scripts in `docs/assets/js/` along with the tooling. `check_gate_lists.mjs` is the easy one to predict: it reads `README.md` and every page under `docs/Documentation/`, so an edit to any developer page that states a gate count can fail it. **`check_code_regions.mjs` is the one worth understanding**, and which half of it a content edit reaches is worth keeping straight. Its corpus sweep reads `DOCS_DIR`, which is `/docs`, and tokenises all 906 markdown files, so a page that provokes a rewrite into *altering* a code region fails it --- that half is content-dependent. Its fixed probes are not: they run against their own sources whatever the tree holds, and they cover the **mirror** fault, where a rewrite silently stops firing. The sweep structurally cannot see that one, because text the rewrite skipped is stashed and restored unchanged and every region still matches. So run `test.bat` after adding an unusual code construct --- a fence whose contents include a fence marker, a 4-space indented block, an admonition wrapping a fence --- and read the built page as well, because for the mirror fault the gate is asserting that the stasher still works rather than checking your page: +**If the change touched `builder/`, `scripts/`, `lib/`, `book/`, `eval/`, `wisdom/`, `test/`, the site's scripts in `docs/assets/js/`, a wrapper or a workflow, run `test.bat` as well** --- another ~9 s. Eleven of its fourteen gates cannot be affected by an edit under `docs/` at all. **Three can.** `check_lint.mjs` lints the site's two scripts in `docs/assets/js/` along with the tooling. `check_gate_lists.mjs` is the easy one to predict: it reads `README.md` and every page under `docs/Documentation/`, so an edit to any developer page that states a gate count can fail it. **`check_code_regions.mjs` is the one worth understanding**, and which half of it a content edit reaches is worth keeping straight. Its corpus sweep reads `DOCS_DIR`, which is `/docs`, and tokenises all 906 markdown files, so a page that provokes a rewrite into *altering* a code region fails it --- that half is content-dependent. Its fixed probes are not: they run against their own sources whatever the tree holds, and they cover the **mirror** fault, where a rewrite silently stops firing. The sweep structurally cannot see that one, because text the rewrite skipped is stashed and restored unchanged and every region still matches. So run `test.bat` after adding an unusual code construct --- a fence whose contents include a fence marker, a 4-space indented block, an admonition wrapping a fence --- and read the built page as well, because for the mirror fault the gate is asserting that the stasher still works rather than checking your page: ```sh build.bat && check.bat && test.bat @@ -508,6 +508,7 @@ wrapper: | `test.bat` | `check_ci_workflows` | both CI workflows run every wrapper gate, with the same arguments and order, and build with `build.bat`'s flags | | `test.bat` | `check_lint` | Biome finds nothing in the tooling, warnings included, and checked at least one script | | `test.bat` | `test/search.test.mjs` | the search entries `builder/search.mjs` writes hold what they should, and the copies of the search client still agree. Run by `node --test`; the gate roster reads such a line as a gate, named by its path | +| `test.bat` | `check_pdf_shims_equiv` | the book's pdf-lib shims write what stock pdf-lib writes: one document written by the gate is saved both ways in child processes, the files are compared object by object with streams inflated and their cross-reference entries checked, and every shim must run | | `test.bat` | `check_publish_policy`, `check_gate_lists`, `check_page_baseline`, `check_book_coverage`, `check_axe_patch_equiv` | the gates on the gates | **A gate belongs in `test.bat` rather than `check.bat` if it would still mean diff --git a/builder/PLAN-TOOLING-REVIEW.md b/builder/PLAN-TOOLING-REVIEW.md index 64a31b93..1e7672f3 100644 --- a/builder/PLAN-TOOLING-REVIEW.md +++ b/builder/PLAN-TOOLING-REVIEW.md @@ -2618,6 +2618,48 @@ Registered in the composite action, Tools.md and WIP.md. **Verify.** Passes; a deliberately broken shim fails it, and the report names the shim. CI waits for the owner's push. +**Landed.** `scripts/check_pdf_shims_equiv.mjs` and `scripts/lib/pdf-shims-side.mjs`, one side +per process; the gate itself never imports pdf-lib (see Where the plan was wrong). The shims +are every module `render-book.mjs` imports from `book/lib/`, in its order, less the four the +side calls as `render-book.mjs` does (`measure-pass`, `postprocesser`, `outline`, +`parallel-deflate`), so a new shim is checked without an edit. The document is written by the +gate: a classic section with a generation-1 object, then an incremental update whose object +stream redefines a page and holds a dictionary and an array of every lexical form the parse +shims branch on, with a cross-reference stream. The side sizes the onebuf shims from +`measure()`, loads, calls `setMetadata` (then pins `/ModDate`, which it stamps with the time) +and `setOutline` with a closed entry, draws text on a page, inserts and removes a page, edits +two early dictionaries and an array, and saves: the shimmed side through `parallelSave` with +500 objects to a stream, the stock side with `save()`'s own steps and +`PDFStreamWriter.forContext(ctx, Infinity, true, 500)`. + +The comparison reads each file as pdf-lib writes it: every object by number, where it is +(top level, generation, or object stream and entry) and its bytes, streams inflated, each +`/Length` and the cross-reference stream's `/W` masked. Each file's cross-reference entries +and `startxref` must locate their objects; a problem in stock's output is the harness failing +(exit 2), in the shimmed output a finding. The reach check is V8 precise coverage in the +shimmed side, taken once after the imports to reset the counts: a shim with no function run +fails the gate, and `parallel-deflate.mjs` fails if `parallelSave` deflated no object stream. +On a difference the shimmed side reruns with each shim alone and each left out, four at a +time, and the report names the shims that differ alone and those whose removal makes the +output match. `--help` and an unknown option are `check_cli`'s cases 255 and 256. + +Building it found three defects, fixed first at the owner's choice: C65c (the gate named +`fast-parse-number.mjs` both ways), C65d (the shimmed side failed at `insertPage`) and C65e. +Now: `stock pdf-lib and 12 shims with parallelSave write the same 22 objects; every shim ran`, +0.49-0.54 s. Faults through the kit's `c43-fault.mjs` in `NODE_OPTIONS`, so the sides load it: +`fast-dict-onebuf.mjs`'s `sizeInBytes` one byte long gives an output the reader cannot place +(`byte 2054 is neither an object nor the trailer`), named both ways; `fast-number-to-string.mjs` +writing `0.50` for `0.5`, which parses to the same values, differs in six objects, the drawn +content stream among them, named both ways; `fast-pdfnumber-pool.mjs` never installed, and +`parallelSave`'s thread-pool branch switched off, are each named by the reach check; a stock +side that throws exits 2. No temporary folder is left behind. Registered in `test.bat` before +`check_axe_patch_equiv`, the composite action, Tools.md (the list, its count, the POSIX block, +the counts after it and a section), Building.md's POSIX block and WIP.md (the table, the +`test.bat` bullet, the count); Fixes-PDFLib.md points to it. `check_gate_lists`: `test.bat +(14)`; `check_ci_workflows`: `the wrappers' 17 gates`; lint `Checked 168 files`; regex safety +`519 literals + 28 constructed in 129 files -- 479 safe, 68 polynomial`. CI waits for the +owner's push. + ### C67 — `book: one module for pdf-lib's internal requires` **A9-5 (R3).** The `createRequire` and `require('pdf-lib/cjs/...').default` block is repeated @@ -3098,6 +3140,14 @@ text, gains a Landed note, and the correction is listed here, as in the last rev only. Folding either way changes one of them, so both stay, and C58 landed as `builder: fold five small duplicates`. See C58's Landed note. +- **C66 (A9-2): both sides run in child processes, and the comparison reads bytes.** The + entry runs stock pdf-lib in a child and the shims in the gate's own process. Under the onebuf + shims a process may hold one `PDFContext`, and the diagnosis needs a fresh shimmed process + per combination, so both sides are children and the gate never imports pdf-lib. The two + files are compared as written, with streams inflated, rather than as pdf-lib parses them: + its parser finds objects without the cross-reference offsets and reads `0.50` as `0.5`, so a wrong + `sizeInBytes` or a `0.50` would pass a comparison of parsed objects. See C66's Landed note. + ## Found while implementing Defects the review did not have, found by building something this plan asks for. diff --git a/docs/Documentation/Building.md b/docs/Documentation/Building.md index 04b4b09f..34f74ad5 100644 --- a/docs/Documentation/Building.md +++ b/docs/Documentation/Building.md @@ -80,6 +80,7 @@ Each `.bat` opens with `@pushd "%~dp0"`, which is what lets it be invoked from a && node scripts/check_symbol_index.mjs \ && node scripts/check_twin_parsers.mjs \ && node scripts/check_cli.mjs \ + && node scripts/check_pdf_shims_equiv.mjs \ && node scripts/check_axe_patch_equiv.mjs `book.bat` has one step that is invisible from the command it ends with. `render-book.mjs` writes the PDF with a plain file write and never creates the directory above it, so `docs/_pdf/` has to exist first --- otherwise the render fails with `ENOENT` at the very last moment, after the whole page-breaking pass has already run. The deploy workflow does the same `mkdir` before its render, for the same reason: diff --git a/docs/Documentation/Fixes-PDFLib.md b/docs/Documentation/Fixes-PDFLib.md index fab3e3dd..b01a5412 100644 --- a/docs/Documentation/Fixes-PDFLib.md +++ b/docs/Documentation/Fixes-PDFLib.md @@ -13,6 +13,8 @@ The files under `book/lib/fast-*.mjs` and `book/lib/parallel-deflate.mjs` are si The root cause of the need for all these patches is the same: pdf-lib is designed for general-purpose use in both browsers and Node, and optimises for generality rather than throughput on a single large document. +Each patch must leave the output unchanged. [`check_pdf_shims_equiv.mjs`](../Tools#check-pdf-shims-equiv), one of `test.bat`'s gates, saves one document with stock pdf-lib and with every patch `render-book.mjs` imports, compares the two files object by object, and fails if any patch never runs. + * TOC goes here {:toc} diff --git a/docs/Documentation/Tools.md b/docs/Documentation/Tools.md index 908d6bce..3ac572ef 100644 --- a/docs/Documentation/Tools.md +++ b/docs/Documentation/Tools.md @@ -65,7 +65,7 @@ One of the four does not mean the same thing locally as it does in CI, on any pl test.bat -The tests the toolchain has to pass. Thirteen steps, each stopping the run if it fails: +The tests the toolchain has to pass. Fourteen steps, each stopping the run if it fails: 1. [`scripts/check_publish_policy.mjs`](#check-publish-policy) --- verifies the publish allowlist still refuses the types it is meant to. Needs neither a browser nor a built tree, so it goes first. 2. [`scripts/check_gate_lists.mjs`](#check-gate-lists) --- verifies the two gate lists on this page still match the wrappers that run them. @@ -79,7 +79,8 @@ The tests the toolchain has to pass. Thirteen steps, each stopping the run if it 10. [`scripts/check_symbol_index.mjs`](#check-symbol-index) --- verifies the symbol index still places each kind of symbol, and its drift guard still refuses a lost URL. 11. [`scripts/check_twin_parsers.mjs`](#check-twin-parsers) --- verifies the scanners of twinBASIC source and of the attribute reference still read the shapes each once misread. 12. [`scripts/check_cli.mjs`](#check-cli) --- verifies `lib/cli.mjs`, the command-line parser, and each tool's recorded command-line errors. -13. [`scripts/check_axe_patch_equiv.mjs`](#check-axe-patch-equiv) --- verifies the vendored axe source patch still produces identical colour values. +13. [`scripts/check_pdf_shims_equiv.mjs`](#check-pdf-shims-equiv) --- verifies the book's pdf-lib shims write what stock pdf-lib writes, and that each of them runs. +14. [`scripts/check_axe_patch_equiv.mjs`](#check-axe-patch-equiv) --- verifies the vendored axe source patch still produces identical colour values. POSIX: @@ -95,9 +96,10 @@ POSIX: && node scripts/check_symbol_index.mjs \ && node scripts/check_twin_parsers.mjs \ && node scripts/check_cli.mjs \ + && node scripts/check_pdf_shims_equiv.mjs \ && node scripts/check_axe_patch_equiv.mjs -**Ten of the thirteen cannot be affected by an edit confined to `docs/`**, which is why they are separate from `check.bat`. Run this one when the change touches `builder/`, `scripts/`, `lib/`, `book/`, `eval/`, `wisdom/` or `test/`, the site's scripts in `docs/assets/js/`, a wrapper, or a workflow. Both CI workflows run all thirteen unconditionally, so skipping it locally cannot let a tooling regression reach `staging`. +**Eleven of the fourteen cannot be affected by an edit confined to `docs/`**, which is why they are separate from `check.bat`. Run this one when the change touches `builder/`, `scripts/`, `lib/`, `book/`, `eval/`, `wisdom/` or `test/`, the site's scripts in `docs/assets/js/`, a wrapper, or a workflow. Both CI workflows run all fourteen unconditionally, so skipping it locally cannot let a tooling regression reach `staging`. The three exceptions are [`check_code_regions.mjs`](#check-code-regions), [`check_gate_lists.mjs`](#check-gate-lists), which reads this page, and [`check_lint.mjs`](#check-lint), which lints the site's scripts in `docs/assets/js/`. The first is worth knowing in detail. Its corpus sweep tokenises every markdown file under `docs/`, so a page that provokes a rewrite into altering a code region fails it. Its fixed probes are a different matter: they run against their own sources whatever the tree holds, and they cover the *mirror* fault, where a rewrite silently stops firing. The sweep cannot see that one --- text the rewrite skipped is stashed and restored unchanged, so every region still matches. Add a page with an unusual code construct and run `test.bat`, but read the built page too. @@ -556,6 +558,17 @@ The recorded cases are invocations that stop while the tool reads its command li Exits 1 on any failed probe or case, 2 if it cannot run. +### check_pdf_shims_equiv.mjs +{: #check-pdf-shims-equiv } + + node scripts/check_pdf_shims_equiv.mjs + +Verifies that the book's [pdf-lib patches](Fixes/PDFLib) write what pdf-lib itself writes. `book/render-book.mjs` loads Chromium's PDF, adds the metadata and the outline, and saves it, with a dozen shims replacing pdf-lib's parser, object classes and writer, and `parallelSave` in place of `save()`. This loads, changes and saves one document twice, with stock pdf-lib and with every shim `render-book.mjs` imports, each side in a process of its own, and compares the two files object by object with every stream inflated, since `node:zlib` and pdf-lib's own deflate can compress the same bytes differently. It also checks each file's cross-reference entries against the objects they locate, since pdf-lib's own parser finds objects without them. No built tree, no browser; under a second. + +The document is written by the gate, without pdf-lib, so the forms the shims' parsers branch on are known to be in it: names with `#` escapes, numbers in every lexical form, a classic cross-reference table, and an incremental update with an object stream and a cross-reference stream. The change mirrors `render-book.mjs`'s and adds what reaches the rest of the shims: text drawn on a page, a page inserted and one removed, and objects parsed early and edited late. A shim none of whose functions runs fails the gate too, since the document then no longer tests it, or the book does not need it. On a difference, the shimmed side runs again with each shim alone and with each left out, and the report names the shims that make it. + +Exits 1 on a difference or a shim that did not run, 2 if it cannot run. + ### check_axe_patch_equiv.mjs {: #check-axe-patch-equiv } diff --git a/scripts/check_cli.mjs b/scripts/check_cli.mjs index ffec5871..da8875ff 100644 --- a/scripts/check_cli.mjs +++ b/scripts/check_cli.mjs @@ -321,6 +321,8 @@ const CASES = [ { tool: "scripts/check_axe_patch_equiv.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/check_axe_patch_equiv\.mjs / }, { tool: "scripts/check_axe_patch_equiv.mjs", args: ["--bogus"], exit: 2, stderr: "unknown arg: --bogus\n" }, { tool: "scripts/check_axe_patch_equiv.mjs", args: ["--patch"], exit: 2, stderr: "unknown arg: --patch\n" }, + { tool: "scripts/check_pdf_shims_equiv.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/check_pdf_shims_equiv\.mjs\n/ }, + { tool: "scripts/check_pdf_shims_equiv.mjs", args: ["--bogus"], exit: 2, stderr: "unknown option: --bogus\n" }, { tool: "scripts/check_tree_fresh.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/check_tree_fresh\.mjs / }, { tool: "scripts/check_tree_fresh.mjs", args: ["--bogus"], exit: 2, stderr: "unknown arg: --bogus\n" }, { tool: "scripts/check_tree_fresh.mjs", args: ["--source"], exit: 2, stderr: "unknown arg: --source\n" }, diff --git a/scripts/check_pdf_shims_equiv.mjs b/scripts/check_pdf_shims_equiv.mjs new file mode 100644 index 00000000..023e60e8 --- /dev/null +++ b/scripts/check_pdf_shims_equiv.mjs @@ -0,0 +1,432 @@ +// Checks the book's pdf-lib shims against stock pdf-lib. +// +// book/render-book.mjs loads Chromium's PDF with pdf-lib, adds the metadata +// and the outline, and saves it, with a dozen shims under pdf-lib replacing its +// parser, its object classes and its writer for speed, and parallelSave in +// place of save(). Nothing else compares what they write with what pdf-lib +// itself writes. This does: one document is loaded, changed and saved by stock +// pdf-lib and by pdf-lib with every shim render-book.mjs imports, each in a +// process of its own, and the two files are compared object by object with +// every stream inflated, since node:zlib and pdf-lib's deflate may compress the +// same bytes differently. Each file's cross-reference entries are checked +// against the objects they locate as well, because pdf-lib's own parser finds +// objects without them, so a wrong size computed for an object is invisible to +// it. +// +// The document is written here, without pdf-lib, so the forms the shims' +// parsers branch on are known to be in it: names with #xx escapes, numbers in +// every lexical form, a classic cross-reference table followed by an +// incremental update with an object stream and a cross-reference stream. The +// change (scripts/lib/pdf-shims-side.mjs) mirrors render-book.mjs and adds what +// reaches the rest of the shims: text drawn on a page, a page inserted and one +// removed, and dictionaries parsed early and edited late. +// +// A shim none of whose functions runs is reported too: it means the document +// no longer tests it, or that the book never needed it. +// +// On a difference, the shimmed side is run again with each shim alone and with +// each left out (parallelSave counts as one), to name the shims that make it. +// +// node scripts/check_pdf_shims_equiv.mjs +// +// Exit codes: 0 the same, 1 a difference or a shim not reached, 2 the check +// itself failed. + +import { spawn } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { availableParallelism, tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { deflateSync, inflateSync } from "node:zlib"; +import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { exitOnCrash } from "./lib/gate-probes.mjs"; + +exitOnCrash(); + +const cli = withUsageError(() => + parseCli(process.argv.slice(2), { options: { help: { type: "boolean", short: "h" } }, stopAt: ["help"] }) +); +if (cli.stopped === "help") { + printHelpAndExit(`usage: node scripts/check_pdf_shims_equiv.mjs + +Loads, changes and saves one PDF with stock pdf-lib and with the book's pdf-lib +shims, and compares the two files object by object, streams inflated. Exit 0 +the same, 1 a difference or a shim the document no longer reaches, 2 the check +itself failed.`); +} + +const TOOL = "check_pdf_shims_equiv"; +const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); +const RENDER_BOOK = path.join(ROOT, "book", "render-book.mjs"); +const SIDE = path.join(ROOT, "scripts", "lib", "pdf-shims-side.mjs"); +const SIDE_TIMEOUT_MS = 120_000; + +// render-book.mjs's imports from book/lib that are not shims: the side calls +// them as render-book.mjs does. +const HELPERS = new Set(["measure-pass.mjs", "postprocesser.mjs", "outline.mjs", "parallel-deflate.mjs"]); + +// The shims are every other module render-book.mjs imports from book/lib, in +// its order, so a shim added there is checked here without an edit. +function shimsOf(source) { + const names = [...source.matchAll(/(?:^import|\bfrom)\s+['"]\.\/lib\/([\w.-]+\.mjs)['"]/gm)].map((m) => m[1]); + const shims = names.filter((name) => !HELPERS.has(name)); + if (shims.length === 0) throw new Error(`found no shim among ${path.relative(ROOT, RENDER_BOOK)}'s imports`); + return shims.map((name) => path.join(ROOT, "book", "lib", name)); +} + +const shimName = (file) => path.basename(file); + +// --------------------------------------------------------------------------- +// The document + +// Written by hand, not by pdf-lib. A classic section as Chromium writes one, +// then an incremental update whose object stream redefines page 4 and adds the +// dictionary and the array that hold the lexical forms. +function buildFixture() { + const parts = []; + let size = 0; + const offsets = new Map(); + const put = (data) => { + const buf = typeof data === "string" ? Buffer.from(data, "latin1") : data; + parts.push(buf); + size += buf.length; + }; + const obj = (num, gen, ...body) => { + offsets.set(num, size); + put(`${num} ${gen} obj\n`); + for (const part of body) put(part); + put("\nendobj\n"); + }; + const stream = (num, dict, data) => { + const packed = deflateSync(data); + obj(num, 0, `<< ${dict} /Filter /FlateDecode /Length ${packed.length} >>\nstream\n`, packed, "\nendstream"); + }; + const at = (n) => String(n).padStart(10, "0"); + + put("%PDF-1.4\n%\xE2\xE3\xCF\xD3\n"); + obj(1, 0, "<< /Type /Catalog /Pages 2 0 R /Dests 8 0 R /Misc 7 0 R /Extra 9 1 R /PageMode /UseOutlines >>"); + obj(2, 0, "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>"); + obj(3, 0, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 6 0 R >>"); + obj(4, 0, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>"); + obj(5, 0, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); + stream(6, "", Buffer.from("BT /F1 12 Tf 72 720 Td (Fixture) Tj ET\n", "latin1")); + obj(8, 0, "<< /sec-1 [3 0 R /XYZ 0 792 0] /sec-2 [4 0 R /Fit] >>"); + obj(9, 1, "<< /Gen (one) /Self 9 1 R >>"); + obj(10, 0, "<< /Producer (hand-written) /Title (Old title) >>"); + + const xref1 = size; + const entry = (num, gen = 0) => `${at(offsets.get(num))} ${String(gen).padStart(5, "0")} n \n`; + put("xref\n0 7\n0000000000 65535 f \n"); + for (const num of [1, 2, 3, 4, 5, 6]) put(entry(num)); + put(`8 3\n${entry(8)}${entry(9, 1)}${entry(10)}`); + put(`trailer\n<< /Size 11 /Root 1 0 R /Info 10 0 R >>\nstartxref\n${xref1}\n%%EOF\n`); + + // The incremental update: an object stream holding 7, 4 and 12. + const members = [ + [7, [ + "<< /Type /Misc /Name#20With#23Escapes /A#42C /Repeated /Repeated", + "/Int 42 /Neg -17 /Frac 3.25 /Lead .25 /NegLead -.5 /Plus +7 /Trail 3. /Paper 595.28 /Sum 2.28 /NegSum -40.8933", + "/Long 12345678901234567 /LongFrac 1234567890123456.5 /Huge 10000000000000000000000", + "/Tiny 0.0000001 /ManyDigits 0.12345678901234567890123", + "/True true /False false /Nil null", + "/Lit (a \\(nested\\) string, a \\\\ and \\053 and a newline\\n) /Hex <48656C6C6F> /OddHex /SpacedHex <48 65 6C>", + "/Arr [1 [2 [3 /Repeated]] << /K /V /Deep << /Z null >> >> (s) <00FF> true 3 0 R] /Empty [] /EmptyDict << >>", + "/Ref 9 1 R >>", + ].join("\n")], + [4, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595.28 841.89] /Rotate 90 /Resources << >> >>"], + [12, "[1 -2 3.5 /Name (str) true false null [[]] << /A 1 >> 7 0 R]"], + ]; + let body = ""; + const head = []; + for (const [num, text] of members) { + head.push(`${num} ${body.length}`); + body += `${text}\n`; + } + const headText = `${head.join(" ")} `; + stream(11, `/Type /ObjStm /N ${members.length} /First ${headText.length}`, Buffer.from(headText + body, "latin1")); + + // Its cross-reference stream: 4, 7 and 12 in the object stream, 11 and 13 + // at their offsets. + const xref2 = size; + const rows = [ + [2, 11, 1], // 4 + [2, 11, 0], // 7 + [1, offsets.get(11), 0], // 11 + [2, 11, 2], // 12 + [1, xref2, 0], // 13 + ]; + const table = Buffer.alloc(rows.length * 7); + rows.forEach(([type, field, index], i) => { + table.writeUInt8(type, i * 7); + table.writeUInt32BE(field, i * 7 + 1); + table.writeUInt16BE(index, i * 7 + 5); + }); + const id = "<0123456789ABCDEF0123456789ABCDEF>"; + stream(13, `/Type /XRef /Size 14 /Root 1 0 R /Info 10 0 R /ID [${id} ${id}] /Prev ${xref1} /W [1 4 2] /Index [4 1 7 1 11 3]`, table); + put(`startxref\n${xref2}\n%%EOF\n`); + return Buffer.concat(parts); +} + +// --------------------------------------------------------------------------- +// Reading a file pdf-lib saved + +// Every object in `bytes`, keyed by number: { gen, container, entry, text }, +// where `container` and `entry` place an object inside an object stream (null +// at the top level), and `text` is what is compared: the object's bytes with +// streams inflated and the fields that follow from the compressed sizes (each +// /Length, and the cross-reference stream's /W) masked. An object stream's own +// text is its dictionary and its header of offsets. `problems` lists each +// cross-reference entry that does not locate its object. Throws if the file +// does not have the shape pdf-lib writes. +function readSaved(bytes) { + const s = bytes.toString("latin1"); + const header = /^%PDF-\d\.\d\n%[^\n]*\n\n/.exec(s); + if (!header) throw new Error("it does not start with the header pdf-lib writes"); + const top = new Map(); + const OBJ = /(\d+) (\d+) obj\n/y; + let p = header[0].length; + for (;;) { + OBJ.lastIndex = p; + const m = OBJ.exec(s); + if (!m) break; + const num = Number(m[1]); + const at = { offset: p, gen: Number(m[2]) }; + const body = p + m[0].length; + const endobj = s.indexOf("\nendobj\n", body); + const keyword = s.indexOf("\nstream\n", body); + if (endobj < 0) throw new Error(`object ${num} at byte ${p} has no endobj`); + if (keyword >= 0 && keyword < endobj) { + const dict = s.slice(body, keyword); + const lengths = [...dict.matchAll(/\/Length (\d+)/g)]; + if (lengths.length !== 1) throw new Error(`stream ${num} has ${lengths.length} /Length entries`); + const start = keyword + "\nstream\n".length; + const end = start + Number(lengths[0][1]); + if (!s.startsWith("\nendstream\nendobj\n", end)) throw new Error(`stream ${num}'s /Length does not end at its endstream`); + Object.assign(at, { dict, content: bytes.subarray(start, end) }); + p = end + "\nendstream\nendobj\n".length; + } else { + at.text = s.slice(body, endobj); + p = endobj + "\nendobj\n".length; + } + if (top.has(num)) throw new Error(`object ${num} is written twice`); + top.set(num, at); + if (s[p] === "\n") p++; + } + const tail = /startxref\n(\d+)\n%%EOF$/y; + tail.lastIndex = p; + const startxref = tail.exec(s); + if (!startxref) throw new Error(`byte ${p} is neither an object nor the trailer`); + + const objects = new Map(); + const add = (num, value) => { + if (objects.has(num)) throw new Error(`object ${num} is written twice`); + objects.set(num, value); + }; + let xref = null; + for (const [num, at] of top) { + const place = { gen: at.gen, container: null, entry: null }; + if (at.dict === undefined) { + add(num, { ...place, text: at.text }); + continue; + } + const data = /\/Filter \/FlateDecode\b/.test(at.dict) ? inflateSync(at.content) : at.content; + const dict = at.dict.replace(/\/Length \d+/, "/Length _"); + if (/\/Type \/XRef\b/.test(at.dict)) { + xref = { num, dict: at.dict, data }; + add(num, { ...place, text: dict.replace(/\/W \[[\d ]+\]/, "/W _") }); + } else if (/\/Type \/ObjStm\b/.test(at.dict)) { + const n = Number(/\/N (\d+)/.exec(at.dict)?.[1]); + const first = Number(/\/First (\d+)/.exec(at.dict)?.[1]); + const text = data.toString("latin1"); + const head = text.slice(0, first).trim().split(/\s+/).map(Number); + if (head.length !== 2 * n || head.some(Number.isNaN)) throw new Error(`object stream ${num}'s header does not list ${n} objects`); + for (let k = 0; k < n; k++) { + const end = k + 1 < n ? first + head[2 * k + 3] : text.length; + add(head[2 * k], { gen: 0, container: num, entry: k, text: text.slice(first + head[2 * k + 1], end) }); + } + add(num, { ...place, text: `${dict}\n${text.slice(0, first)}` }); + } else { + add(num, { ...place, text: `${dict}\nstream\n${data.toString("latin1")}` }); + } + } + + const problems = []; + if (!xref) { + problems.push("it has no cross-reference stream"); + } else { + const w = /\/W \[ ?(\d+) (\d+) (\d+) ?\]/.exec(xref.dict)?.slice(1).map(Number); + const size = Number(/\/Size (\d+)/.exec(xref.dict)?.[1]); + const index = /\/Index \[([\d ]+)\]/.exec(xref.dict)?.[1].trim().split(/\s+/).map(Number) ?? [0, size]; + if (!w) throw new Error("its cross-reference stream has no /W"); + let q = 0; + const field = (width, otherwise) => { + if (width === 0) return otherwise; + let v = 0; + for (let i = 0; i < width; i++) v = v * 256 + xref.data[q++]; + return v; + }; + const located = new Set(); + for (let i = 0; i < index.length; i += 2) { + for (let num = index[i]; num < index[i] + index[i + 1]; num++) { + if (q + w[0] + w[1] + w[2] > xref.data.length) throw new Error("its cross-reference stream is shorter than its /Index"); + const type = field(w[0], 1); + const f2 = field(w[1], 0); + const f3 = field(w[2], 0); + const o = objects.get(num); + if (type === 1) { + const at = top.get(num); + if (!at || at.offset !== f2 || at.gen !== f3) { + problems.push(`the cross-reference entry for object ${num} gives byte ${f2}, generation ${f3}, where ${at ? `it starts at byte ${at.offset}, generation ${at.gen}` : "no such object is written at the top level"}`); + } + } else if (type === 2) { + if (!o || o.container !== f2 || o.entry !== f3) { + problems.push(`the cross-reference entry for object ${num} gives object stream ${f2}, entry ${f3}, where it is ${o ? where(o) : "not written"}`); + } + } else { + continue; + } + located.add(num); + } + } + if (q !== xref.data.length) problems.push(`its cross-reference stream has ${xref.data.length - q} bytes after its last entry`); + for (const num of objects.keys()) if (!located.has(num)) problems.push(`object ${num} has no cross-reference entry`); + const xrefAt = top.get(xref.num).offset; + if (Number(startxref[1]) !== xrefAt) problems.push(`startxref gives byte ${startxref[1]}, where the cross-reference stream starts at byte ${xrefAt}`); + } + return { objects, problems }; +} + +function where(o) { + if (o.container !== null) return `object stream ${o.container}, entry ${o.entry}`; + return o.gen === 0 ? "the top level" : `the top level, generation ${o.gen}`; +} + +// Where `shimmed` differs from `stock`, one line or a few per object. +function differences(stock, shimmed) { + const found = []; + const nums = [...new Set([...stock.objects.keys(), ...shimmed.objects.keys()])].sort((a, b) => a - b); + for (const num of nums) { + const a = stock.objects.get(num); + const b = shimmed.objects.get(num); + if (!b) found.push(`object ${num} is missing; stock writes it in ${where(a)}`); + else if (!a) found.push(`object ${num} is written in ${where(b)}, and stock does not write it`); + else if (where(a) !== where(b)) found.push(`object ${num} is written in ${where(b)}; stock writes it in ${where(a)}`); + else if (a.text !== b.text) { + let i = 0; + while (a.text[i] === b.text[i]) i++; + const around = (t) => JSON.stringify(t.slice(Math.max(0, i - 30), i + 30)); + found.push(`object ${num}, in ${where(a)}, from byte ${i} of its text:\n stock: ${around(a.text)}\n shimmed: ${around(b.text)}`); + } + } + return found; +} + +// --------------------------------------------------------------------------- +// Running a side + +// Resolves to { bytes, streamCount, reached } or { error }. +function runSide(dir, name, fixture, { shims, parallel, coverage = false }) { + const out = path.join(dir, `${name}.pdf`); + const job = JSON.stringify({ fixture, out, shims, parallel, coverage }); + return new Promise((resolve) => { + const child = spawn(process.execPath, [SIDE, job], { stdio: ["ignore", "pipe", "pipe"], timeout: SIDE_TIMEOUT_MS }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => { stdout += d; }); + child.stderr.on("data", (d) => { stderr += d; }); + child.on("error", (err) => resolve({ error: err.message })); + child.on("close", (code, signal) => { + if (code !== 0) { + const why = signal ? `was ended by ${signal}` : `exited ${code}`; + const lines = stderr.trim().split(/\r?\n/).filter((l) => !l.startsWith("Parsed number that is too large")); + resolve({ error: `${why}${lines.length ? `: ${lines.slice(0, 6).join("\n ")}` : ""}` }); + return; + } + resolve({ ...JSON.parse(stdout.trim().split(/\r?\n/).at(-1)), bytes: readFileSync(out) }); + }); + }); +} + +// The differences between a side's output and stock's, or the reason there is +// no output to compare. +function against(stock, side) { + if (side.error) return [`the shimmed side failed: it ${side.error}`]; + let read; + try { + read = readSaved(side.bytes); + } catch (err) { + return [`the shimmed output cannot be read: ${err.message}`]; + } + return [...read.problems, ...differences(stock, read)]; +} + +// Runs `jobs` (functions returning promises) a few at a time. +async function pool(jobs, width) { + const results = new Array(jobs.length); + let next = 0; + const lane = async () => { + while (next < jobs.length) { + const i = next++; + results[i] = await jobs[i](); + } + }; + await Promise.all(Array.from({ length: Math.min(width, jobs.length) }, lane)); + return results; +} + +// Each shim alone, and each left out, as { subject, alone, differs }. +async function diagnose(dir, fixture, shims, stock) { + const variants = [ + ...shims.map((s) => ({ subject: shimName(s), alone: true, shims: [s], parallel: false })), + { subject: "parallelSave", alone: true, shims: [], parallel: true }, + ...shims.map((s) => ({ subject: shimName(s), alone: false, shims: shims.filter((x) => x !== s), parallel: true })), + { subject: "parallelSave", alone: false, shims, parallel: false }, + ]; + const sides = await pool( + variants.map((v, i) => () => runSide(dir, `variant-${i}`, fixture, v)), + Math.min(4, availableParallelism()) + ); + return variants.map((v, i) => ({ ...v, differs: against(stock, sides[i]).length > 0 })); +} + +// --------------------------------------------------------------------------- + +const shims = shimsOf(readFileSync(RENDER_BOOK, "utf8")); +const dir = mkdtempSync(path.join(tmpdir(), "pdf-shims-")); +try { + const fixture = path.join(dir, "fixture.pdf"); + writeFileSync(fixture, buildFixture()); + const [stockSide, shimmedSide] = await Promise.all([ + runSide(dir, "stock", fixture, { shims: [], parallel: false }), + runSide(dir, "shimmed", fixture, { shims, parallel: true, coverage: true }), + ]); + if (stockSide.error) throw new Error(`the stock side ${stockSide.error}`); + const stock = readSaved(stockSide.bytes); + if (stock.problems.length) throw new Error(`stock pdf-lib's output fails the reader here:\n ${stock.problems.join("\n ")}`); + + const found = against(stock, shimmedSide); + const unreached = shimmedSide.error ? [] : shims.filter((s) => !shimmedSide.reached.includes(s)).map(shimName); + if (!shimmedSide.error && shimmedSide.streamCount === 0) unreached.push("parallel-deflate.mjs (no object stream was deflated on the thread pool)"); + + if (found.length === 0 && unreached.length === 0) { + console.log(`${TOOL}: stock pdf-lib and ${shims.length} shims with parallelSave write the same ${stock.objects.size} objects; every shim ran`); + } else { + if (found.length) { + console.log(`${TOOL}: the shimmed output differs from stock pdf-lib's in ${found.length} place(s):`); + for (const line of found.slice(0, 5)) console.log(` ${line.replaceAll("\n", "\n ")}`); + if (found.length > 5) console.log(` ... and ${found.length - 5} more`); + const verdicts = await diagnose(dir, fixture, shims, stock); + const list = (xs) => (xs.length ? xs.map((v) => v.subject).join(", ") : "none"); + console.log(` differs with only this one: ${list(verdicts.filter((v) => v.alone && v.differs))}`); + console.log(` matches with only this left out: ${list(verdicts.filter((v) => !v.alone && !v.differs))}`); + } + if (unreached.length) { + console.log(`${TOOL}: ${unreached.length} shim(s) did nothing while the document was loaded, changed and saved:`); + for (const name of unreached) console.log(` book/lib/${name}`); + console.log(" The document no longer reaches the shim, or the book does not need it."); + } + process.exitCode = 1; + } +} finally { + rmSync(dir, { recursive: true, force: true }); +} diff --git a/scripts/lib/pdf-shims-side.mjs b/scripts/lib/pdf-shims-side.mjs new file mode 100644 index 00000000..9d62ff01 --- /dev/null +++ b/scripts/lib/pdf-shims-side.mjs @@ -0,0 +1,110 @@ +// One side of check_pdf_shims_equiv.mjs: loads the fixture, changes it as +// book/render-book.mjs changes the book, and saves it, with the shims it is +// given and no others. Each side is a process of its own, because the onebuf +// shims allow one PDFContext per process and the stock side must load no shim. +// +// node scripts/lib/pdf-shims-side.mjs +// +// The job is { fixture, out, shims, parallel, coverage }: `shims` are absolute +// paths, imported in the order given; `parallel` saves through parallelSave, as +// the book does, and otherwise as stock pdf-lib's save would with the book's 500 +// objects per stream; `coverage` records which of the shims ran a function +// during the load, change and save, their imports left out. The side writes the +// saved PDF to `out` and prints one JSON line, { streamCount, reached }. + +import { readFileSync, writeFileSync } from "node:fs"; +import { Session } from "node:inspector/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const BOOK_LIB = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "book", "lib"); +const bookLib = (name) => pathToFileURL(path.join(BOOK_LIB, name)).href; + +// A fixed date, so the Info dictionary is the same on both sides. +const WHEN = new Date(Date.UTC(2026, 0, 2, 3, 4, 5)); + +// Two entries, the first closed: a closed entry's /Count is negative. +const OUTLINE = [ + { + title: "First \u{00E9}ntry", + destination: "sec-1", + closed: true, + children: [{ title: "Child (one)", destination: "sec-2", closed: false, children: [] }], + }, + { title: "Second", destination: "sec-2", closed: false, children: [] }, +]; + +const job = JSON.parse(process.argv[2]); + +let session = null; +if (job.coverage) { + session = new Session(); + session.connect(); + await session.post("Profiler.enable"); + await session.post("Profiler.startPreciseCoverage", { callCount: true, detailed: false }); +} + +const pdfLib = await import("pdf-lib"); +const shims = []; +for (const file of job.shims) shims.push(await import(pathToFileURL(file).href)); +const { measure } = await import(bookLib("measure-pass.mjs")); +const { setMetadata } = await import(bookLib("postprocesser.mjs")); +const { setOutline } = await import(bookLib("outline.mjs")); +const parallelSave = job.parallel ? (await import(bookLib("parallel-deflate.mjs"))).parallelSave : null; + +// Taking the coverage resets its counts, so what the imports ran is not +// counted as reaching a shim. +if (session) await session.post("Profiler.takePreciseCoverage"); + +const { PDFDocument, PDFName, PDFNumber, PDFRef, PDFStreamWriter, PDFString, rgb } = pdfLib; +const raw = readFileSync(job.fixture); + +// As render-book.mjs does: size the onebuf shims from a measure of the input. +const counts = measure(raw); +for (const shim of shims) { + shim.setExpectedDictSlots?.(counts.dictSlots); + shim.setExpectedArraySlots?.(counts.arraySlots); +} + +const doc = await PDFDocument.load(raw); +setMetadata(doc, { title: "Fixture", subject: "check_pdf_shims_equiv", keywords: "one,two", creationDate: WHEN }); +doc.setModificationDate(WHEN); // setMetadata stamps the time it runs +await setOutline(doc, OUTLINE, false); + +// What the book's own change does not reach: a page drawn on, which +// normalizes its content streams; a page inserted and one removed, which edit +// /Kids; and dictionaries and an array parsed early, edited after the objects +// above were made. +const [first] = doc.getPages(); +first.drawText("Drawn 0.5 over", { x: 72.25, y: 700.125, size: 11.5, color: rgb(0.25, 0.5, 0.75) }); +doc.insertPage(1, [300.5, 400]); +doc.removePage(2); +const misc = doc.context.lookup(PDFRef.of(7)); +misc.set(PDFName.of("Added"), PDFNumber.of(-0.001)); +misc.set(PDFName.of("Int"), PDFString.of("replaced")); +misc.delete(PDFName.of("Nil")); +doc.context.lookup(PDFRef.of(12)).push(PDFNumber.of(1e-7)); +doc.catalog.set(PDFName.of("Edited"), PDFNumber.of(2 ** 20)); + +let bytes; +let streamCount = null; +if (parallelSave) { + ({ bytes, streamCount } = await parallelSave(doc, { objectsPerStream: 500 })); +} else { + // Stock save() with object streams writes 50 objects to a stream, and the + // book 500; these are save()'s own steps before it writes. + if (doc.getPageCount() === 0) doc.addPage(); + doc.formCache.getValue()?.updateFieldAppearances(); + await doc.flush(); + bytes = await PDFStreamWriter.forContext(doc.context, Infinity, true, 500).serializeToBuffer(); +} +writeFileSync(job.out, bytes); + +let reached = null; +if (session) { + const { result } = await session.post("Profiler.takePreciseCoverage"); + const ran = new Set(result.filter((s) => s.functions.some((f) => f.ranges[0].count > 0)).map((s) => s.url)); + reached = job.shims.filter((file) => ran.has(pathToFileURL(file).href)); + session.disconnect(); +} +console.log(JSON.stringify({ streamCount, reached })); diff --git a/test.bat b/test.bat index 27f541d7..f1aa398c 100644 --- a/test.bat +++ b/test.bat @@ -132,6 +132,16 @@ node scripts/check_twin_parsers.mjs @rem No tree, no browser, no install, ~1 s. node scripts/check_cli.mjs @if errorlevel 1 goto :fail +@rem The book's pdf-lib shims replace pdf-lib's parser, object classes and +@rem writer for speed, and nothing else compares what they write with what +@rem pdf-lib writes. One document, written here without pdf-lib, is loaded, +@rem changed and saved by stock pdf-lib and by pdf-lib with the shims, each +@rem in a child process, and the two files are compared object by object +@rem with streams inflated. A shim none of whose functions runs fails it too, +@rem since the document then no longer tests it, or the book does not need +@rem it. No tree, no browser, ~0.5 s. +node scripts/check_pdf_shims_equiv.mjs +@if errorlevel 1 goto :fail @rem check_a11y.mjs injects a PATCHED axe bundle (plain-color-fields, @rem -26 % on a realistic page set). The patch asserts its substitution @rem targets, so an axe-core bump fails loudly; this catches the other From a1624939702f24f2df35e978c0ac7413de04d60b Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Mon, 28 Sep 2026 20:29:43 +0200 Subject: [PATCH 05/21] book: one module for pdf-lib's internal requires --- book/lib/fast-array-onebuf.mjs | 7 +--- book/lib/fast-dict-onebuf.mjs | 14 ++----- book/lib/fast-indirect-objects.mjs | 8 +--- book/lib/fast-number-to-string.mjs | 7 +--- book/lib/fast-parse-name.mjs | 11 ++--- book/lib/fast-parse-number.mjs | 12 ++---- book/lib/fast-parse-object.mjs | 21 +++------- book/lib/fast-size-in-bytes.mjs | 7 +--- book/lib/fast-sync-load.mjs | 34 ++++----------- book/lib/pdf-lib-internals.mjs | 66 ++++++++++++++++++++++++++++++ builder/PLAN-TOOLING-REVIEW.md | 26 ++++++++++++ docs/Documentation/Fixes-PDFLib.md | 4 +- 12 files changed, 123 insertions(+), 94 deletions(-) create mode 100644 book/lib/pdf-lib-internals.mjs diff --git a/book/lib/fast-array-onebuf.mjs b/book/lib/fast-array-onebuf.mjs index 5ed833ce..e3f49578 100644 --- a/book/lib/fast-array-onebuf.mjs +++ b/book/lib/fast-array-onebuf.mjs @@ -42,12 +42,7 @@ // Composes with --fast-dict-onebuf. Mutually exclusive with // --fast-dict-encoded (which subsumes both via its own encoded shape). -import { createRequire } from 'node:module'; - -const require = createRequire(import.meta.url); -const PDFArray = require('pdf-lib/cjs/core/objects/PDFArray.js').default; -const PDFObjectParser = require('pdf-lib/cjs/core/parser/PDFObjectParser.js').default; -const CharCodes = require('pdf-lib/cjs/core/syntax/CharCodes.js').default; +import { PDFArray, PDFObjectParser, CharCodes } from './pdf-lib-internals.mjs'; // ---- The single buffer --------------------------------------------- diff --git a/book/lib/fast-dict-onebuf.mjs b/book/lib/fast-dict-onebuf.mjs index 28e14d96..86514292 100644 --- a/book/lib/fast-dict-onebuf.mjs +++ b/book/lib/fast-dict-onebuf.mjs @@ -56,17 +56,9 @@ // Mutually exclusive with --fast-dict-double / --fast-dict-view / // --fast-dict-array. -import { createRequire } from 'node:module'; - -const require = createRequire(import.meta.url); -const PDFDict = require('pdf-lib/cjs/core/objects/PDFDict.js').default; -const PDFCatalog = require('pdf-lib/cjs/core/structures/PDFCatalog.js').default; -const PDFPageTree = require('pdf-lib/cjs/core/structures/PDFPageTree.js').default; -const PDFPageLeaf = require('pdf-lib/cjs/core/structures/PDFPageLeaf.js').default; -const PDFName = require('pdf-lib/cjs/core/objects/PDFName.js').default; -const PDFNull = require('pdf-lib/cjs/core/objects/PDFNull.js').default; -const PDFObjectParser = require('pdf-lib/cjs/core/parser/PDFObjectParser.js').default; -const CharCodes = require('pdf-lib/cjs/core/syntax/CharCodes.js').default; +import { + PDFDict, PDFCatalog, PDFPageTree, PDFPageLeaf, PDFName, PDFNull, PDFObjectParser, CharCodes, +} from './pdf-lib-internals.mjs'; const TypeName = PDFName.of('Type'); const CatalogName = PDFName.of('Catalog'); diff --git a/book/lib/fast-indirect-objects.mjs b/book/lib/fast-indirect-objects.mjs index ebb740d6..83b8b2ef 100644 --- a/book/lib/fast-indirect-objects.mjs +++ b/book/lib/fast-indirect-objects.mjs @@ -46,13 +46,7 @@ // // Idempotent -- repeated imports do nothing after the first. -import { createRequire } from 'node:module'; - -const require = createRequire(import.meta.url); -const PDFContext = require('pdf-lib/cjs/core/PDFContext.js').default; -const PDFRef = require('pdf-lib/cjs/core/objects/PDFRef.js').default; -const PDFNull = require('pdf-lib/cjs/core/objects/PDFNull.js').default; -const UnexpectedObjectTypeError = require('pdf-lib/cjs/core/errors.js').UnexpectedObjectTypeError; +import { PDFContext, PDFRef, PDFNull, UnexpectedObjectTypeError } from './pdf-lib-internals.mjs'; const byAscendingObjectNumber = ([a], [b]) => a.objectNumber - b.objectNumber; diff --git a/book/lib/fast-number-to-string.mjs b/book/lib/fast-number-to-string.mjs index 57640a97..ad4ba12a 100644 --- a/book/lib/fast-number-to-string.mjs +++ b/book/lib/fast-number-to-string.mjs @@ -44,12 +44,7 @@ // // Idempotent -- repeated imports do nothing after the first. -import { createRequire } from 'node:module'; - -const require = createRequire(import.meta.url); -const numbers = require('pdf-lib/cjs/utils/numbers.js'); -const utilsBarrel = require('pdf-lib/cjs/utils/index.js'); -const topBarrel = require('pdf-lib/cjs/index.js'); +import { numbers, utilsBarrel, topBarrel } from './pdf-lib-internals.mjs'; if (!numbers.__fastNumberToStringInstalled) { const original = numbers.numberToString; diff --git a/book/lib/fast-parse-name.mjs b/book/lib/fast-parse-name.mjs index 5da62fae..9a1bc789 100644 --- a/book/lib/fast-parse-name.mjs +++ b/book/lib/fast-parse-name.mjs @@ -50,14 +50,9 @@ // Side-effecting import. Import once before PDFDocument.load runs; // idempotent. -import { createRequire } from 'node:module'; - -const require = createRequire(import.meta.url); -const PDFObjectParser = require('pdf-lib/cjs/core/parser/PDFObjectParser.js').default; -const PDFName = require('pdf-lib/cjs/core/objects/PDFName.js').default; -const CharCodes = require('pdf-lib/cjs/core/syntax/CharCodes.js').default; -const { IsWhitespace } = require('pdf-lib/cjs/core/syntax/Whitespace.js'); -const { IsDelimiter } = require('pdf-lib/cjs/core/syntax/Delimiters.js'); +import { + PDFObjectParser, PDFName, CharCodes, IsWhitespace, IsDelimiter, +} from './pdf-lib-internals.mjs'; const FORWARD_SLASH = CharCodes.ForwardSlash; diff --git a/book/lib/fast-parse-number.mjs b/book/lib/fast-parse-number.mjs index 67f592eb..8a22bc42 100644 --- a/book/lib/fast-parse-number.mjs +++ b/book/lib/fast-parse-number.mjs @@ -27,9 +27,9 @@ // NumberParsingError keeps its diagnostic context. // Both fallback paths are vanishingly rare on real PDFs. // -// Mechanism: BaseParser isn't re-exported by pdf-lib's index, so we -// import it via the package's CJS internal path through createRequire. -// Mutating BaseParser.prototype affects every subclass (PDFParser, +// Mechanism: BaseParser isn't re-exported by pdf-lib's index, so it +// comes from pdf-lib-internals.mjs, which requires it by the package's +// CJS internal path. Mutating BaseParser.prototype affects every subclass (PDFParser, // PDFObjectParser, PDFObjectStreamParser, PDFXRefStreamParser). // // Side-effecting import. Import once before PDFDocument.load runs: @@ -38,11 +38,7 @@ // // Idempotent -- repeated imports do nothing after the first. -import { createRequire } from 'node:module'; - -const require = createRequire(import.meta.url); -const BaseParser = require('pdf-lib/cjs/core/parser/BaseParser.js').default; -const { IsDigit } = require('pdf-lib/cjs/core/syntax/Numeric.js'); +import { BaseParser, IsDigit } from './pdf-lib-internals.mjs'; const ZERO = 0x30; // '0' const PERIOD = 0x2E; // '.' diff --git a/book/lib/fast-parse-object.mjs b/book/lib/fast-parse-object.mjs index 33bcbfa4..2c26d052 100644 --- a/book/lib/fast-parse-object.mjs +++ b/book/lib/fast-parse-object.mjs @@ -34,11 +34,9 @@ // keyword still falls through to the same PDFObjectParsingError // throw. // -// Mechanism: PDFObjectParser isn't re-exported from pdf-lib's index, -// so we reach in through the CJS internals via createRequire (same -// shape as fast-sync-load.mjs). Mutating -// PDFObjectParser.prototype.parseObject is global -- every parser -// instance created after this shim loads picks it up. +// Mechanism: PDFObjectParser comes from pdf-lib-internals.mjs. +// Mutating PDFObjectParser.prototype.parseObject is global -- every +// parser instance created after this shim loads picks it up. // // Side-effecting import. Import once before PDFDocument.load runs: // @@ -46,16 +44,9 @@ // // Idempotent -- repeated imports do nothing after the first. -import { createRequire } from 'node:module'; - -const require = createRequire(import.meta.url); -const PDFObjectParser = require('pdf-lib/cjs/core/parser/PDFObjectParser.js').default; -const PDFBool = require('pdf-lib/cjs/core/objects/PDFBool.js').default; -const PDFNull = require('pdf-lib/cjs/core/objects/PDFNull.js').default; -const CharCodes = require('pdf-lib/cjs/core/syntax/CharCodes.js').default; -const { Keywords } = require('pdf-lib/cjs/core/syntax/Keywords.js'); -const { IsNumeric } = require('pdf-lib/cjs/core/syntax/Numeric.js'); -const { PDFObjectParsingError } = require('pdf-lib/cjs/core/errors.js'); +import { + PDFObjectParser, PDFBool, PDFNull, CharCodes, Keywords, IsNumeric, PDFObjectParsingError, +} from './pdf-lib-internals.mjs'; const KwTrue = Keywords.true; const KwFalse = Keywords.false; diff --git a/book/lib/fast-size-in-bytes.mjs b/book/lib/fast-size-in-bytes.mjs index 779ade41..1654abd6 100644 --- a/book/lib/fast-size-in-bytes.mjs +++ b/book/lib/fast-size-in-bytes.mjs @@ -40,12 +40,7 @@ // // Idempotent -- repeated imports do nothing after the first. -import { createRequire } from 'node:module'; - -const require = createRequire(import.meta.url); -const numbers = require('pdf-lib/cjs/utils/numbers.js'); -const utilsBarrel = require('pdf-lib/cjs/utils/index.js'); -const topBarrel = require('pdf-lib/cjs/index.js'); +import { numbers, utilsBarrel, topBarrel } from './pdf-lib-internals.mjs'; if (!numbers.__fastSizeInBytesInstalled) { const fastSizeInBytes = function fastSizeInBytes(n) { diff --git a/book/lib/fast-sync-load.mjs b/book/lib/fast-sync-load.mjs index 4a81f97f..eb4bc70b 100644 --- a/book/lib/fast-sync-load.mjs +++ b/book/lib/fast-sync-load.mjs @@ -49,32 +49,14 @@ // // Idempotent -- repeated imports do nothing after the first. -import { createRequire } from 'node:module'; - -const require = createRequire(import.meta.url); -const PDFParser = require('pdf-lib/cjs/core/parser/PDFParser.js').default; -const PDFObjectStreamParser = require('pdf-lib/cjs/core/parser/PDFObjectStreamParser.js').default; -const PDFXRefStreamParser = require('pdf-lib/cjs/core/parser/PDFXRefStreamParser.js').default; -const PDFRawStream = require('pdf-lib/cjs/core/objects/PDFRawStream.js').default; -const PDFRef = require('pdf-lib/cjs/core/objects/PDFRef.js').default; -const PDFName = require('pdf-lib/cjs/core/objects/PDFName.js').default; -const PDFNumber = require('pdf-lib/cjs/core/objects/PDFNumber.js').default; -const PDFStream = require('pdf-lib/cjs/core/objects/PDFStream.js').default; -const PDFInvalidObject = require('pdf-lib/cjs/core/objects/PDFInvalidObject.js').default; -const PDFDocument = require('pdf-lib/cjs/api/PDFDocument.js').default; -const PDFWriter = require('pdf-lib/cjs/core/writers/PDFWriter.js').default; -const PDFStreamWriter = require('pdf-lib/cjs/core/writers/PDFStreamWriter.js').default; -const PDFHeader = require('pdf-lib/cjs/core/document/PDFHeader.js').default; -const PDFTrailer = require('pdf-lib/cjs/core/document/PDFTrailer.js').default; -const PDFTrailerDict = require('pdf-lib/cjs/core/document/PDFTrailerDict.js').default; -const PDFCrossRefSection = require('pdf-lib/cjs/core/document/PDFCrossRefSection.js').default; -const PDFCrossRefStream = require('pdf-lib/cjs/core/structures/PDFCrossRefStream.js').default; -const PDFObjectStream = require('pdf-lib/cjs/core/structures/PDFObjectStream.js').default; -const CharCodes = require('pdf-lib/cjs/core/syntax/CharCodes.js').default; -const { ReparseError, StalledParserError } = require('pdf-lib/cjs/core/errors.js'); -const { IsDigit } = require('pdf-lib/cjs/core/syntax/Numeric.js'); -const { Keywords } = require('pdf-lib/cjs/core/syntax/Keywords.js'); -const { toUint8Array, copyStringIntoBuffer, last } = require('pdf-lib/cjs/utils/index.js'); +import { + PDFParser, PDFObjectStreamParser, PDFXRefStreamParser, + PDFRawStream, PDFRef, PDFName, PDFNumber, PDFStream, PDFInvalidObject, + PDFDocument, PDFWriter, PDFStreamWriter, + PDFHeader, PDFTrailer, PDFTrailerDict, PDFCrossRefSection, PDFCrossRefStream, PDFObjectStream, + CharCodes, ReparseError, StalledParserError, IsDigit, Keywords, + toUint8Array, copyStringIntoBuffer, last, +} from './pdf-lib-internals.mjs'; // Pool-deduped PDFName instances are reference-stable for the whole // load. Capture the three sentinels parseIndirectObject's Type-dispatch diff --git a/book/lib/pdf-lib-internals.mjs b/book/lib/pdf-lib-internals.mjs new file mode 100644 index 00000000..56bcf161 --- /dev/null +++ b/book/lib/pdf-lib-internals.mjs @@ -0,0 +1,66 @@ +// The pdf-lib objects the book's shims patch or call, required in one place. +// +// Each is required by its CommonJS path under pdf-lib/cjs, and a shim imports +// what it needs by name. Most are the same objects pdf-lib's index exports. +// BaseParser and the syntax tables (Keywords, IsDigit, IsNumeric, IsWhitespace, +// IsDelimiter) are not in the index at all, and the three utility modules are +// exported whole because the shims that replace numberToString and sizeInBytes +// assign into each of them. 'pdf-lib' resolves to cjs/index.js, the package's +// `main` (it has no `exports` map), so each of these is the instance pdf-lib +// itself uses, and a patch applied to it reaches the library, not a copy. + +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); + +export const PDFDocument = require('pdf-lib/cjs/api/PDFDocument.js').default; +export const PDFContext = require('pdf-lib/cjs/core/PDFContext.js').default; + +export const PDFCrossRefSection = require('pdf-lib/cjs/core/document/PDFCrossRefSection.js').default; +export const PDFHeader = require('pdf-lib/cjs/core/document/PDFHeader.js').default; +export const PDFTrailer = require('pdf-lib/cjs/core/document/PDFTrailer.js').default; +export const PDFTrailerDict = require('pdf-lib/cjs/core/document/PDFTrailerDict.js').default; + +export const PDFArray = require('pdf-lib/cjs/core/objects/PDFArray.js').default; +export const PDFBool = require('pdf-lib/cjs/core/objects/PDFBool.js').default; +export const PDFDict = require('pdf-lib/cjs/core/objects/PDFDict.js').default; +export const PDFInvalidObject = require('pdf-lib/cjs/core/objects/PDFInvalidObject.js').default; +export const PDFName = require('pdf-lib/cjs/core/objects/PDFName.js').default; +export const PDFNull = require('pdf-lib/cjs/core/objects/PDFNull.js').default; +export const PDFNumber = require('pdf-lib/cjs/core/objects/PDFNumber.js').default; +export const PDFRawStream = require('pdf-lib/cjs/core/objects/PDFRawStream.js').default; +export const PDFRef = require('pdf-lib/cjs/core/objects/PDFRef.js').default; +export const PDFStream = require('pdf-lib/cjs/core/objects/PDFStream.js').default; + +export const BaseParser = require('pdf-lib/cjs/core/parser/BaseParser.js').default; +export const PDFObjectParser = require('pdf-lib/cjs/core/parser/PDFObjectParser.js').default; +export const PDFObjectStreamParser = require('pdf-lib/cjs/core/parser/PDFObjectStreamParser.js').default; +export const PDFParser = require('pdf-lib/cjs/core/parser/PDFParser.js').default; +export const PDFXRefStreamParser = require('pdf-lib/cjs/core/parser/PDFXRefStreamParser.js').default; + +export const PDFCatalog = require('pdf-lib/cjs/core/structures/PDFCatalog.js').default; +export const PDFCrossRefStream = require('pdf-lib/cjs/core/structures/PDFCrossRefStream.js').default; +export const PDFObjectStream = require('pdf-lib/cjs/core/structures/PDFObjectStream.js').default; +export const PDFPageLeaf = require('pdf-lib/cjs/core/structures/PDFPageLeaf.js').default; +export const PDFPageTree = require('pdf-lib/cjs/core/structures/PDFPageTree.js').default; + +export const CharCodes = require('pdf-lib/cjs/core/syntax/CharCodes.js').default; +export const { IsDelimiter } = require('pdf-lib/cjs/core/syntax/Delimiters.js'); +export const { Keywords } = require('pdf-lib/cjs/core/syntax/Keywords.js'); +export const { IsDigit, IsNumeric } = require('pdf-lib/cjs/core/syntax/Numeric.js'); +export const { IsWhitespace } = require('pdf-lib/cjs/core/syntax/Whitespace.js'); + +export const PDFStreamWriter = require('pdf-lib/cjs/core/writers/PDFStreamWriter.js').default; +export const PDFWriter = require('pdf-lib/cjs/core/writers/PDFWriter.js').default; + +export const { + PDFObjectParsingError, + ReparseError, + StalledParserError, + UnexpectedObjectTypeError, +} = require('pdf-lib/cjs/core/errors.js'); + +export const numbers = require('pdf-lib/cjs/utils/numbers.js'); +export const utilsBarrel = require('pdf-lib/cjs/utils/index.js'); +export const topBarrel = require('pdf-lib/cjs/index.js'); +export const { copyStringIntoBuffer, last, toUint8Array } = utilsBarrel; diff --git a/builder/PLAN-TOOLING-REVIEW.md b/builder/PLAN-TOOLING-REVIEW.md index 1e7672f3..687e2e21 100644 --- a/builder/PLAN-TOOLING-REVIEW.md +++ b/builder/PLAN-TOOLING-REVIEW.md @@ -2670,6 +2670,32 @@ in nine production shims. **Verify.** `check_pdf_shims_equiv.mjs`; `book.bat` renders with the same page count and outline. +**Landed.** `book/lib/pdf-lib-internals.mjs` requires the 44 pdf-lib objects the nine shims +required themselves, each by the same CommonJS path, and exports them under the names the +shims already used, so each require block became one import and no shim's body changed. Two +reads move: `Numeric.js` is required once for `IsDigit` and `IsNumeric`, and +`copyStringIntoBuffer`, `last` and `toUint8Array` are read from the utilities barrel when the +new module loads rather than when `fast-sync-load.mjs` does. Neither changes a value: no shim +assigns those three (the two utility shims assign only `numberToString` and `sizeInBytes`), and +`render-book.mjs` and the gate's side both import `pdf-lib`, which loads every module, before +any shim. A scratch comparison of each export with `require('pdf-lib')` found 35 of the 44 to +be the objects pdf-lib's index exports and 9 not in it (`BaseParser`, the five syntax exports +and the three utility modules); the module's header says so. `fast-parse-object.mjs`'s header +said `PDFObjectParser` is not re-exported from pdf-lib's index, which is false, and now says +only where it comes from; `fast-parse-number.mjs` and Fixes-PDFLib.md say `BaseParser` is not, +which is true, and now name the module, which Fixes-PDFLib.md's introduction describes in a +new paragraph. `perf/`'s instruments keep their own requires, as records of the measurements. + +`check_pdf_shims_equiv`: unchanged. With the module exporting a subclass of `PDFObjectParser` +in its place (a fault through the kit's `c43-fault.mjs`), the gate names `fast-parse-object` +and `fast-parse-name`, whose patches land on the copy, and not `fast-dict-onebuf` or +`fast-array-onebuf`, whose `parseDict` and `parseArray` land there too, because other +functions in both still ran: C67a. With `BaseParser` so, it names `fast-parse-number`. The +book, rendered from one `_site-pdf` through HEAD's `book/` and the working one: 2,299 pages, +2,466 outline entries and 29,130,183 bytes each, differing only in `/CreationDate` and +`/ModDate`; 130 s and 136 s, `process: 1.3s` and `1.4s`. `compare_trees`: Fixes-PDFLib online +and offline, the search data and `book.html`. Lint `Checked 169 files`. + ### C68 — `book: the two onebuf shims share their range machinery` **A9-3 (R2).** `_registerContext` and `_appendArray` are identical apart from names in diff --git a/docs/Documentation/Fixes-PDFLib.md b/docs/Documentation/Fixes-PDFLib.md index b01a5412..4cabd568 100644 --- a/docs/Documentation/Fixes-PDFLib.md +++ b/docs/Documentation/Fixes-PDFLib.md @@ -11,6 +11,8 @@ permalink: /Documentation/Development/Fixes/PDFLib The files under `book/lib/fast-*.mjs` and `book/lib/parallel-deflate.mjs` are side-effecting ES modules that patch pdf-lib's live exports. All are imported at the top of `render-book.mjs` before any pdf-lib operation runs; they are mutually compatible and idempotent (each guards its installation with a flag on the patched prototype or module). Together they reduce the process phase --- parsing Chromium's raw PDF output, adding bookmarks and metadata, and serialising the result --- from ~40 seconds to ~1.6 seconds on a 1,651-page book. +A patch that reaches a pdf-lib class or module by its CommonJS path under `pdf-lib/cjs/`, rather than through the `pdf-lib` package's index, imports it from `book/lib/pdf-lib-internals.mjs`, which requires each one in a single place. `pdf-lib` itself resolves to `pdf-lib/cjs/index.js`, so each is the instance the library uses. + The root cause of the need for all these patches is the same: pdf-lib is designed for general-purpose use in both browsers and Node, and optimises for generality rather than throughput on a single large document. Each patch must leave the output unchanged. [`check_pdf_shims_equiv.mjs`](../Tools#check-pdf-shims-equiv), one of `test.bat`'s gates, saves one document with stock pdf-lib and with every patch `render-book.mjs` imports, compares the two files object by object, and fails if any patch never runs. @@ -30,7 +32,7 @@ Each patch must leave the output unchanged. [`check_pdf_shims_equiv.mjs`](../Too **Fix.** Direct integer accumulators: `n = n * 10 + (byte - 0x30)`, consuming each byte once. `parseRawNumber` additionally accumulates the digits after the period and divides once, `(integer * scale + fraction) / scale`: both operands are exact integers, and a single division rounds to the double nearest the decimal, as `Number` does. Adding the fraction's quotient to the integer part instead would round twice, and read `2.28` as `2.2800000000000002`. Both implementations fall back to the original when the number has more than 15 digits, the fraction's included (preserving `Number.MAX_SAFE_INTEGER` semantics for pathological inputs), or no digits at all. -**Mechanism.** `BaseParser` is not re-exported from pdf-lib's public index; it is imported via `createRequire` through the CJS internal path `pdf-lib/cjs/core/parser/BaseParser.js`. Mutating `BaseParser.prototype` affects all subclasses: `PDFParser`, `PDFObjectParser`, `PDFObjectStreamParser`, and `PDFXRefStreamParser`. +**Mechanism.** `BaseParser` is not re-exported from pdf-lib's public index; it comes from `pdf-lib-internals.mjs`, which requires it by the CJS internal path `pdf-lib/cjs/core/parser/BaseParser.js`. Mutating `BaseParser.prototype` affects all subclasses: `PDFParser`, `PDFObjectParser`, `PDFObjectStreamParser`, and `PDFXRefStreamParser`. ## fast-decode-name.mjs From 85a2f743504c9063158634224fa6102a24a4a2f9 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Mon, 28 Sep 2026 21:08:05 +0200 Subject: [PATCH 06/21] book: check_pdf_shims_equiv checks each member the shims patch --- WIP.md | 2 +- builder/PLAN-TOOLING-REVIEW.md | 92 +++++++++++++++++ docs/Documentation/Fixes-PDFLib.md | 2 +- docs/Documentation/Tools.md | 6 +- scripts/check_pdf_shims_equiv.mjs | 158 +++++++++++++++++++++++++++-- scripts/lib/pdf-shims-side.mjs | 111 +++++++++++++++++++- 6 files changed, 355 insertions(+), 16 deletions(-) diff --git a/WIP.md b/WIP.md index 49ec8e8b..2441811e 100644 --- a/WIP.md +++ b/WIP.md @@ -508,7 +508,7 @@ wrapper: | `test.bat` | `check_ci_workflows` | both CI workflows run every wrapper gate, with the same arguments and order, and build with `build.bat`'s flags | | `test.bat` | `check_lint` | Biome finds nothing in the tooling, warnings included, and checked at least one script | | `test.bat` | `test/search.test.mjs` | the search entries `builder/search.mjs` writes hold what they should, and the copies of the search client still agree. Run by `node --test`; the gate roster reads such a line as a gate, named by its path | -| `test.bat` | `check_pdf_shims_equiv` | the book's pdf-lib shims write what stock pdf-lib writes: one document written by the gate is saved both ways in child processes, the files are compared object by object with streams inflated and their cross-reference entries checked, and every shim must run | +| `test.bat` | `check_pdf_shims_equiv` | the book's pdf-lib shims write what stock pdf-lib writes: one document written by the gate is saved both ways in child processes, the files are compared object by object with streams inflated and their cross-reference entries checked; every shim must run, and the members of pdf-lib the shims patch must be those its `PATCHES` lists, each run unless marked there as not reached | | `test.bat` | `check_publish_policy`, `check_gate_lists`, `check_page_baseline`, `check_book_coverage`, `check_axe_patch_equiv` | the gates on the gates | **A gate belongs in `test.bat` rather than `check.bat` if it would still mean diff --git a/builder/PLAN-TOOLING-REVIEW.md b/builder/PLAN-TOOLING-REVIEW.md index 687e2e21..f4a1c37b 100644 --- a/builder/PLAN-TOOLING-REVIEW.md +++ b/builder/PLAN-TOOLING-REVIEW.md @@ -2696,6 +2696,91 @@ book, rendered from one `_site-pdf` through HEAD's `book/` and the working one: `/ModDate`; 130 s and 136 s, `process: 1.3s` and `1.4s`. `compare_trees`: Fixes-PDFLib online and offline, the search data and `book.html`. Lint `Checked 169 files`. +### C67a — `book: check_pdf_shims_equiv checks each member the shims patch` + +**Found while landing C67** (see Found while implementing). The gate's reach check fails a +shim none of whose functions ran, so a shim with several patches passes while one of them +never runs, or lands on a copy of a class. With `pdf-lib-internals.mjs` exporting a subclass of +`PDFObjectParser`, the gate named the two shims whose only patch is on it, and not +`fast-dict-onebuf.mjs` or `fast-array-onebuf.mjs`, whose `parseDict` and `parseArray` landed on +the copy too. + +**Change.** The shimmed side lists the members of pdf-lib the shims put a function into, and +whether each function ran. The gate checks them against a list of every member each shim +patches, in which a member the document does not reach is marked, with the reason. + +**Verify.** Passes; C67's fault fails it, naming the two members; so do a patched member missing +from the list, an unmarked member that stops running and a marked one that runs. + +**Landed.** In coverage mode the side snapshots the own properties of every pdf-lib module's +exports in `require.cache`, of each function they export and of its prototype, before and +after the shims load; a member that holds a new function, as value, getter or setter, is +patched. The inspector gives each function's `[[FunctionLocation]]` and +`Debugger.getScriptSource` its script, and the function's coverage entry is the one containing +that position whose source is the function's own text. A function never called can have no +entry at all, having never been compiled (measured: `PDFContext.prototype.delete` has none), so +no entry counts as not run. Only functions a shim defines count. The side prints `{ streamCount, +reached, patched }`, `patched` one `{ shim, member, ran }` per member, since +`numberToString` and `sizeInBytes` are each installed in three modules. + +`PATCHES` in the gate lists 74 members of 12 shims. A measurement is behind each of the 22 +marks: 16 `the load, the change and the save do not call it`, both `computeBufferSize` +`parallelSave does not call it`, the two factories `PDFDocument.create` alone calls (read at +`api/PDFDocument.js:146-148`), `PDFCatalog.fromMapWithContext` (called only from the stock +`parseDict` that `fast-dict-onebuf` replaces) and `PDFPageTree.fromMapWithContext` (under the +shims, called only from that shim's `PDFPageTree.withContext`). The gate reports a listed +member not patched, a patched member not listed, an unmarked member that never ran, and a +marked member that ran; a shim that ran nothing is still reported whole, and its members are +left out of the four lists. Now: `stock pdf-lib and 12 shims with parallelSave write the same +22 objects; the 74 members the shims patch are as listed, and all ran but the 22 marked`, 0.49 +s. Faults through the kit's `c67a-faults.mjs`, each exit 1: the `PDFObjectParser` copy names +`fast-parse-object` and `fast-parse-name` whole, `parseDict` and `parseArray` as not patched, +and the two `fromMapWithContext` marks as run, since stock `parseDict` runs again and calls +them; a `BaseParser` copy names `fast-parse-number` whole; the side without its +`misc.delete` names `PDFDict.prototype.delete` as never run; the side calling `misc.has` names +that mark as run; a method a shim adds to `PDFNumber.prototype` is named as not listed. The +header, `--help`, Tools.md's list line, section and exits, Fixes-PDFLib.md and WIP.md's table +say what the gate now checks; `check_cli`'s help case pins only the first line. Lint `Checked +169 files`; regex safety `521 literals + 28 constructed in 129 files -- 480 safe, 69 +polynomial`. `compare_trees`: Fixes-PDFLib and Tools online and offline, the search data and +`book.html`. CI waits for the owner's push. + +### C67b — `book: the shim gate reaches the members it marks` + +**The owner's choice (2026-09-28)**, with C67a. A Sonnet agent measured the 22 marked members +against the real book: rendered with `NODE_V8_COVERAGE`, 2,299 pages, every one ran 0 times +(`PDFDict.prototype.get`, for comparison, 15,124), so the gate's document is not narrower than +the book. Twenty exist because the storage changed. The onebuf classes keep their entries in +one buffer (`_FastArray` holds only `this.d`, `fast-array-onebuf.mjs:147-148`), and a `PDFRef` +holds no `tag` (`fast-refs-class.mjs:74-86`), so every stock method that reads the old fields +had to be replaced, called or not. `PDFContext.prototype.delete` has a caller, +`fast-sync-load.mjs:92-95`, which removes an object 0 that a parsed file defines; Chromium +never writes one. The two `computeBufferSize` overrides are the exception. No storage change +forces them, the shim's comment calls them "patched for consistency" (`fast-sync-load.mjs:237-241`), +`08-pdf-lib.md` finds the writer-side wins "none reliably above noise" (`:1795-1822`), and +`ParallelStreamWriter`, which predates them, overrides the method on the book's only path +(`parallel-deflate.mjs:50-61`). The split renderer the owner recalled, `perf/probe-parallel.mjs`, +never loads the shims and never merges its parts; a renderer that copied pages between +documents would need two `PDFContext`s, which the onebuf shims refuse +(`fast-dict-onebuf.mjs:136-142`, `fast-array-onebuf.mjs:99-105`). + +**Change.** Delete the two `computeBufferSize` overrides from `fast-sync-load.mjs`, at the +owner's choice, with their two `PATCHES` entries. The side's change calls each marked member +a loaded document can reach, with each result written into the document so that the +comparison checks it: a dictionary's and an array's `clone` registered, their `toString` and a +reference's stored as strings, `values`, `entries`, `has`, `asMap`, `indexOf`, `asArray` and +`getObjectRef` reduced to numbers or references stored in a dictionary, and `set` on an array. +The fixture gains an object 0, which the parse removes through `PDFContext.prototype.delete`. +A second pair of sides, stock and shimmed, builds a document with `PDFDocument.create`, adds +pages and saves, since one process allows the onebuf shims one context; the gate compares the +pair as it compares the first, and it reaches the four page-tree and catalog factories. The +two `context` setters stay marked: each is empty by design (`fast-dict-onebuf.mjs:411`, +`fast-array-onebuf.mjs:289`), and nothing it does reaches the output. + +**Verify.** The gate passes with two marks left; each newly reached member, broken through +the kit's `c43-fault.mjs`, fails it by difference; C67a's five faults still fail. The book +renders identically but for its dates. + ### C68 — `book: the two onebuf shims share their range machinery` **A9-3 (R2).** `_registerContext` and `_appendArray` are identical apart from names in @@ -3416,6 +3501,13 @@ Defects the review did not have, found by building something this plan asks for. - **Fixes.md still counted thirteen pdf-lib shims**, found while building C66: C65b deleted one and missed that page. Scheduled as C65e, at the owner's choice. Fixed in `docs: Fixes.md stops counting the pdf-lib shims`. +- **The shim gate's reach check counted shims, not patches**, found while landing C67: with + `PDFObjectParser` replaced by a copy in `pdf-lib-internals.mjs`, the gate named the two shims + whose only patch is on it, and passed over the `parseDict` and `parseArray` patches of two + shims whose other functions ran. A reach check by function alone would not have closed it: + a patch applied to a copy is not a patch of pdf-lib at all. Scheduled as C67a, with the + document's reach of the members it marks as C67b, at the owner's choice. Fixed in `book: + check_pdf_shims_equiv checks each member the shims patch`. ## Open questions diff --git a/docs/Documentation/Fixes-PDFLib.md b/docs/Documentation/Fixes-PDFLib.md index 4cabd568..c287cad8 100644 --- a/docs/Documentation/Fixes-PDFLib.md +++ b/docs/Documentation/Fixes-PDFLib.md @@ -15,7 +15,7 @@ A patch that reaches a pdf-lib class or module by its CommonJS path under `pdf-l The root cause of the need for all these patches is the same: pdf-lib is designed for general-purpose use in both browsers and Node, and optimises for generality rather than throughput on a single large document. -Each patch must leave the output unchanged. [`check_pdf_shims_equiv.mjs`](../Tools#check-pdf-shims-equiv), one of `test.bat`'s gates, saves one document with stock pdf-lib and with every patch `render-book.mjs` imports, compares the two files object by object, and fails if any patch never runs. +Each patch must leave the output unchanged. [`check_pdf_shims_equiv.mjs`](../Tools#check-pdf-shims-equiv), one of `test.bat`'s gates, saves one document with stock pdf-lib and with every patch `render-book.mjs` imports, compares the two files object by object, and fails if the patches are not the ones the gate lists, or if one never runs that the list does not mark as unreached. * TOC goes here {:toc} diff --git a/docs/Documentation/Tools.md b/docs/Documentation/Tools.md index 3ac572ef..9dd1c9c1 100644 --- a/docs/Documentation/Tools.md +++ b/docs/Documentation/Tools.md @@ -79,7 +79,7 @@ The tests the toolchain has to pass. Fourteen steps, each stopping the run if it 10. [`scripts/check_symbol_index.mjs`](#check-symbol-index) --- verifies the symbol index still places each kind of symbol, and its drift guard still refuses a lost URL. 11. [`scripts/check_twin_parsers.mjs`](#check-twin-parsers) --- verifies the scanners of twinBASIC source and of the attribute reference still read the shapes each once misread. 12. [`scripts/check_cli.mjs`](#check-cli) --- verifies `lib/cli.mjs`, the command-line parser, and each tool's recorded command-line errors. -13. [`scripts/check_pdf_shims_equiv.mjs`](#check-pdf-shims-equiv) --- verifies the book's pdf-lib shims write what stock pdf-lib writes, and that each of them runs. +13. [`scripts/check_pdf_shims_equiv.mjs`](#check-pdf-shims-equiv) --- verifies the book's pdf-lib shims write what stock pdf-lib writes, patch the members of pdf-lib it lists, and run. 14. [`scripts/check_axe_patch_equiv.mjs`](#check-axe-patch-equiv) --- verifies the vendored axe source patch still produces identical colour values. POSIX: @@ -565,9 +565,9 @@ Exits 1 on any failed probe or case, 2 if it cannot run. Verifies that the book's [pdf-lib patches](Fixes/PDFLib) write what pdf-lib itself writes. `book/render-book.mjs` loads Chromium's PDF, adds the metadata and the outline, and saves it, with a dozen shims replacing pdf-lib's parser, object classes and writer, and `parallelSave` in place of `save()`. This loads, changes and saves one document twice, with stock pdf-lib and with every shim `render-book.mjs` imports, each side in a process of its own, and compares the two files object by object with every stream inflated, since `node:zlib` and pdf-lib's own deflate can compress the same bytes differently. It also checks each file's cross-reference entries against the objects they locate, since pdf-lib's own parser finds objects without them. No built tree, no browser; under a second. -The document is written by the gate, without pdf-lib, so the forms the shims' parsers branch on are known to be in it: names with `#` escapes, numbers in every lexical form, a classic cross-reference table, and an incremental update with an object stream and a cross-reference stream. The change mirrors `render-book.mjs`'s and adds what reaches the rest of the shims: text drawn on a page, a page inserted and one removed, and objects parsed early and edited late. A shim none of whose functions runs fails the gate too, since the document then no longer tests it, or the book does not need it. On a difference, the shimmed side runs again with each shim alone and with each left out, and the report names the shims that make it. +The document is written by the gate, without pdf-lib, so the forms the shims' parsers branch on are known to be in it: names with `#` escapes, numbers in every lexical form, a classic cross-reference table, and an incremental update with an object stream and a cross-reference stream. The change mirrors `render-book.mjs`'s and adds what reaches the rest of the shims: text drawn on a page, a page inserted and one removed, and objects parsed early and edited late. Each member of pdf-lib that a shim puts a function into is checked against `PATCHES`, a list in the gate. A listed member that is not patched fails it, and so does a patched member that is not listed: a patch applied to a copy of a class leaves pdf-lib's own member as it was. Each listed member's function must run, unless the list marks the member as one the document does not reach and says why, and a marked member that runs fails the gate as well, so the marks stay true. A shim none of whose functions runs is reported whole, since the document then no longer tests it, or the book does not need it. On a difference, the shimmed side runs again with each shim alone and with each left out, and the report names the shims that make it. -Exits 1 on a difference or a shim that did not run, 2 if it cannot run. +Exits 1 on a difference, a shim or listed member that did not run, or a patched member that is not as listed, 2 if it cannot run. ### check_axe_patch_equiv.mjs {: #check-axe-patch-equiv } diff --git a/scripts/check_pdf_shims_equiv.mjs b/scripts/check_pdf_shims_equiv.mjs index 023e60e8..d5cb2355 100644 --- a/scripts/check_pdf_shims_equiv.mjs +++ b/scripts/check_pdf_shims_equiv.mjs @@ -22,15 +22,18 @@ // removed, and dictionaries parsed early and edited late. // // A shim none of whose functions runs is reported too: it means the document -// no longer tests it, or that the book never needed it. +// no longer tests it, or that the book never needed it. So is each member of +// pdf-lib the shims put a function into, against PATCHES below: a member +// listed there and not patched, one patched and not listed, one whose function +// never ran, and one marked there as not reached that ran. // // On a difference, the shimmed side is run again with each shim alone and with // each left out (parallelSave counts as one), to name the shims that make it. // // node scripts/check_pdf_shims_equiv.mjs // -// Exit codes: 0 the same, 1 a difference or a shim not reached, 2 the check -// itself failed. +// Exit codes: 0 the same, 1 a difference, a shim or patched member not reached +// or a patched member not as PATCHES lists it, 2 the check itself failed. import { spawn } from "node:child_process"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; @@ -50,9 +53,10 @@ if (cli.stopped === "help") { printHelpAndExit(`usage: node scripts/check_pdf_shims_equiv.mjs Loads, changes and saves one PDF with stock pdf-lib and with the book's pdf-lib -shims, and compares the two files object by object, streams inflated. Exit 0 -the same, 1 a difference or a shim the document no longer reaches, 2 the check -itself failed.`); +shims, compares the two files object by object, streams inflated, and checks +the members of pdf-lib the shims patch against the list in this file. Exit 0 +the same, 1 a difference, a shim or patched member the document no longer +reaches, or a patched member not as listed, 2 the check itself failed.`); } const TOOL = "check_pdf_shims_equiv"; @@ -76,6 +80,127 @@ function shimsOf(source) { const shimName = (file) => path.basename(file); +// Every member of pdf-lib each shim puts a function into, named as the side +// names it. The side finds them by comparing pdf-lib's modules, their exported +// classes and those classes' prototypes before and after the shims load, so a +// patch applied to anything else, such as a copy of a class, is missing here. +// A member given as [member, reason] is one the document does not reach. +const UNCALLED = "the load, the change and the save do not call it"; +const CREATE_ONLY = + "pdf-lib calls it only from PDFDocument.create, and the onebuf shims allow the side one context, the loaded document's"; +const PATCHES = { + "fast-refs-class.mjs": [ + "PDFRef.of", + ["PDFRef.prototype.toString", UNCALLED], + "PDFRef.prototype.sizeInBytes", + "PDFRef.prototype.copyBytesInto", + ], + "fast-parse-number.mjs": ["BaseParser.prototype.parseRawInt", "BaseParser.prototype.parseRawNumber"], + "fast-decode-name.mjs": ["PDFName.of"], + "fast-number-to-string.mjs": [ + "numberToString in pdf-lib/cjs/index.js", + "numberToString in pdf-lib/cjs/utils/index.js", + "numberToString in pdf-lib/cjs/utils/numbers.js", + ], + "fast-size-in-bytes.mjs": [ + "sizeInBytes in pdf-lib/cjs/index.js", + "sizeInBytes in pdf-lib/cjs/utils/index.js", + "sizeInBytes in pdf-lib/cjs/utils/numbers.js", + ], + "fast-dict-onebuf.mjs": [ + "PDFObjectParser.prototype.parseDict", + "PDFDict.withContext", + "PDFDict.fromMapWithContext", + "PDFDict.prototype.keys", + ["PDFDict.prototype.values", UNCALLED], + ["PDFDict.prototype.entries", UNCALLED], + "PDFDict.prototype.set", + "PDFDict.prototype.get", + ["PDFDict.prototype.has", UNCALLED], + "PDFDict.prototype.delete", + ["PDFDict.prototype.asMap", UNCALLED], + ["PDFDict.prototype.clone", UNCALLED], + ["PDFDict.prototype.toString", UNCALLED], + "PDFDict.prototype.sizeInBytes", + "PDFDict.prototype.copyBytesInto", + "PDFDict.prototype.context (getter)", + ["PDFDict.prototype.context (setter)", UNCALLED], + ["PDFCatalog.withContextAndPages", CREATE_ONLY], + ["PDFCatalog.fromMapWithContext", "pdf-lib calls it only from the parseDict this shim replaces"], + ["PDFPageTree.withContext", CREATE_ONLY], + ["PDFPageTree.fromMapWithContext", "only this shim's PDFPageTree.withContext calls it"], + "PDFPageLeaf.withContextAndParent", + "PDFPageLeaf.fromMapWithContext", + "PDFPageLeaf.prototype.normalized (getter)", + "PDFPageLeaf.prototype.normalized (setter)", + "PDFPageLeaf.prototype.autoNormalizeCTM (getter)", + "PDFPageLeaf.prototype.autoNormalizeCTM (setter)", + ], + "fast-array-onebuf.mjs": [ + "PDFObjectParser.prototype.parseArray", + "PDFArray.withContext", + "PDFArray.prototype.size", + "PDFArray.prototype.push", + "PDFArray.prototype.insert", + ["PDFArray.prototype.indexOf", UNCALLED], + "PDFArray.prototype.remove", + ["PDFArray.prototype.set", UNCALLED], + "PDFArray.prototype.get", + ["PDFArray.prototype.asArray", UNCALLED], + ["PDFArray.prototype.clone", UNCALLED], + ["PDFArray.prototype.toString", UNCALLED], + "PDFArray.prototype.sizeInBytes", + "PDFArray.prototype.copyBytesInto", + "PDFArray.prototype.context (getter)", + ["PDFArray.prototype.context (setter)", UNCALLED], + ], + "fast-parse-object.mjs": ["PDFObjectParser.prototype.parseObject"], + "fast-parse-name.mjs": ["PDFObjectParser.prototype.parseName"], + "fast-sync-load.mjs": [ + "PDFDocument.load", + "PDFParser.prototype.parseDocument", + "PDFParser.prototype.parseDocumentSection", + "PDFParser.prototype.parseIndirectObjects", + "PDFParser.prototype.parseIndirectObject", + "PDFObjectStreamParser.prototype.parseIntoContext", + "PDFWriter.prototype.serializeToBuffer", + ["PDFWriter.prototype.computeBufferSize", "parallelSave does not call it"], + ["PDFStreamWriter.prototype.computeBufferSize", "parallelSave does not call it"], + ], + "fast-indirect-objects.mjs": [ + "PDFContext.prototype.assign", + ["PDFContext.prototype.delete", UNCALLED], + "PDFContext.prototype.lookupMaybe", + "PDFContext.prototype.lookup", + ["PDFContext.prototype.getObjectRef", UNCALLED], + "PDFContext.prototype.enumerateIndirectObjects", + ], + "fast-pdfnumber-pool.mjs": ["PDFNumber.of"], +}; + +// The side's patched members against PATCHES, as lists of "shim: member". +// A shim that ran nothing is reported whole, so its members are left out. +function againstPatches(patched, unreached) { + const listed = new Map(); + for (const [shim, entries] of Object.entries(PATCHES)) { + for (const entry of entries) { + const [member, reason = null] = Array.isArray(entry) ? entry : [entry]; + listed.set(`${shim}: ${member}`, reason); + } + } + const seen = new Map(patched.map((p) => [`${shimName(p.shim)}: ${p.member}`, p])); + const quiet = (key) => unreached.includes(key.slice(0, key.indexOf(":"))); + return { + marked: [...listed.values()].filter((reason) => reason !== null).length, + missing: [...listed.keys()].filter((key) => !seen.has(key) && !quiet(key)), + unlisted: [...seen.keys()].filter((key) => !listed.has(key)), + notRun: [...seen.values()] + .map((p) => `${shimName(p.shim)}: ${p.member}`) + .filter((key) => !seen.get(key).ran && listed.get(key) === null && !quiet(key)), + nowRun: [...seen.keys()].filter((key) => seen.get(key).ran && listed.get(key)), + }; +} + // --------------------------------------------------------------------------- // The document @@ -407,9 +532,14 @@ try { const found = against(stock, shimmedSide); const unreached = shimmedSide.error ? [] : shims.filter((s) => !shimmedSide.reached.includes(s)).map(shimName); if (!shimmedSide.error && shimmedSide.streamCount === 0) unreached.push("parallel-deflate.mjs (no object stream was deflated on the thread pool)"); + const members = shimmedSide.error ? null : againstPatches(shimmedSide.patched, unreached); + const faults = members ? members.missing.length + members.unlisted.length + members.notRun.length + members.nowRun.length : 0; - if (found.length === 0 && unreached.length === 0) { - console.log(`${TOOL}: stock pdf-lib and ${shims.length} shims with parallelSave write the same ${stock.objects.size} objects; every shim ran`); + if (found.length === 0 && unreached.length === 0 && faults === 0) { + console.log( + `${TOOL}: stock pdf-lib and ${shims.length} shims with parallelSave write the same ${stock.objects.size} objects; ` + + `the ${shimmedSide.patched.length} members the shims patch are as listed, and all ran but the ${members.marked} marked` + ); } else { if (found.length) { console.log(`${TOOL}: the shimmed output differs from stock pdf-lib's in ${found.length} place(s):`); @@ -425,6 +555,18 @@ try { for (const name of unreached) console.log(` book/lib/${name}`); console.log(" The document no longer reaches the shim, or the book does not need it."); } + const report = (list, what, why) => { + if (list.length === 0) return; + console.log(`${TOOL}: ${list.length} ${what}:`); + for (const key of list) console.log(` book/lib/${key}`); + console.log(` ${why}`); + }; + if (members) { + report(members.missing, "member(s) PATCHES lists are not patched", "The shim no longer patches pdf-lib's own object, or PATCHES is out of date."); + report(members.unlisted, "patched member(s) are not in PATCHES", "Add each to PATCHES, marked with a reason if the document does not reach it."); + report(members.notRun, "patched member(s) never ran", "The document no longer reaches the function, or the book does not need it; PATCHES can mark it, with the reason."); + report(members.nowRun, "member(s) PATCHES marks as not reached ran", "Remove the mark from PATCHES."); + } process.exitCode = 1; } } finally { diff --git a/scripts/lib/pdf-shims-side.mjs b/scripts/lib/pdf-shims-side.mjs index 9d62ff01..b767f3d3 100644 --- a/scripts/lib/pdf-shims-side.mjs +++ b/scripts/lib/pdf-shims-side.mjs @@ -9,16 +9,21 @@ // paths, imported in the order given; `parallel` saves through parallelSave, as // the book does, and otherwise as stock pdf-lib's save would with the book's 500 // objects per stream; `coverage` records which of the shims ran a function -// during the load, change and save, their imports left out. The side writes the -// saved PDF to `out` and prints one JSON line, { streamCount, reached }. +// during the load, change and save, their imports left out, and which of the +// members of pdf-lib they put a function into, and whether each function ran. +// The side writes the saved PDF to `out` and prints one JSON line, +// { streamCount, reached, patched }. import { readFileSync, writeFileSync } from "node:fs"; import { Session } from "node:inspector/promises"; +import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; const BOOK_LIB = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "book", "lib"); const bookLib = (name) => pathToFileURL(path.join(BOOK_LIB, name)).href; +const PDF_LIB_CJS = /[\\/]node_modules[\\/]pdf-lib[\\/]cjs[\\/](.+)$/; +const require = createRequire(import.meta.url); // A fixed date, so the Info dictionary is the same on both sides. const WHEN = new Date(Date.UTC(2026, 0, 2, 3, 4, 5)); @@ -34,6 +39,101 @@ const OUTLINE = [ { title: "Second", destination: "sec-2", closed: false, children: [] }, ]; +// Where a shim can install a function: the exports of each pdf-lib module +// loaded, each function they export and that function's prototype. Each maps +// to the name a report gives a member of it. +function pdfLibHolders() { + const holders = new Map(); + const add = (obj, label) => { + if (!holders.has(obj)) holders.set(obj, label); + }; + for (const [file, mod] of Object.entries(require.cache)) { + const rel = file.match(PDF_LIB_CJS)?.[1].replaceAll("\\", "/"); + const exported = mod.exports; + if (!rel || exported === null || typeof exported !== "object") continue; + add(exported, (key) => `${key} in pdf-lib/cjs/${rel}`); + for (const key of Object.keys(exported)) { + const fn = exported[key]; + if (typeof fn !== "function") continue; + const name = fn.name || key; + add(fn, (member) => `${name}.${member}`); + if (fn.prototype) add(fn.prototype, (member) => `${name}.prototype.${member}`); + } + } + return holders; +} + +function snapshot(holders) { + const snap = new Map(); + for (const obj of holders.keys()) { + snap.set(obj, new Map(Reflect.ownKeys(obj).map((key) => [key, Object.getOwnPropertyDescriptor(obj, key)]))); + } + return snap; +} + +// The functions the holders hold now and did not hold in `before`, each with +// the members that hold it. +function installedSince(holders, before) { + const installed = new Map(); + for (const [obj, label] of holders) { + const was = before.get(obj); + for (const key of Reflect.ownKeys(obj)) { + const now = Object.getOwnPropertyDescriptor(obj, key); + for (const [part, suffix] of [["value", ""], ["get", " (getter)"], ["set", " (setter)"]]) { + const fn = now[part]; + if (typeof fn !== "function" || was.get(key)?.[part] === fn) continue; + installed.set(fn, [...(installed.get(fn) ?? []), `${label(String(key))}${suffix}`]); + } + } + } + return installed; +} + +// Each member that holds an installed function a shim defines, as +// { shim, member, ran }. The inspector gives each function's position; its +// coverage entry is the one at that position whose source is the function's +// own. +async function patchedMembers(coverage, installed, shimUrls) { + const scripts = new Map(coverage.map((s) => [s.scriptId, s])); + const fns = [...installed.keys()]; + globalThis.__installedByShims = fns; + const { result: list } = await session.post("Runtime.evaluate", { expression: "globalThis.__installedByShims" }); + const { result: items } = await session.post("Runtime.getProperties", { objectId: list.objectId, ownProperties: true }); + delete globalThis.__installedByShims; + await session.post("Debugger.enable"); + const sources = new Map(); + const patched = []; + for (const item of items.filter((p) => /^\d+$/.test(p.name))) { + const fn = fns[Number(item.name)]; + const members = installed.get(fn); + const { internalProperties = [] } = await session.post("Runtime.getProperties", { objectId: item.value.objectId }); + const at = internalProperties.find((p) => p.name === "[[FunctionLocation]]")?.value.value; + if (!at) throw new Error(`the inspector gives no position for ${members[0]}`); + const script = scripts.get(at.scriptId); + if (!script || !shimUrls.has(script.url)) continue; + if (!sources.has(at.scriptId)) { + sources.set(at.scriptId, (await session.post("Debugger.getScriptSource", { scriptId: at.scriptId })).scriptSource); + } + const source = sources.get(at.scriptId); + const offset = lineStart(source, at.lineNumber) + at.columnNumber; + const text = Function.prototype.toString.call(fn); + const entry = script.functions.find(({ ranges: [r] }) => + r.startOffset <= offset && offset < r.endOffset && source.slice(r.startOffset, r.endOffset) === text + ); + // A function never called may never have been compiled, and then the + // coverage has no entry for it at all. + const ran = entry !== undefined && entry.ranges[0].count > 0; + for (const member of members) patched.push({ shim: fileURLToPath(script.url), member, ran }); + } + return patched; +} + +function lineStart(source, line) { + let i = 0; + for (let n = 0; n < line; n++) i = source.indexOf("\n", i) + 1; + return i; +} + const job = JSON.parse(process.argv[2]); let session = null; @@ -45,8 +145,11 @@ if (job.coverage) { } const pdfLib = await import("pdf-lib"); +const holders = session ? pdfLibHolders() : null; +const before = holders && snapshot(holders); const shims = []; for (const file of job.shims) shims.push(await import(pathToFileURL(file).href)); +const installed = holders && installedSince(holders, before); const { measure } = await import(bookLib("measure-pass.mjs")); const { setMetadata } = await import(bookLib("postprocesser.mjs")); const { setOutline } = await import(bookLib("outline.mjs")); @@ -101,10 +204,12 @@ if (parallelSave) { writeFileSync(job.out, bytes); let reached = null; +let patched = null; if (session) { const { result } = await session.post("Profiler.takePreciseCoverage"); const ran = new Set(result.filter((s) => s.functions.some((f) => f.ranges[0].count > 0)).map((s) => s.url)); reached = job.shims.filter((file) => ran.has(pathToFileURL(file).href)); + patched = await patchedMembers(result, installed, new Set(job.shims.map((file) => pathToFileURL(file).href))); session.disconnect(); } -console.log(JSON.stringify({ streamCount, reached })); +console.log(JSON.stringify({ streamCount, reached, patched })); From 0407d5626f86073bca9ebe4fa0da9c366fa81803 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Mon, 28 Sep 2026 21:51:17 +0200 Subject: [PATCH 07/21] book: the shim gate reaches the members it marks --- WIP.md | 2 +- book/lib/fast-sync-load.mjs | 107 ++--------------- book/lib/pdf-lib-internals.mjs | 17 +-- book/render-book.mjs | 4 +- builder/PLAN-TOOLING-REVIEW.md | 47 ++++++++ docs/Documentation/Fixes-PDFLib.md | 5 +- docs/Documentation/Tools.md | 4 +- scripts/check_pdf_shims_equiv.mjs | 180 +++++++++++++++++------------ scripts/lib/pdf-shims-side.mjs | 118 +++++++++++++------ 9 files changed, 257 insertions(+), 227 deletions(-) diff --git a/WIP.md b/WIP.md index 2441811e..4d6c84a4 100644 --- a/WIP.md +++ b/WIP.md @@ -508,7 +508,7 @@ wrapper: | `test.bat` | `check_ci_workflows` | both CI workflows run every wrapper gate, with the same arguments and order, and build with `build.bat`'s flags | | `test.bat` | `check_lint` | Biome finds nothing in the tooling, warnings included, and checked at least one script | | `test.bat` | `test/search.test.mjs` | the search entries `builder/search.mjs` writes hold what they should, and the copies of the search client still agree. Run by `node --test`; the gate roster reads such a line as a gate, named by its path | -| `test.bat` | `check_pdf_shims_equiv` | the book's pdf-lib shims write what stock pdf-lib writes: one document written by the gate is saved both ways in child processes, the files are compared object by object with streams inflated and their cross-reference entries checked; every shim must run, and the members of pdf-lib the shims patch must be those its `PATCHES` lists, each run unless marked there as not reached | +| `test.bat` | `check_pdf_shims_equiv` | the book's pdf-lib shims write what stock pdf-lib writes: one document written by the gate and one built with `PDFDocument.create` are each saved both ways in child processes, and each pair of files is compared object by object with streams inflated and their cross-reference entries checked; every shim must run, and the members of pdf-lib the shims patch must be those its `PATCHES` lists, each run in one document or the other unless marked there as not reached | | `test.bat` | `check_publish_policy`, `check_gate_lists`, `check_page_baseline`, `check_book_coverage`, `check_axe_patch_equiv` | the gates on the gates | **A gate belongs in `test.bat` rather than `check.bat` if it would still mean diff --git a/book/lib/fast-sync-load.mjs b/book/lib/fast-sync-load.mjs index eb4bc70b..3059b227 100644 --- a/book/lib/fast-sync-load.mjs +++ b/book/lib/fast-sync-load.mjs @@ -12,8 +12,8 @@ // still pays the state-machine dispatch + Promise allocation for a // single fall-through `case 0`. // -// Eight methods participate in this pattern; this shim replaces all -// of them with synchronous (or, where a legitimate await remains, +// This shim replaces the seven methods the book calls that follow +// this pattern with synchronous (or, where a legitimate await remains, // awaiterless `async`) twins: // // Load side (parser): @@ -29,8 +29,10 @@ // (kept `async` because the inherited path awaits the // ParallelStreamWriter override of computeBufferSize, which // does genuine Promise.all-driven libuv-pool concurrency) -// PDFWriter.prototype.computeBufferSize -// PDFStreamWriter.prototype.computeBufferSize +// +// The writers' own computeBufferSize methods follow it too, and stay +// as pdf-lib has them: the book calls neither, since +// ParallelStreamWriter overrides the stream writer's. // // The load-side patches have to land together: each method awaits // the next one down, so desugaring any one in isolation still leaves @@ -51,11 +53,10 @@ import { PDFParser, PDFObjectStreamParser, PDFXRefStreamParser, - PDFRawStream, PDFRef, PDFName, PDFNumber, PDFStream, PDFInvalidObject, - PDFDocument, PDFWriter, PDFStreamWriter, - PDFHeader, PDFTrailer, PDFTrailerDict, PDFCrossRefSection, PDFCrossRefStream, PDFObjectStream, + PDFRawStream, PDFRef, PDFName, + PDFDocument, PDFWriter, CharCodes, ReparseError, StalledParserError, IsDigit, Keywords, - toUint8Array, copyStringIntoBuffer, last, + toUint8Array, copyStringIntoBuffer, } from './pdf-lib-internals.mjs'; // Pool-deduped PDFName instances are reference-stable for the whole @@ -65,7 +66,6 @@ const TypeName = PDFName.of('Type'); const ObjStmName = PDFName.of('ObjStm'); const XRefName = PDFName.of('XRef'); const RefZero = PDFRef.of(0); -const SizeName = PDFName.of('Size'); if (!PDFParser.prototype.__fastSyncLoadInstalled) { @@ -234,94 +234,5 @@ if (!PDFParser.prototype.__fastSyncLoadInstalled) { return buffer; }; - // PDFWriter.computeBufferSize -- the basic (non-stream) writer's - // sizing pass. Not on our pipeline's hot path (we route through - // PDFStreamWriter via ParallelStreamWriter, both of which override - // this method) but patched for consistency: the only async thing - // upstream is the conditional waitForTick yield in its loop. - PDFWriter.prototype.computeBufferSize = function computeBufferSizeBaseSync() { - const header = PDFHeader.forVersion(1, 7); - let size = header.sizeInBytes() + 2; - const xref = PDFCrossRefSection.create(); - const indirectObjects = this.context.enumerateIndirectObjects(); - for (let idx = 0, len = indirectObjects.length; idx < len; idx++) { - const indirectObject = indirectObjects[idx]; - const ref = indirectObject[0]; - xref.addEntry(ref, size); - size += this.computeIndirectObjectSize(indirectObject); - } - const xrefOffset = size; - size += xref.sizeInBytes() + 1; - const trailerDict = PDFTrailerDict.of(this.createTrailerDict()); - size += trailerDict.sizeInBytes() + 2; - const trailer = PDFTrailer.forLastCrossRefSectionOffset(xrefOffset); - size += trailer.sizeInBytes(); - return { size, header, indirectObjects, xref, trailerDict, trailer }; - }; - - // PDFStreamWriter.computeBufferSize -- the upstream stream writer's - // sizing pass with two waitForTick gates (one per loop). Not on our - // pipeline's hot path (ParallelStreamWriter overrides this with its - // own three-phase parallel-deflate version) but patched for - // consistency. Logic mirrors the upstream method body exactly. - PDFStreamWriter.prototype.computeBufferSize = function computeBufferSizeStreamSync() { - let objectNumber = this.context.largestObjectNumber + 1; - const header = PDFHeader.forVersion(1, 7); - let size = header.sizeInBytes() + 2; - const xrefStream = PDFCrossRefStream.create(this.createTrailerDict(), this.encodeStreams); - - const uncompressedObjects = []; - const compressedObjects = []; - const objectStreamRefs = []; - - const indirectObjects = this.context.enumerateIndirectObjects(); - for (let idx = 0, len = indirectObjects.length; idx < len; idx++) { - const indirectObject = indirectObjects[idx]; - const ref = indirectObject[0]; - const object = indirectObject[1]; - const shouldNotCompress = - ref === this.context.trailerInfo.Encrypt || - object instanceof PDFStream || - object instanceof PDFInvalidObject || - ref.generationNumber !== 0; - if (shouldNotCompress) { - uncompressedObjects.push(indirectObject); - xrefStream.addUncompressedEntry(ref, size); - size += this.computeIndirectObjectSize(indirectObject); - } else { - let chunk = last(compressedObjects); - let objectStreamRef = last(objectStreamRefs); - if (!chunk || chunk.length % this.objectsPerStream === 0) { - chunk = []; - compressedObjects.push(chunk); - objectStreamRef = PDFRef.of(objectNumber++); - objectStreamRefs.push(objectStreamRef); - } - xrefStream.addCompressedEntry(ref, objectStreamRef, chunk.length); - chunk.push(indirectObject); - } - } - - for (let idx = 0, len = compressedObjects.length; idx < len; idx++) { - const chunk = compressedObjects[idx]; - const ref = objectStreamRefs[idx]; - const objectStream = PDFObjectStream.withContextAndObjects(this.context, chunk, this.encodeStreams); - xrefStream.addUncompressedEntry(ref, size); - size += this.computeIndirectObjectSize([ref, objectStream]); - uncompressedObjects.push([ref, objectStream]); - } - - const xrefStreamRef = PDFRef.of(objectNumber++); - xrefStream.dict.set(SizeName, PDFNumber.of(objectNumber)); - xrefStream.addUncompressedEntry(xrefStreamRef, size); - const xrefOffset = size; - size += this.computeIndirectObjectSize([xrefStreamRef, xrefStream]); - uncompressedObjects.push([xrefStreamRef, xrefStream]); - - const trailer = PDFTrailer.forLastCrossRefSectionOffset(xrefOffset); - size += trailer.sizeInBytes(); - return { size, header, indirectObjects: uncompressedObjects, trailer }; - }; - PDFParser.prototype.__fastSyncLoadInstalled = true; } diff --git a/book/lib/pdf-lib-internals.mjs b/book/lib/pdf-lib-internals.mjs index 56bcf161..ba49157d 100644 --- a/book/lib/pdf-lib-internals.mjs +++ b/book/lib/pdf-lib-internals.mjs @@ -16,21 +16,13 @@ const require = createRequire(import.meta.url); export const PDFDocument = require('pdf-lib/cjs/api/PDFDocument.js').default; export const PDFContext = require('pdf-lib/cjs/core/PDFContext.js').default; -export const PDFCrossRefSection = require('pdf-lib/cjs/core/document/PDFCrossRefSection.js').default; -export const PDFHeader = require('pdf-lib/cjs/core/document/PDFHeader.js').default; -export const PDFTrailer = require('pdf-lib/cjs/core/document/PDFTrailer.js').default; -export const PDFTrailerDict = require('pdf-lib/cjs/core/document/PDFTrailerDict.js').default; - export const PDFArray = require('pdf-lib/cjs/core/objects/PDFArray.js').default; export const PDFBool = require('pdf-lib/cjs/core/objects/PDFBool.js').default; export const PDFDict = require('pdf-lib/cjs/core/objects/PDFDict.js').default; -export const PDFInvalidObject = require('pdf-lib/cjs/core/objects/PDFInvalidObject.js').default; export const PDFName = require('pdf-lib/cjs/core/objects/PDFName.js').default; export const PDFNull = require('pdf-lib/cjs/core/objects/PDFNull.js').default; -export const PDFNumber = require('pdf-lib/cjs/core/objects/PDFNumber.js').default; export const PDFRawStream = require('pdf-lib/cjs/core/objects/PDFRawStream.js').default; export const PDFRef = require('pdf-lib/cjs/core/objects/PDFRef.js').default; -export const PDFStream = require('pdf-lib/cjs/core/objects/PDFStream.js').default; export const BaseParser = require('pdf-lib/cjs/core/parser/BaseParser.js').default; export const PDFObjectParser = require('pdf-lib/cjs/core/parser/PDFObjectParser.js').default; @@ -39,9 +31,7 @@ export const PDFParser = require('pdf-lib/cjs/core/parser/PDFParser. export const PDFXRefStreamParser = require('pdf-lib/cjs/core/parser/PDFXRefStreamParser.js').default; export const PDFCatalog = require('pdf-lib/cjs/core/structures/PDFCatalog.js').default; -export const PDFCrossRefStream = require('pdf-lib/cjs/core/structures/PDFCrossRefStream.js').default; -export const PDFObjectStream = require('pdf-lib/cjs/core/structures/PDFObjectStream.js').default; -export const PDFPageLeaf = require('pdf-lib/cjs/core/structures/PDFPageLeaf.js').default; +export const PDFPageLeaf = require('pdf-lib/cjs/core/structures/PDFPageLeaf.js').default; export const PDFPageTree = require('pdf-lib/cjs/core/structures/PDFPageTree.js').default; export const CharCodes = require('pdf-lib/cjs/core/syntax/CharCodes.js').default; @@ -50,8 +40,7 @@ export const { Keywords } = require('pdf-lib/cjs/core/syntax/Keywords.j export const { IsDigit, IsNumeric } = require('pdf-lib/cjs/core/syntax/Numeric.js'); export const { IsWhitespace } = require('pdf-lib/cjs/core/syntax/Whitespace.js'); -export const PDFStreamWriter = require('pdf-lib/cjs/core/writers/PDFStreamWriter.js').default; -export const PDFWriter = require('pdf-lib/cjs/core/writers/PDFWriter.js').default; +export const PDFWriter = require('pdf-lib/cjs/core/writers/PDFWriter.js').default; export const { PDFObjectParsingError, @@ -63,4 +52,4 @@ export const { export const numbers = require('pdf-lib/cjs/utils/numbers.js'); export const utilsBarrel = require('pdf-lib/cjs/utils/index.js'); export const topBarrel = require('pdf-lib/cjs/index.js'); -export const { copyStringIntoBuffer, last, toUint8Array } = utilsBarrel; +export const { copyStringIntoBuffer, toUint8Array } = utilsBarrel; diff --git a/book/render-book.mjs b/book/render-book.mjs index 585248f1..01adeec9 100644 --- a/book/render-book.mjs +++ b/book/render-book.mjs @@ -121,9 +121,7 @@ import { parseCli, withUsageError } from '../lib/cli.mjs'; // shouldWaitForTick / waitForTick machinery out of both pdf-lib's // load path (PDFDocument.load + five PDFParser / // PDFObjectStreamParser methods underneath it) and its save path -// (PDFWriter.serializeToBuffer + computeBufferSize, plus the -// unreachable PDFStreamWriter.computeBufferSize patched for -// consistency). Each upstream method is wrapped in __awaiter so +// (PDFWriter.serializeToBuffer). Each upstream method is wrapped in __awaiter so // on browsers it can yield to the event loop every objectsPerTick // objects; in Node the gate never fires but every indirect object // still paid for the generator state machine + Promise diff --git a/builder/PLAN-TOOLING-REVIEW.md b/builder/PLAN-TOOLING-REVIEW.md index f4a1c37b..9e50615d 100644 --- a/builder/PLAN-TOOLING-REVIEW.md +++ b/builder/PLAN-TOOLING-REVIEW.md @@ -2781,6 +2781,46 @@ two `context` setters stay marked: each is empty by design (`fast-dict-onebuf.mj the kit's `c43-fault.mjs`, fails it by difference; C67a's five faults still fail. The book renders identically but for its dates. +**Landed.** `fast-sync-load.mjs` loses both `computeBufferSize` overrides, the `Size` name only +they used and eleven imports; `pdf-lib-internals.mjs` loses the eleven exports no shim imports +any more (the four `core/document` classes, `PDFInvalidObject`, `PDFNumber`, `PDFStream`, +`PDFCrossRefStream`, `PDFObjectStream`, `PDFStreamWriter` and `last`), keeping 33. The shim's +header, which said eight methods and listed nine, `render-book.mjs`'s summary and +Fixes-PDFLib.md now name the seven it replaces, and say the writers' own `computeBufferSize` +stay as pdf-lib has them. + +The side keeps its job's shape: a null `fixture` builds the document with `PDFDocument.create` +(both dates set to the fixed one, two pages added, text drawn on one, a third inserted at 0), +and sizes nothing, so the onebuf shims keep their initial capacities (2.4 million dictionary +slots, 800,000 array slots). Then it calls `PDFCatalog.fromMapWithContext` on a copy of the +catalog's map and registers the result (see Where the plan was wrong). The loaded document's +change ends with a registered `/Found` dictionary holding what `values`, `entries`, `asMap`, +`has` (one of them on a null value), `indexOf`, `asArray` and `getObjectRef` (one of them for a +generation-1 object) return, a null standing for an index or reference not found, and +`misc.toString()` as a hex string, which calls a dictionary's, an array's and a reference's +`toString`; then both clones are registered, and each clone and its original edited after the +copy, which also calls an array's `set`. The fixture's object 0 is removed by the parse on both +sides. The gate runs the four sides at once, compares each pair, runs the diagnosis for a +document that differs, and counts a member as run if it ran in either shimmed side. Only the +two `context` setters stay marked. + +Now: `stock pdf-lib and 12 shims with parallelSave write the same 25 objects for a loaded +document and the same 11 for a created one; the 72 members the shims patch are as listed, and +all ran but the 2 marked`, about 0.5 s. The kit's `c67b-faults.mjs` breaks each of the 18 +newly reached members, and each fault exits 1 by a difference in the document that reaches it, +with the diagnosis naming that member's shim alone; the `delete` fault keeps object 0, which +shifts every entry of the object stream. C67a's five faults still exit 1 (`c67a-faults.mjs`, +its `runs` case now setting a dictionary's `context`, since `has` is no longer marked). The +gate's header and help, Tools.md's section and WIP.md's table describe the created document. +The book, rendered from one `_site-pdf` through HEAD's `book/` and the working one: 2,299 pages +and 2,466 outline entries each, identical but for `/CreationDate` and `/ModDate`, whose object +stream deflates a byte longer (29,132,071 and 29,132,072 bytes); 85-103 s a render, `process: +1.1s`-`1.2s`. HEAD's first render, which ran beside `test.bat`, differed from its second in 34 +objects, all Chromium's structure-node ids shifted by one (`/ID (node00151028)` against +`node00151029`): two renders of one tree are not always byte-identical, so a render pair that +differs outside its dates needs a repeat render before the change is blamed. Lint `Checked 169 files`; regex safety unchanged. `compare_trees`: Fixes-PDFLib and +Tools online and offline, the search data and `book.html`. CI waits for the owner's push. + ### C68 — `book: the two onebuf shims share their range machinery` **A9-3 (R2).** `_registerContext` and `_appendArray` are identical apart from names in @@ -3258,6 +3298,13 @@ text, gains a Landed note, and the correction is listed here, as in the last rev files are compared as written, with streams inflated, rather than as pdf-lib parses them: its parser finds objects without the cross-reference offsets and reads `0.50` as `0.5`, so a wrong `sizeInBytes` or a `0.50` would pass a comparison of parsed objects. See C66's Landed note. +- **C67b: `PDFDocument.create` reaches three of the four factories.** The entry has the + created document reach all four page-tree and catalog factories. pdf-lib calls + `PDFCatalog.fromMapWithContext` only from the stock `parseDict` + (`core/parser/PDFObjectParser.js:159`), which `fast-dict-onebuf` replaces, and the shim's + `PDFCatalog.withContextAndPages` builds its catalog without it (`fast-dict-onebuf.mjs:455-461`), + so the created side calls it directly, on a copy of the created catalog's map. See C67b's + Landed note. ## Found while implementing diff --git a/docs/Documentation/Fixes-PDFLib.md b/docs/Documentation/Fixes-PDFLib.md index c287cad8..ac2f782d 100644 --- a/docs/Documentation/Fixes-PDFLib.md +++ b/docs/Documentation/Fixes-PDFLib.md @@ -94,7 +94,7 @@ Both caches converge on the same `PDFName` instance per logical name. Direct `PD **Problem.** pdf-lib's parser and writer methods are compiled from TypeScript `async function`s to tslib's `__awaiter` + `__generator` state machines. On browsers, these yield periodically via `objectsPerTick` / `waitForTick()` to keep the page responsive. In Node with `objectsPerTick: Infinity` (the `parseSpeed: Fastest` configuration), the yield gate never fires --- the entire generator runs in one tick --- yet every indirect object (~50 000 on the book) still paid the state-machine dispatch overhead for a single `case 0` fall-through. -**Fix.** Eight methods are replaced with plain synchronous equivalents. +**Fix.** Seven methods are replaced with plain synchronous equivalents. Load side: - `PDFParser.parseDocument`, `parseDocumentSection`, `parseIndirectObjects`, `parseIndirectObject` @@ -103,7 +103,8 @@ Load side: Save side: - `PDFWriter.serializeToBuffer` (kept `async` because `ParallelStreamWriter.computeBufferSize` is genuinely async via `Promise.all` over libuv) -- `PDFWriter.computeBufferSize` and `PDFStreamWriter.computeBufferSize` + +The writers' own `computeBufferSize` methods are left as pdf-lib has them. The book calls neither, since `parallelSave`'s `ParallelStreamWriter` overrides the stream writer's. `PDFDocument.load` returns a plain `PDFDocument` value rather than a Promise. `await PDFDocument.load(...)` at existing call sites still works, because `await` on a non-thenable resolves immediately to the value. diff --git a/docs/Documentation/Tools.md b/docs/Documentation/Tools.md index 9dd1c9c1..9f3d6ebf 100644 --- a/docs/Documentation/Tools.md +++ b/docs/Documentation/Tools.md @@ -563,9 +563,9 @@ Exits 1 on any failed probe or case, 2 if it cannot run. node scripts/check_pdf_shims_equiv.mjs -Verifies that the book's [pdf-lib patches](Fixes/PDFLib) write what pdf-lib itself writes. `book/render-book.mjs` loads Chromium's PDF, adds the metadata and the outline, and saves it, with a dozen shims replacing pdf-lib's parser, object classes and writer, and `parallelSave` in place of `save()`. This loads, changes and saves one document twice, with stock pdf-lib and with every shim `render-book.mjs` imports, each side in a process of its own, and compares the two files object by object with every stream inflated, since `node:zlib` and pdf-lib's own deflate can compress the same bytes differently. It also checks each file's cross-reference entries against the objects they locate, since pdf-lib's own parser finds objects without them. No built tree, no browser; under a second. +Verifies that the book's [pdf-lib patches](Fixes/PDFLib) write what pdf-lib itself writes. `book/render-book.mjs` loads Chromium's PDF, adds the metadata and the outline, and saves it, with a dozen shims replacing pdf-lib's parser, object classes and writer, and `parallelSave` in place of `save()`. This loads, changes and saves one document twice, with stock pdf-lib and with every shim `render-book.mjs` imports, each side in a process of its own, does the same for a document built with `PDFDocument.create`, and compares each pair of files object by object with every stream inflated, since `node:zlib` and pdf-lib's own deflate can compress the same bytes differently. It also checks each file's cross-reference entries against the objects they locate, since pdf-lib's own parser finds objects without them. No built tree, no browser; under a second. -The document is written by the gate, without pdf-lib, so the forms the shims' parsers branch on are known to be in it: names with `#` escapes, numbers in every lexical form, a classic cross-reference table, and an incremental update with an object stream and a cross-reference stream. The change mirrors `render-book.mjs`'s and adds what reaches the rest of the shims: text drawn on a page, a page inserted and one removed, and objects parsed early and edited late. Each member of pdf-lib that a shim puts a function into is checked against `PATCHES`, a list in the gate. A listed member that is not patched fails it, and so does a patched member that is not listed: a patch applied to a copy of a class leaves pdf-lib's own member as it was. Each listed member's function must run, unless the list marks the member as one the document does not reach and says why, and a marked member that runs fails the gate as well, so the marks stay true. A shim none of whose functions runs is reported whole, since the document then no longer tests it, or the book does not need it. On a difference, the shimmed side runs again with each shim alone and with each left out, and the report names the shims that make it. +The document is written by the gate, without pdf-lib, so the forms the shims' parsers branch on are known to be in it: names with `#` escapes, numbers in every lexical form, a classic cross-reference table, and an incremental update with an object stream and a cross-reference stream. The change mirrors `render-book.mjs`'s and adds what reaches the rest of the shims: text drawn on a page, a page inserted and one removed, objects parsed early and edited late, and a call of each patched method the book does not make, its result written into the document so that the comparison checks it. The created document reaches the factories that build a page tree and a catalog. Each member of pdf-lib that a shim puts a function into is checked against `PATCHES`, a list in the gate. A listed member that is not patched fails it, and so does a patched member that is not listed: a patch applied to a copy of a class leaves pdf-lib's own member as it was. Each listed member's function must run, unless the list marks the member as one neither document reaches and says why, and a marked member that runs fails the gate as well, so the marks stay true. A shim none of whose functions runs is reported whole, since the documents then no longer test it, or the book does not need it. On a difference, that document's shimmed side runs again with each shim alone and with each left out, and the report names the shims that make it. Exits 1 on a difference, a shim or listed member that did not run, or a patched member that is not as listed, 2 if it cannot run. diff --git a/scripts/check_pdf_shims_equiv.mjs b/scripts/check_pdf_shims_equiv.mjs index d5cb2355..a2df9078 100644 --- a/scripts/check_pdf_shims_equiv.mjs +++ b/scripts/check_pdf_shims_equiv.mjs @@ -6,29 +6,32 @@ // place of save(). Nothing else compares what they write with what pdf-lib // itself writes. This does: one document is loaded, changed and saved by stock // pdf-lib and by pdf-lib with every shim render-book.mjs imports, each in a -// process of its own, and the two files are compared object by object with -// every stream inflated, since node:zlib and pdf-lib's deflate may compress the -// same bytes differently. Each file's cross-reference entries are checked -// against the objects they locate as well, because pdf-lib's own parser finds -// objects without them, so a wrong size computed for an object is invisible to -// it. +// process of its own, and so is one built with PDFDocument.create. Each pair +// of files is compared object by object with every stream inflated, since +// node:zlib and pdf-lib's deflate may compress the same bytes differently. +// Each file's cross-reference entries are checked against the objects they +// locate as well, because pdf-lib's own parser finds objects without them, so +// a wrong size computed for an object is invisible to it. // -// The document is written here, without pdf-lib, so the forms the shims' -// parsers branch on are known to be in it: names with #xx escapes, numbers in -// every lexical form, a classic cross-reference table followed by an -// incremental update with an object stream and a cross-reference stream. The -// change (scripts/lib/pdf-shims-side.mjs) mirrors render-book.mjs and adds what -// reaches the rest of the shims: text drawn on a page, a page inserted and one -// removed, and dictionaries parsed early and edited late. +// The loaded document is written here, without pdf-lib, so the forms the +// shims' parsers branch on are known to be in it: names with #xx escapes, +// numbers in every lexical form, a classic cross-reference table followed by +// an incremental update with an object stream and a cross-reference stream. +// The change (scripts/lib/pdf-shims-side.mjs) mirrors render-book.mjs and adds +// what reaches the rest of the shims: text drawn on a page, a page inserted +// and one removed, dictionaries parsed early and edited late, and a call of +// each patched method the book does not make, its result written into the +// document. The created document reaches the page-tree and catalog factories. // -// A shim none of whose functions runs is reported too: it means the document -// no longer tests it, or that the book never needed it. So is each member of +// A shim none of whose functions runs is reported too: it means the documents +// no longer test it, or that the book never needed it. So is each member of // pdf-lib the shims put a function into, against PATCHES below: a member // listed there and not patched, one patched and not listed, one whose function -// never ran, and one marked there as not reached that ran. +// ran in neither document, and one marked there as not reached that ran. // -// On a difference, the shimmed side is run again with each shim alone and with -// each left out (parallelSave counts as one), to name the shims that make it. +// On a difference, that document's shimmed side is run again with each shim +// alone and with each left out (parallelSave counts as one), to name the shims +// that make it. // // node scripts/check_pdf_shims_equiv.mjs // @@ -53,10 +56,11 @@ if (cli.stopped === "help") { printHelpAndExit(`usage: node scripts/check_pdf_shims_equiv.mjs Loads, changes and saves one PDF with stock pdf-lib and with the book's pdf-lib -shims, compares the two files object by object, streams inflated, and checks -the members of pdf-lib the shims patch against the list in this file. Exit 0 -the same, 1 a difference, a shim or patched member the document no longer -reaches, or a patched member not as listed, 2 the check itself failed.`); +shims, and creates and saves another, compares each pair of files object by +object, streams inflated, and checks the members of pdf-lib the shims patch +against the list in this file. Exit 0 the same, 1 a difference, a shim or +patched member the documents no longer reach, or a patched member not as +listed, 2 the check itself failed.`); } const TOOL = "check_pdf_shims_equiv"; @@ -84,14 +88,12 @@ const shimName = (file) => path.basename(file); // names it. The side finds them by comparing pdf-lib's modules, their exported // classes and those classes' prototypes before and after the shims load, so a // patch applied to anything else, such as a copy of a class, is missing here. -// A member given as [member, reason] is one the document does not reach. -const UNCALLED = "the load, the change and the save do not call it"; -const CREATE_ONLY = - "pdf-lib calls it only from PDFDocument.create, and the onebuf shims allow the side one context, the loaded document's"; +// A member given as [member, reason] is one neither document reaches. +const SETTER = "only pdf-lib's constructors set it, and the shim builds its objects without them; it does nothing"; const PATCHES = { "fast-refs-class.mjs": [ "PDFRef.of", - ["PDFRef.prototype.toString", UNCALLED], + "PDFRef.prototype.toString", "PDFRef.prototype.sizeInBytes", "PDFRef.prototype.copyBytesInto", ], @@ -112,23 +114,23 @@ const PATCHES = { "PDFDict.withContext", "PDFDict.fromMapWithContext", "PDFDict.prototype.keys", - ["PDFDict.prototype.values", UNCALLED], - ["PDFDict.prototype.entries", UNCALLED], + "PDFDict.prototype.values", + "PDFDict.prototype.entries", "PDFDict.prototype.set", "PDFDict.prototype.get", - ["PDFDict.prototype.has", UNCALLED], + "PDFDict.prototype.has", "PDFDict.prototype.delete", - ["PDFDict.prototype.asMap", UNCALLED], - ["PDFDict.prototype.clone", UNCALLED], - ["PDFDict.prototype.toString", UNCALLED], + "PDFDict.prototype.asMap", + "PDFDict.prototype.clone", + "PDFDict.prototype.toString", "PDFDict.prototype.sizeInBytes", "PDFDict.prototype.copyBytesInto", "PDFDict.prototype.context (getter)", - ["PDFDict.prototype.context (setter)", UNCALLED], - ["PDFCatalog.withContextAndPages", CREATE_ONLY], - ["PDFCatalog.fromMapWithContext", "pdf-lib calls it only from the parseDict this shim replaces"], - ["PDFPageTree.withContext", CREATE_ONLY], - ["PDFPageTree.fromMapWithContext", "only this shim's PDFPageTree.withContext calls it"], + ["PDFDict.prototype.context (setter)", SETTER], + "PDFCatalog.withContextAndPages", + "PDFCatalog.fromMapWithContext", + "PDFPageTree.withContext", + "PDFPageTree.fromMapWithContext", "PDFPageLeaf.withContextAndParent", "PDFPageLeaf.fromMapWithContext", "PDFPageLeaf.prototype.normalized (getter)", @@ -142,17 +144,17 @@ const PATCHES = { "PDFArray.prototype.size", "PDFArray.prototype.push", "PDFArray.prototype.insert", - ["PDFArray.prototype.indexOf", UNCALLED], + "PDFArray.prototype.indexOf", "PDFArray.prototype.remove", - ["PDFArray.prototype.set", UNCALLED], + "PDFArray.prototype.set", "PDFArray.prototype.get", - ["PDFArray.prototype.asArray", UNCALLED], - ["PDFArray.prototype.clone", UNCALLED], - ["PDFArray.prototype.toString", UNCALLED], + "PDFArray.prototype.asArray", + "PDFArray.prototype.clone", + "PDFArray.prototype.toString", "PDFArray.prototype.sizeInBytes", "PDFArray.prototype.copyBytesInto", "PDFArray.prototype.context (getter)", - ["PDFArray.prototype.context (setter)", UNCALLED], + ["PDFArray.prototype.context (setter)", SETTER], ], "fast-parse-object.mjs": ["PDFObjectParser.prototype.parseObject"], "fast-parse-name.mjs": ["PDFObjectParser.prototype.parseName"], @@ -164,15 +166,13 @@ const PATCHES = { "PDFParser.prototype.parseIndirectObject", "PDFObjectStreamParser.prototype.parseIntoContext", "PDFWriter.prototype.serializeToBuffer", - ["PDFWriter.prototype.computeBufferSize", "parallelSave does not call it"], - ["PDFStreamWriter.prototype.computeBufferSize", "parallelSave does not call it"], ], "fast-indirect-objects.mjs": [ "PDFContext.prototype.assign", - ["PDFContext.prototype.delete", UNCALLED], + "PDFContext.prototype.delete", "PDFContext.prototype.lookupMaybe", "PDFContext.prototype.lookup", - ["PDFContext.prototype.getObjectRef", UNCALLED], + "PDFContext.prototype.getObjectRef", "PDFContext.prototype.enumerateIndirectObjects", ], "fast-pdfnumber-pool.mjs": ["PDFNumber.of"], @@ -205,8 +205,9 @@ function againstPatches(patched, unreached) { // The document // Written by hand, not by pdf-lib. A classic section as Chromium writes one, -// then an incremental update whose object stream redefines page 4 and adds the -// dictionary and the array that hold the lexical forms. +// but for an object 0, which the parse removes; then an incremental update +// whose object stream redefines page 4 and adds the dictionary and the array +// that hold the lexical forms. function buildFixture() { const parts = []; let size = 0; @@ -229,6 +230,7 @@ function buildFixture() { const at = (n) => String(n).padStart(10, "0"); put("%PDF-1.4\n%\xE2\xE3\xCF\xD3\n"); + obj(0, 0, "<< /Zero true >>"); obj(1, 0, "<< /Type /Catalog /Pages 2 0 R /Dests 8 0 R /Misc 7 0 R /Extra 9 1 R /PageMode /UseOutlines >>"); obj(2, 0, "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>"); obj(3, 0, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 6 0 R >>"); @@ -500,7 +502,7 @@ async function pool(jobs, width) { } // Each shim alone, and each left out, as { subject, alone, differs }. -async function diagnose(dir, fixture, shims, stock) { +async function diagnose(dir, document, shims, stock) { const variants = [ ...shims.map((s) => ({ subject: shimName(s), alone: true, shims: [s], parallel: false })), { subject: "parallelSave", alone: true, shims: [], parallel: true }, @@ -508,12 +510,23 @@ async function diagnose(dir, fixture, shims, stock) { { subject: "parallelSave", alone: false, shims, parallel: false }, ]; const sides = await pool( - variants.map((v, i) => () => runSide(dir, `variant-${i}`, fixture, v)), + variants.map((v, i) => () => runSide(dir, `${document.name}-variant-${i}`, document.fixture, v)), Math.min(4, availableParallelism()) ); return variants.map((v, i) => ({ ...v, differs: against(stock, sides[i]).length > 0 })); } +// The two shimmed sides' patched members as one list: a member ran if it ran +// in either. +function mergePatched(sides) { + const merged = new Map(); + for (const p of sides.flatMap((side) => side.patched)) { + const key = `${p.shim}: ${p.member}`; + merged.set(key, { ...p, ran: p.ran || (merged.get(key)?.ran ?? false) }); + } + return [...merged.values()]; +} + // --------------------------------------------------------------------------- const shims = shimsOf(readFileSync(RENDER_BOOK, "utf8")); @@ -521,39 +534,58 @@ const dir = mkdtempSync(path.join(tmpdir(), "pdf-shims-")); try { const fixture = path.join(dir, "fixture.pdf"); writeFileSync(fixture, buildFixture()); - const [stockSide, shimmedSide] = await Promise.all([ - runSide(dir, "stock", fixture, { shims: [], parallel: false }), - runSide(dir, "shimmed", fixture, { shims, parallel: true, coverage: true }), - ]); - if (stockSide.error) throw new Error(`the stock side ${stockSide.error}`); - const stock = readSaved(stockSide.bytes); - if (stock.problems.length) throw new Error(`stock pdf-lib's output fails the reader here:\n ${stock.problems.join("\n ")}`); - - const found = against(stock, shimmedSide); - const unreached = shimmedSide.error ? [] : shims.filter((s) => !shimmedSide.reached.includes(s)).map(shimName); - if (!shimmedSide.error && shimmedSide.streamCount === 0) unreached.push("parallel-deflate.mjs (no object stream was deflated on the thread pool)"); - const members = shimmedSide.error ? null : againstPatches(shimmedSide.patched, unreached); + const documents = [ + { name: "loaded", fixture }, + { name: "created", fixture: null }, + ]; + await Promise.all( + documents.map(async (document) => { + [document.stockSide, document.shimmedSide] = await Promise.all([ + runSide(dir, `${document.name}-stock`, document.fixture, { shims: [], parallel: false }), + runSide(dir, `${document.name}-shimmed`, document.fixture, { shims, parallel: true, coverage: true }), + ]); + }) + ); + for (const document of documents) { + const { name, stockSide, shimmedSide } = document; + if (stockSide.error) throw new Error(`the stock side for the ${name} document ${stockSide.error}`); + document.stock = readSaved(stockSide.bytes); + if (document.stock.problems.length) { + throw new Error(`stock pdf-lib's output for the ${name} document fails the reader here:\n ${document.stock.problems.join("\n ")}`); + } + document.found = against(document.stock, shimmedSide); + } + + const shimmedSides = documents.map((d) => d.shimmedSide); + const ok = shimmedSides.every((side) => !side.error); + const unreached = ok ? shims.filter((s) => !shimmedSides.some((side) => side.reached.includes(s))).map(shimName) : []; + if (ok && shimmedSides.some((side) => side.streamCount === 0)) unreached.push("parallel-deflate.mjs (no object stream was deflated on the thread pool)"); + const patched = ok ? mergePatched(shimmedSides) : null; + const members = ok ? againstPatches(patched, unreached) : null; const faults = members ? members.missing.length + members.unlisted.length + members.notRun.length + members.nowRun.length : 0; + const [loaded, created] = documents; - if (found.length === 0 && unreached.length === 0 && faults === 0) { + if (documents.every((d) => d.found.length === 0) && unreached.length === 0 && faults === 0) { console.log( - `${TOOL}: stock pdf-lib and ${shims.length} shims with parallelSave write the same ${stock.objects.size} objects; ` + - `the ${shimmedSide.patched.length} members the shims patch are as listed, and all ran but the ${members.marked} marked` + `${TOOL}: stock pdf-lib and ${shims.length} shims with parallelSave write the same ${loaded.stock.objects.size} objects ` + + `for a loaded document and the same ${created.stock.objects.size} for a created one; ` + + `the ${patched.length} members the shims patch are as listed, and all ran but the ${members.marked} marked` ); } else { - if (found.length) { - console.log(`${TOOL}: the shimmed output differs from stock pdf-lib's in ${found.length} place(s):`); + for (const document of documents.filter((d) => d.found.length)) { + const { name, found } = document; + console.log(`${TOOL}: the shimmed output for the ${name} document differs from stock pdf-lib's in ${found.length} place(s):`); for (const line of found.slice(0, 5)) console.log(` ${line.replaceAll("\n", "\n ")}`); if (found.length > 5) console.log(` ... and ${found.length - 5} more`); - const verdicts = await diagnose(dir, fixture, shims, stock); + const verdicts = await diagnose(dir, document, shims, document.stock); const list = (xs) => (xs.length ? xs.map((v) => v.subject).join(", ") : "none"); console.log(` differs with only this one: ${list(verdicts.filter((v) => v.alone && v.differs))}`); console.log(` matches with only this left out: ${list(verdicts.filter((v) => !v.alone && !v.differs))}`); } if (unreached.length) { - console.log(`${TOOL}: ${unreached.length} shim(s) did nothing while the document was loaded, changed and saved:`); + console.log(`${TOOL}: ${unreached.length} shim(s) did nothing while the documents were loaded or created, changed and saved:`); for (const name of unreached) console.log(` book/lib/${name}`); - console.log(" The document no longer reaches the shim, or the book does not need it."); + console.log(" The documents no longer reach the shim, or the book does not need it."); } const report = (list, what, why) => { if (list.length === 0) return; @@ -563,8 +595,8 @@ try { }; if (members) { report(members.missing, "member(s) PATCHES lists are not patched", "The shim no longer patches pdf-lib's own object, or PATCHES is out of date."); - report(members.unlisted, "patched member(s) are not in PATCHES", "Add each to PATCHES, marked with a reason if the document does not reach it."); - report(members.notRun, "patched member(s) never ran", "The document no longer reaches the function, or the book does not need it; PATCHES can mark it, with the reason."); + report(members.unlisted, "patched member(s) are not in PATCHES", "Add each to PATCHES, marked with a reason if neither document reaches it."); + report(members.notRun, "patched member(s) never ran", "The documents no longer reach the function, or the book does not need it; PATCHES can mark it, with the reason."); report(members.nowRun, "member(s) PATCHES marks as not reached ran", "Remove the mark from PATCHES."); } process.exitCode = 1; diff --git a/scripts/lib/pdf-shims-side.mjs b/scripts/lib/pdf-shims-side.mjs index b767f3d3..cc9ac84c 100644 --- a/scripts/lib/pdf-shims-side.mjs +++ b/scripts/lib/pdf-shims-side.mjs @@ -1,15 +1,18 @@ // One side of check_pdf_shims_equiv.mjs: loads the fixture, changes it as // book/render-book.mjs changes the book, and saves it, with the shims it is -// given and no others. Each side is a process of its own, because the onebuf -// shims allow one PDFContext per process and the stock side must load no shim. +// given and no others; or, with no fixture, builds a document with +// PDFDocument.create and saves that. Each side is a process of its own, +// because the onebuf shims allow one PDFContext per process and the stock side +// must load no shim. // // node scripts/lib/pdf-shims-side.mjs // -// The job is { fixture, out, shims, parallel, coverage }: `shims` are absolute -// paths, imported in the order given; `parallel` saves through parallelSave, as -// the book does, and otherwise as stock pdf-lib's save would with the book's 500 -// objects per stream; `coverage` records which of the shims ran a function -// during the load, change and save, their imports left out, and which of the +// The job is { fixture, out, shims, parallel, coverage }: `fixture` is the PDF +// to load, or null; `shims` are absolute paths, imported in the order given; +// `parallel` saves through parallelSave, as the book does, and otherwise as +// stock pdf-lib's save would with the book's 500 objects per stream; `coverage` +// records which of the shims ran a function while the document was loaded or +// built, changed and saved, their imports left out, and which of the // members of pdf-lib they put a function into, and whether each function ran. // The side writes the saved PDF to `out` and prints one JSON line, // { streamCount, reached, patched }. @@ -159,35 +162,84 @@ const parallelSave = job.parallel ? (await import(bookLib("parallel-deflate.mjs" // counted as reaching a shim. if (session) await session.post("Profiler.takePreciseCoverage"); -const { PDFDocument, PDFName, PDFNumber, PDFRef, PDFStreamWriter, PDFString, rgb } = pdfLib; -const raw = readFileSync(job.fixture); +const { PDFCatalog, PDFDocument, PDFHexString, PDFName, PDFNumber, PDFRef, PDFStreamWriter, PDFString, rgb } = pdfLib; +const name = (s) => PDFName.of(s); +const doc = job.fixture ? await loadAndChange(readFileSync(job.fixture)) : await create(); -// As render-book.mjs does: size the onebuf shims from a measure of the input. -const counts = measure(raw); -for (const shim of shims) { - shim.setExpectedDictSlots?.(counts.dictSlots); - shim.setExpectedArraySlots?.(counts.arraySlots); +async function loadAndChange(raw) { + // As render-book.mjs does: size the onebuf shims from a measure of the input. + const counts = measure(raw); + for (const shim of shims) { + shim.setExpectedDictSlots?.(counts.dictSlots); + shim.setExpectedArraySlots?.(counts.arraySlots); + } + + const doc = await PDFDocument.load(raw); + setMetadata(doc, { title: "Fixture", subject: "check_pdf_shims_equiv", keywords: "one,two", creationDate: WHEN }); + doc.setModificationDate(WHEN); // setMetadata stamps the time it runs + await setOutline(doc, OUTLINE, false); + + // What the book's own change does not reach: a page drawn on, which + // normalizes its content streams; a page inserted and one removed, which + // edit /Kids; and dictionaries and an array parsed early, edited after the + // objects above were made. + const [first] = doc.getPages(); + first.drawText("Drawn 0.5 over", { x: 72.25, y: 700.125, size: 11.5, color: rgb(0.25, 0.5, 0.75) }); + doc.insertPage(1, [300.5, 400]); + doc.removePage(2); + const ctx = doc.context; + const misc = ctx.lookup(PDFRef.of(7)); + misc.set(name("Added"), PDFNumber.of(-0.001)); + misc.set(name("Int"), PDFString.of("replaced")); + misc.delete(name("Nil")); + const arr = ctx.lookup(PDFRef.of(12)); + arr.push(PDFNumber.of(1e-7)); + doc.catalog.set(name("Edited"), PDFNumber.of(2 ** 20)); + + // What neither change calls, each result written into the document so that + // the comparison checks it. misc's text holds a dictionary's, an array's and + // a reference's. An index or reference not found is written as null. + const inline = misc.get(name("Arr")).get(2); // << /K /V /Deep << /Z null >> >> + const found = ctx.obj({ + Values: misc.values(), + Entries: misc.entries().flat(), + AsMap: [...misc.asMap()].flat(), + Has: [misc.has(name("Type")), misc.has(name("Absent")), inline.get(name("Deep")).has(name("Z"))], + IndexOf: [arr.indexOf(name("Name")), arr.indexOf(PDFRef.of(7)), arr.indexOf(name("Absent"))], + AsArray: arr.asArray(), + ObjectRef: [ctx.getObjectRef(misc), ctx.getObjectRef(ctx.lookup(PDFRef.of(9, 1))), ctx.getObjectRef(inline)], + Text: PDFHexString.fromText(misc.toString()), + }); + // Each clone and its original edited after the copy, which must not reach + // the other. + const miscClone = misc.clone(); + const arrClone = arr.clone(ctx); + miscClone.set(name("InClone"), PDFNumber.of(1)); + misc.set(name("AfterClone"), PDFNumber.of(2)); + arrClone.push(PDFNumber.of(3)); + arr.set(0, PDFNumber.of(4)); + doc.catalog.set(name("Found"), ctx.obj([ctx.register(found), ctx.register(miscClone), ctx.register(arrClone)])); + return doc; } -const doc = await PDFDocument.load(raw); -setMetadata(doc, { title: "Fixture", subject: "check_pdf_shims_equiv", keywords: "one,two", creationDate: WHEN }); -doc.setModificationDate(WHEN); // setMetadata stamps the time it runs -await setOutline(doc, OUTLINE, false); - -// What the book's own change does not reach: a page drawn on, which -// normalizes its content streams; a page inserted and one removed, which edit -// /Kids; and dictionaries and an array parsed early, edited after the objects -// above were made. -const [first] = doc.getPages(); -first.drawText("Drawn 0.5 over", { x: 72.25, y: 700.125, size: 11.5, color: rgb(0.25, 0.5, 0.75) }); -doc.insertPage(1, [300.5, 400]); -doc.removePage(2); -const misc = doc.context.lookup(PDFRef.of(7)); -misc.set(PDFName.of("Added"), PDFNumber.of(-0.001)); -misc.set(PDFName.of("Int"), PDFString.of("replaced")); -misc.delete(PDFName.of("Nil")); -doc.context.lookup(PDFRef.of(12)).push(PDFNumber.of(1e-7)); -doc.catalog.set(PDFName.of("Edited"), PDFNumber.of(2 ** 20)); +// A document built rather than loaded, which is what reaches the page-tree +// and catalog factories. It has no input to measure, so the onebuf shims keep +// the capacity they start with. +async function create() { + const doc = await PDFDocument.create(); + doc.setCreationDate(WHEN); + doc.setModificationDate(WHEN); + doc.addPage(); + const page = doc.addPage([300.5, 400]); + page.drawText("Created", { x: 20.5, y: 300, size: 9 }); + doc.insertPage(0, [200, 200.25]); + // pdf-lib calls this only from the parseDict fast-dict-onebuf replaces, so + // nothing reaches it but a direct call. + const copy = PDFCatalog.fromMapWithContext(doc.catalog.asMap(), doc.context); + copy.set(name("Copied"), PDFNumber.of(1)); + doc.catalog.set(name("Copy"), doc.context.register(copy)); + return doc; +} let bytes; let streamCount = null; From 9f08584d5ba4cfcb39d188e84146b50f2eff6ce6 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Mon, 28 Sep 2026 22:21:48 +0200 Subject: [PATCH 08/21] book: the two onebuf shims share their range machinery --- book/lib/fast-array-onebuf.mjs | 187 +++++----------------- book/lib/fast-dict-onebuf.mjs | 243 ++++++++--------------------- book/lib/onebuf-range.mjs | 175 +++++++++++++++++++++ book/render-book.mjs | 7 +- builder/PLAN-TOOLING-REVIEW.md | 59 +++++++ docs/Documentation/Fixes-PDFLib.md | 2 + docs/Documentation/Tools.md | 2 +- scripts/check_pdf_shims_equiv.mjs | 5 +- scripts/lib/pdf-shims-side.mjs | 6 +- 9 files changed, 354 insertions(+), 332 deletions(-) create mode 100644 book/lib/onebuf-range.mjs diff --git a/book/lib/fast-array-onebuf.mjs b/book/lib/fast-array-onebuf.mjs index e3f49578..7a463a39 100644 --- a/book/lib/fast-array-onebuf.mjs +++ b/book/lib/fast-array-onebuf.mjs @@ -30,107 +30,17 @@ // - push(v) at HWM: in-place extend (no other arrays follow) // - push(v) not at HWM: COW the range to tail, then push // - insert / remove: always COW (shifts would corrupt neighbours) -// Same at-HWM-determines-safety logic as fast-dict-onebuf; no owned -// bit needed (see fast-dict-onebuf commit 7e8b1f7). // -// Singleton PDFContext (one PDFDocument.load per process in our -// pipeline). The singleton is duplicated rather than shared with -// fast-dict-onebuf -- the mechanism is ten lines and keeping each -// shim independently injectable is worth more than dedup'ing it. -// Both shims end up holding references to the same PDFContext. +// The range machinery -- packing, appends, copy-on-write and the +// singleton PDFContext -- is onebuf-range.mjs's, shared with +// fast-dict-onebuf. The buffer and the context are this shim's own, so +// it loads without that one. // // Composes with --fast-dict-onebuf. Mutually exclusive with // --fast-dict-encoded (which subsumes both via its own encoded shape). import { PDFArray, PDFObjectParser, CharCodes } from './pdf-lib-internals.mjs'; - -// ---- The single buffer --------------------------------------------- - -// Pre-sized to total array slots + slack on the book. Other workloads -// grow it naturally from this starting size. When the measure-pass -// shim runs first, it calls setExpectedArraySlots() before parse, -// which resizes `arrayMain` to exact measured demand via -// `arrayMain.length = N`. -const ARRAY_MAIN_INITIAL_CAP = 800000; -const arrayMain = new Array(ARRAY_MAIN_INITIAL_CAP); -let arrayMainLen = 0; - -export { arrayMain }; -export function getArrayMainLen() { return arrayMainLen; } - -// Resize arrayMain in place. Must be called before any parseArray / -// withContext (i.e. while arrayMainLen is still 0). `slack` is a -// multiplier on `slots`; default 1.0 (exact). Same in-place-resize -// rationale as fast-dict-onebuf's setExpectedDictSlots: reassigning -// the module-level binding invalidates V8's inline-cache slots in -// every closure that reads it, and the deopt + recompile shows up as -// a parse-time allocation spike. -export function setExpectedArraySlots(slots, slack = 1.0) { - if (arrayMainLen > 0) { - throw new Error( - `fast-array-onebuf: setExpectedArraySlots called after parse started (arrayMainLen=${arrayMainLen})`, - ); - } - arrayMain.length = Math.ceil(slots * slack); -} - -// ---- Bit-packing helpers ------------------------------------------- - -const POW_24 = 16777216; // 2^24 -const MASK_24 = 0xFFFFFF; -const MASK_16 = 0xFFFF; - -const MAX_START = POW_24; // exclusive -const MAX_LENGTH = 1 << 16; // 65 536, exclusive - -function pack(start, length) { - if (start >= MAX_START) throw new Error(`fast-array-onebuf: start ${start} exceeds 24-bit budget`); - if (length >= MAX_LENGTH) throw new Error(`fast-array-onebuf: length ${length} exceeds 16-bit budget`); - return start + length * POW_24; -} - -function _start(d) { return d & MASK_24; } -function _length(d) { return Math.floor(d / POW_24) & MASK_16; } - -// ---- Singleton context --------------------------------------------- - -let _singletonContext = null; - -function _registerContext(ctx) { - if (_singletonContext === null) { - _singletonContext = ctx; - } else if (_singletonContext !== ctx) { - throw new Error('fast-array-onebuf: expected a singleton PDFContext, got a second distinct one.'); - } -} - -// ---- Append + COW helpers ------------------------------------------ - -function _appendFromTemp(temp, fromOffset, lenSlots) { - for (let i = 0; i < lenSlots; i++) { - arrayMain[arrayMainLen + i] = temp[fromOffset + i]; - } - arrayMainLen += lenSlots; -} - -function _appendArray(arr) { - const len = arr.length; - for (let i = 0; i < len; i++) arrayMain[arrayMainLen + i] = arr[i]; - arrayMainLen += len; -} - -// COW: copy this array's range to arrayMain's tail. If already at -// the HWM, nothing to copy -- return d unchanged. -function _cow(pa) { - const d = pa.d; - const start = _start(d); - const length = _length(d); - if (start + length === arrayMainLen) return d; // at HWM - const newStart = arrayMainLen; - for (let i = 0; i < length; i++) arrayMain[arrayMainLen + i] = arrayMain[start + i]; - arrayMainLen += length; - return pack(newStart, length); -} +import { onebufRange } from './onebuf-range.mjs'; // ---- Construction -------------------------------------------------- // @@ -147,15 +57,33 @@ function _cow(pa) { function _FastArray(d) { this.d = d; } _FastArray.prototype = PDFArray.prototype; -function _makeFromRange(start, length, ctx) { - _registerContext(ctx); - return new _FastArray(pack(start, length)); +function _construct(_ProtoClass, d) { + return new _FastArray(d); } -function _makeFromAppend(arr, ctx) { - const start = arrayMainLen; - _appendArray(arr); - return _makeFromRange(start, arr.length, ctx); +// ---- The single buffer --------------------------------------------- + +// Pre-sized to total array slots + slack on the book. Other workloads +// grow it naturally from this starting size. When the measure-pass +// shim runs first, it calls setExpectedArraySlots() before parse, +// which resizes `arrayMain` to exact measured demand. +const ranges = onebufRange({ + name: 'fast-array-onebuf', + capacity: 800000, + startBits: 24, + construct: _construct, +}); +const arrayMain = ranges.slots; +const _start = ranges.startOf; +const _length = ranges.lengthOf; + +export { arrayMain }; +export function getArrayMainLen() { return ranges.used(); } + +// Must be called before any parseArray / withContext. `slack` is a +// multiplier on `slots`; default 1.0 (exact). +export function setExpectedArraySlots(slots, slack = 1.0) { + ranges.reserve(slots, slack); } if (!PDFArray.prototype.__fastArrayOnebufInstalled) { @@ -167,16 +95,7 @@ if (!PDFArray.prototype.__fastArrayOnebufInstalled) { }; PDFArray.prototype.push = function (object) { - const d0 = this.d; - const start0 = _start(d0); - const length0 = _length(d0); - let dNow = d0; - if (start0 + length0 !== arrayMainLen) { - dNow = _cow(this); - } - arrayMain[arrayMainLen++] = object; - const start = _start(dNow); - this.d = pack(start, length0 + 1); + ranges.push(this, object); }; PDFArray.prototype.get = function (index) { @@ -198,33 +117,11 @@ if (!PDFArray.prototype.__fastArrayOnebufInstalled) { }; PDFArray.prototype.insert = function (index, object) { - // Always COW -- shifting elements in place would corrupt other - // arrays' ranges past this one. - const d0 = this.d; - const start0 = _start(d0); - const length0 = _length(d0); - const newStart = arrayMainLen; - for (let i = 0; i < index; i++) { - arrayMain[arrayMainLen++] = arrayMain[start0 + i]; - } - arrayMain[arrayMainLen++] = object; - for (let i = index; i < length0; i++) { - arrayMain[arrayMainLen++] = arrayMain[start0 + i]; - } - this.d = pack(newStart, length0 + 1); + ranges.insert(this, index, object); }; PDFArray.prototype.remove = function (index) { - // Always COW (same reason as insert). - const d0 = this.d; - const start0 = _start(d0); - const length0 = _length(d0); - const newStart = arrayMainLen; - for (let i = 0; i < length0; i++) { - if (i === index) continue; - arrayMain[arrayMainLen++] = arrayMain[start0 + i]; - } - this.d = pack(newStart, length0 - 1); + ranges.cut(this, index, 1); }; PDFArray.prototype.asArray = function () { @@ -237,14 +134,7 @@ if (!PDFArray.prototype.__fastArrayOnebufInstalled) { }; PDFArray.prototype.clone = function (context) { - const d = this.d; - const start = _start(d); - const length = _length(d); - const newStart = arrayMainLen; - for (let i = 0; i < length; i++) arrayMain[arrayMainLen + i] = arrayMain[start + i]; - arrayMainLen += length; - _registerContext(context || _singletonContext); - return new _FastArray(pack(newStart, length)); + return ranges.clone(this, PDFArray, context); }; PDFArray.prototype.toString = function () { @@ -285,7 +175,7 @@ if (!PDFArray.prototype.__fastArrayOnebufInstalled) { // and dispatch through our overrides. Object.defineProperty(PDFArray.prototype, 'context', { - get() { return _singletonContext; }, + get() { return ranges.context(); }, set(_ctx) { /* singleton is source of truth */ }, configurable: true, }); @@ -293,7 +183,7 @@ if (!PDFArray.prototype.__fastArrayOnebufInstalled) { // ---- PDFArray factory ------------------------------------------- PDFArray.withContext = function (context) { - return _makeFromAppend([], context); + return ranges.viewOf(PDFArray, [], context); }; // ---- PDFObjectParser.prototype.parseArray ----------------------- @@ -324,11 +214,10 @@ if (!PDFArray.prototype.__fastArrayOnebufInstalled) { bytes.assertNext(CharCodes.RightSquareBracket); const frameLen = this._arrayTempLen - frameStart; - const start = arrayMainLen; - _appendFromTemp(temp, frameStart, frameLen); + const start = ranges.append(temp, frameStart, frameLen); this._arrayTempLen = frameStart; - return _makeFromRange(start, frameLen, this.context); + return ranges.view(PDFArray, start, frameLen, this.context); }; PDFArray.prototype.__fastArrayOnebufInstalled = true; diff --git a/book/lib/fast-dict-onebuf.mjs b/book/lib/fast-dict-onebuf.mjs index 86514292..62043272 100644 --- a/book/lib/fast-dict-onebuf.mjs +++ b/book/lib/fast-dict-onebuf.mjs @@ -8,17 +8,14 @@ // packed value -- frees up bits. // // 41-bit packed Number layout (well within Number.MAX_SAFE_INTEGER): -// bits 0-22: start (23 bits, max 8.4 M slots in main; mainLen ~2.3 M today) +// bits 0-22: start (23 bits, max 8.4 M slots in main; ~2.3 M used on the book) // bit 23: PDFPageLeaf `normalized` flag (zero on all other dict subtypes) // bit 24: PDFPageLeaf `autoNormalizeCTM` flag (zero on all other dict subtypes) // bits 25-40: length (16 bits, max 65 535 slots; max observed 8 706) // bits 41-52: spare (12 bits; unused, available headroom) // -// V8 Smi (31-bit signed) covers values < 2^30. start + length*2^25 stays -// Smi iff length < 32 (the 2^30 boundary). Beyond that, `d` boxes to a -// HeapNumber but bit math via `& MASK_*` and `+`/`-` continues to work -- -// reads still extract bits 0..30 correctly via Int32 coercion, writes -// use arithmetic so high bits survive. +// `d` stays a Smi iff length < 32; past that it is a HeapNumber, which +// onebuf-range.mjs's packing handles. // // PDFPageLeaf collapses to the same single-`d` field as plain PDFDict; // `normalized` and `autoNormalizeCTM` are gettters/setters that mask @@ -43,15 +40,11 @@ // range to main's tail, then push the new pair, update encoded // value to the new range) // - delete: COW (copy range minus deleted entry to tail) -// The at-HWM check fully determines whether extending is safe; -// each dict's range is unique to that dict (no slot sharing), so -// extending past the dict's end at HWM never disturbs anything. -// An earlier design tracked an owned/shared bit to gate this; it -// was redundant -- shared dicts at HWM extend just as safely as -// owned ones. // -// Singleton PDFContext (one PDFDocument.load per process in our -// pipeline; throws if a second distinct context appears). +// The range machinery -- packing, appends, copy-on-write and the +// singleton PDFContext (a second distinct context throws) -- is +// onebuf-range.mjs's, shared with fast-array-onebuf. The buffer and the +// context are this shim's own, so it loads without that one. // // Mutually exclusive with --fast-dict-double / --fast-dict-view / // --fast-dict-array. @@ -59,121 +52,20 @@ import { PDFDict, PDFCatalog, PDFPageTree, PDFPageLeaf, PDFName, PDFNull, PDFObjectParser, CharCodes, } from './pdf-lib-internals.mjs'; +import { onebufRange } from './onebuf-range.mjs'; const TypeName = PDFName.of('Type'); const CatalogName = PDFName.of('Catalog'); const PagesName = PDFName.of('Pages'); const PageName = PDFName.of('Page'); -// ---- The single buffer + temp --------------------------------------- +// ---- Gap bits ------------------------------------------------------- -// Pre-sized to total entries + slack measured on the book. Other -// workloads grow it naturally (V8-amortized array growth from this -// starting size). When the measure-pass shim runs first, it calls -// setExpectedDictSlots() before parse, which resizes `main` to exact -// measured demand via `main.length = N`. -const MAIN_INITIAL_CAP = 2400000; -const main = new Array(MAIN_INITIAL_CAP); -let mainLen = 0; - -// Exposed for measurement-only consumers (perf/instrument-*.mjs). -// The encoded `d` values held by PDFDict instances reference main by -// (start, length); reading the slots requires access to main itself. -export { main }; -export function getMainLen() { return mainLen; } - -// Replace `main` with an exact-sized backing array. Must be called -// before any parseDict / withContext / fromMapWithContext (i.e. while -// mainLen is still 0). `slack` is a multiplier on `slots`; default 1.0 -// (exact). Use a small slack only if the measure pass is approximate. -export function setExpectedDictSlots(slots, slack = 1.0) { - if (mainLen > 0) { - throw new Error( - `fast-dict-onebuf: setExpectedDictSlots called after parse started (mainLen=${mainLen})`, - ); - } - const sized = Math.ceil(slots * slack); - // Resize in place rather than reassigning. Reassigning the module- - // level `main` binding invalidates V8's inline-cache slots in every - // closure that reads it -- the closures get deopted on first call - // and recompile against the new array, with a parse-time allocation - // spike attributed to _appendEntries (~27 MB sampled on the book). - // `main.length = N` keeps the same Array identity; ICs stay valid. - main.length = sized; -} - -// ---- Bit-packing helpers -------------------------------------------- - -const POW_23 = 1 << 23; // 8 388 608 -- gap-bit base / start ceiling -const POW_25 = 1 << 25; // 33 554 432 -- length multiplier -const MASK_23 = 0x7FFFFF; // 23-bit start mask -const MASK_16 = 0xFFFF; // 16-bit length mask - -const NORM_BIT = POW_23; // bit 23: PDFPageLeaf `normalized` -const AUTO_BIT = POW_23 * 2; // bit 24: PDFPageLeaf `autoNormalizeCTM` -const GAP_MASK = NORM_BIT | AUTO_BIT; - -const MAX_START = POW_23; // exclusive -const MAX_LENGTH = 1 << 16; // 65536, exclusive - -function pack(start, length) { - if (start >= MAX_START) throw new Error(`fast-dict-onebuf: start ${start} exceeds 23-bit budget`); - if (length >= MAX_LENGTH) throw new Error(`fast-dict-onebuf: length ${length} exceeds 16-bit budget`); - return start + length * POW_25; -} - -// Read start (bits 0-22) and length (bits 25-40). Both work on -// HeapNumber'd d: `& MASK_23` lives in low 32 bits (Int32 coercion -// reads it correctly); `Math.floor(d / POW_25)` operates on the full -// Number range before the `& MASK_16` truncates. -function _start(d) { return d & MASK_23; } -function _length(d) { return Math.floor(d / POW_25) & MASK_16; } - -// ---- Singleton context --------------------------------------------- - -let _singletonContext = null; - -function _registerContext(ctx) { - if (_singletonContext === null) { - _singletonContext = ctx; - } else if (_singletonContext !== ctx) { - throw new Error('fast-dict-onebuf: expected a singleton PDFContext, got a second distinct one.'); - } -} - -// ---- Append helpers ------------------------------------------------ - -function _appendEntries(entries, fromOffset, lenSlots) { - for (let i = 0; i < lenSlots; i++) { - main[mainLen + i] = entries[fromOffset + i]; - } - mainLen += lenSlots; -} - -function _appendArray(arr) { - const len = arr.length; - for (let i = 0; i < len; i++) main[mainLen + i] = arr[i]; - mainLen += len; -} - -// COW: copy this dict's range to main's tail, return the new packed -// value anchored at the new range. If we're already at the HWM, -// nothing to copy -- return d unchanged. -// -// Gap bits (bits 23-24, used by PDFPageLeaf for normalized / -// autoNormalizeCTM) are preserved across the repack. For non-PageLeaf -// dicts the mask is zero, so `+ (d & GAP_MASK)` is a no-op. Addition -// is used instead of `|` so the high bits of HeapNumber'd d survive. -function _cow(pd) { - const d = pd.d; - const start = _start(d); - const length = _length(d); - if (start + length === mainLen) return d; // at HWM, extend in place - const newStart = mainLen; - for (let i = 0; i < length; i++) main[mainLen + i] = main[start + i]; - mainLen += length; - return pack(newStart, length) + (d & GAP_MASK); -} +// The two bits onebuf-range.mjs leaves between a 23-bit start and the +// length. Every repack carries them over; they are zero on every dict +// but a PDFPageLeaf. +const NORM_BIT = 1 << 23; // bit 23: PDFPageLeaf `normalized` +const AUTO_BIT = 1 << 24; // bit 24: PDFPageLeaf `autoNormalizeCTM` // ---- Construction --------------------------------------------------- // @@ -208,16 +100,14 @@ _FastCatalog.prototype = PDFCatalog.prototype; function _FastPageTree(d) { this.d = d; } _FastPageTree.prototype = PDFPageTree.prototype; -// d arrives from pack(start, length) so bits 23-24 are zero; -// `+ AUTO_BIT` sets bit 24 unconditionally (autoNormalizeCTM = true -// default). Use addition not `|`: if length >= 32, d > 2^30 (HeapNumber) +// d arrives from onebuf-range.mjs's pack(start, length), so bits 23-24 +// are zero; `+ AUTO_BIT` sets bit 24 unconditionally (autoNormalizeCTM +// = true default). Use addition not `|`: if length >= 32, d > 2^30 (HeapNumber) // and `|` would truncate to Int32 losing high bits. function _FastPageLeaf(d) { this.d = d + AUTO_BIT; } _FastPageLeaf.prototype = PDFPageLeaf.prototype; -function _makeFromRange(ProtoClass, start, length, ctx) { - _registerContext(ctx); - const d = pack(start, length); +function _construct(ProtoClass, d) { if (ProtoClass === PDFDict) return new _FastDict(d); if (ProtoClass === PDFPageLeaf) return new _FastPageLeaf(d); if (ProtoClass === PDFCatalog) return new _FastCatalog(d); @@ -228,10 +118,35 @@ function _makeFromRange(ProtoClass, start, length, ctx) { return pd; } -function _makeFromAppend(ProtoClass, arr, ctx) { - const start = mainLen; - _appendArray(arr); - return _makeFromRange(ProtoClass, start, arr.length, ctx); +// ---- The single buffer ---------------------------------------------- + +// Pre-sized to total entries + slack measured on the book. Other +// workloads grow it naturally (V8-amortized array growth from this +// starting size). When the measure-pass shim runs first, it calls +// setExpectedDictSlots() before parse, which resizes `main` to exact +// measured demand. +const ranges = onebufRange({ + name: 'fast-dict-onebuf', + capacity: 2400000, + startBits: 23, + gapBits: 2, + construct: _construct, +}); +const main = ranges.slots; +const _start = ranges.startOf; +const _length = ranges.lengthOf; + +// Exposed for measurement-only consumers (perf/instrument-*.mjs). +// The encoded `d` values held by PDFDict instances reference main by +// (start, length); reading the slots requires access to main itself. +export { main }; +export function getMainLen() { return ranges.used(); } + +// Must be called before any parseDict / withContext / +// fromMapWithContext. `slack` is a multiplier on `slots`; default 1.0 +// (exact). Use a small slack only if the measure pass is approximate. +export function setExpectedDictSlots(slots, slack = 1.0) { + ranges.reserve(slots, slack); } function mapToArray(map) { @@ -282,18 +197,8 @@ if (!PDFDict.prototype.__fastDictOnebufInstalled) { for (let i = 0; i < length0; i += 2) { if (main[start0 + i] === key) { main[start0 + i + 1] = value; return; } } - // Append: requires the dict to be at main's high-water mark, OR we COW. - let dNow = d0; - if (start0 + length0 !== mainLen) { - dNow = _cow(this); - } - // After _cow (or if we were already at HWM), we abut the tail. - main[mainLen++] = key; - main[mainLen++] = value; - const start = _start(dNow); - // Preserve gap bits (PageLeaf flags) from dNow into the freshly - // packed value. Zero for non-PageLeaf dicts. - this.d = pack(start, length0 + 2) + (dNow & GAP_MASK); + // Append in place at main's high-water mark, else COW first. + ranges.pushPair(this, key, value); }; PDFDict.prototype.get = function (key, preservePDFNull) { @@ -330,19 +235,13 @@ if (!PDFDict.prototype.__fastDictOnebufInstalled) { const d0 = this.d; const start0 = _start(d0); const length0 = _length(d0); - let foundIdx = -1; for (let i = 0; i < length0; i += 2) { - if (main[start0 + i] === key) { foundIdx = i; break; } - } - if (foundIdx < 0) return false; - const newStart = mainLen; - for (let i = 0; i < length0; i++) { - if (i === foundIdx || i === foundIdx + 1) continue; - main[mainLen++] = main[start0 + i]; + if (main[start0 + i] === key) { + ranges.cut(this, i, 2); + return true; + } } - // Preserve gap bits (PageLeaf flags); zero for non-PageLeaf dicts. - this.d = pack(newStart, length0 - 2) + (d0 & GAP_MASK); - return true; + return false; }; PDFDict.prototype.asMap = function () { @@ -355,14 +254,7 @@ if (!PDFDict.prototype.__fastDictOnebufInstalled) { }; PDFDict.prototype.clone = function (context) { - const d = this.d; - const start = _start(d); - const length = _length(d); - const newStart = mainLen; - for (let i = 0; i < length; i++) main[mainLen + i] = main[start + i]; - mainLen += length; - _registerContext(context || _singletonContext); - return new _FastDict(pack(newStart, length)); + return ranges.clone(this, PDFDict, context); }; PDFDict.prototype.toString = function () { @@ -407,7 +299,7 @@ if (!PDFDict.prototype.__fastDictOnebufInstalled) { }; Object.defineProperty(PDFDict.prototype, 'context', { - get() { return _singletonContext; }, + get() { return ranges.context(); }, set(_ctx) { /* singleton is source of truth */ }, configurable: true, }); @@ -446,29 +338,29 @@ if (!PDFDict.prototype.__fastDictOnebufInstalled) { // ---- PDFDict factories -------------------------------------------- PDFDict.withContext = function (context) { - return _makeFromAppend(PDFDict, [], context); + return ranges.viewOf(PDFDict, [], context); }; PDFDict.fromMapWithContext = function (map, context) { - return _makeFromAppend(PDFDict, mapToArray(map), context); + return ranges.viewOf(PDFDict, mapToArray(map), context); }; PDFCatalog.withContextAndPages = function (context, pages) { - return _makeFromAppend( + return ranges.viewOf( PDFCatalog, [PDFName.of('Type'), CatalogName, PagesName, pages], context, ); }; PDFCatalog.fromMapWithContext = function (map, context) { - return _makeFromAppend(PDFCatalog, mapToArray(map), context); + return ranges.viewOf(PDFCatalog, mapToArray(map), context); }; PDFPageTree.fromMapWithContext = function (map, context) { - return _makeFromAppend(PDFPageTree, mapToArray(map), context); + return ranges.viewOf(PDFPageTree, mapToArray(map), context); }; PDFPageLeaf.fromMapWithContext = function (map, context, autoNormalizeCTM) { - const d = _makeFromAppend(PDFPageLeaf, mapToArray(map), context); + const d = ranges.viewOf(PDFPageLeaf, mapToArray(map), context); if (autoNormalizeCTM !== undefined) d.autoNormalizeCTM = autoNormalizeCTM; return d; }; @@ -534,8 +426,7 @@ if (!PDFDict.prototype.__fastDictOnebufInstalled) { const frameLen = this._dictTempLen - frameStart; // Commit this frame to main in one contiguous append - const start = mainLen; - _appendEntries(temp, frameStart, frameLen); + const start = ranges.append(temp, frameStart, frameLen); // Pop our frame off temp this._dictTempLen = frameStart; @@ -545,10 +436,10 @@ if (!PDFDict.prototype.__fastDictOnebufInstalled) { for (let i = start; i < end; i += 2) { if (main[i] === TypeName) { Type = main[i + 1]; break; } } - if (Type === CatalogName) return _makeFromRange(PDFCatalog, start, frameLen, this.context); - if (Type === PagesName) return _makeFromRange(PDFPageTree, start, frameLen, this.context); - if (Type === PageName) return _makeFromRange(PDFPageLeaf, start, frameLen, this.context); - return _makeFromRange(PDFDict, start, frameLen, this.context); + if (Type === CatalogName) return ranges.view(PDFCatalog, start, frameLen, this.context); + if (Type === PagesName) return ranges.view(PDFPageTree, start, frameLen, this.context); + if (Type === PageName) return ranges.view(PDFPageLeaf, start, frameLen, this.context); + return ranges.view(PDFDict, start, frameLen, this.context); }; PDFDict.prototype.__fastDictOnebufInstalled = true; diff --git a/book/lib/onebuf-range.mjs b/book/lib/onebuf-range.mjs new file mode 100644 index 00000000..4f097852 --- /dev/null +++ b/book/lib/onebuf-range.mjs @@ -0,0 +1,175 @@ +// The range machinery the two one-buffer shims share: fast-dict-onebuf for +// PDFDict and its subclasses, fast-array-onebuf for PDFArray. Each shim calls +// onebufRange() once and gets a buffer of its own -- one append-only Array +// holding every slot of every object the shim makes, for the document's +// lifetime -- with the functions that pack an object's range into a Number, +// append to the buffer and copy a range to its tail, and a singleton +// PDFContext. The shims share this code and no state, so either loads +// without the other. +// +// An object holds only `d`, a Number packing its range in the buffer: +// +// bits 0 .. startBits-1 start: the index of its first slot +// the next gapBits bits flags a shim keeps in `d`; every repack +// carries them over (zero where unused) +// the 16 bits above those length: its number of slots +// +// The value stays well below Number.MAX_SAFE_INTEGER. Past 2^30 it is no +// longer a Smi, and V8 boxes it as a HeapNumber: `d & mask` still reads bits +// 0-30 correctly through Int32 coercion, and writes use arithmetic rather +// than `|`, so the high bits survive. +// +// Only these functions append. A shim reads the buffer directly and may +// overwrite a slot inside an object's range, which never moves another +// object's slots. An object whose range ends at the buffer's high-water mark +// extends in place; any other object has its range copied to the tail first +// (copy-on-write). Each range belongs to one object, so extending at the +// high-water mark never disturbs another. A removal or an insertion always +// copies, since shifting slots in place would corrupt the ranges after it. +// +// One PDFContext per buffer, and so per shim: a second distinct context +// throws. The book's pipeline makes one PDFDocument per process. +// +// Options: +// name the shim's name, which starts each error message +// capacity the buffer's initial length +// startBits the width of the start field +// gapBits the number of flag bits between start and length (default 0) +// construct (ProtoClass, d) => a new object of that pdf-lib class holding +// `d`. The dispatch to each class's constructor is the shim's. + +const LENGTH_LIMIT = 1 << 16; // 65 536, exclusive +const LENGTH_MASK = 0xFFFF; + +export function onebufRange({ name, capacity, startBits, gapBits = 0, construct }) { + const START_LIMIT = 2 ** startBits; // exclusive + const START_MASK = START_LIMIT - 1; + const LENGTH_BASE = 2 ** (startBits + gapBits); // length multiplier + const GAP_MASK = LENGTH_BASE - START_LIMIT; + + const slots = new Array(capacity); + let used = 0; + let context = null; + + function pack(start, length) { + if (start >= START_LIMIT) throw new Error(`${name}: start ${start} exceeds ${startBits}-bit budget`); + if (length >= LENGTH_LIMIT) throw new Error(`${name}: length ${length} exceeds 16-bit budget`); + return start + length * LENGTH_BASE; + } + + function startOf(d) { return d & START_MASK; } + function lengthOf(d) { return Math.floor(d / LENGTH_BASE) & LENGTH_MASK; } + + // Resizes the buffer to `count * slack` in place. A new Array would + // invalidate V8's inline caches in every closure that reads the buffer, + // and the deopt and recompile show up as a parse-time allocation spike + // (~27 MB sampled on the book). Only before the first append. + function reserve(count, slack = 1.0) { + if (used > 0) { + throw new Error(`${name}: the buffer was sized after parse started (${used} slots in use)`); + } + slots.length = Math.ceil(count * slack); + } + + function registerContext(ctx) { + if (context === null) { + context = ctx; + } else if (context !== ctx) { + throw new Error(`${name}: expected a singleton PDFContext, got a second distinct one.`); + } + } + + // Appends `count` slots of `source` from `from` and returns where they + // start: a parser's finished frame, a new object's values, or a range of + // the buffer itself, which always lies below the tail it is copied to. + function append(source, from, count) { + const at = used; + for (let i = 0; i < count; i++) slots[at + i] = source[from + i]; + used = at + count; + return at; + } + + function view(ProtoClass, start, length, ctx) { + registerContext(ctx); + return construct(ProtoClass, pack(start, length)); + } + + function viewOf(ProtoClass, values, ctx) { + return view(ProtoClass, append(values, 0, values.length), values.length, ctx); + } + + // The object's `d` once its range ends at the high-water mark: unchanged + // if it already does, else for a copy of the range at the tail. + function cow(obj) { + const d = obj.d; + const start = startOf(d); + const length = lengthOf(d); + if (start + length === used) return d; + return pack(append(slots, start, length), length) + (d & GAP_MASK); + } + + function push(obj, value) { + const d = cow(obj); + slots[used++] = value; + obj.d = pack(startOf(d), lengthOf(d) + 1) + (d & GAP_MASK); + } + + // A dictionary entry: its key and its value. + function pushPair(obj, key, value) { + const d = cow(obj); + slots[used++] = key; + slots[used++] = value; + obj.d = pack(startOf(d), lengthOf(d) + 2) + (d & GAP_MASK); + } + + // Copies the object's range to the tail without its `count` slots from + // `index`. + function cut(obj, index, count) { + const d = obj.d; + const start = startOf(d); + const length = lengthOf(d); + const at = used; + for (let i = 0; i < length; i++) { + if (i >= index && i < index + count) continue; + slots[used++] = slots[start + i]; + } + obj.d = pack(at, length - count) + (d & GAP_MASK); + } + + // Copies the object's range to the tail with `value` inserted at `index`. + function insert(obj, index, value) { + const d = obj.d; + const start = startOf(d); + const length = lengthOf(d); + const at = used; + for (let i = 0; i < index; i++) slots[used++] = slots[start + i]; + slots[used++] = value; + for (let i = index; i < length; i++) slots[used++] = slots[start + i]; + obj.d = pack(at, length + 1) + (d & GAP_MASK); + } + + // A new object of `ProtoClass` holding a copy of the object's range and + // none of its flags. + function clone(obj, ProtoClass, ctx) { + const d = obj.d; + const length = lengthOf(d); + return view(ProtoClass, append(slots, startOf(d), length), length, ctx || context); + } + + return { + slots, + used: () => used, + reserve, + startOf, + lengthOf, + context: () => context, + append, + view, + viewOf, + push, + pushPair, + cut, + insert, + clone, + }; +} diff --git a/book/render-book.mjs b/book/render-book.mjs index 01adeec9..0550b9e8 100644 --- a/book/render-book.mjs +++ b/book/render-book.mjs @@ -77,9 +77,10 @@ import { parseCli, withUsageError } from '../lib/cli.mjs'; // per-instance temp array as a stack of recursion frames; each // parseDict invocation appends to temp, commits its frame to // main in one contiguous append, and pops temp back. PDFDicts -// only ever read from main, so a packed (start, length, owned) -// Number is the whole instance state -- no separate bufIdx. -// Owned dicts (factory-created post-parse) also append to main. +// only ever read from main, so a packed (start, flags, length) +// Number is the whole instance state -- no separate bufIdx; the +// two flag bits are PDFPageLeaf's. Dicts the factories make +// after the parse also append to main. // Mutations: in-place replace for existing keys, COW (copy // range to tail, push new pair) for new keys or delete. // PDFContext is a singleton -- one PDFDocument.load per diff --git a/builder/PLAN-TOOLING-REVIEW.md b/builder/PLAN-TOOLING-REVIEW.md index 9e50615d..d80f4338 100644 --- a/builder/PLAN-TOOLING-REVIEW.md +++ b/builder/PLAN-TOOLING-REVIEW.md @@ -2834,6 +2834,54 @@ renamed variables. **Verify.** `check_pdf_shims_equiv.mjs`; the book's page count and outline unchanged; render time within noise of before, since these shims exist for speed. +**Landed.** `onebufRange({ name, capacity, startBits, gapBits, construct })` builds one buffer +per call, with its length and its singleton `PDFContext` inside the closure, so the two shims +share code and no state. The layout comes from `startBits` and `gapBits` (the dictionary 23 +and 2, the array 24 and 0): the gap mask is the bits between the two fields, zero for the +array, so one `cow` and one repack serve both. `construct(ProtoClass, d)` is each shim's +dispatch: the dictionary's picks among its four constructors as `_makeFromRange` did, the +array's ignores the class. Beyond the review's two identical helpers, `_appendEntries` and +`_appendFromTemp` were a third pair and the two sizers a fourth; those two loops, +`_appendArray` and the copies inside `_cow` and both `clone`s are now one +`append(source, from, count)`. Every append goes through the module (`append`, `view`, +`viewOf`, `push`, `pushPair`, `cut`, `insert`, `clone`); a shim reads its buffer directly and +overwrites slots inside a range, so the hot read paths (`get`, `copyBytesInto`, +`sizeInBytes`, the parsers' type scan) are unchanged. Every function installed on pdf-lib +stays in its shim: the side attributes a member to the file that defines the function, and +skips one defined anywhere else, so moving `set` or `push` into the module would have shown +as unpatched. Each shim keeps `main` / `arrayMain`, its sizer and its length getter as +exports. One message changed: a sizer called after parse now says `the buffer was sized after +parse started ( slots in use)`; nothing calls one late. A Sonnet agent compared every +changed function with HEAD's and found no other difference for any argument pdf-lib passes +(`remove` now differs only for an index that is not an integer). The array's header lost its +paragraph on why the singleton was duplicated, and both headers the history of the dropped +owned bit; `render-book.mjs`'s summary of the dictionary shim still described that bit (see +Found while implementing). Fixes-PDFLib.md names the module under fast-array-onebuf. + +The gate's side now gives the drawn page a new key before drawing on it (see Found while +implementing): the page's entries move to the end of the buffer while `autoNormalizeCTM` is +set, and the draw wraps the old content only if the flag moved with them. The kit's +`c68-faults.mjs` drops the gap bits in `cow`, which passed the gate before and fails it now by +difference, with the diagnosis naming `fast-dict-onebuf.mjs` alone; its faults on `cut`, +`insert`, `push` and `pushPair` fail it by difference too, and its `singleton` mode shows each +shim alone refusing a second `PDFDocument.create`, at HEAD and now, with the same message. +The counts are unchanged: the same 25 and 11 objects, and the same 72 members, all run but +the 2 marked. C67b's faults moved with the code (the two `clone` cases now cut the clone's +first entry, the two `fromMapWithContext` cases name `ranges.viewOf`), and all 18 still fail +by difference; C67a's five still exit 1. The gate's header and Tools.md's section describe the +new key. + +The book, three renders a side from one `_site-pdf`, alternating HEAD's `book/` and the working +one: 2,299 pages and 2,466 outline entries each; `process:` 1.3, 1.1 and 1.2 s at HEAD, 1.0, +1.2 and 1.0 s now; totals 95.6, 85.2 and 94.4 s at HEAD, 90.3, 99.6 and 88.7 s now. HEAD's +three files were 29,132,946 bytes each; the working tree's 29,132,892, 29,132,951 and +29,132,946, and that last pair is identical but for `/CreationDate` and `/ModDate`, so the +same code wrote the other two and their sizes are the render's own variation (the owner: +around a second is fast enough beside the other phases, so they were not taken apart). +`build.bat`, `check.bat` and `test.bat` clean; lint `Checked 170 files`; regex safety +unchanged. `compare_trees`: Fixes-PDFLib and Tools online and offline, the search data and +`book.html`. CI waits for the owner's push. + ### C69 — `book: each pdf-lib shim checks what it overwrites` **A9-1 (R2).** The twelve production shims (thirteen before C65b) each guard against being installed twice and @@ -3555,6 +3603,17 @@ Defects the review did not have, found by building something this plan asks for. a patch applied to a copy is not a patch of pdf-lib at all. Scheduled as C67a, with the document's reach of the members it marks as C67b, at the owner's choice. Fixed in `book: check_pdf_shims_equiv checks each member the shims patch`. +- **`render-book.mjs` described the dictionary shim's `d` with an owned bit**, found while + landing C68: its summary said "a packed (start, length, owned) Number" and named "Owned + dicts", a bit the shim dropped long before. Folded into C68, at the owner's choice. Fixed in + `book: the two onebuf shims share their range machinery`. +- **The shim gate passed with PDFPageLeaf's flags dropped in a copy-on-write**, found while + landing C68: a fault removing the gap bits from the new module's `cow` left the gate green. + pdf-lib reads `autoNormalizeCTM` only inside `normalize()`, and the one copy of the drawn + page's range came from the `/Annots` key that `normalize()` adds last, after that read. The + side now sets a new key on the page before drawing on it. Folded into + C68, at the owner's choice. Fixed in `book: the two onebuf shims share their range + machinery`. ## Open questions diff --git a/docs/Documentation/Fixes-PDFLib.md b/docs/Documentation/Fixes-PDFLib.md index ac2f782d..5e96bdce 100644 --- a/docs/Documentation/Fixes-PDFLib.md +++ b/docs/Documentation/Fixes-PDFLib.md @@ -128,6 +128,8 @@ An additional optimisation in `parseIndirectObjects`: the upstream implementatio **Fix.** The same one-buffer strategy as `fast-dict-onebuf`, applied to `PDFArray`. A single append-only Array (`arrayMain`) shared across all `PDFArray` instances. Each `PDFArray` holds one encoded integer (`d`) packing `start` (24 bits) and `length` (16 bits). `arrayMain[start..start+length]` holds array elements as plain JavaScript references --- no encoding, no decode step on reads. `PDFObjectParser.parseArray` uses a per-parser `_arrayTemp` stack, committing each completed frame to `arrayMain` in one contiguous append. Mutations follow the same copy-on-write logic as `fast-dict-onebuf`. +Both shims take that logic from one module, `book/lib/onebuf-range.mjs`: packing and reading `d`, appending to the buffer, copying a range to its end, and checking that only one `PDFContext` is used. Each shim calls it with its own bit layout and its own constructors, and gets a buffer and a context of its own, so either shim works without the other. + `setExpectedArraySlots(n)` from `measure-pass.mjs` resizes `arrayMain` in-place before parse for the same reason as `setExpectedDictSlots`: in-place resize preserves V8's inline-cache slots. ## parallel-deflate.mjs diff --git a/docs/Documentation/Tools.md b/docs/Documentation/Tools.md index 9f3d6ebf..0aa729d9 100644 --- a/docs/Documentation/Tools.md +++ b/docs/Documentation/Tools.md @@ -565,7 +565,7 @@ Exits 1 on any failed probe or case, 2 if it cannot run. Verifies that the book's [pdf-lib patches](Fixes/PDFLib) write what pdf-lib itself writes. `book/render-book.mjs` loads Chromium's PDF, adds the metadata and the outline, and saves it, with a dozen shims replacing pdf-lib's parser, object classes and writer, and `parallelSave` in place of `save()`. This loads, changes and saves one document twice, with stock pdf-lib and with every shim `render-book.mjs` imports, each side in a process of its own, does the same for a document built with `PDFDocument.create`, and compares each pair of files object by object with every stream inflated, since `node:zlib` and pdf-lib's own deflate can compress the same bytes differently. It also checks each file's cross-reference entries against the objects they locate, since pdf-lib's own parser finds objects without them. No built tree, no browser; under a second. -The document is written by the gate, without pdf-lib, so the forms the shims' parsers branch on are known to be in it: names with `#` escapes, numbers in every lexical form, a classic cross-reference table, and an incremental update with an object stream and a cross-reference stream. The change mirrors `render-book.mjs`'s and adds what reaches the rest of the shims: text drawn on a page, a page inserted and one removed, objects parsed early and edited late, and a call of each patched method the book does not make, its result written into the document so that the comparison checks it. The created document reaches the factories that build a page tree and a catalog. Each member of pdf-lib that a shim puts a function into is checked against `PATCHES`, a list in the gate. A listed member that is not patched fails it, and so does a patched member that is not listed: a patch applied to a copy of a class leaves pdf-lib's own member as it was. Each listed member's function must run, unless the list marks the member as one neither document reaches and says why, and a marked member that runs fails the gate as well, so the marks stay true. A shim none of whose functions runs is reported whole, since the documents then no longer test it, or the book does not need it. On a difference, that document's shimmed side runs again with each shim alone and with each left out, and the report names the shims that make it. +The document is written by the gate, without pdf-lib, so the forms the shims' parsers branch on are known to be in it: names with `#` escapes, numbers in every lexical form, a classic cross-reference table, and an incremental update with an object stream and a cross-reference stream. The change mirrors `render-book.mjs`'s and adds what reaches the rest of the shims: text drawn on a page that has just been given a new key, which moves the page's entries in `fast-dict-onebuf`'s buffer and must keep the page's two flags with them, a page inserted and one removed, objects parsed early and edited late, and a call of each patched method the book does not make, its result written into the document so that the comparison checks it. The created document reaches the factories that build a page tree and a catalog. Each member of pdf-lib that a shim puts a function into is checked against `PATCHES`, a list in the gate. A listed member that is not patched fails it, and so does a patched member that is not listed: a patch applied to a copy of a class leaves pdf-lib's own member as it was. Each listed member's function must run, unless the list marks the member as one neither document reaches and says why, and a marked member that runs fails the gate as well, so the marks stay true. A shim none of whose functions runs is reported whole, since the documents then no longer test it, or the book does not need it. On a difference, that document's shimmed side runs again with each shim alone and with each left out, and the report names the shims that make it. Exits 1 on a difference, a shim or listed member that did not run, or a patched member that is not as listed, 2 if it cannot run. diff --git a/scripts/check_pdf_shims_equiv.mjs b/scripts/check_pdf_shims_equiv.mjs index a2df9078..08cc2d8f 100644 --- a/scripts/check_pdf_shims_equiv.mjs +++ b/scripts/check_pdf_shims_equiv.mjs @@ -18,8 +18,9 @@ // numbers in every lexical form, a classic cross-reference table followed by // an incremental update with an object stream and a cross-reference stream. // The change (scripts/lib/pdf-shims-side.mjs) mirrors render-book.mjs and adds -// what reaches the rest of the shims: text drawn on a page, a page inserted -// and one removed, dictionaries parsed early and edited late, and a call of +// what reaches the rest of the shims: text drawn on a page given a new key +// first, which moves the page's entries and must keep its flags, a page +// inserted and one removed, dictionaries parsed early and edited late, and a call of // each patched method the book does not make, its result written into the // document. The created document reaches the page-tree and catalog factories. // diff --git a/scripts/lib/pdf-shims-side.mjs b/scripts/lib/pdf-shims-side.mjs index cc9ac84c..3c64274e 100644 --- a/scripts/lib/pdf-shims-side.mjs +++ b/scripts/lib/pdf-shims-side.mjs @@ -182,8 +182,12 @@ async function loadAndChange(raw) { // What the book's own change does not reach: a page drawn on, which // normalizes its content streams; a page inserted and one removed, which // edit /Kids; and dictionaries and an array parsed early, edited after the - // objects above were made. + // objects above were made. The page gets a new key first, which moves its + // entries to the end of fast-dict-onebuf's buffer: the draw wraps its old + // content in a saved graphics state only if the page's autoNormalizeCTM + // flag moved with them. const [first] = doc.getPages(); + first.node.set(name("Probe"), PDFNumber.of(5)); first.drawText("Drawn 0.5 over", { x: 72.25, y: 700.125, size: 11.5, color: rgb(0.25, 0.5, 0.75) }); doc.insertPage(1, [300.5, 400]); doc.removePage(2); From e92e521cd9a3a140bb4f6ca8c0505c44b9773a07 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Mon, 28 Sep 2026 22:46:06 +0200 Subject: [PATCH 09/21] book: each pdf-lib shim checks what it overwrites --- book/lib/fast-array-onebuf.mjs | 24 ++++++++++++ book/lib/fast-decode-name.mjs | 8 ++++ book/lib/fast-dict-onebuf.mjs | 37 ++++++++++++++++++ book/lib/fast-indirect-objects.mjs | 13 +++++++ book/lib/fast-number-to-string.mjs | 10 +++++ book/lib/fast-parse-name.mjs | 8 ++++ book/lib/fast-parse-number.mjs | 9 +++++ book/lib/fast-parse-object.mjs | 8 ++++ book/lib/fast-pdfnumber-pool.mjs | 8 ++++ book/lib/fast-refs-class.mjs | 14 +++++++ book/lib/fast-size-in-bytes.mjs | 10 +++++ book/lib/fast-sync-load.mjs | 14 +++++++ book/lib/parallel-deflate.mjs | 14 +++++++ book/lib/shim-targets.mjs | 60 ++++++++++++++++++++++++++++++ builder/PLAN-TOOLING-REVIEW.md | 49 ++++++++++++++++++++++++ docs/Documentation/Builder.md | 2 +- docs/Documentation/Fixes-PDFLib.md | 2 + 17 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 book/lib/shim-targets.mjs diff --git a/book/lib/fast-array-onebuf.mjs b/book/lib/fast-array-onebuf.mjs index 7a463a39..ca36b303 100644 --- a/book/lib/fast-array-onebuf.mjs +++ b/book/lib/fast-array-onebuf.mjs @@ -38,9 +38,15 @@ // // Composes with --fast-dict-onebuf. Mutually exclusive with // --fast-dict-encoded (which subsumes both via its own encoded shape). +// +// At load it checks that what it replaces is as in pdf-lib 1.17.1 (see +// shim-targets.mjs), PDFArray's constructor included, since no instance is +// built with it; and throws otherwise. It goes when pdf-lib is replaced; +// when a release changes what it patches, it is re-derived or removed. import { PDFArray, PDFObjectParser, CharCodes } from './pdf-lib-internals.mjs'; import { onebufRange } from './onebuf-range.mjs'; +import { checkTargets, ABSENT } from './shim-targets.mjs'; // ---- Construction -------------------------------------------------- // @@ -87,6 +93,24 @@ export function setExpectedArraySlots(slots, slack = 1.0) { } if (!PDFArray.prototype.__fastArrayOnebufInstalled) { + checkTargets(import.meta.url, { PDFArray, PDFObjectParser }, { + 'PDFArray': [1, 'b05b46a2fecd'], + 'PDFArray.withContext': [1, '48fa5b16b9ac'], + 'PDFArray.prototype.context': ABSENT, + 'PDFArray.prototype.size': [0, '7b9a440e025c'], + 'PDFArray.prototype.push': [1, '66baea7b877c'], + 'PDFArray.prototype.insert': [2, '0ca5e6c28a34'], + 'PDFArray.prototype.indexOf': [1, 'c043853ad8e8'], + 'PDFArray.prototype.remove': [1, '8c270493978b'], + 'PDFArray.prototype.set': [2, '85504942af56'], + 'PDFArray.prototype.get': [1, 'a2c3fc7788bc'], + 'PDFArray.prototype.asArray': [0, 'd3d28f8e6178'], + 'PDFArray.prototype.clone': [1, 'd4378f452c27'], + 'PDFArray.prototype.toString': [0, '8485a123e118'], + 'PDFArray.prototype.sizeInBytes': [0, 'f45594859246'], + 'PDFArray.prototype.copyBytesInto': [2, 'df948c1f1d7d'], + 'PDFObjectParser.prototype.parseArray': [0, '045f8aca220b'], + }); // ---- PDFArray.prototype ----------------------------------------- diff --git a/book/lib/fast-decode-name.mjs b/book/lib/fast-decode-name.mjs index 0f20a9fd..dede6a90 100644 --- a/book/lib/fast-decode-name.mjs +++ b/book/lib/fast-decode-name.mjs @@ -45,6 +45,10 @@ // pdf-lib's pool is already populated with the canonical instances // the parser will see. // +// At load it checks that what it replaces is as in pdf-lib 1.17.1 (see +// shim-targets.mjs), and throws otherwise. It goes when pdf-lib is replaced; +// when a release changes what it patches, it is re-derived or removed. +// // Side-effecting import. Import once before any pdf-lib operation: // // import "./lib/fast-decode-name.mjs"; @@ -52,8 +56,12 @@ // Idempotent -- repeated imports do nothing after the first. import { PDFName } from "pdf-lib"; +import { checkTargets } from "./shim-targets.mjs"; if (!PDFName.__fastDecodeNameInstalled) { + checkTargets(import.meta.url, { PDFName }, { + "PDFName.of": [1, "69a406b28b28"], + }); const original = PDFName.of; const fastCache = new Map(); PDFName.of = function fastOf(name) { diff --git a/book/lib/fast-dict-onebuf.mjs b/book/lib/fast-dict-onebuf.mjs index 62043272..babe8bfe 100644 --- a/book/lib/fast-dict-onebuf.mjs +++ b/book/lib/fast-dict-onebuf.mjs @@ -48,11 +48,18 @@ // // Mutually exclusive with --fast-dict-double / --fast-dict-view / // --fast-dict-array. +// +// At load it checks that what it replaces is as in pdf-lib 1.17.1 (see +// shim-targets.mjs), the constructors of PDFDict and its three subclasses +// included, since no instance is built with them; and throws otherwise. It +// goes when pdf-lib is replaced; when a release changes what it patches, it +// is re-derived or removed. import { PDFDict, PDFCatalog, PDFPageTree, PDFPageLeaf, PDFName, PDFNull, PDFObjectParser, CharCodes, } from './pdf-lib-internals.mjs'; import { onebufRange } from './onebuf-range.mjs'; +import { checkTargets, ABSENT } from './shim-targets.mjs'; const TypeName = PDFName.of('Type'); const CatalogName = PDFName.of('Catalog'); @@ -157,6 +164,36 @@ function mapToArray(map) { } if (!PDFDict.prototype.__fastDictOnebufInstalled) { + checkTargets(import.meta.url, { PDFDict, PDFCatalog, PDFPageTree, PDFPageLeaf, PDFObjectParser }, { + 'PDFDict': [2, '60eaf0675cb7'], + 'PDFDict.withContext': [1, 'b79783f2d4dd'], + 'PDFDict.fromMapWithContext': [2, 'dc931ea57734'], + 'PDFDict.prototype.context': ABSENT, + 'PDFDict.prototype.keys': [0, '91d41cc6de06'], + 'PDFDict.prototype.values': [0, 'b00e6e0e9a70'], + 'PDFDict.prototype.entries': [0, 'c7d0666742c0'], + 'PDFDict.prototype.set': [2, '8f7998a17fbe'], + 'PDFDict.prototype.get': [2, 'f2fc35be96de'], + 'PDFDict.prototype.has': [1, 'c0f455563c27'], + 'PDFDict.prototype.delete': [1, 'ed89c9b2480e'], + 'PDFDict.prototype.asMap': [0, '2406d1634030'], + 'PDFDict.prototype.clone': [1, '42c1d3265d15'], + 'PDFDict.prototype.toString': [0, 'a718a21ad3b3'], + 'PDFDict.prototype.sizeInBytes': [0, 'ad72e2c097c3'], + 'PDFDict.prototype.copyBytesInto': [2, '8b0e4be9ce23'], + 'PDFCatalog': [0, 'f30b610c8906'], + 'PDFCatalog.withContextAndPages': [2, 'feba5de98084'], + 'PDFCatalog.fromMapWithContext': [2, 'de86988a4da7'], + 'PDFPageTree': [0, 'f90f91df0873'], + 'PDFPageTree.withContext': [2, 'adff8ad3530b'], + 'PDFPageTree.fromMapWithContext': [2, '3a685cbe77d3'], + 'PDFPageLeaf': [3, '6af6b6fbd5e3'], + 'PDFPageLeaf.withContextAndParent': [2, '37706c20ca6b'], + 'PDFPageLeaf.fromMapWithContext': [3, '291ad87437e0'], + 'PDFPageLeaf.prototype.normalized': ABSENT, + 'PDFPageLeaf.prototype.autoNormalizeCTM': ABSENT, + 'PDFObjectParser.prototype.parseDict': [0, '8056773f38fb'], + }); // ---- PDFDict.prototype -------------------------------------------- diff --git a/book/lib/fast-indirect-objects.mjs b/book/lib/fast-indirect-objects.mjs index 83b8b2ef..2991fc92 100644 --- a/book/lib/fast-indirect-objects.mjs +++ b/book/lib/fast-indirect-objects.mjs @@ -40,6 +40,10 @@ // sort: dense-array iteration is already in ascending objectNumber // order. (The Map-sourced gen!=0 entries are merged in sorted.) // +// At load it checks that what it replaces is as in pdf-lib 1.17.1 (see +// shim-targets.mjs), and throws otherwise. It goes when pdf-lib is replaced; +// when a release changes what it patches, it is re-derived or removed. +// // Side-effecting import. Import once before any PDFDocument.load: // // import "./lib/fast-indirect-objects.mjs"; @@ -47,10 +51,19 @@ // Idempotent -- repeated imports do nothing after the first. import { PDFContext, PDFRef, PDFNull, UnexpectedObjectTypeError } from './pdf-lib-internals.mjs'; +import { checkTargets } from './shim-targets.mjs'; const byAscendingObjectNumber = ([a], [b]) => a.objectNumber - b.objectNumber; if (!PDFContext.prototype.__fastIndirectObjectsInstalled) { + checkTargets(import.meta.url, { PDFContext }, { + 'PDFContext.prototype.assign': [2, '5a54add382ef'], + 'PDFContext.prototype.delete': [1, 'eaed088d9bc8'], + 'PDFContext.prototype.lookupMaybe': [1, 'b6066d63ce03'], + 'PDFContext.prototype.lookup': [1, '5fcfc6aef545'], + 'PDFContext.prototype.getObjectRef': [1, 'e5c04aab2cb9'], + 'PDFContext.prototype.enumerateIndirectObjects': [0, 'c16a955c69f1'], + }); // ---- assign ------------------------------------------------------- // Hot path. gen=0 → dense array store; gen!=0 → Map. Maintains diff --git a/book/lib/fast-number-to-string.mjs b/book/lib/fast-number-to-string.mjs index ad4ba12a..1c0387ca 100644 --- a/book/lib/fast-number-to-string.mjs +++ b/book/lib/fast-number-to-string.mjs @@ -38,6 +38,10 @@ // chain: utils/numbers (source), utils/index (the barrel PDFNumber // reads from), and pdf-lib's top-level index (the public surface). // +// At load it checks that what it replaces is as in pdf-lib 1.17.1 (see +// shim-targets.mjs), and throws otherwise. It goes when pdf-lib is replaced; +// when a release changes what it patches, it is re-derived or removed. +// // Side-effecting import. Import once before any pdf-lib operation: // // import "./lib/fast-number-to-string.mjs"; @@ -45,8 +49,14 @@ // Idempotent -- repeated imports do nothing after the first. import { numbers, utilsBarrel, topBarrel } from './pdf-lib-internals.mjs'; +import { checkTargets } from './shim-targets.mjs'; if (!numbers.__fastNumberToStringInstalled) { + checkTargets(import.meta.url, { numbers, utilsBarrel, topBarrel }, { + 'numbers.numberToString': [1, 'cffe79177032'], + 'utilsBarrel.numberToString': [1, 'cffe79177032'], + 'topBarrel.numberToString': [1, 'cffe79177032'], + }); const original = numbers.numberToString; const fastNumberToString = function fastNumberToString(num) { const numStr = String(num); diff --git a/book/lib/fast-parse-name.mjs b/book/lib/fast-parse-name.mjs index 9a1bc789..67db7e16 100644 --- a/book/lib/fast-parse-name.mjs +++ b/book/lib/fast-parse-name.mjs @@ -47,12 +47,17 @@ // through fast-decode-name -- correct, since those calls don't have // a byte range to work with. // +// At load it checks that what it replaces is as in pdf-lib 1.17.1 (see +// shim-targets.mjs), and throws otherwise. It goes when pdf-lib is replaced; +// when a release changes what it patches, it is re-derived or removed. +// // Side-effecting import. Import once before PDFDocument.load runs; // idempotent. import { PDFObjectParser, PDFName, CharCodes, IsWhitespace, IsDelimiter, } from './pdf-lib-internals.mjs'; +import { checkTargets } from './shim-targets.mjs'; const FORWARD_SLASH = CharCodes.ForwardSlash; @@ -77,6 +82,9 @@ function _bytesEqual(a, buf, start, end) { } if (!PDFObjectParser.prototype.__fastParseNameInstalled) { + checkTargets(import.meta.url, { PDFObjectParser }, { + 'PDFObjectParser.prototype.parseName': [0, '7881ea54990b'], + }); const orig = PDFObjectParser.prototype.parseName; PDFObjectParser.prototype.parseName = function fastParseName() { diff --git a/book/lib/fast-parse-number.mjs b/book/lib/fast-parse-number.mjs index 8a22bc42..a136a0b0 100644 --- a/book/lib/fast-parse-number.mjs +++ b/book/lib/fast-parse-number.mjs @@ -32,6 +32,10 @@ // CJS internal path. Mutating BaseParser.prototype affects every subclass (PDFParser, // PDFObjectParser, PDFObjectStreamParser, PDFXRefStreamParser). // +// At load it checks that what it replaces is as in pdf-lib 1.17.1 (see +// shim-targets.mjs), and throws otherwise. It goes when pdf-lib is replaced; +// when a release changes what it patches, it is re-derived or removed. +// // Side-effecting import. Import once before PDFDocument.load runs: // // import "./lib/fast-parse-number.mjs"; @@ -39,6 +43,7 @@ // Idempotent -- repeated imports do nothing after the first. import { BaseParser, IsDigit } from './pdf-lib-internals.mjs'; +import { checkTargets } from './shim-targets.mjs'; const ZERO = 0x30; // '0' const PERIOD = 0x2E; // '.' @@ -50,6 +55,10 @@ const MINUS = 0x2D; // '-' const MAX_SAFE_INT_DIGITS = 15; if (!BaseParser.__fastParseNumberInstalled) { + checkTargets(import.meta.url, { BaseParser }, { + 'BaseParser.prototype.parseRawInt': [0, '3d5dd7302180'], + 'BaseParser.prototype.parseRawNumber': [0, 'a3bd30d0e8b3'], + }); const origParseRawNumber = BaseParser.prototype.parseRawNumber; const origParseRawInt = BaseParser.prototype.parseRawInt; diff --git a/book/lib/fast-parse-object.mjs b/book/lib/fast-parse-object.mjs index 2c26d052..61ed9d3d 100644 --- a/book/lib/fast-parse-object.mjs +++ b/book/lib/fast-parse-object.mjs @@ -38,6 +38,10 @@ // Mutating PDFObjectParser.prototype.parseObject is global -- every // parser instance created after this shim loads picks it up. // +// At load it checks that what it replaces is as in pdf-lib 1.17.1 (see +// shim-targets.mjs), and throws otherwise. It goes when pdf-lib is replaced; +// when a release changes what it patches, it is re-derived or removed. +// // Side-effecting import. Import once before PDFDocument.load runs: // // import "./lib/fast-parse-object.mjs"; @@ -47,6 +51,7 @@ import { PDFObjectParser, PDFBool, PDFNull, CharCodes, Keywords, IsNumeric, PDFObjectParsingError, } from './pdf-lib-internals.mjs'; +import { checkTargets } from './shim-targets.mjs'; const KwTrue = Keywords.true; const KwFalse = Keywords.false; @@ -61,6 +66,9 @@ const f_code = CharCodes.f; const n_code = CharCodes.n; if (!PDFObjectParser.prototype.__fastParseObjectInstalled) { + checkTargets(import.meta.url, { PDFObjectParser }, { + 'PDFObjectParser.prototype.parseObject': [0, '96b386d327ae'], + }); PDFObjectParser.prototype.parseObject = function fastParseObject() { this.skipWhitespaceAndComments(); const bytes = this.bytes; diff --git a/book/lib/fast-pdfnumber-pool.mjs b/book/lib/fast-pdfnumber-pool.mjs index b0ee9990..4a4e72e4 100644 --- a/book/lib/fast-pdfnumber-pool.mjs +++ b/book/lib/fast-pdfnumber-pool.mjs @@ -29,14 +29,22 @@ // (numberValue and stringValue are set in the constructor and never // mutated), so sharing instances is safe. // +// At load it checks that what it replaces is as in pdf-lib 1.17.1 (see +// shim-targets.mjs), and throws otherwise. It goes when pdf-lib is replaced; +// when a release changes what it patches, it is re-derived or removed. +// // Side-effecting import. Import once before any pdf-lib operation. // Idempotent. import { PDFNumber } from "pdf-lib"; +import { checkTargets } from "./shim-targets.mjs"; const POOL_SIZE = 16384; if (!PDFNumber.__fastPoolInstalled) { + checkTargets(import.meta.url, { PDFNumber }, { + "PDFNumber.of": [1, "f86986605078"], + }); const original = PDFNumber.of; const intPool = new Array(POOL_SIZE); // sparse, holes for unused slots const otherPool = new Map(); // floats / negatives / large ints diff --git a/book/lib/fast-refs-class.mjs b/book/lib/fast-refs-class.mjs index f88b29d4..f48c6d97 100644 --- a/book/lib/fast-refs-class.mjs +++ b/book/lib/fast-refs-class.mjs @@ -31,8 +31,14 @@ // raw, aligned to 16 B by V8 -- versus 12 + 2*4 = 20 B raw, aligned to // 24 B for a 2-slot instance. Saves 8 B per gen=0 PDFRef * ~226 k unique // = ~1.8 MB heap on the book. +// +// At load it checks that what it replaces is as in pdf-lib 1.17.1 (see +// shim-targets.mjs), PDFRef's constructor included, since no instance is +// built with it; and throws otherwise. It goes when pdf-lib is replaced; +// when a release changes what it patches, it is re-derived or removed. import { PDFRef } from 'pdf-lib'; +import { checkTargets, ABSENT } from './shim-targets.mjs'; // ---- helpers ----------------------------------------------------------- @@ -86,6 +92,14 @@ function _FastRefGen(objectNumber, generationNumber) { _FastRefGen.prototype = PDFRef.prototype; if (!PDFRef.__fastRefsClassInstalled) { + checkTargets(import.meta.url, { PDFRef }, { + 'PDFRef': [3, 'caa1a139b254'], + 'PDFRef.of': [2, '0a2fd82dd164'], + 'PDFRef.prototype.generationNumber': ABSENT, + 'PDFRef.prototype.toString': [0, 'eef3fb80e9cb'], + 'PDFRef.prototype.sizeInBytes': [0, 'dd38686c5b40'], + 'PDFRef.prototype.copyBytesInto': [2, '127b12f52ae2'], + }); const pool0 = []; // dense gen=0 cache, indexed by objectNumber const poolGenN = new Map(); // gen!=0 cache, keyed by "N M" string diff --git a/book/lib/fast-size-in-bytes.mjs b/book/lib/fast-size-in-bytes.mjs index 1654abd6..21394543 100644 --- a/book/lib/fast-size-in-bytes.mjs +++ b/book/lib/fast-size-in-bytes.mjs @@ -34,6 +34,10 @@ // object we mutate first, so it picks up the fast path without a // separate patch. // +// At load it checks that what it replaces is as in pdf-lib 1.17.1 (see +// shim-targets.mjs), and throws otherwise. It goes when pdf-lib is replaced; +// when a release changes what it patches, it is re-derived or removed. +// // Side-effecting import. Import once before any pdf-lib operation: // // import "./lib/fast-size-in-bytes.mjs"; @@ -41,8 +45,14 @@ // Idempotent -- repeated imports do nothing after the first. import { numbers, utilsBarrel, topBarrel } from './pdf-lib-internals.mjs'; +import { checkTargets } from './shim-targets.mjs'; if (!numbers.__fastSizeInBytesInstalled) { + checkTargets(import.meta.url, { numbers, utilsBarrel, topBarrel }, { + 'numbers.sizeInBytes': [1, '9d66145407d5'], + 'utilsBarrel.sizeInBytes': [1, '9d66145407d5'], + 'topBarrel.sizeInBytes': [1, '9d66145407d5'], + }); const fastSizeInBytes = function fastSizeInBytes(n) { if (n < 0x100) return 1; if (n < 0x10000) return 2; diff --git a/book/lib/fast-sync-load.mjs b/book/lib/fast-sync-load.mjs index 3059b227..f3d8e99a 100644 --- a/book/lib/fast-sync-load.mjs +++ b/book/lib/fast-sync-load.mjs @@ -45,6 +45,10 @@ // parallelSave drops `objectsPerTick` from its public API in step // with this shim. // +// At load it checks that what it replaces is as in pdf-lib 1.17.1 (see +// shim-targets.mjs), and throws otherwise. It goes when pdf-lib is replaced; +// when a release changes what it patches, it is re-derived or removed. +// // Side-effecting import. Import once before any pdf-lib operation: // // import "./lib/fast-sync-load.mjs"; @@ -58,6 +62,7 @@ import { CharCodes, ReparseError, StalledParserError, IsDigit, Keywords, toUint8Array, copyStringIntoBuffer, } from './pdf-lib-internals.mjs'; +import { checkTargets } from './shim-targets.mjs'; // Pool-deduped PDFName instances are reference-stable for the whole // load. Capture the three sentinels parseIndirectObject's Type-dispatch @@ -68,6 +73,15 @@ const XRefName = PDFName.of('XRef'); const RefZero = PDFRef.of(0); if (!PDFParser.prototype.__fastSyncLoadInstalled) { + checkTargets(import.meta.url, { PDFDocument, PDFParser, PDFObjectStreamParser, PDFWriter }, { + 'PDFDocument.load': [2, '2210a96a2600'], + 'PDFParser.prototype.parseDocument': [0, 'cd50190ce6db'], + 'PDFParser.prototype.parseDocumentSection': [0, '3b44d9ed7bfa'], + 'PDFParser.prototype.parseIndirectObjects': [0, '06726e96f501'], + 'PDFParser.prototype.parseIndirectObject': [0, '80737430e7b7'], + 'PDFObjectStreamParser.prototype.parseIntoContext': [0, '88169eabbeb7'], + 'PDFWriter.prototype.serializeToBuffer': [0, '906a4bbe8d47'], + }); // ----- Load side --------------------------------------------------- diff --git a/book/lib/parallel-deflate.mjs b/book/lib/parallel-deflate.mjs index 00698712..c63193e3 100644 --- a/book/lib/parallel-deflate.mjs +++ b/book/lib/parallel-deflate.mjs @@ -29,10 +29,17 @@ // Parallelism is bounded by UV_THREADPOOL_SIZE (default 4). Bump it via // `process.env.UV_THREADPOOL_SIZE = '8'` before any libuv work fires // if you want more concurrency. +// +// At load it checks that what it copies is as in pdf-lib 1.17.1 (see +// shim-targets.mjs): PDFStreamWriter's constructor and computeBufferSize, +// and PDFDocument.save, whose steps before serializing parallelSave +// repeats. It throws otherwise. It goes when pdf-lib is replaced; when a +// release changes what it copies, it is re-derived or removed. import { deflate, deflateSync } from 'node:zlib'; import { promisify } from 'node:util'; import { + PDFDocument, PDFStreamWriter, PDFObjectStream, PDFCrossRefStream, @@ -44,6 +51,13 @@ import { PDFHeader, PDFTrailer, } from 'pdf-lib'; +import { checkTargets } from './shim-targets.mjs'; + +checkTargets(import.meta.url, { PDFStreamWriter, PDFDocument }, { + 'PDFStreamWriter': [4, 'cd5bc5d1816a'], + 'PDFStreamWriter.prototype.computeBufferSize': [0, '5c50ff2801f3'], + 'PDFDocument.prototype.save': [1, '696cb8a85b9f'], +}); const deflateAsync = promisify(deflate); diff --git a/book/lib/shim-targets.mjs b/book/lib/shim-targets.mjs new file mode 100644 index 00000000..9edd3acf --- /dev/null +++ b/book/lib/shim-targets.mjs @@ -0,0 +1,60 @@ +// What a pdf-lib shim replaces, checked before it replaces it. +// +// Each shim was written against the source of pdf-lib 1.17.1, which +// package.json pins exact. The pin stops an accidental update; this stops a +// deliberate one, or an edit under node_modules, from changing the book +// silently. Before it patches anything, a shim passes checkTargets() a table +// of what it replaces: each member it overwrites, and the constructor of each +// class whose instances it builds without calling it, with the arity and +// source fingerprint each has in 1.17.1; and ABSENT for each member it adds, +// which 1.17.1 does not have. A member that differs makes the import throw, +// naming the shim and every member that differs, with the fingerprint it has +// now. Read the new source, then re-derive the shim and its table, or remove +// the shim. +// +// A fingerprint is the first 12 hex digits of the SHA-256 of the function's +// source text, as Function.prototype.toString returns it. +// +// Not a shim: it installs nothing, and imports nothing from pdf-lib. + +import { createHash } from 'node:crypto'; + +export const ABSENT = Symbol('not in pdf-lib 1.17.1'); + +export function fingerprint(fn) { + return createHash('sha256').update(Function.prototype.toString.call(fn)).digest('hex').slice(0, 12); +} + +// shimUrl is the shim's import.meta.url. roots holds the pdf-lib objects the +// shim imports, by name; each key of targets is a path from one of them, such +// as 'PDFDict.prototype.get', and each value is [arity, fingerprint] or ABSENT. +export function checkTargets(shimUrl, roots, targets) { + const faults = []; + for (const [path, expected] of Object.entries(targets)) { + const names = path.split('.'); + const key = names.pop(); + let holder = roots; + for (const name of names) holder = holder?.[name]; + if (Object(holder) !== holder) { + faults.push(`${path}: ${names.join('.')} is missing`); + } else if (expected === ABSENT) { + if (key in holder) faults.push(`${path}: pdf-lib has it now, and the shim adds it`); + } else { + const [arity, print] = expected; + const value = holder[key]; + if (typeof value !== 'function') { + faults.push(`${path}: ${value === undefined ? 'is missing' : `is a ${typeof value}, not a function`}`); + } else if (value.length !== arity) { + faults.push(`${path}: takes ${value.length} argument(s), not ${arity}`); + } else if (fingerprint(value) !== print) { + faults.push(`${path}: its source has changed (fingerprint ${fingerprint(value)}, not ${print})`); + } + } + } + if (faults.length === 0) return; + const shim = new URL(shimUrl).pathname.split('/').pop(); + throw new Error( + `${shim}: pdf-lib is not what this shim replaces:\n ${faults.join('\n ')}\n` + + 'Read the new source, then re-derive the shim and its table, or remove it (see book/lib/shim-targets.mjs).' + ); +} diff --git a/builder/PLAN-TOOLING-REVIEW.md b/builder/PLAN-TOOLING-REVIEW.md index d80f4338..3992473d 100644 --- a/builder/PLAN-TOOLING-REVIEW.md +++ b/builder/PLAN-TOOLING-REVIEW.md @@ -2899,6 +2899,50 @@ shim goes when pdf-lib is replaced, or when a release changes what it patches. **Verify.** With a target altered in a scratch copy of pdf-lib, the named shim throws at load; `check_pdf_shims_equiv.mjs` passes; `book.bat` renders. +**Landed.** A new module, `book/lib/shim-targets.mjs`, exports `checkTargets(shimUrl, roots, +targets)`, `ABSENT` and `fingerprint(fn)` (the first 12 hex digits of the SHA-256 of +`Function.prototype.toString`); it installs nothing and imports nothing from pdf-lib. Each of +the twelve shims calls it inside its install guard, before it patches anything, and +`parallel-deflate.mjs` at module level, which is its guard. A table's keys are paths from the +pdf-lib objects the shim imports (`'PDFDict.prototype.get'`, `'topBarrel.numberToString'`, +`'PDFRef'` for a constructor), and each value is `[arity, fingerprint]` or `ABSENT`. A member +missing, of another arity or another source, or an `ABSENT` member present, makes the import +throw one error naming the shim (from its `import.meta.url`) and every member that differs, +with its fingerprint now. The tables hold 78 targets: the gate's 72 members with each getter +and setter pair as one `ABSENT` entry (64 functions and 4 absences), `PDFRef.prototype +.generationNumber` (absent; `fast-refs-class` adds it as a data property the gate does not +list), and, at the owner's choice, the constructor of each class a shim builds instances of +without calling it (`PDFRef`, `PDFArray`, and `PDFDict` with its three subclasses) and +`PDFDocument.prototype.save`, whose steps before serializing `parallelSave` repeats; with +`PDFStreamWriter`'s constructor and `computeBufferSize` that is 66 functions, 7 of them +constructors, and 5 absences. Each header says the shim checks at load and goes when pdf-lib is +replaced, or is re-derived or removed when a release changes what it patches; the onebuf, +refs and deflate headers name the constructors. Fixes-PDFLib.md describes the check, and +Builder.md's pdf-lib pin says what it adds to the pin. Fingerprints were taken from stock +pdf-lib 1.17.1 with no shim loaded (the kit's `c69-stock.mjs`). + +The kit's `c43-fault.mjs` does not reach pdf-lib's CommonJS files: `module.register`'s hooks +never see a file loaded by `require`, and a fault on `PDFNumber.js` left the fingerprint as it +was. The kit's new `c69-cjs-fault.mjs` registers `module.registerHooks`' synchronous `load`, +which does, so each case alters pdf-lib's source as it loads, the in-memory equivalent of the +entry's scratch copy, with nothing under `node_modules` edited. `c69-faults.mjs` runs 16 cases +through `c69-load.mjs` (the thirteen files in `render-book.mjs`'s order): a changed source for +one member of each shim and for each of `parallel-deflate`'s three, a constructor (`PDFRef`, +`PDFDict`, `PDFStreamWriter`), an arity (`parseRawInt` given a parameter), a member removed +(`PDFArray.prototype.asArray`) and an absent member added (`PDFPageLeaf.prototype +.normalized`). Each exits 1 from the named shim, listing exactly the members altered: three +for `numberToString` and three for `sizeInBytes`, whose barrels hold one function. The control +loads all thirteen. The gate's line is unchanged. Loading the thirteen files took 197 ms, and +248 ms at HEAD, one run each: pdf-lib's own load dominates, and the checks are within its +noise. + +The book, one render a side from one `_site-pdf` through `render-book.mjs` with `book.bat`'s +arguments: 2,299 pages and 2,466 outline entries each, 29,140,555 bytes each, and the two files +differ in 5 bytes, all in `/CreationDate` and `/ModDate` inside one object stream; `process:` +1.2 s at HEAD and 1.3 s now. `build.bat`, `check.bat` and `test.bat` clean; lint `Checked 171 +files`; regex safety unchanged. `compare_trees`: Builder and Fixes-PDFLib online and offline, +the search data and `book.html`. + *impexp (decision (b)): C70.* ### C70 — `scripts: check_impexp_parity.mjs, the two impexp editions compared` @@ -3614,6 +3658,11 @@ Defects the review did not have, found by building something this plan asks for. side now sets a new key on the page before drawing on it. Folded into C68, at the owner's choice. Fixed in `book: the two onebuf shims share their range machinery`. +- **Nothing checks that a shim's table covers what it patches**, found while landing C69: a + shim that gains a patch and no table entry passes, since the gate's `PATCHES` lists the + members each shim patches and nothing compares that with the shim's own `checkTargets` + table. The side already lists each shim's patched members, so each shim could export its + table for the side to compare. Left for a commit of its own, at the owner's choice. ## Open questions diff --git a/docs/Documentation/Builder.md b/docs/Documentation/Builder.md index 8b409a89..92a9cbc9 100644 --- a/docs/Documentation/Builder.md +++ b/docs/Documentation/Builder.md @@ -461,7 +461,7 @@ No template engine, no framework, no bundler, no postinstall hooks. For the site - `@biomejs/biome` --- a new release can add or change a rule, which would change the linter's verdict on code nobody touched. [PLAN-TOOLING-REVIEW.md](https://github.com/twinbasic/documentation/blob/main/builder/PLAN-TOOLING-REVIEW.md), decision 4, records why the pin is exact. - `axe-core` --- the scan injects a copy of its bundle patched at source level, and its rules decide the accessibility gate's verdict. [PLAN-axe-perf.md](https://github.com/twinbasic/documentation/blob/main/builder/PLAN-axe-perf.md) records why the pin is exact. -- `pdf-lib` --- the shims under `book/lib/` are line-by-line ports of this release's source, and pdf-lib is no longer maintained; [08-pdf-lib.md](https://github.com/twinbasic/documentation/blob/main/perf/notes/08-pdf-lib.md) records the pin. +- `pdf-lib` --- the shims under `book/lib/` are line-by-line ports of this release's source, and pdf-lib is no longer maintained. Each shim checks at load that what it replaces is this release's, so another release stops the book rather than changing it, and the pin keeps an update from getting that far; [08-pdf-lib.md](https://github.com/twinbasic/documentation/blob/main/perf/notes/08-pdf-lib.md) records the pin. - `puppeteer` --- the book renderer and the accessibility gate measure what its Chromium renders, and the performance notes reason about that version at source level. It was pinned in the same change as `pdf-lib`. - `recheck` --- the regex-safety gate reports its analysis, and finds its native backend itself, because this release cannot find it on Windows; [WIP.Build.md](https://github.com/twinbasic/documentation/blob/main/WIP.Build.md) records the workaround. diff --git a/docs/Documentation/Fixes-PDFLib.md b/docs/Documentation/Fixes-PDFLib.md index 5e96bdce..832cc5ce 100644 --- a/docs/Documentation/Fixes-PDFLib.md +++ b/docs/Documentation/Fixes-PDFLib.md @@ -13,6 +13,8 @@ The files under `book/lib/fast-*.mjs` and `book/lib/parallel-deflate.mjs` are si A patch that reaches a pdf-lib class or module by its CommonJS path under `pdf-lib/cjs/`, rather than through the `pdf-lib` package's index, imports it from `book/lib/pdf-lib-internals.mjs`, which requires each one in a single place. `pdf-lib` itself resolves to `pdf-lib/cjs/index.js`, so each is the instance the library uses. +Each patch was written against the source of pdf-lib 1.17.1, which `package.json` pins exact, and checks at load that what it replaces is still that source. Before it patches anything, it passes `book/lib/shim-targets.mjs` a table: the arity and a fingerprint of the source of each member it overwrites, and of the constructor of each class whose objects it builds without calling it, and each member it adds, which must be absent. A patch whose targets differ throws at import, naming itself and each member that differs, so a different pdf-lib stops the book instead of changing it. The patch is then re-derived from the new source, or removed. + The root cause of the need for all these patches is the same: pdf-lib is designed for general-purpose use in both browsers and Node, and optimises for generality rather than throughput on a single large document. Each patch must leave the output unchanged. [`check_pdf_shims_equiv.mjs`](../Tools#check-pdf-shims-equiv), one of `test.bat`'s gates, saves one document with stock pdf-lib and with every patch `render-book.mjs` imports, compares the two files object by object, and fails if the patches are not the ones the gate lists, or if one never runs that the list does not mark as unreached. From 1650fe847658b5a708e358f21c4173ab82c1c5af Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Mon, 28 Sep 2026 22:56:58 +0200 Subject: [PATCH 10/21] scripts: check_impexp_parity.mjs, the two impexp editions compared --- .github/actions/run-gates/action.yml | 9 + WIP.md | 5 +- builder/PLAN-TOOLING-REVIEW.md | 37 ++++ docs/Documentation/Building.md | 1 + docs/Documentation/Tools.md | 21 ++- scripts/check_cli.mjs | 2 + scripts/check_impexp_parity.mjs | 252 +++++++++++++++++++++++++++ test.bat | 9 + 8 files changed, 330 insertions(+), 6 deletions(-) create mode 100644 scripts/check_impexp_parity.mjs diff --git a/.github/actions/run-gates/action.yml b/.github/actions/run-gates/action.yml index d0899f3e..f09bc791 100644 --- a/.github/actions/run-gates/action.yml +++ b/.github/actions/run-gates/action.yml @@ -168,6 +168,15 @@ runs: - name: Verify the book's pdf-lib shims against stock (check_pdf_shims_equiv.mjs) shell: bash run: node scripts/check_pdf_shims_equiv.mjs + # impexp.mjs and impexp.py are one published tool in two languages, and + # Tools.md promises the same output and the same bytes. Both built-in test + # suites, then one sequence of commands through each edition, comparing + # exit codes, output and written files. The runner's own python3 serves; + # without one the gate fails here, because GitHub sets CI=true, where + # test.bat on a machine without Python reports it skipped. + - name: Verify the two impexp editions agree (check_impexp_parity.mjs) + shell: bash + run: node scripts/check_impexp_parity.mjs # Graphviz sizes each node box from a width table; the browser paints the # label with a real font. Nothing in the build compares the two, so a # mismatch ships as text hanging outside its box on a green build -- which diff --git a/WIP.md b/WIP.md index 4d6c84a4..37b3c9b6 100644 --- a/WIP.md +++ b/WIP.md @@ -451,7 +451,7 @@ Why the report separates the wedged task from the merely blocked ones, and why - `build.bat` — runs `node builder\tbdocs.mjs --src docs --check-audit-index` (which implies `--check`) and produces three trees in one pass: the online copy at `_site/`, a `file://`-browsable copy at `_site-offline/`, and the sparse pagedjs source at `_site-pdf/`. The offline pass adds ~700 ms and the PDF pass adds ~150 ms on top of the ~2 s online build. Toggle `also_build_offline` / `also_build_pdf` in `_config.yml` (or pass `--no-offline` / `--no-pdf`) to skip a sibling output. `--check` adds ~1.7 s and runs the link + integrity check over the HTML while it is still in worker memory; `build.bat --no-check` gets a plain build. - `serve.bat` — runs `tbdocs --serve`: initial build, then a long-lived process with watcher, debounced rebuilds, and SSE-driven browser auto-reload. Writes to `docs/_serve/` (disjoint from `build.bat`'s `_site*/`) and skips the offline + PDF passes — so a one-off `build.bat` for the PDF or offline mirror doesn't disturb the live preview. Ctrl+C to stop. - `check.bat` — the gates that read the built site: a freshness check that refuses a stale tree (`scripts/check_tree_fresh.mjs`), the DOT diagram fit check (`scripts/check_dot_fit.mjs`), the a11y sample-coverage check (`scripts/pick_a11y_sample.mjs --check`), then the accessibility check (`scripts/check_a11y.mjs`). The link + integrity check moved into `build.bat`. ~37 s. -- `test.bat` — the tests the *toolchain* has to pass: the publish-allowlist self-test (`scripts/check_publish_policy.mjs`), the gate-list check (`scripts/check_gate_lists.mjs`), the CI-workflow roster check (`scripts/check_ci_workflows.mjs`), the lint gate (`scripts/check_lint.mjs`), the site-search unit tests (`node --test test/search.test.mjs`), the regex-safety gate (`scripts/check_regex_safety.mjs`), the code-region gate (`scripts/check_code_regions.mjs`), the page-count drift-guard probes (`scripts/check_page_baseline.mjs`), the book-coverage probes (`scripts/check_book_coverage.mjs`), the symbol-index probes (`scripts/check_symbol_index.mjs`), the twinBASIC-scanner probes (`scripts/check_twin_parsers.mjs`), the command-line probes and cases (`scripts/check_cli.mjs`), the pdf-lib shim comparison (`scripts/check_pdf_shims_equiv.mjs`), and the axe source-patch verification (`scripts/check_axe_patch_equiv.mjs`). ~9 s. See [What belongs in test.bat rather than check.bat](WIP.Build.md#what-belongs-in-testbat-rather-than-checkbat). +- `test.bat` — the tests the *toolchain* has to pass: the publish-allowlist self-test (`scripts/check_publish_policy.mjs`), the gate-list check (`scripts/check_gate_lists.mjs`), the CI-workflow roster check (`scripts/check_ci_workflows.mjs`), the lint gate (`scripts/check_lint.mjs`), the site-search unit tests (`node --test test/search.test.mjs`), the regex-safety gate (`scripts/check_regex_safety.mjs`), the code-region gate (`scripts/check_code_regions.mjs`), the page-count drift-guard probes (`scripts/check_page_baseline.mjs`), the book-coverage probes (`scripts/check_book_coverage.mjs`), the symbol-index probes (`scripts/check_symbol_index.mjs`), the twinBASIC-scanner probes (`scripts/check_twin_parsers.mjs`), the command-line probes and cases (`scripts/check_cli.mjs`), the pdf-lib shim comparison (`scripts/check_pdf_shims_equiv.mjs`), the impexp parity check (`scripts/check_impexp_parity.mjs`), and the axe source-patch verification (`scripts/check_axe_patch_equiv.mjs`). ~23 s, of which the regex-safety gate is ~9 s and the impexp check ~4 s. See [What belongs in test.bat rather than check.bat](WIP.Build.md#what-belongs-in-testbat-rather-than-checkbat). - `book.bat` — renders the PDF from `docs\_site-pdf\book.html` via `node book\render-book.mjs` into `docs\_pdf\twinBASIC Book.pdf`. Run `build.bat` first to populate `_site-pdf/`; `book.bat` refuses a tree older than its sources rather than rendering the previous book (see [The book refuses a stale source tree](WIP.Build.md#the-book-refuses-a-stale-source-tree)). - `examples.bat` — compiles the documentation's own twinBASIC code samples, every `tb` fence marked `check_build`, and reports the ones the compiler refuses against the line in the page they came from. Needs a twinBASIC install and Windows, so it is outside every gate and outside CI; ~120 s over the 1,129 samples marked as of 2026-09-25. Two modes need no compiler at all: `--census` classifies every fence and says how many classifiable ones are still unmarked, and `--report ` groups a saved `--propose --json` survey by diagnostic, section and unresolved name. `--propose` itself does compile. See [Compiling the reference's own code samples](#compiling-the-references-own-code-samples) and [WIP.ExamplesBuild.md](WIP.ExamplesBuild.md). @@ -469,7 +469,7 @@ build.bat && check.bat On the dev box that is ~4 s of build against ~37 s of check, of which the axe scan is ~20 s. [builder/PLAN-checks.md](builder/PLAN-checks.md) records how the link checker got folded into the build's task graph, what it cost and what it saved; the axe follow-ons are designed there but not implemented. -**If the change touched `builder/`, `scripts/`, `lib/`, `book/`, `eval/`, `wisdom/`, `test/`, the site's scripts in `docs/assets/js/`, a wrapper or a workflow, run `test.bat` as well** --- another ~9 s. Eleven of its fourteen gates cannot be affected by an edit under `docs/` at all. **Three can.** `check_lint.mjs` lints the site's two scripts in `docs/assets/js/` along with the tooling. `check_gate_lists.mjs` is the easy one to predict: it reads `README.md` and every page under `docs/Documentation/`, so an edit to any developer page that states a gate count can fail it. **`check_code_regions.mjs` is the one worth understanding**, and which half of it a content edit reaches is worth keeping straight. Its corpus sweep reads `DOCS_DIR`, which is `/docs`, and tokenises all 906 markdown files, so a page that provokes a rewrite into *altering* a code region fails it --- that half is content-dependent. Its fixed probes are not: they run against their own sources whatever the tree holds, and they cover the **mirror** fault, where a rewrite silently stops firing. The sweep structurally cannot see that one, because text the rewrite skipped is stashed and restored unchanged and every region still matches. So run `test.bat` after adding an unusual code construct --- a fence whose contents include a fence marker, a 4-space indented block, an admonition wrapping a fence --- and read the built page as well, because for the mirror fault the gate is asserting that the stasher still works rather than checking your page: +**If the change touched `builder/`, `scripts/`, `lib/`, `book/`, `eval/`, `wisdom/`, `test/`, the site's scripts in `docs/assets/js/`, a wrapper or a workflow, run `test.bat` as well** --- another ~23 s. Twelve of its fifteen gates cannot be affected by an edit under `docs/` at all. **Three can.** `check_lint.mjs` lints the site's two scripts in `docs/assets/js/` along with the tooling. `check_gate_lists.mjs` is the easy one to predict: it reads `README.md` and every page under `docs/Documentation/`, so an edit to any developer page that states a gate count can fail it. **`check_code_regions.mjs` is the one worth understanding**, and which half of it a content edit reaches is worth keeping straight. Its corpus sweep reads `DOCS_DIR`, which is `/docs`, and tokenises all 906 markdown files, so a page that provokes a rewrite into *altering* a code region fails it --- that half is content-dependent. Its fixed probes are not: they run against their own sources whatever the tree holds, and they cover the **mirror** fault, where a rewrite silently stops firing. The sweep structurally cannot see that one, because text the rewrite skipped is stashed and restored unchanged and every region still matches. So run `test.bat` after adding an unusual code construct --- a fence whose contents include a fence marker, a 4-space indented block, an admonition wrapping a fence --- and read the built page as well, because for the mirror fault the gate is asserting that the stasher still works rather than checking your page: ```sh build.bat && check.bat && test.bat @@ -509,6 +509,7 @@ wrapper: | `test.bat` | `check_lint` | Biome finds nothing in the tooling, warnings included, and checked at least one script | | `test.bat` | `test/search.test.mjs` | the search entries `builder/search.mjs` writes hold what they should, and the copies of the search client still agree. Run by `node --test`; the gate roster reads such a line as a gate, named by its path | | `test.bat` | `check_pdf_shims_equiv` | the book's pdf-lib shims write what stock pdf-lib writes: one document written by the gate and one built with `PDFDocument.create` are each saved both ways in child processes, and each pair of files is compared object by object with streams inflated and their cross-reference entries checked; every shim must run, and the members of pdf-lib the shims patch must be those its `PATCHES` lists, each run in one document or the other unless marked there as not reached | +| `test.bat` | `check_impexp_parity` | `impexp.mjs` and `impexp.py` pass the same built-in tests, and one sequence of commands through each gives the exit code each command is there for, the same output and the same files. Without Python it reports itself skipped and passes, but fails when `CI=true` | | `test.bat` | `check_publish_policy`, `check_gate_lists`, `check_page_baseline`, `check_book_coverage`, `check_axe_patch_equiv` | the gates on the gates | **A gate belongs in `test.bat` rather than `check.bat` if it would still mean diff --git a/builder/PLAN-TOOLING-REVIEW.md b/builder/PLAN-TOOLING-REVIEW.md index 3992473d..8f94d779 100644 --- a/builder/PLAN-TOOLING-REVIEW.md +++ b/builder/PLAN-TOOLING-REVIEW.md @@ -2965,6 +2965,43 @@ and WIP.md. **Verify.** Passes; a copy of one edition with one output line changed fails it. CI waits for the owner's push. +**Landed.** `scripts/check_impexp_parity.mjs` runs both `--self-test` suites, which must exit 0 +with every line `[PASS]` and print the same once the line naming the temporary folder is +dropped (19 tests each), and then 21 commands through each edition, each edition in a scratch +folder of its own holding copies of `indexer/sample.twinpack` and +`test/example-projects/console`. The commands run in order, so each sees what the ones before +wrote: `--help`; export, refused (3), with `--overwrite`, and warned (6) over a file the +project lacks; import of the exported folder and of the console project, given a README with a +CRLF and a non-ASCII character and a code file in LF first, refused (3) and with +`--overwrite`; an export of that; the four printing commands, with `readme` missing (4) from +the sample; a missing project (4), a folder with no Settings (5), a damaged project (5), and +three command lines refused (2). After each, both editions must give the exit code the command +names, the same stdout and stderr, and the same files as bytes; a differing file is reported +once, at the command that made it. On Windows Python's text streams write CRLF, so there +printed output is compared with CRLF read as LF; on Linux as written. Python is `python3`, +`python`, then `py -3` on Windows, 3.6 or later. Without one it prints `SKIPPED` and a line +saying the editions were not compared, and exits 0, unless `CI` is `true`, when it exits 2. +The two editions run each command at the same time; about 4 s here, most of it Python starting +(~180 ms a process against Node's ~80 ms). Registered in `test.bat` after the shim gate, the +composite action, Tools.md's list, POSIX block and a section (the impexp section points to +it), Building.md's POSIX block, WIP.md's bullet, table and counts, and two `check_cli` cases. + +The kit's `c70-faults.mjs` runs seven cases: four alter `impexp.mjs` through `c43-fault.mjs` +in `NODE_OPTIONS` (a progress line, the refusal's exit code, the LF-to-CRLF extensions, a test +name), one alters what `impexp.py` prints through a `sitecustomize.py` on `PYTHONPATH` +(`c70-pyfault/`), and two run with only Node on `PATH`, with `CI` empty and `true`. Each exits +as it should (1, 1, 1, 1, 1, 0, 2), naming the command and the line or file that differs; the +extensions fault also fails the Node edition's own LF-to-CRLF test, and the exit-code fault its +exit-code test. `check_cli: 258 probes`; `check_ci_workflows: ... the wrappers' 18 gates`; +`check_gate_lists: check.bat (4) + test.bat (15)`; lint `Checked 172 files`; regex safety `524 +literals + 28 constructed in 130 files ... 483 safe, 69 polynomial, 0 undecided, 0 +exponential` (the gate's three literals, all safe). `build.bat`, `check.bat` and `test.bat` +clean; `test.bat` took 23 s, so WIP.md's "~9 s" was stale before this gate, and now says ~23 s, +with the regex-safety gate ~9 s of it. `compare_trees`: Building and Tools online and offline, +the search data and `book.html`. CI waits for the owner's push: a new step, `Verify the two +impexp editions agree (check_impexp_parity.mjs)`, printing the summary line with the runner's +Python version, is this gate's first run on Linux, with the runner's own `python3`: read it. + ## Phase 3: conventions users see Decision (e): converge on `impexp.mjs`'s discipline. `--help` prints usage to stdout and exits diff --git a/docs/Documentation/Building.md b/docs/Documentation/Building.md index 34f74ad5..35374751 100644 --- a/docs/Documentation/Building.md +++ b/docs/Documentation/Building.md @@ -81,6 +81,7 @@ Each `.bat` opens with `@pushd "%~dp0"`, which is what lets it be invoked from a && node scripts/check_twin_parsers.mjs \ && node scripts/check_cli.mjs \ && node scripts/check_pdf_shims_equiv.mjs \ + && node scripts/check_impexp_parity.mjs \ && node scripts/check_axe_patch_equiv.mjs `book.bat` has one step that is invisible from the command it ends with. `render-book.mjs` writes the PDF with a plain file write and never creates the directory above it, so `docs/_pdf/` has to exist first --- otherwise the render fails with `ENOENT` at the very last moment, after the whole page-breaking pass has already run. The deploy workflow does the same `mkdir` before its render, for the same reason: diff --git a/docs/Documentation/Tools.md b/docs/Documentation/Tools.md index 0aa729d9..8d7befce 100644 --- a/docs/Documentation/Tools.md +++ b/docs/Documentation/Tools.md @@ -65,7 +65,7 @@ One of the four does not mean the same thing locally as it does in CI, on any pl test.bat -The tests the toolchain has to pass. Fourteen steps, each stopping the run if it fails: +The tests the toolchain has to pass. Fifteen steps, each stopping the run if it fails: 1. [`scripts/check_publish_policy.mjs`](#check-publish-policy) --- verifies the publish allowlist still refuses the types it is meant to. Needs neither a browser nor a built tree, so it goes first. 2. [`scripts/check_gate_lists.mjs`](#check-gate-lists) --- verifies the two gate lists on this page still match the wrappers that run them. @@ -80,7 +80,8 @@ The tests the toolchain has to pass. Fourteen steps, each stopping the run if it 11. [`scripts/check_twin_parsers.mjs`](#check-twin-parsers) --- verifies the scanners of twinBASIC source and of the attribute reference still read the shapes each once misread. 12. [`scripts/check_cli.mjs`](#check-cli) --- verifies `lib/cli.mjs`, the command-line parser, and each tool's recorded command-line errors. 13. [`scripts/check_pdf_shims_equiv.mjs`](#check-pdf-shims-equiv) --- verifies the book's pdf-lib shims write what stock pdf-lib writes, patch the members of pdf-lib it lists, and run. -14. [`scripts/check_axe_patch_equiv.mjs`](#check-axe-patch-equiv) --- verifies the vendored axe source patch still produces identical colour values. +14. [`scripts/check_impexp_parity.mjs`](#check-impexp-parity) --- verifies the two editions of the impexp tool pass the same built-in tests, and exit, print and write the same for one sequence of commands. Without Python it reports itself skipped and passes, except in CI. +15. [`scripts/check_axe_patch_equiv.mjs`](#check-axe-patch-equiv) --- verifies the vendored axe source patch still produces identical colour values. POSIX: @@ -97,9 +98,10 @@ POSIX: && node scripts/check_twin_parsers.mjs \ && node scripts/check_cli.mjs \ && node scripts/check_pdf_shims_equiv.mjs \ + && node scripts/check_impexp_parity.mjs \ && node scripts/check_axe_patch_equiv.mjs -**Eleven of the fourteen cannot be affected by an edit confined to `docs/`**, which is why they are separate from `check.bat`. Run this one when the change touches `builder/`, `scripts/`, `lib/`, `book/`, `eval/`, `wisdom/` or `test/`, the site's scripts in `docs/assets/js/`, a wrapper, or a workflow. Both CI workflows run all fourteen unconditionally, so skipping it locally cannot let a tooling regression reach `staging`. +**Twelve of the fifteen cannot be affected by an edit confined to `docs/`**, which is why they are separate from `check.bat`. Run this one when the change touches `builder/`, `scripts/`, `lib/`, `book/`, `eval/`, `wisdom/` or `test/`, the site's scripts in `docs/assets/js/`, a wrapper, or a workflow. Both CI workflows run all fifteen unconditionally, so skipping it locally cannot let a tooling regression reach `staging`. The three exceptions are [`check_code_regions.mjs`](#check-code-regions), [`check_gate_lists.mjs`](#check-gate-lists), which reads this page, and [`check_lint.mjs`](#check-lint), which lints the site's scripts in `docs/assets/js/`. The first is worth knowing in detail. Its corpus sweep tokenises every markdown file under `docs/`, so a page that provokes a rewrite into altering a code region fails it. Its fixed probes are a different matter: they run against their own sources whatever the tree holds, and they cover the *mirror* fault, where a rewrite silently stops firing. The sweep cannot see that one --- text the rewrite skipped is stashed and restored unchanged, so every region still matches. Add a page with an unusual code construct and run `test.bat`, but read the built page too. @@ -569,6 +571,17 @@ The document is written by the gate, without pdf-lib, so the forms the shims' pa Exits 1 on a difference, a shim or listed member that did not run, or a patched member that is not as listed, 2 if it cannot run. +### check_impexp_parity.mjs +{: #check-impexp-parity } + + node scripts/check_impexp_parity.mjs + +Verifies that the two editions of the [impexp tool](#impexp), `scripts/impexp.mjs` and `scripts/impexp.py`, behave the same, as that section promises. Both `--self-test` suites must pass, with the same test names in the same order. Then one sequence of commands runs through each edition, each in a scratch folder of its own holding copies of `indexer/sample.twinpack` and `test/example-projects/console`: export and import, the printing commands, and each refusal, failure and warning the exit codes name. After every command, both editions must give the exit code the command is there for, print the same on each stream, and leave the same files, compared as bytes. On Windows, Python writes CRLF to the console, so there a CRLF in the printed output is read as LF on both sides; on Linux, as in CI, the output is compared as written. About four seconds, most of it Python starting once a command. + +Without Python 3.6 or later on the `PATH` (it tries `python3`, then `python`, then `py -3` on Windows), the gate prints `SKIPPED` and exits 0, so `test.bat` passes on a machine without Python. When `CI` is `true`, as GitHub sets it, the same case fails instead: CI must compare the two. + +Exits 1 on a difference or a failed built-in test, 2 if it cannot run or finds no Python in CI. + ### check_axe_patch_equiv.mjs {: #check-axe-patch-equiv } @@ -1078,7 +1091,7 @@ The report ends with what the scanner could not resolve, and **that section is e node scripts/impexp.mjs settings|licence|changelog|readme node scripts/impexp.mjs --self-test -Standalone `.twinproj` / `.twinpack` unpacker and packer, with the compiler executable's own command line: the same six commands, the project file first, and `--overwrite` required to replace anything. `scripts/impexp.py` is the same tool, run as `python scripts/impexp.py ...`; the two editions print the same output and write byte-identical project files. Neither has dependencies; the Node edition needs Node 18+, the Python edition Python 3.6+. The exit code says what happened --- `0` done, `3` refused to overwrite, `6` done with a warning, and four more --- so a caller need not read the output; [Import/Export Tool](../../Features/Packages/Import-Export-Tool#checking-the-result) has the table. `--self-test` needs nothing but the script, and adds a round trip of `indexer/sample.twinpack` when run from this repository. +Standalone `.twinproj` / `.twinpack` unpacker and packer, with the compiler executable's own command line: the same six commands, the project file first, and `--overwrite` required to replace anything. `scripts/impexp.py` is the same tool, run as `python scripts/impexp.py ...`; the two editions print the same output and write byte-identical project files, which [`check_impexp_parity.mjs`](#check-impexp-parity) checks. Neither has dependencies; the Node edition needs Node 18+, the Python edition Python 3.6+. The exit code says what happened --- `0` done, `3` refused to overwrite, `6` done with a warning, and four more --- so a caller need not read the output; [Import/Export Tool](../../Features/Packages/Import-Export-Tool#checking-the-result) has the table. `--self-test` needs nothing but the script, and adds a round trip of `indexer/sample.twinpack` when run from this repository. **Neither is build tooling.** They are published downloads: `_config.yml`'s `bundle_extra` copies both into `Features/Packages/downloads/`, and [Import/Export Tool](../../Features/Packages/Import-Export-Tool) offers them to readers as the two editions of one tool. That is why `impexp.py` is one of only two `.py` files in a repository whose tooling is otherwise all Node --- porting it would delete a deliberate offering rather than tidy anything up. The `bundle_extra` exemption is by exact path, so moving either file breaks the download; see [`check_publish_policy.mjs`](#check-publish-policy). diff --git a/scripts/check_cli.mjs b/scripts/check_cli.mjs index da8875ff..926fff70 100644 --- a/scripts/check_cli.mjs +++ b/scripts/check_cli.mjs @@ -323,6 +323,8 @@ const CASES = [ { tool: "scripts/check_axe_patch_equiv.mjs", args: ["--patch"], exit: 2, stderr: "unknown arg: --patch\n" }, { tool: "scripts/check_pdf_shims_equiv.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/check_pdf_shims_equiv\.mjs\n/ }, { tool: "scripts/check_pdf_shims_equiv.mjs", args: ["--bogus"], exit: 2, stderr: "unknown option: --bogus\n" }, + { tool: "scripts/check_impexp_parity.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/check_impexp_parity\.mjs\n/ }, + { tool: "scripts/check_impexp_parity.mjs", args: ["--bogus"], exit: 2, stderr: "unknown option: --bogus\n" }, { tool: "scripts/check_tree_fresh.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/check_tree_fresh\.mjs / }, { tool: "scripts/check_tree_fresh.mjs", args: ["--bogus"], exit: 2, stderr: "unknown arg: --bogus\n" }, { tool: "scripts/check_tree_fresh.mjs", args: ["--source"], exit: 2, stderr: "unknown arg: --source\n" }, diff --git a/scripts/check_impexp_parity.mjs b/scripts/check_impexp_parity.mjs new file mode 100644 index 00000000..ed2f58d6 --- /dev/null +++ b/scripts/check_impexp_parity.mjs @@ -0,0 +1,252 @@ +// Checks that the two editions of the impexp tool behave the same. +// +// scripts/impexp.mjs and scripts/impexp.py are one tool offered to readers in +// two languages, and Tools.md promises that they print the same output and +// write byte-identical files. Each has the same built-in tests, and nothing ran +// them. This runs both suites, which must pass with the same test names in the +// same order, and then runs one sequence of commands through each edition, each +// in a scratch folder of its own holding copies of indexer/sample.twinpack and +// test/example-projects/console: export, import, the printing commands, and +// each refusal and failure the exit codes name. After every command the two +// exit codes, the two outputs and the two folders' files must be the same. +// +// Python's text streams write CRLF on Windows, so there a CRLF in the printed +// output is read as LF on both sides; on Linux, as in CI, the output is +// compared as written. Written files are always compared as bytes. +// +// Without Python 3.6 or later this reports the gate skipped, loudly, and exits +// 0, except in CI (GitHub sets CI=true), where it fails: CI must compare them. +// +// node scripts/check_impexp_parity.mjs +// +// Exit codes: 0 the same, or skipped outside CI; 1 a difference or a failed +// built-in test; 2 the check itself failed, or found no Python in CI. + +import { spawn, spawnSync } from "node:child_process"; +import { cpSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { exitOnCrash } from "./lib/gate-probes.mjs"; + +exitOnCrash(); + +const cli = withUsageError(() => + parseCli(process.argv.slice(2), { options: { help: { type: "boolean", short: "h" } }, stopAt: ["help"] }) +); +if (cli.stopped === "help") { + printHelpAndExit(`usage: node scripts/check_impexp_parity.mjs + +Runs the built-in tests of scripts/impexp.mjs and scripts/impexp.py, and one +sequence of commands through each, comparing exit codes, printed output and +written files. Without Python 3.6 or later it reports the check skipped and +exits 0, or fails when CI=true. Exit 0 the same or skipped, 1 a difference or +a failed test, 2 the check itself failed or found no Python in CI.`); +} + +const TOOL = "check_impexp_parity"; +const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); +const NODE_EDITION = path.join(ROOT, "scripts", "impexp.mjs"); +const PYTHON_EDITION = path.join(ROOT, "scripts", "impexp.py"); +const SAMPLE = path.join(ROOT, "indexer", "sample.twinpack"); +const CONSOLE = path.join(ROOT, "test", "example-projects", "console"); +const TIMEOUT_MS = 60_000; +const IN_CI = process.env.CI === "true"; + +// The commands, run in order in each edition's folder, so each sees what the +// ones before it wrote. `setup` changes both folders the same way first, and +// `exit` is the code both must give, so each command is known to reach the +// branch it is here for. +const COMMANDS = [ + { args: ["--help"], exit: 0 }, + { args: ["export", "sample.twinpack", "sample"], exit: 0 }, + { args: ["export", "sample.twinpack", "sample"], exit: 3, why: "refused: the files exist" }, + { args: ["export", "sample.twinpack", "sample", "--overwrite"], exit: 0 }, + { + args: ["export", "sample.twinpack", "sample", "--overwrite"], + exit: 6, + why: "warns: a file the project lacks", + setup: (dir) => writeFileSync(path.join(dir, "sample", "extra.txt"), "not in the project\r\n"), + }, + { args: ["import", "repacked.twinpack", "sample"], exit: 0, why: "packs the extra file in" }, + { + args: ["import", "console.twinproj", "console"], + exit: 0, + why: "with a README and a code file in LF", + setup: (dir) => { + writeFileSync(path.join(dir, "console", "README.md"), "# Console\r\n\r\nCaf\u{e9}, in UTF-8.\r\n"); + writeFileSync(path.join(dir, "console", "Sources", "Extra.twin"), "Module Extra\n Sub Nothing()\n End Sub\nEnd Module\n"); + }, + }, + { args: ["import", "console.twinproj", "console"], exit: 3, why: "refused: the project exists" }, + { args: ["import", "console.twinproj", "console", "--overwrite"], exit: 0 }, + { args: ["export", "console.twinproj", "console-out"], exit: 0 }, + { args: ["settings", "sample.twinpack"], exit: 0 }, + { args: ["licence", "sample.twinpack"], exit: 0 }, + { args: ["changelog", "sample.twinpack"], exit: 0 }, + { args: ["readme", "sample.twinpack"], exit: 4, why: "the project has no README.md" }, + { args: ["readme", "console.twinproj"], exit: 0 }, + { args: ["export", "missing.twinproj", "out"], exit: 4, why: "the project file does not exist" }, + { + args: ["import", "empty.twinproj", "empty"], + exit: 5, + why: "the folder has no Settings", + setup: (dir) => mkdirSync(path.join(dir, "empty")), + }, + { + args: ["settings", "damaged.twinproj"], + exit: 5, + why: "the project file is damaged", + setup: (dir) => writeFileSync(path.join(dir, "damaged.twinproj"), "not a project file"), + }, + { args: ["export", "sample", "sample.twinpack"], exit: 2, why: "the arguments in the wrong order" }, + { args: ["bogus", "sample.twinpack"], exit: 2, why: "an unknown command" }, + { args: ["export", "sample.twinpack", "sample", "--bogus"], exit: 2, why: "an unknown option" }, +]; + +// The first Python 3.6+ on PATH, as [command, ...args, version], or null. +function findPython() { + const candidates = [["python3"], ["python"]]; + if (process.platform === "win32") candidates.push(["py", "-3"]); + for (const [cmd, ...pre] of candidates) { + const r = spawnSync(cmd, [...pre, "--version"], { encoding: "utf8", timeout: TIMEOUT_MS }); + const m = /^Python (3)\.(\d+)\.\S+/.exec(`${r.stdout ?? ""}${r.stderr ?? ""}`.trim()); + if (r.status === 0 && m && Number(m[2]) >= 6) return { cmd, pre, version: m[0].slice(7) }; + } + return null; +} + +const python = findPython(); +if (!python) { + const tried = process.platform === "win32" ? "python3, python, py -3" : "python3, python"; + if (IN_CI) { + console.error(`${TOOL}: no Python 3.6 or later found (tried ${tried}), and CI must compare the two editions`); + process.exit(2); + } + console.error( + `${TOOL}: SKIPPED -- no Python 3.6 or later found (tried ${tried}).\n` + + `${TOOL}: impexp.mjs and impexp.py were NOT compared; CI compares them and fails without Python.` + ); + process.exit(0); +} + +const editions = [ + { name: "impexp.mjs", cmd: process.execPath, pre: [NODE_EDITION] }, + { name: "impexp.py", cmd: python.cmd, pre: [...python.pre, PYTHON_EDITION] }, +]; + +// One command through one edition. The two editions run each command at the +// same time, each in its own folder. +function run(edition, args, cwd) { + const text = (chunks) => { + const s = Buffer.concat(chunks).toString("latin1"); + return process.platform === "win32" ? s.replace(/\r\n/g, "\n") : s; + }; + return new Promise((resolve, reject) => { + const child = spawn(edition.cmd, [...edition.pre, ...args], { cwd, timeout: TIMEOUT_MS }); + const out = []; + const err = []; + child.stdout.on("data", (b) => out.push(b)); + child.stderr.on("data", (b) => err.push(b)); + child.on("error", (e) => reject(new Error(`${edition.name} ${args.join(" ")}: ${e.message}`))); + child.on("close", (status, signal) => { + if (status === null) reject(new Error(`${edition.name} ${args.join(" ")}: ended by ${signal}`)); + else resolve({ status, stdout: text(out), stderr: text(err) }); + }); + }); +} + +// Every file under dir, by path relative to it with forward slashes. +function files(dir) { + const out = new Map(); + for (const e of readdirSync(dir, { recursive: true, withFileTypes: true })) { + if (!e.isFile()) continue; + const full = path.join(e.parentPath, e.name); + out.set(path.relative(dir, full).split(path.sep).join("/"), readFileSync(full)); + } + return out; +} + +// The first line two texts differ at, quoted from each, or null. +function firstDifference(a, b) { + if (a === b) return null; + const la = a.split("\n"); + const lb = b.split("\n"); + let i = 0; + while (i < la.length && i < lb.length && la[i] === lb[i]) i++; + const q = (l) => (l === undefined ? "(ends)" : JSON.stringify(l.length > 100 ? `${l.slice(0, 100)}...` : l)); + return `line ${i + 1}: ${q(la[i])} and ${q(lb[i])}`; +} + +// The files that differ between the two folders, each named only the first +// time, so a difference is reported against the command that made it. +const reported = new Set(); +function compareFolders(a, b) { + const fa = files(a); + const fb = files(b); + const problems = []; + const report = (p, what) => { + if (!reported.has(p)) problems.push(`${p} ${what}`); + reported.add(p); + }; + for (const p of fa.keys()) if (!fb.has(p)) report(p, "written by impexp.mjs only"); + for (const p of fb.keys()) if (!fa.has(p)) report(p, "written by impexp.py only"); + for (const [p, bytes] of fa) { + if (fb.has(p) && !bytes.equals(fb.get(p))) report(p, `differs (${bytes.length} and ${fb.get(p).length} bytes)`); + } + return problems; +} + +const problems = []; + +// The built-in tests: both pass, with the same names in the same order. The +// first line names a temporary folder, which differs. +const selfTestRuns = await Promise.all(editions.map((e) => run(e, ["--self-test"], ROOT))); +const selfTests = editions.map((e, i) => { + const r = selfTestRuns[i]; + const lines = r.stdout.split("\n").filter((l) => !l.startsWith("Self-test workdir:")); + const names = lines.filter((l) => /^\s+\[(PASS|FAIL|SKIP)\] /.test(l)).map((l) => l.trim()); + if (r.status !== 0) problems.push(`${e.name} --self-test exited ${r.status}`); + for (const n of names) if (!n.startsWith("[PASS] ")) problems.push(`${e.name} --self-test: ${n}`); + if (names.length === 0) problems.push(`${e.name} --self-test ran no test`); + return { names, text: `${lines.join("\n")}\n${r.stderr}` }; +}); +const selfTestDifference = firstDifference(selfTests[0].text, selfTests[1].text); +if (selfTestDifference) problems.push(`--self-test prints differently, ${selfTestDifference}`); + +const scratch = mkdtempSync(path.join(tmpdir(), "impexp-parity-")); +try { + const dirs = editions.map((e) => { + const dir = path.join(scratch, e.name); + mkdirSync(dir); + cpSync(SAMPLE, path.join(dir, "sample.twinpack")); + cpSync(CONSOLE, path.join(dir, "console"), { recursive: true }); + return dir; + }); + for (const c of COMMANDS) { + const label = `impexp ${c.args.join(" ")}${c.why ? ` (${c.why})` : ""}`; + for (const dir of dirs) c.setup?.(dir); + const [a, b] = await Promise.all(editions.map((e, i) => run(e, c.args, dirs[i]))); + if (a.status !== c.exit || b.status !== c.exit) { + problems.push(`${label}: exit ${a.status} from impexp.mjs and ${b.status} from impexp.py, not ${c.exit}`); + } + for (const stream of ["stdout", "stderr"]) { + const d = firstDifference(a[stream], b[stream]); + if (d) problems.push(`${label}: ${stream} differs, ${d}`); + } + for (const p of compareFolders(dirs[0], dirs[1])) problems.push(`${label}: ${p}`); + } +} finally { + rmSync(scratch, { recursive: true, force: true }); +} + +if (problems.length) { + console.error(`${TOOL}: impexp.mjs and impexp.py (Python ${python.version}) differ:`); + for (const p of problems) console.error(` ${p}`); + process.exit(1); +} +console.log( + `${TOOL}: impexp.mjs and impexp.py (Python ${python.version}) pass the same ${selfTests[0].names.length} ` + + `built-in tests, and ${COMMANDS.length} commands exit, print and write the same` +); diff --git a/test.bat b/test.bat index f1aa398c..57fc415d 100644 --- a/test.bat +++ b/test.bat @@ -142,6 +142,15 @@ node scripts/check_cli.mjs @rem it. No tree, no browser, ~0.5 s. node scripts/check_pdf_shims_equiv.mjs @if errorlevel 1 goto :fail +@rem impexp.mjs and impexp.py are one published tool in two languages, and +@rem Tools.md promises they print the same and write the same bytes. Both +@rem built-in test suites must pass with the same names, and one sequence +@rem of commands runs through each edition, comparing exit codes, output +@rem and written files. Without Python it says SKIPPED and passes here; +@rem in CI (CI=true) it fails instead. No tree, no browser, ~4 s, most of +@rem it Python starting 21 times. +node scripts/check_impexp_parity.mjs +@if errorlevel 1 goto :fail @rem check_a11y.mjs injects a PATCHED axe bundle (plain-color-fields, @rem -26 % on a realistic page set). The patch asserts its substitution @rem targets, so an axe-core bump fails loudly; this catches the other From 2f7bf05a9a83c42ad22cb621285bdcf7a1d3413b Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober Date: Mon, 28 Sep 2026 23:23:15 +0200 Subject: [PATCH 11/21] builder: cut Phase 2's landed entries in the tooling plan --- builder/PLAN-TOOLING-REVIEW.md | 2493 +++----------------------------- 1 file changed, 175 insertions(+), 2318 deletions(-) diff --git a/builder/PLAN-TOOLING-REVIEW.md b/builder/PLAN-TOOLING-REVIEW.md index 8f94d779..61e2bf32 100644 --- a/builder/PLAN-TOOLING-REVIEW.md +++ b/builder/PLAN-TOOLING-REVIEW.md @@ -167,9 +167,11 @@ work still needs. The landed entries of C01–C18 and C13a were cut this way on their full text is in this file as it stood before the commit `builder: cut the tooling plan's landed entries to what later work needs`. Those of the rest of Phase 1 (C19–C30, with C22a–C22j, C25a–C25f, C27a and C27b) were cut the same day, and their full text is in this -file as it stood before `builder: cut Phase 1's landed entries in the tooling plan`. A -pointer below to a cut entry's Landed note means that text. Line numbers are the -review's, at `fe9ce12b`, and move as the commits land. +file as it stood before `builder: cut Phase 1's landed entries in the tooling plan`. Those of +Phase 2 (C31–C70, with C32a, C41a–C41c, C51a, C51b, C65a–C65e, C67a and C67b) were cut on +2026-09-28, and their full text is in this file as it stood before `builder: cut Phase 2's +landed entries in the tooling plan`. A pointer below to a cut entry's Landed note means that +text. Line numbers are the review's, at `fe9ce12b`, and move as the commits land. ### The organising idea @@ -214,7 +216,8 @@ Four commits wait for the owner before a command runs: C05, C09 and C40 change i packages, and C08 changes this clone's git configuration. All four were confirmed on 2026-09-25, C08 on condition that the hook runs Biome and nothing else. The commits that change a workflow or add a gate to one (C03, C04, C06, C47, C62, C66, C70, C87) wait for the owner's next push -before CI can confirm them. +before CI can confirm them. CI has confirmed those through C62; C66 and C70 wait for the next +push. ### Where this plan departs from the review @@ -327,8 +330,7 @@ Landed. ### C10 — `scripts: move census_attributes.mjs out of builder/` -**Carried forward.** `census_attributes.mjs` moved from `builder/` to `scripts/`, beside -`build_package_api.mjs` (C38 relies on both readers now living in `scripts/`). +Landed. ### C11 — `scripts: census_attributes finds the install through tb-install` @@ -387,9 +389,8 @@ value exits 2." **Carried forward.** `scripts/lib/browser.mjs` holds `launchBrowser` and `withBrowser(fn, options)`, which closes the browser in a `finally`; `LAUNCH_ARGS` is no longer exported. -`axe-scan.mjs` re-exports `launchBrowser` for the `perf/` rigs that import it. C44 moves -`check_dot_fit.mjs` and `build_dot_metrics.mjs` onto the same module, adding a file-access -launch option to it. +`axe-scan.mjs` re-exports `launchBrowser` for the `perf/` rigs that import it. +`check_dot_fit.mjs` and `build_dot_metrics.mjs` launch through the same module (C44). ### C20 — `a11y: validate --theme and --viewport wherever a matrix is built` @@ -481,11 +482,7 @@ Landed. ### C26 — `wisdom: parseStaging refuses a chunk it cannot place` -**Carried forward.** `parseStaging` throws, naming the line, when the chunk after a `---` line -does not start with `## `, so a `---` inside a fenced sample stops the run. C26's oracle, a -scratch script not in the repository, ran it on the real `staging.md` and on synthetic files, -one of them a section whose fenced sample holds a `---`. C36's Verify expects that file to -keep its section's tail and its `_Source threads:_` line. +Landed. ### C27 — `wisdom: write manifest.json and denied.json atomically` @@ -501,11 +498,7 @@ Landed. ### C28 — `scripts: exit 2 on a crash in three tools that exit 1` -**Carried forward.** `pick_a11y_sample.mjs`, `build_dot_metrics.mjs` and -`check_tb_registry.mjs` each gained `check_dot_fit.mjs`'s crash handler, installed after -their imports, exiting 2 on a crash; `check_tb_registry.mjs`'s catch passes on everything but -an `AssertionError`. C43 folds these three handlers, and C07's, into `lib/gate-probes.mjs`'s -shared handler. +Landed. ### C29 — `test.bat: cite check_gate_lists for the gate's history` @@ -531,2476 +524,340 @@ the shim refactors it checks. ### C31 — `lib: markdown.mjs and frontmatter.mjs, with their probes` -**Decision (a).** The module the inventory sketched (`markdown-inventory.md`, Task 3), built -on what it measured: block tokens have `.map` line ranges, blockquote and list prefixes -included; inline tokens have none; frontmatter must be split off before markdown-it sees a -page, which otherwise reads it as a rule and a setext heading; a block-only parse of the -whole corpus takes about 34 ms. - -**Change.** - -- `lib/markdown.mjs`: `blockRegions(src, {md})`, every fence, code block and HTML block with - its line range, from a block-only parse; `maskCode(src, {indented})`, the mask, rewrite and - restore helper, with markdown-it's CommonMark opener rules, the info-string rule that - `maskCodeRegions` lacks included; `splitCodeSpans(line)`, the one tested backtick and tilde - run scanner, since markdown-it gives inline spans no offsets; `splitOnMarker(src, - isMarker)`, sections split on a marker line outside any code region; and a line-splice - helper that keeps each line's ending, which `convert_em_dash_separators.mjs` and - `check_examples.mjs` both do by hand. -- The regions come from the caller's markdown-it instance when it passes one. `render.mjs` - passes the site's, because its plugins (the definition-list one among them) change what - counts as a block; other callers get a bare `html: true` instance. -- `lib/frontmatter.mjs`: `parseFrontmatter(raw)` strips a BOM, splits off the `---` block, - parses it with `js-yaml` 4, and leaves the content's bytes untouched. -- The probes ride along in `check_code_regions.mjs`, which becomes the gate on the module as - well as on the rewrites: A3-1's shape (a ```` ```abc`def ```` fence holding a - `> [!NOTE]`), fences inside blockquotes and list items, the 16 code-span cases V1 used for - L4-7, CRLF input, a BOM, a fenced `---`, and frontmatter that markdown-it would read as a - heading. - -**Verify.** The probes. Over every page under `docs/`, `blockRegions` finds the same 1,371 -fences as `check_code_regions.mjs`'s own pass over the tokens. - -**Landed.** `lib/markdown.mjs` exports `blockRegions(src, { md })`, `maskCode(src, { md, -indented })`, `splitCodeSpans(line)`, `splitOnMarker(src, isMarker, { md })` and `mapLines(src, -fn)`, the line-splice helper. `lib/frontmatter.mjs` exports `parseFrontmatter(raw)`. Nothing -adopts either yet. `check_code_regions.mjs` gained eleven module probes, one of them the -sixteen code-span cases, and its sweep now checks on every page that `blockRegions` gives the -full parse's fences, code blocks and HTML blocks at the same lines; its summary line adds the -fence count. `lib/README.md`, Tools.md's list entry and section, Extending.md's "Say what a -pass covered" and WIP.md's gate row say so. - -What the module decides, which later entries rely on: -- **Lines** end at CRLF, LF or a lone CR, as in CommonMark, and are 0-based. `blockRegions` - applies markdown-it's `normalize` rule itself, since a block-only parse skips it: without - it a CRLF closing fence closes nothing. `mapLines` and `splitOnMarker` split the same way, - so a region's `start` and `end` index what they see. Neither gives a line for the empty - remainder after a final line ending, where `split("\n")` gives one (C35, C36). -- **A region** is `{ type, start, end, markup, info, content }`: a half-open range whose - lines keep their blockquote and list prefixes, and markdown-it's token fields. Regions are - disjoint and in document order. -- **`maskCode`** replaces a fence whole, prefixes included, with one line that starts with a - backtick, as `maskCodeRegions` did; the line's ending stays outside the placeholder. - Code spans are masked on every other line, HTML blocks included, and indented code blocks - only under `indented: true`. -- **`splitOnMarker`** returns `{ marker, start, lines }[]`: first the lines before any marker, - with `marker` null, then each marker line's text and index with the lines after it, line - endings dropped. A line inside any region, HTML blocks included, is never a marker. -- **`splitCodeSpans`** is `convert_em_dash_separators.mjs`'s `splitInlineCode` verbatim, so - V1's equivalence with `maskInlineCode` carries over. It scans backticks only: the entry's - "backtick and tilde run scanner" is wrong, since a code span cannot be written with tildes. - Its two known gaps are stated beside it, and both copies had them: a span over two lines - is not seen, and a backslash before a backtick does not stop it opening a span. -- **`parseFrontmatter`** returns `{ data, content }`, or null when the first line is not - `---`. It differs from gray-matter only on input no page holds (C40, C41): a block closes - only at a line that is `---` with optional trailing whitespace, where gray-matter closes at - the first `\n---` whatever follows; nothing may follow the opening `---` but whitespace, - where gray-matter reads a language name there; an unclosed block throws, where gray-matter - parses the rest of the file as YAML; a block that parses to anything but a mapping throws. - An empty block, or one of only comments, gives `{}`, as gray-matter's does. A YAML error - names the line of the file, because the parse is given a newline in place of the opener. - -**Verify.** The eleven module probes and `--self-test` pass. Over all 912 pages -`blockRegions` agrees with the full parse, and finds **1,372 fences, not 1,371**: one has -been added since the review. Each of eight faults put into the modules fails the gate: -dropping the newline normalisation (the CRLF probe and 598 pages), a marker split that -ignores regions, a span closed by a longer run, `blockRegions` ignoring the parser passed, -indented blocks always masked, the placeholder losing its line ending (three probes), no BOM -strip, and no stand-in line for js-yaml (the error-line probe). `maskCodeRegions` fails the -A3-1 probe's mask assertion: it masks the ```` ```abc`def ```` line and all after it as a -fence. Ahead of C32 and C40, two scratch comparisons over the corpus: `maskCode` with the -site's parser against `maskCodeRegions`, on the content `render.mjs` sees, gives the same -masked text on 905 of 912 pages (the other seven are under C32); `parseFrontmatter` against -gray-matter as `discover.mjs` calls it gives the same result on all 912 pages and on the two -tracked `.html` pages, which have frontmatter too: data deep-equal, content byte-equal. The -tree comparison differs only where the commit edits pages: Tools and Extending online and -offline, the search data, and `book.html`. Nothing in `builder/` imports the modules yet. - -Writing the modules, the Write tool turned a four-digit `\u` escape into the character it -names, twice; a scan for raw non-ASCII found both. WIP.md's Don'ts now say so, in the commit -before this one. +**Carried forward.** `lib/markdown.mjs` is the one answer to what is code. `blockRegions(src, +{ md })` returns every fence, indented code block and HTML block as `{ type, start, end, +markup, info, content }`: a half-open, 0-based line range whose lines keep their blockquote +and list prefixes, with lines ending at CRLF, LF or a lone CR. `maskCode(src, { md, indented +})` replaces a fence whole with one line that starts with a backtick, and masks code spans on +every other line, indented blocks only under `indented: true`. `splitOnMarker` never takes a +line inside a region as a marker, and `mapLines` splices lines and keeps each ending. +`splitCodeSpans` scans backticks only, and misses a span over two lines and a backslash before +a backtick. A caller that reads a page passes the site's markdown-it instance, whose plugins +change what counts as a block; a caller that reads a file that is not a page uses the bare +one. `lib/frontmatter.mjs`'s `parseFrontmatter(raw)` returns `{ data, content }`, or null when +the first line is not `---`. ### C32 — `render: the pre-render rewrites ask lib/markdown what is code` -**A3-1 (R1), first half.** `maskCodeRegions` (`render.mjs:141-175`) accepts a backtick fence -whose info string holds a backtick, and `stashCodeFences` (`:1704-1730`) refuses it, as -CommonMark does. Reproduced: ```` ```abc`def ```` is protected by one and exposed to the -other, and a `> [!NOTE]` inside it becomes a live admonition. `check_code_regions.mjs` cannot -see this class of fault. - -**Change.** `applyPreRenderRewrites` masks through `maskCode` and `splitCodeSpans`, with the -site's instance and `indented: false` as today. `maskCodeRegions` and `maskInlineCode` go; -`counts.mjs`'s `findCountRefs`, which reuses the mask, follows. - -**Verify.** The tree comparison identical, since no page holds the shape. -`check_code_regions.mjs` clean, its mirror-fault probes included. - -**Found in C31: the new mask covers seven fences the old one does not.** `maskCodeRegions` -masks a fence only when nothing but up to three spaces or tabs precedes its opener, and -`maskCode` masks every fence markdown-it finds. On the content `render.mjs` sees, with the site's parser, the -two masks give the same text on 905 of 912 pages. The other seven each hold a `> ```tb` -fence inside an admonition, which only the new mask hides: `CEF/CefBrowser/index.md`, -`WebView2/WebView2/index.md`, `tbIDE/HtmlElement.md`, `Core/If-Then-Else.md`, -`Core/Option.md`, `Core/WithEvents.md` and `VBA/Interaction/InputBox.md`. The tree comparison -should still come out identical, but that is reasoned, not measured: `check_code_regions.mjs` -already shows that no rewrite alters those fences, and `rewriteAdmonitions` runs after the -restore. If it differs, look at those seven pages first. Two more things this commit must -settle. `applyPreRenderRewrites` takes no parser today, so it needs one to pass the site's. -And `check_code_regions.mjs` calls it: with the bare parser by default, the gate would mask -differently from the build wherever the definition-list plugin makes a fence. - -**Landed.** `applyPreRenderRewrites(rawContent, md)` masks with `maskCode(source, { md })` -and throws a `TypeError` when given no parser, so no caller can mask differently from the -build without noticing; `renderPage` passes the site's. `maskCodeRegions` and -`maskInlineCode` are gone. `findCountRefs(rawContent, md)` and `validateCountNames(pages, -counts, md)` mask the same way, so `counts.mjs` no longer imports `render.mjs` and the -circular import between them is gone; `tbdocs.mjs` now creates the site's parser before it -validates the count names. `check_code_regions.mjs` builds the site's parser with -`createMarkdownIt` and passes it to the chain, which settles the entry's question, and gains -`UNCHANGED_PROBES`: sources the chain must return byte for byte, one so far, a fence the -definition-list plugin makes. That probe had to be a direct comparison. The region -comparison parses bare, finds no fence there, and so never sees the fence's body rewritten. -`check_examples.mjs`'s markup probe masks with `maskCode`. At the owner's request, -`encodeSpacesInMediaUrls`' comment, stranded above `applyPreRenderRewrites`, went back above -its function. Citations of the old mask are updated in `builder/README.md`, WIP.md, -WIP.Build.md, WIP.ExamplesBuild.md, Extending, Authoring, Pipeline-Stages, -`eval/usecases.md` and `scripts/lib/tb-fences.mjs`; two of those said the old mask skipped a -fence whose info string holds a backtick, which A3-1 shows it did not. The comment above -`stashCodeFences` still names `maskCodeRegions`; C33 deletes it. - -For later entries: the region comparison cannot see a code region that only the site's -parser finds, so a probe for one compares the chain's output directly. With the mask taken -out of the chain altogether, the corpus sweep still reports no page altered; today only the -probes catch that fault. - -**Verify.** Before the citation edits the tree comparison was identical (1,461 files online, -1,457 offline, 137 pdf), the seven admonition-fence pages included, so the entry's reasoning -now has a measurement behind it. After them it differs only in Authoring, Extending and -Pipeline-Stages online and offline, the search data, and `book.html`. -`check_code_regions.mjs`: `912 file(s), 0 with altered code regions, 1372 fence(s) in the -full parse -- clean`, with 7, 5, 1 and 11 probes. Three faults each fail it with exit 1: the -gate passing its bare parser to the chain and the mask ignoring the parser it is given both -fail the new probe, and the chain rewriting unmasked source fails three fence probes and the -new one. `check_examples.mjs --census` runs the markup probe: `ok 119 probes`. From a -scratch page, the count validator ignores a name in a fence, in a code span and in a -definition-list fence, and reports one in prose. - -Found: the line `validateCountNames` reports is a line of the masked content after the -frontmatter, not of the file: `x.md:5` for a name on line 12, below three lines of -frontmatter and a five-line fence. C32 keeps that number, and C32a fixes it. +Landed. ### C32a — `builder: an unknown count name is reported at its file line` -**Found in C32** (see Found while implementing); the owner asked for the fix as its own -commit straight after C32, so that C32 changes nothing. - -**Landed.** `discover` gives each page `contentLine`, the file's 1-based line on which -`rawContent` starts, counted from the text before gray-matter's content. `findCountRefs` -counts a reference's line in `rawContent` with the code put back, so a masked fence counts -all its lines, and `validateCountNames` adds `contentLine - 1`. C40 needs no change for it, -because the line comes from the content's length. If `parseFrontmatter`'s content is ever -not the file's tail, the probe fails. The probe is in `check_code_regions.mjs`, which the -owner chose because the validator asks `lib/markdown` what is code. It writes one page to a -temporary folder and runs it through the real `discover` and `validateCountNames`: CRLF -frontmatter, count names in a fence, a code span and a definition-list fence, then an -unknown one in prose on line 16. Pipeline-Stages gains the `contentLine` field and states -both functions' lines. Tools.md's section and WIP.md's gate row name C32's probe and this one. - -**Verify.** HEAD's validator reports the probe's page at `x.md:9`, and this one at -`x.md:16`. Four faults each fail the probe: lines counted in the masked content, no -frontmatter offset, `discover` setting no `contentLine`, and the validator masking with a -bare parser. Over all 914 pages `discover` finds (912 `.md`, 2 `.html`), the file from line -`contentLine` on is exactly `rawContent`. The tree comparison differs only where the commit -edits pages: Pipeline-Stages and Tools online and offline, the search data, and `book.html`. +Landed. ### C33 — `render: admonitions find their fences through lib/markdown` -**A3-1, second half.** `rewriteAdmonitions` protects fences with its own `stashCodeFences`. -It runs after the mask is restored, so it must recognise a fence inside an admonition with -its `> ` markers still on; `blockRegions`' ranges include those prefixes. - -**Change.** `stashCodeFences` goes, and `rewriteAdmonitions` skips the ranges `blockRegions` -reports. A3-1's reproduction becomes a probe in `check_code_regions.mjs` against the whole -pre-render chain. - -**Verify.** The tree comparison identical. `check_code_regions.mjs`'s `ADMONITION_PROBES` -(five variants, `Attributes.md`'s literal fence strings among them) and the new probe pass; -putting back a private opener test fails the new one. - -**Landed.** `stashCodeFences` and its `FENCE_OPEN_RE` are gone, with the comment above them -that named `maskCodeRegions`. `rewriteAdmonitions(src, md)` asks `blockRegions` with the -site's parser, which `applyPreRenderRewrites` passes on, and throws a `TypeError` without one, -as the chain does. It leaves a match alone when the match's `[!TYPE]` line is in a region. -That is narrower than the entry's "skips the ranges": a fence inside an admonition is a -region too, and the rewrite must still strip its `> ` markers, so only the opener line -decides. HTML blocks count as regions, as they do for `splitOnMarker`; the old stash did not -protect them, but no page has an admonition in one (below). The new comment says what decides -and points to WIP.Build.md for the two defects the old scan had. Two probes: A3-1's shape -joins `ADMONITION_PROBES`, six now, with a fence after it that a wrong opener would close on; -`UNCHANGED_PROBES` gains an admonition written inside a definition-list fence, the shape where -the old scan and the parser disagree. The gate's failure message and summary line no longer -name the stasher. Pipeline-Stages (both functions' rows), Extending (its sample passes `md`, -and a rewrite outside the mask can skip `blockRegions`' lines), Authoring, Building, Tools -and WIP.Build say so; what WIP.Build tells of the old scan stays in the past tense. - -**Verify.** Before the change, over all 912 pages: 632 admonition openers, one of them in a -region (a fence in `Documentation/Wisdom.md`), which the old stash protected too, and none in -an indented code block or an HTML block. The tree comparison with the page edits set aside is -identical (1,461 files online, 1,457 offline, 137 pdf); with them it differs only in -Authoring, Building, Extending, Pipeline-Stages and Tools online and offline, the search -data, and `book.html`. `check_code_regions.mjs`: 7, 6, 2 and 11 probes, and `912 file(s), 0 -with altered code regions, 1372 fence(s) in the full parse -- clean`. Four faults each fail -it with exit 1: the rewrite ignoring the regions (the new unchanged probe, and Wisdom.md's -fence altered), the rewrite asking a bare parser (the new unchanged probe), a private opener -test that allows a backtick in the info string, as the old mask's did (both new probes), and -HEAD's `render.mjs` put back whole, stash and all (the new unchanged probe). +Landed. ### C34 — `scripts: convert_em_dash_separators reads code regions from lib/` -**L3-4, L4-7, merged into A3-1.** Its `FENCE_OPEN_RE` (`:59-60`) is byte for byte -`maskCodeRegions`' pattern, with the same gap, and `splitInlineCode` (`:74-99`) is a second -copy of the code-span scan, equivalent today; the tool's own comment (`:70-73`) records a bug -it already shipped in that scan. - -**Change.** Code regions from `blockRegions`, code spans from `splitCodeSpans`, line endings -kept by the module's splice. The file's accepted gap for two-space list-item fences -(`Reference/Core/Get.md`, `Option.md`) closes, because markdown-it recognises those fences. - -**Verify.** `--check` over `docs/` exits 0 before and after. A seeded probe file with CRLF -endings, a doubled-backtick span and a list-item fence has only its prose converted, and -keeps its endings. - -**Landed.** `convertText(text, md)` takes its code regions from `blockRegions` with the -site's parser, which `main` builds with `createMarkdownIt`, and throws a `TypeError` without -one, as the build's two rewrites do. A line inside any region is left alone, and the other -lines go through `splitCodeSpans`, rejoined by `mapLines`. HTML blocks count, because the -typographer converts only text tokens, so a dash in one is literal on the page. `FENCE_OPEN_RE`, -`FENCE_CLOSE_RE`, `splitInlineCode` and `KEEP_ENDS` are gone, and so is the comment on the -known gap for indented code blocks, which this closes. The entry's gap for two-space -list-item fences was already closed by `427f77a6`, whose opener allows up to three spaces. -**The old scan closed no fence** (see Found while implementing), and deleting it is the fix, -at the owner's choice. Also at the owner's choice, the entry's seeded file became seven -permanent `DASH_PROBES` in `check_code_regions.mjs`: a fence after an earlier fence with CRLF -and lone-CR endings, a doubled-backtick span, a fence five spaces into a nested list item, -one behind `> `, an indented code block beside an HTML block, a definition-list fence, and a -See Also separator. Tools.md's two sections and WIP.md's gate row say so. The exit paragraph -in Tools.md now says "any other probe" where it named module probes and left out two kinds. - -**Verify.** `--check` over `docs/` exits 0 before and after, with `Files affected: 0`. Over -all 912 pages against the old scan: 37,861 lines it read as code are prose to `blockRegions`, -in 1,386 runs across 598 files, all after the page's first fence. 304 lines it read as prose -are code now: 15 in fences behind `> `, 180 in indented code blocks and 109 in HTML blocks. -The pages hold 77 literal dashes in fences and 4 on other lines, all four in code spans -(`Authoring.md:388`, `Tools.md:596`), so no dash got past the old scan. The site's parser and -a bare one find the same regions on every page, and so do the whole file and its content -after the frontmatter. `check_code_regions.mjs`: 7, 6, 2, 7, 11 and 1 probes, and `912 -file(s), 0 with altered code regions, 1372 fence(s) in the full parse -- clean`. Six faults -each fail it with exit 1: HEAD's scan put back and the regions -ignored each fail five probes, the first among them; a bare parser fails the definition-list -probe; skipping fences only fails the indented-block probe; unsplit code spans fail the span -probe; and normalised line endings fail the first. The tree comparison differs only in Tools -online and offline, the search data, and `book.html`. +Landed. ### C35 — `scripts: check_examples' marker splice uses lib/markdown's line splice` -**Inventory site A4.** `applyMarkers` (`check_examples.mjs:1123-1154`) already finds its line -through markdown-it and re-verifies it before editing; only the split, edit and rejoin that -keeps CRLF is private. - -**Change.** The splice comes from `lib/markdown.mjs`; the re-verification stays. - -**Verify.** An equivalence run in a scratch script: the old and new splice give identical -files for every `tb` fence opener in `docs/`. - -**Landed.** `applyMarkers` rewrites each file through `mapLines`, with a map from a line's -0-based index to the fence found there; the re-verification and its finding are unchanged, -and a refused line comes back as it was. The one difference is intended: `collectFences` -numbers lines from a full markdown-it parse, which also ends a line at a lone CR, and -`split("\n")` did not, so below a lone CR the old splice read the wrong line and refused the -fence. `mapLines` splits as the parse does. - -**Verify.** HEAD's `applyMarkers` and this one, each cut from its file and run with a fake -`fs` over the 1,222 `tb` fences `collectFences` returns from 606 pages, every one claimed -unmarked with the info string `tb`. On the pages as they are, both refuse all 1,222 and write -back identical files. With ` check_build` taken off every opener that carries it (569 pages), -both mark 950 and refuse 272, and the marked pages are byte for byte the pages in the tree, -all 606 of them, CRLF included. For a fence below a line holding a lone CR, HEAD refuses and -this marks. -`check_examples.mjs --census` loads it and passes its 119 probes. +Landed. ### C36 — `wisdom: parseStaging splits only on real section boundaries` -**L3-3 (R1), second half.** After C26 a fenced `---` makes the run fail instead of dropping -content; this makes it not a boundary at all. - -**Change.** `parseStaging` splits with `splitOnMarker(src, line => line === "---")`, and -`serializeStaging` writes back through the same module. - -**Verify.** The real `staging.md` parses to the same 1,160 sections, with the same headings -and metadata, and serialises to the same bytes as before. C26's synthetic file now keeps its -section's tail and metadata. - -**Landed.** `parseStaging` splits with `splitOnMarker(content, (line) => line === '---')`, -with the bare parser, since `staging.md` is not a page. Each chunk's first line for C26's -error comes from the marker's index, and C26's message no longer says that a fenced `---` -splits the file. The CRLF replace goes: the module splits at any line ending. A fence left -open runs to the end of the file in markdown, so every section after it would have become -body text of one section, where the old split cut at every `---`. At the owner's choice -`parseStaging` refuses that instead: a region that reaches the file's last line and holds a -`---` line throws, naming its first line and the `---`. `serializeStaging` has nothing to -take from the module: it builds lines from parsed sections and reads no source. Wisdom now -loads markdown-it through `lib/`, from `wisdom.mjs` on, since it imports `prep.mjs` and so -`merger.mjs` statically: Wisdom.md's Prerequisites said no `npm install` was needed, and now -say it is, and WIP.Wisdom.md's opening says the same. C41 would have made that so anyway, -through `lib/frontmatter.mjs` and js-yaml. Wisdom.md's merge step says what the split skips -and what it refuses. - -**Verify.** The kit's `c36-oracle.mjs`, against a HEAD copy of `wisdom/extract` and -`wisdom/files.mjs`. The real `staging.md` (1,586,815 bytes, LF, 180 fences and 10 HTML -blocks, none holding a `---` or `## ` line) parses to 1,160 sections under both, deep-equal, -and the same parse from its CRLF form. A graft of no additions writes back 1,586,815 bytes -under both, equal to the file. C26's synthetic file, and one with a `---` in a tilde fence and -in an HTML comment, give two sections with their finding ids and every body line, where HEAD -throws. A fence left open and an HTML comment left open throw, where HEAD split them into -three and two sections. Prose after an unfenced `---` throws under both at the same line; -blank chunks, an all-preamble file and a file with no final newline parse the same. The tree -comparison differs only in Wisdom online and offline, the search data, and `book.html`. +Landed. ### C37 — `scripts: check_gate_lists' sections ignore fenced headings` -**Inventory site B3.** `splitSections` (`:251-264`) starts a section at any line matching -`/^#{1,6}\s/`, so the fenced `staging.md` example at `docs/Documentation/Wisdom.md:304-305` -splits `### staging.md format` in two. No verdict changes today only because the phantom -section states no gate count. - -**Change.** Sections through `splitOnMarker`, so a heading-shaped line inside a fence starts -none. A probe: a fenced `## ` line inside a section that states a count. - -**Verify.** The gate's verdict and its 18 probes unchanged; the new probe fails with the old -splitter. - -**Landed.** `splitSections` splits with `splitOnMarker` on the same `/^#{1,6}\s/` test, with -the site's parser, since the pages are the site's; it is built on first use inside `main`, so -a failure to build it exits 2. A section still begins with its heading line and carries that -line's 1-based number. `sectionBody` had the same fault in Tools.md's two wrapper sections, -which the entry does not name, and now takes its section from `splitSections`. The new prose -probe states a wrong total after a fenced `## ` line in a wrapper's section: 19 probes, and -Tools.md's section and WIP.Build.md now say thirteen of the nineteen cover the sweep. Two -other differences from the old split reach no page: a lone CR now ends a line (no page the -gate reads has one), and a final line ending no longer gives an empty last line, which no -rule matches. - -**Verify.** Of the 16 pages the gate reads, one has a heading-shaped line inside a region: -`Wisdom.md:310`, in the `staging.md` example, under the bare parser and the site's alike. -`--verbose` under a HEAD copy and the working file gives the same claim lines, 6 stated -counts across 16 pages, exit 0 both; the only difference is the new probe's line. With the -old splitter put back the new probe fails: exit 1, `1 of 19 self-test probes failed`. +Landed. ### C38 — `scripts: one Attributes.md reader for census and the probe generator` -**Inventory sites B1, B2.** `census_attributes.mjs`'s `documentedAttributes` (`:377-392`) and -`gen_attribute_probes.mjs`'s `parseAttributes` (`:66-90`) read `docs/Reference/Attributes.md` -line by line with near-identical patterns and no fence exclusion, so a future example showing -the page's own `Syntax:` format inside a fence would be read as an attribute. Both are in -`scripts/` since C10. - -**Change.** One reader in `scripts/lib/`, over `blockRegions`, that skips fenced lines; both -tools use it. - -**Verify.** A scratch script: both tools' parsed attribute lists identical before and after on -the real page, and a fenced `Syntax:` line ignored. - -**Landed.** `scripts/lib/attributes-doc.mjs` exports `parseAttributes(src)`, which is -`gen_attribute_probes`' reader, `{ name, syntax, line, app }[]` in page order, with every line -inside a region from `blockRegions` skipped. Its regions come from the site's parser, built on -first use, as in C37. `gen_attribute_probes` calls it on the file it reads; `census_attributes` -builds its map from it, keyed by name, a later entry replacing an earlier one as before, and -its values now also carry `syntax`, which it never reads. Both tools therefore load -markdown-it and `builder/render.mjs`, where they imported only `node:` modules, so neither runs -without `npm install` any more. Tools.md's `gen_attribute_probes.mjs` section says a fenced -line is not read and names the module. - -**Verify.** The kit-style oracle, HEAD's `documentedAttributes` and `parseAttributes` cut from -HEAD's files and run beside the new reader: the real page and its CRLF form give the same 71 -attributes, each with a target, under all three. A fenced `Syntax:` block appended to the page -is read by both HEAD readers and not by the new one, and a fenced `Applicable to:` line put -straight after the first `Syntax:` line becomes that attribute's target under HEAD, where the -new reader takes the real line after the fence. End to end, HEAD copies and the working tools -give byte-identical output, 136 files: `gen_attribute_probes`' two probe trees and key, and -`census_attributes --json` over the BETA 987 cache (661 files, 9,706 sites; no IDE started). +Landed. ### C39 — `builder, eval: counts, run_case and nav_hops skip code` -**Inventory sites B4, B5, B8, B9.** `counts.mjs`'s `countAttributeAnchors` (`:112-116`) and -`countEnumerations` (`:130-140`) apply regexes to raw source. `eval/run_case.mjs`'s -`evaluatorProtocol` (`:99-110`) cuts `eval/protocol.md` at the first bare `---` and a -heading, and the text it cuts becomes the evaluator's system prompt. `eval/nav_hops.mjs`'s -`hrefs` (`:86-94`) strips fences with a regex that recognises 1,353 of the corpus's 1,371; -the ones it misses are indented or use four backticks. None misfires today. - -**Change.** Each finds its lines through `blockRegions`. - -**Verify.** The tree comparison identical (the counts); `evaluatorProtocol` returns the same -text; `hrefs` returns the same links for every page. - -**Landed.** All four use the bare parser. `counts.mjs` has no choice: the site's parser is -built with the counts (`tbdocs.mjs:607-613`), so it does not exist when they are derived. A new -`proseLines(src)` there gives a page's lines with each line in a region replaced by null; -`countAttributeAnchors` counts the `{: #id }` lines in it, and `countEnumerations` finds the -index heading and scans to the next heading in it, where each did a multiline regex over the -raw source. `evaluatorProtocol` takes neither boundary from a region. `hrefs` blanks the lines -of every fence and indented code block and keeps HTML blocks, whose `href`s are links. -`eval/` still imports only `lib/`, not `builder/`: over all 912 pages the bare parser and the -site's give the same regions. Extending.md's paragraph on the two scanning counts says they -skip code, and that a new one should. - -**Verify.** A scratch oracle cut each function out of HEAD's file and the working one and ran -both with their dependencies injected. The counts come out 72 anchors and 140 enumerations -from the real pages and their CRLF forms under both; with a fenced `{: #fake }` appended and -a fence holding `- [Fake](Fake)` and `## x` put under the index heading, HEAD gives 73 and 1 -and the new code 72 and 140. The protocol's evaluator half is the same 3,985 characters from -the file and its CRLF form; with a fence holding `---` and the orchestrator heading put -before the first rule, HEAD throws and the new code returns the same text. `hrefs` gives the -same links on 911 of 912 pages (12,835 links in all). The one difference is `Authoring.md`, -81 links under HEAD and 86 now, which is the Found item "`nav_hops.mjs`' `hrefs` misread -`Authoring.md`": the new reader drops the three link strings in indented code and finds the -five prose link targets HEAD's regex dropped. On a probe page it keeps a prose link and an -HTML block's `href`, and drops one in a four-backtick fence, a fence in a list item and an -indented code block, where HEAD dropped none of the three. `nav_hops` itself, HEAD copy against -the working file, prints the same paths for four targets. The tree comparison differs only in -Extending online and offline, the search data, and `book.html`. +Landed. ### C40 — `builder, eval: frontmatter through lib/frontmatter; drop gray-matter` -**Decision (a)'s frontmatter half.** `gray-matter@4.0.3` bundles its own `js-yaml@3.15.2`, -while `data.mjs`, `tbdocs.mjs` and `check_publish_policy.mjs` parse the configuration with -`js-yaml@4.3.2`: page frontmatter and site configuration go through two major versions of one -library. - -**Change.** `discover.mjs` (`:94-117`, which strips the BOM itself because `matter.test()` -does not) and `eval/nav_hops.mjs` use `parseFrontmatter`. `gray-matter` leaves -`package.json` and Builder.md's Dependencies, after the **owner's confirmation** for -`npm uninstall`. - -**Verify.** Every page's parsed frontmatter deep-equal under both parsers, and the tree -comparison identical. Compare before uninstalling, since the before side needs `gray-matter`; -rebuild once after. - -**Landed.** `discover.mjs`'s own frontmatter step is renamed `readFrontmatter`, to free the -name: it calls `parseFrontmatter` inside the same `Failed to parse frontmatter in ` -wrap, and takes `contentLine` (C32a) from the raw text, since the stripped BOM holds no line -ending. `stripBom` goes, as `parseFrontmatter` strips the BOM. `nav_hops` takes a missing block as no permalink, as gray-matter's -empty data was. `npm uninstall gray-matter` removed it from `package.json`, and from the -lockfile it, its nested `js-yaml@3` and `argparse`, and seven packages only it used -(`esprima`, `extend-shallow`, `is-extendable`, `kind-of`, `section-matter`, `sprintf-js`, -`strip-bom-string`): 120 lines deleted, none added. Builder.md's `package.json` listing and -its Dependencies paragraph, Pipeline-Stages.md's `frontmatter` row, WIP.md's BOM paragraph, -WIP.Build.md's publish-allowlist note, two comments in `publish-policy.mjs` and the headers -of `lib/frontmatter.mjs` and `discover.mjs`'s frontmatter step no longer name gray-matter, in -the present or as history: at the owner's word, history that no later task needs is left to -git. - -**Verify.** `discover()` from a HEAD copy of `builder/` and `lib/` against the working one, -over `docs/`: 914 pages (912 `.md`, 2 `.html`) deep-equal, frontmatter, content and -`contentLine` included, in the same order, and the same 281 static files. With gray-matter -still installed, the tree comparison before any page edit matched in all three trees (1,461, -1,457 and 137 files). Its after side was set aside, gray-matter uninstalled, and the tree -comparison run again with `--before` a `git stash create` commit of the working tree; the -two after sides, compared by a scratch script with `compare_trees`' normalisers, agree on -every file, five of them once normalised (the Gantt chart twice over in two trees, and the -book's build line). HEAD's side no longer builds once the package is gone, so the final tree -comparison takes that stash commit as its before side: it differs only in Builder and -Pipeline-Stages online and offline, the search data, and `book.html`. +Landed. ### C41 — `wisdom: read pages and threads through lib/` -**A10-2 (R1), A10-3 (R2).** Three frontmatter readers disagree: `wisdom/extract/sitemap.mjs:69-87` -has no BOM strip and no type coercion, `prep.mjs:343-388` coerces arrays, booleans and -numbers, and `discover.mjs` strips a BOM, after the AppGlobalClassObject incident. No page -has a BOM today. `sitemap.mjs:61-67`'s `walk()` repeats the markdown walker, and its recorded -reason, "no dependency on `builder/`" (`wisdom/PLAN-3.md:404`), never applied to a walker -that depends on nothing. - -**Change.** Both readers use `parseFrontmatter`, and `sitemap.mjs` lists its pages with -`lib/markdown-files.mjs`. - -**Verify.** Every page's and every harvested thread's parsed frontmatter deep-equal before and -after, with each difference resolved on purpose. Two are likely: YAML gives numbers and dates -where `sitemap.mjs` gave strings, and it refuses an unquoted `: ` inside a value, which a -harvested Discord title may hold. If a thread file has one, the harvester that writes these -files quotes its values, and the existing files are fixed in the same commit. The walker -returns the same files. - -**Landed.** `wisdom/files.mjs` gains `readFrontmatter(path)`: `parseFrontmatter`'s data, `{}` -for a file with no block, and an error naming the file for a block that does not parse, as -`readJsonFile` names a state file. `sitemap.mjs`'s `parseFrontmatter` and `walk()` and -`prep.mjs`'s `parseThreadFrontmatter` go, and all three call sites read through it. -`buildSitemap` lists its pages with `markdownFiles`, so it is async and `runExtract` awaits -it. `findThreadMetadata` still skips a thread file it cannot read, which now includes one -whose frontmatter does not parse; `runExtract` stops on such a file and names it. The -serializer's `quote` says why every string is written quoted: YAML would read an unquoted -snowflake as a number and lose its low digits. `wisdom/PLAN-3.md`'s layout line no longer -says the sitemap has a parser of its own. - -**Verify.** The kit's `c41-oracle.mjs`, HEAD's two readers cut out of the files against -`parseFrontmatter`. Threads: 1,847 files, 1,830 of them in channel folders; none throws and -no snowflake is unquoted, and the record `prep.mjs` builds from each of the 1,830 is -deep-equal. The whole frontmatter differs on 919 files, each on purpose: the old reader -dropped the nested `top_reactions` (898 files) and `starter_reactions` (250), and kept the -backslash of an escaped `"` in 41 titles; `prep.mjs` reads none of those. Pages: 760 under -`docs/Reference`, none throws, and `title`, `permalink` and `parent` agree on 757. The other -three are `Input.md`, `Line-Input.md` and `Write.md`, whose unquoted `title: Input #` YAML -reads as `Input` with a comment after it, which is how the site has shown them since -`042210e2`; the old reader kept the `#`. The keys the sitemap does not read differ as -expected: numbers and booleans where the old reader gave strings (`nav_order`, `has_toc`, -`has_children`, `vba_attribution`), and lists it dropped or kept as text (`redirect_from`, -`symbols`, `exclude_from_docs`, `exclude_kinds`). The walk returns the same 760 files, in -code-unit order where it was the file system's. With HEAD's three titles read as YAML reads -them, the entries, the package summary and the page index are the same; only -`page-index.json`'s key order differs. End to end, the kit's `c41-prep.mjs` runs HEAD's -`runExtract` and the working one into scratch folders over the real threads (it starts no -agent): the ten batch files and the manifest are byte-identical, `package-summary.txt` -differs by the three ` #` (6 bytes), and `page-index.json` by the six keys that held them -(12 bytes) and by its key order. An unclosed block, a YAML error and a list each throw with -the file's path; a BOM and CRLF parse. The tree comparison is identical in all three trees -(1,461, 1,457 and 137 files). +Landed. ### C41a — `docs: quote the three statement titles that end in #` -**Found while landing C41.** `Input.md`, `Line-Input.md` and `Write.md` under -`docs/Reference/Core/` have `title: Input #` and the like unquoted, and YAML reads ` #` as -the start of a comment. - -**Change.** The three titles are quoted. - -**Landed.** As the entry says. No other frontmatter line under `docs/` holds an unquoted -` #`: a grep for a key whose unquoted value contains one finds these three lines only. - -**Verify.** The tree comparison differs on all 914 pages online and offline, and on -`book.html`. A word diff of the two kept trees, the Gantt chart's two files left out, finds -nothing but the three titles gaining their `#`: the sidebar's link text on every page, the -three pages' breadcrumbs, ``, meta titles and JSON-LD headline, their entries in the -search data, and their running heads in the book. The page count and the symbol index are -unchanged. +Landed. ### C41b — `builder: warn about an unquoted frontmatter value that ends in #` -**The owner's request, after C41a.** Nothing told the author of C41a's three pages that YAML -had dropped their `#`. The build warns when a frontmatter value left unquoted ends in `#`, -whitespace after the `#` aside, so the author is reminded to check; quoting the value -always silences it. - -**Landed.** `lib/frontmatter.mjs` gains `unquotedHashValues(raw)`, the block's lines whose -value is unquoted and ends in `#`, as `{ line, text }` counted from the opening `---`; it -shares a new private `splitBlock` with `parseFrontmatter`, which is otherwise unchanged. A -key's value, a list item, and a key opening a list item are checked; a comment line, a key -whose value is on the lines below, and the lines of a `|` or `>` block scalar are not, since -a scalar cannot be quoted. A value such as `C#`, which YAML keeps whole, is still reported, -as the rule asks. `discover.mjs`'s `readFrontmatter` prints each as `discover: -<page>:<line>: an unquoted value ends in #, ...` with the line's text, and the build goes on. -`check_code_regions.mjs` gains a module probe (eighteen lines: five reported, among them a -BOM, CRLF, trailing spaces and a list item's key; a quoted value, a quoted value followed by -a bare `#`, a comment, a list of quoted items and a block scalar's lines not) and a -`discover` probe that captures `console.warn` over a real page. Authoring.md's frontmatter -section, Pipeline-Stages.md's `frontmatter` row, Tools.md's section on the gate, WIP.md's -gate row and `lib/README.md` say so. - -**Verify.** On today's 912 pages no frontmatter line ends in `#` and none holds a block -scalar, so the build prints no warning. With C41a's `title: "Input #"` put back unquoted and -three spaces after the `#`, a build printed `discover: Reference/Core/Input.md:2: an -unquoted value ends in #, ...: title: Input #` and exited 0. Four faults put into the code -each fail `check_code_regions`: no call in `discover` (the `discover` probe), no block-scalar -skip (line 16 reported), no allowance for trailing whitespace (line 5 missed), and no quote -check (line 18 reported). +Landed. ### C41c — `wisdom: group reference pages by package, below Default/ and Built-In/` -**Found while landing C41.** `buildPackageSummary` and `buildPageIndex` take the first -folder under `docs/Reference/` as a page's package, and since `58a5e1c` that folder is -`Default`, `Built-In` or `Core`. - -**Change.** Both take a page's parts through one `packageParts(path)`, which drops -`docs/Reference/` and then a `Default/` or `Built-In/`. - -**Landed.** As the entry says. Wisdom.md already describes `Package > Module` lines and -`Package/Title` keys, so no page changes. The Assert package groups as -`TwinBasicAssertions`, its folder's name, where the last summary written (2026-06-04, from a -tree before the move) said `Assert`; the grouping has always been by folder. Open -questions keeps this, at the owner's request. - -**Verify.** The kit's `c41c-oracle.mjs`, HEAD's two functions against the working ones over -one sitemap: the summary goes from three groups to 40, VBA and VBRUN split by module again, -and lists 735 titles where it listed 726, since a title shared by two packages no longer -collapses into one group. The page index goes from 1,492 keys to 1,500, its 740 bare-title -keys and their paths unchanged, and the pages a `Package/Title` key reaches from 751 to 759 -of 760, none lost. The kit's `c41-prep.mjs`, HEAD's `runExtract` against the working one: -the batch files and the manifest are byte-identical, and only `package-summary.txt` and -`page-index.json` differ. +**Carried forward.** `buildPackageSummary` and `buildPageIndex` in +`wisdom/extract/sitemap.mjs` take a page's parts through one `packageParts(path)`, which drops +`docs/Reference/` and then a `Default/` or `Built-In/`, so a page is grouped by its package +folder's name, and the Assert package is `TwinBasicAssertions` (see Open questions). *The link checker, the gates' scaffolding, the browser tools and the repository root: C42–C46.* ### C42 — `scripts: check_links.mjs becomes a thin wrapper over builder/check.mjs` -**Decision 5, A4-2 (R2), A4-1 (R3).** `check_links.mjs:602-634`'s `buildFindings` repeats -`check.mjs:422-449`'s `findingsFor`, and `statSafe` is in both (`link-check.mjs:455-457`, -`check_links.mjs:151-153`). The comments that justify the pair (`check.mjs:410-414`, and -`check_links.mjs`'s header, `:6-14`) describe two independent implementations for -`check_links_diff.mjs` to compare, which decision 5 replaces with one implementation read two -ways. - -**Change.** `check_links.mjs` keeps its command line and its own reading of a tree from disk, -and calls `check.mjs`'s `checkChunk`, `joinChunks`, `findingsFor` and `formatReport` for the -rest. `buildFindings` and its `statSafe` go. Both comments are rewritten to say what -`check_links_diff.mjs` now compares: a tree read from disk against the same tree held in -memory, through one implementation. - -**Verify.** `check_links_diff.mjs --a script --b fused` agrees on every case, and its -`--self-test` passes. `check_links.mjs` prints the same report on `docs/_site` and -`docs/_site-offline` before and after. CI's fixture cases unchanged. - -**Landed.** `runCheck` walks the tree as before, then hands every page to `checkChunk` as one -chunk, each page's `html` a getter that reads the file, so one page is in memory at a time -and the chunk's unique count, fragment-target count and stage timings are the tree's. It -passes the oracle on `env` (`FsOracle`, or `treeIndexFor` over its own listing for `--oracle -index`), then `joinChunks` with the tree's rel paths, the sniffed stubs, and `sitemap.xml` and -`search-data.json` read from disk, and returns `findingsFor`'s view with `counts.unique` filled -in from the chunk. `buildFindings`, the three `*Contents` wrappers, `extractLinksAndIds`, -`relFilesFor` and `statSafe` (inlined as a `try` in `collectHtmlFiles`) go. `check.mjs` pages -are tree-relative; a `walkPath` map turns them back into the walk's paths for the report, so -the report is unchanged. The owner chose that report over `formatReport` (Where the plan was -wrong). `checkChunk` now also returns `stubs` (always empty in the build, whose `TREES` never -set `captureRedirectStub`), `fragmentTargets` and `stages` (with `extract`), for `-v`. The -self-test's guards 1 and 3 now run `runCheck` over its one-page tree, four passes, ~20 ms -instead of <10 ms. Both comments the entry names are rewritten, and so is every sentence that -called the pair two implementations or the script the reference implementation: Tools.md, -Building.md, Extending.md, Builder.md, Pipeline-Stages.md (also `checkChunk`'s and -`findingsFor`'s rows), WIP.Build.md (a heading, which nothing links to), test/README.md, -builder/README.md, `link-check.mjs`'s banner and reporter comment, `check_links_diff.mjs`'s -header and `script` side, `page-baseline.mjs`, a `checks.yml` comment (no step changes), and -`check_gate_lists.mjs`'s note on `checks?`, whose example phrase is gone from the corpus. - -Oracle: the kit's `c42-oracle.mjs` runs `runCheck` in 25 cases (the three real trees, an -absolute root, `-v`, the book with and without `--no-fail`, the base-path tree right and -wrong, `check_links_diff`'s hand-written fixture cut from its source in five flag sets, the -two built fixture trees, a subfolder, two inputs, no `--root-dir`, a missing input, only a -missing input, a root without `sitemap.xml`, unknown flags and the three usage errors), each -with the default, `fs` and `index` oracles, plus four CLI runs (`-h`, no arguments, three -passes over `/sep/`, one pass), with timings masked. Against HEAD everything is identical, -the self-test included, but two things: the findings gain `findingsFor`'s `skipped`, which -`check_links_diff` does not compare; and where cross-file issues are printed (the wrong base -path and the root without a sitemap, 13 canonical lines each) they now come sorted by code -unit, as `joinChunks` sorts them, rather than in the walk's order (`tB/Core/LSet.html` now -before `LeftShift.html`); line order only, no line gained or lost. `check_links_diff.mjs --a -script --b fused`: no differences across 6 cases; `--a script --b index`: none across 8, both -`online-abs` identities ok; `--self-test` ok, and with `basePath: ""` passed to `joinChunks` -it fails on guard 1 (`--check-sitemap with --base-path`). Timing on `docs/_site` with `-v`, -HEAD against now: 3.81 s against 3.38 (`fs`), 3.49 against 3.27 (`index`). `compare_trees`: -the trees match. Two cases change and are not covered: an input on another drive than -`--root-dir` on Windows, where a page's tree-relative path is absolute, so `checkChunk` -resolves its links from a folder that does not exist; and a tree whose every canonical-bearing -page is a redirect stub, where `--check-canonical` now warns instead of reporting `[]`. +Landed. ### C43 — `scripts: lib/gate-probes.mjs for the gates' probes and crash handler` -**A6-1 / L1-11 / L4-13 (R1).** A probe accumulator and report loop in -`check_page_baseline.mjs` (`:38-44,128-139`), `check_book_coverage.mjs` (`:81-87,158-170`) and -`check_symbol_index.mjs` (`:40-45,361-370`); a crash handler in those three and in -`check_publish_policy.mjs:29`; `withBaseline` in `check_page_baseline.mjs:46-55` and -`check_symbol_index.mjs:314-323`. Only `check_book_coverage.mjs:162` re-indents a multi-line -detail. - -**Change.** `scripts/lib/gate-probes.mjs` holds the accumulator, the report, the crash handler -and `withBaseline`. Probes stay unconditional, and the exit code for a failed probe is a -parameter: 1 for these gates, 2 where probes guard a separate sweep. The three gates and -`check_publish_policy.mjs` adopt it, and so do the handlers C07 and C28 added. C07's sits -inside `convert_em_dash_separators.mjs`'s entry-point guard because that module is -importable, so the shared handler is installed by a call, never as a side effect of the import. -`check_gate_lists.mjs` and `check_regex_safety.mjs` adopt it only if the fit is exact. The -re-indenting becomes the shared behaviour: the one change in output, and only in gate text. - -**Verify.** Each adopting gate's probe count and verdict unchanged; a broken probe still fails -it; a forced crash exits 2. - -**Landed.** `scripts/lib/gate-probes.mjs` exports `exitOnCrash()`, `createProbes(tool, -onFailure)`, whose `check` records a probe and whose `report()` prints the lines and the -summary and returns the exit code, and `baselineFixture(name)`, which returns the -`withBaseline(initial, fn)` both drift-guard self-tests call. Each does its work when called, -never on import. The three probe gates adopt all three; `check_book_coverage.mjs` passes its -summary's remedy as `onFailure`. `check_symbol_index.mjs`'s fixture now takes `{ src, urls }` -(`BASE_FILE`) and writes it pretty-printed where it was compact, which both guards read with -`JSON.parse`. The temporary folders are `tb-page-baseline-*` and `tb-symbol-baseline-*` -(were `tb-pagebaseline-*`, `tb-symbolbaseline-*`). `exitOnCrash()` replaces the handler in -eleven files: the three gates, `check_publish_policy.mjs` (which keeps its own reporter), C07's -in `convert_em_dash_separators.mjs`, still inside its entry-point guard, and C28's three -(`pick_a11y_sample`, `build_dot_metrics`, `check_tb_registry`); and, at the owner's choice, -the three copies this entry did not name: `check_dot_fit.mjs`, which the others copied, -`check_ci_workflows.mjs` (C03) and `check_lint.mjs` (C06). Where a handler's comment said what -exit 1 means, one line keeps that. Neither `check_gate_lists.mjs` nor `check_regex_safety.mjs` -fits exactly, so neither adopts, and the failed-probe exit code is not a parameter (Where the -plan was wrong). Extending.md's exit-code convention names `exitOnCrash` for a script with no -`main()`, and a sentence after its four probe shapes names `createProbes`. Tools.md's -`check_page_baseline` section now says it exits 2 if it cannot run, as its two siblings' -sections say; the handler was there before. - -Oracle: the kit's `c43-oracle.mjs before|after|compare` runs 26 cases: each of the eleven -tools plainly (`pick_a11y_sample`, `build_dot_metrics` and the converter with `--check`, -`check_tb_registry` once each side, 37 s), each through a forced crash (`c43-crash.mjs`, a -preload whose wrapped `process.on` throws from the line that installs the handler), an -import of the converter that counts the `uncaughtException` listeners it leaves (0), and the -three probe gates through a broken probe (`c43-fault.mjs`, a load hook that edits a builder -module's source as it loads: `was ${baseline[k]}` in `page-baseline.mjs`, `is missing` in -`symbol-baseline.mjs`, the `unlisted` push in `book.mjs`). 23 cases are identical, every crash -exiting 2 with `Error: c43 forced crash` and each fault exiting 1 with the same probe failed. -The three differences: lint checks 143 files, the new module among them; and in the page and -symbol faults the detail's continuation lines gain seven spaces, so they stay aligned under -`ERROR:` as the build prints them, the one change the entry expects. No `tb-*-baseline-*` -folder is left in `%TEMP%`. +Landed. ### C44 — `scripts: the dot tools share one launch, host page and source list` -**A5-5, A5-6 / L2-3 (R2).** `check_dot_fit.mjs:76-87` and `build_dot_metrics.mjs:57-68` -build the same Inter host page and the same launch, and neither explains -`--allow-file-access-from-files`. `check_dot_fit.mjs:49-67`'s `findDotSvgs` repeats -`builder/dot.mjs:135-155`'s `listDotSources`, which is not exported. - -**Change.** Both launch through `scripts/lib/browser.mjs` (C19), whose file-access option -adds the flag and states its reason once; the host page is built in one place; -`listDotSources` is exported and `check_dot_fit.mjs` uses it, keeping the broad `_` and `.` -skip, which is safe today. - -**Verify.** `check_dot_fit.mjs`'s output unchanged; `build_dot_metrics.mjs` regenerates -`builder/inter-metrics.json` byte for byte; both listings name the same files. - -**Landed.** Both tools launch through `withBrowser` from `scripts/lib/browser.mjs`, and -`--allow-file-access-from-files` is gone rather than explained: the host page loads both Inter -faces without it (Where the plan was wrong), so `browser.mjs` is unchanged. The host page is -built in a new `scripts/lib/inter-page.mjs`, whose `openInterPage(browser, name, { css, body -})` writes `docs/_<name>-host.html` with the two `@font-face` rules, loads it, removes it, and -returns the page with its `pageerror` logger; each tool passes only its own styles, and the -two host files keep their names. The page's `<title>` is now the name (`dot-fit`, -`dot-metrics`; was `dot fit`, `metrics`), which nothing reads. `builder/dot.mjs` exports -`listDotSources`, and `check_dot_fit.mjs` maps its sources to the SVGs on disk in place of -`findDotSvgs`, then sorts them as before (a sorted list of `.dot` paths can come out in -another order once each ends in `.svg`, as `a.dot` and `a.e.dot` do). Pipeline-Stages.md's -`dot.mjs` table gains the row. Neither tool's `try` body returned or exited, so moving it into -the callback changed nothing; `build_dot_metrics.mjs`'s `table` is now the callback's result. - -Oracle: the kit's `c44-oracle.mjs before|after|compare`, 7 cases: `check_dot_fit` plain and -`--verbose` (their `ok` lines name every SVG found, so they are the listing), with its -tolerance forced to -100 through `c43-fault.mjs` (exit 1, every diagram reported), and through -`c43-crash.mjs` (exit 2); `build_dot_metrics --check`, a regeneration, and a crash. All 7 are -identical before and after, the regeneration leaves `inter-metrics.json` unchanged (sha256 -`15d2c7739cde15b2…` both sides), and no run leaves a host file in `docs/` or a Puppeteer -profile in `%TEMP%`. The kit's `c44-noflag.mjs` ran the four plain cases with the flag still -there but turned off, and all four matched; `c44-flag.mjs` prints each face's -`FontFace.status` after a load, `loaded` both ways. With a font URL broken through -`c43-fault.mjs`, both tools exit 2 on `NetworkError` from `document.fonts.load`, so a refused -font stops the run rather than measuring a fallback; `inter-page.mjs`'s header says so. +Landed. ### C45 — `a11y: one page discovery and stub ceiling for the sampler and the sweep` -**A5-3 / L4-9, A5-4 (R2, R3).** `pick_a11y_sample.mjs` (`:122,159-169,184`, `STUB_CEILING`) -and `sweep_a11y.mjs` (`:78,129-153`, `STUB_TAG_CEILING`) each discover pages, count tags and -apply a ceiling of 100, though `--propose` reads the JSONL the sweep writes, so the two must -agree. `pad` and `median` are identical in both. - -**Change.** One discovery, ceiling, `pad` and `median`, in `axe-scan.mjs`, which both already -import. - -**Verify.** `pick_a11y_sample.mjs --census` and `--propose` output unchanged; a sweep over two -pages writes records of the same shape. - -**Landed.** `axe-scan.mjs` gains a page-discovery section: `STUB_TAG_CEILING` (the sweep's -name; the sampler's `STUB_CEILING` goes), `staticTagCount`, `discoverPages(dir, fields)`, -`splitStubs(pages)` returning `{ content, stubs }`, `pad` and `median`. `discoverPages` reads -each page once and returns `{ filePath, tags, ...fields(html) }` sorted by `localeCompare`, as -the sweep did; the sampler passes its family counts as `fields` (`raw`), so it still reads each -page once, and its pages now come sorted where they came in `readdir` order. The two stub -comments are merged into the ceiling's, in the present tense. Tools.md's `axe-scan.mjs` -section names the shared discovery, and, at the owner's choice, all five scripts that import -the module (it named three, leaving out `pick_a11y_sample.mjs` and `check_axe_patch_equiv.mjs`, -a Found-in-passing item). `builder/PLAN-checks.md`'s open question cites the new name. - -Oracle: the kit's `c45-oracle.mjs before|after|compare`: `pick_a11y_sample --census`, -`--propose` (over a copy of `perf/results/a11y-sweep.jsonl`, 3,476 records) and `--check`; -`sweep_a11y --report` over the same copy; and a sweep of the first two `/tB/Core/A` pages, -light and desktop, into a fresh JSONL. The sampler's three runs and the report are identical, -so its new page order changed no tie. The two-page sweep writes the same records, `runMs` -masked, and its output differs only in the per-page time column. +Landed. ### C46 — `lib: one repository root for every tool` -**L2-5 (R2).** Nineteen files derive the repository root inline, in about five different -expressions; `axe-scan.mjs:29` exports `REPO_ROOT`, and two files import it. `Extending.md:654` -says nearly all use one expression, and three do. - -**Change.** `lib/repo-paths.mjs` exports the root, and the few paths several tools build from -it. The nineteen use it, `builder/`'s two included, which a `scripts/lib/` module could not -serve; `axe-scan.mjs` re-exports it for its two importers. `Extending.md:654` states the -convention as it now is. - -**Verify.** The tree comparison identical; `test.bat` and `check.bat` clean; -`check_examples.mjs --census`, and each `eval/` and `wisdom/` tool's cheapest mode, unchanged. - -**Landed.** `lib/repo-paths.mjs` exports `REPO_ROOT`, from its own URL, and `DOCS_DIR`, the -one path several tools built from it; every other path built from the root is built by one or -two tools and stays with them. Twenty-six files take the two from it: the review's nineteen; -`check_regex_safety.mjs`, which was there at `fe9ce12b` and missing from the count; -`check_ci_workflows.mjs`, `check_lint.mjs`, `compare_trees.mjs` and `survey_tooling.mjs`, -added since, the last keeping its own `ROOT`, now `--root` or `REPO_ROOT`; `inter-page.mjs` -(C44); and `axe-scan.mjs`, which imports the root and re-exports it with `export { REPO_ROOT -}` for `pick_a11y_sample.mjs` and `sweep_a11y.mjs`, whose imports are unchanged. Local names -follow the exports: `REPO`, `ROOT` and `PROJECT_ROOT` become `REPO_ROOT`; the docs folder's -`ROOT` (`check_code_regions.mjs`, `convert_em_dash_separators.mjs`), `SRC` (`check_dot_fit.mjs`) -and `DOCS` (`check_examples.mjs`, `inter-page.mjs`) become `DOCS_DIR`; and -`gen_attribute_probes.mjs`'s `DOCS`, which held `Attributes.md`'s path, becomes `ATTR_DOC`, as -in `census_attributes.mjs`. Left alone: `perf/`, outside the review's scope; `impexp.mjs`'s -self-test path to `indexer/`, since the script is a download that must stand alone; -`check_publish_policy.mjs`'s working-directory `docs` default, a behaviour change, which -Extending.md names as the one gate that does not; and paths relative to a module that are not -the root (Wisdom's data folders, `builder/`'s vendored assets, `test/addin/`'s `HERE`). -Extending.md states the convention as it now is, `lib/README.md` names the module, and -WIP.md's sentence on `check_code_regions`' sweep names `DOCS_DIR`. The mechanical edit was a -Sonnet agent's (177 calls, ~207k, 12 min), reviewed line by line. - -Oracle: the kit's `c46-oracle.mjs` takes each of the 32 root and docs constants HEAD defined -in the 26 files, evaluates it as HEAD wrote it for that file's own URL, and compares it with -the exports: all 32 equal, byte for byte (the two `Attributes.md` paths as `DOCS_DIR` plus -`\Reference\Attributes.md`). `c46-tools.mjs` runs each touched tool's cheapest mode from a -HEAD worktree and from the working tree, both roots masked: `check_examples --census`, -`nav_hops`, `site_search --site`, `run_case --prompt-only` over a `build_corpus` corpus, -`survey_tooling --summary` (each tree measured by both scripts), `gen_attribute_probes` (135 -files) and `convert_em_dash_separators --check` give the same exit and output, and -`site_search`'s default `--site` answers as the explicit one does. `build_corpus`'s corpus -differs only in the three files this commit changes and in untracked local output that the -worktree lacks; it holds `wisdom/.token` as the stub every unlisted file type gets, not the -token. Wisdom's prep step, the kit's `c41-prep.mjs`, writes the same files. The tree -comparison differs only in Extending.md's page, online and offline, the search index and -`book.html`. Not run: `addin_test`, `build_dot_metrics`, `build_package_api`, -`census_attributes` and `check_links_diff`, whose changed lines are the constants above; Biome's -`noUndeclaredVariables` over the 26 files names none of the renamed identifiers. +Landed. *Command lines (decision (e)): C47–C52.* ### C47 — `lib: cli.mjs on node:util parseArgs, and check_cli.mjs` -**Decision (e), L1-13 (R2).** Nothing tests any tool's argument handling, which is how L1-1, -L1-2, L1-3 and L1-6 shipped. - -**Change.** - -- `lib/cli.mjs`, in `lib/` because `tbdocs` migrates onto it (departure 2), to L1's - specification in the ledger: `parseCli(argv, {options, positionals})` over `parseArgs` - with `strict`, `allowPositionals` and `tokens`, camelCase names and positional-count - checks; `withUsageError(fn, {stream, exitCode})`, which keeps each tool's message, stream - and code; `numberOption()`; and `printHelpAndExit(text, {stream, exitCode})`, which keeps - each tool's current help behaviour until Phase 3. The options that repeat (`--forbid`, - `--source`, `--case`, `--additional-script`, `--channel`) are `multiple`. -- `scripts/check_cli.mjs`, a `test.bat` gate: the module's own probes, and a table of cases - per migrated tool (arguments, exit code, stream, and a pattern for the message), recorded - from each tool's behaviour before it migrates. Only invocations that stop during argument - parsing qualify. They run as child processes, in parallel, each with a timeout, and with the - IDE and browser locations pointed at nothing, so a case that got past parsing fails instead - of starting either. -- Registered in `test.bat`, the composite action, Tools.md's list and WIP.md. - -**Verify.** The probes; changing one recorded expectation fails the gate; the roster gate -passes. CI waits for the owner's push. - -**Landed.** `lib/cli.mjs` exports `parseCli`, `CliError`, `numberOption`, `withUsageError` and -`printHelpAndExit`. `parseCli(argv, { options, positionals, unknown, acceptsValue })` takes -options in `parseArgs`' shape and returns `{ values, positionals, tokens, ignored }`: values -keyed in camelCase; an absent option its `default`, a `multiple` one `[]`, any other none; a -repeat keeps the last; `tokens` the kept options (each with its `key`), positionals and `--`, -in order, for `tbdocs`' order-dependent resets. `parseArgs` runs loose and the module makes a -strict parse's checks itself, because a strict parse stops at the first unknown option and a -loose one alone takes a trailing value flag as `true`; with the defaults the two agree on 64 -argument lists (a probe). A tool's leniency is two parameters, each use of which Phase 3 -removes. `unknown` is `"error"`, `"ignore"` (an unknown option, a boolean given a value and a -positional beyond `max` go into `ignored`, as given: the `includes` and `opt()` tools, and the -list `check_links` warns about) or `"positional"` (an unknown option becomes a positional as -given, and counts: `nav_hops`, `site_search`, `tbrun`, `gen_attribute_probes`); a value flag -with no value is refused under all three. `acceptsValue(value, inline)` defaults to the strict -rule (a separate value may not be missing or start with a dash and one more character; any -inline value is taken; `-` and `""` are values); the survey's other guards are `(v) => v !== -undefined` (`check_links`), `Boolean` (the a11y family's truthiness test), `(v) => v !== -undefined && !v.startsWith("--")` (`compare_trees`) and `() => true` (no guard, which stores -`undefined` for a trailing flag and skips its default). A `CliError` has a `code` -(`unknown-option`, `unexpected-value`, `missing-value`, `unexpected-positional`, -`missing-positional`, `bad-number`), `option` as typed, `arg` and `value`; its default -messages are `unknown option: X`, `--x needs a value` (the majority wording), `--x takes no -value`, `unexpected argument: X`, `expected at least N argument(s), got M` and `--x expects a -whole number from 1 to 65535, got: 0`. `numberOption(value, { option, integer, min, max, -message })` refuses blank text, which `Number` reads as 0. `withUsageError(fn, { stream, -exitCode = 2, format, exit })` prints `format(err)` with one newline and exits, and throws -anything but a `CliError` on; `printHelpAndExit(text, { stream = "stdout", exitCode = 0, exit -})`. A `stream` object with a `write` and an `exit` are the probes' hooks, which no tool needs. - -`scripts/check_cli.mjs` holds 36 module probes and 14 cases, 50 checks in 0.6 s. A case is `{ -tool, args, exit, stdout, stderr }`: a string is the whole stream, a RegExp must match, and a -stream not named must be empty. The tool's own words for the error are pinned exactly, a usage -text after them by its opening. Each case runs in an empty folder of its own, with `TB_IDE` and -`PUPPETEER_EXECUTABLE_PATH` naming missing files and `TBBUILD_SHOW` removed, 30 s at most, -`availableParallelism()` at once. C47 records C18's settled behaviour (`tbdocs`: a trailing and -a dash-led missing value, `--port=0` and an unknown argument, exit 4 on stderr; `check_links`: -a trailing missing value for `--root-dir` and `--forbid`, exit 4, `error: ` on **stdout**) and -C17's (`tbbuild`, `check_examples`, `census_attributes`, `build_package_api`: a trailing and a -dash-led missing value, exit 2, `tbbuild`'s usage line matched by its opening). - -Measured: Puppeteer 25.0.4 honours `PUPPETEER_EXECUTABLE_PATH`; `check_dot_fit` and -`check_axe_patch_equiv` exit 2 in about 0.3 s ("Tried to find the browser at the configured -path"). The kit's `c47-faults.mjs` puts one fault at a time into the gate or the module as it -loads (the `c43-fault.mjs` preload) and counts twinBASIC and Chromium processes, none before or -after. A changed exit code, a changed message and a message moved to the other stream each fail -their case alone (1 of 50); the value rule loosened to a loose parse's fails 2 probes, the -comparison with `parseArgs` among them; unknown options accepted fails 6. Cases forced past the -command line fail on their own message: `check_examples --jobs 2` runs its 119 probes and stops -on "no compiler beside the IDE" (1.1 s), `census_attributes --out x.json` on "no packages/ -under", `tbdocs --port 4000` exits 1 with "task config failed" (0.5 s; its default `docs` is -relative to the empty folder), `tbbuild` on "no such project". `check_gate_lists` passes, and -`check_ci_workflows` counts 14 gates where it counted 13 (it leaves out `check_tree_fresh`, -which CI does not run). Registered in `test.bat` after `check_symbol_index`, the -composite action, Tools.md's list, POSIX block and a section, Building.md's POSIX block, -WIP.md's table and bullet, and `lib/README.md`. CI waits for the push, where the four harness -tools and `tbdocs` run on Linux for the first time, each only as far as its error. -`build.bat`, `check.bat` (the a11y line unchanged) and `test.bat` exit 0, and the tree -comparison differs only in Tools.md's and Building.md's pages, online and offline, the search -index and `book.html`. - -For C48–C52: -- `opt()` in `tbbuild`, `check_examples`, `census_attributes`, `build_package_api` and - `addin_test` finds its flag with `indexOf`, so a repeated flag keeps its **first** value; - `parseCli` keeps the last, as `parseArgs` does (read, not run; `tbrun`'s `opt` not read). The - survey's "every other repeat: last wins" is wrong for these. A repeat does not stop the tool, - so `check_cli` cannot hold it; C49 decides. -- `check_tb_registry` reads no arguments, and C49's entry now says so; C50's names `check_lint` - and `compare_trees`, which no entry did; C51's counts five `eval/` scripts. -- A migration adds its tools' cases to `CASES`, with a comment naming the commit, and runs the - gate against the unedited tool before the edit. -- Found in passing, not fixed: a launch refused for a missing browser leaves a Puppeteer profile - folder in `%TEMP%`, since `ChromeLauncher` makes it (`ChromeLauncher.js:77`) before it - resolves the executable (`:87`), and `withBrowser` removes only the profile of a browser it - got. Only a case that gets past its command line reaches it here. -- Building.md said "all ten gates" where there were 14 before this commit, and - `check_gate_lists` did not report it; it now says "all the gates", as that gate advises. +**Carried forward.** `lib/cli.mjs` exports `parseCli(argv, { options, positionals, unknown, +acceptsValue, stopAt })`, `CliError`, `numberOption`, `withUsageError(fn, { stream, exitCode = +2, format })` and `printHelpAndExit(text, { stream = "stdout", exitCode = 0 })`. `parseCli` +returns `{ values, positionals, tokens, ignored, stopped }`: values keyed in camelCase, a +repeated flag keeping its last value, and `tokens` the kept options, positionals and `--` in +order. A tool's leniency is a parameter, and Phase 3 removes each use of it: `unknown` is +`"error"`, `"ignore"` (an unknown option, a boolean given a value and a positional beyond +`max` go into `ignored`) or `"positional"` (an unknown option becomes a positional, and +counts); `acceptsValue(value, inline)` defaults to the strict rule (a separate value may not +be missing or look like a flag; `-` and `""` are values), and a tool's own guard replaces it; +`stopAt` names options that end the parse on the spot, for `--help`. `numberOption` refuses +blank text, which `Number` reads as 0. `scripts/check_cli.mjs` is the gate: probes of the +module, and a `CASES` table of `{ tool, args, exit, stdout, stderr }` for invocations that +stop during argument parsing (a string is the whole stream, a RegExp must match, an unnamed +stream must be empty). Each case runs as a child process in an empty folder, with `TB_IDE` and +`PUPPETEER_EXECUTABLE_PATH` naming missing files and `TBBUILD_SHOW` removed, so a case that +gets past parsing fails instead of starting an IDE or a browser. A change to a tool's command +line records its cases from the unedited tool first, and adds them with a comment naming the +commit. ### C48 — `scripts: the a11y and diagram tools parse through lib/cli.mjs` -**A5-1 (R1).** Eight hand-written loops, diverged three ways. `check_a11y.mjs:63-79` answers -`--help` with "unknown arg" and exit 2, where `pick_a11y_sample.mjs:144-147` and -`sweep_a11y.mjs:106-110` print usage to stderr and exit 0; `check_dot_fit.mjs:47` and -`build_dot_metrics.mjs:55` test with `.includes()` and ignore a mistyped flag; -`check_a11y_fingerprint.mjs`, `check_axe_patch_equiv.mjs` and `check_tree_fresh.mjs` print -usage to stdout. - -**Change.** All eight parse through `parseCli`, each keeping today's behaviour, the ignored -typo included, which C72 removes. Their cases go into `check_cli.mjs` first. - -**Verify.** `check_cli.mjs`'s cases for the eight, recorded before and passing after; -`check.bat` unchanged. - -**Landed.** All eight parse through `parseCli`. The six with a loop take `acceptsValue: -Boolean`, which is their truthiness test (a missing or empty value is an error, a following -flag is taken as the value), and `withUsageError` with `format: (err) => \`unknown arg: -${err.arg}\``, since the loop printed every refused argument as given; positionals stay at 0. -The loops answer `--help` and fingerprint's `--list` on the spot, so an error before them wins -and nothing after them is read: `lib/cli.mjs` gained `stopAt` for this, with `stopped` in the -result and three probes. `help` (short `h`) is in `stopAt` in the five that answer it, and each -prints its usage unchanged through `printHelpAndExit`, to stdout (`check_a11y_fingerprint`, -`check_axe_patch_equiv`, `check_tree_fresh`) or stderr (`pick_a11y_sample`, `sweep_a11y`); -`check_a11y` has none and still refuses `--help`. `pick_a11y_sample`'s mode is the last of -`--check`, `--propose` and `--census` in `tokens`; numbers are still read by `parseInt` and -`parseFloat`. `check_dot_fit` and `build_dot_metrics` declare their one boolean with `unknown: -"ignore"`, which cannot throw. The edit was a Sonnet agent's (43 calls, ~192k, 10.6 min), -reviewed line by line; it gave the booleans `default: false`, so each value is a boolean. - -The cases were recorded from the unedited tools first: 25 across six tools (`--help`, an -unknown flag, a trailing and an empty value, `--theme`/`--viewport` refused by C20's check); -the two diagram tools ignore every argument and have none. `check_cli` now makes 78 checks, 39 -probes and 39 cases. The kit's `c48-tools.mjs` writes HEAD's copy of each tool beside the real -one (`scripts/c48-head-<name>.mjs`, removed at the end) and runs both on 20 real invocations: -the same exit and output on all 20, but for a stack-trace line number in one that fails alike -on both (`--pages --list`, which takes `--list` as the page list). It covers the modes' order -(`--propose --check` is check, `--check --census` census), `--help --bogus` against `--bogus ---help`, `--list --bogus`, a flag taken as a value, `check_dot_fit` and `build_dot_metrics ---check` with stray arguments, and `check_tree_fresh` with `--tree` twice. A HEAD worktree -does not serve here: it lacks `node_modules/axe-core` and `perf/results`, which these tools -read through `REPO_ROOT`. - -What differs, none of it a recorded case: `--name=value` is accepted (departure 9); after -`--` an argument is a positional, so `check_dot_fit -- --verbose` is no longer verbose; and a -short-option group is split into its letters, so `-hx` prints the usage where the loop said -`unknown arg: -hx`. `build.bat`, `check.bat` (the a11y line unchanged, run by the migrated -`check_tree_fresh`, `check_dot_fit`, `pick_a11y_sample --check` and `check_a11y`) and `test.bat` -exit 0. +**Carried forward.** The a11y and diagram tools keep their leniency until Phase 3. +`check_a11y`, `check_a11y_fingerprint`, `check_axe_patch_equiv`, `check_tree_fresh`, +`pick_a11y_sample` and `sweep_a11y` take `acceptsValue: Boolean` (a missing or empty value is +an error; a following flag is taken as the value) and print every refused argument as +`unknown arg: X` with exit 2, through `withUsageError`. Five of them have `help` (short `h`) +in `stopAt`, and print their usage through `printHelpAndExit`: to stdout in +`check_a11y_fingerprint`, `check_axe_patch_equiv` and `check_tree_fresh`, to stderr with exit +0 in `pick_a11y_sample` and `sweep_a11y`. `check_a11y` has no `--help` and refuses it as an +unknown argument, and `check_a11y_fingerprint`'s `--list` is a `stopAt` too. `check_dot_fit` +and `build_dot_metrics` declare their one boolean with `unknown: "ignore"`, so they ignore +every other argument. ### C49 — `scripts: the harness tools parse through lib/cli.mjs` -**A7-5 (R2)'s `die()` half, and L1-2's copies.** `tbbuild`, `tbrun` and `addin_test` each -have a `flag()` and `opt()` pair and a `die()`, in three shapes (V3's fifth note); -`check_examples`, `census_attributes`, `build_package_api`, `gen_attribute_probes` and -`check_tb_registry` parse by hand as well. - -**Change.** All seven through `parseCli`, keeping today's behaviour, C17's fixes included; -`tbrun` and `addin_test` still substitute their default for an empty value until C72. -`check_tb_registry` reads no arguments (C47 found), so nothing of it migrates. - -**Verify.** `check_cli.mjs`'s cases; the `examples.bat` summary and `addin-test.bat` -unchanged (harness runs, one at a time). - -**Landed.** All seven parse through `parseCli`. `tbbuild`, `check_examples`, -`census_attributes` and `build_package_api` take the default `acceptsValue`, which is their -old check for a value flag with no value, and print a `CliError` through `withUsageError`: -`tbbuild` the message and then its usage line, `check_examples` with its `check_examples: ` -prefix, the other two the message alone, all on stderr with exit 2. Their number checks -(`positive`, `positiveInteger`) stay in the tools, and so does the order: `tbbuild` and -`check_examples` check their numbers before `--help`. `tbrun` and `addin_test` take -`acceptsValue: () => true` and read each value as `values.x || default`, which keeps their -truthy test until C72: a value flag at the end of the list, or given `""`, gets its default, -and any other argument after it is its value. All six ignore an unknown flag (`unknown: -"ignore"`); `tbbuild` and `tbrun` take one positional (`max: 1`), a second one ignored as -before. `gen_attribute_probes` takes `unknown: "positional"` with no maximum, since every -argument after its second is ignored. `check_examples` prints its help through -`printHelpAndExit` (the same bytes: the text has no final newline); `census_attributes` still -prints the slice of its own header comment with `console.log`; `build_package_api` still has -no `--help`. The survey's list for `check_examples` lacked `--show` and `--hide`, which it -passes on to `tbbuild`; they are in its table. The edit was a Sonnet agent's (65 calls, -~275k, 18.6 min), reviewed line by line; one comment lost a dangling "too". - -The cases were recorded from the unedited tools first: 28 across the seven (`--help` in each -shape, a number refused, a dash-led value, a bad `--arch`, a number error before `--help`, -the project after an unknown flag, `tbrun`'s value flag taking the source folder, a trailing -and an empty value given the default, `build_package_api --help` ignored, -`gen_attribute_probes` with no argument). `census_attributes --help` prints its header with -the checkout's line endings, so its case allows `\r`. Some cases stop at the first check of -what the command line names (a project, a source folder, an install), which is where a -default or a positional shows; Tools.md's `check_cli` section now says a case may. `check_cli` -now makes 106 checks, 39 -probes and 67 cases. The kit's `c49-tools.mjs` (`c48-tools.mjs`'s shape, HEAD's copies -beside the real tools) runs 35 real invocations that start no IDE: `tbbuild` and `tbrun` -stopped at the project, Settings or IDE check with every option given, `addin_test` at the -IDE check and at a lane filter matching nothing, `check_examples --census` in four forms, -`--report` and `--help`, `census_attributes` over the warm BETA 987 cache, `build_package_api ---check`, and `gen_attribute_probes` writing two probe trees, compared by hash. 29 are the -same. Four differ as recorded below. `addin_test --only "("` crashes alike on both, the stack -trace's line number moved (75 to 88). `census_attributes --help` differs only in `\r`: HEAD's -copy is written from the LF blob and the tool prints its own file; the two slices are equal -without it. The harness bar is unchanged from the BETA 987 baselines: `examples.bat` exit 0 -after 126.3 s, `1129 sample(s), 1129 compile, 0 finding(s), 124.0s -- clean`; -`addin-test.bat` exit 0 after 129.8 s, `10 of 10 lane(s) ran: 10 passed`, `registry: put -back (20 project-state, 21 recent-list and 3 association writes)`, the kit's registry -snapshots identical before and after. `build.bat`, `check.bat` (the a11y line unchanged) -and `test.bat` exit 0, and the tree comparison differs only in Tools.md's page, online and -offline, the search index and `book.html`. - -What differs, none of it a recorded case: a repeated flag keeps its last value in `tbbuild`, -`check_examples`, `census_attributes`, `build_package_api`, `addin_test` and `tbrun`, whose -`opt()` also used `indexOf` (departure 9, at the owner's choice; `--census --jobs 3 --jobs -0` now refuses the 0); `--name=value` is accepted (departure 9; `--only=Reference/Core` -now filters); after `--` an argument is a positional, and `--` itself is not one; a -single-dash argument is an unknown short option, so `tbbuild -x proj.twinproj` builds -`proj.twinproj` where it said `not a .twinproj: -x`; and in `tbrun` and `addin_test` a flag -taken as another flag's value (`--port --json`) no longer also counts as itself. - -Found in passing, not fixed: `census_attributes`' `--dump-sites <file>` is in neither its -header comment nor its `--help`, which prints that comment; its help's first line is empty, -since the slice starts at a bare `//`. +**Carried forward.** `tbbuild`, `check_examples`, `census_attributes` and +`build_package_api` take the default `acceptsValue` and print a `CliError` on stderr with exit +2: `tbbuild` the message and then its usage line, `check_examples` with a `check_examples: ` +prefix, the other two the message alone. `tbbuild` and `check_examples` check their numbers +before `--help`. `tbrun` and `addin_test` take `acceptsValue: () => true` and read each value +as `values.x || default`, so a value flag at the end of the list, or given `""`, gets its +default and any other argument after it is its value; C72 removes this. All six ignore an +unknown flag (`unknown: "ignore"`), and `tbbuild` and `tbrun` take one positional and ignore +a second. `gen_attribute_probes` takes `unknown: "positional"` with no maximum, so a bare +`--help` is still its output folder (C71). `check_examples` prints its help through +`printHelpAndExit`; `census_attributes` prints the slice of its own header comment with +`console.log`; `build_package_api` has no `--help`; `check_tb_registry` reads no arguments. ### C50 — `scripts: the gates and link tools parse through lib/cli.mjs` -**Decision (e); L1-2's fourth variant.** The rest of `scripts/`. - -**Change.** `check_links.mjs`, whose collect-and-warn handling of unknown flags stays custom -code until C72; `check_links_diff.mjs`; `crawl_check.mjs`; `check_publish_policy.mjs`, whose -inline `opt` is L1-2's fourth variant; `check_regex_safety.mjs`, with its internal `--shard` -flag; `check_code_regions.mjs`; `check_gate_lists.mjs`; `convert_em_dash_separators.mjs`; -`survey_tooling.mjs`; and two no entry named until C47: `check_lint.mjs`, whose argument list -must be `--staged` or nothing, and `compare_trees.mjs`, which passes what follows `--` to -`tbdocs`. Each keeps today's behaviour. - -**Verify.** `check_cli.mjs`'s cases; `test.bat` and `check.bat` unchanged; -`check_links_diff.mjs --a script --b fused` agrees. - -**Landed.** All eleven parse through `parseCli`. The four that read their flags with -`includes` (`check_regex_safety`, `check_code_regions`, `check_gate_lists`, +**Carried forward.** The four gates that read their flags with `includes` +(`check_regex_safety`, `check_code_regions`, `check_gate_lists`, `convert_em_dash_separators`) and `check_publish_policy` take `unknown: "ignore"`; `check_publish_policy` reads `--src` with `acceptsValue: () => true`, so given last it is -still undefined. `check_links` takes `acceptsValue: (v) => v !== undefined` (its old `need()`) -and `unknown: "ignore"`, and turns a missing value back into `--x requires a value`; its -warning list is rebuilt from the kept tokens' indexes, because an unknown `--flag` without -`=` takes the positional after it along, which parseArgs makes an input. `check_links_diff` -and `crawl_check` take `acceptsValue: () => true` (a value flag takes whatever follows, and -given last is `undefined`, `NaN` once read as a number) and turn every `CliError` into their -own words, `unknown argument: X` and `unknown flag: X`. `compare_trees` splits its list at the -first `--` before parsing, which is exactly where its loop stopped, and parses the rest with -its old value guard as `acceptsValue` and `stopAt: ["help"]`. `check_lint` refuses any error, -and more kept tokens than `--staged` accounts for. `survey_tooling` moves off `node:util`'s -strict parse onto `parseCli`'s, at the owner's choice (2026-09-27): its three parse errors -now read `unknown option: --bogus`, `unexpected argument: x` and `--root needs a value` where -they were node:util's words, still followed by its usage line. The edit was a Sonnet agent's -(82 calls, ~280k, 33.9 min), reviewed line by line; two of its comments described the old -loop, one of them wrongly, and one repeated an old reason that is no longer true (`check.bat` -passes no arguments to `check_links`); all three were rewritten. - -The cases were recorded from the unedited tools first: 39 across seven tools (`check_links`' -unknown flag taking its positional and a value flag taking a following flag; -`check_links_diff`'s refusals and its same-sides stop; `crawl_check`'s refusals, `--help` -among them; `check_publish_policy --src` given last; `survey_tooling`'s refusals, pinned by -their line and the usage after it, and its number checks; `check_lint`'s whole list; -`compare_trees`' refusals, `--` included). The other four ignore every argument and have none. -`check_cli` makes 145 checks, 39 probes and 106 cases. The kit's `c50-tools.mjs` runs 22 real -read-only invocations through HEAD's copies and the migrated tools: three `check_links` runs -over the built site with every kind of flag and a `/sep/` segment (56 MB of output each, -identical), `check_links_diff --self-test` and `--list`, `crawl_check` against a port with -nothing listening, both modes of the four gates, `convert_em_dash_separators --check`, -`survey_tooling` in two forms, `check_lint` both ways and `compare_trees --max 2 -- --no-pdf`. -All are the same but `survey_tooling --bogus`, whose words change as above. -`check_links_diff --a script --b fused` reports `No differences across 6 case(s)`, and -`build.bat`, `check.bat` (the a11y line unchanged) and `test.bat` exit 0. - -What differs, beyond `survey_tooling`'s words and none of it a recorded case: `--src` given -twice keeps the last (departure 9); `--name=value` is accepted for a known flag; after `--` an -argument is a positional, outside `compare_trees`, and `--` is no longer an unknown argument -in `check_links`; a short-option group such as `-vh` is split; a lone `-` is a start URL to -`crawl_check`; `compare_trees --max x --bogus` reports the unknown argument where it reported -the bad number. +undefined. `check_links` keeps its collect-and-warn handling of unknown flags (`unknown: +"ignore"`, `acceptsValue: (v) => v !== undefined`, a missing value reported as `--x requires a +value`, and a warning list rebuilt from the kept tokens' indexes, since an unknown `--flag` +without `=` takes the positional after it along); C72 removes it. `check_links_diff` and +`crawl_check` take `acceptsValue: () => true`, so a value flag given last is `undefined` +(`NaN` once read as a number), and turn every `CliError` into their own words, `unknown +argument: X` and `unknown flag: X`. `compare_trees` splits its list at the first `--` before +parsing, and parses the rest with its old value guard (`v !== undefined && !v.startsWith("--")`) +and `stopAt: ["help"]`. `check_lint` refuses any error and any token beyond `--staged`. +`survey_tooling` parses through `parseCli`'s strict rules, with its usage line after each +error. ### C51 — `book, eval, wisdom: parse through lib/cli.mjs` -**A10-1, L1-12 (R3).** `render-book.mjs:204-225` parses by hand and rejects `--help`; -`eval/`'s four parsers differ on unknown arguments and exit codes, and `transcript.mjs:190-198` -exits 1 on a bare `--help`; "usage, then an exit code chosen by whether help was asked" is -repeated six times across `eval/` and `wisdom/`. - -**Change.** `render-book.mjs` and the five `eval/` scripts (the four parsers and -`transcript.mjs`) through `parseCli`, with -`printHelpAndExit` replacing the six copies, each keeping today's behaviour. `wisdom.mjs`'s -subcommands need code the module does not have, so it migrates only if the fit is clean, and -otherwise stays as it is with a comment saying why. - -**Verify.** `check_cli.mjs`'s cases; `book.bat` renders; each `eval/` script's cheapest mode -unchanged. - -**Landed.** All seven tools the entry names, and `eval/search_quality.mjs`, parse through -`parseCli`. `search_quality` came in with the merge of PR #210, after this entry was written, -and the owner added it to this commit (2026-09-27). `wisdom.mjs` fits cleanly, so it migrated: -its command is its first argument, whatever that is, and the rest is parsed against one table -for all three commands, as its loop did. Every tool takes `acceptsValue: () => true`, since each -value flag took whatever followed it, and converts a value (`path.resolve`, `Number`, -`parseInt`, `parseFloat`) only when one was given, so a trailing value flag still fails where -it did: a path `TypeError`, a `NaN`, a `split` of undefined. `render-book` (`unknown arg: X`, -exit 2), `build_corpus` (`unknown argument: X`, exit 1), `run_case` (`unknown argument: X`, -exit 2), `search_quality` (`unrecognised argument: X`, exit 1) and `wisdom` (`Unknown option: -X`, exit 1) refuse through `withUsageError`. `nav_hops`, `site_search` and `transcript` take -`unknown: "positional"`, because an unknown flag was a pattern, a search term or an ignored -argument to them. `printHelpAndExit` replaces the six usage-then-exit copies (five in `eval/`, -and `wisdom`'s dispatch default, on stderr), each keeping its exit-code condition, and prints -`search_quality`'s help. `transcript` declares `--help` without `-h`, because a lone `-h` was -its file argument: until C71, `-h` alone exits 0 and `--help` alone exits 1. `build_corpus` -threw on an unknown argument, so Node printed its stack; it now prints the line, still exiting -1. `render-book`'s usage line for a missing input or output is an error, not one of the six, -and is unchanged. The edit was a Sonnet agent's (49 calls, ~236k, 22 min), reviewed line by -line; one comment was rewritten. - -The cases were recorded from the unedited tools first: 73 across the eight. They cover -`render-book`'s refusals, `--help` among them, and a value flag taking a dash-led value. For -each `eval/` tool they cover its usage both ways, its refusals or its taking an unknown flag, -and a value flag given last. They also cover `transcript`'s exit codes for `--help` and `-h`, -and `wisdom`'s usage for no command, `--help` and an unknown command, and its refusals after a -command. Every `wisdom` case gives the command `bogus`, so a broken parse can only print the -usage, never start an export. A crash's stream is pinned by the line that names the problem, -allowing `\r?\n` for Node's own report. Tools.md's `check_cli` section says so now, adds a -file to what a case may stop at, and says the gate takes a few seconds (2.8 s before this -commit, 4.5 s after). The `site_search` cases were recorded before PR #210's merge rewrote -much of that file and pass on both. `check_cli` makes 218 checks: 39 probes and 179 cases. -The kit's `c51-tools.mjs` runs 26 real invocations through HEAD's copies and the migrated -tools, and compares the exit code, the masked output and every file written. The -invocations: `render-book` stopping at a missing input and a missing extra script, and one full -`book.bat` render per side, compared by page count and `pdftotext`; `build_corpus` over a -fixture and over the whole repository; `run_case --prompt-only` for both protocols and -`--smoke`, and its refusal of a corpus holding `CLAUDE.md`; `nav_hops` three ways, one over -that corpus; `site_search` four ways; `search_quality` over the full query set with `--save` -and with `--compare` (not `--sample`, which draws its queries at random, so no two runs -agree); `transcript` over a made-up session; `wisdom` with no command, `process` into scratch -whole and filtered by `--since`, `--force` and two `--channel`s, and `extract --dry-run` three -ways. All are the same. The kit's `c27-compare.mjs` reports all 21 of its export cases the -same. `build.bat`, `check.bat` (the a11y line unchanged) and `test.bat` exit 0. - -What differs, none of it a recorded case: `--name=value` is accepted for a known flag. After -`--`, an argument is a positional and `--` is not one, so `wisdom extract --` runs `extract` -and `build_corpus --dest x --` builds, where both refused the `--`. A lone `-` is -`render-book`'s input. A short group is split, so `nav_hops -hx` prints the usage. And -`render-book -ofile` and `-t5` take the attached value. +**Carried forward.** `render-book`, `build_corpus`, the `eval/` scripts (`run_case`, +`search_quality`, `nav_hops`, `site_search`, `transcript`) and `wisdom` take `acceptsValue: () +=> true` and convert a value only when one was given, so a trailing value flag still fails as +it did (a path `TypeError`, a `NaN`, a `split` of undefined). Five refuse an unknown argument +through `withUsageError`: `render-book` with `unknown arg: X` (exit 2), `build_corpus` with +`unknown argument: X` (1), `run_case` with `unknown argument: X` (2), `search_quality` with +`unrecognised argument: X` (1) and `wisdom` with `Unknown option: X` (1). `nav_hops`, +`site_search` and `transcript` take `unknown: "positional"`, since an unknown flag was a +pattern, a search term or an ignored argument to them. `transcript` declares `--help` without +`-h`, because a lone `-h` is its file argument, so `-h` alone exits 0 and `--help` alone exits +1 until C71. `wisdom`'s command is its first argument, whatever that is, and the rest is parsed +against one table for all three commands. `printHelpAndExit` prints each tool's help, keeping +each one's exit-code condition, and `wisdom`'s dispatch default prints to stderr. ### C51a — `scripts: check_cli's transcript -x case passes on Linux` -**Found by CI after C51.** The fork's deploy runs of C51 (36345344000) and of the search -commit after it (36347252812) failed at `check_cli`, 1 of 218, on `transcript -x`. The case -required a folder before the file name in Node's `ENOENT` line, which Node prints on Windows, -where it resolves the path, and not on Linux, where it prints `open '-x'` as given. - -**Change.** The folder is optional in the case's pattern, and the C51 block's comment says -why. - -**Landed.** As the entry says. The new pattern matches CI's line and a resolved Windows or -POSIX path, and refuses `--x` and `a-x`; `check_cli` makes 218 checks, all passing, and lint -is clean. The other 217 passed on Linux in both runs, so this is the whole of what CI found. -The steps after `check_cli` in the composite action did not run in either, so -`check_dot_fit`, `check_axe_patch_equiv` and the accessibility steps wait for the next push. +Landed. ### C51b — `scripts: the gate roster reads node --test lines; CI runs the search tests` -**Found while landing C51a.** PR #210 put `node --test test/search.test.mjs` into `test.bat`, -and CI has never run it: `scripts/lib/gate-roster.mjs` read only `node scripts/<name>.mjs` -lines, so `check_ci_workflows` and `check_gate_lists` did not see the step, and neither the -composite action nor Tools.md's list had it. - -**Change.** The roster reads `node --test test/<name>.mjs` as a gate too, named by its path -from the repository root (`gateName`), and `check_gate_lists` reads such a name in Tools.md's -list (a `test/` link) and in a POSIX block. The composite action runs the tests after -`check_lint`, as `test.bat` does; Tools.md lists them as `test.bat`'s fifth step, with a -section of their own, and Building.md's POSIX block and WIP.md's bullet and gate table have -them. - -**Landed.** As the entry says, at the owner's choice of registering the step fully over -adding it to CI alone. Before the action and the pages had it, both gates failed on the real -tree: `check_ci_workflows` with a `missing` finding for `test/search.test.mjs` in each -workflow, and `check_gate_lists` with six disagreements (the list, the stated count, both -POSIX blocks, and Tools.md's "Eleven steps" and "of the eleven"). After, `check_ci_workflows` -passes with 19 probes and 15 gates, and `check_gate_lists` with 21 probes and `test.bat -(12)`. The new probes: in `check_ci_workflows`, a test file in `test.bat` and not in CI, and -one in both; in `check_gate_lists`, a test file the docs do not list, with a count that agrees -with the list unless the file is read, and a test file listed by its path, CRLF and a -backslash in the wrapper. Each fault through the kit's `c43-fault.mjs` fails: a roster that -reads no test line fails a probe in both gates; a doc list that reads no `test/` link fails -the new negative; POSIX blocks that read no test line split Building.md's and Tools.md's -blocks in two. The last is caught by the real tree only, as every POSIX-block defect is. - -Found in passing, and fixed here at the owner's choice: Tools.md said eight of `test.bat`'s -eleven gates could not be affected by an edit under `docs/` and named two exceptions; -`check_lint`, which lints `docs/assets/js/`, was the third. It now says nine of twelve, and -names all three. The search tests read `builder/`, `builder/vendor/` and `eval/` only. - -**CI must show**, on the owner's next push: `check_ci_workflows: 19 probes, all pass` and -`both workflows run the wrappers' 15 gates`, and the new step passing on Linux with `tests -67` and `pass 67`. +Landed. ### C52 — `builder: tbdocs parses through lib/cli.mjs` -**A1-8 (R3), last, as decision (e) says.** `tbdocs.mjs`'s parser (`:91-194`) is neither -exported nor tested, and `--no-check` resets other flags, so order matters. - -**Change.** The option table moves out of `tbdocs.mjs` into a module that exports it. The -order-dependent resets read `parseCli`'s tokens in order. `--name=value`, which today works -for only some of its flags (L1-10), then works for all of them. - -**Verify.** `check_cli.mjs`'s cases for `tbdocs`, C18's exit value and the `--no-check` -ordering included; the tree comparison identical; `build.bat`, `serve.bat` and the CI build -steps behave as before. - -**Landed.** The new `builder/command-line.mjs` exports `OPTIONS`, `DEFAULTS` and -`parseCommandLine(argv)`. It reads the command line through `parseCli` with the defaults --- -`unknown: "error"`, no positionals, the strict value rule --- and then applies each option -token in the order given, so `--no-check` undoes only the check flags before it, the last of -`--fetch-assets` and `--no-fetch-assets` wins, and each `--port` and `--stall-timeout` is -checked where it stands. A `missing-value` error keeps `parseCli`'s words, which were -already `tbdocs`'s (`--dest needs a value`); every other refusal is `Unknown argument: -<arg>`, the argument as given, so `-xy` and `--dry-run=1` read as before. `--port` goes -through `numberOption` with `tbdocs`'s message; `--stall-timeout` keeps a hand check, -because `numberOption` refuses the blank value that `--stall-timeout=` gives, and that -disables the watchdog. `main()` parses through `withUsageError` with exit 4, and its `catch` -still exits 4 on write.mjs's `--dest` refusal; `commandLineError` is gone, since nothing -else built one. The result is today's object, key for key, with `fetchAssets` still absent -unless given. Pipeline-Stages.md has the module's export table, and a `stallTimeoutMs` row -the `BuildOpts` table lacked; Builder.md's module map has a row; Tools.md's synopsis gains -`--stall-timeout`, which it lacked, and says a value may be given as `--flag=value`. - -The oracle, in four parts. Cases first: 26 recorded from the unedited tool (with C47's four, -30 for `tbdocs`), among them every missing-value shape, `--dest --`, `-xy`, `--help`, a -boolean given a value, four bad `--port` values and a bad one before a good one, three bad -`--stall-timeout` values, and write.mjs's two `--dest` refusals, pinned by patterns since -the paths are the case's folder. Ten probes of `parseCommandLine` in `check_cli` for what no -case can reach, since each list starts a build: the defaults, `--no-check` before and after -the check flags, `--check-audit-index` after `--no-check`, both orders of each pair, -`--stall-timeout=` as 0, seconds as milliseconds, `--name=value` for four value flags, and -the two negations. `check_cli` makes 254 checks, 49 probes and 205 cases, in about 6 s (4.5 s -before). Five faults put into the module through the kit's `c43-fault.mjs`, also in -`NODE_OPTIONS` so the cases' children load them (`c52-faults.mjs`), each fail it: a -`--no-check` that keeps `auditIndex` (one probe), `parseCli`'s words for a refusal (ten -cases), `--stall-timeout` allowing -1 (one case), `--port` unchecked (seven cases and a -probe), and the first of the fetch pair winning (one probe). The kit's `c52-oracle.mjs` cuts -HEAD's parser out of `git show` and compares it with `parseCommandLine` over 76 argument -lists --- `build.bat`'s with flags a person adds, `serve.bat`'s, both workflows' builds (the -deploy's with an empty `--baseurl`, as a custom domain gives), `check_links_diff`'s and -`compare_trees`' spawns, the cases and the probes' lists: 70 are the same, and the 6 that -differ are the three differences below. `compare_trees`: only Builder.md, Pipeline-Stages.md -and Tools.md, the search index and `book.html` differ, online and offline. A test serve -(`--serve --port 4393 --dest=docs/_serve-c52 --stall-timeout=60`) built 914 pages and served -them. `build.bat`, `check.bat` (the a11y line unchanged) and `test.bat` exit 0. - -What differs, none of it a recorded case: `--check-findings=x` and `--symbol-gaps=x` are -accepted (L1-10, the point of the entry); `--` is no longer refused, and an argument after it -is refused under its own name (`--src docs -- x` prints `Unknown argument: x`), as in every -tool C51 migrated; and a bad `--port` or `--stall-timeout` value followed by a parse error -now reports the parse error, since values are checked after `parseCli` returns. All three -still exit 4. +**Carried forward.** `builder/command-line.mjs` exports `OPTIONS`, `DEFAULTS` and +`parseCommandLine(argv)`. `tbdocs` is already strict: it parses with `unknown: "error"`, no +positionals and the default value rule, through `withUsageError` with exit 4 (C18). A refusal +reads `Unknown argument: <arg>` as given, a missing value `--x needs a value`, and an argument +after `--` is refused under its own name. `--stall-timeout` keeps a hand check, because +`numberOption` refuses the blank value that `--stall-timeout=` gives, and that disables the +watchdog. *`builder/`'s helpers, defined twice: C53–C60.* ### C53 — `builder: one URL module` -**A3-3 / L4-6 (R1), A9-8 / A2-6 (R2), A2-5, and A3-5's `splitFragment` (R3).** `seo.mjs:121-148` -and `template.mjs:917-936` each define `absoluteUrl` and `relativeUrl`, and they disagree -three ways: a forced leading slash; protocol-relative `//host`, which `seo.mjs`'s scheme-only -`isAbsoluteUrl` misses and prefixes; and `null` against `""` for a non-string. -`normalizeBaseurl` is byte-identical in `book.mjs:303-307` and `offline-rewrite.mjs:232-236`, -and `book.mjs:300-302` justifies its copy by the retired Jekyll plugin layout. `encodeSpaces` -(`search.mjs:204`, `template.mjs:925`) and `splitFragment` (`render.mjs:1593`, -`crawl_check.mjs:51`) are each written twice. - -**Change.** `builder/url.mjs` with one of each. The two URL helpers take the semantics that is -right for `//host` and for a non-string, keeping a forced leading slash only where a call site -needs it. `crawl_check.mjs` imports `splitFragment` from it. - -**Verify.** The tree comparison identical, and again with `--baseurl /docs`: a non-empty base -URL is where the two helpers disagree, and this site's is empty. - -**Landed.** `builder/url.mjs` exports `absoluteUrl(url, config)`, `relativeUrl(url, -baseurl)`, `normalizeBaseurl`, `encodeSpaces` and `splitFragment`, and every copy is gone: -`seo.mjs`'s two helpers with its `ensureLeadingSlash` and `isAbsoluteUrl`, `template.mjs`'s -three, `search.mjs`'s `encodeSpaces`, `book.mjs`'s `normalizeBaseurl` with its comment about -the Ruby plugins, `offline-rewrite.mjs`'s exported one (its three importers, -`cpu-worker.mjs`, `offline.mjs` and `tbdocs.mjs`, now import `url.mjs`), and the two -`splitFragment`s, `crawl_check.mjs`'s included. `redirects.mjs` and `sitemap.mjs` import -`absoluteUrl` from `url.mjs` instead of `seo.mjs`. `relativeUrl` is `template.mjs`'s, which -is the only caller: no forced leading slash, spaces encoded, `baseurl` used as given, `""` -for a non-string. `absoluteUrl` treats a network-path reference `//host` as absolute, like a -scheme, gives `""` for a non-string (what Liquid prints for Jekyll's nil), normalises -`config.baseurl`, and reads a path from the site root, with or without its leading slash: its -result is a URL on the site, and `new URL(siteUrl + "a/b")` gave `https://docs.twinbasic.coma/b`. -That is the one place a forced leading slash is needed. Pipeline-Stages.md has `url.mjs`'s -export table, drops the two rows from `seo.mjs`'s and the one from `offline-rewrite.mjs`'s -(which said the opposite of what the function does, "the canonical trailing-slash form"), and -Builder.md's module map has a row. - -`compare_trees`: all three trees identical, and identical again with `-- --baseurl /docs`. -The kit's `c53-oracle.mjs` cuts HEAD's three helpers out of `git show` and runs them beside -`url.mjs`'s over 12 inputs, 2 site URLs and 5 base URLs (288 comparisons). `relativeUrl` -matches `template.mjs`'s on every one. The 94 that differ are all `absoluteUrl`, in six -kinds: a non-string (`null` or `"/5"` before, `""` now), `//host` (the base URL or site URL -put in front before), a path without a leading slash in `template.mjs`'s (`#x`, `a/b`), a -base URL of `/docs/` or `docs` in `template.mjs`'s (`/docs//a/`, `docs/a/` before), a space -in `seo.mjs`'s when there is no site URL, and an empty path under a base URL, now the base -URL's root, `/docs/`. None is an input any call site passes, as the tree comparisons show. -`build.bat`, `check.bat` (the a11y line unchanged) and `test.bat` exit 0. +Landed. ### C54 — `builder: one module for the HTML, XML and RegExp escapers` -**A3-2 / L3-5, A2-4 (R2).** Seven HTML escapers of two kinds: `&<>` in `render.mjs:2230-2233`, -`highlight.mjs:251-254` (matching Rouge, a recorded reason) and `gantt.mjs:213`; `&<>"'` in -`render.mjs:2225-2228`, `template.mjs:993-998` and `:999-1001` (identical bodies under two -names), and `sitemap.mjs:106-113`. `escapeHtml` names both kinds, and markdown-it has a third -function of that name. `render.mjs:1437-1450`'s `headingTocHtml` escapes `text` tokens with -one kind and `code_inline` tokens with the other. `escapeRegExp` is identical in -`render.mjs:2235-2237`, `offline-rewrite.mjs:239-241` and `book.mjs:212-214`. - -**Change.** `builder/escape.mjs` holds one escaper of each kind, named for what it escapes -(the three-character one for Rouge parity), and one `escapeRegExp`; every copy imports from -it. `headingTocHtml` escapes both token kinds alike. - -**Verify.** The tree comparison identical, since no heading in a table of contents holds an -apostrophe or quote today; a scratch page with one renders the same text in the heading and -in the table of contents. `check_regex_safety.mjs` still recognises the escaper, which it does -by shape. - -**Landed.** `builder/escape.mjs` exports `escapeMarkup` (`&`, `<`, `>`), -`escapeMarkupAndQuotes` (those and `"`, `'`) and `escapeRegExp`, and every copy is gone: -`render.mjs`'s three, `highlight.mjs`'s `escapeHtml` (its Rouge reason is now on -`escapeMarkup`), `gantt.mjs`'s `esc`, `template.mjs`'s `escText` and `escAttr` with their -"§5.15" section, `sitemap.mjs`'s `xmlEscape` (its Liquid note now at its one call), -`book.mjs`'s `escapeRegExpBook`, and `offline-rewrite.mjs`'s exported `escapeRegExp`, which -nothing imported. No name is `escapeHtml` any more; markdown-it's own function keeps it. Both -HTML escapers take `String(s)`, as `template.mjs`'s and `sitemap.mjs`'s did; every other -caller passes a string. `seo.mjs`'s `escape_once` port is not one of the seven and stays: it -leaves an existing entity alone, which neither escaper does. `headingTocHtml` escapes text -tokens with `escapeMarkup`, as it already escaped code spans, since a table-of-contents entry -is element content. `buildSvgWrapper` keeps its local `esc`, now bound to -`escapeMarkupAndQuotes`. - -**The regex-safety gate now follows an import.** It recognised an escaper by its shape within -one file, so moving `escapeRegExp` out of the three files that call it would have left their -three constructions unresolved. `scripts/lib/regex-fold.mjs` has `exportedEscapers(ast)`, the -names under which a module exports a helper of that shape (`export function`, `export const`, -`export { f as g }`; a re-export from another module is not followed), and -`foldConstructedRegexes` takes an `escapersOf(source)` that each `import { x as y }` is checked -against. `check_regex_safety.mjs` parses every file before folding any and resolves a relative -import among them. Three new fold probes: an imported escaper, one exported under another -name, and (negative) an imported function of another shape. WIP.Build.md's probe count -(fourteen to seventeen) and its paragraph on the model say so, and its book-transform -paragraph names `escapeMarkup`. Pipeline-Stages.md has `escape.mjs`'s export table and drops -`escapeRegExp` from `offline-rewrite.mjs`'s helpers row; Builder.md's module map has a row. - -`compare_trees`: all three trees identical. The gate: `504 literals + 25 constructed in 122 -files ... 0 exponential; 9 construction(s) not resolvable`, `8 classification + 17 fold -probes correct`, against HEAD's `506 literals + 25 constructed in 121 files` and 14 probes; -the census is otherwise identical. The literals lose `gantt.mjs`'s `/&/g`, `/</g` and `/>/g` -and gain the negative probe's `reason` regex. With an exponential construction planted through -the imported `escapeRegExp` (`^${escapeRegExp(s)}(a+)+$` in a scratch module), the gate exits -1 naming it, `escaped splice modelled as "x"`. With the import resolution faulted out of -`regex-fold.mjs` (the kit's `c43-fault.mjs`), it resolves 22 constructions and leaves 12 -unresolved, misses the planted one, and exits 2 on the two failing import probes. A scratch -heading holding `"`, `'`, `&` and a code span with quotes shows the same text in the heading -and in its table-of-contents entry, under HEAD's `render.mjs` and the working one; the entry's -bytes now leave a quote in text literal, as its code span always did. +Landed. ### C55 — `builder: one code/pre guard and replaceOutsideCode` -**A9-7 (R2).** The `<code>`/`<pre>` leading alternative that WIP.Build.md prescribes for a -whole-page rewrite is typed four times: `book.mjs:210` and again inside `:228-229`, -`pdf.mjs:143-144` (identical to `book.mjs`'s), and at the head of `offline-rewrite.mjs:299`. - -**Change.** A `builder/` module exports the fragment and `replaceOutsideCode`, which is -private in `book.mjs:216` today; the four patterns are composed from the fragment. - -**Verify.** The tree comparison identical, covering `book.html` in the PDF tree and every -offline page. `check_regex_safety.mjs` clean on the composed patterns. - -**Landed.** `builder/code-guard.mjs` exports `CODE_OR_PRE` and `replaceOutsideCode`, with the -reason for the guard that `book.mjs` gave; `book.mjs`'s `CODE_OR_PRE_BOOK` and private -`replaceOutsideCode` are gone. The entry's `pdf.mjs` copy went with the code C14 deleted, and -it missed a copy written since: `counts.mjs`'s `SURVIVING_PLACEHOLDER_RE`. So three patterns -are composed from the fragment, each as ``new RegExp(String.raw`${CODE_OR_PRE.source}|...`, -"g")``: `book.mjs`'s `IMG_SRC_RE_BOOK`, `offline-rewrite.mjs`'s `HTML_COMBINED_RE` and that -one. `compress.mjs`'s `CODE_BLOCK_RE` is not a guard (it splits a page into code and the -rest, `<pre>` first, with no `[^>]*>`) and stays. - -**The regex-safety gate resolves an imported literal `const`.** Composed from an imported -fragment, the three patterns, which the gate checked as literals, would have become -constructions it could not resolve. C54's `exportedEscapers` is now `moduleExports(ast)`, -giving the escape helpers a module exports and the `const`s it exports with a string or regex -literal as initialiser; an import of one is a `const` in the importing file. A literal needs -nothing from its module's scope, which is why nothing else is followed. Two new fold probes: -an imported regex's `.source`, and (negative) an imported `const` that is not a literal. -WIP.Build.md's fold paragraph says so and drops its stale "Twelve of the tree's eighteen -constructions" for the summary line's own count; its probe count is nineteen; its rule for -rendered-HTML rewrites, and WIP.md's Don't, name `code-guard.mjs`. Pipeline-Stages.md and -Builder.md have its table and row. - -`compare_trees`: all three trees identical. The kit's `c55-equal.mjs` evaluates HEAD's three -literals and `CODE_OR_PRE_BOOK` and the new expressions: the same -`source` and `flags`, all four. The gate: `502 literals + 28 constructed in 123 files ... 462 -safe, 68 polynomial, ... 9 construction(s) not resolvable`, `19 fold probes correct`, against -C54's `504 literals + 25 constructed ... 461 safe`: three literals are now constructions -under the same keys (deg3, deg3 and deg2 in the census, as before), and the negative probe's -`reason` is a new safe literal. With the import of a `const` faulted out of `regex-fold.mjs` -(`c43-fault.mjs`), the three go unresolved (`25 constructed`, `65 polynomial`, `12 ... not -resolvable`, each reported as `CODE_OR_PRE` not being a `const` in the file) and the gate -exits 2 on the failing probe. +Landed. ### C56 — `builder: guard code in the three whole-page HTML rewrites` -**A3-6 (R2).** `padEmptyCells` (`render.mjs:74-80`), `normaliseVoidTags` (`:351-354`) and -`injectAnchorHeadings` (`template.mjs:714-727`, whose `HEADING_REGEX` at `:694` has no code -alternative) rewrite whole pages with no guard. They are safe only because code is -entity-escaped before they run, which holds for fenced, indented and inline code and fails -for hand-written raw HTML, which markdown-it passes through; no page has any today. The -invariant is stated at none of the three, and `check_code_regions.mjs` checks only the -pre-render chain. - -**Change.** Each rewrite goes through C55's `replaceOutsideCode`, or, if a guard would change -what it does, states the invariant where it runs. `check_code_regions.mjs` gains post-render -probes: a raw `<pre>` holding an empty cell, a void tag and a heading-shaped line comes -through all three rewrites unchanged. WIP.Build.md's section on rewrites names these three, -and also the token-scoped `md.core` rules, the third sound mechanism it does not yet name (a -lead from L3). - -**Verify.** The tree comparison identical; each probe fails with its guard removed. - -**Landed.** All three go through `replaceOutsideCode`, so none states the invariant in place of -a guard: the guard changes nothing on today's pages, and on a raw `<pre>` or `<code>` it keeps -`padEmptyCells` and `injectAnchorHeadings` from adding whitespace where whitespace is content. -`normaliseVoidTags` changes only a tag's spelling, so its guard is for the rule's sake. -`render.mjs` exports `applyPostRenderRewrites(html)`, the two rewrites as `renderPage` applies -them, which the gate imports as it imports `applyPreRenderRewrites`; its comment says what the -guard is for (code the renderer produced cannot match, since its `<` is escaped; raw HTML -reaches the rewrites as written). `padEmptyCells` takes a function replacer, its no-break space -written `\u{a0}` instead of as a raw character, and its two comments, which disagreed about a -space and a no-break space, are one. `template.mjs`'s comment on `injectAnchorHeadings` points -to it. - -**Found in the move and fixed in it: `replaceOutsideCode` broke under the `i` flag.** It told a -guard match by `startsWith("<code")` or `"<pre"`, while the guard alternative takes the -pattern's flags. With `VOID_TAGS_RE` (`gi`) a raw `<PRE>` element was consumed by the guard, -handed to the replacer with its groups undefined, and `tag.toLowerCase()` threw: a build crash -on any page with a raw upper-case `<PRE>` or `<CODE>` (`git grep` finds none). The test now -follows the flags, `/^<(?:code|pre)/i` under `i` and case-sensitive otherwise; Pipeline-Stages.md's -row says so. - -`check_code_regions.mjs` has four `POST_RENDER_PROBES`: a raw `<pre>` holding an empty cell; -one holding a void tag, beside a `<code>` holding one; a raw `<PRE>` holding `<BR>`; a raw -`<pre>` holding a heading. Each page has a match outside the code that must still be rewritten, -and each is compared with its exact expected output. A rewrite that throws fails its probe -rather than exiting 2. The kit's `c56-faults.mjs` removes one guard at a time through -`c43-fault.mjs`: without `padEmptyCells`'s guard the first probe fails, without -`normaliseVoidTags`'s the second and third, without `injectAnchorHeadings`'s the fourth, and -with the case-sensitive test put back the third fails with `threw Cannot read properties of -undefined (reading 'toLowerCase')`. Each run exits 1, its sweep clean. - -WIP.Build.md's rewrite section names three mechanisms: the rendered-HTML bullet names the -three rewrites and why the guard matters for them, and a new bullet names the token-scoped -`md.core` rules (`kramdown-dashes`, `kramdown-ellipsis`, `kramdown-possessive` rewrite only -`text` tokens). Its gate section, WIP.md's gate row, Tools.md's list item and section, -Extending.md's failure section and gate bullet, `builder/README.md` and Pipeline-Stages.md (a -row for `applyPostRenderRewrites`; `injectAnchorHeadings`'s and `replaceOutsideCode`'s rows) -say what the probes hold. - -`compare_trees`: all three trees identical. The regex-safety gate: `504 literals + 28 -constructed in 123 files ... 464 safe, 68 polynomial ... 9 construction(s) not resolvable`, -against C55's `502 ... 462 safe`; the two new literals are the guard tests in -`code-guard.mjs`. Lint `Checked 162 files`. +Landed. ### C57 — `builder: one drift guard for the page and symbol baselines` -**A2-3 / L2-4 / L4-5 (R2).** `readBaseline` is byte-identical in `page-baseline.mjs:83-90` and -`symbol-baseline.mjs:45-52`, and the six-branch drift logic is typed twice -(`checkPageBaseline`, `:117-176`; `checkSymbolBaseline`, `:75-125`). `symbol-baseline.mjs:55-58`'s -hand-written `writeBaseline` equals `JSON.stringify(x, null, 2) + "\n"` except for an empty -list. - -**Change.** One module for the read, the drift logic and the write, parametrised by what is -compared, counts or a set of URLs; the write is `JSON.stringify`. - -**Verify.** After a build, and after each `--update-*-baseline`, both committed baselines are -byte-identical. `check_page_baseline.mjs` (11 probes) and `check_symbol_index.mjs` (46) pass, -and a reintroduced drift fails each. - -**Landed.** `builder/baseline.mjs` exports `GUARDED_SRC` (moved from `page-baseline.mjs`) and -`checkBaseline(guard, { record, write, force, file })`, which holds the read, the write and the -six branches: another source tree skipped, a forced write, a missing file failing or created, -a loss failing, a gain written. `guard` gives the file's name (`page` or `symbol`), the rest of -the missing-file sentence, and four functions: the figures of a new file, what a forced write -changed, the loss's failure text up to the commands, and the gain. The accept commands are -built from the name, once. The write is `JSON.stringify(record, null, 2)` and a newline. -`page-baseline.mjs` and `symbol-baseline.mjs` keep their exports and signatures, each now a -guard object and a one-line call, so `tbdocs.mjs` is unchanged; the two probe scripts import -`GUARDED_SRC` from `baseline.mjs`. The comments on the write restrictions, the source-tree key -and the accept command moved into `baseline.mjs`; the last lost its history (the use-case -round that found the defect), keeping the reason. - -The kit's `c57-oracle.mjs` runs HEAD's two functions and the working ones over 34 scenarios -(17 each: every branch, a loss and a gain together, a baseline missing a key, one that is not -JSON, one with CRLF, 25 and 30 lost URLs, unsorted and repeated URLs), each in a fresh folder, -and compares the result and the file's bytes after: A/A 0 differ, after the change 1, the -entry's known exception, an empty URL list now written `"urls": []` where the hand-written form -gave `"urls": [` and a blank line. The kit's `c57-faults.mjs` puts four faults into -`baseline.mjs` through `c43-fault.mjs` (a loss passes; a gain is written with `write` false; a -missing file is created with `write` false; another source tree is measured), and each fails -probes in both gates (3 and 1 for the first, 1 and 1 for each other), every run exit 1. A -build, then `tbdocs --src docs --check-audit-index --update-page-baseline`, then -`--update-symbol-baseline` (`pages 914 -> 914, static files 250 -> 250`, `4086 -> 4086 URLs`): -both committed baselines hash as HEAD's blobs after each. - -`compare_trees`: the two pages edited differ (Builder.md, Pipeline-Stages.md, with the search -data and `book.html`), nothing else. Pipeline-Stages.md has a `baseline.mjs` table and drops -`GUARDED_SRC` from `page-baseline.mjs`'s; Builder.md's module table gains rows for -`baseline.mjs` and `symbol-baseline.mjs`, which had none; WIP.Build.md's drift-guard section -names where each of its three lessons is now a comment (it said all three were in -`page-baseline.mjs`, and the second never was; it is in `check_tree_fresh.mjs`). - -**Found in passing, not fixed:** Builder.md's module table has no row for `symbols.mjs` either; -every other `builder/*.mjs` has one. Fixed in C59, at the owner's request. +Landed. ### C58 — `builder: fold six small duplicates` -Each written twice, with no recorded reason for the copy: - -- **A2-7 (R2):** the ASCII-only whitespace collapse that keeps NBSP, by regex in - `compress.mjs:73-84` and by char code in `search.mjs:251-261`. WIP.Build.md records a - shipped defect in exactly this area. -- **L4-3 (R2):** `vendor-assets.mjs`'s `fetchToFile` (`:222-252`) and `fetchAttachment` - (`:279-319`) each implement the guarded fetch and the temp-and-rename write. Their - validation, which differs for good reason, stays apart. -- **L4-2 (R3):** `scss.mjs`'s `compileLightScss` and `compileDarkScss` (`:65-76,78-89`). -- **A3-8 (R3):** three palette loops in `highlight-theme.mjs` (`:336-344,351-359,364-372`). -- **A2-8 (R3):** `replaceAll("\\", "/")` at ten sites in `offline-rewrite.mjs` and - `offline.mjs`, and a private `posix()` in `publish-policy.mjs:189`, become one helper. - `check-tree.mjs:46`'s copy stays, for its recorded reason: import cost on the dispatch - path. -- **A3-4 (R3):** `isNonEmpty` in `nav.mjs:338` and `seo.mjs:164`. - -**Verify.** The tree comparison identical, the search index, compressed pages and both -stylesheets included. For the fetch, the stubbed-fetch script from the last review -(`PLAN-REVIEW-c9f2dfe0-1b6922b.md`, C09) still rejects an HTML body and survives a network -failure, and every committed thumbnail still validates. - -**Landed**, five of the six, by one Sonnet agent (121 calls, ~227k, 11.6 min) and reviewed by -hand. A2-7 is not folded: the two collapses trim differently ("Where the plan was wrong"). -- **L4-3**: `vendor-assets.mjs` has two private helpers, `guardedFetch(url)` (the fetch, the - status check and the body read, every failure as `{ ok: false, status }`) and - `writeAtomic(buf, destPath)` (temp file and rename). `fetchToFile` and `fetchAttachment` - call both and keep their own validation. -- **L4-2**: `scss.mjs`'s two exports call a private `compileScss(srcRoot, rel, label)`. -- **A3-8**: `highlight-theme.mjs`'s three loops call a local `renderPalette(selectorFor, - palette, bg)`. -- **A2-8**: `paths.mjs` exports `posix(p)`; the seven sites in `offline-rewrite.mjs` and - `offline.mjs` and `publish-policy.mjs`'s private copy use it. `check-tree.mjs` keeps its own - exported copy for its recorded reason, which `check.mjs` imports, and `paths.mjs`'s comment - names it. A site that called `replaceAll` on a value now passes it through `String()`, as - the private copy did. -- **A3-4**: `nav.mjs` exports `isNonEmpty` and `seo.mjs` imports it. - -Pipeline-Stages.md has rows for `posix` and `isNonEmpty`. `compare_trees`: only -Pipeline-Stages.md's page differs (with the search data and `book.html`); the search index, -every compressed page and both stylesheets are identical. The agent's `c58-fetch.mjs` (the -C09 script was described there, not kept, so it was rewritten) drives `vendorAssets()` with a -stubbed `fetch` through both paths: an HTML body is rejected with no file and no temp file -left, a rejected `fetch` is warned about and counted, and all 16 committed thumbnails validate -(there are no committed attachments). Its output from a `git archive` copy of HEAD and from the -working tree is identical. The regex-safety gate is unchanged: `504 literals + 28 constructed -in 124 files` (C57's `baseline.mjs` is the 124th file). +Landed. ### C59 — `builder: cpu-worker's timed task paths share one runner` -**A1-3 / L4-1 (R2), A1-7 (R3).** The same run, time and report block appears three times in -`cpu-worker.mjs` (`:360-377,423-440,469-486`). The fourth path (`:497-540`) is genuinely -different, with an ordering that closes a race, and stays as it is. `:510` writes the literal -`4` for FAILED, the one SAB constant not used by name. - -**Change.** One `runTimed()` for the three paths; `:510` uses the named constant. - -**Verify.** The tree comparison identical; the build reports its task timings as before. - -**Landed.** `cpu-worker.mjs` has a private `runPerWorkerTask(taskIdx, meta)`, named for what it -runs rather than `runTimed`, since the fourth path is timed too. It times the handler, marks -the task done for the lane and posts the `perWorkerTiming` message; on a throw it posts -`taskFailed` and returns false, and the caller ends the pull loop, as each copy's `return` -did. The idle, nested and on-demand paths call it in one line each, and each still reads the -task's metadata where it did, the nested path after releasing its claimed task. The fourth path -writes `FAILED`, now imported from `sab-scheduler.mjs`. Nothing reads that status (A1-7), so -the name changes nothing. - -`compare_trees` on the code change alone: identical (1461, 1457 and 137 files). The build's -summary still gives a `boot`, `render` and `write` time for each of the 16 lanes. The Gantt -charts of HEAD's build and the working one, kept by `compare_trees --keep`, draw the same bars -by class: 16 `gb-boot`, 16 `gb-env`, 16 `gb-cold`, 153 `gb-render`, 167 `gb-write`, 23 -`gb-spine`, 9 `gb-seeds`. The kit's `c59-faults.mjs` builds the `check-src` fixture from a `git -archive` copy of HEAD and from the working tree, with a throw put at the top of `warmInit`, -`renderEnvInit` or `flush` through `c43-fault.mjs`. `flush:<i>` runs through the fourth path, -so its fault covers the `FAILED` write. Every faulted build exits 1 in about a second with -`task <name> failed` and the fault as its cause, and the two sides print the same lines. The -first run differed only in which flush chunk failed first (`flush:0` against `flush:1`), a race -the second run did not repeat. - -Builder.md's module table gains a row for `symbols.mjs` (C57's Found item), under Write phase -beside `search.mjs`, as `builder/README.md` groups them; every `builder/*.mjs` now has exactly -one row. Its "Architecture at a glance" said `~34 modules` against 44; at the owner's choice it -now says "dozens of modules", linked to the module map, rather than a figure nothing derives. -With both, `compare_trees` differs in that page alone, with the search data and `book.html`. +Landed. ### C60 — `builder: name tbdocs's exit bits and set them in one place` -**A1-6 (R2).** Seven sites set exit bits with bare literals -(`tbdocs.mjs:560,1469,1470,1568-1573,1598,1610`), five of them bit 0. The three plain -assignments are safe only because they run before the ones that OR (V1's third note). - -**Change.** Named constants for the two bits and for C18's command-line value, and one -`failBuild(bit)` that ORs. - -**Verify.** Each provoked failure exits as before: a broken link 1, an integrity failure 2, -both 3, a command-line error 4, a baseline drift 1. A scratch copy that moves an assignment -after an OR still exits with both bits. - -**Landed.** `tbdocs.mjs` exports `EXIT_FAILED` (1), `EXIT_INTEGRITY` (2) and -`EXIT_COMMAND_LINE` (4), with the reasons for the scheme beside them, and has a private -`failBuild(bit)` that ORs a bit into `process.exitCode`. All seven sites call it: the three -plain assignments (vendorAssets, dot, scss), the check's combined code (now one call per bit) -and its recheck-only branch, and the two baseline guards. `main()`'s usage error, its `--dest` -refusal and its crash exit use the constants, and so do `serve.mjs`'s refusal and its two -`process.exit(1)`s, since it imports from `tbdocs.mjs` already. The comments that justified -each OR in place (one of them the history of the clobbered bits) are gone; `failBuild`'s says -why. Pipeline-Stages.md has a row for the constants and names `failBuild` in `checkReport`'s -exit-code line; Builder.md, Building.md, Pipeline-Stages.md's vendorAssets paragraph, and the -`dot.mjs` and `scss.mjs` headers say "exit bit 1" where they quoted `process.exitCode = 1`; -WIP.Build.md's rule now names `failBuild`. - -The kit's `c60-exits.mjs` builds scratch sources made from the `check-src` fixture from a `git -archive` copy of HEAD and from the working tree, 17 cases each: clean 0, a broken link 1, a -duplicate id 2, both 3, a broken diagram 1, a broken stylesheet 1, a diagram with an integrity -failure 3, a stylesheet with a link 1, and through `c43-fault.mjs` a failed asset fetch 1 (with -an integrity failure 3), a baseline drift 1 (3), a crash in `discover` 1, and an unknown flag, -a `--dest` over the source and the same under `--serve`, each 4. Every case exits and prints -the same on both sides, before the change and after. The one designed to differ puts a bit-0 -failure after the check has set bit 2: HEAD's form, `process.exitCode = 1`, exits 1, losing -the integrity failure, and the working tree's, `failBuild(EXIT_FAILED)`, exits 3. `compare_trees`: -the three pages edited differ, with the search data and `book.html`, and nothing else. No -non-zero exit literal is left in `builder/*.mjs`. +**Carried forward.** `tbdocs.mjs` exports the exit bits `EXIT_FAILED` (1), `EXIT_INTEGRITY` +(2) and `EXIT_COMMAND_LINE` (4), and sets them through a private `failBuild(bit)` that ORs a +bit into `process.exitCode`; `serve.mjs` imports the constants for its refusal and its two +failure exits. No non-zero exit literal is left in `builder/*.mjs`. *The harness: C61–C65.* ### C61 — `scripts: one logicalLines for twinBASIC source` -**L4-10 (R1).** `tb-fences.mjs:423-445` and `twin-api.mjs:51-86` each split source into -logical lines, and differ on a BOM and on block comments: `tb-fences.mjs` has no `/* */` -handling at all, and survives a BOM only because `trim()` strips U+FEFF (V3). Both strip -comments with quotes in mind, for the same reason. Swapping one for the other is not -mechanical: `twin-api.mjs` keeps blank lines, numbers lines from 1 where `tb-fences.mjs` -counts from 0, never trims, and recognises `Rem`. - -**Change.** One exported `logicalLines` in `twin-api.mjs`; `tb-fences.mjs` uses it and -absorbs each of the four differences on purpose. Probes for a `/* */` spanning two lines and -for a BOM join `check_examples.mjs`'s `runProbes`. - -**Verify.** `check_examples.mjs --census`, which runs its 119 probes and needs no compiler, -unchanged apart from the new probes. The `examples.bat` summary unchanged, 1,119 samples (a -harness run). - -**Landed.** `tb-fences.mjs` imports `logicalLines` from `twin-api.mjs`, and its own splitter -is gone. `classify` trims each logical line and drops the blank ones. `usesMe`, a third copy -of the same quote-aware strip, reads the logical lines too: its comment's reason, that -`logicalLines` does not blank strings, stopped being true. `twin-api.mjs` changes only by a -line in `logicalLines`' comment naming its second user. The entry's differences are absorbed -so: a BOM and a `/* */` are now handled; blank lines are dropped in `classify`; the line -number went with its field, which nothing read; each line is trimmed; a `Rem` line is now a -comment. Three the entry does not list: strings arrive blanked, a joined continuation keeps -the space before its ` _`, and a lone `\r` no longer ends a line (a markdown-it fence never -holds one). `CLASSIFIER_PROBES` gains three: a `/* */` over two lines, a BOM, and a fence of -only comments and blank lines, so the census runs 122 probes. - -The kit's `c61-oracle.mjs [<rev>]` archives HEAD's `scripts/lib` and `lib` and compares -HEAD's splitter, `classify` and `usesMe` with the working tree's over all 1,226 `tb` fences, -and `twin-api.mjs`'s splitter and `parseTwin` over the 661 `.twin` files of the BETA 987 -census export. The split text differs on 725 fences: 558 by blanked strings, 64 by -continuation whitespace, 102 by both, and one by a `/* in */` inside a signature -(`Features/Language/Comments.md`). `classify` and `usesMe` agree on all 1,226 fences, and -`parseTwin` on all 661 files. `check_examples --census` differs only in its probe count. -HEAD's classifier gives the block-comment probe `null`. The kit's `c61-faults.mjs` puts four -faults in through `c43-fault.mjs`: without block comments the first probe fails, with blank -lines kept the third, untrimmed the first. Without the BOM strip every probe passes, because -`classify`'s `trim()` removes U+FEFF as well, so the BOM probe fails only with both gone. -`examples.bat`: exit 0 after 152.5 s, `1134 sample(s) from 598 page(s) in 43 project(s), 4 -lane(s), BETA 987, 2 staged file(s)`, then `1134 sample(s), 1134 compile, 0 finding(s), -149.2s -- clean`; the census before the edit already counted 1,134 marked, so the rise from -1,129 is the pages'. +**Carried forward.** `check_examples.mjs --census`, which needs no compiler, runs 122 probes: +the 119 of departure 10, and three `CLASSIFIER_PROBES` added by this entry (a `/* */` over two +lines, a BOM, and a fence of only comments and blank lines). ### C62 — `scripts: one twinBASIC keyword classifier, with probes in test.bat` -**A8-1 (R1), A8-4 (R2).** `census_attributes.mjs`'s `MODS` (`:138-148`) lacks `Overridable`, -`Iterator` and `Dim`, which `twin-api.mjs:123-125` and `tb-fences.mjs:340-343` have, so -`Public Overridable Sub` falls through to the variable path. The BETA 983 packages hold 31 -such lines and none has an attribute, so no census result changes today. Two more -divergences are structural: `blankStrings` has no `""` escape, and the block-comment state is -kept per line. Neither this classifier nor `gen_attribute_probes.mjs`'s `parseTargets` -(`:961-978`) has a test, and both have shipped silent misparses -(`census_attributes.mjs:42-67,150-152`; `gen_attribute_probes.mjs:946-947`). - -**Change.** One classifier module in `scripts/lib/` for the modifier keywords and declaration -shapes, used by all three. Ride-along probes for it, for census's classification and for -`parseTargets`, in a new `test.bat` gate, `check_twin_parsers.mjs`, registered in the -composite action, Tools.md and WIP.md. - -**Verify.** `census_attributes.mjs --json` unchanged (a harness run); -`gen_attribute_probes.mjs`'s output unchanged; removing `Overridable` from the list fails a -probe. CI waits for the owner's push. - -**Landed.** `scripts/lib/twin-declarations.mjs` exports `MODIFIERS`, the words allowed -before a declaration keyword as regex alternatives, and census's line classifier, moved -there so a gate can import it: `decomment` and `declarationKind(decl, container)` (census's -`classify`, with `DECL_RE` and `VAR_RE`). `MODIFIERS` is the union of the three lists less -`Optional`, `Dim` and `Const`: 30 words. `Dim` and `Const` open declarations of their own, and -the two scanners that read one as a modifier add it (census `Const`, twin-api `Dim`). -`Optional` is a parameter keyword: in the census it turned seven parameter-continuation lines -from unresolved into `Variable`. The list is one string literal, because -`check_regex_safety` folds an imported `const` only when its initialiser is a literal; as a -concatenation, the three constructions built from it went unresolvable (28 constructed and 9 -unresolvable became 26 and 11). `census_attributes.mjs` keeps `OPEN_RE` and `CLOSE_RE`, built -from `MODIFIERS` plus `Const`; `twin-api.mjs`'s `MODIFIER_RE` and `tb-fences.mjs`'s `rx` build -from it too. `parseTargets`, its two rule tables and `stripDots` moved unchanged from -`gen_attribute_probes.mjs` to `scripts/lib/attributes-doc.mjs` (a script compared the cut text -with the moved text). The two structural divergences are left alone: census reads its source -a physical line at a time, so its `blankStrings` and its per-line `/* */` stay as they are. - -The new gate `scripts/check_twin_parsers.mjs` (in `test.bat`, the composite action, Tools.md's -list, POSIX block and a section, Building.md's POSIX block, and WIP.md's bullet and table) runs -121 probes: `Public <word> Sub Foo()` for each of the 30 words through `declarationKind`, -`parseTwin` and `classify`, 19 `declarationKind` shapes and 12 `parseTargets` lines. Tools.md -now says "Thirteen steps", which `check_gate_lists` could not read: its number words stopped at -twelve, and now run to twenty. The kit's `c62-faults.mjs` puts five faults in through -`c43-fault.mjs`, and each fails the gate on the probes named after it: `Overridable` out of the -list (three), no `decomment` (one), a `Const` read as a variable (two), no whole-phrase rules -(two), the singular `const` rule (one). A `check_gate_lists` word list off by one fails it -three times. - -The kit's `c62-oracle.mjs` runs HEAD copies of `census_attributes.mjs` and -`gen_attribute_probes.mjs` beside the real ones (census `--json` and the Markdown report over -the BETA 987 cache, no compiler; the generator's project and key), and all 137 files written are -identical. It also runs HEAD's `classify` and `declarationKind` over every line of the 661 -`.twin` files under four containers: 128 of 415,428 pairs differ, all of them the 32 -`Overridable` procedure lines, now read as `Sub` or `Function` (A8-1's fix; none carries an -attribute, so the census does not move). C61's `c61-oracle.mjs` finds `classify` identical on -all 1,226 fences and `parseTwin` on all 661 files. `check_regex_safety`: `503 literals + 28 -constructed in 125 files ... 463 safe, 68 polynomial ... 9 construction(s) not resolvable`. - -Found, not fixed: `declarationKind` reads a field named `Type` inside a `Type` block as a -`Type` declaration, since `DECL_RE` is tried before the container rules. It takes an attribute -on such a field to matter, and the census output shows none. +Landed. ### C63 — `scripts: click the build icon like every other control` -**A7-3 (R2).** `tb-ide.mjs:848-861`'s `clickCenter` has no scroll into view, hit test or -retry, and its two callers are both the build icon (`tb-ide.mjs:757`, `tbrun.mjs:295`). -`tb-operate.mjs:79-134`'s `click` has all three and serves four of the ten add-in tests. - -**Change.** Both callers use the click with the hit test and retry. The harness modules stay a -DAG: if `tb-operate.mjs` imports `tb-ide.mjs`, the click moves down to where both can reach -it. If the build icon turns out to need the plain click, it keeps it, with a comment saying -why. - -**Verify.** `addin-test.bat` green, all ten lanes; the `examples.bat` summary unchanged -(harness runs, one at a time). - -**Landed.** The click moved down into a new `scripts/lib/tb-click.mjs`, word for word from -`tb-operate.mjs`: `targetJs` (now exported), `named`, `clickAt` and `click`. Its `sleep` is -`node:timers/promises`' `setTimeout`, because `tb-ide.mjs`, which exports the other one, -imports this module. `tb-ide.mjs` imports `click` and `clickCenter` is gone. `buildProject` -returns `click`'s error as its `message` (`cannot click #buildIcon: <why>`) where it returned -`no #buildIcon in the IDE page -- did the project load?`, and `tbrun` throws it where it threw -that. `tb-operate.mjs` keeps `elementRect`, imports `targetJs`, `click` and `clickAt`, and -re-exports the last two, so no scenario's import changed. WIP.Harness.md's list of files to -read it before changing names the new module. The build icon now gets what every other control -gets: the pointer moved there first, a scroll into view, a hit test, and up to five seconds for -the icon to be there, sized and uncovered. Where `clickCenter` returned false at once for a -missing icon, the click now throws after five seconds; where it pressed whatever covered the -icon, and the build then waited out its timeout, the click throws naming what covers it. - -`tbrun` on the kit's `tbrun-probes/clean`, before and after: exit 0 (23 s, 22 s), `one`, -`two`. The kit's `c63-faults.mjs` puts two faults in through `c43-fault.mjs`: an overlay over -the whole page, added before the first hit test, gives exit 2 after 24.8 s and `tbrun: cannot -click #buildIcon: its centre is covered by #c63cover`; a Build button that is not there gives -exit 2 after 40.3 s and `there is no such element`. `addin-test.bat` through the kit's -`c25-run.mjs`: exit 0 after 145.9 s, `10 of 10 lane(s) ran: 10 passed`, `registry: put back -(20 project-state, 21 recent-list and 3 association writes)`, the snapshots before and after -identical. Eight of the ten lanes, all but `symbols` and `ideserver`, build add-ins through -`buildProject`, ten builds in all, so the Build button was pressed through the new click ten -times. `examples.bat`, which presses no Build button (`tbbuild` only compiles), shows that -`tb-ide.mjs` still loads and does what it did: exit 0 after 146 s, `1134 sample(s), 1134 -compile, 0 finding(s), 142.7s -- clean`, as in C61. +Landed. ### C64 — `scripts: three small harness duplicates` -- **A7-4 (R2):** `alive` and `norm`, private in `tb-registry.mjs` (`:588-590`, `:462`) and - repeated in `addin_test.mjs` (`:105,230`), which imports ten other names from it. Export - them. -- **A7-7 (R3):** the 180-second compile timeout, a literal at seven sites in four files. One - named constant. -- **A8-3 (R3):** `check_examples.mjs` computes a fence's unit key in `makeBatches` (`:424`) - and again in `unitsOf` (`:675`). One function. - -**Verify.** The `examples.bat` summary and `addin-test.bat` unchanged (harness runs, one at a -time). - -**Landed.** `tb-registry.mjs` exports `alive` and `norm` under their own names, each with a -line saying what it is, and `addin_test.mjs` imports them and drops its copies. `tb-ide.mjs` -exports `COMPILE_TIMEOUT` (180,000 ms) above `waitForCompile`. There were eight sites in five -files, not seven in four: `tbbuild.mjs`'s `--timeout` default, 180 in seconds, is the eighth, -and now reads `COMPILE_TIMEOUT / 1000`; its header still states 180, and the constant's comment -says so. The two JSDoc lines that said `default 180000` name the constant. `check_examples.mjs` -has a module-level `unitKey(fence)` above `makeBatches`, carrying the comment that stood over -the arrow function it replaces, and `unitsOf` calls it too. The two sites held the same -expression, so a clean run, which never splits a batch, covers `makeBatches` only, and -`unitsOf` is the same code by reading. `examples.bat`, whose lanes run `tbbuild` with its -default: exit 0 after 138 s, `1134 sample(s) from 598 page(s) in 43 project(s), 4 lane(s)`, -then `1134 compile, 0 finding(s), 135.3s -- clean`, the layout and result of C61 and C63. -`addin-test.bat` through the kit's `c25-run.mjs`: `10 of 10 lane(s) ran: 10 passed`, the -registry put back as in C63, the snapshots identical. `tbrun` on `tbrun-probes/clean`: exit 0, -`one`, `two`. +Landed. ### C65 — `test: one scenario preamble and one linesSince for the add-in tests` -**A7-8 / L4-14 (R2).** All ten `test/addin/*.test.mjs` files write their own lane preamble -and skip object, and six read "console lines since a mark" in four ways: `appdata.test.mjs:33` -and `panes.test.mjs:86` with hard-coded slice offsets, `arch.test.mjs:31` and -`reload.test.mjs:37` with two different regex captures, `keys.test.mjs:28` with a split and a -trim, and `sample10.test.mjs:81` with a bare trim. - -**Change.** A scenario helper for the preamble and the skip object, and one `linesSince` -beside `readConsole`. - -**Verify.** `addin-test.bat` green, all ten lanes (a harness run). - -**Landed.** The new `test/addin/scenario.mjs` exports `scenario(title, fn)`: the file's lane -from `addinLane`, a `describe` block skipped with the one reason when there is none, and an -`after` hook that closes the lane. `fn` gets the lane, and a function it returns runs after the -close in a `finally`, which is how `panes.test.mjs` keeps closing its page server when closing -the lane fails. All ten files are one `scenario()` block now, with no `addinLane`, skip object -or `after` of their own; `tb-lane.mjs`'s header points to the module for the outline it used -to show, and WIP.Harness.md's runner section names it. `tb-ide.mjs` exports `linesSince(c, -mark, { prefix })` after `consoleMark`: `readConsole`'s text since the mark, split and each -line trimmed, and with `prefix` only the lines that start with it, without it, which replaces -`appdata`'s and `panes`' `slice(15)` and `slice(13)`. `arch`, `reload`, `entry`, `keys` and -both of `sample10`'s reads use it, and so do `buildProject` and `tb-operate.mjs`'s -`openedUrls`, which read the console the same way. `keys.test.mjs`'s substring search for -`[KeysProbe] registered` still reads the text. Two reads changed slightly: `sample10`'s -`[WaynesWorldAddin]` lines are trimmed before the prefix test, and its one-line wait reads the -non-empty lines joined, where it trimmed the whole text. Both give the same answer for what the -add-in prints. The ten files were converted by one Sonnet agent from a brief (69 calls, ~193k, -3.6 min; one comment needed correcting) after a Sonnet Explore survey (24 calls, ~139k, 4.2 -min). - -A scratch test through `c43-fault.mjs`, with `addinLane()` replaced by a lane whose `close` -logs and optionally throws, shows the order: the close, then the returned function, also when -the close throws; with no lane the block is skipped. `addin-test.bat` through the kit's -`c25-run.mjs`: exit 0 after 130.0 s, `10 of 10 lane(s) ran: 10 passed`, the registry put back -as in C63, the snapshots identical, and no `✖` line in any lane's output. +Landed. ### C65a — `test: a lane fails when closing it finds a problem` -**Found while landing C65** (see Found while implementing). What `Lane.close` finds, a -compiler crash or a javascript dialog, never fails a lane, because a throwing `after` hook -leaves a `node --test` file's exit code 0. - -**Change.** `scenario()` closes the lane in a test of its own, the block's last, so that what -the close finds is a failing test. The `after` hook stays, for a block whose tests never ran; -closing a lane a second time does nothing (`closeProject` finds no connection, `shutdownIde` -returns on null, the copy is already gone). The function a scenario returns still runs after -the close, whichever of the two closed it. WIP.Harness.md's runner section says a problem -found at close fails the lane only because the close is a test. - -**Verify.** The scratch test with a lane whose `close` throws exits 1 under `node --test`, -and 0 with one whose `close` works; `addin-test.bat` green, all ten lanes (a harness run). - -**Landed.** After `fn` has declared the block's hooks and tests, `scenario()` declares one more -test, `the lane closes with nothing found`, and gives the `after` hook the same function. That -function runs once, whichever calls it first, so the hook closes the lane only when the test -never ran, and the function a scenario returns, called in the close's `finally`, runs once too. -A flag rather than `Lane.close`'s own idempotence keeps it to one run. `scenario.mjs`'s -comments say why the close is a test, WIP.Harness.md's runner section says so as well, and -`Lane.close`'s comment says it is for `scenario()` rather than `after()`. Each lane reports one -test more. - -The kit's `c65-scenario.mjs` runs a scratch scenario seven ways, with `addinLane()` replaced -through `c43-fault.mjs` by a lane whose `close` logs and optionally throws. On HEAD a close -that throws exits 0, run directly and under `--test`; now it exits 1 both ways, with the close -test under "failing tests" and its `close failed`. A close that works exits 0 with `pass 2`; -with no lane the block is skipped (`tests 0`). A `before` hook that throws exits 1 on both -sides, and the close still runs, from the `after` hook. In the six cases with a lane, the close -and the returned function run once each, in that order. - -`addin-test.bat` through the kit's `c25-run.mjs`, twice: exit 0 after 131.8 s and 131.4 s, `10 -of 10 lane(s) ran: 10 passed`, the registry put back as in C63 and the snapshots identical. -The second run counted the close test's result lines in the lanes' output: ten, one per lane. +Landed. ### C65b — `book: delete fast-inflate.mjs, which patched a function pdf-lib never calls` -**Found while designing C66** (see Found while implementing). `fast-inflate.mjs` replaces -`pako.inflate` with `zlib.inflateSync`, and pdf-lib 1.17.1 never calls `pako.inflate`: its -`cjs/` tree, the build Node loads, calls only `pako.deflate`, and a load decodes the -cross-reference stream and object streams through pdf-lib's own `FlateStream`. The shim changes -nothing, and three documents describe a call site that does not exist: its Fixes-PDFLib.md -section, `render-book.mjs`'s header, and Builder.md's reason for declaring and pinning `pako`. -No code of ours imports `pako` without it. - -**Change.** Delete the shim and its import; drop its Fixes-PDFLib.md section, its line in -`render-book.mjs`'s header and the `perf/` rigs' `--fast-inflate` flag and imports; `npm -uninstall pako` (the owner's choice, which confirmed the uninstall), with Builder.md's -Dependencies updated. pdf-lib still installs pako 1.0.11 as its own dependency. C66 and C69 -then cover twelve shims. - -**Verify.** `book.bat` renders with the same page count and outline; the lockfile loses only -the root's `pako` line. - -**Landed.** As the entry says. `render-book.mjs` loses the import and the shim's four lines in -its header; Fixes-PDFLib.md loses the section, which no page linked to; Builder.md loses -`pako` from its Dependencies block, from the sentence on the PDF renderer's packages and from -the pin list. In `perf/`, `measure.mjs` loses the flag, its comment, variable, argument branch -and import, `instrument-objclasses.mjs`, `instrument-pioh.mjs` and `phase0-measure.mjs` their -import, and `perf/README.md` the flag's bullet and its mentions in three command lines (done by -a Sonnet agent: 33 calls, ~126k, 2 min; accurate). `perf/notes/` is the record of the -measurements and keeps its account. `npm uninstall pako` removed one line from each of -`package.json` and `package-lock.json`, and `node_modules/pako` is still 1.0.11. - -The book rendered twice from one `_site-pdf`, through HEAD's `book/` and `lib/` archived into -a scratch folder and through the working tree's: 92 s and 93 s, both `process: 1.1s` and -`1.0s`, 2,298 pages, 2,466 outline entries and 29,111,771 bytes each. The files differ in one -run of 28,991 bytes, inside one object stream of 500 objects; inflated, the two streams differ -only in `/CreationDate` and `/ModDate`, the render times. `compare_trees`: Builder.html and -Fixes/PDFLib.html online and offline, the search data, and `book.html`. +Landed. ### C65c — `book: fast-parse-number reads a decimal as Number() does` -**Found while building C66** (see Found while implementing). `fast-parse-number.mjs` read a -decimal as `intPart + frac / scale`, which rounds twice, and accumulated a fraction of any -length, so `2.28` read as `2.2800000000000002` and `-40.8933` as `-40.893299999999996`, where -stock pdf-lib's `Number()` gives the double nearest the decimal. The book is not affected -today: none of the 70,978 decimals in the object streams of the render of 2026-09-25 has the 15 -or more significant digits that a misread number is written with. - -**Change.** Divide once, `(intPart * scale + frac) / scale`: while the number has at most 15 -digits both operands are exact integers, and IEEE 754's correctly rounded division gives what -`Number()` gives. Past 15 digits, the fraction's included, rewind and delegate to the original, -as a long integer already did. Fixes-PDFLib.md's section says so. - -**Verify.** The kit's `c65c-oracle.mjs` runs stock `parseRawNumber`, HEAD's shim and the -working one over the same numbers; the book renders the same through HEAD's shims and the -working ones. - -**Landed.** As the entry says; the shim's header says the 15 digits include the fraction's. -The kit's `c65c-oracle.mjs` over 289,724 numbers (16 fixed, the rest random: a sign or none, up -to 17 integer digits, up to 19 after the period, or a bare period): HEAD's shim differs from -stock `parseRawNumber` on 1,747, `2.28`, `-40.8933` and `1.610936` among them; the working one -on none, in value (`-0` told apart), end offset and throw alike. The book rendered twice from -one `_site-pdf`, through HEAD's `book/` and `lib/` and through the working tree's: 91 s and -88 s, both `process: 1.1s`, 2,298 pages, 2,466 outline entries and 29,111,771 bytes each, -differing in one object stream and there only in `/CreationDate` and `/ModDate`. C66's gate, -not yet committed, passes over the fixture that found the defect, with its page insertion -taken out until C65d. `compare_trees`: Fixes/PDFLib.html online and offline, the search data, -and `book.html`. +Landed. ### C65d — `book: fast-dict-onebuf builds new pages and page trees in its buffer` -**Found while building C66** (see Found while implementing). `fast-dict-onebuf.mjs` replaces -`PDFDict`'s methods with ones that read a dictionary's entries from one shared buffer, and -replaces six of pdf-lib's eight factories for `PDFDict`, `PDFCatalog`, `PDFPageTree` and -`PDFPageLeaf` to build there. The other two, `PDFPageTree.withContext` and -`PDFPageLeaf.withContextAndParent`, still build on a `Map` with `new`, and the replaced methods -cannot read what they make: `insertPage` and `addPage` fail (`Expected instance of PDFArray, -but got instance of undefined`, from the new page's `/MediaBox`), and `PDFDocument.create` -would. The book never adds a page; `parallelSave` would, only for a document with none. - -**Change.** Replace the two, building pdf-lib's entries in pdf-lib's order through the shim's -own `fromMapWithContext`. Fixes-PDFLib.md's section names the eight factories. - -**Verify.** `create`, `addPage`, `insertPage` and a save give stock's bytes under the shim -alone and under every shim; C66's gate passes with its page insertion; the book renders the -same. - -**Landed.** As the entry says. pdf-lib 1.17.1 has exactly these eight static factories on the -four classes, and C65d's two are the ones `PDFDocument.create` (`PDFDocument.js:146`) and -`PDFPage.create` (`PDFPage.js:1435`) call. The kit's `c65d-oracle.mjs` runs `create`, -`addPage`, `insertPage`, `drawText`, another `addPage` and `save` in a process per shim set: -stock gives 3 pages and 1,019 bytes; HEAD's shim fails at `insertPage` alone and with every -shim; the working one gives stock's bytes (same sha256) both ways. C66's gate, not yet -committed, passes with nothing taken out of its change: 22 objects, every shim run. The book -rendered through HEAD's `book/` and `lib/` and through the working tree's: 88 s each, -`process: 1.1s` and `1.0s`, 2,298 pages, 2,466 outline entries and 29,108,192 bytes each (the -count moved with C65c's edit to Fixes-PDFLib.md), differing only in `/CreationDate` and -`/ModDate`. `compare_trees`: Fixes/PDFLib.html online and offline, the search data, and -`book.html`. +Landed. ### C65e — `docs: Fixes.md stops counting the pdf-lib shims` -**Found while building C66** (see Found while implementing). Fixes.md says twice that there -are thirteen `fast-*.mjs` shims; C65b left twelve and did not edit that page. - -**Change.** Both sentences name the shims without a count, which nothing keeps true. - -**Verify.** `compare_trees` shows the page, the search data and `book.html`, and nothing else. - -**Landed.** As the entry says; a search of the tree outside `perf/notes/` for a count of -thirteen shims finds only this plan's own entries. `compare_trees`: Fixes.html online and -offline, the search data, and `book.html`. +Landed. *The book's pdf-lib shims (decision (c)): C66–C69.* ### C66 — `book: check_pdf_shims_equiv.mjs, the shims against stock pdf-lib` -**A9-2 (R2).** No test compares shimmed pdf-lib output with stock; the only comparisons are -one-off notes in `perf/notes/08-pdf-lib.md`. - -**Change.** A `test.bat` gate modelled on `check_axe_patch_equiv.mjs`. The same document is -loaded, changed and saved by stock pdf-lib in a child process and by pdf-lib with the twelve -shims (thirteen before C65b) installed here, and the two results are compared object by object, with streams -decompressed, since a different deflate can give different bytes for the same content. The -document is generated in the gate to reach every shimmed path (parsing, arrays and -dictionaries, the parallel deflate, the inflate replacement), so the gate needs no built tree. -Registered in the composite action, Tools.md and WIP.md. - -**Verify.** Passes; a deliberately broken shim fails it, and the report names the shim. CI -waits for the owner's push. - -**Landed.** `scripts/check_pdf_shims_equiv.mjs` and `scripts/lib/pdf-shims-side.mjs`, one side -per process; the gate itself never imports pdf-lib (see Where the plan was wrong). The shims -are every module `render-book.mjs` imports from `book/lib/`, in its order, less the four the -side calls as `render-book.mjs` does (`measure-pass`, `postprocesser`, `outline`, -`parallel-deflate`), so a new shim is checked without an edit. The document is written by the -gate: a classic section with a generation-1 object, then an incremental update whose object -stream redefines a page and holds a dictionary and an array of every lexical form the parse -shims branch on, with a cross-reference stream. The side sizes the onebuf shims from -`measure()`, loads, calls `setMetadata` (then pins `/ModDate`, which it stamps with the time) -and `setOutline` with a closed entry, draws text on a page, inserts and removes a page, edits -two early dictionaries and an array, and saves: the shimmed side through `parallelSave` with -500 objects to a stream, the stock side with `save()`'s own steps and -`PDFStreamWriter.forContext(ctx, Infinity, true, 500)`. - -The comparison reads each file as pdf-lib writes it: every object by number, where it is -(top level, generation, or object stream and entry) and its bytes, streams inflated, each -`/Length` and the cross-reference stream's `/W` masked. Each file's cross-reference entries -and `startxref` must locate their objects; a problem in stock's output is the harness failing -(exit 2), in the shimmed output a finding. The reach check is V8 precise coverage in the -shimmed side, taken once after the imports to reset the counts: a shim with no function run -fails the gate, and `parallel-deflate.mjs` fails if `parallelSave` deflated no object stream. -On a difference the shimmed side reruns with each shim alone and each left out, four at a -time, and the report names the shims that differ alone and those whose removal makes the -output match. `--help` and an unknown option are `check_cli`'s cases 255 and 256. - -Building it found three defects, fixed first at the owner's choice: C65c (the gate named -`fast-parse-number.mjs` both ways), C65d (the shimmed side failed at `insertPage`) and C65e. -Now: `stock pdf-lib and 12 shims with parallelSave write the same 22 objects; every shim ran`, -0.49-0.54 s. Faults through the kit's `c43-fault.mjs` in `NODE_OPTIONS`, so the sides load it: -`fast-dict-onebuf.mjs`'s `sizeInBytes` one byte long gives an output the reader cannot place -(`byte 2054 is neither an object nor the trailer`), named both ways; `fast-number-to-string.mjs` -writing `0.50` for `0.5`, which parses to the same values, differs in six objects, the drawn -content stream among them, named both ways; `fast-pdfnumber-pool.mjs` never installed, and -`parallelSave`'s thread-pool branch switched off, are each named by the reach check; a stock -side that throws exits 2. No temporary folder is left behind. Registered in `test.bat` before -`check_axe_patch_equiv`, the composite action, Tools.md (the list, its count, the POSIX block, -the counts after it and a section), Building.md's POSIX block and WIP.md (the table, the -`test.bat` bullet, the count); Fixes-PDFLib.md points to it. `check_gate_lists`: `test.bat -(14)`; `check_ci_workflows`: `the wrappers' 17 gates`; lint `Checked 168 files`; regex safety -`519 literals + 28 constructed in 129 files -- 479 safe, 68 polynomial`. CI waits for the -owner's push. +**Carried forward.** `scripts/check_pdf_shims_equiv.mjs` is a step of the composite action, +and CI first runs it on Linux on the owner's next push. It must print `stock pdf-lib and 12 +shims with parallelSave write the same 25 objects for a loaded document and the same 11 for a +created one; the 72 members the shims patch are as listed, and all ran but the 2 marked`. ### C67 — `book: one module for pdf-lib's internal requires` -**A9-5 (R3).** The `createRequire` and `require('pdf-lib/cjs/...').default` block is repeated -in nine production shims. - -**Change.** `book/lib/pdf-lib-internals.mjs`, which the nine import. - -**Verify.** `check_pdf_shims_equiv.mjs`; `book.bat` renders with the same page count and -outline. - -**Landed.** `book/lib/pdf-lib-internals.mjs` requires the 44 pdf-lib objects the nine shims -required themselves, each by the same CommonJS path, and exports them under the names the -shims already used, so each require block became one import and no shim's body changed. Two -reads move: `Numeric.js` is required once for `IsDigit` and `IsNumeric`, and -`copyStringIntoBuffer`, `last` and `toUint8Array` are read from the utilities barrel when the -new module loads rather than when `fast-sync-load.mjs` does. Neither changes a value: no shim -assigns those three (the two utility shims assign only `numberToString` and `sizeInBytes`), and -`render-book.mjs` and the gate's side both import `pdf-lib`, which loads every module, before -any shim. A scratch comparison of each export with `require('pdf-lib')` found 35 of the 44 to -be the objects pdf-lib's index exports and 9 not in it (`BaseParser`, the five syntax exports -and the three utility modules); the module's header says so. `fast-parse-object.mjs`'s header -said `PDFObjectParser` is not re-exported from pdf-lib's index, which is false, and now says -only where it comes from; `fast-parse-number.mjs` and Fixes-PDFLib.md say `BaseParser` is not, -which is true, and now name the module, which Fixes-PDFLib.md's introduction describes in a -new paragraph. `perf/`'s instruments keep their own requires, as records of the measurements. - -`check_pdf_shims_equiv`: unchanged. With the module exporting a subclass of `PDFObjectParser` -in its place (a fault through the kit's `c43-fault.mjs`), the gate names `fast-parse-object` -and `fast-parse-name`, whose patches land on the copy, and not `fast-dict-onebuf` or -`fast-array-onebuf`, whose `parseDict` and `parseArray` land there too, because other -functions in both still ran: C67a. With `BaseParser` so, it names `fast-parse-number`. The -book, rendered from one `_site-pdf` through HEAD's `book/` and the working one: 2,299 pages, -2,466 outline entries and 29,130,183 bytes each, differing only in `/CreationDate` and -`/ModDate`; 130 s and 136 s, `process: 1.3s` and `1.4s`. `compare_trees`: Fixes-PDFLib online -and offline, the search data and `book.html`. Lint `Checked 169 files`. +Landed. ### C67a — `book: check_pdf_shims_equiv checks each member the shims patch` -**Found while landing C67** (see Found while implementing). The gate's reach check fails a -shim none of whose functions ran, so a shim with several patches passes while one of them -never runs, or lands on a copy of a class. With `pdf-lib-internals.mjs` exporting a subclass of -`PDFObjectParser`, the gate named the two shims whose only patch is on it, and not -`fast-dict-onebuf.mjs` or `fast-array-onebuf.mjs`, whose `parseDict` and `parseArray` landed on -the copy too. - -**Change.** The shimmed side lists the members of pdf-lib the shims put a function into, and -whether each function ran. The gate checks them against a list of every member each shim -patches, in which a member the document does not reach is marked, with the reason. - -**Verify.** Passes; C67's fault fails it, naming the two members; so do a patched member missing -from the list, an unmarked member that stops running and a marked one that runs. - -**Landed.** In coverage mode the side snapshots the own properties of every pdf-lib module's -exports in `require.cache`, of each function they export and of its prototype, before and -after the shims load; a member that holds a new function, as value, getter or setter, is -patched. The inspector gives each function's `[[FunctionLocation]]` and -`Debugger.getScriptSource` its script, and the function's coverage entry is the one containing -that position whose source is the function's own text. A function never called can have no -entry at all, having never been compiled (measured: `PDFContext.prototype.delete` has none), so -no entry counts as not run. Only functions a shim defines count. The side prints `{ streamCount, -reached, patched }`, `patched` one `{ shim, member, ran }` per member, since -`numberToString` and `sizeInBytes` are each installed in three modules. - -`PATCHES` in the gate lists 74 members of 12 shims. A measurement is behind each of the 22 -marks: 16 `the load, the change and the save do not call it`, both `computeBufferSize` -`parallelSave does not call it`, the two factories `PDFDocument.create` alone calls (read at -`api/PDFDocument.js:146-148`), `PDFCatalog.fromMapWithContext` (called only from the stock -`parseDict` that `fast-dict-onebuf` replaces) and `PDFPageTree.fromMapWithContext` (under the -shims, called only from that shim's `PDFPageTree.withContext`). The gate reports a listed -member not patched, a patched member not listed, an unmarked member that never ran, and a -marked member that ran; a shim that ran nothing is still reported whole, and its members are -left out of the four lists. Now: `stock pdf-lib and 12 shims with parallelSave write the same -22 objects; the 74 members the shims patch are as listed, and all ran but the 22 marked`, 0.49 -s. Faults through the kit's `c67a-faults.mjs`, each exit 1: the `PDFObjectParser` copy names -`fast-parse-object` and `fast-parse-name` whole, `parseDict` and `parseArray` as not patched, -and the two `fromMapWithContext` marks as run, since stock `parseDict` runs again and calls -them; a `BaseParser` copy names `fast-parse-number` whole; the side without its -`misc.delete` names `PDFDict.prototype.delete` as never run; the side calling `misc.has` names -that mark as run; a method a shim adds to `PDFNumber.prototype` is named as not listed. The -header, `--help`, Tools.md's list line, section and exits, Fixes-PDFLib.md and WIP.md's table -say what the gate now checks; `check_cli`'s help case pins only the first line. Lint `Checked -169 files`; regex safety `521 literals + 28 constructed in 129 files -- 480 safe, 69 -polynomial`. `compare_trees`: Fixes-PDFLib and Tools online and offline, the search data and -`book.html`. CI waits for the owner's push. +**Carried forward.** `PATCHES` in `scripts/check_pdf_shims_equiv.mjs` lists every member of +pdf-lib that each shim patches (72 members of 12 shims after C67b), and a member the document +does not reach is marked with the reason (two are, the `context` setters). The shimmed side +finds the patched members by snapshotting the own properties of every pdf-lib module's +exports before and after the shims load, and prints `{ streamCount, reached, patched }`, +`patched` being one `{ shim, member, ran }` per member a shim puts a function into. The gate +reports a listed member not patched, a patched member not listed, an unmarked member that +never ran and a marked one that ran. ### C67b — `book: the shim gate reaches the members it marks` -**The owner's choice (2026-09-28)**, with C67a. A Sonnet agent measured the 22 marked members -against the real book: rendered with `NODE_V8_COVERAGE`, 2,299 pages, every one ran 0 times -(`PDFDict.prototype.get`, for comparison, 15,124), so the gate's document is not narrower than -the book. Twenty exist because the storage changed. The onebuf classes keep their entries in -one buffer (`_FastArray` holds only `this.d`, `fast-array-onebuf.mjs:147-148`), and a `PDFRef` -holds no `tag` (`fast-refs-class.mjs:74-86`), so every stock method that reads the old fields -had to be replaced, called or not. `PDFContext.prototype.delete` has a caller, -`fast-sync-load.mjs:92-95`, which removes an object 0 that a parsed file defines; Chromium -never writes one. The two `computeBufferSize` overrides are the exception. No storage change -forces them, the shim's comment calls them "patched for consistency" (`fast-sync-load.mjs:237-241`), -`08-pdf-lib.md` finds the writer-side wins "none reliably above noise" (`:1795-1822`), and -`ParallelStreamWriter`, which predates them, overrides the method on the book's only path -(`parallel-deflate.mjs:50-61`). The split renderer the owner recalled, `perf/probe-parallel.mjs`, -never loads the shims and never merges its parts; a renderer that copied pages between -documents would need two `PDFContext`s, which the onebuf shims refuse -(`fast-dict-onebuf.mjs:136-142`, `fast-array-onebuf.mjs:99-105`). - -**Change.** Delete the two `computeBufferSize` overrides from `fast-sync-load.mjs`, at the -owner's choice, with their two `PATCHES` entries. The side's change calls each marked member -a loaded document can reach, with each result written into the document so that the -comparison checks it: a dictionary's and an array's `clone` registered, their `toString` and a -reference's stored as strings, `values`, `entries`, `has`, `asMap`, `indexOf`, `asArray` and -`getObjectRef` reduced to numbers or references stored in a dictionary, and `set` on an array. -The fixture gains an object 0, which the parse removes through `PDFContext.prototype.delete`. -A second pair of sides, stock and shimmed, builds a document with `PDFDocument.create`, adds -pages and saves, since one process allows the onebuf shims one context; the gate compares the -pair as it compares the first, and it reaches the four page-tree and catalog factories. The -two `context` setters stay marked: each is empty by design (`fast-dict-onebuf.mjs:411`, -`fast-array-onebuf.mjs:289`), and nothing it does reaches the output. - -**Verify.** The gate passes with two marks left; each newly reached member, broken through -the kit's `c43-fault.mjs`, fails it by difference; C67a's five faults still fail. The book -renders identically but for its dates. - -**Landed.** `fast-sync-load.mjs` loses both `computeBufferSize` overrides, the `Size` name only -they used and eleven imports; `pdf-lib-internals.mjs` loses the eleven exports no shim imports -any more (the four `core/document` classes, `PDFInvalidObject`, `PDFNumber`, `PDFStream`, -`PDFCrossRefStream`, `PDFObjectStream`, `PDFStreamWriter` and `last`), keeping 33. The shim's -header, which said eight methods and listed nine, `render-book.mjs`'s summary and -Fixes-PDFLib.md now name the seven it replaces, and say the writers' own `computeBufferSize` -stay as pdf-lib has them. - -The side keeps its job's shape: a null `fixture` builds the document with `PDFDocument.create` -(both dates set to the fixed one, two pages added, text drawn on one, a third inserted at 0), -and sizes nothing, so the onebuf shims keep their initial capacities (2.4 million dictionary -slots, 800,000 array slots). Then it calls `PDFCatalog.fromMapWithContext` on a copy of the -catalog's map and registers the result (see Where the plan was wrong). The loaded document's -change ends with a registered `/Found` dictionary holding what `values`, `entries`, `asMap`, -`has` (one of them on a null value), `indexOf`, `asArray` and `getObjectRef` (one of them for a -generation-1 object) return, a null standing for an index or reference not found, and -`misc.toString()` as a hex string, which calls a dictionary's, an array's and a reference's -`toString`; then both clones are registered, and each clone and its original edited after the -copy, which also calls an array's `set`. The fixture's object 0 is removed by the parse on both -sides. The gate runs the four sides at once, compares each pair, runs the diagnosis for a -document that differs, and counts a member as run if it ran in either shimmed side. Only the -two `context` setters stay marked. - -Now: `stock pdf-lib and 12 shims with parallelSave write the same 25 objects for a loaded -document and the same 11 for a created one; the 72 members the shims patch are as listed, and -all ran but the 2 marked`, about 0.5 s. The kit's `c67b-faults.mjs` breaks each of the 18 -newly reached members, and each fault exits 1 by a difference in the document that reaches it, -with the diagnosis naming that member's shim alone; the `delete` fault keeps object 0, which -shifts every entry of the object stream. C67a's five faults still exit 1 (`c67a-faults.mjs`, -its `runs` case now setting a dictionary's `context`, since `has` is no longer marked). The -gate's header and help, Tools.md's section and WIP.md's table describe the created document. -The book, rendered from one `_site-pdf` through HEAD's `book/` and the working one: 2,299 pages -and 2,466 outline entries each, identical but for `/CreationDate` and `/ModDate`, whose object -stream deflates a byte longer (29,132,071 and 29,132,072 bytes); 85-103 s a render, `process: -1.1s`-`1.2s`. HEAD's first render, which ran beside `test.bat`, differed from its second in 34 -objects, all Chromium's structure-node ids shifted by one (`/ID (node00151028)` against -`node00151029`): two renders of one tree are not always byte-identical, so a render pair that -differs outside its dates needs a repeat render before the change is blamed. Lint `Checked 169 files`; regex safety unchanged. `compare_trees`: Fixes-PDFLib and -Tools online and offline, the search data and `book.html`. CI waits for the owner's push. +Landed. ### C68 — `book: the two onebuf shims share their range machinery` -**A9-3 (R2).** `_registerContext` and `_appendArray` are identical apart from names in -`fast-array-onebuf.mjs` and `fast-dict-onebuf.mjs`, while `pack`, `_cow`, `_makeFromRange` -and `_makeFromAppend` differ by real bit-packing and subclass dispatch. - -**Change.** `book/lib/onebuf-range.mjs`, a factory parametrised over the subclass dispatch and -the gap mask that the two genuinely need differently (V3's fourth note), not one body with -renamed variables. - -**Verify.** `check_pdf_shims_equiv.mjs`; the book's page count and outline unchanged; render -time within noise of before, since these shims exist for speed. - -**Landed.** `onebufRange({ name, capacity, startBits, gapBits, construct })` builds one buffer -per call, with its length and its singleton `PDFContext` inside the closure, so the two shims -share code and no state. The layout comes from `startBits` and `gapBits` (the dictionary 23 -and 2, the array 24 and 0): the gap mask is the bits between the two fields, zero for the -array, so one `cow` and one repack serve both. `construct(ProtoClass, d)` is each shim's -dispatch: the dictionary's picks among its four constructors as `_makeFromRange` did, the -array's ignores the class. Beyond the review's two identical helpers, `_appendEntries` and -`_appendFromTemp` were a third pair and the two sizers a fourth; those two loops, -`_appendArray` and the copies inside `_cow` and both `clone`s are now one -`append(source, from, count)`. Every append goes through the module (`append`, `view`, -`viewOf`, `push`, `pushPair`, `cut`, `insert`, `clone`); a shim reads its buffer directly and -overwrites slots inside a range, so the hot read paths (`get`, `copyBytesInto`, -`sizeInBytes`, the parsers' type scan) are unchanged. Every function installed on pdf-lib -stays in its shim: the side attributes a member to the file that defines the function, and -skips one defined anywhere else, so moving `set` or `push` into the module would have shown -as unpatched. Each shim keeps `main` / `arrayMain`, its sizer and its length getter as -exports. One message changed: a sizer called after parse now says `the buffer was sized after -parse started (<n> slots in use)`; nothing calls one late. A Sonnet agent compared every -changed function with HEAD's and found no other difference for any argument pdf-lib passes -(`remove` now differs only for an index that is not an integer). The array's header lost its -paragraph on why the singleton was duplicated, and both headers the history of the dropped -owned bit; `render-book.mjs`'s summary of the dictionary shim still described that bit (see -Found while implementing). Fixes-PDFLib.md names the module under fast-array-onebuf. - -The gate's side now gives the drawn page a new key before drawing on it (see Found while -implementing): the page's entries move to the end of the buffer while `autoNormalizeCTM` is -set, and the draw wraps the old content only if the flag moved with them. The kit's -`c68-faults.mjs` drops the gap bits in `cow`, which passed the gate before and fails it now by -difference, with the diagnosis naming `fast-dict-onebuf.mjs` alone; its faults on `cut`, -`insert`, `push` and `pushPair` fail it by difference too, and its `singleton` mode shows each -shim alone refusing a second `PDFDocument.create`, at HEAD and now, with the same message. -The counts are unchanged: the same 25 and 11 objects, and the same 72 members, all run but -the 2 marked. C67b's faults moved with the code (the two `clone` cases now cut the clone's -first entry, the two `fromMapWithContext` cases name `ranges.viewOf`), and all 18 still fail -by difference; C67a's five still exit 1. The gate's header and Tools.md's section describe the -new key. - -The book, three renders a side from one `_site-pdf`, alternating HEAD's `book/` and the working -one: 2,299 pages and 2,466 outline entries each; `process:` 1.3, 1.1 and 1.2 s at HEAD, 1.0, -1.2 and 1.0 s now; totals 95.6, 85.2 and 94.4 s at HEAD, 90.3, 99.6 and 88.7 s now. HEAD's -three files were 29,132,946 bytes each; the working tree's 29,132,892, 29,132,951 and -29,132,946, and that last pair is identical but for `/CreationDate` and `/ModDate`, so the -same code wrote the other two and their sizes are the render's own variation (the owner: -around a second is fast enough beside the other phases, so they were not taken apart). -`build.bat`, `check.bat` and `test.bat` clean; lint `Checked 170 files`; regex safety -unchanged. `compare_trees`: Fixes-PDFLib and Tools online and offline, the search data and -`book.html`. CI waits for the owner's push. +Landed. ### C69 — `book: each pdf-lib shim checks what it overwrites` -**A9-1 (R2).** The twelve production shims (thirteen before C65b) each guard against being installed twice and -never check what they replace, and `parallel-deflate.mjs:50`'s `PDFStreamWriter` subclass has -no guard at all. Only the exact pin protects them (recorded in `08-pdf-lib.md:1751-1757`), -and it catches an accidental `npm update`, not a deliberate upgrade or a mistaken edit. -Upstream is abandoned, and the `@cantoo` fork was evaluated and rejected -(`08-pdf-lib.md:5048-5061`). - -**Change.** At load, each shim asserts the shape of what it overwrites (the member exists, -its arity, and a fingerprint of its source where one is stable), modelled on `axe-scan.mjs`'s -`SOURCE_PATCHES` counts, and throws naming itself otherwise. Each header states the exit: the -shim goes when pdf-lib is replaced, or when a release changes what it patches. - -**Verify.** With a target altered in a scratch copy of pdf-lib, the named shim throws at load; -`check_pdf_shims_equiv.mjs` passes; `book.bat` renders. - -**Landed.** A new module, `book/lib/shim-targets.mjs`, exports `checkTargets(shimUrl, roots, -targets)`, `ABSENT` and `fingerprint(fn)` (the first 12 hex digits of the SHA-256 of -`Function.prototype.toString`); it installs nothing and imports nothing from pdf-lib. Each of -the twelve shims calls it inside its install guard, before it patches anything, and -`parallel-deflate.mjs` at module level, which is its guard. A table's keys are paths from the -pdf-lib objects the shim imports (`'PDFDict.prototype.get'`, `'topBarrel.numberToString'`, -`'PDFRef'` for a constructor), and each value is `[arity, fingerprint]` or `ABSENT`. A member -missing, of another arity or another source, or an `ABSENT` member present, makes the import -throw one error naming the shim (from its `import.meta.url`) and every member that differs, -with its fingerprint now. The tables hold 78 targets: the gate's 72 members with each getter -and setter pair as one `ABSENT` entry (64 functions and 4 absences), `PDFRef.prototype -.generationNumber` (absent; `fast-refs-class` adds it as a data property the gate does not -list), and, at the owner's choice, the constructor of each class a shim builds instances of -without calling it (`PDFRef`, `PDFArray`, and `PDFDict` with its three subclasses) and -`PDFDocument.prototype.save`, whose steps before serializing `parallelSave` repeats; with -`PDFStreamWriter`'s constructor and `computeBufferSize` that is 66 functions, 7 of them -constructors, and 5 absences. Each header says the shim checks at load and goes when pdf-lib is -replaced, or is re-derived or removed when a release changes what it patches; the onebuf, -refs and deflate headers name the constructors. Fixes-PDFLib.md describes the check, and -Builder.md's pdf-lib pin says what it adds to the pin. Fingerprints were taken from stock -pdf-lib 1.17.1 with no shim loaded (the kit's `c69-stock.mjs`). - -The kit's `c43-fault.mjs` does not reach pdf-lib's CommonJS files: `module.register`'s hooks -never see a file loaded by `require`, and a fault on `PDFNumber.js` left the fingerprint as it -was. The kit's new `c69-cjs-fault.mjs` registers `module.registerHooks`' synchronous `load`, -which does, so each case alters pdf-lib's source as it loads, the in-memory equivalent of the -entry's scratch copy, with nothing under `node_modules` edited. `c69-faults.mjs` runs 16 cases -through `c69-load.mjs` (the thirteen files in `render-book.mjs`'s order): a changed source for -one member of each shim and for each of `parallel-deflate`'s three, a constructor (`PDFRef`, -`PDFDict`, `PDFStreamWriter`), an arity (`parseRawInt` given a parameter), a member removed -(`PDFArray.prototype.asArray`) and an absent member added (`PDFPageLeaf.prototype -.normalized`). Each exits 1 from the named shim, listing exactly the members altered: three -for `numberToString` and three for `sizeInBytes`, whose barrels hold one function. The control -loads all thirteen. The gate's line is unchanged. Loading the thirteen files took 197 ms, and -248 ms at HEAD, one run each: pdf-lib's own load dominates, and the checks are within its -noise. - -The book, one render a side from one `_site-pdf` through `render-book.mjs` with `book.bat`'s -arguments: 2,299 pages and 2,466 outline entries each, 29,140,555 bytes each, and the two files -differ in 5 bytes, all in `/CreationDate` and `/ModDate` inside one object stream; `process:` -1.2 s at HEAD and 1.3 s now. `build.bat`, `check.bat` and `test.bat` clean; lint `Checked 171 -files`; regex safety unchanged. `compare_trees`: Builder and Fixes-PDFLib online and offline, -the search data and `book.html`. +**Carried forward.** `book/lib/shim-targets.mjs` exports `checkTargets(shimUrl, roots, +targets)`, `ABSENT` and `fingerprint(fn)`. Each of the twelve shims calls `checkTargets` in +its install guard, before it patches anything (`parallel-deflate.mjs` at module level), with a +table keyed by path from the pdf-lib objects it imports (`'PDFDict.prototype.get'`, `'PDFRef'` +for a constructor); each value is `[arity, fingerprint]` or `ABSENT`, and a member that is +missing, of another arity or source, or an `ABSENT` one that is present, makes the import +throw an error naming the shim and every member that differs. The tables cover the members +the gate's `PATCHES` lists (a getter and setter pair as one `ABSENT` entry), and also the +constructors and `PDFDocument.prototype.save` that a shim relies on without patching. Nothing +compares a shim's table with `PATCHES` (the open item under Found while implementing). *impexp (decision (b)): C70.* ### C70 — `scripts: check_impexp_parity.mjs, the two impexp editions compared` -**A10-6 (R2).** `impexp.mjs` and `impexp.py` each have 19 self-tests, with identical names in -the same order, run by nothing, and Tools.md promises that the two print the same output and -write byte-identical files. - -**Change.** A `test.bat` gate that runs both `--self-test` suites (the same names, all -passing) and runs the same commands through both editions on the repository's fixtures -(`indexer/sample.twinpack`, and a project under `test/example-projects/`), comparing printed -output and written files byte for byte. Decision (b)'s open question is settled here: whether -`test.bat` without Python fails, or reports the gate skipped, loudly. In CI a missing -interpreter fails the gate and never skips it. **The owner settled it on 2026-09-25:** -without Python, `test.bat` reports the gate skipped, loudly, and passes. The gate tells the -two cases apart by the `CI` variable GitHub sets, not by an argument, because -`check_ci_workflows` requires CI to pass each gate the wrapper's arguments unchanged. Registered in the composite action, Tools.md -and WIP.md. - -**Verify.** Passes; a copy of one edition with one output line changed fails it. CI waits for -the owner's push. - -**Landed.** `scripts/check_impexp_parity.mjs` runs both `--self-test` suites, which must exit 0 -with every line `[PASS]` and print the same once the line naming the temporary folder is -dropped (19 tests each), and then 21 commands through each edition, each edition in a scratch -folder of its own holding copies of `indexer/sample.twinpack` and -`test/example-projects/console`. The commands run in order, so each sees what the ones before -wrote: `--help`; export, refused (3), with `--overwrite`, and warned (6) over a file the -project lacks; import of the exported folder and of the console project, given a README with a -CRLF and a non-ASCII character and a code file in LF first, refused (3) and with -`--overwrite`; an export of that; the four printing commands, with `readme` missing (4) from -the sample; a missing project (4), a folder with no Settings (5), a damaged project (5), and -three command lines refused (2). After each, both editions must give the exit code the command -names, the same stdout and stderr, and the same files as bytes; a differing file is reported -once, at the command that made it. On Windows Python's text streams write CRLF, so there -printed output is compared with CRLF read as LF; on Linux as written. Python is `python3`, -`python`, then `py -3` on Windows, 3.6 or later. Without one it prints `SKIPPED` and a line -saying the editions were not compared, and exits 0, unless `CI` is `true`, when it exits 2. -The two editions run each command at the same time; about 4 s here, most of it Python starting -(~180 ms a process against Node's ~80 ms). Registered in `test.bat` after the shim gate, the -composite action, Tools.md's list, POSIX block and a section (the impexp section points to -it), Building.md's POSIX block, WIP.md's bullet, table and counts, and two `check_cli` cases. - -The kit's `c70-faults.mjs` runs seven cases: four alter `impexp.mjs` through `c43-fault.mjs` -in `NODE_OPTIONS` (a progress line, the refusal's exit code, the LF-to-CRLF extensions, a test -name), one alters what `impexp.py` prints through a `sitecustomize.py` on `PYTHONPATH` -(`c70-pyfault/`), and two run with only Node on `PATH`, with `CI` empty and `true`. Each exits -as it should (1, 1, 1, 1, 1, 0, 2), naming the command and the line or file that differs; the -extensions fault also fails the Node edition's own LF-to-CRLF test, and the exit-code fault its -exit-code test. `check_cli: 258 probes`; `check_ci_workflows: ... the wrappers' 18 gates`; -`check_gate_lists: check.bat (4) + test.bat (15)`; lint `Checked 172 files`; regex safety `524 -literals + 28 constructed in 130 files ... 483 safe, 69 polynomial, 0 undecided, 0 -exponential` (the gate's three literals, all safe). `build.bat`, `check.bat` and `test.bat` -clean; `test.bat` took 23 s, so WIP.md's "~9 s" was stale before this gate, and now says ~23 s, -with the regex-safety gate ~9 s of it. `compare_trees`: Building and Tools online and offline, -the search data and `book.html`. CI waits for the owner's push: a new step, `Verify the two -impexp editions agree (check_impexp_parity.mjs)`, printing the summary line with the runner's -Python version, is this gate's first run on Linux, with the runner's own `python3`: read it. +**Carried forward.** On the owner's next push CI runs the step `Verify the two impexp +editions agree (check_impexp_parity.mjs)` for the first time on Linux, with the runner's own +`python3`. Its summary line prints the Python version, and a missing interpreter fails it +there (`CI` set to `true` exits 2) instead of skipping. ## Phase 3: conventions users see From 7fcc36cec12f958b983f2b9181dbe3edc0a2e6c2 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober <kuba@mareimbrium.org> Date: Mon, 28 Sep 2026 23:57:41 +0200 Subject: [PATCH 12/21] builder, scripts, book, eval, wisdom: --help prints usage to stdout and exits 0 --- book/render-book.mjs | 20 ++- builder/PLAN-TOOLING-REVIEW.md | 51 +++++++ builder/command-line.mjs | 50 ++++++- builder/tbdocs.mjs | 10 +- docs/Documentation/Tools.md | 2 +- eval/build_corpus.mjs | 3 +- eval/nav_hops.mjs | 3 +- eval/run_case.mjs | 3 +- eval/search_quality.mjs | 3 +- eval/site_search.mjs | 3 +- eval/transcript.mjs | 24 ++-- scripts/addin_test.mjs | 25 +++- scripts/build_dot_metrics.mjs | 18 ++- scripts/build_package_api.mjs | 19 ++- scripts/census_attributes.mjs | 46 +++--- scripts/check_a11y.mjs | 17 ++- scripts/check_a11y_fingerprint.mjs | 2 +- scripts/check_axe_patch_equiv.mjs | 2 +- scripts/check_book_coverage.mjs | 16 +++ scripts/check_ci_workflows.mjs | 17 +++ scripts/check_cli.mjs | 187 ++++++++++++++++++------- scripts/check_code_regions.mjs | 15 +- scripts/check_dot_fit.mjs | 18 ++- scripts/check_examples.mjs | 44 +++--- scripts/check_gate_lists.mjs | 15 +- scripts/check_links.mjs | 10 +- scripts/check_links_diff.mjs | 2 + scripts/check_lint.mjs | 23 ++- scripts/check_page_baseline.mjs | 16 +++ scripts/check_publish_policy.mjs | 14 +- scripts/check_regex_safety.mjs | 13 +- scripts/check_symbol_index.mjs | 16 +++ scripts/check_tb_registry.mjs | 16 +++ scripts/check_tree_fresh.mjs | 2 +- scripts/check_twin_parsers.mjs | 16 +++ scripts/compare_trees.mjs | 6 +- scripts/convert_em_dash_separators.mjs | 17 ++- scripts/crawl_check.mjs | 17 ++- scripts/gen_attribute_probes.mjs | 18 ++- scripts/pick_a11y_sample.mjs | 3 +- scripts/survey_tooling.mjs | 23 +-- scripts/sweep_a11y.mjs | 4 +- scripts/tbbuild.mjs | 25 +++- scripts/tbrun.mjs | 32 ++++- wisdom/wisdom.mjs | 6 + 45 files changed, 706 insertions(+), 186 deletions(-) diff --git a/book/render-book.mjs b/book/render-book.mjs index 0550b9e8..57c6708f 100644 --- a/book/render-book.mjs +++ b/book/render-book.mjs @@ -32,7 +32,7 @@ import { dirname, resolve } from 'node:path'; import { writeFileSync, existsSync } from 'node:fs'; import puppeteer from 'puppeteer'; import { PDFDocument } from 'pdf-lib'; -import { parseCli, withUsageError } from '../lib/cli.mjs'; +import { parseCli, printHelpAndExit, withUsageError } from '../lib/cli.mjs'; // Side-effecting imports. Mutate pdf-lib's live module exports // before any pdf-lib operation -- order doesn't matter. See // perf/notes/08-pdf-lib.md. @@ -196,24 +196,40 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); // --- arg parsing -------------------------------------------------------- +// A missing input or output prints the first line alone. +const SYNOPSIS = 'usage: node render-book.mjs <input.html> -o <output.pdf> [--outline-tags ...] [-t ms] [--additional-script path]...'; +const USAGE = `${SYNOPSIS} [-h, --help] + +Renders an HTML book to a PDF with paged.js and headless Chromium. + + <input.html> the book to render + -o, --output <output.pdf> the PDF to write + --outline-tags <tags> headings to put in the PDF outline (default h1,h2,h3,h4) + -t, --timeout <ms> per-operation timeout in milliseconds; 0 disables (default 0) + --additional-script <path> a script to inject after paged.js; repeatable + -h, --help print this text and exit`; + const { values, positionals } = withUsageError(() => parseCli(process.argv.slice(2), { options: { output: { type: 'string', short: 'o' }, 'outline-tags': { type: 'string', default: 'h1,h2,h3,h4' }, timeout: { type: 'string', short: 't', default: '0' }, 'additional-script': { type: 'string', multiple: true }, + help: { type: 'boolean', short: 'h' }, }, positionals: { max: 1 }, unknown: 'error', acceptsValue: () => true, + stopAt: ['help'], }), { format: (err) => `unknown arg: ${err.arg}`, exitCode: 2 }); +if (values.help) printHelpAndExit(USAGE); const inputArg = positionals[0]; const outputArg = values.output; const outlineTagsArg = values.outlineTags; const timeoutMs = parseInt(values.timeout, 10); const additionalScripts = values.additionalScript; if (!inputArg || !outputArg) { - console.error('usage: node render-book.mjs <input.html> -o <output.pdf> [--outline-tags ...] [-t ms] [--additional-script path]...'); + console.error(SYNOPSIS); process.exit(2); } diff --git a/builder/PLAN-TOOLING-REVIEW.md b/builder/PLAN-TOOLING-REVIEW.md index 61e2bf32..4336dbc4 100644 --- a/builder/PLAN-TOOLING-REVIEW.md +++ b/builder/PLAN-TOOLING-REVIEW.md @@ -877,6 +877,44 @@ it. `gen_attribute_probes.mjs:1147-1158` takes a bare `--help` as its output fol **Verify.** `check_cli.mjs` gains a `--help` case for every tool, safe to run for all of them once this lands; no file or folder appears. +**Landed.** Every Node tool, 45 in all (the `tbdocs` builder, `render-book`, `wisdom`, the six +`eval/` tools and every command under `scripts/`, the seven that read no arguments included), +answers `--help` and `-h` by printing its usage to stdout and exiting 0 (the owner's four +choices, 2026-09-28: every tool; the parse stops at `--help`; `-h` everywhere; a short usage +text where there was none). Each declares `help` with `short: "h"` and `stopAt: ["help"]`, so +arguments before it are read as before and nothing after it is, and answers it straight after +the parse, before any number, project or install check. The seven argument-less gates parse +with `unknown: "ignore"` and no positionals, so every other argument is still ignored. +`builder/command-line.mjs` exports `USAGE` and returns `{ ...DEFAULTS, help: true }` when the +parse stops at help. 28 tools gained or rewrote a `USAGE` constant: the usage line, one sentence, +the options; no exit codes, which are C74's. `check_lint` and `render-book` keep their pinned +one-line error message as a `SYNOPSIS` that `USAGE` starts with; `tbbuild`, `tbrun`, +`crawl_check` and `survey_tooling` print the whole `USAGE` on their usage errors, which keep +their stream and exit code. `transcript`'s `-h` is help, not a file name. `wisdom` answers +`--help` as its command or after one. Unchanged: `impexp.mjs`, already so, and `check_links`, +whose raw-argument test already answered both; a bare invocation, or a missing project or +input, keeps its old answer everywhere. `check_regex_safety`'s internal `--shard` is not in +its usage. Two Found items folded in: `census_attributes` now prints a `USAGE` with +`--dump-sites` (its header points there), and `check_links`' help says a missing sitemap or +search file prints a warning. Tools.md's introduction says every Node tool answers `--help` +(`build_fonts.py` reads no arguments). + +`check_cli` gains `HELP_TOOLS`, which adds a `--help` and a `-h` case for every tool the +table does not already hold (61 cases), and a probe after every case whose arguments hold +`--help` or `-h` that its scratch folder is still empty (104); 24 existing cases changed their +expectation, among them `tbbuild x.twinproj --port 0 --help`, `check_examples --jobs 0 +--help`, `build_corpus -hq` (the parse stops at the `h` of the group) and `wisdom bogus +--help`, all now usage on stdout and exit 0; `compare_trees --bogus --help` still exits 2. +`check_cli: 423 probes, all pass` (258 before). With `gen_attribute_probes`' `help` option +removed through `c43-fault.mjs` in `NODE_OPTIONS`, it fails 4 probes, two of them the empty +folder (`--help`, `--help-2`, `--help-explore`, `probe-key.md` appeared, and the same for `-h`). +A Sonnet agent checked each new usage text against its code; the eight wrong claims and five +missing options it found were fixed. `compare_trees`: Tools online and offline, the search +data and `book.html`. Lint `Checked 172 files`; regex safety `528 literals + 30 constructed in +130 files ... 489 safe, 69 polynomial, 0 undecided, 0 exponential` (the new ones all safe); +`build.bat`, `check.bat` and `test.bat` clean. On the owner's next push CI prints +`check_cli: 423 probes, all pass` and the regex-safety line above. + ### C72 — `scripts, book, eval, wisdom: an unknown flag or a bad value exits 2` **L1-7, L1-5 (R2), A5-1's typo half, A10-1.** Eleven tools ignore an unknown flag, @@ -1291,6 +1329,11 @@ text, gains a Landed note, and the correction is listed here, as in the last rev `PDFCatalog.withContextAndPages` builds its catalog without it (`fast-dict-onebuf.mjs:455-461`), so the created side calls it directly, on a copy of the created catalog's map. See C67b's Landed note. +- **C71: `tbdocs` answers `--help` too.** The entry's subject leaves out `builder/`, whose + `tbdocs` refused `--help` and `-h` with exit 4. At the owner's choice (2026-09-28) every Node + tool answers them, `tbdocs` and the argument-less gates included, so it landed as `builder, + scripts, book, eval, wisdom: --help prints usage to stdout and exits 0`. See C71's Landed + note. ## Found while implementing @@ -1557,6 +1600,14 @@ Defects the review did not have, found by building something this plan asks for. members each shim patches and nothing compares that with the shim's own `checkTargets` table. The side already lists each shim's patched members, so each shim could export its table for the side to compare. Left for a commit of its own, at the owner's choice. +- **`census_attributes`' `--help` left out `--dump-sites`**, found while landing C49: it + printed a slice of the header comment, whose option list lacked the flag and whose first + printed line was empty. Folded into C71, at the owner's choice. Fixed in `builder, scripts, + book, eval, wisdom: --help prints usage to stdout and exits 0`. +- **`check_links`' help said `--check-sitemap` and `--check-search` were skipped silently** + when their file is absent; each prints a `warning:` line and skips the check. Folded into + C71, at the owner's choice. Fixed in `builder, scripts, book, eval, wisdom: --help prints + usage to stdout and exits 0`. ## Open questions diff --git a/builder/command-line.mjs b/builder/command-line.mjs index 490268a0..a8747937 100644 --- a/builder/command-line.mjs +++ b/builder/command-line.mjs @@ -6,7 +6,8 @@ // boolean given a value and a value flag given none (or one that starts with // a dash) are all refused. Flags are then applied in the order they were // given, because --no-check undoes the check flags before it and not the -// ones after. +// ones after. -h and --help end the parse where they stand: nothing after +// them is read, and the options returned say only `help`. import { CliError, numberOption, parseCli } from "../lib/cli.mjs"; @@ -32,8 +33,46 @@ export const OPTIONS = { serve: { type: "boolean" }, port: { type: "string" }, "stall-timeout": { type: "string" }, + help: { type: "boolean", short: "h" }, }; +// What -h and --help print. The flags are listed in the order of OPTIONS. +export const USAGE = `usage: node builder/tbdocs.mjs [options] + +Builds the documentation site into three trees: the online copy, a file:// +browsable copy and the source of the PDF book. Flags are read in the order +given, and a flag that takes a value takes it as the next argument or as +--flag=value. + + --src <path> source root (default docs) + --dest <path> online-tree destination (default <src>/_site, or + <src>/_serve with --serve); the offline tree is + <dest>-offline, the PDF tree <dest>-pdf + --baseurl <prefix> override _config.yml's baseurl + --url <origin> override _config.yml's url + --dry-run build without writing the trees; the check does not + run, and a baseline update still writes its file + --no-offline skip the offline tree + --no-pdf skip the PDF tree + --tolerate-missing-images downgrade a missing book image from an error to a warning + --fetch-assets download missing remote assets, even when $CI is set + --no-fetch-assets never download; a missing remote asset is an error + --profile-offline print per-substep timing for the offline tree + --check run the link and integrity check over the built HTML + --no-check turn off the check flags given before it + --check-audit-index implies --check; also diff the derived tree index + against the files written + --check-findings <path> implies --check; write the findings to a JSON file + --update-page-baseline record this build's page and static-file counts as + the new baseline + --update-symbol-baseline record this build's symbol-index URLs as the new baseline + --symbol-gaps <path> write the public symbols no page documents to a JSON file + --serve start the dev server: watch, rebuild, live-reload + --port <N> port for --serve (default 4000) + --stall-timeout <seconds> give up when no task completes for this long + (default 120; 0 disables) + -h, --help print this text and exit`; + // fetchAssets is left out: absent, the build downloads unless $CI is set. export const DEFAULTS = Object.freeze({ src: "docs", @@ -65,7 +104,7 @@ export const DEFAULTS = Object.freeze({ // argument as it was given, `-xy` and `--dry-run=1` whole. function parse(argv) { try { - return parseCli(argv, { options: OPTIONS }); + return parseCli(argv, { options: OPTIONS, stopAt: ["help"] }); } catch (err) { if (!(err instanceof CliError) || err.code === "missing-value") throw err; throw new CliError(err.code, `Unknown argument: ${err.arg}`, { arg: err.arg }); @@ -74,7 +113,12 @@ function parse(argv) { export function parseCommandLine(argv) { const args = { ...DEFAULTS }; - for (const t of parse(argv).tokens) { + const cli = parse(argv); + // -h and --help are answered before any value is read, so a bad --port + // before one does not stop it. `help` is present only then, which keeps the + // options of every other command line equal to DEFAULTS. + if (cli.stopped === "help") return { ...args, help: true }; + for (const t of cli.tokens) { if (t.kind !== "option") continue; switch (t.key) { case "src": args.src = t.value; break; diff --git a/builder/tbdocs.mjs b/builder/tbdocs.mjs index 701dac5e..44600a69 100644 --- a/builder/tbdocs.mjs +++ b/builder/tbdocs.mjs @@ -7,10 +7,11 @@ // [--check | --no-check] [--check-audit-index] // [--check-findings <path>] [--serve] [--port <N>] // [--update-page-baseline] [--update-symbol-baseline] -// [--symbol-gaps <path>] [--stall-timeout <seconds>] +// [--symbol-gaps <path>] [--stall-timeout <seconds>] [-h | --help] // // builder/command-line.mjs reads these, in the order given; a flag that -// takes a value also takes it as --flag=value. +// takes a value also takes it as --flag=value. -h and --help print the +// USAGE text there and exit 0 before anything is built. // // --check runs the link + integrity check over the HTML the build // already holds in worker memory, instead of writing ~270 MB out and @@ -41,10 +42,10 @@ import { fileURLToPath } from "node:url"; import yaml from "js-yaml"; import pc from "picocolors"; -import { withUsageError } from "../lib/cli.mjs"; +import { printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; -import { parseCommandLine } from "./command-line.mjs"; +import { parseCommandLine, USAGE } from "./command-line.mjs"; import { WorkerPool } from "./worker-pool.mjs"; import { Scheduler } from "./scheduler.mjs"; import { renderGantt } from "./gantt.mjs"; @@ -1515,6 +1516,7 @@ export async function runBuild(opts) { // before any task runs, with `commandLine` for the same exit. async function main() { const opts = withUsageError(() => parseCommandLine(process.argv.slice(2)), { exitCode: EXIT_COMMAND_LINE }); + if (opts.help) printHelpAndExit(USAGE); if (opts.serve) { const { runServe } = await import("./serve.mjs"); await runServe(opts); diff --git a/docs/Documentation/Tools.md b/docs/Documentation/Tools.md index 8d7befce..0a9085bc 100644 --- a/docs/Documentation/Tools.md +++ b/docs/Documentation/Tools.md @@ -8,7 +8,7 @@ permalink: /Documentation/Development/Tools # Tools and Scripts {: .no_toc } -One-line-per-tool reference for every executable in the documentation repository: the seven Windows batch wrappers at the repository root, the Node and Python scripts under `scripts/` (cross-platform except for [`tbbuild.mjs`](#tbbuild), which drives the twinBASIC IDE), the `tbdocs` orchestrator and its CLI flags, and the PDF render driver. If you are looking for the day-to-day workflow rather than a cheat sheet, the [Building and Deployment](Building) page is the gentler read; if you are modifying the build pipeline itself, the [tbdocs Internals](Builder) page goes one level deeper. +One-line-per-tool reference for every executable in the documentation repository: the seven Windows batch wrappers at the repository root, the Node and Python scripts under `scripts/` (cross-platform except for [`tbbuild.mjs`](#tbbuild), which drives the twinBASIC IDE), the `tbdocs` orchestrator and its CLI flags, and the PDF render driver. If you are looking for the day-to-day workflow rather than a cheat sheet, the [Building and Deployment](Building) page is the gentler read; if you are modifying the build pipeline itself, the [tbdocs Internals](Builder) page goes one level deeper. Every Node tool answers `--help` or `-h` by printing its usage to standard output and exiting 0, without doing any of its work. * TOC goes here {:toc} diff --git a/eval/build_corpus.mjs b/eval/build_corpus.mjs index 5223ece3..3d0e581b 100644 --- a/eval/build_corpus.mjs +++ b/eval/build_corpus.mjs @@ -105,6 +105,7 @@ function parseArgs(argv) { positionals: 0, unknown: "error", acceptsValue: () => true, + stopAt: ["help"], }), { format: (err) => `unknown argument: ${err.arg}`, exitCode: 1 }); return { src: "src" in values ? path.resolve(values.src) : REPO_ROOT, @@ -206,7 +207,7 @@ function report(dest, counts) { const opts = parseArgs(process.argv.slice(2)); if (opts.help || !opts.dest) { printHelpAndExit( - "Usage: node eval/build_corpus.mjs --dest <path> [--src <path>] [--quiet]\n\n" + + "Usage: node eval/build_corpus.mjs --dest <path> [--src <path>] [--quiet] [-h, --help]\n\n" + "Mirrors the repository with every non-prose file replaced by an unreadable\n" + "stub, so a documentation evaluation cannot silently read the implementation.\n" + "See eval/README.md.", diff --git a/eval/nav_hops.mjs b/eval/nav_hops.mjs index 2566534f..c8489dce 100644 --- a/eval/nav_hops.mjs +++ b/eval/nav_hops.mjs @@ -40,7 +40,7 @@ import { REPO_ROOT } from "../lib/repo-paths.mjs"; const SITE_HOST = /^https?:\/\/docs\.twinbasic\.com/i; const USAGE = - "Usage: node eval/nav_hops.mjs [--from <page>] [--src <root>] <url-regex> [...]\n\n" + + "Usage: node eval/nav_hops.mjs [--from <page>] [--src <root>] [-h, --help] <url-regex> [...]\n\n" + "Shortest path by links from the start page (default docs/index.md) to the first page\n" + "whose permalink matches each regex. See eval/README.md."; @@ -54,6 +54,7 @@ function parseArgs(argv) { positionals: { max: Infinity }, unknown: "positional", acceptsValue: () => true, + stopAt: ["help"], }); return { from: values.from, diff --git a/eval/run_case.mjs b/eval/run_case.mjs index 8ef63d8a..6eb32107 100644 --- a/eval/run_case.mjs +++ b/eval/run_case.mjs @@ -94,6 +94,7 @@ function parseArgs(argv) { positionals: 0, unknown: "error", acceptsValue: () => true, + stopAt: ["help"], }), { format: (err) => `unknown argument: ${err.arg}`, exitCode: 2 }); const o = { corpus: "corpus" in values ? path.resolve(values.corpus) : undefined, @@ -252,7 +253,7 @@ function runClaude(o, prompt, cwd, binDir) { const USAGE = "Usage: node eval/run_case.mjs --corpus <dir> --site <snapshot> --protocol <repo|site>\n" + " --goal <file> --out <prefix> [--claude <exe>] [--model <m>]\n" + - " [--timeout <min>] [--prompt-only]\n" + + " [--timeout <min>] [--prompt-only] [-h, --help]\n" + " node eval/run_case.mjs --smoke --corpus <dir> --site <snapshot> --out <prefix>\n\n" + "Runs one use-case evaluator as an isolated Claude Code process and audits its\n" + "session. See eval/README.md."; diff --git a/eval/search_quality.mjs b/eval/search_quality.mjs index 3383e4c2..e467a908 100644 --- a/eval/search_quality.mjs +++ b/eval/search_quality.mjs @@ -146,6 +146,7 @@ function parseArgs(argv) { positionals: 0, unknown: "error", acceptsValue: () => true, + stopAt: ["help"], }), { format: (err) => `unrecognised argument: ${err.arg}`, exitCode: 1 }); return { site: "site" in values ? path.resolve(values.site) : path.join(REPO_ROOT, "docs/_site"), @@ -696,7 +697,7 @@ function main() { if (opts.help) { printHelpAndExit( "Usage: node eval/search_quality.mjs [--site docs/_site] [--save file] " + - "[--compare file] [--worst N] [--sample N] [--failures N]\n\nSee the header comment in this file." + "[--compare file] [--worst N] [--sample N] [--failures N] [-h, --help]\n\nSee the header comment in this file." ); } diff --git a/eval/site_search.mjs b/eval/site_search.mjs index c68a2123..f5a97adc 100644 --- a/eval/site_search.mjs +++ b/eval/site_search.mjs @@ -41,6 +41,7 @@ function parseArgs(argv) { positionals: { max: Infinity }, unknown: "positional", acceptsValue: () => true, + stopAt: ["help"], }); return { site: "site" in values ? path.resolve(values.site) : path.join(REPO_ROOT, "docs/_site"), @@ -528,7 +529,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) const opts = parseArgs(process.argv.slice(2)); if (opts.help || (!opts.composition && !opts.terms.length)) { printHelpAndExit( - 'Usage: node eval/site_search.mjs "<query>" [--n <count>] [--site <path>]\n' + + 'Usage: node eval/site_search.mjs "<query>" [--n <count>] [--site <path>] [-h, --help]\n' + " node eval/site_search.mjs --composition\n\n" + "Queries the built site's real lunr index with the real query logic.\n" + "See eval/README.md.", diff --git a/eval/transcript.mjs b/eval/transcript.mjs index 43c4e09e..f8576201 100644 --- a/eval/transcript.mjs +++ b/eval/transcript.mjs @@ -188,28 +188,28 @@ export function printDigest(s, { calls = false, report = false } = {}) { return a; } +const USAGE = + "Usage: node eval/transcript.mjs <case.jsonl> [--calls] [--report] [-h, --help]\n\n" + + "Summarises an evaluator's session and audits the order of its channels.\n" + + "See eval/README.md."; + function main(argv) { const { values, positionals } = parseCli(argv, { options: { calls: { type: "boolean", default: false }, report: { type: "boolean", default: false }, - // No short "h": a lone -h is taken as the file, so it prints the usage - // and exits 0, where --help alone exits 1. C71 makes both exit 0. - help: { type: "boolean" }, + // -h and --help print the usage and exit 0. An unknown flag is a + // positional, and the file is the first positional that does not start + // with --; with none, the usage is printed and the exit is 1. + help: { type: "boolean", short: "h" }, }, positionals: { max: Infinity }, unknown: "positional", + stopAt: ["help"], }); + if (values.help) printHelpAndExit(USAGE); const file = positionals.find((a) => !a.startsWith("--")); - const help = values.help || positionals.includes("-h"); - if (!file || help) { - printHelpAndExit( - "Usage: node eval/transcript.mjs <case.jsonl> [--calls] [--report]\n\n" + - "Summarises an evaluator's session and audits the order of its channels.\n" + - "See eval/README.md.", - { exitCode: file ? 0 : 1 }, - ); - } + if (!file) printHelpAndExit(USAGE, { exitCode: 1 }); printDigest(summarize(readTranscript(file)), { calls: values.calls, report: values.report }); } diff --git a/scripts/addin_test.mjs b/scripts/addin_test.mjs index b5afa44b..d124f7b1 100644 --- a/scripts/addin_test.mjs +++ b/scripts/addin_test.mjs @@ -53,7 +53,7 @@ import { existsSync, mkdirSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { parseCli } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; import { removeTree } from "./lib/tb-ide-copy.mjs"; import { wantShow } from "./lib/tb-ide.mjs"; import { buildNumber, findIde } from "./lib/tb-install.mjs"; @@ -64,6 +64,20 @@ import { REPO_ROOT } from "../lib/repo-paths.mjs"; const SUITE = path.join(REPO_ROOT, "test", "addin"); +const USAGE = `usage: node scripts/addin_test.mjs [--only REGEX] [--port N] [--jobs N] [--timeout S] [--ide <twinBASIC.exe>] [--show|--hide] [-h, --help] + +Runs the IDE add-in scenarios: every lane in test/addin/lanes.mjs, each in a +process of its own with its own IDE copy, DevTools port and work folder. + + --only <regex> only the lanes whose name matches + --port <n> base DevTools port (default 9560); the lanes get n, n+1, ... + --jobs <n> lanes at once (default 2) + --timeout <secs> a lane still running after this long is ended (default 600) + --ide <path> the twinBASIC.exe to copy (default: $TB_IDE, else the + newest twinBASIC_IDE_BETA_* on the Desktop) + --show, --hide as tbbuild's + -h, --help print this text and exit`; + const { values } = parseCli(process.argv.slice(2), { options: { only: { type: "string" }, @@ -73,18 +87,15 @@ const { values } = parseCli(process.argv.slice(2), { ide: { type: "string" }, show: { type: "boolean", default: false }, hide: { type: "boolean", default: false }, - help: { type: "boolean", default: false }, + help: { type: "boolean", short: "h", default: false }, }, unknown: "ignore", positionals: 0, acceptsValue: () => true, + stopAt: ["help"], }); +if (values.help) printHelpAndExit(USAGE); const die = (code, msg) => { console.error(msg); process.exit(code); }; - -if (values.help) { - die(2, "usage: node scripts/addin_test.mjs [--only REGEX] [--port N] [--jobs N] " + - "[--timeout S] [--ide <twinBASIC.exe>] [--show|--hide]"); -} const only = values.only ? new RegExp(values.only) : null; const basePort = Number(values.port || 9560); const jobs = Math.max(1, Number(values.jobs || 2)); diff --git a/scripts/build_dot_metrics.mjs b/scripts/build_dot_metrics.mjs index b7b1ea13..da1ad5df 100644 --- a/scripts/build_dot_metrics.mjs +++ b/scripts/build_dot_metrics.mjs @@ -36,12 +36,20 @@ import path from "node:path"; import { withBrowser } from "./lib/browser.mjs"; import { exitOnCrash } from "./lib/gate-probes.mjs"; import { openInterPage } from "./lib/inter-page.mjs"; -import { parseCli } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; // A crash exits 2, where 1 is --check finding the table stale. exitOnCrash(); +const USAGE = `usage: node scripts/build_dot_metrics.mjs [--check] [-h, --help] + +Regenerates builder/inter-metrics.json, the Inter width table that Graphviz is +given, by measuring the font in a browser. + + --check fail if the table is stale, instead of writing it + -h, --help print this text and exit`; + const OUT = path.join(REPO_ROOT, "builder", "inter-metrics.json"); // Graphviz stores widths as `short`, in the family's own em units. The Times @@ -57,7 +65,13 @@ const VARIANTS = [ { key: "boldItalic", weight: 700, style: "italic" }, ]; -const check = parseCli(process.argv.slice(2), { options: { check: { type: "boolean" } }, unknown: "ignore" }).values.check === true; +const cli = parseCli(process.argv.slice(2), { + options: { check: { type: "boolean" }, help: { type: "boolean", short: "h" } }, + unknown: "ignore", + stopAt: ["help"], +}); +if (cli.values.help) printHelpAndExit(USAGE); +const check = cli.values.check === true; const table = await withBrowser(async (browser) => { const page = await openInterPage(browser, "dot-metrics", { css: "body{margin:0}" }); diff --git a/scripts/build_package_api.mjs b/scripts/build_package_api.mjs index 0b0fec43..3c6b3c90 100644 --- a/scripts/build_package_api.mjs +++ b/scripts/build_package_api.mjs @@ -46,11 +46,25 @@ import path from "node:path"; import { buildNumber, findIde } from "./lib/tb-install.mjs"; import { defaultCache, exportPackages, packageName } from "./lib/tb-packages.mjs"; import { apiSnapshot, parsePackage } from "./lib/twin-api.mjs"; -import { parseCli, withUsageError } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; const OUT = path.join(REPO_ROOT, "builder", "package-api.json"); +const USAGE = `usage: node scripts/build_package_api.mjs [options] + +Records the public API of the packages a twinBASIC install ships, as the package +half of the documentation's symbol index, in builder/package-api.json. + + --check fail if the file is stale, instead of writing it + --ide <path> the install root, or its twinBASIC.exe (default: $TB_IDE, + else the newest Desktop\\twinBASIC_IDE_BETA_<n>) + --src <dir> read an existing export of the packages instead + --cache <dir> where exports are kept (default %TEMP%\\tb-census\\beta-<n>) + --refresh export again even if the cache has this build + --out <file> write somewhere other than builder/package-api.json + -h, --help print this text and exit`; + const { values } = withUsageError(() => parseCli(process.argv.slice(2), { options: { @@ -60,10 +74,13 @@ const { values } = withUsageError(() => out: { type: "string" }, refresh: { type: "boolean", default: false }, check: { type: "boolean", default: false }, + help: { type: "boolean", short: "h", default: false }, }, unknown: "ignore", positionals: 0, + stopAt: ["help"], })); +if (values.help) printHelpAndExit(USAGE); const die = (code, msg) => { console.error(msg); process.exit(code); }; function sources() { diff --git a/scripts/census_attributes.mjs b/scripts/census_attributes.mjs index 568ec5b1..a9667263 100644 --- a/scripts/census_attributes.mjs +++ b/scripts/census_attributes.mjs @@ -3,18 +3,7 @@ // // node scripts/census_attributes.mjs [options] // -// --ide <path> twinBASIC install root (default: $TB_IDE, else the -// newest %USERPROFILE%/Desktop/twinBASIC_IDE_BETA_*) -// --src <dir> census an already-exported tree and do not export -// --cache <dir> where exports are kept (default: %TEMP%/tb-census) -// --refresh re-export even if the cache already has this build -// --samples also census projects/ and addins/, not just packages/ -// --attr <name> restrict the report to one attribute -// --json emit JSON instead of markdown -// --out <file> write the report to a file instead of stdout -// --quiet suppress progress on stderr -// -// Exit codes: 0 report produced, 2 the harness failed. +// The options, and the exit codes, are in USAGE below, which --help prints. // // ---------------------------------------------------------------- why this // @@ -74,12 +63,11 @@ // how the wrong answer gets published with a number beside it. import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { parseAttributes } from "./lib/attributes-doc.mjs"; import { findIde } from "./lib/tb-install.mjs"; import { defaultCache, exportPackages, packageName } from "./lib/tb-packages.mjs"; import { MODIFIERS, declarationKind, decomment } from "./lib/twin-declarations.mjs"; -import { parseCli, withUsageError } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { DOCS_DIR } from "../lib/repo-paths.mjs"; const ATTR_DOC = path.join(DOCS_DIR, "Reference", "Attributes.md"); @@ -97,19 +85,37 @@ const { values } = withUsageError(() => samples: { type: "boolean", default: false }, json: { type: "boolean", default: false }, quiet: { type: "boolean", default: false }, - help: { type: "boolean", default: false }, + help: { type: "boolean", short: "h", default: false }, }, unknown: "ignore", positionals: 0, + stopAt: ["help"], })); const die = (code, msg) => { console.error(msg); process.exit(code); }; const log = (...a) => { if (!values.quiet) console.error(...a); }; -if (values.help) { - console.log(readFileSync(fileURLToPath(import.meta.url), "utf8") - .split("\n").filter((l) => l.startsWith("//")).slice(1, 18).map((l) => l.slice(3)).join("\n")); - process.exit(0); -} +const USAGE = `usage: node scripts/census_attributes.mjs [options] + +Counts every attribute used by the twinBASIC packages an IDE install ships, by +declaration keyword and by enclosing construct. + + --ide <path> twinBASIC install root (default: $TB_IDE, else the + newest %USERPROFILE%/Desktop/twinBASIC_IDE_BETA_*) + --src <dir> census an already-exported tree and do not export + --cache <dir> where exports are kept (default: %TEMP%/tb-census/beta-<n>) + --refresh re-export even if the cache already has this build + --samples also census projects/ and addins/, not just packages/ + --attr <name> restrict the report to one attribute + --json emit JSON instead of markdown + --out <file> write the report to a file instead of stdout + --dump-sites <file> write every raw site, or with --attr those of that + attribute, to a JSON file, to find the file behind a row + --quiet suppress progress on stderr + -h, --help print this text and exit + +Exit codes: 0 report produced, 2 the harness failed.`; + +if (values.help) printHelpAndExit(USAGE); // ------------------------------------------------------------- the install // Found as every harness tool finds it, by scripts/lib/tb-install.mjs's diff --git a/scripts/check_a11y.mjs b/scripts/check_a11y.mjs index 415eebe1..1472181a 100644 --- a/scripts/check_a11y.mjs +++ b/scripts/check_a11y.mjs @@ -60,7 +60,19 @@ import { runMatrix, } from "./lib/axe-scan.mjs"; import { withBrowser } from "./lib/browser.mjs"; -import { parseCli, withUsageError } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; + +const USAGE = `usage: node scripts/check_a11y.mjs [--root-dir DIR] [--theme light|dark|both] [--viewport desktop|mobile|both] [--stock-axe] [--minified] [-h, --help] + +Scans the sample pages of the built offline site with axe-core against WCAG +2.0, 2.1 and 2.2 at Level A and AA. Needs build.bat to have produced the site. + + --root-dir DIR the tree to scan (default docs/_site-offline) + --theme T light, dark or both (default both) + --viewport V desktop, mobile or both (default both) + --stock-axe run stock axe, without the source patches + --minified with --stock-axe, run the minified axe build + -h, --help print this text and exit`; const { values } = withUsageError( () => @@ -71,11 +83,14 @@ const { values } = withUsageError( viewport: { type: "string", default: "both" }, "stock-axe": { type: "boolean", default: false }, minified: { type: "boolean", default: false }, + help: { type: "boolean", short: "h" }, }, acceptsValue: Boolean, + stopAt: ["help"], }), { format: (err) => `unknown arg: ${err.arg}` }, ); +if (values.help) printHelpAndExit(USAGE); let rootDir = values.rootDir; let themeArg = values.theme; let viewportArg = values.viewport; diff --git a/scripts/check_a11y_fingerprint.mjs b/scripts/check_a11y_fingerprint.mjs index 2df46913..b226ad1c 100644 --- a/scripts/check_a11y_fingerprint.mjs +++ b/scripts/check_a11y_fingerprint.mjs @@ -119,7 +119,7 @@ if (cli.stopped === "help") { printHelpAndExit( "usage: node scripts/check_a11y_fingerprint.mjs [--baseline SCHEME] " + "[--candidate SCHEME] [--root-dir DIR] [--theme T] [--viewport V] " + - "[--pages P,P] [--json FILE] [--unminified] [--list]" + "[--pages P,P] [--json FILE] [--unminified] [--patches NAME,NAME] [--list] [-h, --help]" ); } diff --git a/scripts/check_axe_patch_equiv.mjs b/scripts/check_axe_patch_equiv.mjs index 0ef00354..3af15135 100644 --- a/scripts/check_axe_patch_equiv.mjs +++ b/scripts/check_axe_patch_equiv.mjs @@ -50,7 +50,7 @@ const cli = withUsageError( { format: (err) => `unknown arg: ${err.arg}` }, ); if (cli.stopped === "help") { - printHelpAndExit("usage: node scripts/check_axe_patch_equiv.mjs [--patch NAME]"); + printHelpAndExit("usage: node scripts/check_axe_patch_equiv.mjs [--patch NAME] [-h, --help]"); } let patchName = cli.values.patch; diff --git a/scripts/check_book_coverage.mjs b/scripts/check_book_coverage.mjs index 13f3833b..46390bef 100644 --- a/scripts/check_book_coverage.mjs +++ b/scripts/check_book_coverage.mjs @@ -20,10 +20,26 @@ // node scripts/check_book_coverage.mjs import { resolveBookChapters, bookCoverage, formatBookCoverage } from "../builder/book.mjs"; +import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; import { createProbes, exitOnCrash } from "./lib/gate-probes.mjs"; exitOnCrash(); +const USAGE = `usage: node scripts/check_book_coverage.mjs [-h, --help] + +Checks that each of the book-coverage warnings of builder/book.mjs still fires, +against pages and a manifest built in memory. + + -h, --help print this text and exit`; + +// Every other argument is ignored. +if (parseCli(process.argv.slice(2), { + options: { help: { type: "boolean", short: "h" } }, + unknown: "ignore", + positionals: { min: 0, max: 0 }, + stopAt: ["help"], +}).values.help) printHelpAndExit(USAGE); + const page = (srcRel, permalink, title, frontmatter = {}) => ({ srcRel, permalink, navPath: title, frontmatter: { title, permalink, ...frontmatter }, }); diff --git a/scripts/check_ci_workflows.mjs b/scripts/check_ci_workflows.mjs index c342e8b2..082d5543 100644 --- a/scripts/check_ci_workflows.mjs +++ b/scripts/check_ci_workflows.mjs @@ -31,9 +31,26 @@ import path from "node:path"; import yaml from "js-yaml"; import { exitOnCrash } from "./lib/gate-probes.mjs"; import { buildArgs, gateSteps, workflowSteps } from "./lib/gate-roster.mjs"; +import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; exitOnCrash(); + +const USAGE = `usage: node scripts/check_ci_workflows.mjs [-h, --help] + +Checks that both CI workflows run every gate the wrappers run, with the same +arguments and in the same order. + + -h, --help print this text and exit`; + +// Every other argument is ignored. +if (parseCli(process.argv.slice(2), { + options: { help: { type: "boolean", short: "h" } }, + unknown: "ignore", + positionals: { min: 0, max: 0 }, + stopAt: ["help"], +}).values.help) printHelpAndExit(USAGE); + const JOB = "build"; const WORKFLOWS = ["checks.yml", "tbdocs-gh-pages.yml"]; diff --git a/scripts/check_cli.mjs b/scripts/check_cli.mjs index 926fff70..c4d5f1bc 100644 --- a/scripts/check_cli.mjs +++ b/scripts/check_cli.mjs @@ -21,6 +21,10 @@ // a flag to a tool's usage does not fail this gate. A string is the whole // stream; a RegExp must match it; a stream a case does not name must be empty. // +// Every tool answers --help and -h with its usage on stdout and exit 0 (C71), +// so each has a case for both, and after each of them the folder it ran in must +// still be empty: a help request starts no IDE or browser and writes nothing. +// // Only invocations that stop while reading the command line belong here. Each // runs as a child process, all of them at once, each with a time limit, in an // empty folder of its own and with TB_IDE and PUPPETEER_EXECUTABLE_PATH naming @@ -31,7 +35,7 @@ // second. import { execFile } from "node:child_process"; -import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, readdir, rm } from "node:fs/promises"; import { availableParallelism, tmpdir } from "node:os"; import path from "node:path"; import { parseArgs } from "node:util"; @@ -42,6 +46,21 @@ import { createProbes, exitOnCrash } from "./lib/gate-probes.mjs"; exitOnCrash(); +const USAGE = `usage: node scripts/check_cli.mjs [-h, --help] + +Tests lib/cli.mjs, the command-line parser, and runs the recorded command-line +cases of every tool, each in an empty folder with no IDE and no browser. + + -h, --help print this text and exit`; + +// Every other argument is ignored. +if (parseCli(process.argv.slice(2), { + options: { help: { type: "boolean", short: "h" } }, + unknown: "ignore", + positionals: { min: 0, max: 0 }, + stopAt: ["help"], +}).values.help) printHelpAndExit(USAGE); + const { check, report } = createProbes("check_cli"); const show = (x) => JSON.stringify(x); const caught = (fn) => { @@ -305,10 +324,11 @@ const CASES = [ // Recorded in C48, before the a11y and diagram tools moved onto lib/cli.mjs. // A value flag given nothing, at the end or as "", reads as an unknown // argument; one followed by another flag takes the flag as its value, so that - // is no case. check_a11y has no --help and refuses it. --theme and --viewport - // are checked against their lists (C20). check_dot_fit and build_dot_metrics - // ignore every argument, so neither has a case. - { tool: "scripts/check_a11y.mjs", args: ["--help"], exit: 2, stderr: "unknown arg: --help\n" }, + // is no case. Each of them answers --help on stdout with exit 0 (C71). + // --theme and --viewport are checked against their lists (C20). + // check_dot_fit and build_dot_metrics ignore every argument but --help and -h, + // so neither has another case. + { tool: "scripts/check_a11y.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/check_a11y\.mjs / }, { tool: "scripts/check_a11y.mjs", args: ["--bogus"], exit: 2, stderr: "unknown arg: --bogus\n" }, { tool: "scripts/check_a11y.mjs", args: ["--root-dir"], exit: 2, stderr: "unknown arg: --root-dir\n" }, { tool: "scripts/check_a11y.mjs", args: ["--theme", ""], exit: 2, stderr: "unknown arg: --theme\n" }, @@ -329,66 +349,62 @@ const CASES = [ { tool: "scripts/check_tree_fresh.mjs", args: ["--bogus"], exit: 2, stderr: "unknown arg: --bogus\n" }, { tool: "scripts/check_tree_fresh.mjs", args: ["--source"], exit: 2, stderr: "unknown arg: --source\n" }, { tool: "scripts/check_tree_fresh.mjs", args: ["--tree"], exit: 2, stderr: "unknown arg: --tree\n" }, - { tool: "scripts/pick_a11y_sample.mjs", args: ["--help"], exit: 0, stderr: /^usage: node scripts\/pick_a11y_sample\.mjs / }, + { tool: "scripts/pick_a11y_sample.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/pick_a11y_sample\.mjs / }, { tool: "scripts/pick_a11y_sample.mjs", args: ["--bogus"], exit: 2, stderr: "unknown arg: --bogus\n" }, { tool: "scripts/pick_a11y_sample.mjs", args: ["--budget"], exit: 2, stderr: "unknown arg: --budget\n" }, - { tool: "scripts/sweep_a11y.mjs", args: ["--help"], exit: 0, stderr: /^usage: node scripts\/sweep_a11y\.mjs / }, + { tool: "scripts/sweep_a11y.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/sweep_a11y\.mjs / }, { tool: "scripts/sweep_a11y.mjs", args: ["--bogus"], exit: 2, stderr: "unknown arg: --bogus\n" }, { tool: "scripts/sweep_a11y.mjs", args: ["--limit"], exit: 2, stderr: "unknown arg: --limit\n" }, { tool: "scripts/sweep_a11y.mjs", args: ["--theme", "drak"], exit: 2, stderr: 'unknown --theme "drak"; expected one of light, dark or both\n' }, { tool: "scripts/sweep_a11y.mjs", args: ["--viewport", "huge"], exit: 2, stderr: 'unknown --viewport "huge"; expected one of desktop, mobile or both\n' }, // Recorded in C49, before the harness tools moved onto lib/cli.mjs. All of - // them ignore an unknown flag. tbbuild, tbrun and addin_test answer --help - // with their usage line on stderr and exit 2; tbbuild checks its numbers - // first. tbbuild finds its project anywhere in the list (C17). tbrun and - // addin_test give a value flag with nothing after it, or "", its default, - // and a value flag takes the argument after it whatever it is. - // check_examples and census_attributes print their help on stdout and exit - // 0, after the value checks; build_package_api has no --help, and - // gen_attribute_probes takes any argument as its output folder, so only its - // empty list is a case. census_attributes prints its own header comment, - // with the checkout's line endings. + // them ignore an unknown flag, and answer --help and -h on stdout with exit 0 + // before any other check, a number or a missing project included (C71). + // tbbuild finds its project anywhere in the list (C17). tbrun and addin_test + // give a value flag with nothing after it, or "", its default, and a value + // flag takes the argument after it whatever it is. gen_attribute_probes takes + // any other argument as its output folder, so only its empty list is a case. { tool: "scripts/tbbuild.mjs", args: [], exit: 2, stderr: /^usage: node scripts\/tbbuild\.mjs / }, - { tool: "scripts/tbbuild.mjs", args: ["x.twinproj", "--help"], exit: 2, stderr: /^usage: node scripts\/tbbuild\.mjs / }, + { tool: "scripts/tbbuild.mjs", args: ["x.twinproj", "--help"], exit: 0, stdout: /^usage: node scripts\/tbbuild\.mjs / }, { tool: "scripts/tbbuild.mjs", args: ["x.twinproj", "--arch", "win99"], exit: 2, stderr: /^usage: node scripts\/tbbuild\.mjs / }, { tool: "scripts/tbbuild.mjs", args: ["x.twinproj", "--port", "1.5"], exit: 2, stderr: /^--port takes a positive whole number\nusage: node scripts\/tbbuild\.mjs / }, { tool: "scripts/tbbuild.mjs", args: ["x.twinproj", "--timeout", "abc"], exit: 2, stderr: /^--timeout takes a positive number\nusage: node scripts\/tbbuild\.mjs / }, { tool: "scripts/tbbuild.mjs", args: ["x.twinproj", "--timeout", "-3"], exit: 2, stderr: /^--timeout needs a value\nusage: node scripts\/tbbuild\.mjs / }, - { tool: "scripts/tbbuild.mjs", args: ["x.twinproj", "--port", "0", "--help"], exit: 2, stderr: /^--port takes a positive whole number\nusage: node scripts\/tbbuild\.mjs / }, + { tool: "scripts/tbbuild.mjs", args: ["x.twinproj", "--port", "0", "--help"], exit: 0, stdout: /^usage: node scripts\/tbbuild\.mjs / }, { tool: "scripts/tbbuild.mjs", args: ["--bogus", "--keep", "x.twinproj"], exit: 2, stderr: "no such project: x.twinproj\n" }, { tool: "scripts/tbrun.mjs", args: [], exit: 2, stderr: /^usage: node scripts\/tbrun\.mjs / }, - { tool: "scripts/tbrun.mjs", args: ["no-such-dir", "--help"], exit: 2, stderr: /^usage: node scripts\/tbrun\.mjs / }, + { tool: "scripts/tbrun.mjs", args: ["no-such-dir", "--help"], exit: 0, stdout: /^usage: node scripts\/tbrun\.mjs / }, { tool: "scripts/tbrun.mjs", args: ["no-such-dir", "--arch", "win99"], exit: 2, stderr: /^usage: node scripts\/tbrun\.mjs / }, { tool: "scripts/tbrun.mjs", args: ["--port", "no-such-dir"], exit: 2, stderr: /^usage: node scripts\/tbrun\.mjs / }, { tool: "scripts/tbrun.mjs", args: ["no-such-dir", "--arch"], exit: 2, stderr: /^not a directory: .*no-such-dir\ntbrun takes an exported source tree / }, { tool: "scripts/tbrun.mjs", args: ["no-such-dir", "--arch", ""], exit: 2, stderr: /^not a directory: .*no-such-dir\ntbrun takes an exported source tree / }, { tool: "scripts/tbrun.mjs", args: ["--bogus", "no-such-dir"], exit: 2, stderr: /^not a directory: .*no-such-dir\ntbrun takes an exported source tree / }, - { tool: "scripts/addin_test.mjs", args: ["--help"], exit: 2, stderr: /^usage: node scripts\/addin_test\.mjs / }, + { tool: "scripts/addin_test.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/addin_test\.mjs / }, { tool: "scripts/addin_test.mjs", args: ["--ide"], exit: 2, stderr: /^no twinBASIC IDE found: pass --ide / }, { tool: "scripts/addin_test.mjs", args: ["--ide", ""], exit: 2, stderr: /^no twinBASIC IDE found: pass --ide / }, { tool: "scripts/check_examples.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/check_examples\.mjs \[options\]\n/ }, { tool: "scripts/check_examples.mjs", args: ["--jobs", "0"], exit: 2, stderr: "check_examples: --jobs takes a positive whole number\n" }, { tool: "scripts/check_examples.mjs", args: ["--batch", "1.5"], exit: 2, stderr: "check_examples: --batch takes a positive whole number\n" }, - { tool: "scripts/check_examples.mjs", args: ["--jobs", "0", "--help"], exit: 2, stderr: "check_examples: --jobs takes a positive whole number\n" }, - { tool: "scripts/check_examples.mjs", args: ["--help", "--jobs"], exit: 2, stderr: "check_examples: --jobs needs a value\n" }, - { tool: "scripts/census_attributes.mjs", args: ["--help"], exit: 0, stdout: /^\n {4}node scripts\/census_attributes\.mjs \[options\]\r?\n/ }, - { tool: "scripts/census_attributes.mjs", args: ["--help", "--attr"], exit: 2, stderr: "--attr needs a value\n" }, + { tool: "scripts/check_examples.mjs", args: ["--jobs", "0", "--help"], exit: 0, stdout: /^usage: node scripts\/check_examples\.mjs \[options\]\n/ }, + { tool: "scripts/check_examples.mjs", args: ["--help", "--jobs"], exit: 0, stdout: /^usage: node scripts\/check_examples\.mjs \[options\]\n/ }, + { tool: "scripts/census_attributes.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/census_attributes\.mjs \[options\]\n/ }, + { tool: "scripts/census_attributes.mjs", args: ["--help", "--attr"], exit: 0, stdout: /^usage: node scripts\/census_attributes\.mjs \[options\]\n/ }, { tool: "scripts/census_attributes.mjs", args: ["--dump-sites"], exit: 2, stderr: "--dump-sites needs a value\n" }, - { tool: "scripts/build_package_api.mjs", args: ["--help"], exit: 2, stderr: "no twinBASIC install found; pass --ide or set TB_IDE\n" }, + { tool: "scripts/build_package_api.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/build_package_api\.mjs \[options\]\n/ }, { tool: "scripts/gen_attribute_probes.mjs", args: [], exit: 2, stdout: /^Generate a twinBASIC probe project for Reference\/Attributes\.md applicability\.\n/ }, // Recorded in C50, before the gates and link tools moved onto lib/cli.mjs. // check_links prints on stdout and exits 4; an unknown flag is warned about // and takes the argument after it along, unless that starts with a dash, and // a value flag takes whatever follows. check_links_diff, crawl_check and - // compare_trees refuse an unknown argument; crawl_check has no --help, and a - // value flag there takes whatever follows. check_publish_policy reads only - // --src and ignores the rest; check_lint takes --staged alone or nothing. - // survey_tooling's words for a parse error were node:util's, so only the - // line and the usage after it are pinned. check_regex_safety, - // check_code_regions, check_gate_lists and convert_em_dash_separators - // ignore every argument they do not know, so none has a case. + // compare_trees refuse an unknown argument, and a value flag in crawl_check + // takes whatever follows. check_publish_policy reads only --src and ignores + // the rest; check_lint takes --staged alone, --help, or nothing. survey_tooling's words for a parse error + // were node:util's, so only the line and the usage after it are pinned. + // check_regex_safety, check_code_regions, check_gate_lists and + // convert_em_dash_separators ignore every argument they do not know, so none + // has a case beyond --help and -h. { tool: "scripts/check_links.mjs", args: ["--help"], exit: 0, stdout: /^Usage: node check_links\.mjs \[options\] <inputs\.\.\.>\n/ }, { tool: "scripts/check_links.mjs", args: ["no-such-tree"], exit: 4, stdout: "error: --offline is required. Online (network) checking is not implemented by this tool.\n" }, { tool: "scripts/check_links.mjs", args: ["--offline"], exit: 4, stdout: "error: at least one input file or directory is required\n" }, @@ -401,7 +417,7 @@ const CASES = [ { tool: "scripts/check_links_diff.mjs", args: ["stray"], exit: 2, stderr: "error: unknown argument: stray\n" }, { tool: "scripts/check_links_diff.mjs", args: ["--list=1"], exit: 2, stderr: "error: unknown argument: --list=1\n" }, { tool: "scripts/crawl_check.mjs", args: [], exit: 2, stderr: /^usage: node scripts\/crawl_check\.mjs <start-url> / }, - { tool: "scripts/crawl_check.mjs", args: ["--help"], exit: 2, stderr: "unknown flag: --help\n" }, + { tool: "scripts/crawl_check.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/crawl_check\.mjs <start-url> / }, { tool: "scripts/crawl_check.mjs", args: ["--bogus", "http://127.0.0.1:9/"], exit: 2, stderr: "unknown flag: --bogus\n" }, { tool: "scripts/crawl_check.mjs", args: ["--timeout", "5", "-x"], exit: 2, stderr: "unknown flag: -x\n" }, { tool: "scripts/crawl_check.mjs", args: ["--skip-external=1"], exit: 2, stderr: "unknown flag: --skip-external=1\n" }, @@ -414,7 +430,7 @@ const CASES = [ { tool: "scripts/survey_tooling.mjs", args: ["--window", "0"], exit: 2, stderr: /^--window expects a positive integer, got: 0\nusage: node scripts\/survey_tooling\.mjs / }, { tool: "scripts/survey_tooling.mjs", args: ["--top", "1.5"], exit: 2, stderr: /^--top expects a positive integer, got: 1\.5\nusage: node scripts\/survey_tooling\.mjs / }, { tool: "scripts/survey_tooling.mjs", args: ["--window", "0", "--help"], exit: 0, stdout: /^usage: node scripts\/survey_tooling\.mjs \[--root DIR\] / }, - { tool: "scripts/check_lint.mjs", args: ["--help"], exit: 2, stderr: "check_lint: usage: node scripts/check_lint.mjs [--staged]\n" }, + { tool: "scripts/check_lint.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/check_lint\.mjs \[--staged\]\n/ }, { tool: "scripts/check_lint.mjs", args: ["--staged", "--staged"], exit: 2, stderr: "check_lint: usage: node scripts/check_lint.mjs [--staged]\n" }, { tool: "scripts/check_lint.mjs", args: ["--staged", "x"], exit: 2, stderr: "check_lint: usage: node scripts/check_lint.mjs [--staged]\n" }, { tool: "scripts/check_lint.mjs", args: ["--"], exit: 2, stderr: "check_lint: usage: node scripts/check_lint.mjs [--staged]\n" }, @@ -432,20 +448,20 @@ const CASES = [ // Recorded in C51, before render-book, eval/ and wisdom moved onto // lib/cli.mjs. In every one of them a value flag takes whatever follows it, // so a missing value shows only where the value is used. render-book - // refuses --help, an unknown flag and a second input with "unknown arg". + // refuses an unknown flag and a second input with "unknown arg". // build_corpus threw on an unknown argument, so only its message is pinned, // as are the other crashes here. Node names a file it cannot open with its // folder on Windows and as given on Linux, so a crash's file name may // follow a folder or stand alone. run_case and search_quality refuse one in // their own words. nav_hops and site_search take an unknown // flag as a pattern or a search term. transcript's file is its first - // argument that does not start with --, -h included, and its exit code - // follows whether there is one, so a bare --help exits 1. wisdom takes its - // first argument as the command and answers --help there as an unknown - // command; after it, an unknown option or a stray argument is refused - // before any command runs, and the command in these cases is never a real - // one, so that none can start an export. - { tool: "book/render-book.mjs", args: ["--help"], exit: 2, stderr: "unknown arg: --help\n" }, + // argument that does not start with --, and no file exits 1. wisdom takes its + // first argument as the command; --help there or after it prints the usage. + // Every tool here answers -h and --help on stdout with exit 0 (C71), and + // reads nothing after it. Any other unknown option + // or stray argument is refused before any command runs, and the command in + // these cases is never a real one, so that none can start an export. + { tool: "book/render-book.mjs", args: ["--help"], exit: 0, stdout: /^usage: node render-book\.mjs <input\.html> / }, { tool: "book/render-book.mjs", args: [], exit: 2, stderr: "usage: node render-book.mjs <input.html> -o <output.pdf> [--outline-tags ...] [-t ms] [--additional-script path]...\n" }, { tool: "book/render-book.mjs", args: ["a.html", "b.html"], exit: 2, stderr: "unknown arg: b.html\n" }, { tool: "book/render-book.mjs", args: ["a.html", "-o"], exit: 2, stderr: /^usage: node render-book\.mjs <input\.html> / }, @@ -457,9 +473,9 @@ const CASES = [ { tool: "eval/build_corpus.mjs", args: [], exit: 1, stdout: /^Usage: node eval\/build_corpus\.mjs --dest <path> / }, { tool: "eval/build_corpus.mjs", args: ["--bogus"], exit: 1, stderr: /(^|\n)(Error: )?unknown argument: --bogus\r?\n/ }, { tool: "eval/build_corpus.mjs", args: ["stray"], exit: 1, stderr: /(^|\n)(Error: )?unknown argument: stray\r?\n/ }, - { tool: "eval/build_corpus.mjs", args: ["--help", "--bogus"], exit: 1, stderr: /(^|\n)(Error: )?unknown argument: --bogus\r?\n/ }, + { tool: "eval/build_corpus.mjs", args: ["--help", "--bogus"], exit: 0, stdout: /^Usage: node eval\/build_corpus\.mjs --dest <path> / }, { tool: "eval/build_corpus.mjs", args: ["--quiet=1"], exit: 1, stderr: /(^|\n)(Error: )?unknown argument: --quiet=1\r?\n/ }, - { tool: "eval/build_corpus.mjs", args: ["-hq"], exit: 1, stderr: /(^|\n)(Error: )?unknown argument: -hq\r?\n/ }, + { tool: "eval/build_corpus.mjs", args: ["-hq"], exit: 0, stdout: /^Usage: node eval\/build_corpus\.mjs --dest <path> / }, { tool: "eval/build_corpus.mjs", args: ["--src"], exit: 1, stderr: /TypeError \[ERR_INVALID_ARG_TYPE\]: The "paths\[0\]" argument must be of type string\. Received undefined\r?\n/ }, { tool: "eval/nav_hops.mjs", args: ["--help"], exit: 0, stdout: /^Usage: node eval\/nav_hops\.mjs \[--from <page>\] / }, { tool: "eval/nav_hops.mjs", args: [], exit: 2, stdout: /^Usage: node eval\/nav_hops\.mjs \[--from <page>\] / }, @@ -474,7 +490,7 @@ const CASES = [ { tool: "eval/run_case.mjs", args: [], exit: 2, stdout: /^Usage: node eval\/run_case\.mjs --corpus <dir> / }, { tool: "eval/run_case.mjs", args: ["--bogus"], exit: 2, stderr: "unknown argument: --bogus\n" }, { tool: "eval/run_case.mjs", args: ["stray"], exit: 2, stderr: "unknown argument: stray\n" }, - { tool: "eval/run_case.mjs", args: ["--help", "--bogus"], exit: 2, stderr: "unknown argument: --bogus\n" }, + { tool: "eval/run_case.mjs", args: ["--help", "--bogus"], exit: 0, stdout: /^Usage: node eval\/run_case\.mjs --corpus <dir> / }, { tool: "eval/run_case.mjs", args: ["--prompt-only=1"], exit: 2, stderr: "unknown argument: --prompt-only=1\n" }, { tool: "eval/run_case.mjs", args: ["--corpus"], exit: 2, stderr: 'The "paths[0]" argument must be of type string. Received undefined\n' }, { tool: "eval/run_case.mjs", args: ["--smoke", "--corpus", "c", "--site", "s", "--out", "o"], exit: 2, stderr: /^missing: .*[\\/]c[\\/]docs, .*search-data\.json, .*lunr\.min\.js\n$/ }, @@ -489,7 +505,7 @@ const CASES = [ { tool: "eval/search_quality.mjs", args: ["--help"], exit: 0, stdout: /^Usage: node eval\/search_quality\.mjs \[--site docs\/_site\] / }, { tool: "eval/search_quality.mjs", args: ["--bogus"], exit: 1, stderr: "unrecognised argument: --bogus\n" }, { tool: "eval/search_quality.mjs", args: ["stray"], exit: 1, stderr: "unrecognised argument: stray\n" }, - { tool: "eval/search_quality.mjs", args: ["--help", "--bogus"], exit: 1, stderr: "unrecognised argument: --bogus\n" }, + { tool: "eval/search_quality.mjs", args: ["--help", "--bogus"], exit: 0, stdout: /^Usage: node eval\/search_quality\.mjs \[--site docs\/_site\] / }, { tool: "eval/search_quality.mjs", args: ["--help=1"], exit: 1, stderr: "unrecognised argument: --help=1\n" }, { tool: "eval/search_quality.mjs", args: ["-x"], exit: 1, stderr: "unrecognised argument: -x\n" }, { tool: "eval/search_quality.mjs", args: ["--site", "nowhere"], exit: 1, stderr: /^missing .*search-data\.json\nRun build\.bat / }, @@ -497,7 +513,7 @@ const CASES = [ { tool: "eval/search_quality.mjs", args: ["--site", "--help"], exit: 1, stderr: /^missing .*[\\/]--help[\\/]assets[\\/]js[\\/]search-data\.json\nRun build\.bat / }, { tool: "eval/search_quality.mjs", args: ["--site"], exit: 1, stderr: /TypeError \[ERR_INVALID_ARG_TYPE\]: The "paths\[0\]" argument must be of type string\. Received undefined\r?\n/ }, { tool: "eval/search_quality.mjs", args: ["--site", "nowhere", "--save"], exit: 1, stderr: /TypeError \[ERR_INVALID_ARG_TYPE\]: The "paths\[0\]" argument must be of type string\. Received undefined\r?\n/ }, - { tool: "eval/transcript.mjs", args: ["--help"], exit: 1, stdout: /^Usage: node eval\/transcript\.mjs <case\.jsonl> / }, + { tool: "eval/transcript.mjs", args: ["--help"], exit: 0, stdout: /^Usage: node eval\/transcript\.mjs <case\.jsonl> / }, { tool: "eval/transcript.mjs", args: ["-h"], exit: 0, stdout: /^Usage: node eval\/transcript\.mjs <case\.jsonl> / }, { tool: "eval/transcript.mjs", args: [], exit: 1, stdout: /^Usage: node eval\/transcript\.mjs <case\.jsonl> / }, { tool: "eval/transcript.mjs", args: ["nope.jsonl", "--help"], exit: 0, stdout: /^Usage: node eval\/transcript\.mjs <case\.jsonl> / }, @@ -508,13 +524,13 @@ const CASES = [ { tool: "eval/transcript.mjs", args: ["-x"], exit: 1, stderr: /Error: ENOENT: no such file or directory, open '(?:[^']*[\\/])?-x'\r?\n/ }, { tool: "eval/transcript.mjs", args: ["a.jsonl", "b.jsonl"], exit: 1, stderr: /Error: ENOENT: no such file or directory, open '[^']*a\.jsonl'\r?\n/ }, { tool: "wisdom/wisdom.mjs", args: [], exit: 0, stderr: /^Usage: node wisdom\/wisdom\.mjs <command> \[options\]\n/ }, - { tool: "wisdom/wisdom.mjs", args: ["--help"], exit: 1, stderr: /^Usage: node wisdom\/wisdom\.mjs <command> \[options\]\n/ }, + { tool: "wisdom/wisdom.mjs", args: ["--help"], exit: 0, stdout: /^Usage: node wisdom\/wisdom\.mjs <command> \[options\]\n/ }, { tool: "wisdom/wisdom.mjs", args: ["bogus"], exit: 1, stderr: /^Usage: node wisdom\/wisdom\.mjs <command> \[options\]\n/ }, { tool: "wisdom/wisdom.mjs", args: ["bogus", "--guild", "--bogus"], exit: 1, stderr: /^Usage: node wisdom\/wisdom\.mjs <command> \[options\]\n/ }, { tool: "wisdom/wisdom.mjs", args: ["bogus", "--cap"], exit: 1, stderr: /^Usage: node wisdom\/wisdom\.mjs <command> \[options\]\n/ }, { tool: "wisdom/wisdom.mjs", args: ["bogus", "--bogus"], exit: 1, stderr: "Unknown option: --bogus\n" }, { tool: "wisdom/wisdom.mjs", args: ["bogus", "stray"], exit: 1, stderr: "Unknown option: stray\n" }, - { tool: "wisdom/wisdom.mjs", args: ["bogus", "--help"], exit: 1, stderr: "Unknown option: --help\n" }, + { tool: "wisdom/wisdom.mjs", args: ["bogus", "--help"], exit: 0, stdout: /^Usage: node wisdom\/wisdom\.mjs <command> \[options\]\n/ }, { tool: "wisdom/wisdom.mjs", args: ["bogus", "--force=1"], exit: 1, stderr: "Unknown option: --force=1\n" }, { tool: "wisdom/wisdom.mjs", args: ["bogus", "-x"], exit: 1, stderr: "Unknown option: -x\n" }, { tool: "wisdom/wisdom.mjs", args: ["bogus", "--guild", "x", "--bogus"], exit: 1, stderr: "Unknown option: --bogus\n" }, @@ -537,8 +553,8 @@ const CASES = [ { tool: "builder/tbdocs.mjs", args: ["-"], exit: 4, stderr: "Unknown argument: -\n" }, { tool: "builder/tbdocs.mjs", args: ["-x"], exit: 4, stderr: "Unknown argument: -x\n" }, { tool: "builder/tbdocs.mjs", args: ["-xy"], exit: 4, stderr: "Unknown argument: -xy\n" }, - { tool: "builder/tbdocs.mjs", args: ["-h"], exit: 4, stderr: "Unknown argument: -h\n" }, - { tool: "builder/tbdocs.mjs", args: ["--help"], exit: 4, stderr: "Unknown argument: --help\n" }, + { tool: "builder/tbdocs.mjs", args: ["-h"], exit: 0, stdout: /^usage: node builder\/tbdocs\.mjs \[options\]\n/ }, + { tool: "builder/tbdocs.mjs", args: ["--help"], exit: 0, stdout: /^usage: node builder\/tbdocs\.mjs \[options\]\n/ }, { tool: "builder/tbdocs.mjs", args: ["--dry-run=1"], exit: 4, stderr: "Unknown argument: --dry-run=1\n" }, { tool: "builder/tbdocs.mjs", args: ["--no-check=1"], exit: 4, stderr: "Unknown argument: --no-check=1\n" }, { tool: "builder/tbdocs.mjs", args: ["--no-check", "--bogus"], exit: 4, stderr: "Unknown argument: --bogus\n" }, @@ -556,6 +572,67 @@ const CASES = [ stderr: /^refusing --dest (.+)[\\/]sub: it is inside the source tree, so a build would read its output back as source, or serve would rebuild on its own writes\. Use a folder directly under \1 whose name starts with _site, _serve, _pdf, or one inside such a folder, or one outside \1\.\n$/ }, ]; +// Recorded in C71. Every tool prints its usage on stdout and exits 0 for +// --help and for -h, so each is a case, the two forms alike, unless the table +// above already holds it. The value is the start of the tool's text where that +// is not `usage: node <tool>`: an older text that opens otherwise, or one that +// names the tool without its folder. +const HELP_TOOLS = { + "builder/tbdocs.mjs": null, + "book/render-book.mjs": "usage: node render-book.mjs <input.html> ", + "wisdom/wisdom.mjs": "Usage: node wisdom/wisdom.mjs <command> [options]\n", + "eval/build_corpus.mjs": "Usage: node eval/build_corpus.mjs ", + "eval/nav_hops.mjs": "Usage: node eval/nav_hops.mjs ", + "eval/run_case.mjs": "Usage: node eval/run_case.mjs ", + "eval/search_quality.mjs": "Usage: node eval/search_quality.mjs ", + "eval/site_search.mjs": "Usage: node eval/site_search.mjs ", + "eval/transcript.mjs": "Usage: node eval/transcript.mjs ", + "scripts/addin_test.mjs": null, + "scripts/build_dot_metrics.mjs": null, + "scripts/build_package_api.mjs": null, + "scripts/census_attributes.mjs": null, + "scripts/check_a11y.mjs": null, + "scripts/check_a11y_fingerprint.mjs": null, + "scripts/check_axe_patch_equiv.mjs": null, + "scripts/check_book_coverage.mjs": null, + "scripts/check_ci_workflows.mjs": null, + "scripts/check_cli.mjs": null, + "scripts/check_code_regions.mjs": null, + "scripts/check_dot_fit.mjs": null, + "scripts/check_examples.mjs": null, + "scripts/check_gate_lists.mjs": null, + "scripts/check_impexp_parity.mjs": null, + "scripts/check_links.mjs": "Usage: node check_links.mjs [options] <inputs...>\n", + "scripts/check_links_diff.mjs": "Usage: node scripts/check_links_diff.mjs [options]\n", + "scripts/check_lint.mjs": null, + "scripts/check_page_baseline.mjs": null, + "scripts/check_pdf_shims_equiv.mjs": null, + "scripts/check_publish_policy.mjs": null, + "scripts/check_regex_safety.mjs": null, + "scripts/check_symbol_index.mjs": null, + "scripts/check_tb_registry.mjs": null, + "scripts/check_tree_fresh.mjs": null, + "scripts/check_twin_parsers.mjs": null, + "scripts/compare_trees.mjs": null, + "scripts/convert_em_dash_separators.mjs": null, + "scripts/crawl_check.mjs": null, + "scripts/gen_attribute_probes.mjs": "Generate a twinBASIC probe project for Reference/Attributes.md applicability.\n", + "scripts/impexp.mjs": "Usage:\n", + "scripts/pick_a11y_sample.mjs": null, + "scripts/survey_tooling.mjs": null, + "scripts/sweep_a11y.mjs": null, + "scripts/tbbuild.mjs": null, + "scripts/tbrun.mjs": null, +}; +const literal = (text) => text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&"); +for (const [tool, start] of Object.entries(HELP_TOOLS)) { + const opening = start ? literal(start) : `${literal(`usage: node ${tool}`)}[ \\n]`; + for (const flag of ["--help", "-h"]) { + if (CASES.some((c) => c.tool === tool && c.args.length === 1 && c.args[0] === flag)) continue; + CASES.push({ tool, args: [flag], exit: 0, stdout: new RegExp(`^${opening}`) }); + } +} + const TIMEOUT_MS = 30_000; function runCase({ tool, args }, cwd, env) { @@ -568,6 +645,7 @@ function runCase({ tool, args }, cwd, env) { }); } +const asksForHelp = ({ args }) => args.includes("--help") || args.includes("-h"); const matches = (expected = "", text) => (expected instanceof RegExp ? expected.test(text) : text === expected); const expectation = (expected = "") => (expected instanceof RegExp ? String(expected) : show(expected)); const clip = (text) => show(text.length > 400 ? `${text.slice(0, 400)}...` : text); @@ -588,15 +666,18 @@ try { const cwd = path.join(scratch, `case-${i}`); await mkdir(cwd); results[i] = await runCase(CASES[i], cwd, env); + if (asksForHelp(CASES[i])) results[i].left = await readdir(cwd); } }; await Promise.all(Array.from({ length: Math.min(availableParallelism(), CASES.length) }, lane)); CASES.forEach((c, i) => { const got = results[i]; const ok = got.exit === c.exit && matches(c.stdout, got.stdout) && matches(c.stderr, got.stderr); - check(`${path.basename(c.tool, ".mjs")} ${c.args.join(" ")}: exit ${c.exit}, ${c.stdout ? "stdout" : "stderr"}`, ok, + const label = `${path.basename(c.tool, ".mjs")} ${c.args.join(" ")}`; + check(`${label}: exit ${c.exit}, ${c.stdout ? "stdout" : "stderr"}`, ok, `expected exit ${c.exit}, stdout ${expectation(c.stdout)}, stderr ${expectation(c.stderr)}\n` + `got exit ${got.exit}, stdout ${clip(got.stdout)}, stderr ${clip(got.stderr)}`); + if (got.left) check(`${label}: leaves its folder empty`, got.left.length === 0, `appeared in the folder: ${show(got.left)}`); }); } finally { await rm(scratch, { recursive: true, force: true }); diff --git a/scripts/check_code_regions.mjs b/scripts/check_code_regions.mjs index 4344cf90..30f4c266 100644 --- a/scripts/check_code_regions.mjs +++ b/scripts/check_code_regions.mjs @@ -88,7 +88,7 @@ import { validateCountNames } from "../builder/counts.mjs"; import { discover } from "../builder/discover.mjs"; import { applyPostRenderRewrites, applyPreRenderRewrites, createMarkdownIt } from "../builder/render.mjs"; import { injectAnchorHeadings } from "../builder/template.mjs"; -import { parseCli } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; import { parseFrontmatter, unquotedHashValues } from "../lib/frontmatter.mjs"; import { blockRegions, mapLines, maskCode, splitCodeSpans, splitOnMarker } from "../lib/markdown.mjs"; import { markdownFiles } from "../lib/markdown-files.mjs"; @@ -447,11 +447,22 @@ const DISCOVER_PROBES = [ ["discover's warning about an unquoted value that ends in #", hashProbe], ]; +const USAGE = `usage: node scripts/check_code_regions.mjs [--verbose] [--self-test] [-h, --help] + +Checks that no rewrite of page source or rendered HTML alters a code region, and +runs the probes of the modules that decide what is code. + + --verbose print the detail of every finding + --self-test prove the comparison still detects an altered code region + -h, --help print this text and exit`; + async function main(argv) { const { values } = parseCli(argv, { - options: { verbose: { type: "boolean" }, "self-test": { type: "boolean" } }, + options: { verbose: { type: "boolean" }, "self-test": { type: "boolean" }, help: { type: "boolean", short: "h" } }, unknown: "ignore", + stopAt: ["help"], }); + if (values.help) printHelpAndExit(USAGE); const verbose = values.verbose; if (values.selfTest) { diff --git a/scripts/check_dot_fit.mjs b/scripts/check_dot_fit.mjs index 61d4bba7..fef991dd 100644 --- a/scripts/check_dot_fit.mjs +++ b/scripts/check_dot_fit.mjs @@ -29,11 +29,19 @@ import { listDotSources } from "../builder/dot.mjs"; import { withBrowser } from "./lib/browser.mjs"; import { exitOnCrash } from "./lib/gate-probes.mjs"; import { openInterPage } from "./lib/inter-page.mjs"; -import { parseCli } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; import { DOCS_DIR, REPO_ROOT } from "../lib/repo-paths.mjs"; exitOnCrash(); +const USAGE = `usage: node scripts/check_dot_fit.mjs [--verbose] [-h, --help] + +Checks that the text of every committed DOT diagram still fits the boxes +Graphviz drew for it, by measuring the text in a browser. + + --verbose also print the tolerance under each diagram that fits + -h, --help print this text and exit`; + // A label may sit this far past its box edge before it counts as a failure. // Kerning is the irreducible part: a per-character table cannot express it, // and on the site's labels it makes Graphviz over-estimate by up to 2.8% -- @@ -42,7 +50,13 @@ exitOnCrash(); // tens of units rather than ones. const TOLERANCE = 1.0; -const verbose = parseCli(process.argv.slice(2), { options: { verbose: { type: "boolean" } }, unknown: "ignore" }).values.verbose === true; +const cli = parseCli(process.argv.slice(2), { + options: { verbose: { type: "boolean" }, help: { type: "boolean", short: "h" } }, + unknown: "ignore", + stopAt: ["help"], +}); +if (cli.values.help) printHelpAndExit(USAGE); +const verbose = cli.values.verbose === true; // The committed SVG of every diagram the build renders, found as it finds them. const svgs = []; diff --git a/scripts/check_examples.mjs b/scripts/check_examples.mjs index bb8a6915..59e349b5 100644 --- a/scripts/check_examples.mjs +++ b/scripts/check_examples.mjs @@ -107,14 +107,38 @@ const { values } = withUsageError( keep: { type: "boolean", default: false }, show: { type: "boolean", default: false }, hide: { type: "boolean", default: false }, - help: { type: "boolean", default: false }, + help: { type: "boolean", short: "h", default: false }, }, unknown: "ignore", positionals: 0, + stopAt: ["help"], }), { format: (err) => `check_examples: ${err.message}` }, ); +const USAGE = `usage: node scripts/check_examples.mjs [options] + +Compiles the documentation's own twinBASIC code samples, every tb fence marked +\`${MARKER}\`, and reports the ones the compiler refuses. + + --only <regex> restrict to pages whose path matches + --census classify every tb fence and print the table; no compiler + --propose treat every classifiable fence as marked, and say which pass + --apply with --propose, add \`${MARKER}\` to the fences that passed + --report <file> group the findings of a saved \`--propose --json\` survey by + diagnostic, section, undeclared symbol and page; no compiler + --jobs <n> concurrent IDE lanes (default 4) + --port <n> base DevTools port (default 9480) + --batch <n> samples per generated project (default 120) + --ide <path> twinBASIC.exe (default: $TB_IDE, else the newest on the Desktop) + --keep leave the generated projects on disk and say where + --show, --hide as tbbuild's + --verbose also print warnings, not only errors + --json one JSON object instead of a report + -h, --help print this text and exit`; + +if (values.help) printHelpAndExit(USAGE); + // A count or a port that is not a positive whole number is refused: Number() // makes NaN of anything it cannot read. function positiveInteger(n, d) { @@ -134,24 +158,6 @@ const jobs = positiveInteger("jobs", 4); const basePort = positiveInteger("port", 9480); const batchSize = positiveInteger("batch", 120); -if (values.help) { - printHelpAndExit(`usage: node scripts/check_examples.mjs [options] - - --only <regex> restrict to pages whose path matches - --census classify every tb fence and print the table; no compiler - --propose treat every classifiable fence as marked, and say which pass - --apply with --propose, add \`${MARKER}\` to the fences that passed - --report <file> group the findings of a saved \`--propose --json\` survey by - diagnostic, section, undeclared symbol and page; no compiler - --jobs <n> concurrent IDE lanes (default 4) - --port <n> base DevTools port (default 9480) - --batch <n> samples per generated project (default 120) - --ide <path> twinBASIC.exe (default: $TB_IDE, else the newest on the Desktop) - --keep leave the generated projects on disk and say where - --verbose also print warnings, not only errors - --json one JSON object instead of a report`); -} - // A page's template, when its fence does not name one. Inferred from the path // because the package a sample needs is what the page is ABOUT -- stating // project= on all 263 Reference/Built-In fences would be markup that only ever diff --git a/scripts/check_gate_lists.mjs b/scripts/check_gate_lists.mjs index b16c783e..c7bc1946 100644 --- a/scripts/check_gate_lists.mjs +++ b/scripts/check_gate_lists.mjs @@ -81,7 +81,7 @@ import { readFile, readdir } from "node:fs/promises"; import path from "node:path"; import { createMarkdownIt } from "../builder/render.mjs"; -import { parseCli } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; import { splitOnMarker } from "../lib/markdown.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; import { gateName, gatesFromBat } from "./lib/gate-roster.mjs"; @@ -551,11 +551,22 @@ function selfTest() { // ------------------------------------------------------------------ main +const USAGE = `usage: node scripts/check_gate_lists.mjs [--verbose] [--self-test] [-h, --help] + +Checks that check.bat and test.bat still match the two gate lists in Tools.md, +and that no developer page states a gate count that disagrees with them. + + --verbose print every probe and every wrapper that agrees + --self-test run the probes only, to prove the check still detects a wrong list + -h, --help print this text and exit`; + async function main(argv) { const { values } = parseCli(argv, { - options: { verbose: { type: "boolean" }, "self-test": { type: "boolean" } }, + options: { verbose: { type: "boolean" }, "self-test": { type: "boolean" }, help: { type: "boolean", short: "h" } }, unknown: "ignore", + stopAt: ["help"], }); + if (values.help) printHelpAndExit(USAGE); const verbose = values.verbose; const onlySelfTest = values.selfTest; diff --git a/scripts/check_links.mjs b/scripts/check_links.mjs index f7445278..cc810670 100644 --- a/scripts/check_links.mjs +++ b/scripts/check_links.mjs @@ -173,12 +173,14 @@ Integrity checks (share the existing htmlparser2 SAX parse pass): file under the section's Images/ folder. --check-sitemap Every .html file in the input is in sitemap.xml (or is a known exclusion). - Reads <root-dir>/sitemap.xml; skipped - silently if the file is absent. + Reads <root-dir>/sitemap.xml; if the file is + absent, prints a warning and skips the + check without failing. --check-search Every .html file in the input has at least one entry in assets/js/search-data.json. - Reads from <root-dir>; skipped silently if - the file is absent. + Reads from <root-dir>; if the file is + absent, prints a warning and skips the + check without failing. --check-canonical Every page's <link rel="canonical" href> URL path matches the page's own deployment URL. Catches canonical URLs that include diff --git a/scripts/check_links_diff.mjs b/scripts/check_links_diff.mjs index a9bb96e8..b1dc7867 100644 --- a/scripts/check_links_diff.mjs +++ b/scripts/check_links_diff.mjs @@ -556,6 +556,7 @@ function parseArgs(argv) { }, positionals: 0, acceptsValue: () => true, + stopAt: ["help"], })); } catch (err) { throw new Error(`unknown argument: ${err.arg}`); @@ -582,6 +583,7 @@ function printHelp() { fail unless the difference is reported --list list cases and sides, then exit -v, --verbose print per-case finding counts even when clean + -h, --help print this text and exit `); } diff --git a/scripts/check_lint.mjs b/scripts/check_lint.mjs index f8d9929a..d59f4efb 100644 --- a/scripts/check_lint.mjs +++ b/scripts/check_lint.mjs @@ -36,7 +36,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; -import { parseCli } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; import { exitOnCrash } from "./lib/gate-probes.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; @@ -47,14 +47,27 @@ function cannotLint(message) { process.exit(2); } -const USAGE = "usage: node scripts/check_lint.mjs [--staged]"; +// A command-line error prints the first line alone, after the tool's name. +const SYNOPSIS = "usage: node scripts/check_lint.mjs [--staged]"; +const USAGE = `${SYNOPSIS} + +Runs the pinned Biome over the tooling and the site's scripts, and fails on a +warning as well as an error. + + --staged lint only the scripts the next commit adds or changes + -h, --help print this text and exit`; let cli; try { - cli = parseCli(process.argv.slice(2), { options: { staged: { type: "boolean", default: false } }, positionals: 0 }); + cli = parseCli(process.argv.slice(2), { + options: { staged: { type: "boolean", default: false }, help: { type: "boolean", short: "h" } }, + positionals: 0, + stopAt: ["help"], + }); } catch { - cannotLint(USAGE); + cannotLint(SYNOPSIS); } -if (cli.tokens.length > (cli.values.staged ? 1 : 0)) cannotLint(USAGE); +if (cli.values.help) printHelpAndExit(USAGE); +if (cli.tokens.length > (cli.values.staged ? 1 : 0)) cannotLint(SYNOPSIS); const staged = cli.values.staged; // The scripts the next commit adds or changes that are still on disk, by the diff --git a/scripts/check_page_baseline.mjs b/scripts/check_page_baseline.mjs index 9cdef960..76d91162 100644 --- a/scripts/check_page_baseline.mjs +++ b/scripts/check_page_baseline.mjs @@ -26,10 +26,26 @@ import { readFile } from "node:fs/promises"; import { GUARDED_SRC } from "../builder/baseline.mjs"; import { checkPageBaseline } from "../builder/page-baseline.mjs"; +import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; import { baselineFixture, createProbes, exitOnCrash } from "./lib/gate-probes.mjs"; exitOnCrash(); +const USAGE = `usage: node scripts/check_page_baseline.mjs [-h, --help] + +Checks that the page-count drift guard of builder/page-baseline.mjs still +refuses what it exists to refuse, against a scratch baseline file. + + -h, --help print this text and exit`; + +// Every other argument is ignored. +if (parseCli(process.argv.slice(2), { + options: { help: { type: "boolean", short: "h" } }, + unknown: "ignore", + positionals: { min: 0, max: 0 }, + stopAt: ["help"], +}).values.help) printHelpAndExit(USAGE); + const BASE = { src: GUARDED_SRC, pages: 908, staticFiles: 247 }; const { check, report } = createProbes("check_page_baseline"); diff --git a/scripts/check_publish_policy.mjs b/scripts/check_publish_policy.mjs index 1e9740e2..0c0d1841 100644 --- a/scripts/check_publish_policy.mjs +++ b/scripts/check_publish_policy.mjs @@ -22,16 +22,26 @@ import { publishPolicyFor, unpublishableSourceFiles, unpublishableTreePaths, SOURCE_EXTENSIONS, BUILD_EXTENSIONS, } from "../builder/publish-policy.mjs"; -import { parseCli } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; import { exitOnCrash } from "./lib/gate-probes.mjs"; exitOnCrash(); +const USAGE = `usage: node scripts/check_publish_policy.mjs [--src DIR] [-h, --help] + +Checks that the publish allowlist of builder/publish-policy.mjs still refuses +the file types it should, and that the source tree holds nothing it refuses. + + --src DIR the source tree to check (default docs) + -h, --help print this text and exit`; + const { values } = parseCli(process.argv.slice(2), { - options: { src: { type: "string", default: "docs" } }, + options: { src: { type: "string", default: "docs" }, help: { type: "boolean", short: "h" } }, unknown: "ignore", acceptsValue: () => true, + stopAt: ["help"], }); +if (values.help) printHelpAndExit(USAGE); const SRC = values.src; // Each probe names why refusing it matters. A probe that starts passing diff --git a/scripts/check_regex_safety.mjs b/scripts/check_regex_safety.mjs index 6969f89a..9c4af9fb 100644 --- a/scripts/check_regex_safety.mjs +++ b/scripts/check_regex_safety.mjs @@ -78,7 +78,7 @@ import * as walk from "acorn-walk"; import fg from "fast-glob"; import { foldConstructedRegexes, moduleExports } from "./lib/regex-fold.mjs"; -import { parseCli } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; // ── Backend selection ──────────────────────────────────────────────────────── @@ -581,14 +581,25 @@ async function selfTest() { return 0; } +const USAGE = `usage: node scripts/check_regex_safety.mjs [--census] [--self-test] [-h, --help] + +Refuses a regex literal in the tree that can backtrack exponentially. + + --census list the full classification of every regex + --self-test prove the gate still detects an exponential regex + -h, --help print this text and exit`; + const { values } = parseCli(process.argv.slice(2), { options: { shard: { type: "boolean" }, "self-test": { type: "boolean" }, census: { type: "boolean" }, + help: { type: "boolean", short: "h" }, }, unknown: "ignore", + stopAt: ["help"], }); +if (values.help) printHelpAndExit(USAGE); if (values.shard) { // Worker half of checkAll(): a slice in on stdin, its verdicts out on // stdout. Not meant to be run by hand. diff --git a/scripts/check_symbol_index.mjs b/scripts/check_symbol_index.mjs index a9188d83..c85619cd 100644 --- a/scripts/check_symbol_index.mjs +++ b/scripts/check_symbol_index.mjs @@ -30,11 +30,27 @@ import { readFile } from "node:fs/promises"; import { GUARDED_SRC } from "../builder/baseline.mjs"; import { checkSymbolBaseline } from "../builder/symbol-baseline.mjs"; import { deriveSymbolIndex, headingsOf, serializeSymbolIndex } from "../builder/symbols.mjs"; +import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; import { baselineFixture, createProbes, exitOnCrash } from "./lib/gate-probes.mjs"; import { apiSnapshot, isPublicType, parseTwin } from "./lib/twin-api.mjs"; exitOnCrash(); +const USAGE = `usage: node scripts/check_symbol_index.mjs [-h, --help] + +Checks that the symbol index still places each kind of symbol, from fixtures: +the .twin declaration scanner, the derivation from the pages and the drift guard. + + -h, --help print this text and exit`; + +// Every other argument is ignored. +if (parseCli(process.argv.slice(2), { + options: { help: { type: "boolean", short: "h" } }, + unknown: "ignore", + positionals: { min: 0, max: 0 }, + stopAt: ["help"], +}).values.help) printHelpAndExit(USAGE); + const { check, report } = createProbes("check_symbol_index"); const show = (x) => JSON.stringify(x); diff --git a/scripts/check_tb_registry.mjs b/scripts/check_tb_registry.mjs index 0f307ecb..d2362da5 100644 --- a/scripts/check_tb_registry.mjs +++ b/scripts/check_tb_registry.mjs @@ -49,12 +49,28 @@ import assert from "node:assert/strict"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; import { exitOnCrash } from "./lib/gate-probes.mjs"; import * as R from "./lib/tb-registry.mjs"; // A crash exits 2; the catch below passes on everything but a failed assertion. exitOnCrash(); +const USAGE = `usage: node scripts/check_tb_registry.mjs [-h, --help] + +Tests the harness's registry tidy in scripts/lib/tb-registry.mjs, under +HKCU\\Software\\tbharness-selftest. Windows only, and not a gate. + + -h, --help print this text and exit`; + +// Every other argument is ignored. +if (parseCli(process.argv.slice(2), { + options: { help: { type: "boolean", short: "h" } }, + unknown: "ignore", + positionals: { min: 0, max: 0 }, + stopAt: ["help"], +}).values.help) printHelpAndExit(USAGE); + const BASE = "Software\\tbharness-selftest"; const ROOT = BASE + "\\twinBASIC_IDE"; const ASSOC = BASE + "\\Classes\\twinBASIC.ProjectFile"; diff --git a/scripts/check_tree_fresh.mjs b/scripts/check_tree_fresh.mjs index 24eae886..88d1f4f5 100644 --- a/scripts/check_tree_fresh.mjs +++ b/scripts/check_tree_fresh.mjs @@ -80,7 +80,7 @@ const cli = withUsageError( ); if (cli.stopped === "help") { printHelpAndExit( - "usage: node scripts/check_tree_fresh.mjs [--tree DIR] [--marker FILE] [--source DIR ...]", + "usage: node scripts/check_tree_fresh.mjs [--tree DIR] [--marker FILE] [--source DIR ...] [-h, --help]", ); } let tree = cli.values.tree; diff --git a/scripts/check_twin_parsers.mjs b/scripts/check_twin_parsers.mjs index bb1d54e6..fac92cee 100644 --- a/scripts/check_twin_parsers.mjs +++ b/scripts/check_twin_parsers.mjs @@ -21,6 +21,7 @@ // - parseTargets (scripts/lib/attributes-doc.mjs), which turns an // `Applicable to:` line into gen_attribute_probes.mjs's targets. +import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; import { parseTargets } from "./lib/attributes-doc.mjs"; import { createProbes, exitOnCrash } from "./lib/gate-probes.mjs"; import { classify } from "./lib/tb-fences.mjs"; @@ -29,6 +30,21 @@ import { MODIFIERS, declarationKind } from "./lib/twin-declarations.mjs"; exitOnCrash(); +const USAGE = `usage: node scripts/check_twin_parsers.mjs [-h, --help] + +Runs the probes of the scanners that read twinBASIC source and the attribute +reference, each a shape one of them once misread. + + -h, --help print this text and exit`; + +// Every other argument is ignored. +if (parseCli(process.argv.slice(2), { + options: { help: { type: "boolean", short: "h" } }, + unknown: "ignore", + positionals: { min: 0, max: 0 }, + stopAt: ["help"], +}).values.help) printHelpAndExit(USAGE); + const { check, report } = createProbes("check_twin_parsers"); const show = (x) => JSON.stringify(x); diff --git a/scripts/compare_trees.mjs b/scripts/compare_trees.mjs index 77e6d0b7..a9111e13 100644 --- a/scripts/compare_trees.mjs +++ b/scripts/compare_trees.mjs @@ -87,7 +87,7 @@ const NORMALISERS = [ const TEXT_EXT = /\.(?:html?|css|js|mjs|json|xml|svg|txt|md|map|py|yml)$/i; -const USAGE = `usage: node scripts/compare_trees.mjs [--before <ref>] [--keep] [--max <n>] [-- <tbdocs args>] +const USAGE = `usage: node scripts/compare_trees.mjs [--before <ref>] [--keep] [--max <n>] [-h, --help] [-- <tbdocs args>] Builds <ref> (default HEAD) and the working tree, each from a git worktree under .compare-trees/ with tbdocs --no-fetch-assets and CI=1, and compares the @@ -96,7 +96,9 @@ online, offline and PDF trees byte for byte. --before <ref> the commit to build as the before side (default HEAD) --keep leave .compare-trees/ in place: both worktrees, their trees and both build logs - --max <n> list at most n differences per tree (default 20) + --max <n> list at most n files of each kind of difference per tree + (default 20) + -h, --help print this text and exit -- everything after it is passed to both tbdocs builds Exit codes: 0 the trees match, 1 they differ, 2 the tool failed. diff --git a/scripts/convert_em_dash_separators.mjs b/scripts/convert_em_dash_separators.mjs index 896614c3..c5d8b510 100644 --- a/scripts/convert_em_dash_separators.mjs +++ b/scripts/convert_em_dash_separators.mjs @@ -45,7 +45,7 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { createMarkdownIt } from "../builder/render.mjs"; -import { parseCli } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; import { blockRegions, mapLines, splitCodeSpans } from "../lib/markdown.mjs"; import { markdownFiles } from "../lib/markdown-files.mjs"; import { DOCS_DIR } from "../lib/repo-paths.mjs"; @@ -126,8 +126,21 @@ function byPathParts(a, b) { return x.length - y.length; } +const USAGE = `usage: node scripts/convert_em_dash_separators.mjs [--check] [-h, --help] + +Rewrites literal en- and em-dashes in docs/ markdown source to the ASCII forms +the typographer converts at build time, leaving code as it is. + + --check report the files that hold a literal dash and change nothing + -h, --help print this text and exit`; + async function main(argv) { - const { values } = parseCli(argv, { options: { check: { type: "boolean" } }, unknown: "ignore" }); + const { values } = parseCli(argv, { + options: { check: { type: "boolean" }, help: { type: "boolean", short: "h" } }, + unknown: "ignore", + stopAt: ["help"], + }); + if (values.help) printHelpAndExit(USAGE); const check = values.check; let files = 0; let sep = 0; diff --git a/scripts/crawl_check.mjs b/scripts/crawl_check.mjs index c3d054af..8a7cc16b 100644 --- a/scripts/crawl_check.mjs +++ b/scripts/crawl_check.mjs @@ -19,7 +19,17 @@ import { Parser } from "htmlparser2"; import { forEachLink } from "../builder/link-check.mjs"; import { splitFragment } from "../builder/url.mjs"; -import { parseCli, withUsageError } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; + +const USAGE = `usage: node scripts/crawl_check.mjs <start-url> [--concurrency N] [--timeout MS] [--skip-external] [-h, --help] + +Crawls a deployed site from <start-url> and checks that every link responds 2xx +and every anchor exists. + + --concurrency N requests at once (default 10) + --timeout MS give up on a request after this long (default 15000) + --skip-external do not check links to other sites + -h, --help print this text and exit`; const { values, positionals } = withUsageError( () => @@ -28,18 +38,21 @@ const { values, positionals } = withUsageError( concurrency: { type: "string", default: "10" }, timeout: { type: "string", default: "15000" }, "skip-external": { type: "boolean" }, + help: { type: "boolean", short: "h" }, }, positionals: { min: 0 }, acceptsValue: () => true, + stopAt: ["help"], }), { format: (err) => `unknown flag: ${err.arg}` }, ); +if (values.help) printHelpAndExit(USAGE); const startArg = positionals[0]; const concurrency = Number(values.concurrency); const timeoutMs = Number(values.timeout); const skipExternal = values.skipExternal; if (!startArg) { - console.error("usage: node scripts/crawl_check.mjs <start-url> [--concurrency N] [--timeout MS] [--skip-external]"); + console.error(USAGE); process.exit(2); } diff --git a/scripts/gen_attribute_probes.mjs b/scripts/gen_attribute_probes.mjs index d1b36e91..d1cdbe34 100644 --- a/scripts/gen_attribute_probes.mjs +++ b/scripts/gen_attribute_probes.mjs @@ -38,18 +38,22 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { parseAttributes, parseTargets } from "./lib/attributes-doc.mjs"; -import { parseCli } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; import { DOCS_DIR } from "../lib/repo-paths.mjs"; const ATTR_DOC = path.join(DOCS_DIR, "Reference", "Attributes.md"); const USAGE = `Generate a twinBASIC probe project for Reference/Attributes.md applicability. - node scripts/gen_attribute_probes.mjs <out_dir> [key.md] + node scripts/gen_attribute_probes.mjs <out_dir> [key.md] [-h, --help] Writes one source file per claimed attribute target, plus a key naming the Attributes.md line each probe came from. Every probe is expected to compile; a -diagnostic naming a probe module is a finding.`; +diagnostic naming a probe module is a finding. + + <out_dir> the folder to write the probe project into + key.md where to write the key (default: probe-key.md beside <out_dir>) + -h, --help print this text and exit`; // --------------------------------------------------------------- arguments // An attribute with a mandatory argument needs a value that is itself valid, or @@ -1028,7 +1032,13 @@ const MAIN_TWIN = "' Startup object for the probe project. Does nothing.\n\n" + "Module ProbeMain\n Public Sub Main()\n End Sub\nEnd Module\n"; async function main(argv) { - const { positionals } = parseCli(argv, { unknown: "positional", positionals: { min: 0 } }); + const { values, positionals } = parseCli(argv, { + options: { help: { type: "boolean", short: "h" } }, + unknown: "positional", + positionals: { min: 0 }, + stopAt: ["help"], + }); + if (values.help) printHelpAndExit(USAGE); if (positionals.length < 1) { console.log(USAGE); return 2; diff --git a/scripts/pick_a11y_sample.mjs b/scripts/pick_a11y_sample.mjs index a05378b0..2a338527 100644 --- a/scripts/pick_a11y_sample.mjs +++ b/scripts/pick_a11y_sample.mjs @@ -147,8 +147,7 @@ const cli = withUsageError( if (cli.stopped === "help") { printHelpAndExit( "usage: node scripts/pick_a11y_sample.mjs [--check|--propose|--census] [--fresh]\n" - + " [--root-dir DIR] [--sweep FILE] [--budget MS]", - { stream: "stderr" }, + + " [--root-dir DIR] [--sweep FILE] [--budget MS] [-h, --help]", ); } const modeTokens = cli.tokens.filter((t) => t.key === "check" || t.key === "propose" || t.key === "census"); diff --git a/scripts/survey_tooling.mjs b/scripts/survey_tooling.mjs index 8491098e..5749e3bb 100644 --- a/scripts/survey_tooling.mjs +++ b/scripts/survey_tooling.mjs @@ -52,7 +52,7 @@ import { builtinModules } from "node:module"; import path from "node:path"; import * as acorn from "acorn"; import * as walk from "acorn-walk"; -import { parseCli, withUsageError } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; const TOOLING_DIRS = ["builder", "scripts", "lib", "book", "eval", "wisdom", "test", "perf"]; @@ -61,8 +61,17 @@ const LAB = "perf/"; const MAX_OCCURRENCES = 40; const ARG_HELPERS = new Set(["flag", "opt", "die"]); -const USAGE = "usage: node scripts/survey_tooling.mjs [--root DIR] [--summary] " + - "[--window N] [--top N] [--include-perf]"; +const USAGE = `usage: node scripts/survey_tooling.mjs [--root DIR] [--summary] [--window N] [--top N] [--include-perf] + +Measures the repository's own tooling for repeated code and structure, over the +files git tracks. It is a measurement taken by hand and is not a gate. + + --root DIR measure another checkout (default: this one) + --summary print the summary only, without the listings + --window N tokens two places must share to count as a clone (default 60) + --top N list at most N clone regions (default 45) + --include-perf list what involves perf/ too + -h, --help print this text and exit`; const { values } = withUsageError( () => @@ -73,16 +82,14 @@ const { values } = withUsageError( window: { type: "string", default: "60" }, top: { type: "string", default: "45" }, "include-perf": { type: "boolean", default: false }, - help: { type: "boolean", default: false }, + help: { type: "boolean", short: "h", default: false }, }, positionals: 0, + stopAt: ["help"], }), { format: (err) => `${err.message}\n${USAGE}` }, ); -if (values.help) { - console.log(USAGE); - process.exit(0); -} +if (values.help) printHelpAndExit(USAGE); const WINDOW = positiveInt("window", values.window); const TOP = positiveInt("top", values.top); const ROOT = path.resolve(values.root ?? REPO_ROOT); diff --git a/scripts/sweep_a11y.mjs b/scripts/sweep_a11y.mjs index 23922d30..d7ec9a28 100644 --- a/scripts/sweep_a11y.mjs +++ b/scripts/sweep_a11y.mjs @@ -96,8 +96,8 @@ if (cli.stopped === "help") { printHelpAndExit( "usage: node scripts/sweep_a11y.mjs [--theme T] [--viewport V] [--filter SUBSTR]\n" + " [--limit N] [--out FILE] [--resume] [--report]\n" - + " [--stock-axe] [--root-dir DIR]", - { stream: "stderr" }, + + " [--stock-axe] [--root-dir DIR] [--recycle-every N]\n" + + " [-h, --help]", ); } diff --git a/scripts/tbbuild.mjs b/scripts/tbbuild.mjs index 78018ceb..14e1bf0a 100644 --- a/scripts/tbbuild.mjs +++ b/scripts/tbbuild.mjs @@ -51,15 +51,26 @@ // front of you". import { existsSync, statSync } from "node:fs"; import path from "node:path"; -import { parseCli, withUsageError } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { findIde } from "./lib/tb-install.mjs"; import { COMPILE_TIMEOUT, TARGETS, attachIde, compileOutcome, launchIde, setBuildTarget, shutdownIde, summaryLine, waitForCompile, wantShow } from "./lib/tb-ide.mjs"; import { finishTidy, startTidy } from "./lib/tb-registry.mjs"; -const USAGE = "usage: node scripts/tbbuild.mjs <project.twinproj> " + - "[--ide <twinBASIC.exe>] [--port N] [--arch win32|win64] [--timeout S] [--json] " + - "[--keep] [--show|--hide]"; +const USAGE = `usage: node scripts/tbbuild.mjs <project.twinproj> [--ide <twinBASIC.exe>] [--port N] [--arch win32|win64] [--timeout S] [--json] [--keep] [--show|--hide] [-h, --help] + +Compiles a packed .twinproj in the twinBASIC IDE and prints its diagnostics. + + --ide <path> twinBASIC.exe (default: $TB_IDE, else the newest + twinBASIC_IDE_BETA_* on the Desktop) + --port <n> DevTools port to start the IDE on (default 9333) + --arch <target> win32 or win64 (default win32) + --timeout <secs> give up waiting for the compile (default 180) + --json emit one JSON object instead of text + --keep leave the IDE running; its pid is printed as \`ide-pid: N\` + --show, --hide show the IDE on the desktop, or keep it on a private one + (default: hidden, unless TBBUILD_SHOW is set) + -h, --help print this text and exit`; function usage(why) { if (why) console.error(why); @@ -78,13 +89,15 @@ const { values, positionals } = withUsageError( keep: { type: "boolean", default: false }, show: { type: "boolean", default: false }, hide: { type: "boolean", default: false }, - help: { type: "boolean", default: false }, + help: { type: "boolean", short: "h", default: false }, }, unknown: "ignore", positionals: { min: 0, max: 1 }, + stopAt: ["help"], }), { format: (err) => `${err.message}\n${USAGE}` }, ); +if (values.help) printHelpAndExit(USAGE); // A number that is not positive, or a port that is not whole, is refused too. // Anything Number() cannot read is NaN, and a NaN timeout ends @@ -110,7 +123,7 @@ const keep = values.keep; const show = wantShow({ show: values.show, hide: values.hide }); const proj = positionals[0]; -if (!proj || values.help || !TARGETS.includes(arch)) usage(); +if (!proj || !TARGETS.includes(arch)) usage(); // Refuse anything that is not a .twinproj, rather than discovering it two // minutes later. A source directory is the tempting mistake -- it is what // `tbrun` takes -- and handing one to the IDE does not fail: the IDE starts, diff --git a/scripts/tbrun.mjs b/scripts/tbrun.mjs index 0ae3e85e..d6a3e01b 100644 --- a/scripts/tbrun.mjs +++ b/scripts/tbrun.mjs @@ -101,7 +101,7 @@ import { execFileSync } from "node:child_process"; import { existsSync, readFileSync, mkdirSync, statSync, readdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { parseCli } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; import { click } from "./lib/tb-click.mjs"; import { compilerExe, findIde } from "./lib/tb-install.mjs"; import { BUILD_FAILED, COMPILE_TIMEOUT, TARGETS, attachIde, compileOutcome, keepClears, keptClears, @@ -110,6 +110,26 @@ import { BUILD_FAILED, COMPILE_TIMEOUT, TARGETS, attachIde, compileOutcome, keep import { laneProjectId, stageProject } from "./lib/tb-project.mjs"; import { finishTidy, startTidy } from "./lib/tb-registry.mjs"; +const USAGE = `usage: node scripts/tbrun.mjs <source-dir> [--ide <twinBASIC.exe>] [--port N] [--arch win32|win64] [--timeout S] [--quiet MS] [--json] [--raw] [--keep] [--no-reap] [--reap-images a,b] [--show|--hide] [-h, --help] + +Builds an exported twinBASIC source tree in the IDE, runs it, and prints what it +writes to the DEBUG CONSOLE. + + --ide <path> as tbbuild's + --port <n> DevTools port to start the IDE on (default 9346) + --arch <target> win32 or win64 (default win32) + --timeout <secs> give up waiting for console output (default 120) + --quiet <ms> output is complete after this long with no change + (default 2500) + --json emit one JSON object instead of text + --raw do not strip the console's timestamp column + --keep leave the IDE running afterwards (implies --no-reap) + --no-reap do not harvest automation servers the probe left behind + --reap-images a,b comma-separated image names to harvest (default: the + Office suite) + --show, --hide as tbbuild's + -h, --help print this text and exit`; + const { values, positionals } = parseCli(process.argv.slice(2), { options: { port: { type: "string" }, @@ -124,22 +144,20 @@ const { values, positionals } = parseCli(process.argv.slice(2), { "no-reap": { type: "boolean", default: false }, show: { type: "boolean", default: false }, hide: { type: "boolean", default: false }, - help: { type: "boolean", default: false }, + help: { type: "boolean", short: "h", default: false }, }, unknown: "ignore", positionals: { min: 0, max: 1 }, acceptsValue: () => true, + stopAt: ["help"], }); +if (values.help) printHelpAndExit(USAGE); const die = (code, msg) => { console.error(msg); process.exit(code); }; const arch = values.arch || TARGETS[0]; -if (!positionals.length || values.help || !TARGETS.includes(arch)) { - die(2, "usage: node scripts/tbrun.mjs <source-dir> [--port N] [--arch win32|win64] " + - "[--timeout S] [--quiet MS] [--json] [--raw] [--keep] [--no-reap] " + - "[--reap-images a,b] [--show|--hide]"); -} +if (!positionals.length || !TARGETS.includes(arch)) die(2, USAGE); const srcDir = path.resolve(positionals[0]); if (!existsSync(srcDir) || !statSync(srcDir).isDirectory()) { diff --git a/wisdom/wisdom.mjs b/wisdom/wisdom.mjs index bc8051ea..6013f0ed 100644 --- a/wisdom/wisdom.mjs +++ b/wisdom/wisdom.mjs @@ -16,8 +16,10 @@ const __dirname = dirname(fileURLToPath(import.meta.url)) function parseArgs(argv) { const [command, ...rest] = argv.slice(2) + if (command === '--help' || command === '-h') printHelpAndExit(USAGE) const { values } = withUsageError(() => parseCli(rest, { options: { + help: { type: 'boolean', short: 'h' }, guild: { type: 'string' }, channel: { type: 'string', multiple: true }, since: { type: 'string' }, @@ -35,7 +37,9 @@ function parseArgs(argv) { positionals: 0, unknown: 'error', acceptsValue: () => true, + stopAt: ['help'], }), { format: (err) => `Unknown option: ${err.arg}`, exitCode: 1 }) + if (values.help) printHelpAndExit(USAGE) const flags = { channels: values.channel } if ('guild' in values) flags.guild = values.guild @@ -222,6 +226,8 @@ Commands: process Convert raw JSON to structured .md files extract Prepare data for Claude-agent knowledge extraction +Any command, or none, also takes -h, --help: print this text and exit. + Export options: --guild <id> Guild (server) ID --channel <id> Restrict to this channel (repeatable) From 914d01d08d1d8956677a5e8c26604fdda3848dab Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober <kuba@mareimbrium.org> Date: Wed, 30 Sep 2026 11:30:44 +0200 Subject: [PATCH 13/21] builder, scripts, book, eval, wisdom: a refused command line exits 2 --- WIP.md | 2 +- book/render-book.mjs | 4 +- builder/PLAN-TOOLING-REVIEW.md | 123 +++++++ builder/command-line.mjs | 31 +- docs/Documentation/Extending.md | 2 +- docs/Documentation/PDF-Generation.md | 2 +- docs/Documentation/Pipeline-Stages.md | 4 +- docs/Documentation/Tools.md | 12 +- docs/Documentation/Wisdom.md | 2 +- eval/README.md | 5 + eval/build_corpus.mjs | 6 +- eval/nav_hops.mjs | 16 +- eval/protocol.md | 2 +- eval/run_case.mjs | 7 +- eval/search_quality.mjs | 9 +- eval/site_search.mjs | 14 +- eval/transcript.mjs | 18 +- lib/cli.mjs | 101 ++--- scripts/addin_test.mjs | 9 +- scripts/build_dot_metrics.mjs | 7 +- scripts/build_package_api.mjs | 2 - scripts/census_attributes.mjs | 2 - scripts/check_a11y.mjs | 2 - scripts/check_a11y_fingerprint.mjs | 2 - scripts/check_axe_patch_equiv.mjs | 2 - scripts/check_book_coverage.mjs | 9 +- scripts/check_ci_workflows.mjs | 9 +- scripts/check_cli.mjs | 490 +++++++++++++++---------- scripts/check_code_regions.mjs | 7 +- scripts/check_dot_fit.mjs | 7 +- scripts/check_examples.mjs | 2 - scripts/check_gate_lists.mjs | 7 +- scripts/check_links.mjs | 95 ++--- scripts/check_links_diff.mjs | 47 +-- scripts/check_lint.mjs | 29 +- scripts/check_page_baseline.mjs | 9 +- scripts/check_publish_policy.mjs | 8 +- scripts/check_regex_safety.mjs | 7 +- scripts/check_symbol_index.mjs | 9 +- scripts/check_tb_registry.mjs | 9 +- scripts/check_tree_fresh.mjs | 2 - scripts/check_twin_parsers.mjs | 9 +- scripts/compare_trees.mjs | 9 +- scripts/convert_em_dash_separators.mjs | 7 +- scripts/crawl_check.mjs | 5 +- scripts/gen_attribute_probes.mjs | 15 +- scripts/pick_a11y_sample.mjs | 2 - scripts/sweep_a11y.mjs | 2 - scripts/tbbuild.mjs | 1 - scripts/tbrun.mjs | 45 +-- wisdom/PLAN-3.md | 2 +- wisdom/wisdom.mjs | 7 +- 52 files changed, 658 insertions(+), 578 deletions(-) diff --git a/WIP.md b/WIP.md index 37b3c9b6..26b67d72 100644 --- a/WIP.md +++ b/WIP.md @@ -504,7 +504,7 @@ wrapper: | `test.bat` | `check_regex_safety` | no regex in the tree can backtrack exponentially | | `test.bat` | `check_symbol_index` | the symbol index still places each kind of symbol, from fixtures | | `test.bat` | `check_twin_parsers` | every word of the shared modifier list reaches all three scanners of twinBASIC source; the census's declaration kinds and `parseTargets`' targets hold for the shapes each once misread | -| `test.bat` | `check_cli` | `lib/cli.mjs` parses as a strict `parseArgs` does, and keeps a lenient tool's leniency where asked; each tool's recorded command-line errors still exit and print as recorded, run with an IDE and a browser that do not exist | +| `test.bat` | `check_cli` | `lib/cli.mjs` parses as a strict `parseArgs` does, and also refuses an empty value unless the option allows one; every tool refuses an unknown flag, and every tool with a value option an empty value; each tool's recorded command-line errors still exit and print as recorded, run with an IDE and a browser that do not exist | | `test.bat` | `check_ci_workflows` | both CI workflows run every wrapper gate, with the same arguments and order, and build with `build.bat`'s flags | | `test.bat` | `check_lint` | Biome finds nothing in the tooling, warnings included, and checked at least one script | | `test.bat` | `test/search.test.mjs` | the search entries `builder/search.mjs` writes hold what they should, and the copies of the search client still agree. Run by `node --test`; the gate roster reads such a line as a gate, named by its path | diff --git a/book/render-book.mjs b/book/render-book.mjs index 57c6708f..bf75fbcf 100644 --- a/book/render-book.mjs +++ b/book/render-book.mjs @@ -218,10 +218,8 @@ const { values, positionals } = withUsageError(() => parseCli(process.argv.slice help: { type: 'boolean', short: 'h' }, }, positionals: { max: 1 }, - unknown: 'error', - acceptsValue: () => true, stopAt: ['help'], -}), { format: (err) => `unknown arg: ${err.arg}`, exitCode: 2 }); +})); if (values.help) printHelpAndExit(USAGE); const inputArg = positionals[0]; const outputArg = values.output; diff --git a/builder/PLAN-TOOLING-REVIEW.md b/builder/PLAN-TOOLING-REVIEW.md index 4336dbc4..52a171b8 100644 --- a/builder/PLAN-TOOLING-REVIEW.md +++ b/builder/PLAN-TOOLING-REVIEW.md @@ -930,6 +930,110 @@ both comments are corrected. Any exception a tool keeps is stated in its usage t **Verify.** `check_cli.mjs` gains an unknown-flag case and an empty-value case for every tool. +**Landed** as `builder, scripts, book, eval, wisdom: a refused command line exits 2` (see +"Where the plan was wrong"), on the owner's four choices of 2026-09-29: strict everywhere, +with a term that starts with a dash given after `--`; an empty value refused by `lib/cli.mjs`; +every usage error reported one way; the values a tool reads after the parse left to C72a. +`parseCli` has lost `unknown`, `acceptsValue` and the `ignored` result, and refuses an +unknown option, a boolean given a value, a value flag with none, an empty value (code +`empty-value`, `--x needs a non-empty value`) unless the option's spec says `empty: true`, +and a positional beyond the tool's count. Only `tbdocs`' `--baseurl` allows an empty value +(the site root), so `--stall-timeout=` is refused where it was 0. Every tool but the four +already strict (`check_impexp_parity`, `check_pdf_shims_equiv`, `survey_tooling` and +`impexp.mjs`, which has its own parser) drops its leniency: the gates that ignored every argument but `--help`, the harness tools that +ignored an unknown flag, `tbrun`'s and `addin_test`'s empty value taking the default, +`check_publish_policy`'s, `crawl_check`'s and the `eval/` tools' flag at the end taken as +`undefined`, the a11y tools' flag taken as the value before it. `crawl_check` takes one start +URL, `transcript` one file (simply the positional), `gen_attribute_probes` a folder and a key; +`nav_hops`, `site_search`, `transcript` and `gen_attribute_probes` say in their usage that a +term, file or folder starting with a dash goes after `--`, and so does `eval/protocol.md` for +the evaluator's `site-search`. `check_links`' tolerance goes whole: the unrecognised-argument +warning, the rule that an unknown `--flag` took the positional after it, and `--threads` +(accepted and unused; nothing passed it). The plan's `check_links.mjs:307-314` and `:406-412` +no longer held the `check.bat` comments, which went with C50's migration. + +Every usage error goes to stderr with exit 2, or 4 in `tbdocs` and `check_links`, in the +`CliError`'s own words (`unknown option: --bogus`, `--theme needs a value`, `unexpected +argument: x`). The rewordings that misnamed the fault went: the a11y tools' `unknown arg:` +for a missing value, `crawl_check`'s `unknown flag:`, `check_links_diff`'s and the +`eval/` tools' `unknown argument:`, `search_quality`'s `unrecognised argument:`, `wisdom`'s +`Unknown option:`, `tbdocs`' `Unknown argument:`. A tool's name prefix stays +(`check_examples: `, `compare_trees: `, `check_lint: `, `check_links`' `error: `), and so does +a usage text printed after the message; `check_lint` names the fault before its synopsis +line, where it printed the synopsis alone. `build_corpus`, `search_quality`, `site_search`, +`transcript` and `wisdom` exit 2 where they exited 1; `wisdom` with no command exits 2 where it +exited 0, and names an unknown command; a missing required argument prints the usage on +stderr in `gen_attribute_probes`, `nav_hops`, `run_case`, `site_search`, `transcript` and +`build_corpus`; `check_links` writes its command-line errors, and its usage after no +arguments, to stderr. `render-book`'s missing input stays exit 1: it is not a usage error. +`wisdom`'s 2 is now also its request cap's code, a code with two meanings for C74. Tools.md +states the rule once beside the `--help` sentence and in the `tbdocs`, `check_links`, +`crawl_check` and `check_cli` sections; Extending.md's, PDF-Generation.md's and Wisdom.md's +exit tables, Pipeline-Stages.md's `command-line.mjs` table, `eval/README.md`, WIP.md's `check_cli` row and `wisdom/PLAN-3.md` (whose +`extract` listed `--threads` for `--in`) follow. Two Found items close with it: +`build_corpus --dest ""` removed the current folder, and `convert_em_dash_separators --chek` +rewrote `docs/`. + +`check_cli: 568 probes, all pass` (423 before). The probes of `unknown` and `acceptsValue` +became strict ones (an unknown letter in a short group, a dash-led positional after `--`, a +fault after `--help` not read while one before it is), with empty-value probes (separate, +inline, short, `multiple`, and `empty: true`); the comparison with a strict `parseArgs` +leaves out the empty values, which it accepts. Every case whose tool changed was re-pointed +rather than dropped, but for `check_links`' two warning cases, which went with the warning: +an ignored flag's case keeps its later failure with the flag removed (`tbbuild --keep +x.twinproj`), a term case moves after `--`. A `REFUSALS` table, +beside `HELP_TOOLS` and checked against it, adds an unknown-flag case for all 45 tools and an +empty-value case for the 26 with a value option (`convert_em_dash_separators --check --bogus`, +`wisdom bogus --bogus`, so a regression does no work), and every such case also checks that +its folder stays empty. With `lib/cli.mjs`'s unknown-option refusal turned into a `continue` +through `c43-fault.mjs` in `NODE_OPTIONS`, 69 of 568 fail. A Sonnet agent checked every new +text against the code: its eleven findings in the comments, Tools.md, `eval/README.md`, +Pipeline-Stages.md and this note were fixed, and its note that exit-code texts leave out a +refused command line is C74's; its twelfth, that Extending.md's gate table should give +`check_links`' 4, was wrong, since no wrapper runs `check_links`. `compare_trees`: Extending, Pipeline-Stages, +PDF-Generation, Tools and Wisdom online and offline, the search data and `book.html`. Lint +`Checked 172 files`; regex safety unchanged at `528 literals + 30 constructed in 130 files +... 489 safe, 69 polynomial, 0 undecided, 0 exponential`; `build.bat`, `check.bat` and +`test.bat` clean. On the owner's next push CI prints `check_cli: 568 probes, all pass`. + +### C72a — `scripts, book, eval, wisdom: a bad value exits 2` + +**Split from C72** (the owner, 2026-09-29). C72 makes the parse strict; the values each tool +reads after the parse are still unchecked. A survey of the code for C72 found these (read +before editing, not run): + +- **Numbers read with `Number`, `parseInt` or `parseFloat` and never checked**, so text gives + `NaN` and `12abc` gives 12: `crawl_check`'s `--concurrency` and `--timeout` (a `NaN` timeout + reports every link broken; 0 workers check nothing and pass); `sweep_a11y`'s `--limit` (a + `NaN` sweeps nothing) and `--recycle-every`; `pick_a11y_sample`'s `--budget` (`NaN` makes it + unlimited); `run_case`'s `--timeout` (`NaN` or 0 kills its `claude` child at once); + `search_quality`'s `--sample`, `--worst` and `--failures`; `site_search`'s `-n`; `wisdom`'s + `--concurrency`, `--rate-limit` and `--cap`; `tbrun`'s `--port`, `--timeout` and `--quiet`; + `addin_test`'s `--port`, `--jobs` and `--timeout`; `render-book`'s `-t`; + `check_links_diff`'s `--max-lines`. The checks that exist accept `0x10` and `1e3` + (`tbbuild`, `check_examples`, `survey_tooling`, `compare_trees`, `tbdocs`'s + `--stall-timeout`), and `tbbuild`'s `--port` has no upper bound. +- **Regexes that crash with exit 1**: `addin_test`'s and `check_examples`' `--only`; a bad + `nav_hops` term exits 2 with a stack. +- **A URL**: `crawl_check`'s start URL, uncaught at module level, exit 1 with a stack. +- **Fixed sets and dates**: `check_links`' `--oracle` (anything but `index` is `fs`); + `wisdom`'s `--min-confidence` and `--since` (`Date.parse` gives `NaN`); + `check_a11y_fingerprint`'s `--baseline` and `--candidate` (an unknown scheme throws + uncaught, exit 1); `run_case`'s `--protocol`. +- **Conflicts that pass silently**: `site_search`'s `--composition` with terms (the terms are + ignored); `pick_a11y_sample`'s `--check` with `--propose` (the last wins). +- **A destination removed before writing**: `build_corpus` removes its `--dest` recursively + (`build_corpus.mjs:148`), so `--dest .` deletes the current folder and `--dest ..` can + delete the repository. It refuses a `--dest` that is the repository, contains it, or + contains the current folder, as `tbdocs` refuses a `--dest` that overlaps its source (the + owner, 2026-09-30). + +**Change.** Every number through `numberOption`, and every regex, URL, date and value from a +fixed set checked straight after the parse, refused on stderr with exit 2 (C18's 4 in the two +link tools), in the tool's usage-error form. + +**Verify.** `check_cli.mjs` gains a bad-value case for each. + ### C73 — `scripts: one meaning each for --json and --src` **L1-8 (R2), L1-9 (R3).** `--json` prints to stdout in `tbbuild`, `tbrun`, `check_examples` @@ -1334,6 +1438,13 @@ text, gains a Landed note, and the correction is listed here, as in the last rev tool answers them, `tbdocs` and the argument-less gates included, so it landed as `builder, scripts, book, eval, wisdom: --help prints usage to stdout and exits 0`. See C71's Landed note. +- **C72: the values a tool reads after the parse are C72a's.** The entry's "a bad value" + covered two kinds of fault: what the parse can see (a missing, empty or flag-like value, an + unknown flag, an extra argument) and what only the tool can judge (a number, a regex, a URL, + a value from a fixed set). At the owner's choice (2026-09-29) C72 did the first and C72a + takes the second. `tbdocs`' `builder/command-line.mjs` changed too, and the empty-value + case exists only for a tool with a value option (26 of 45), so it landed as `builder, + scripts, book, eval, wisdom: a refused command line exits 2`. See C72's Landed note. ## Found while implementing @@ -1608,6 +1719,18 @@ Defects the review did not have, found by building something this plan asks for. when their file is absent; each prints a `warning:` line and skips the check. Folded into C71, at the owner's choice. Fixed in `builder, scripts, book, eval, wisdom: --help prints usage to stdout and exits 0`. +- **`eval/build_corpus.mjs --dest ""` deleted the current folder**, found while landing C72: + the empty value resolved to the working folder, which `build()` removes recursively before + writing (`build_corpus.mjs:148`). C72's empty-value refusal closes that form; `--dest .` and + `--dest ..` still reach the removal. The guard goes into C72a, at the owner's choice. + Fixed, for the empty value, in `builder, scripts, book, eval, wisdom: a refused command line + exits 2`. +- **`convert_em_dash_separators --chek` rewrote `docs/`**, found while landing C72: a + misspelt `--check` was ignored, and the tool converts in place unless `--check` is given. + Fixed in `builder, scripts, book, eval, wisdom: a refused command line exits 2`. +- **`wisdom/PLAN-3.md` listed `--threads <dir>` for `extract`**, which takes `--in`; ignored + before, refused once C72 lands. Fixed in `builder, scripts, book, eval, wisdom: a refused + command line exits 2`. ## Open questions diff --git a/builder/command-line.mjs b/builder/command-line.mjs index a8747937..f24a711f 100644 --- a/builder/command-line.mjs +++ b/builder/command-line.mjs @@ -3,18 +3,19 @@ // parseCommandLine(argv) returns the options runBuild() and runServe() read, // or throws a CliError whose message is what tbdocs prints before it exits 4. // The table is lib/cli.mjs's strict one: an unknown option, a positional, a -// boolean given a value and a value flag given none (or one that starts with -// a dash) are all refused. Flags are then applied in the order they were -// given, because --no-check undoes the check flags before it and not the -// ones after. -h and --help end the parse where they stand: nothing after -// them is read, and the options returned say only `help`. +// boolean given a value, a value flag given none (or one that starts with a +// dash) and an empty value, except --baseurl's, are all refused. Flags are +// then applied in the order they were given, because --no-check undoes the +// check flags before it and not the ones after. -h and --help end the parse +// where they stand: nothing after them is read, and the options returned say +// only `help`. import { CliError, numberOption, parseCli } from "../lib/cli.mjs"; export const OPTIONS = { src: { type: "string" }, dest: { type: "string" }, - baseurl: { type: "string" }, + baseurl: { type: "string", empty: true }, url: { type: "string" }, "dry-run": { type: "boolean" }, "no-offline": { type: "boolean" }, @@ -48,7 +49,8 @@ given, and a flag that takes a value takes it as the next argument or as --dest <path> online-tree destination (default <src>/_site, or <src>/_serve with --serve); the offline tree is <dest>-offline, the PDF tree <dest>-pdf - --baseurl <prefix> override _config.yml's baseurl + --baseurl <prefix> override _config.yml's baseurl; an empty value is the + site root --url <origin> override _config.yml's url --dry-run build without writing the trees; the check does not run, and a baseline update still writes its file @@ -100,20 +102,9 @@ export const DEFAULTS = Object.freeze({ stallTimeoutMs: 120000, }); -// A value flag's own complaint names the flag; every other refusal names the -// argument as it was given, `-xy` and `--dry-run=1` whole. -function parse(argv) { - try { - return parseCli(argv, { options: OPTIONS, stopAt: ["help"] }); - } catch (err) { - if (!(err instanceof CliError) || err.code === "missing-value") throw err; - throw new CliError(err.code, `Unknown argument: ${err.arg}`, { arg: err.arg }); - } -} - export function parseCommandLine(argv) { const args = { ...DEFAULTS }; - const cli = parse(argv); + const cli = parseCli(argv, { options: OPTIONS, stopAt: ["help"] }); // -h and --help are answered before any value is read, so a bad --port // before one does not stop it. `help` is present only then, which keeps the // options of every other command line equal to DEFAULTS. @@ -171,7 +162,7 @@ export function parseCommandLine(argv) { }); break; case "stallTimeout": { - // Not numberOption, which refuses blank: --stall-timeout= is 0. + // Seconds, fractions included; 0 disables the watchdog. const secs = Number(t.value); if (!Number.isFinite(secs) || secs < 0) { throw new CliError("bad-number", `--stall-timeout expects seconds (0 disables), got: ${t.value}`, diff --git a/docs/Documentation/Extending.md b/docs/Documentation/Extending.md index 5280a973..ab4d30a5 100644 --- a/docs/Documentation/Extending.md +++ b/docs/Documentation/Extending.md @@ -649,7 +649,7 @@ The split exists so that an edit confined to `docs/` usually has to pay for `che |---|---| | `0` | The checked thing is fine. | | `1` | The checked thing failed. This is the finding. | -| `2` | The harness or the environment failed --- an unknown argument, an absent tree, an unhandled throw. Nothing was checked. | +| `2` | The harness or the environment failed --- a command line the tool refuses (an unknown flag, a flag without its value or with an empty one, an unexpected argument), an absent tree, an unhandled throw. Nothing was checked. | Separating 1 from 2 is what stops a broken gate reading as a clean site, and it has to hold at the top level too. End the script with `main().catch((err) => { console.error(err); process.exit(2); })`, the way `check_a11y.mjs` does, so a crash cannot fall through to node's default exit 1 and be mistaken for a finding. A script that runs at top level, with no `main()`, calls `exitOnCrash()` from `scripts/lib/gate-probes.mjs` before it does anything else; that handler also catches a rejected top-level await. diff --git a/docs/Documentation/PDF-Generation.md b/docs/Documentation/PDF-Generation.md index 5bff3203..176bc5bb 100644 --- a/docs/Documentation/PDF-Generation.md +++ b/docs/Documentation/PDF-Generation.md @@ -153,7 +153,7 @@ The same fork checks fonts on the same principle: |---|---| | `0` | The PDF was written. | | `1` | A file the run needs is missing --- the input HTML, `lib/paged.browser.js`, `lib/progress-handler.js`, or an `--additional-script` path --- or the render threw. | -| `2` | Bad arguments: an unrecognised flag, or a missing `<input.html>` or `-o`. | +| `2` | Bad arguments: an unknown flag, a flag without its value or with an empty one, a second input file, or a missing `<input.html>` or `-o`. | **`book.bat` propagates all three.** It copies `%ERRORLEVEL%` into a variable immediately after the renderer runs and exits with that variable once `popd` has restored the caller's directory --- the same pattern `build.bat` and `check.bat` already used. A batch file's exit code is otherwise its last command's, and an unguarded `popd` resets `ERRORLEVEL` to `0`; `book.bat` used to end on a bare `popd`, so a failed render always reported success to whatever launched it. A script can check `book.bat`'s own exit code directly now. Calling `node book\render-book.mjs` directly and reading its exit code, or watching for the `saved:` line, remain equally valid. diff --git a/docs/Documentation/Pipeline-Stages.md b/docs/Documentation/Pipeline-Stages.md index f5abffbc..42e7d85e 100644 --- a/docs/Documentation/Pipeline-Stages.md +++ b/docs/Documentation/Pipeline-Stages.md @@ -970,9 +970,9 @@ The handler table is built from the imported `HANDLERS` constant: | Symbol | Signature | Description | |---|---|---| -| `OPTIONS` | `{ [flag]: { type } }` | `tbdocs`'s flags, in the table shape `lib/cli.mjs`'s `parseCli` reads: `"string"` for a flag that takes a value, `"boolean"` for the rest. `--no-check`, `--no-offline`, `--no-pdf` and `--no-fetch-assets` are flags of their own, not negations. | +| `OPTIONS` | `{ [flag]: { type, empty? } }` | `tbdocs`'s flags, in the table shape `lib/cli.mjs`'s `parseCli` reads: `"string"` for a flag that takes a value, `"boolean"` for the rest. `--baseurl` alone has `empty: true`, since an empty base URL is the site root. `--no-check`, `--no-offline`, `--no-pdf` and `--no-fetch-assets` are flags of their own, not negations. | | `DEFAULTS` | frozen `BuildOpts` | The options a build takes with no flags given --- the defaults in the `BuildOpts` table below. `fetchAssets` is left out. | -| `parseCommandLine` | `(argv) → BuildOpts` | Reads `argv` (without Node's own two entries) through `parseCli`, then applies the flags in the order given, so a `--no-check` undoes only the check flags before it. Throws a `CliError` whose message `main()` prints before it exits 4: `<flag> needs a value`, `Unknown argument: <arg>`, or a `--port` or `--stall-timeout` value that is not one. | +| `parseCommandLine` | `(argv) → BuildOpts` | Reads `argv` (without Node's own two entries) through `parseCli`, then applies the flags in the order given, so a `--no-check` undoes only the check flags before it. Throws a `CliError` whose message `main()` prints before it exits 4: `unknown option: <arg>`, `unexpected argument: <arg>`, `<flag> takes no value`, `<flag> needs a value`, `<flag> needs a non-empty value`, or a `--port` or `--stall-timeout` value that is not one. | ### `tbdocs.mjs` orchestrator diff --git a/docs/Documentation/Tools.md b/docs/Documentation/Tools.md index 0a9085bc..01f8c0ba 100644 --- a/docs/Documentation/Tools.md +++ b/docs/Documentation/Tools.md @@ -8,7 +8,7 @@ permalink: /Documentation/Development/Tools # Tools and Scripts {: .no_toc } -One-line-per-tool reference for every executable in the documentation repository: the seven Windows batch wrappers at the repository root, the Node and Python scripts under `scripts/` (cross-platform except for [`tbbuild.mjs`](#tbbuild), which drives the twinBASIC IDE), the `tbdocs` orchestrator and its CLI flags, and the PDF render driver. If you are looking for the day-to-day workflow rather than a cheat sheet, the [Building and Deployment](Building) page is the gentler read; if you are modifying the build pipeline itself, the [tbdocs Internals](Builder) page goes one level deeper. Every Node tool answers `--help` or `-h` by printing its usage to standard output and exiting 0, without doing any of its work. +One-line-per-tool reference for every executable in the documentation repository: the seven Windows batch wrappers at the repository root, the Node and Python scripts under `scripts/` (cross-platform except for [`tbbuild.mjs`](#tbbuild), which drives the twinBASIC IDE), the `tbdocs` orchestrator and its CLI flags, and the PDF render driver. If you are looking for the day-to-day workflow rather than a cheat sheet, the [Building and Deployment](Building) page is the gentler read; if you are modifying the build pipeline itself, the [tbdocs Internals](Builder) page goes one level deeper. Every Node tool answers `--help` or `-h` by printing its usage to standard output and exiting 0, without doing any of its work. A command line a tool cannot use is refused the same way by every Node tool: an unknown flag, a flag without its value or with an empty one, and an unexpected argument are reported on standard error and exit **2** (**4** in `tbdocs` and `check_links`, whose lower codes are a bitmask). A tool that reads its command line through `lib/cli.mjs` and takes search terms, file names or folder names takes one that starts with a dash after `--`. * TOC goes here {:toc} @@ -238,7 +238,7 @@ Full invocation: | `--serve` | Start the long-lived dev server (watch + rebuild + SSE live-reload). Offline and PDF passes are skipped each rebuild. | | `--port <N>` | HTTP port for `--serve` mode. Default: 4000. | -Exit codes: **0** clean; **1** a link failure, a failed build step, a fall in the page count, a symbol-index URL lost, or a crash; **2** an integrity failure; **3** both. A command-line error --- an unknown flag, a flag without its value, or a `--dest` the build refuses --- exits **4**, which no check can produce, so it is never read as a broken link. +Exit codes: **0** clean; **1** a link failure, a failed build step, a fall in the page count, a symbol-index URL lost, or a crash; **2** an integrity failure; **3** both. A command-line error --- an unknown flag, an unexpected argument, a flag without its value or with an empty one (`--baseurl` alone accepts one, meaning the site root), or a `--dest` the build refuses --- is reported on standard error and exits **4**, which no check can produce, so it is never read as a broken link. ### check_links.mjs {: #check-links } @@ -265,13 +265,13 @@ Offline (filesystem-only) link checker plus optional integrity checks. Multiple | `--check-canonical` | Assert each page's canonical URL matches its location. | | `--no-fail` | Downgrade failures to informational output (exit 0 even with broken links). | -Exit code 1 indicates broken links; exit code 2 indicates integrity-only failures (the integrity checks share the same SAX parse pass as link extraction). Exit code 4 is a command-line error --- no arguments, a flag without its value, no `--offline`, or no input --- and no check can produce it. The script dedupes `(target, fragment)` so each unique filesystem check fires exactly once regardless of how many pages link to the same target --- on the current tree (~733k link occurrences, ~12k unique targets across 1,127 HTML files / 124 MB) each pass runs in ~2.2 seconds on a development box. +Exit code 1 indicates broken links; exit code 2 indicates integrity-only failures (the integrity checks share the same SAX parse pass as link extraction). Exit code 4 is a command-line error --- no arguments, an unknown flag, a flag without its value or with an empty one, no `--offline`, or no input --- reported on standard error, and no check can produce it. The script dedupes `(target, fragment)` so each unique filesystem check fires exactly once regardless of how many pages link to the same target --- on the current tree (~733k link occurrences, ~12k unique targets across 1,127 HTML files / 124 MB) each pass runs in ~2.2 seconds on a development box. ### crawl_check.mjs node scripts/crawl_check.mjs <start-url> [--concurrency N] [--timeout MS] [--skip-external] -Online link crawler for the deployed site. Starts at `<start-url>`, GETs every same-origin / same-base-path page recursively, extracts every link the build's own check follows (`srcset` and `poster` included), and verifies that each link responds 2xx (HEAD for cross-origin, GET for same-origin). A request that fails before any response arrives, whether its connection is reset or it times out, is tried twice more, each time with the full `--timeout`, before its link is reported broken. The timeout covers a page's body as well as its headers. A page whose body breaks off, or is still arriving when the timeout runs out, is reported broken at once, without a retry, and the part that arrived is not parsed for links. Exits 0 if every link is reachable and every anchor exists, 1 if a link is broken or an anchor is missing, and 2 on a usage error or a crash. Use it after a manual `workflow_dispatch` deploy to verify the published site --- `check_links.mjs` covers the local filesystem; `crawl_check.mjs` covers the live deployed site. +Online link crawler for the deployed site. Starts at `<start-url>`, GETs every same-origin / same-base-path page recursively, extracts every link the build's own check follows (`srcset` and `poster` included), and verifies that each link responds 2xx (HEAD for cross-origin, GET for same-origin). A request that fails before any response arrives, whether its connection is reset or it times out, is tried twice more, each time with the full `--timeout`, before its link is reported broken. The timeout covers a page's body as well as its headers. A page whose body breaks off, or is still arriving when the timeout runs out, is reported broken at once, without a retry, and the part that arrived is not parsed for links. Exits 0 if every link is reachable and every anchor exists, 1 if a link is broken or an anchor is missing, and 2 on a usage error --- a missing start URL, an unknown flag, a flag without its value, or a second argument --- or a crash. Use it after a manual `workflow_dispatch` deploy to verify the published site --- `check_links.mjs` covers the local filesystem; `crawl_check.mjs` covers the live deployed site. ### check_a11y.mjs {: #check-a11y } @@ -554,9 +554,9 @@ Exits 1 on any failed probe, 2 if it cannot run. Verifies `lib/cli.mjs`, the module the tools read their command lines through, and each tool's recorded command-line errors. Nothing else tests how a tool reads its command line, which is how a value flag given no value came to be read as `NaN` or as the next flag. No built tree, no browser, no twinBASIC install; a few seconds. -The module's probes cover what `parseCli` returns and refuses, with a comparison against a strict `node:util` `parseArgs` over the same argument lists, and what `numberOption`, `withUsageError` and `printHelpAndExit` do. A tool that is more lenient than a strict parse today --- one that ignores an unknown flag, say --- keeps its leniency through two of `parseCli`'s parameters, and the probes cover those too. They also cover the options `builder/command-line.mjs` returns for `tbdocs`, where `--no-check` makes the order of the flags matter; no case can, since each of those command lines starts a build. +The module's probes cover what `parseCli` returns and refuses, with a comparison against a strict `node:util` `parseArgs` over the same argument lists, and what `numberOption`, `withUsageError` and `printHelpAndExit` do. The parse is strict for every tool: `parseCli` refuses an unknown option, a boolean flag given a value, a value flag with no value, a positional beyond the count the tool declares, and an empty value unless the option allows one --- only `tbdocs`'s `--baseurl` does. The probes cover each refusal and the `--` that ends the options. They also cover the options `builder/command-line.mjs` returns for `tbdocs`, where `--no-check` makes the order of the flags matter; no case can, since each of those command lines starts a build. -The recorded cases are invocations that stop while the tool reads its command line, or at its first check of the project, folder, file or install the command line names, each with its exit code and what it prints on each stream: the tool's own words for the error exactly, a crash's only by the line that names the problem, and the opening of a usage text printed after it. A tool's cases are recorded before it moves onto `lib/cli.mjs`, so the move has to keep them. Each case runs the tool as a child process, in an empty folder of its own and with `TB_IDE` and `PUPPETEER_EXECUTABLE_PATH` naming files that do not exist, so a case that gets past the command line fails on a different message rather than starting a twinBASIC IDE or a browser. A case belongs here only if the tool stops before doing any work. +The recorded cases are invocations that stop while the tool reads its command line, or at its first check of the project, folder, file or install the command line names, each with its exit code and what it prints on each stream: the tool's own words for the error exactly, a crash's only by the line that names the problem, and the opening of a usage text printed after it. Each case runs the tool as a child process, in an empty folder of its own and with `TB_IDE` and `PUPPETEER_EXECUTABLE_PATH` naming files that do not exist, so a case that gets past the command line fails on a different message rather than starting a twinBASIC IDE or a browser. A case belongs here only if the tool stops before doing any work. Exits 1 on any failed probe or case, 2 if it cannot run. diff --git a/docs/Documentation/Wisdom.md b/docs/Documentation/Wisdom.md index 38e8ade6..c294836e 100644 --- a/docs/Documentation/Wisdom.md +++ b/docs/Documentation/Wisdom.md @@ -114,7 +114,7 @@ Outputs raw JSON under `wisdom/data/raw/`. Supports incremental runs --- a manif | `--dry-run` | Discover channels/threads; do not fetch messages | | `--force` | Ignore manifest; re-fetch all history | -When the session request cap is reached, the tool exits with code 2 --- re-run to continue where it left off. +When the session request cap is reached, the tool exits with code 2 --- re-run to continue where it left off. A command line the tool cannot use --- an unknown command or flag, a flag without its value or with an empty one, an unexpected argument --- is refused on standard error and also exits with code 2, so read the message to tell the two apart. ### Phase 2 --- Process diff --git a/eval/README.md b/eval/README.md index 16c6cbd9..2d5b88e5 100644 --- a/eval/README.md +++ b/eval/README.md @@ -91,6 +91,11 @@ node eval/nav_hops.mjs '^/tB/Modules/ErrObject/Number$' # hops by link from docs node eval/nav_hops.mjs --from README.md '^/Documentation/Development/Tools$' ``` +Each of these refuses an unknown flag and a flag without its value on stderr and exits 2, and +`transcript.mjs` also refuses a second file. A search term, regex or file name that starts +with a dash goes after `--`: +`node eval/site_search.mjs -- "-1 as an error code"`. + ## Why an evaluator is a separate process Rounds 1--7 ran each evaluator as a subagent of the session orchestrating the round. **A diff --git a/eval/build_corpus.mjs b/eval/build_corpus.mjs index 3d0e581b..c4d844ea 100644 --- a/eval/build_corpus.mjs +++ b/eval/build_corpus.mjs @@ -103,10 +103,8 @@ function parseArgs(argv) { help: { type: "boolean", short: "h" }, }, positionals: 0, - unknown: "error", - acceptsValue: () => true, stopAt: ["help"], - }), { format: (err) => `unknown argument: ${err.arg}`, exitCode: 1 }); + })); return { src: "src" in values ? path.resolve(values.src) : REPO_ROOT, dest: "dest" in values ? path.resolve(values.dest) : null, @@ -211,7 +209,7 @@ if (opts.help || !opts.dest) { "Mirrors the repository with every non-prose file replaced by an unreadable\n" + "stub, so a documentation evaluation cannot silently read the implementation.\n" + "See eval/README.md.", - { exitCode: opts.help ? 0 : 1 }, + opts.help ? {} : { stream: "stderr", exitCode: 2 }, ); } build(opts); diff --git a/eval/nav_hops.mjs b/eval/nav_hops.mjs index c8489dce..100cb551 100644 --- a/eval/nav_hops.mjs +++ b/eval/nav_hops.mjs @@ -32,7 +32,7 @@ import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { parseFrontmatter } from "../lib/frontmatter.mjs"; import { blockRegions } from "../lib/markdown.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; @@ -42,20 +42,19 @@ const SITE_HOST = /^https?:\/\/docs\.twinbasic\.com/i; const USAGE = "Usage: node eval/nav_hops.mjs [--from <page>] [--src <root>] [-h, --help] <url-regex> [...]\n\n" + "Shortest path by links from the start page (default docs/index.md) to the first page\n" + - "whose permalink matches each regex. See eval/README.md."; + "whose permalink matches each regex. A regex that starts with a dash goes after --.\n" + + "See eval/README.md."; function parseArgs(argv) { - const { values, positionals } = parseCli(argv, { + const { values, positionals } = withUsageError(() => parseCli(argv, { options: { from: { type: "string", default: "docs/index.md" }, src: { type: "string" }, help: { type: "boolean", short: "h" }, }, - positionals: { max: Infinity }, - unknown: "positional", - acceptsValue: () => true, + positionals: { min: 0, max: Infinity }, stopAt: ["help"], - }); + })); return { from: values.from, src: "src" in values ? path.resolve(values.src) : REPO_ROOT, @@ -123,7 +122,8 @@ function resolve(pages, from, href) { async function main(argv) { const o = parseArgs(argv); - if (o.help || !o.targets.length) return printHelpAndExit(USAGE, { exitCode: o.help ? 0 : 2 }); + if (o.help) return printHelpAndExit(USAGE); + if (!o.targets.length) return printHelpAndExit(USAGE, { stream: "stderr", exitCode: 2 }); // Git Bash turns an argument that looks like a POSIX path into a Windows one, // so '^/tB/Core/Open$' arrives as '^C:/Program Files/Git/tB/Core/Open$', and // every target then reports as unreachable, which reads as a finding. diff --git a/eval/protocol.md b/eval/protocol.md index 18de3f4b..0557e65c 100644 --- a/eval/protocol.md +++ b/eval/protocol.md @@ -31,7 +31,7 @@ logic: Run it as a command of its own, exactly like that: it is on your PATH and works from any directory. Chained after a `cd`, or piped into another command, it is refused, as every -other shell command is. +other shell command is. A search term that starts with a dash goes after `--`. It prints ranked results as title + URL + snippet. A URL like `/Documentation/Development/Extending#adding-a-pipeline-task` corresponds to the corpus file diff --git a/eval/run_case.mjs b/eval/run_case.mjs index 6eb32107..ff537c2d 100644 --- a/eval/run_case.mjs +++ b/eval/run_case.mjs @@ -92,10 +92,8 @@ function parseArgs(argv) { help: { type: "boolean", short: "h" }, }, positionals: 0, - unknown: "error", - acceptsValue: () => true, stopAt: ["help"], - }), { format: (err) => `unknown argument: ${err.arg}`, exitCode: 2 }); + })); const o = { corpus: "corpus" in values ? path.resolve(values.corpus) : undefined, site: "site" in values ? path.resolve(values.site) : undefined, @@ -262,7 +260,8 @@ async function main(argv) { const o = parseArgs(argv); const complete = o.corpus && o.site && o.out && (o.smoke || (o.goal && ["repo", "site"].includes(o.protocol))); - if (o.help || !complete) return printHelpAndExit(USAGE, { exitCode: o.help ? 0 : 2 }); + if (o.help) return printHelpAndExit(USAGE); + if (!complete) return printHelpAndExit(USAGE, { stream: "stderr", exitCode: 2 }); const cwd = o.protocol === "site" ? path.join(o.corpus, "docs") : o.corpus; const needed = [cwd, path.join(o.site, "assets/js/search-data.json"), path.join(o.site, "assets/js/vendor/lunr.min.js")]; diff --git a/eval/search_quality.mjs b/eval/search_quality.mjs index e467a908..233c65bc 100644 --- a/eval/search_quality.mjs +++ b/eval/search_quality.mjs @@ -16,8 +16,9 @@ // node eval/search_quality.mjs --sample 500 # fast iteration // node eval/search_quality.mjs --failures 20 # queries not at rank 1 // -// Exit code is always 0: this is a measuring tool, not a pass/fail check -// (scripts/ is for those). +// Exit code is 0 whatever the measurement finds: this is a measuring tool, +// not a pass/fail check (scripts/ is for those). A command line it refuses +// exits 2, and a site with no search index 1. // // GROUND TRUTH // @@ -144,10 +145,8 @@ function parseArgs(argv) { help: { type: "boolean", short: "h" }, }, positionals: 0, - unknown: "error", - acceptsValue: () => true, stopAt: ["help"], - }), { format: (err) => `unrecognised argument: ${err.arg}`, exitCode: 1 }); + })); return { site: "site" in values ? path.resolve(values.site) : path.join(REPO_ROOT, "docs/_site"), save: "save" in values ? path.resolve(values.save) : null, diff --git a/eval/site_search.mjs b/eval/site_search.mjs index f5a97adc..ef6b59f6 100644 --- a/eval/site_search.mjs +++ b/eval/site_search.mjs @@ -25,24 +25,22 @@ import { createRequire } from "node:module"; import { pathToFileURL } from "node:url"; import fs from "node:fs"; import path from "node:path"; -import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; const require = createRequire(import.meta.url); function parseArgs(argv) { - const { values, positionals } = parseCli(argv, { + const { values, positionals } = withUsageError(() => parseCli(argv, { options: { site: { type: "string" }, n: { type: "string" }, composition: { type: "boolean" }, help: { type: "boolean", short: "h" }, }, - positionals: { max: Infinity }, - unknown: "positional", - acceptsValue: () => true, + positionals: { min: 0 }, stopAt: ["help"], - }); + })); return { site: "site" in values ? path.resolve(values.site) : path.join(REPO_ROOT, "docs/_site"), n: "n" in values ? Number(values.n) : 8, @@ -532,8 +530,8 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) 'Usage: node eval/site_search.mjs "<query>" [--n <count>] [--site <path>] [-h, --help]\n' + " node eval/site_search.mjs --composition\n\n" + "Queries the built site's real lunr index with the real query logic.\n" + - "See eval/README.md.", - { exitCode: opts.help ? 0 : 1 }, + "A term that starts with a dash goes after --. See eval/README.md.", + opts.help ? {} : { stream: "stderr", exitCode: 2 }, ); } diff --git a/eval/transcript.mjs b/eval/transcript.mjs index f8576201..0b079d59 100644 --- a/eval/transcript.mjs +++ b/eval/transcript.mjs @@ -24,7 +24,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; /** Every event in a stream-json session, in order. */ export function readTranscript(file) { @@ -191,25 +191,21 @@ export function printDigest(s, { calls = false, report = false } = {}) { const USAGE = "Usage: node eval/transcript.mjs <case.jsonl> [--calls] [--report] [-h, --help]\n\n" + "Summarises an evaluator's session and audits the order of its channels.\n" + - "See eval/README.md."; + "A file name that starts with a dash goes after --. See eval/README.md."; function main(argv) { - const { values, positionals } = parseCli(argv, { + const { values, positionals } = withUsageError(() => parseCli(argv, { options: { calls: { type: "boolean", default: false }, report: { type: "boolean", default: false }, - // -h and --help print the usage and exit 0. An unknown flag is a - // positional, and the file is the first positional that does not start - // with --; with none, the usage is printed and the exit is 1. help: { type: "boolean", short: "h" }, }, - positionals: { max: Infinity }, - unknown: "positional", + positionals: { min: 0, max: 1 }, stopAt: ["help"], - }); + })); if (values.help) printHelpAndExit(USAGE); - const file = positionals.find((a) => !a.startsWith("--")); - if (!file) printHelpAndExit(USAGE, { exitCode: 1 }); + const [file] = positionals; + if (!file) printHelpAndExit(USAGE, { stream: "stderr", exitCode: 2 }); printDigest(summarize(readTranscript(file)), { calls: values.calls, report: values.report }); } diff --git a/lib/cli.mjs b/lib/cli.mjs index 59bfe0c8..c3c6bc68 100644 --- a/lib/cli.mjs +++ b/lib/cli.mjs @@ -2,21 +2,19 @@ // // parseCli() reads an argument list against a table of options and either // returns what it found or throws a CliError that names the problem. A tool -// turns that error into its own message with withUsageError(), so moving a tool -// onto this module changes nothing it prints. numberOption() reads a number -// that has to be one, and printHelpAndExit() prints a usage text. +// prints that error and exits with withUsageError(). numberOption() reads a +// number that has to be one, and printHelpAndExit() prints a usage text. // -// The defaults are strict: an unknown option, a value flag with no value, and a -// positional the tool does not take are all errors. A tool that is more lenient -// today keeps its leniency through two parameters, `unknown` and -// `acceptsValue`, until Phase 3 of builder/PLAN-TOOLING-REVIEW.md makes every -// tool strict and removes them. +// The parse is strict: an unknown option, a boolean given a value, a value flag +// with no value, an empty value and a positional the tool does not take are all +// errors. A value that starts with a dash is given inline, as `--port=-5`, and +// a positional that starts with one after `--`. // // parseArgs runs loose, and this module makes a strict parse's checks itself, -// from the tokens: a strict parse stops at the first unknown option, so it -// cannot ignore one, and a loose parse alone takes a trailing value flag as -// `true`. With the defaults, parseCli refuses what a strict parse refuses and -// returns what it returns; the probes compare the two. +// from the tokens: a strict parse stops at the first unknown option and takes +// an empty value, and a loose parse alone takes a trailing value flag as +// `true`. Apart from the empty value, parseCli refuses what a strict parse +// refuses and returns what it returns; the probes compare the two. // // scripts/check_cli.mjs carries the probes for this module, and the recorded // command-line cases of the tools that use it. @@ -37,98 +35,74 @@ export class CliError extends Error { // characters, the first a dash. So `-` alone is a value, and so is "". const isOptionLike = (v) => v.length > 1 && v[0] === "-"; -// The default for acceptsValue: a value flag takes the next argument unless it -// looks like an option, as a strict parse does; given inline, as `--port=-5`, -// any value is taken. An argument list that ends at the flag gives it none. -const strictValue = (value, inline) => value !== undefined && (inline || !isOptionLike(value)); +// Whether a value flag was given a value: it takes the next argument unless +// that looks like an option, as a strict parse does; given inline, as +// `--port=-5`, any value is taken. An argument list that ends at the flag gives +// it none. +const hasValue = (value, inline) => value !== undefined && (inline || !isOptionLike(value)); const camelCase = (name) => name.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase()); /** * Parses `argv` (without Node's own two entries) against `options`, a table in * parseArgs' shape keyed by long name: `{ type: "boolean" | "string", short, - * multiple, default }`. + * multiple, default, empty }`. * - * Returns `{ values, positionals, tokens, ignored, stopped }`. `values` is keyed by the + * Returns `{ values, positionals, tokens, stopped }`. `values` is keyed by the * camelCase of each long name (`--root-dir` is `rootDir`); an absent option * takes its `default`, a `multiple` one with no default is `[]`, and any other * absent option is missing from `values`. A repeated option that is not * `multiple` keeps its last value. `tokens` are parseArgs' tokens for the * options, positionals and `--` that were kept, in order, each option's with - * its `key`, for a tool whose flags depend on their order. `ignored` lists the - * arguments dropped under `unknown: "ignore"`, as given. + * its `key`, for a tool whose flags depend on their order. * * `positionals` is how many the tool takes: a number, or `{ min, max }`; 0 by - * default. `unknown` is what happens to an unknown option, a boolean given a - * value, and a positional beyond `max`: "error" (the default) throws; "ignore" - * drops it into `ignored`; "positional" makes an unknown option a positional, - * as given, and counts it. `acceptsValue(value, inline)` decides whether a - * value flag was given a value: `value` is the argument after the flag, or - * undefined at the end of the list, and `inline` is true for `--flag=value`. - * A value it accepts is stored as it is, undefined included. `stopAt` names - * options that end the parse where they stand, as a hand-written loop that - * answers `--help` or `--list` on the spot does: nothing after the first of - * them is read or checked, the positional count included, and `stopped` in the - * result is its key. + * default. A string option's value may not be empty, `--x ""` or `--x=`, unless + * its spec says `empty: true`. `stopAt` names options that end the parse where + * they stand, as a hand-written loop that answers `--help` or `--list` on the + * spot does: nothing after the first of them is read or checked, the positional + * count included, and `stopped` in the result is its key. * * Throws a CliError whose code is "unknown-option", "unexpected-value", - * "missing-value", "unexpected-positional" or "missing-positional", with - * `option` (the flag as typed, `--port` or `-p`), `arg` (the argument as given) - * and `value` where they apply. + * "missing-value", "empty-value", "unexpected-positional" or + * "missing-positional", with `option` (the flag as typed, `--port` or `-p`), + * `arg` (the argument as given) and `value` where they apply. */ -export function parseCli(argv, { options = {}, positionals = 0, unknown = "error", acceptsValue = strictValue, stopAt = [] } = {}) { - if (!["error", "ignore", "positional"].includes(unknown)) { - throw new TypeError(`parseCli: unknown must be "error", "ignore" or "positional", not ${JSON.stringify(unknown)}`); - } +export function parseCli(argv, { options = {}, positionals = 0, stopAt = [] } = {}) { const { min, max } = typeof positionals === "number" ? { min: positionals, max: positionals } : { min: 0, max: Infinity, ...positionals }; const table = {}; - for (const [name, { default: _, ...spec }] of Object.entries(options)) table[name] = spec; + for (const [name, { default: _default, empty: _empty, ...spec }] of Object.entries(options)) table[name] = spec; const { tokens } = parseArgs({ args: argv, options: table, strict: false, allowPositionals: true, tokens: true }); const values = {}; const kept = []; - const ignored = []; const found = []; let stopped; - // A short-option group, -abc, is one token per letter with one index: an - // argument is dropped or made a positional once, however many it holds. - const seen = new Set(); - const once = (list, t) => { if (!seen.has(t.index)) { seen.add(t.index); list.push(argv[t.index]); } }; - const notOurs = (t, error) => { - if (unknown === "error") throw error; - once(unknown === "ignore" ? ignored : found, t); - }; for (const t of tokens) { if (t.kind === "option-terminator") { kept.push(t); continue; } if (t.kind === "positional") { - if (found.length >= max && unknown !== "positional") { - notOurs(t, new CliError("unexpected-positional", `unexpected argument: ${t.value}`, { arg: t.value })); - continue; - } + if (found.length >= max) throw new CliError("unexpected-positional", `unexpected argument: ${t.value}`, { arg: t.value }); found.push(t.value); kept.push(t); continue; } const spec = options[t.name]; const arg = argv[t.index]; - if (!spec) { - notOurs(t, new CliError("unknown-option", `unknown option: ${arg}`, { option: t.rawName, arg })); - continue; - } + if (!spec) throw new CliError("unknown-option", `unknown option: ${arg}`, { option: t.rawName, arg }); const key = camelCase(t.name); let value; if (spec.type === "boolean") { - if (t.inlineValue) { - notOurs(t, new CliError("unexpected-value", `${t.rawName} takes no value`, { option: t.rawName, arg, value: t.value })); - continue; - } + if (t.inlineValue) throw new CliError("unexpected-value", `${t.rawName} takes no value`, { option: t.rawName, arg, value: t.value }); value = true; } else { - if (!acceptsValue(t.value, Boolean(t.inlineValue))) { + if (!hasValue(t.value, Boolean(t.inlineValue))) { throw new CliError("missing-value", `${t.rawName} needs a value`, { option: t.rawName, arg, value: t.value }); } + if (t.value === "" && !spec.empty) { + throw new CliError("empty-value", `${t.rawName} needs a non-empty value`, { option: t.rawName, arg, value: t.value }); + } value = t.value; } if (spec.multiple) (values[key] ??= []).push(value); @@ -137,9 +111,6 @@ export function parseCli(argv, { options = {}, positionals = 0, unknown = "error if (stopAt.includes(t.name)) { stopped = key; break; } } - if (found.length > max && !stopped) { - throw new CliError("unexpected-positional", `unexpected argument: ${found[max]}`, { arg: found[max] }); - } if (found.length < min && !stopped) { throw new CliError("missing-positional", `expected at least ${min} argument${min === 1 ? "" : "s"}, got ${found.length}`, { count: found.length }); } @@ -149,7 +120,7 @@ export function parseCli(argv, { options = {}, positionals = 0, unknown = "error if ("default" in spec) values[key] = spec.default; else if (spec.multiple) values[key] = []; } - return { values, positionals: found, tokens: kept, ignored, stopped }; + return { values, positionals: found, tokens: kept, stopped }; } /** diff --git a/scripts/addin_test.mjs b/scripts/addin_test.mjs index d124f7b1..4c83aa35 100644 --- a/scripts/addin_test.mjs +++ b/scripts/addin_test.mjs @@ -53,7 +53,7 @@ import { existsSync, mkdirSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { removeTree } from "./lib/tb-ide-copy.mjs"; import { wantShow } from "./lib/tb-ide.mjs"; import { buildNumber, findIde } from "./lib/tb-install.mjs"; @@ -78,7 +78,7 @@ process of its own with its own IDE copy, DevTools port and work folder. --show, --hide as tbbuild's -h, --help print this text and exit`; -const { values } = parseCli(process.argv.slice(2), { +const { values } = withUsageError(() => parseCli(process.argv.slice(2), { options: { only: { type: "string" }, port: { type: "string" }, @@ -89,11 +89,8 @@ const { values } = parseCli(process.argv.slice(2), { hide: { type: "boolean", default: false }, help: { type: "boolean", short: "h", default: false }, }, - unknown: "ignore", - positionals: 0, - acceptsValue: () => true, stopAt: ["help"], -}); +})); if (values.help) printHelpAndExit(USAGE); const die = (code, msg) => { console.error(msg); process.exit(code); }; const only = values.only ? new RegExp(values.only) : null; diff --git a/scripts/build_dot_metrics.mjs b/scripts/build_dot_metrics.mjs index da1ad5df..b9cb082c 100644 --- a/scripts/build_dot_metrics.mjs +++ b/scripts/build_dot_metrics.mjs @@ -36,7 +36,7 @@ import path from "node:path"; import { withBrowser } from "./lib/browser.mjs"; import { exitOnCrash } from "./lib/gate-probes.mjs"; import { openInterPage } from "./lib/inter-page.mjs"; -import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; // A crash exits 2, where 1 is --check finding the table stale. @@ -65,11 +65,10 @@ const VARIANTS = [ { key: "boldItalic", weight: 700, style: "italic" }, ]; -const cli = parseCli(process.argv.slice(2), { +const cli = withUsageError(() => parseCli(process.argv.slice(2), { options: { check: { type: "boolean" }, help: { type: "boolean", short: "h" } }, - unknown: "ignore", stopAt: ["help"], -}); +})); if (cli.values.help) printHelpAndExit(USAGE); const check = cli.values.check === true; diff --git a/scripts/build_package_api.mjs b/scripts/build_package_api.mjs index 3c6b3c90..0962a363 100644 --- a/scripts/build_package_api.mjs +++ b/scripts/build_package_api.mjs @@ -76,8 +76,6 @@ const { values } = withUsageError(() => check: { type: "boolean", default: false }, help: { type: "boolean", short: "h", default: false }, }, - unknown: "ignore", - positionals: 0, stopAt: ["help"], })); if (values.help) printHelpAndExit(USAGE); diff --git a/scripts/census_attributes.mjs b/scripts/census_attributes.mjs index a9667263..d2215c18 100644 --- a/scripts/census_attributes.mjs +++ b/scripts/census_attributes.mjs @@ -87,8 +87,6 @@ const { values } = withUsageError(() => quiet: { type: "boolean", default: false }, help: { type: "boolean", short: "h", default: false }, }, - unknown: "ignore", - positionals: 0, stopAt: ["help"], })); const die = (code, msg) => { console.error(msg); process.exit(code); }; diff --git a/scripts/check_a11y.mjs b/scripts/check_a11y.mjs index 1472181a..8140b106 100644 --- a/scripts/check_a11y.mjs +++ b/scripts/check_a11y.mjs @@ -85,10 +85,8 @@ const { values } = withUsageError( minified: { type: "boolean", default: false }, help: { type: "boolean", short: "h" }, }, - acceptsValue: Boolean, stopAt: ["help"], }), - { format: (err) => `unknown arg: ${err.arg}` }, ); if (values.help) printHelpAndExit(USAGE); let rootDir = values.rootDir; diff --git a/scripts/check_a11y_fingerprint.mjs b/scripts/check_a11y_fingerprint.mjs index b226ad1c..8831be78 100644 --- a/scripts/check_a11y_fingerprint.mjs +++ b/scripts/check_a11y_fingerprint.mjs @@ -93,10 +93,8 @@ const cli = withUsageError( list: { type: "boolean" }, help: { type: "boolean", short: "h" }, }, - acceptsValue: Boolean, stopAt: ["list", "help"], }), - { format: (err) => `unknown arg: ${err.arg}` }, ); if (cli.stopped === "list") { diff --git a/scripts/check_axe_patch_equiv.mjs b/scripts/check_axe_patch_equiv.mjs index 3af15135..9524650c 100644 --- a/scripts/check_axe_patch_equiv.mjs +++ b/scripts/check_axe_patch_equiv.mjs @@ -44,10 +44,8 @@ const cli = withUsageError( patch: { type: "string", default: "plain-color-fields" }, help: { type: "boolean", short: "h" }, }, - acceptsValue: Boolean, stopAt: ["help"], }), - { format: (err) => `unknown arg: ${err.arg}` }, ); if (cli.stopped === "help") { printHelpAndExit("usage: node scripts/check_axe_patch_equiv.mjs [--patch NAME] [-h, --help]"); diff --git a/scripts/check_book_coverage.mjs b/scripts/check_book_coverage.mjs index 46390bef..c3ecd13f 100644 --- a/scripts/check_book_coverage.mjs +++ b/scripts/check_book_coverage.mjs @@ -20,7 +20,7 @@ // node scripts/check_book_coverage.mjs import { resolveBookChapters, bookCoverage, formatBookCoverage } from "../builder/book.mjs"; -import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { createProbes, exitOnCrash } from "./lib/gate-probes.mjs"; exitOnCrash(); @@ -32,13 +32,10 @@ against pages and a manifest built in memory. -h, --help print this text and exit`; -// Every other argument is ignored. -if (parseCli(process.argv.slice(2), { +if (withUsageError(() => parseCli(process.argv.slice(2), { options: { help: { type: "boolean", short: "h" } }, - unknown: "ignore", - positionals: { min: 0, max: 0 }, stopAt: ["help"], -}).values.help) printHelpAndExit(USAGE); +})).values.help) printHelpAndExit(USAGE); const page = (srcRel, permalink, title, frontmatter = {}) => ({ srcRel, permalink, navPath: title, frontmatter: { title, permalink, ...frontmatter }, diff --git a/scripts/check_ci_workflows.mjs b/scripts/check_ci_workflows.mjs index 082d5543..f6542835 100644 --- a/scripts/check_ci_workflows.mjs +++ b/scripts/check_ci_workflows.mjs @@ -31,7 +31,7 @@ import path from "node:path"; import yaml from "js-yaml"; import { exitOnCrash } from "./lib/gate-probes.mjs"; import { buildArgs, gateSteps, workflowSteps } from "./lib/gate-roster.mjs"; -import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; exitOnCrash(); @@ -43,13 +43,10 @@ arguments and in the same order. -h, --help print this text and exit`; -// Every other argument is ignored. -if (parseCli(process.argv.slice(2), { +if (withUsageError(() => parseCli(process.argv.slice(2), { options: { help: { type: "boolean", short: "h" } }, - unknown: "ignore", - positionals: { min: 0, max: 0 }, stopAt: ["help"], -}).values.help) printHelpAndExit(USAGE); +})).values.help) printHelpAndExit(USAGE); const JOB = "build"; const WORKFLOWS = ["checks.yml", "tbdocs-gh-pages.yml"]; diff --git a/scripts/check_cli.mjs b/scripts/check_cli.mjs index c4d5f1bc..e104aea0 100644 --- a/scripts/check_cli.mjs +++ b/scripts/check_cli.mjs @@ -1,4 +1,4 @@ -// Self-test for lib/cli.mjs, the command-line parser the tools move onto, and +// Self-test for lib/cli.mjs, the command-line parser every tool reads through, and // the tools' own command-line cases. // // node scripts/check_cli.mjs @@ -14,9 +14,8 @@ // makes the order of its flags matter. // // The recorded cases: invocations that stop while the tool reads its command -// line, each with the exit code and the text printed on each stream. A tool's -// cases are recorded from its behaviour before it moves onto lib/cli.mjs, so -// the move has to keep them. A case pins the tool's own words for the error +// line, each with the exit code and the text printed on each stream. A case +// pins the tool's own words for the error // exactly; a usage text printed after it is matched by its opening, so adding // a flag to a tool's usage does not fail this gate. A string is the whole // stream; a RegExp must match it; a stream a case does not name must be empty. @@ -53,13 +52,10 @@ cases of every tool, each in an empty folder with no IDE and no browser. -h, --help print this text and exit`; -// Every other argument is ignored. -if (parseCli(process.argv.slice(2), { +if (withUsageError(() => parseCli(process.argv.slice(2), { options: { help: { type: "boolean", short: "h" } }, - unknown: "ignore", - positionals: { min: 0, max: 0 }, stopAt: ["help"], -}).values.help) printHelpAndExit(USAGE); +})).values.help) printHelpAndExit(USAGE); const { check, report } = createProbes("check_cli"); const show = (x) => JSON.stringify(x); @@ -122,21 +118,18 @@ const OPTIONS = { check("a positional the tool does not take is an error", extra?.arg === "a" && extra.message === "unexpected argument: a", show(extra)); } { - const spec = { options: OPTIONS, positionals: { max: 1 }, unknown: "ignore" }; - const r = parseCli(["--bogus", "x", "t", "--verbose=1", "--port", "80", "-q", "u"], spec); - check("unknown: ignore drops unknown options, a boolean's value and surplus positionals, in order", - show(r.values) === show({ port: "80", forbid: [] }) && show(r.positionals) === show(["x"]) - && show(r.ignored) === show(["--bogus", "t", "--verbose=1", "-q", "u"]), - show(r)); - const err = cliError(() => parseCli(["--bogus", "--port"], spec), "missing-value"); - check("unknown: ignore still refuses a value flag with no value", err?.option === "--port", show(err)); -} -{ - const r = parseCli(["--x", "-5", "-ab", "t", "--verbose", "--y=1"], { options: OPTIONS, positionals: { max: Infinity }, unknown: "positional" }); - check("unknown: positional makes each unknown option one positional, as given", - show(r.positionals) === show(["--x", "-5", "-ab", "t", "--y=1"]) && r.values.verbose === true, show(r)); - const err = cliError(() => parseCli(["--x", "t"], { options: OPTIONS, positionals: 1, unknown: "positional" }), "unexpected-positional"); - check("unknown: positional counts them", err?.arg === "t", show(err)); + const spec = { options: OPTIONS, positionals: { max: Infinity } }; + const bare = cliError(() => parseCli(["a", "--x", "b"], spec), "unknown-option"); + check("an unknown option is refused even where positionals are taken", bare?.option === "--x", show(bare)); + const short = cliError(() => parseCli(["-ab"], spec), "unknown-option"); + check("a short option cluster with an unknown letter is refused", short?.option === "-a", show(short)); + const r = parseCli(["--", "--x", "-5", "-ab", "--y=1"], spec); + check("after -- an argument that starts with a dash is a positional", + show(r.positionals) === show(["--x", "-5", "-ab", "--y=1"]), show(r)); + const one = cliError(() => parseCli(["a", "b"], { options: OPTIONS, positionals: 1 }), "unexpected-positional"); + check("a positional beyond max is refused, naming it", one?.arg === "b", show(one)); + const value = cliError(() => parseCli(["--verbose=1", "--port"], spec), "unexpected-value"); + check("the first fault is the one reported", value?.option === "--verbose", show(value)); } { const few = cliError(() => parseCli([], { options: OPTIONS, positionals: 1 }), "missing-positional"); @@ -156,26 +149,49 @@ const OPTIONS = { show(r.tokens.map((t) => t.key ?? t.value)) === show(["noCheck", "x", "check", "verbose"]), show(r.tokens)); } { - const truthy = { options: OPTIONS, acceptsValue: (v) => Boolean(v) }; - check("acceptsValue can take a flag as a value", parseCli(["--port", "--verbose"], truthy).values.port === "--verbose"); - check("acceptsValue can refuse an empty value", Boolean(cliError(() => parseCli(["--port", ""], truthy), "missing-value"))); - const any = parseCli(["--port"], { options: { port: { type: "string", default: "d" } }, acceptsValue: () => true }).values; - check("a value acceptsValue takes is stored as it is, undefined included", "port" in any && any.port === undefined, show(any)); + const apart = cliError(() => parseCli(["--port", ""], { options: OPTIONS }), "empty-value"); + check("an empty value given separately is refused", apart?.option === "--port" && apart.value === "" && apart.message === "--port needs a non-empty value", + show(apart)); + const inline = cliError(() => parseCli(["--port="], { options: OPTIONS }), "empty-value"); + check("an empty value given inline is refused", inline?.arg === "--port=" && inline.message === "--port needs a non-empty value", show(inline)); + const short = cliError(() => parseCli(["-p", ""], { options: OPTIONS }), "empty-value"); + check("the empty-value error names the flag as typed", short?.message === "-p needs a non-empty value", show(short)); + const many = cliError(() => parseCli(["--forbid", "a", "--forbid", ""], { options: OPTIONS }), "empty-value"); + check("an empty value of a multiple option is refused", many?.option === "--forbid" && many.message === "--forbid needs a non-empty value", show(many)); + const manyInline = cliError(() => parseCli(["--forbid="], { options: OPTIONS }), "empty-value"); + check("an empty inline value of a multiple option is refused", Boolean(manyInline), show(manyInline)); + const first = cliError(() => parseCli(["--port", "--", "--forbid="], { options: OPTIONS }), "missing-value"); + check("a value that looks like an option is missing, not empty", first?.option === "--port", show(first)); +} +{ + const options = { ...OPTIONS, port: { type: "string", empty: true }, forbid: { type: "string", multiple: true, empty: true } }; + check("empty: true accepts an empty value, separate or inline", + parseCli(["--port", ""], { options }).values.port === "" && parseCli(["--port="], { options }).values.port === ""); + check("empty: true accepts empty values of a multiple option", + show(parseCli(["--forbid=", "--forbid", "", "--forbid", "x"], { options }).values.forbid) === show(["", "", "x"])); + const missing = cliError(() => parseCli(["--port"], { options }), "missing-value"); + check("empty: true does not excuse a missing value", Boolean(missing), show(missing)); + const other = cliError(() => parseCli(["--root-dir", ""], { options }), "empty-value"); + check("empty: true is per option", other?.option === "--root-dir", show(other)); + check("empty is not passed to parseArgs", caught(() => parseCli([], { options })) === null); } { const spec = { options: { ...OPTIONS, help: { type: "boolean", short: "h" } }, positionals: 1, stopAt: ["help"] }; - const r = parseCli(["--port", "80", "-h", "--bogus", "--port"], spec); + const r = parseCli(["--port", "80", "-h", "--bogus", "--port", "--verbose=1", "a", "b"], spec); check("stopAt ends the parse at the option, reading nothing after it and counting no positionals", - r.stopped === "help" && r.values.port === "80" && r.values.help === true, show(r)); + r.stopped === "help" && r.values.port === "80" && r.values.help === true && r.positionals.length === 0, show(r)); check("stopAt does not excuse an error before the option", Boolean(cliError(() => parseCli(["--bogus", "--help"], spec), "unknown-option"))); + check("stopAt does not excuse an empty value before the option", + Boolean(cliError(() => parseCli(["--port=", "--help"], spec), "empty-value"))); check("without its option, stopAt changes nothing", parseCli(["a"], spec).stopped === undefined && Boolean(cliError(() => parseCli([], spec), "missing-positional"))); } -check("unknown takes only its three values", caught(() => parseCli([], { unknown: "warn" })) instanceof TypeError); -// With the defaults, parseCli refuses what a strict parseArgs refuses, in the -// same kind, and otherwise returns the same values and positionals. +// parseCli refuses what a strict parseArgs refuses, in the same kind, and +// otherwise returns the same values and positionals. An empty value is the +// exception: a strict parseArgs takes it and parseCli refuses it, so the lists +// hold none, and the probes above cover it. { const KINDS = { ERR_PARSE_ARGS_UNKNOWN_OPTION: ["unknown-option"], @@ -197,8 +213,8 @@ check("unknown takes only its three values", caught(() => parseCli([], { unknown }; const LISTS = [ [], ["--verbose"], ["-v"], ["--port", "80"], ["--port=80"], ["-p", "80"], ["-p80"], ["-vp", "80"], ["-pv"], - ["--port"], ["-p"], ["--port", "--verbose"], ["--port", "-5"], ["--port=-5"], ["--port", "-"], ["--port", ""], - ["--port="], ["--port", "--"], ["--verbose=1"], ["--verbose="], ["--bogus"], ["--bogus=1"], ["-x"], ["-5"], ["--no-verbose"], + ["--port"], ["-p"], ["--port", "--verbose"], ["--port", "-5"], ["--port=-5"], ["--port", "-"], + ["--port", "--"], ["--verbose=1"], ["--verbose="], ["--bogus"], ["--bogus=1"], ["-x"], ["-5"], ["--no-verbose"], ["--forbid", "a", "--forbid", "b"], ["--port", "1", "--port", "2"], ["--root-dir", "x"], ["a"], ["a", "--verbose", "b"], ["--", "--verbose"], ["--port", "1", "--", "-x"], ]; @@ -210,7 +226,7 @@ check("unknown takes only its three values", caught(() => parseCli([], { unknown if (strict !== ours) differ.push(`${allow ? "" : "no positionals: "}${show(args)}\n parseArgs ${strict}\n parseCli ${ours}`); } } - check(`the defaults agree with a strict parseArgs on ${LISTS.length * 2} argument lists`, differ.length === 0, differ.join("\n")); + check(`parseCli agrees with a strict parseArgs on ${LISTS.length * 2} argument lists`, differ.length === 0, differ.join("\n")); } // ------------------------------------------------------------ numberOption @@ -284,7 +300,8 @@ function capture(fn) { parsed(["--no-check", "--check"]).check === true && parsed(["--check", "--no-check"]).check === false); check("tbdocs's last of --fetch-assets and --no-fetch-assets wins", parsed(["--fetch-assets", "--no-fetch-assets"]).fetchAssets === false && parsed(["--no-fetch-assets", "--fetch-assets"]).fetchAssets === true); - check("tbdocs reads --stall-timeout= as 0, disabling the watchdog", parsed(["--stall-timeout="]).stallTimeoutMs === 0); + check("tbdocs reads --stall-timeout 0 as disabling the watchdog", parsed(["--stall-timeout", "0"]).stallTimeoutMs === 0); + check("tbdocs reads --baseurl= as the site root, an empty value", parsed(["--baseurl="]).baseurl === "" && parsed(["--baseurl", ""]).baseurl === ""); check("tbdocs reads --stall-timeout in seconds", parsed(["--stall-timeout", "1.5"]).stallTimeoutMs === 1500); check("tbdocs takes --name=value for every value flag", pick(["--check-findings=f", "--symbol-gaps=g", "--port=81", "--dest=d"], ["check", "checkFindings", "symbolGaps", "port", "dest"]) @@ -300,14 +317,14 @@ const CASES = [ // tbdocs and check_links exits 4, outside the 1/2/3 bitmask of the link and // integrity checks, so it never reads as a broken link. A value flag has no // value at the end of the list or before another flag, and --port is a whole - // number from 1 to 65535. check_links prints on stdout, and requires a value - // only at the end: before a flag, it takes the flag as the value. + // number from 1 to 65535. check_links prints its errors on stderr, after + // "error: ". { tool: "builder/tbdocs.mjs", args: ["--port"], exit: 4, stderr: "--port needs a value\n" }, { tool: "builder/tbdocs.mjs", args: ["--dest", "--no-pdf"], exit: 4, stderr: "--dest needs a value\n" }, { tool: "builder/tbdocs.mjs", args: ["--port=0"], exit: 4, stderr: "--port expects a port number from 1 to 65535, got: 0\n" }, - { tool: "builder/tbdocs.mjs", args: ["--bogus"], exit: 4, stderr: "Unknown argument: --bogus\n" }, - { tool: "scripts/check_links.mjs", args: ["no-such-tree", "--root-dir"], exit: 4, stdout: "error: --root-dir requires a value\n" }, - { tool: "scripts/check_links.mjs", args: ["no-such-tree", "--forbid"], exit: 4, stdout: "error: --forbid requires a value\n" }, + { tool: "builder/tbdocs.mjs", args: ["--bogus"], exit: 4, stderr: "unknown option: --bogus\n" }, + { tool: "scripts/check_links.mjs", args: ["no-such-tree", "--root-dir"], exit: 4, stderr: "error: --root-dir needs a value\n" }, + { tool: "scripts/check_links.mjs", args: ["no-such-tree", "--forbid"], exit: 4, stderr: "error: --forbid needs a value\n" }, // Recorded in C47, from the behaviour C17 settled: in the four harness tools // that check, a value flag with no value, at the end or before another flag, @@ -321,50 +338,50 @@ const CASES = [ { tool: "scripts/build_package_api.mjs", args: ["--out"], exit: 2, stderr: "--out needs a value\n" }, { tool: "scripts/build_package_api.mjs", args: ["--src", "--check"], exit: 2, stderr: "--src needs a value\n" }, - // Recorded in C48, before the a11y and diagram tools moved onto lib/cli.mjs. - // A value flag given nothing, at the end or as "", reads as an unknown - // argument; one followed by another flag takes the flag as its value, so that - // is no case. Each of them answers --help on stdout with exit 0 (C71). + // Recorded in C48, for the a11y and diagram tools. A value flag given + // nothing, at the end or as "", or followed by another flag, is refused. + // Each of them answers --help on stdout with exit 0 (C71). // --theme and --viewport are checked against their lists (C20). - // check_dot_fit and build_dot_metrics ignore every argument but --help and -h, - // so neither has another case. + // check_dot_fit and build_dot_metrics take one flag of their own besides + // --help and -h, and neither has a case beyond those and the generated ones. { tool: "scripts/check_a11y.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/check_a11y\.mjs / }, - { tool: "scripts/check_a11y.mjs", args: ["--bogus"], exit: 2, stderr: "unknown arg: --bogus\n" }, - { tool: "scripts/check_a11y.mjs", args: ["--root-dir"], exit: 2, stderr: "unknown arg: --root-dir\n" }, - { tool: "scripts/check_a11y.mjs", args: ["--theme", ""], exit: 2, stderr: "unknown arg: --theme\n" }, + { tool: "scripts/check_a11y.mjs", args: ["--bogus"], exit: 2, stderr: "unknown option: --bogus\n" }, + { tool: "scripts/check_a11y.mjs", args: ["--root-dir"], exit: 2, stderr: "--root-dir needs a value\n" }, + { tool: "scripts/check_a11y.mjs", args: ["--root-dir", "--theme", "dark"], exit: 2, stderr: "--root-dir needs a value\n" }, + { tool: "scripts/check_a11y.mjs", args: ["--theme", ""], exit: 2, stderr: "--theme needs a non-empty value\n" }, { tool: "scripts/check_a11y.mjs", args: ["--theme", "drak"], exit: 2, stderr: 'unknown --theme "drak"; expected one of light, dark or both\n' }, { tool: "scripts/check_a11y.mjs", args: ["--viewport", "huge"], exit: 2, stderr: 'unknown --viewport "huge"; expected one of desktop, mobile or both\n' }, { tool: "scripts/check_a11y_fingerprint.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/check_a11y_fingerprint\.mjs / }, - { tool: "scripts/check_a11y_fingerprint.mjs", args: ["--bogus"], exit: 2, stderr: "unknown arg: --bogus\n" }, - { tool: "scripts/check_a11y_fingerprint.mjs", args: ["--pages"], exit: 2, stderr: "unknown arg: --pages\n" }, + { tool: "scripts/check_a11y_fingerprint.mjs", args: ["--bogus"], exit: 2, stderr: "unknown option: --bogus\n" }, + { tool: "scripts/check_a11y_fingerprint.mjs", args: ["--pages"], exit: 2, stderr: "--pages needs a value\n" }, { tool: "scripts/check_a11y_fingerprint.mjs", args: ["--theme", "drak"], exit: 2, stderr: 'unknown --theme "drak"; expected one of light, dark or both\n' }, { tool: "scripts/check_axe_patch_equiv.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/check_axe_patch_equiv\.mjs / }, - { tool: "scripts/check_axe_patch_equiv.mjs", args: ["--bogus"], exit: 2, stderr: "unknown arg: --bogus\n" }, - { tool: "scripts/check_axe_patch_equiv.mjs", args: ["--patch"], exit: 2, stderr: "unknown arg: --patch\n" }, + { tool: "scripts/check_axe_patch_equiv.mjs", args: ["--bogus"], exit: 2, stderr: "unknown option: --bogus\n" }, + { tool: "scripts/check_axe_patch_equiv.mjs", args: ["--patch"], exit: 2, stderr: "--patch needs a value\n" }, { tool: "scripts/check_pdf_shims_equiv.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/check_pdf_shims_equiv\.mjs\n/ }, { tool: "scripts/check_pdf_shims_equiv.mjs", args: ["--bogus"], exit: 2, stderr: "unknown option: --bogus\n" }, { tool: "scripts/check_impexp_parity.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/check_impexp_parity\.mjs\n/ }, { tool: "scripts/check_impexp_parity.mjs", args: ["--bogus"], exit: 2, stderr: "unknown option: --bogus\n" }, { tool: "scripts/check_tree_fresh.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/check_tree_fresh\.mjs / }, - { tool: "scripts/check_tree_fresh.mjs", args: ["--bogus"], exit: 2, stderr: "unknown arg: --bogus\n" }, - { tool: "scripts/check_tree_fresh.mjs", args: ["--source"], exit: 2, stderr: "unknown arg: --source\n" }, - { tool: "scripts/check_tree_fresh.mjs", args: ["--tree"], exit: 2, stderr: "unknown arg: --tree\n" }, + { tool: "scripts/check_tree_fresh.mjs", args: ["--bogus"], exit: 2, stderr: "unknown option: --bogus\n" }, + { tool: "scripts/check_tree_fresh.mjs", args: ["--source"], exit: 2, stderr: "--source needs a value\n" }, + { tool: "scripts/check_tree_fresh.mjs", args: ["--tree"], exit: 2, stderr: "--tree needs a value\n" }, { tool: "scripts/pick_a11y_sample.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/pick_a11y_sample\.mjs / }, - { tool: "scripts/pick_a11y_sample.mjs", args: ["--bogus"], exit: 2, stderr: "unknown arg: --bogus\n" }, - { tool: "scripts/pick_a11y_sample.mjs", args: ["--budget"], exit: 2, stderr: "unknown arg: --budget\n" }, + { tool: "scripts/pick_a11y_sample.mjs", args: ["--bogus"], exit: 2, stderr: "unknown option: --bogus\n" }, + { tool: "scripts/pick_a11y_sample.mjs", args: ["--budget"], exit: 2, stderr: "--budget needs a value\n" }, { tool: "scripts/sweep_a11y.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/sweep_a11y\.mjs / }, - { tool: "scripts/sweep_a11y.mjs", args: ["--bogus"], exit: 2, stderr: "unknown arg: --bogus\n" }, - { tool: "scripts/sweep_a11y.mjs", args: ["--limit"], exit: 2, stderr: "unknown arg: --limit\n" }, + { tool: "scripts/sweep_a11y.mjs", args: ["--bogus"], exit: 2, stderr: "unknown option: --bogus\n" }, + { tool: "scripts/sweep_a11y.mjs", args: ["--limit"], exit: 2, stderr: "--limit needs a value\n" }, { tool: "scripts/sweep_a11y.mjs", args: ["--theme", "drak"], exit: 2, stderr: 'unknown --theme "drak"; expected one of light, dark or both\n' }, { tool: "scripts/sweep_a11y.mjs", args: ["--viewport", "huge"], exit: 2, stderr: 'unknown --viewport "huge"; expected one of desktop, mobile or both\n' }, - // Recorded in C49, before the harness tools moved onto lib/cli.mjs. All of - // them ignore an unknown flag, and answer --help and -h on stdout with exit 0 - // before any other check, a number or a missing project included (C71). - // tbbuild finds its project anywhere in the list (C17). tbrun and addin_test - // give a value flag with nothing after it, or "", its default, and a value - // flag takes the argument after it whatever it is. gen_attribute_probes takes - // any other argument as its output folder, so only its empty list is a case. + // Recorded in C49, for the harness tools. All of them answer --help and -h + // on stdout with exit 0 before any other check, a number or a missing + // project included (C71). tbbuild finds its project anywhere in the list + // (C17). An unknown option, a value flag with no value or an empty one is + // refused, and tbbuild and tbrun follow the message with their usage. + // gen_attribute_probes takes one output folder and an optional key file, so + // only its empty list is a case here. { tool: "scripts/tbbuild.mjs", args: [], exit: 2, stderr: /^usage: node scripts\/tbbuild\.mjs / }, { tool: "scripts/tbbuild.mjs", args: ["x.twinproj", "--help"], exit: 0, stdout: /^usage: node scripts\/tbbuild\.mjs / }, { tool: "scripts/tbbuild.mjs", args: ["x.twinproj", "--arch", "win99"], exit: 2, stderr: /^usage: node scripts\/tbbuild\.mjs / }, @@ -372,17 +389,20 @@ const CASES = [ { tool: "scripts/tbbuild.mjs", args: ["x.twinproj", "--timeout", "abc"], exit: 2, stderr: /^--timeout takes a positive number\nusage: node scripts\/tbbuild\.mjs / }, { tool: "scripts/tbbuild.mjs", args: ["x.twinproj", "--timeout", "-3"], exit: 2, stderr: /^--timeout needs a value\nusage: node scripts\/tbbuild\.mjs / }, { tool: "scripts/tbbuild.mjs", args: ["x.twinproj", "--port", "0", "--help"], exit: 0, stdout: /^usage: node scripts\/tbbuild\.mjs / }, - { tool: "scripts/tbbuild.mjs", args: ["--bogus", "--keep", "x.twinproj"], exit: 2, stderr: "no such project: x.twinproj\n" }, + { tool: "scripts/tbbuild.mjs", args: ["--bogus", "--keep", "x.twinproj"], exit: 2, stderr: /^unknown option: --bogus\nusage: node scripts\/tbbuild\.mjs / }, + { tool: "scripts/tbbuild.mjs", args: ["--keep", "x.twinproj"], exit: 2, stderr: "no such project: x.twinproj\n" }, { tool: "scripts/tbrun.mjs", args: [], exit: 2, stderr: /^usage: node scripts\/tbrun\.mjs / }, { tool: "scripts/tbrun.mjs", args: ["no-such-dir", "--help"], exit: 0, stdout: /^usage: node scripts\/tbrun\.mjs / }, { tool: "scripts/tbrun.mjs", args: ["no-such-dir", "--arch", "win99"], exit: 2, stderr: /^usage: node scripts\/tbrun\.mjs / }, { tool: "scripts/tbrun.mjs", args: ["--port", "no-such-dir"], exit: 2, stderr: /^usage: node scripts\/tbrun\.mjs / }, - { tool: "scripts/tbrun.mjs", args: ["no-such-dir", "--arch"], exit: 2, stderr: /^not a directory: .*no-such-dir\ntbrun takes an exported source tree / }, - { tool: "scripts/tbrun.mjs", args: ["no-such-dir", "--arch", ""], exit: 2, stderr: /^not a directory: .*no-such-dir\ntbrun takes an exported source tree / }, - { tool: "scripts/tbrun.mjs", args: ["--bogus", "no-such-dir"], exit: 2, stderr: /^not a directory: .*no-such-dir\ntbrun takes an exported source tree / }, + { tool: "scripts/tbrun.mjs", args: ["no-such-dir", "--arch"], exit: 2, stderr: /^--arch needs a value\nusage: node scripts\/tbrun\.mjs / }, + { tool: "scripts/tbrun.mjs", args: ["no-such-dir", "--arch", ""], exit: 2, stderr: /^--arch needs a non-empty value\nusage: node scripts\/tbrun\.mjs / }, + { tool: "scripts/tbrun.mjs", args: ["--bogus", "no-such-dir"], exit: 2, stderr: /^unknown option: --bogus\nusage: node scripts\/tbrun\.mjs / }, + { tool: "scripts/tbrun.mjs", args: ["no-such-dir", "--arch", "win64"], exit: 2, stderr: /^not a directory: .*no-such-dir\ntbrun takes an exported source tree / }, { tool: "scripts/addin_test.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/addin_test\.mjs / }, - { tool: "scripts/addin_test.mjs", args: ["--ide"], exit: 2, stderr: /^no twinBASIC IDE found: pass --ide / }, - { tool: "scripts/addin_test.mjs", args: ["--ide", ""], exit: 2, stderr: /^no twinBASIC IDE found: pass --ide / }, + { tool: "scripts/addin_test.mjs", args: ["--ide"], exit: 2, stderr: "--ide needs a value\n" }, + { tool: "scripts/addin_test.mjs", args: ["--ide", ""], exit: 2, stderr: "--ide needs a non-empty value\n" }, + { tool: "scripts/addin_test.mjs", args: ["--ide", "no-such.exe"], exit: 2, stderr: /^no twinBASIC IDE found: pass --ide / }, { tool: "scripts/check_examples.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/check_examples\.mjs \[options\]\n/ }, { tool: "scripts/check_examples.mjs", args: ["--jobs", "0"], exit: 2, stderr: "check_examples: --jobs takes a positive whole number\n" }, { tool: "scripts/check_examples.mjs", args: ["--batch", "1.5"], exit: 2, stderr: "check_examples: --batch takes a positive whole number\n" }, @@ -392,156 +412,157 @@ const CASES = [ { tool: "scripts/census_attributes.mjs", args: ["--help", "--attr"], exit: 0, stdout: /^usage: node scripts\/census_attributes\.mjs \[options\]\n/ }, { tool: "scripts/census_attributes.mjs", args: ["--dump-sites"], exit: 2, stderr: "--dump-sites needs a value\n" }, { tool: "scripts/build_package_api.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/build_package_api\.mjs \[options\]\n/ }, - { tool: "scripts/gen_attribute_probes.mjs", args: [], exit: 2, stdout: /^Generate a twinBASIC probe project for Reference\/Attributes\.md applicability\.\n/ }, - - // Recorded in C50, before the gates and link tools moved onto lib/cli.mjs. - // check_links prints on stdout and exits 4; an unknown flag is warned about - // and takes the argument after it along, unless that starts with a dash, and - // a value flag takes whatever follows. check_links_diff, crawl_check and - // compare_trees refuse an unknown argument, and a value flag in crawl_check - // takes whatever follows. check_publish_policy reads only --src and ignores - // the rest; check_lint takes --staged alone, --help, or nothing. survey_tooling's words for a parse error - // were node:util's, so only the line and the usage after it are pinned. - // check_regex_safety, check_code_regions, check_gate_lists and - // convert_em_dash_separators ignore every argument they do not know, so none - // has a case beyond --help and -h. + { tool: "scripts/gen_attribute_probes.mjs", args: [], exit: 2, stderr: /^Generate a twinBASIC probe project for Reference\/Attributes\.md applicability\.\n/ }, + + // Recorded in C50, for the gates and link tools. check_links prints its + // errors on stderr, after "error: ", and exits 4. Every one of them refuses + // an unknown option, a stray argument, a value given to a flag that takes + // none, and a value flag with no value, and crawl_check, compare_trees and + // survey_tooling follow the message with their usage. check_lint prints its + // name, the message and its usage line. check_regex_safety, + // check_code_regions, check_gate_lists and convert_em_dash_separators take + // only flags of their own, and none has a case beyond --help, -h and the + // generated ones below. { tool: "scripts/check_links.mjs", args: ["--help"], exit: 0, stdout: /^Usage: node check_links\.mjs \[options\] <inputs\.\.\.>\n/ }, - { tool: "scripts/check_links.mjs", args: ["no-such-tree"], exit: 4, stdout: "error: --offline is required. Online (network) checking is not implemented by this tool.\n" }, - { tool: "scripts/check_links.mjs", args: ["--offline"], exit: 4, stdout: "error: at least one input file or directory is required\n" }, - { tool: "scripts/check_links.mjs", args: ["--offline", "--bogus", "no-such-tree"], exit: 4, stdout: "warning: ignoring unrecognised arguments: --bogus no-such-tree\nerror: at least one input file or directory is required\n" }, - { tool: "scripts/check_links.mjs", args: ["--offline", "-x", "--bogus=1"], exit: 4, stdout: "warning: ignoring unrecognised arguments: -x --bogus=1\nerror: at least one input file or directory is required\n" }, - { tool: "scripts/check_links.mjs", args: ["--offline", "--root-dir", "--forbid"], exit: 4, stdout: "error: at least one input file or directory is required\n" }, + { tool: "scripts/check_links.mjs", args: ["no-such-tree"], exit: 4, stderr: "error: --offline is required. Online (network) checking is not implemented by this tool.\n" }, + { tool: "scripts/check_links.mjs", args: ["--offline"], exit: 4, stderr: "error: at least one input file or directory is required\n" }, + { tool: "scripts/check_links.mjs", args: ["--offline", "--bogus", "no-such-tree"], exit: 4, stderr: "error: unknown option: --bogus\n" }, + { tool: "scripts/check_links.mjs", args: ["--offline", "-x", "--bogus=1"], exit: 4, stderr: "error: unknown option: -x\n" }, + { tool: "scripts/check_links.mjs", args: ["--offline", "--root-dir", "--forbid"], exit: 4, stderr: "error: --root-dir needs a value\n" }, { tool: "scripts/check_links_diff.mjs", args: ["--help"], exit: 0, stdout: /^Usage: node scripts\/check_links_diff\.mjs \[options\]\n/ }, { tool: "scripts/check_links_diff.mjs", args: [], exit: 2, stderr: /^error: --a and --b are both 'script', which compares nothing\.\n/ }, - { tool: "scripts/check_links_diff.mjs", args: ["--bogus"], exit: 2, stderr: "error: unknown argument: --bogus\n" }, - { tool: "scripts/check_links_diff.mjs", args: ["stray"], exit: 2, stderr: "error: unknown argument: stray\n" }, - { tool: "scripts/check_links_diff.mjs", args: ["--list=1"], exit: 2, stderr: "error: unknown argument: --list=1\n" }, + { tool: "scripts/check_links_diff.mjs", args: ["--bogus"], exit: 2, stderr: "unknown option: --bogus\n" }, + { tool: "scripts/check_links_diff.mjs", args: ["stray"], exit: 2, stderr: "unexpected argument: stray\n" }, + { tool: "scripts/check_links_diff.mjs", args: ["--list=1"], exit: 2, stderr: "--list takes no value\n" }, { tool: "scripts/crawl_check.mjs", args: [], exit: 2, stderr: /^usage: node scripts\/crawl_check\.mjs <start-url> / }, { tool: "scripts/crawl_check.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/crawl_check\.mjs <start-url> / }, - { tool: "scripts/crawl_check.mjs", args: ["--bogus", "http://127.0.0.1:9/"], exit: 2, stderr: "unknown flag: --bogus\n" }, - { tool: "scripts/crawl_check.mjs", args: ["--timeout", "5", "-x"], exit: 2, stderr: "unknown flag: -x\n" }, - { tool: "scripts/crawl_check.mjs", args: ["--skip-external=1"], exit: 2, stderr: "unknown flag: --skip-external=1\n" }, - { tool: "scripts/crawl_check.mjs", args: ["--concurrency", "--bogus"], exit: 2, stderr: /^usage: node scripts\/crawl_check\.mjs <start-url> / }, - { tool: "scripts/check_publish_policy.mjs", args: ["--src"], exit: 2, stderr: /^TypeError \[ERR_INVALID_ARG_TYPE\]: The "path" argument must be of type string\. Received undefined\n/ }, + { tool: "scripts/crawl_check.mjs", args: ["--bogus", "http://127.0.0.1:9/"], exit: 2, stderr: /^unknown option: --bogus\nusage: node scripts\/crawl_check\.mjs <start-url> / }, + { tool: "scripts/crawl_check.mjs", args: ["--timeout", "5", "-x"], exit: 2, stderr: /^unknown option: -x\nusage: node scripts\/crawl_check\.mjs <start-url> / }, + { tool: "scripts/crawl_check.mjs", args: ["--skip-external=1"], exit: 2, stderr: /^--skip-external takes no value\nusage: node scripts\/crawl_check\.mjs <start-url> / }, + { tool: "scripts/crawl_check.mjs", args: ["--concurrency", "--bogus"], exit: 2, stderr: /^--concurrency needs a value\nusage: node scripts\/crawl_check\.mjs <start-url> / }, + { tool: "scripts/check_publish_policy.mjs", args: ["--src"], exit: 2, stderr: "--src needs a value\n" }, { tool: "scripts/survey_tooling.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/survey_tooling\.mjs \[--root DIR\] / }, - { tool: "scripts/survey_tooling.mjs", args: ["--bogus"], exit: 2, stderr: /^[^\n]+\nusage: node scripts\/survey_tooling\.mjs \[--root DIR\] / }, - { tool: "scripts/survey_tooling.mjs", args: ["stray"], exit: 2, stderr: /^[^\n]+\nusage: node scripts\/survey_tooling\.mjs \[--root DIR\] / }, - { tool: "scripts/survey_tooling.mjs", args: ["--root"], exit: 2, stderr: /^[^\n]+\nusage: node scripts\/survey_tooling\.mjs \[--root DIR\] / }, + { tool: "scripts/survey_tooling.mjs", args: ["--bogus"], exit: 2, stderr: /^unknown option: --bogus\nusage: node scripts\/survey_tooling\.mjs \[--root DIR\] / }, + { tool: "scripts/survey_tooling.mjs", args: ["stray"], exit: 2, stderr: /^unexpected argument: stray\nusage: node scripts\/survey_tooling\.mjs \[--root DIR\] / }, + { tool: "scripts/survey_tooling.mjs", args: ["--root"], exit: 2, stderr: /^--root needs a value\nusage: node scripts\/survey_tooling\.mjs \[--root DIR\] / }, { tool: "scripts/survey_tooling.mjs", args: ["--window", "0"], exit: 2, stderr: /^--window expects a positive integer, got: 0\nusage: node scripts\/survey_tooling\.mjs / }, { tool: "scripts/survey_tooling.mjs", args: ["--top", "1.5"], exit: 2, stderr: /^--top expects a positive integer, got: 1\.5\nusage: node scripts\/survey_tooling\.mjs / }, { tool: "scripts/survey_tooling.mjs", args: ["--window", "0", "--help"], exit: 0, stdout: /^usage: node scripts\/survey_tooling\.mjs \[--root DIR\] / }, { tool: "scripts/check_lint.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/check_lint\.mjs \[--staged\]\n/ }, - { tool: "scripts/check_lint.mjs", args: ["--staged", "--staged"], exit: 2, stderr: "check_lint: usage: node scripts/check_lint.mjs [--staged]\n" }, - { tool: "scripts/check_lint.mjs", args: ["--staged", "x"], exit: 2, stderr: "check_lint: usage: node scripts/check_lint.mjs [--staged]\n" }, - { tool: "scripts/check_lint.mjs", args: ["--"], exit: 2, stderr: "check_lint: usage: node scripts/check_lint.mjs [--staged]\n" }, - { tool: "scripts/check_lint.mjs", args: ["--staged=1"], exit: 2, stderr: "check_lint: usage: node scripts/check_lint.mjs [--staged]\n" }, + { tool: "scripts/check_lint.mjs", args: ["--staged", "--staged"], exit: 2, stderr: "check_lint: --staged given more than once\nusage: node scripts/check_lint.mjs [--staged]\n" }, + { tool: "scripts/check_lint.mjs", args: ["--staged", "x"], exit: 2, stderr: "check_lint: unexpected argument: x\nusage: node scripts/check_lint.mjs [--staged]\n" }, + { tool: "scripts/check_lint.mjs", args: ["--"], exit: 2, stderr: "check_lint: unexpected argument: --\nusage: node scripts/check_lint.mjs [--staged]\n" }, + { tool: "scripts/check_lint.mjs", args: ["--staged=1"], exit: 2, stderr: "check_lint: --staged takes no value\nusage: node scripts/check_lint.mjs [--staged]\n" }, + { tool: "scripts/check_lint.mjs", args: ["--bogus"], exit: 2, stderr: "check_lint: unknown option: --bogus\nusage: node scripts/check_lint.mjs [--staged]\n" }, { tool: "scripts/compare_trees.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/compare_trees\.mjs \[--before <ref>\] / }, - { tool: "scripts/compare_trees.mjs", args: ["--bogus"], exit: 2, stderr: /^compare_trees: unknown argument "--bogus"\n\nusage: node scripts\/compare_trees\.mjs / }, - { tool: "scripts/compare_trees.mjs", args: ["stray"], exit: 2, stderr: /^compare_trees: unknown argument "stray"\n\nusage: node scripts\/compare_trees\.mjs / }, + { tool: "scripts/compare_trees.mjs", args: ["--bogus"], exit: 2, stderr: /^compare_trees: unknown option: --bogus\n\nusage: node scripts\/compare_trees\.mjs / }, + { tool: "scripts/compare_trees.mjs", args: ["stray"], exit: 2, stderr: /^compare_trees: unexpected argument: stray\n\nusage: node scripts\/compare_trees\.mjs / }, { tool: "scripts/compare_trees.mjs", args: ["--before"], exit: 2, stderr: /^compare_trees: --before needs a value\n\nusage: node scripts\/compare_trees\.mjs / }, { tool: "scripts/compare_trees.mjs", args: ["--max", "--keep"], exit: 2, stderr: /^compare_trees: --max needs a value\n\nusage: node scripts\/compare_trees\.mjs / }, { tool: "scripts/compare_trees.mjs", args: ["--max", "1.5"], exit: 2, stderr: /^compare_trees: --max takes a whole number, not "1\.5"\n\nusage: node scripts\/compare_trees\.mjs / }, - { tool: "scripts/compare_trees.mjs", args: ["--bogus", "--help"], exit: 2, stderr: /^compare_trees: unknown argument "--bogus"\n\nusage: node scripts\/compare_trees\.mjs / }, + { tool: "scripts/compare_trees.mjs", args: ["--bogus", "--help"], exit: 2, stderr: /^compare_trees: unknown option: --bogus\n\nusage: node scripts\/compare_trees\.mjs / }, { tool: "scripts/compare_trees.mjs", args: ["--before", "--", "x"], exit: 2, stderr: /^compare_trees: --before needs a value\n\nusage: node scripts\/compare_trees\.mjs / }, - { tool: "scripts/compare_trees.mjs", args: ["--keep=1"], exit: 2, stderr: /^compare_trees: unknown argument "--keep=1"\n\nusage: node scripts\/compare_trees\.mjs / }, - - // Recorded in C51, before render-book, eval/ and wisdom moved onto - // lib/cli.mjs. In every one of them a value flag takes whatever follows it, - // so a missing value shows only where the value is used. render-book - // refuses an unknown flag and a second input with "unknown arg". - // build_corpus threw on an unknown argument, so only its message is pinned, - // as are the other crashes here. Node names a file it cannot open with its - // folder on Windows and as given on Linux, so a crash's file name may - // follow a folder or stand alone. run_case and search_quality refuse one in - // their own words. nav_hops and site_search take an unknown - // flag as a pattern or a search term. transcript's file is its first - // argument that does not start with --, and no file exits 1. wisdom takes its - // first argument as the command; --help there or after it prints the usage. + { tool: "scripts/compare_trees.mjs", args: ["--keep=1"], exit: 2, stderr: /^compare_trees: --keep takes no value\n\nusage: node scripts\/compare_trees\.mjs / }, + + // Recorded in C51, for render-book, eval/ and wisdom. In every one of them an + // unknown option, a value flag with no value and a value given to a boolean + // are refused at the parse, in the parser's own words, exit 2, and so is an + // argument beyond those a tool takes (nav_hops and site_search take any + // number). A search term or a file name that starts with a dash goes after + // --. build_corpus, nav_hops, run_case, site_search and transcript print + // their usage on stderr when an argument they need is + // missing; wisdom does when no command is given, and names an unknown one. // Every tool here answers -h and --help on stdout with exit 0 (C71), and - // reads nothing after it. Any other unknown option - // or stray argument is refused before any command runs, and the command in - // these cases is never a real one, so that none can start an export. + // reads nothing after it. The command in the wisdom cases is never a real + // one, so that none can start an export. render-book's missing input file is + // not a usage error and exits 1, as does a --site that holds no search index. { tool: "book/render-book.mjs", args: ["--help"], exit: 0, stdout: /^usage: node render-book\.mjs <input\.html> / }, { tool: "book/render-book.mjs", args: [], exit: 2, stderr: "usage: node render-book.mjs <input.html> -o <output.pdf> [--outline-tags ...] [-t ms] [--additional-script path]...\n" }, - { tool: "book/render-book.mjs", args: ["a.html", "b.html"], exit: 2, stderr: "unknown arg: b.html\n" }, - { tool: "book/render-book.mjs", args: ["a.html", "-o"], exit: 2, stderr: /^usage: node render-book\.mjs <input\.html> / }, - { tool: "book/render-book.mjs", args: ["-o", "--bogus", "a.html"], exit: 1, stderr: /^input not found: .*a\.html\n$/ }, - { tool: "book/render-book.mjs", args: ["a.html", "-o", "out.pdf", "--outline-tags"], exit: 1, stderr: /TypeError: Cannot read properties of undefined \(reading 'split'\)\r?\n/ }, + { tool: "book/render-book.mjs", args: ["a.html", "b.html"], exit: 2, stderr: "unexpected argument: b.html\n" }, + { tool: "book/render-book.mjs", args: ["a.html", "-o"], exit: 2, stderr: "-o needs a value\n" }, + { tool: "book/render-book.mjs", args: ["-o", "--bogus", "a.html"], exit: 2, stderr: "-o needs a value\n" }, + { tool: "book/render-book.mjs", args: ["a.html", "-o", "out.pdf", "--bogus"], exit: 2, stderr: "unknown option: --bogus\n" }, + { tool: "book/render-book.mjs", args: ["a.html", "-o", "out.pdf", "--outline-tags"], exit: 2, stderr: "--outline-tags needs a value\n" }, { tool: "book/render-book.mjs", args: ["a.html", "-o", "out.pdf", "-t", "abc"], exit: 1, stderr: /^input not found: .*a\.html\n$/ }, - { tool: "book/render-book.mjs", args: ["-x"], exit: 2, stderr: "unknown arg: -x\n" }, + { tool: "book/render-book.mjs", args: ["-x"], exit: 2, stderr: "unknown option: -x\n" }, { tool: "eval/build_corpus.mjs", args: ["--help"], exit: 0, stdout: /^Usage: node eval\/build_corpus\.mjs --dest <path> / }, - { tool: "eval/build_corpus.mjs", args: [], exit: 1, stdout: /^Usage: node eval\/build_corpus\.mjs --dest <path> / }, - { tool: "eval/build_corpus.mjs", args: ["--bogus"], exit: 1, stderr: /(^|\n)(Error: )?unknown argument: --bogus\r?\n/ }, - { tool: "eval/build_corpus.mjs", args: ["stray"], exit: 1, stderr: /(^|\n)(Error: )?unknown argument: stray\r?\n/ }, + { tool: "eval/build_corpus.mjs", args: [], exit: 2, stderr: /^Usage: node eval\/build_corpus\.mjs --dest <path> / }, + { tool: "eval/build_corpus.mjs", args: ["--bogus"], exit: 2, stderr: "unknown option: --bogus\n" }, + { tool: "eval/build_corpus.mjs", args: ["stray"], exit: 2, stderr: "unexpected argument: stray\n" }, { tool: "eval/build_corpus.mjs", args: ["--help", "--bogus"], exit: 0, stdout: /^Usage: node eval\/build_corpus\.mjs --dest <path> / }, - { tool: "eval/build_corpus.mjs", args: ["--quiet=1"], exit: 1, stderr: /(^|\n)(Error: )?unknown argument: --quiet=1\r?\n/ }, + { tool: "eval/build_corpus.mjs", args: ["--quiet=1"], exit: 2, stderr: "--quiet takes no value\n" }, { tool: "eval/build_corpus.mjs", args: ["-hq"], exit: 0, stdout: /^Usage: node eval\/build_corpus\.mjs --dest <path> / }, - { tool: "eval/build_corpus.mjs", args: ["--src"], exit: 1, stderr: /TypeError \[ERR_INVALID_ARG_TYPE\]: The "paths\[0\]" argument must be of type string\. Received undefined\r?\n/ }, + { tool: "eval/build_corpus.mjs", args: ["--src"], exit: 2, stderr: "--src needs a value\n" }, { tool: "eval/nav_hops.mjs", args: ["--help"], exit: 0, stdout: /^Usage: node eval\/nav_hops\.mjs \[--from <page>\] / }, - { tool: "eval/nav_hops.mjs", args: [], exit: 2, stdout: /^Usage: node eval\/nav_hops\.mjs \[--from <page>\] / }, + { tool: "eval/nav_hops.mjs", args: [], exit: 2, stderr: /^Usage: node eval\/nav_hops\.mjs \[--from <page>\] / }, { tool: "eval/nav_hops.mjs", args: ["--from", "nope.md", "x"], exit: 2, stderr: /^no start page: .*[\\/]nope\.md\n$/ }, - { tool: "eval/nav_hops.mjs", args: ["--src", "nowhere", "--bogus"], exit: 2, stderr: /^no start page: .*[\\/]nowhere[\\/]docs[\\/]index\.md\n$/ }, - { tool: "eval/nav_hops.mjs", args: ["--help=1", "--src", "nowhere"], exit: 2, stderr: /^no start page: .*[\\/]nowhere[\\/]docs[\\/]index\.md\n$/ }, - { tool: "eval/nav_hops.mjs", args: ["--from", "--src", "x"], exit: 2, stderr: /^no start page: .*[\\/]--src\n$/ }, - { tool: "eval/nav_hops.mjs", args: ["--bogus", "C:/x"], exit: 2, stderr: "these patterns arrived as Windows paths: C:/x\nGit Bash converted them. Run with MSYS_NO_PATHCONV=1 set, or from another shell.\n" }, - { tool: "eval/nav_hops.mjs", args: ["--src"], exit: 2, stderr: /^TypeError \[ERR_INVALID_ARG_TYPE\]: The "paths\[0\]" argument must be of type string\. Received undefined\n/ }, - { tool: "eval/nav_hops.mjs", args: ["x", "--from"], exit: 2, stderr: /^TypeError \[ERR_INVALID_ARG_TYPE\]: The "paths\[1\]" argument must be of type string\. Received undefined\n/ }, + { tool: "eval/nav_hops.mjs", args: ["--src", "nowhere", "--bogus"], exit: 2, stderr: "unknown option: --bogus\n" }, + { tool: "eval/nav_hops.mjs", args: ["--src", "nowhere", "--", "--bogus"], exit: 2, stderr: /^no start page: .*[\\/]nowhere[\\/]docs[\\/]index\.md\n$/ }, + { tool: "eval/nav_hops.mjs", args: ["--help=1", "--src", "nowhere"], exit: 2, stderr: "--help takes no value\n" }, + { tool: "eval/nav_hops.mjs", args: ["--from", "--src", "x"], exit: 2, stderr: "--from needs a value\n" }, + { tool: "eval/nav_hops.mjs", args: ["--", "C:/x"], exit: 2, stderr: "these patterns arrived as Windows paths: C:/x\nGit Bash converted them. Run with MSYS_NO_PATHCONV=1 set, or from another shell.\n" }, + { tool: "eval/nav_hops.mjs", args: ["--src"], exit: 2, stderr: "--src needs a value\n" }, + { tool: "eval/nav_hops.mjs", args: ["x", "--from"], exit: 2, stderr: "--from needs a value\n" }, { tool: "eval/run_case.mjs", args: ["--help"], exit: 0, stdout: /^Usage: node eval\/run_case\.mjs --corpus <dir> / }, - { tool: "eval/run_case.mjs", args: [], exit: 2, stdout: /^Usage: node eval\/run_case\.mjs --corpus <dir> / }, - { tool: "eval/run_case.mjs", args: ["--bogus"], exit: 2, stderr: "unknown argument: --bogus\n" }, - { tool: "eval/run_case.mjs", args: ["stray"], exit: 2, stderr: "unknown argument: stray\n" }, + { tool: "eval/run_case.mjs", args: [], exit: 2, stderr: /^Usage: node eval\/run_case\.mjs --corpus <dir> / }, + { tool: "eval/run_case.mjs", args: ["--bogus"], exit: 2, stderr: "unknown option: --bogus\n" }, + { tool: "eval/run_case.mjs", args: ["stray"], exit: 2, stderr: "unexpected argument: stray\n" }, { tool: "eval/run_case.mjs", args: ["--help", "--bogus"], exit: 0, stdout: /^Usage: node eval\/run_case\.mjs --corpus <dir> / }, - { tool: "eval/run_case.mjs", args: ["--prompt-only=1"], exit: 2, stderr: "unknown argument: --prompt-only=1\n" }, - { tool: "eval/run_case.mjs", args: ["--corpus"], exit: 2, stderr: 'The "paths[0]" argument must be of type string. Received undefined\n' }, + { tool: "eval/run_case.mjs", args: ["--prompt-only=1"], exit: 2, stderr: "--prompt-only takes no value\n" }, + { tool: "eval/run_case.mjs", args: ["--corpus"], exit: 2, stderr: "--corpus needs a value\n" }, { tool: "eval/run_case.mjs", args: ["--smoke", "--corpus", "c", "--site", "s", "--out", "o"], exit: 2, stderr: /^missing: .*[\\/]c[\\/]docs, .*search-data\.json, .*lunr\.min\.js\n$/ }, { tool: "eval/run_case.mjs", args: ["--smoke", "--corpus", "c", "--site", "s", "--out", "o", "--timeout", "abc"], exit: 2, stderr: /^missing: .*[\\/]c[\\/]docs, / }, - { tool: "eval/run_case.mjs", args: ["--corpus", "c", "--site", "s", "--out", "o", "--protocol", "--smoke"], exit: 2, stdout: /^Usage: node eval\/run_case\.mjs --corpus <dir> / }, + { tool: "eval/run_case.mjs", args: ["--corpus", "c", "--site", "s", "--out", "o", "--protocol", "repo"], exit: 2, stderr: /^Usage: node eval\/run_case\.mjs --corpus <dir> / }, + { tool: "eval/run_case.mjs", args: ["--corpus", "c", "--site", "s", "--out", "o", "--protocol", "--smoke"], exit: 2, stderr: "--protocol needs a value\n" }, { tool: "eval/site_search.mjs", args: ["--help"], exit: 0, stdout: /^Usage: node eval\/site_search\.mjs "<query>" / }, - { tool: "eval/site_search.mjs", args: [], exit: 1, stdout: /^Usage: node eval\/site_search\.mjs "<query>" / }, - { tool: "eval/site_search.mjs", args: ["--site", "nowhere", "--bogus"], exit: 1, stderr: /^missing .*search-data\.json\nRun build\.bat / }, - { tool: "eval/site_search.mjs", args: ["--help=1", "--site", "nowhere"], exit: 1, stderr: /^missing .*search-data\.json\nRun build\.bat / }, + { tool: "eval/site_search.mjs", args: [], exit: 2, stderr: /^Usage: node eval\/site_search\.mjs "<query>" / }, + { tool: "eval/site_search.mjs", args: ["--site", "nowhere", "--bogus"], exit: 2, stderr: "unknown option: --bogus\n" }, + { tool: "eval/site_search.mjs", args: ["--site", "nowhere", "--", "--bogus"], exit: 1, stderr: /^missing .*search-data\.json\nRun build\.bat / }, + { tool: "eval/site_search.mjs", args: ["--help=1", "--site", "nowhere"], exit: 2, stderr: "--help takes no value\n" }, { tool: "eval/site_search.mjs", args: ["--composition", "--site", "nowhere"], exit: 1, stderr: /^missing .*search-data\.json\nRun build\.bat / }, - { tool: "eval/site_search.mjs", args: ["--site"], exit: 1, stderr: /TypeError \[ERR_INVALID_ARG_TYPE\]: The "paths\[0\]" argument must be of type string\. Received undefined\r?\n/ }, + { tool: "eval/site_search.mjs", args: ["--site"], exit: 2, stderr: "--site needs a value\n" }, { tool: "eval/search_quality.mjs", args: ["--help"], exit: 0, stdout: /^Usage: node eval\/search_quality\.mjs \[--site docs\/_site\] / }, - { tool: "eval/search_quality.mjs", args: ["--bogus"], exit: 1, stderr: "unrecognised argument: --bogus\n" }, - { tool: "eval/search_quality.mjs", args: ["stray"], exit: 1, stderr: "unrecognised argument: stray\n" }, + { tool: "eval/search_quality.mjs", args: ["--bogus"], exit: 2, stderr: "unknown option: --bogus\n" }, + { tool: "eval/search_quality.mjs", args: ["stray"], exit: 2, stderr: "unexpected argument: stray\n" }, { tool: "eval/search_quality.mjs", args: ["--help", "--bogus"], exit: 0, stdout: /^Usage: node eval\/search_quality\.mjs \[--site docs\/_site\] / }, - { tool: "eval/search_quality.mjs", args: ["--help=1"], exit: 1, stderr: "unrecognised argument: --help=1\n" }, - { tool: "eval/search_quality.mjs", args: ["-x"], exit: 1, stderr: "unrecognised argument: -x\n" }, + { tool: "eval/search_quality.mjs", args: ["--help=1"], exit: 2, stderr: "--help takes no value\n" }, + { tool: "eval/search_quality.mjs", args: ["-x"], exit: 2, stderr: "unknown option: -x\n" }, { tool: "eval/search_quality.mjs", args: ["--site", "nowhere"], exit: 1, stderr: /^missing .*search-data\.json\nRun build\.bat / }, { tool: "eval/search_quality.mjs", args: ["--site", "nowhere", "--sample", "abc"], exit: 1, stderr: /^missing .*search-data\.json\nRun build\.bat / }, - { tool: "eval/search_quality.mjs", args: ["--site", "--help"], exit: 1, stderr: /^missing .*[\\/]--help[\\/]assets[\\/]js[\\/]search-data\.json\nRun build\.bat / }, - { tool: "eval/search_quality.mjs", args: ["--site"], exit: 1, stderr: /TypeError \[ERR_INVALID_ARG_TYPE\]: The "paths\[0\]" argument must be of type string\. Received undefined\r?\n/ }, - { tool: "eval/search_quality.mjs", args: ["--site", "nowhere", "--save"], exit: 1, stderr: /TypeError \[ERR_INVALID_ARG_TYPE\]: The "paths\[0\]" argument must be of type string\. Received undefined\r?\n/ }, + { tool: "eval/search_quality.mjs", args: ["--site", "--help"], exit: 2, stderr: "--site needs a value\n" }, + { tool: "eval/search_quality.mjs", args: ["--site"], exit: 2, stderr: "--site needs a value\n" }, + { tool: "eval/search_quality.mjs", args: ["--site", "nowhere", "--save"], exit: 2, stderr: "--save needs a value\n" }, { tool: "eval/transcript.mjs", args: ["--help"], exit: 0, stdout: /^Usage: node eval\/transcript\.mjs <case\.jsonl> / }, { tool: "eval/transcript.mjs", args: ["-h"], exit: 0, stdout: /^Usage: node eval\/transcript\.mjs <case\.jsonl> / }, - { tool: "eval/transcript.mjs", args: [], exit: 1, stdout: /^Usage: node eval\/transcript\.mjs <case\.jsonl> / }, + { tool: "eval/transcript.mjs", args: [], exit: 2, stderr: /^Usage: node eval\/transcript\.mjs <case\.jsonl> / }, { tool: "eval/transcript.mjs", args: ["nope.jsonl", "--help"], exit: 0, stdout: /^Usage: node eval\/transcript\.mjs <case\.jsonl> / }, - { tool: "eval/transcript.mjs", args: ["--bogus"], exit: 1, stdout: /^Usage: node eval\/transcript\.mjs <case\.jsonl> / }, - { tool: "eval/transcript.mjs", args: ["--help=1"], exit: 1, stdout: /^Usage: node eval\/transcript\.mjs <case\.jsonl> / }, + { tool: "eval/transcript.mjs", args: ["--bogus"], exit: 2, stderr: "unknown option: --bogus\n" }, + { tool: "eval/transcript.mjs", args: ["--help=1"], exit: 2, stderr: "--help takes no value\n" }, { tool: "eval/transcript.mjs", args: ["nope.jsonl"], exit: 1, stderr: /Error: ENOENT: no such file or directory, open '[^']*nope\.jsonl'\r?\n/ }, - { tool: "eval/transcript.mjs", args: ["--bogus", "nope.jsonl"], exit: 1, stderr: /Error: ENOENT: no such file or directory, open '[^']*nope\.jsonl'\r?\n/ }, - { tool: "eval/transcript.mjs", args: ["-x"], exit: 1, stderr: /Error: ENOENT: no such file or directory, open '(?:[^']*[\\/])?-x'\r?\n/ }, - { tool: "eval/transcript.mjs", args: ["a.jsonl", "b.jsonl"], exit: 1, stderr: /Error: ENOENT: no such file or directory, open '[^']*a\.jsonl'\r?\n/ }, - { tool: "wisdom/wisdom.mjs", args: [], exit: 0, stderr: /^Usage: node wisdom\/wisdom\.mjs <command> \[options\]\n/ }, + { tool: "eval/transcript.mjs", args: ["--bogus", "nope.jsonl"], exit: 2, stderr: "unknown option: --bogus\n" }, + { tool: "eval/transcript.mjs", args: ["-x"], exit: 2, stderr: "unknown option: -x\n" }, + { tool: "eval/transcript.mjs", args: ["--", "-x"], exit: 1, stderr: /Error: ENOENT: no such file or directory, open '(?:[^']*[\\/])?-x'\r?\n/ }, + { tool: "eval/transcript.mjs", args: ["a.jsonl", "b.jsonl"], exit: 2, stderr: "unexpected argument: b.jsonl\n" }, + { tool: "wisdom/wisdom.mjs", args: [], exit: 2, stderr: /^Usage: node wisdom\/wisdom\.mjs <command> \[options\]\n/ }, { tool: "wisdom/wisdom.mjs", args: ["--help"], exit: 0, stdout: /^Usage: node wisdom\/wisdom\.mjs <command> \[options\]\n/ }, - { tool: "wisdom/wisdom.mjs", args: ["bogus"], exit: 1, stderr: /^Usage: node wisdom\/wisdom\.mjs <command> \[options\]\n/ }, - { tool: "wisdom/wisdom.mjs", args: ["bogus", "--guild", "--bogus"], exit: 1, stderr: /^Usage: node wisdom\/wisdom\.mjs <command> \[options\]\n/ }, - { tool: "wisdom/wisdom.mjs", args: ["bogus", "--cap"], exit: 1, stderr: /^Usage: node wisdom\/wisdom\.mjs <command> \[options\]\n/ }, - { tool: "wisdom/wisdom.mjs", args: ["bogus", "--bogus"], exit: 1, stderr: "Unknown option: --bogus\n" }, - { tool: "wisdom/wisdom.mjs", args: ["bogus", "stray"], exit: 1, stderr: "Unknown option: stray\n" }, + { tool: "wisdom/wisdom.mjs", args: ["bogus"], exit: 2, stderr: /^unknown command: bogus\nUsage: node wisdom\/wisdom\.mjs <command> \[options\]\n/ }, + { tool: "wisdom/wisdom.mjs", args: ["bogus", "--guild", "--bogus"], exit: 2, stderr: "--guild needs a value\n" }, + { tool: "wisdom/wisdom.mjs", args: ["bogus", "--cap"], exit: 2, stderr: "--cap needs a value\n" }, + { tool: "wisdom/wisdom.mjs", args: ["bogus", "--bogus"], exit: 2, stderr: "unknown option: --bogus\n" }, + { tool: "wisdom/wisdom.mjs", args: ["bogus", "stray"], exit: 2, stderr: "unexpected argument: stray\n" }, { tool: "wisdom/wisdom.mjs", args: ["bogus", "--help"], exit: 0, stdout: /^Usage: node wisdom\/wisdom\.mjs <command> \[options\]\n/ }, - { tool: "wisdom/wisdom.mjs", args: ["bogus", "--force=1"], exit: 1, stderr: "Unknown option: --force=1\n" }, - { tool: "wisdom/wisdom.mjs", args: ["bogus", "-x"], exit: 1, stderr: "Unknown option: -x\n" }, - { tool: "wisdom/wisdom.mjs", args: ["bogus", "--guild", "x", "--bogus"], exit: 1, stderr: "Unknown option: --bogus\n" }, - - // Recorded in C52, before tbdocs moved onto lib/cli.mjs, with C47's four - // above. Every command-line error exits 4. A value flag refuses a missing - // value and one that starts with a dash, "--" included; an unknown option, a - // positional and a boolean given a value are all "Unknown argument", named - // as given. Each --port and --stall-timeout is checked where it stands, so a - // bad one fails even when a later one is good. A --dest the build refuses - // exits 4 too, after the command line has been read. + { tool: "wisdom/wisdom.mjs", args: ["bogus", "--force=1"], exit: 2, stderr: "--force takes no value\n" }, + { tool: "wisdom/wisdom.mjs", args: ["bogus", "-x"], exit: 2, stderr: "unknown option: -x\n" }, + { tool: "wisdom/wisdom.mjs", args: ["bogus", "--guild", "x", "--bogus"], exit: 2, stderr: "unknown option: --bogus\n" }, + + // Recorded in C52, for tbdocs, with C47's four above. Every command-line + // error exits 4. A value flag refuses a missing value and one that starts + // with a dash, "--" included; an unknown option is refused as given, as is a + // positional, and a boolean given a value. Each --port and --stall-timeout + // is checked where it stands, so a bad one fails even when a later one is + // good. A --dest the build refuses exits 4 too, after the command line has + // been read. --baseurl takes an empty value, meaning the site root; the + // other value flags refuse one. { tool: "builder/tbdocs.mjs", args: ["--src"], exit: 4, stderr: "--src needs a value\n" }, { tool: "builder/tbdocs.mjs", args: ["--url"], exit: 4, stderr: "--url needs a value\n" }, { tool: "builder/tbdocs.mjs", args: ["--check-findings"], exit: 4, stderr: "--check-findings needs a value\n" }, @@ -549,17 +570,18 @@ const CASES = [ { tool: "builder/tbdocs.mjs", args: ["--dest", "--"], exit: 4, stderr: "--dest needs a value\n" }, { tool: "builder/tbdocs.mjs", args: ["--stall-timeout"], exit: 4, stderr: "--stall-timeout needs a value\n" }, { tool: "builder/tbdocs.mjs", args: ["--stall-timeout", "-1"], exit: 4, stderr: "--stall-timeout needs a value\n" }, - { tool: "builder/tbdocs.mjs", args: ["foo"], exit: 4, stderr: "Unknown argument: foo\n" }, - { tool: "builder/tbdocs.mjs", args: ["-"], exit: 4, stderr: "Unknown argument: -\n" }, - { tool: "builder/tbdocs.mjs", args: ["-x"], exit: 4, stderr: "Unknown argument: -x\n" }, - { tool: "builder/tbdocs.mjs", args: ["-xy"], exit: 4, stderr: "Unknown argument: -xy\n" }, + { tool: "builder/tbdocs.mjs", args: ["foo"], exit: 4, stderr: "unexpected argument: foo\n" }, + { tool: "builder/tbdocs.mjs", args: ["-"], exit: 4, stderr: "unexpected argument: -\n" }, + { tool: "builder/tbdocs.mjs", args: ["-x"], exit: 4, stderr: "unknown option: -x\n" }, + { tool: "builder/tbdocs.mjs", args: ["-xy"], exit: 4, stderr: "unknown option: -xy\n" }, { tool: "builder/tbdocs.mjs", args: ["-h"], exit: 0, stdout: /^usage: node builder\/tbdocs\.mjs \[options\]\n/ }, { tool: "builder/tbdocs.mjs", args: ["--help"], exit: 0, stdout: /^usage: node builder\/tbdocs\.mjs \[options\]\n/ }, - { tool: "builder/tbdocs.mjs", args: ["--dry-run=1"], exit: 4, stderr: "Unknown argument: --dry-run=1\n" }, - { tool: "builder/tbdocs.mjs", args: ["--no-check=1"], exit: 4, stderr: "Unknown argument: --no-check=1\n" }, - { tool: "builder/tbdocs.mjs", args: ["--no-check", "--bogus"], exit: 4, stderr: "Unknown argument: --bogus\n" }, + { tool: "builder/tbdocs.mjs", args: ["--dry-run=1"], exit: 4, stderr: "--dry-run takes no value\n" }, + { tool: "builder/tbdocs.mjs", args: ["--no-check=1"], exit: 4, stderr: "--no-check takes no value\n" }, + { tool: "builder/tbdocs.mjs", args: ["--no-check", "--bogus"], exit: 4, stderr: "unknown option: --bogus\n" }, { tool: "builder/tbdocs.mjs", args: ["--port", "abc"], exit: 4, stderr: "--port expects a port number from 1 to 65535, got: abc\n" }, - { tool: "builder/tbdocs.mjs", args: ["--port="], exit: 4, stderr: "--port expects a port number from 1 to 65535, got: \n" }, + { tool: "builder/tbdocs.mjs", args: ["--port="], exit: 4, stderr: "--port needs a non-empty value\n" }, + { tool: "builder/tbdocs.mjs", args: ["--stall-timeout="], exit: 4, stderr: "--stall-timeout needs a non-empty value\n" }, { tool: "builder/tbdocs.mjs", args: ["--port=65536"], exit: 4, stderr: "--port expects a port number from 1 to 65535, got: 65536\n" }, { tool: "builder/tbdocs.mjs", args: ["--port=1.5"], exit: 4, stderr: "--port expects a port number from 1 to 65535, got: 1.5\n" }, { tool: "builder/tbdocs.mjs", args: ["--port=80", "--port=0"], exit: 4, stderr: "--port expects a port number from 1 to 65535, got: 0\n" }, @@ -633,6 +655,84 @@ for (const [tool, start] of Object.entries(HELP_TOOLS)) { } } +// Recorded in C72. Every tool refuses an unknown flag and an empty value at +// the parse, so each has a case for the first and, where it has a value +// option, for the second: `tool: [option, extras]`, the option given as +// `--option=`. Both exit 2, or 4 in tbdocs and check_links, print the refusal on +// stderr and nothing on stdout. `prefix` is the text a tool puts before its +// message. `args` replaces `--bogus` where the tool must never get further than +// the parse: convert_em_dash_separators would rewrite docs/ but for --check, and +// wisdom needs a command that is not a real one. A case the table above already +// holds, the same tool with the same arguments, is not added again. +const REFUSALS = { + "builder/tbdocs.mjs": ["src", { exit: 4 }], + "book/render-book.mjs": ["output"], + "wisdom/wisdom.mjs": ["guild", { args: ["bogus", "--bogus"], empty: ["bogus", "--guild="] }], + "eval/build_corpus.mjs": ["dest"], + "eval/nav_hops.mjs": ["from"], + "eval/run_case.mjs": ["corpus"], + "eval/search_quality.mjs": ["site"], + "eval/site_search.mjs": ["site"], + "eval/transcript.mjs": [null], + "scripts/addin_test.mjs": ["ide"], + "scripts/build_dot_metrics.mjs": [null], + "scripts/build_package_api.mjs": ["out"], + "scripts/census_attributes.mjs": ["out"], + "scripts/check_a11y.mjs": ["root-dir"], + "scripts/check_a11y_fingerprint.mjs": ["pages"], + "scripts/check_axe_patch_equiv.mjs": ["patch"], + "scripts/check_book_coverage.mjs": [null], + "scripts/check_ci_workflows.mjs": [null], + "scripts/check_cli.mjs": [null], + "scripts/check_code_regions.mjs": [null], + "scripts/check_dot_fit.mjs": [null], + "scripts/check_examples.mjs": ["only", { prefix: "check_examples: " }], + "scripts/check_gate_lists.mjs": [null], + "scripts/check_impexp_parity.mjs": [null], + "scripts/check_links.mjs": ["root-dir", { exit: 4, prefix: "error: " }], + "scripts/check_links_diff.mjs": ["a"], + "scripts/check_lint.mjs": [null, { prefix: "check_lint: " }], + "scripts/check_page_baseline.mjs": [null], + "scripts/check_pdf_shims_equiv.mjs": [null], + "scripts/check_publish_policy.mjs": ["src"], + "scripts/check_regex_safety.mjs": [null], + "scripts/check_symbol_index.mjs": [null], + "scripts/check_tb_registry.mjs": [null], + "scripts/check_tree_fresh.mjs": ["tree"], + "scripts/check_twin_parsers.mjs": [null], + "scripts/compare_trees.mjs": ["before", { prefix: "compare_trees: " }], + "scripts/convert_em_dash_separators.mjs": [null, { args: ["--check", "--bogus"] }], + "scripts/crawl_check.mjs": ["timeout"], + "scripts/gen_attribute_probes.mjs": [null], + "scripts/impexp.mjs": [null, { prefix: "ERROR: " }], + "scripts/pick_a11y_sample.mjs": ["sweep"], + "scripts/survey_tooling.mjs": ["root"], + "scripts/sweep_a11y.mjs": ["out"], + "scripts/tbbuild.mjs": ["ide"], + "scripts/tbrun.mjs": ["ide"], +}; +// The cases whose folder must stay empty, as for a help request: a refusal +// starts no IDE or browser and writes nothing, and a tool that read the flag as +// a folder name would create one. +const LEAVES_EMPTY = new Set(); +for (const tool of Object.keys(HELP_TOOLS)) { + if (!(tool in REFUSALS)) throw new Error(`HELP_TOOLS names ${tool}, which REFUSALS does not`); +} +for (const [tool, [option, { exit = 2, prefix = "", args, empty } = {}]] of Object.entries(REFUSALS)) { + if (!(tool in HELP_TOOLS)) throw new Error(`REFUSALS names ${tool}, which HELP_TOOLS does not`); + const wanted = [{ args: args ?? ["--bogus"], stderr: new RegExp(`^${literal(`${prefix}unknown option: --bogus\n`)}`) }]; + if (option) wanted.push({ args: empty ?? [`--${option}=`], stderr: new RegExp(`^${literal(`${prefix}--${option} needs a non-empty value\n`)}`) }); + for (const w of wanted) { + const held = CASES.find((c) => c.tool === tool && c.args.length === w.args.length && c.args.every((a, i) => a === w.args[i])); + if (held) LEAVES_EMPTY.add(held); + else { + const made = { tool, args: w.args, exit, stderr: w.stderr }; + CASES.push(made); + LEAVES_EMPTY.add(made); + } + } +} + const TIMEOUT_MS = 30_000; function runCase({ tool, args }, cwd, env) { @@ -666,7 +766,7 @@ try { const cwd = path.join(scratch, `case-${i}`); await mkdir(cwd); results[i] = await runCase(CASES[i], cwd, env); - if (asksForHelp(CASES[i])) results[i].left = await readdir(cwd); + if (asksForHelp(CASES[i]) || LEAVES_EMPTY.has(CASES[i])) results[i].left = await readdir(cwd); } }; await Promise.all(Array.from({ length: Math.min(availableParallelism(), CASES.length) }, lane)); diff --git a/scripts/check_code_regions.mjs b/scripts/check_code_regions.mjs index 30f4c266..c4f472a8 100644 --- a/scripts/check_code_regions.mjs +++ b/scripts/check_code_regions.mjs @@ -88,7 +88,7 @@ import { validateCountNames } from "../builder/counts.mjs"; import { discover } from "../builder/discover.mjs"; import { applyPostRenderRewrites, applyPreRenderRewrites, createMarkdownIt } from "../builder/render.mjs"; import { injectAnchorHeadings } from "../builder/template.mjs"; -import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { parseFrontmatter, unquotedHashValues } from "../lib/frontmatter.mjs"; import { blockRegions, mapLines, maskCode, splitCodeSpans, splitOnMarker } from "../lib/markdown.mjs"; import { markdownFiles } from "../lib/markdown-files.mjs"; @@ -457,11 +457,10 @@ runs the probes of the modules that decide what is code. -h, --help print this text and exit`; async function main(argv) { - const { values } = parseCli(argv, { + const { values } = withUsageError(() => parseCli(argv, { options: { verbose: { type: "boolean" }, "self-test": { type: "boolean" }, help: { type: "boolean", short: "h" } }, - unknown: "ignore", stopAt: ["help"], - }); + })); if (values.help) printHelpAndExit(USAGE); const verbose = values.verbose; diff --git a/scripts/check_dot_fit.mjs b/scripts/check_dot_fit.mjs index fef991dd..2e98f2f9 100644 --- a/scripts/check_dot_fit.mjs +++ b/scripts/check_dot_fit.mjs @@ -29,7 +29,7 @@ import { listDotSources } from "../builder/dot.mjs"; import { withBrowser } from "./lib/browser.mjs"; import { exitOnCrash } from "./lib/gate-probes.mjs"; import { openInterPage } from "./lib/inter-page.mjs"; -import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { DOCS_DIR, REPO_ROOT } from "../lib/repo-paths.mjs"; exitOnCrash(); @@ -50,11 +50,10 @@ Graphviz drew for it, by measuring the text in a browser. // tens of units rather than ones. const TOLERANCE = 1.0; -const cli = parseCli(process.argv.slice(2), { +const cli = withUsageError(() => parseCli(process.argv.slice(2), { options: { verbose: { type: "boolean" }, help: { type: "boolean", short: "h" } }, - unknown: "ignore", stopAt: ["help"], -}); +})); if (cli.values.help) printHelpAndExit(USAGE); const verbose = cli.values.verbose === true; diff --git a/scripts/check_examples.mjs b/scripts/check_examples.mjs index 59e349b5..5cef5696 100644 --- a/scripts/check_examples.mjs +++ b/scripts/check_examples.mjs @@ -109,8 +109,6 @@ const { values } = withUsageError( hide: { type: "boolean", default: false }, help: { type: "boolean", short: "h", default: false }, }, - unknown: "ignore", - positionals: 0, stopAt: ["help"], }), { format: (err) => `check_examples: ${err.message}` }, diff --git a/scripts/check_gate_lists.mjs b/scripts/check_gate_lists.mjs index c7bc1946..b79dcd78 100644 --- a/scripts/check_gate_lists.mjs +++ b/scripts/check_gate_lists.mjs @@ -81,7 +81,7 @@ import { readFile, readdir } from "node:fs/promises"; import path from "node:path"; import { createMarkdownIt } from "../builder/render.mjs"; -import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { splitOnMarker } from "../lib/markdown.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; import { gateName, gatesFromBat } from "./lib/gate-roster.mjs"; @@ -561,11 +561,10 @@ and that no developer page states a gate count that disagrees with them. -h, --help print this text and exit`; async function main(argv) { - const { values } = parseCli(argv, { + const { values } = withUsageError(() => parseCli(argv, { options: { verbose: { type: "boolean" }, "self-test": { type: "boolean" }, help: { type: "boolean", short: "h" } }, - unknown: "ignore", stopAt: ["help"], - }); + })); if (values.help) printHelpAndExit(USAGE); const verbose = values.verbose; const onlySelfTest = values.selfTest; diff --git a/scripts/check_links.mjs b/scripts/check_links.mjs index cc810670..54d59179 100644 --- a/scripts/check_links.mjs +++ b/scripts/check_links.mjs @@ -77,7 +77,7 @@ import { FsOracle, formatLinkReport, formatIntegrityReport, resolve, OUTSIDE_BASEPATH_MARKER, } from "../builder/link-check.mjs"; -import { parseCli } from "../lib/cli.mjs"; +import { CliError, parseCli } from "../lib/cli.mjs"; // Tree-relative POSIX path, the space check.mjs works and reports in, so // the same tree checked with a relative --root-dir, an absolute one, or @@ -101,8 +101,8 @@ function readIfPresent(p) { try { return fs.readFileSync(p, "utf8"); } catch { return null; } } -function printHelp() { - process.stdout.write(`Usage: node check_links.mjs [options] <inputs...> +function printHelp(stream = process.stdout) { + stream.write(`Usage: node check_links.mjs [options] <inputs...> node check_links.mjs <args1...> /sep/ <args2...> [/sep/ ...] Offline link checker for static sites. Only offline checking is @@ -142,7 +142,6 @@ Options: Pages. 'index' compares strings and is case-sensitive on every platform. See builder/link-check.mjs. - --threads N Accepted for CLI compatibility; ignored. -v, --verbose Print per-stage timing breakdown. -h, --help Show this help and exit. @@ -191,16 +190,17 @@ Exit codes: 1 Link / forbidden-prefix check failed. 2 Integrity check failed (no link failures). 3 Both link and integrity checks failed. - 4 Command-line error: no arguments, a flag without its value, - no --offline, or no input. + 4 Command-line error, reported on stderr: no arguments, an unknown + option, a flag without its value or with an empty one, no + --offline, or no input. Inputs are files or directories; directories are searched recursively for *.html. `); } -// `forbid` is the only repeatable flag. `threads` is accepted and unused, and -// `help` is answered before any argument list is parsed. +// `forbid` is the only repeatable flag, and `help` is answered before any +// argument list is parsed. const LINK_OPTIONS = { offline: { type: "boolean", default: false }, "include-fragments": { type: "boolean", default: false }, @@ -210,7 +210,6 @@ const LINK_OPTIONS = { "base-path": { type: "string", default: "" }, forbid: { type: "string", multiple: true }, "no-fail": { type: "boolean", default: false }, - threads: { type: "string" }, verbose: { type: "boolean", short: "v", default: false }, help: { type: "boolean", short: "h", default: false }, "check-html": { type: "boolean", default: false }, @@ -233,20 +232,7 @@ const LINK_OPTIONS = { }; function parseArgs(argv) { - let cli; - try { - cli = parseCli(argv, { - options: LINK_OPTIONS, - unknown: "ignore", - positionals: { min: 0 }, - acceptsValue: (v) => v !== undefined, - }); - } catch (err) { - if (err.code === "missing-value") throw new Error(`${err.option} requires a value`); - throw err; - } - - const { values } = cli; + const { values, positionals } = parseCli(argv, { options: LINK_OPTIONS, positionals: { min: 0 } }); const opts = { offline: values.offline, includeFragments: values.includeFragments, @@ -267,32 +253,7 @@ function parseArgs(argv) { oracle: values.oracle, }; - // An index is covered once it is a kept token's own index, or the index - // right after a kept value option that took its value as a separate - // argument -- the two positions the parse actually looked at. - const covered = new Set(); - const positionalAt = new Map(); - for (const t of cli.tokens) { - covered.add(t.index); - if (t.kind === "option" && t.value !== undefined && !t.inlineValue) covered.add(t.index + 1); - if (t.kind === "positional") positionalAt.set(t.index, t.value); - } - - // Everything else went unrecognised, and is warned about rather than - // refused. An unknown --flag with no "=" takes the positional right after - // it along, so that one goes to `unknown` too, not `inputs`. - const inputs = []; - const unknown = []; - for (let i = 0; i < argv.length; i++) { - if (positionalAt.has(i)) { - const prev = argv[i - 1]; - if (i > 0 && !covered.has(i - 1) && prev.startsWith("--") && !prev.includes("=")) unknown.push(argv[i]); - else inputs.push(positionalAt.get(i)); - } else if (!covered.has(i)) { - unknown.push(argv[i]); - } - } - return { opts, inputs, unknown }; + return { opts, inputs: positionals }; } function collectHtmlFiles(inputs) { @@ -340,7 +301,9 @@ function collectAllRelFiles(rootStr) { } // Run a single check pass. All output is collected into a buffer; -// nothing is written to stdout/stderr. Returns { output, exitCode }. +// nothing is written to stdout/stderr. Returns { output, exitCode }, and +// for a command-line error (exit 4) an empty output and `error`, the line for +// stderr. // // With `structured: true` the result additionally carries a `findings` // object -- builder/check.mjs's findingsFor, the same conclusions the @@ -351,31 +314,25 @@ export function runCheck(argv, { structured = false } = {}) { const buf = []; const write = (s) => buf.push(s); + const commandLineError = (error) => ({ output: "", exitCode: 4, error }); + let parsed; try { parsed = parseArgs(argv); } catch (e) { - write(`error: ${e.message}\n`); - return { output: buf.join(""), exitCode: 4 }; - } - const { opts, inputs, unknown } = parsed; - - if (unknown.length) { - write( - `warning: ignoring unrecognised arguments: ${unknown.join(" ")}\n` - ); + if (!(e instanceof CliError)) throw e; + return commandLineError(`error: ${e.message}`); } + const { opts, inputs } = parsed; if (!opts.offline) { - write( + return commandLineError( "error: --offline is required. Online (network) checking is not " + - "implemented by this tool.\n" + "implemented by this tool." ); - return { output: buf.join(""), exitCode: 4 }; } if (!inputs.length) { - write("error: at least one input file or directory is required\n"); - return { output: buf.join(""), exitCode: 4 }; + return commandLineError("error: at least one input file or directory is required"); } // --root-dir is used in the shape it was given. checkChunk joins it to @@ -562,8 +519,8 @@ export function selfTest() { const argv = ["--offline", "--check-sitemap", "--check-search", "--check-canonical", "--root-dir", tmp, tmp]; if (bp) argv.push("--base-path", bp); - const { findings, output } = runCheck(argv, { structured: true }); - if (!findings) throw new Error(`runCheck refused the self-test's arguments:\n${output}`); + const { findings, error } = runCheck(argv, { structured: true }); + if (!findings) throw new Error(`runCheck refused the self-test's arguments:\n${error}`); return findings; }; @@ -664,14 +621,15 @@ if (!isMainThread && workerData?.argv) { const segments = commands.filter(c => c.length > 0); if (segments.length === 0) { - printHelp(); + printHelp(process.stderr); process.exit(4); } if (segments.length === 1) { // Single command -- run inline, no worker overhead. - const { output, exitCode } = runCheck(segments[0]); + const { output, exitCode, error } = runCheck(segments[0]); process.stdout.write(output); + if (error) process.stderr.write(`${error}\n`); process.exit(exitCode); } @@ -711,6 +669,7 @@ if (!isMainThread && workerData?.argv) { if (settled[i].status === "fulfilled") { const r = settled[i].value; process.stdout.write(r.output); + if (r.error) process.stderr.write(`${r.error}\n`); if (r.exitCode !== 0 && exitCode === 0) exitCode = r.exitCode; } else { process.stdout.write(`INTERNAL ERROR: ${settled[i].reason}\n`); diff --git a/scripts/check_links_diff.mjs b/scripts/check_links_diff.mjs index b1dc7867..165e36bd 100644 --- a/scripts/check_links_diff.mjs +++ b/scripts/check_links_diff.mjs @@ -79,7 +79,7 @@ import * as path from "node:path"; import { performance } from "node:perf_hooks"; import { runCheck, selfTest as scriptSelfTest } from "./check_links.mjs"; -import { parseCli } from "../lib/cli.mjs"; +import { parseCli, withUsageError } from "../lib/cli.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; const BASE_PATH = "/twinBASIC-docs"; @@ -341,9 +341,9 @@ const SIDES = { describe: "scripts/check_links.mjs with --oracle fs, in-process", run(argv) { argv = [...argv, "--oracle", "fs"]; - const { findings, exitCode, output } = runCheck(argv, { structured: true }); + const { findings, exitCode, error } = runCheck(argv, { structured: true }); if (!findings) { - throw new Error(`runCheck refused the arguments (exit ${exitCode}):\n${output}`); + throw new Error(`runCheck refused the arguments (exit ${exitCode}):\n${error}`); } return findings; }, @@ -539,28 +539,21 @@ function ensureBasePathTree(dir, allowBuild) { // ── Main ──────────────────────────────────────────────────────────── function parseArgs(argv) { - let values; - try { - ({ values } = parseCli(argv, { - options: { - a: { type: "string", default: "script" }, - b: { type: "string", default: "script" }, - case: { type: "string", multiple: true }, - "max-lines": { type: "string", default: "12" }, - "base-path-tree": { type: "string", default: DEFAULT_BASEPATH_TREE }, - "build-base-path": { type: "boolean" }, - "self-test": { type: "boolean" }, - list: { type: "boolean" }, - verbose: { type: "boolean", short: "v" }, - help: { type: "boolean", short: "h" }, - }, - positionals: 0, - acceptsValue: () => true, - stopAt: ["help"], - })); - } catch (err) { - throw new Error(`unknown argument: ${err.arg}`); - } + const { values } = withUsageError(() => parseCli(argv, { + options: { + a: { type: "string", default: "script" }, + b: { type: "string", default: "script" }, + case: { type: "string", multiple: true }, + "max-lines": { type: "string", default: "12" }, + "base-path-tree": { type: "string", default: DEFAULT_BASEPATH_TREE }, + "build-base-path": { type: "boolean" }, + "self-test": { type: "boolean" }, + list: { type: "boolean" }, + verbose: { type: "boolean", short: "v" }, + help: { type: "boolean", short: "h" }, + }, + stopAt: ["help"], + })); const o = { a: values.a, b: values.b, cases: values.case, verbose: values.verbose, list: values.list, maxLines: Number(values.maxLines), basePathTree: values.basePathTree, buildBasePath: values.buildBasePath, @@ -615,9 +608,7 @@ function selfTest(opts) { } function main() { - let opts; - try { opts = parseArgs(process.argv.slice(2)); } - catch (e) { console.error(`error: ${e.message}`); process.exit(2); } + const opts = parseArgs(process.argv.slice(2)); if (opts.help) { printHelp(); return 0; } if (opts.selfTest) return selfTest(opts); diff --git a/scripts/check_lint.mjs b/scripts/check_lint.mjs index d59f4efb..2e88874d 100644 --- a/scripts/check_lint.mjs +++ b/scripts/check_lint.mjs @@ -36,7 +36,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; -import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { exitOnCrash } from "./lib/gate-probes.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; @@ -47,7 +47,7 @@ function cannotLint(message) { process.exit(2); } -// A command-line error prints the first line alone, after the tool's name. +// A command-line error prints the message, then the first line of the usage. const SYNOPSIS = "usage: node scripts/check_lint.mjs [--staged]"; const USAGE = `${SYNOPSIS} @@ -56,18 +56,21 @@ warning as well as an error. --staged lint only the scripts the next commit adds or changes -h, --help print this text and exit`; -let cli; -try { - cli = parseCli(process.argv.slice(2), { - options: { staged: { type: "boolean", default: false }, help: { type: "boolean", short: "h" } }, - positionals: 0, - stopAt: ["help"], - }); -} catch { - cannotLint(SYNOPSIS); -} +const cli = withUsageError( + () => + parseCli(process.argv.slice(2), { + options: { staged: { type: "boolean", default: false }, help: { type: "boolean", short: "h" } }, + positionals: 0, + stopAt: ["help"], + }), + { format: (err) => `check_lint: ${err.message}\n${SYNOPSIS}` }, +); if (cli.values.help) printHelpAndExit(USAGE); -if (cli.tokens.length > (cli.values.staged ? 1 : 0)) cannotLint(SYNOPSIS); +if (cli.tokens.length > (cli.values.staged ? 1 : 0)) { + const bare = cli.tokens.some((t) => t.kind === "option-terminator"); + console.error(`check_lint: ${bare ? "unexpected argument: --" : "--staged given more than once"}\n${SYNOPSIS}`); + process.exit(2); +} const staged = cli.values.staged; // The scripts the next commit adds or changes that are still on disk, by the diff --git a/scripts/check_page_baseline.mjs b/scripts/check_page_baseline.mjs index 76d91162..69c7cfae 100644 --- a/scripts/check_page_baseline.mjs +++ b/scripts/check_page_baseline.mjs @@ -26,7 +26,7 @@ import { readFile } from "node:fs/promises"; import { GUARDED_SRC } from "../builder/baseline.mjs"; import { checkPageBaseline } from "../builder/page-baseline.mjs"; -import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { baselineFixture, createProbes, exitOnCrash } from "./lib/gate-probes.mjs"; exitOnCrash(); @@ -38,13 +38,10 @@ refuses what it exists to refuse, against a scratch baseline file. -h, --help print this text and exit`; -// Every other argument is ignored. -if (parseCli(process.argv.slice(2), { +if (withUsageError(() => parseCli(process.argv.slice(2), { options: { help: { type: "boolean", short: "h" } }, - unknown: "ignore", - positionals: { min: 0, max: 0 }, stopAt: ["help"], -}).values.help) printHelpAndExit(USAGE); +})).values.help) printHelpAndExit(USAGE); const BASE = { src: GUARDED_SRC, pages: 908, staticFiles: 247 }; diff --git a/scripts/check_publish_policy.mjs b/scripts/check_publish_policy.mjs index 0c0d1841..99e42ca4 100644 --- a/scripts/check_publish_policy.mjs +++ b/scripts/check_publish_policy.mjs @@ -22,7 +22,7 @@ import { publishPolicyFor, unpublishableSourceFiles, unpublishableTreePaths, SOURCE_EXTENSIONS, BUILD_EXTENSIONS, } from "../builder/publish-policy.mjs"; -import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { exitOnCrash } from "./lib/gate-probes.mjs"; exitOnCrash(); @@ -35,12 +35,10 @@ the file types it should, and that the source tree holds nothing it refuses. --src DIR the source tree to check (default docs) -h, --help print this text and exit`; -const { values } = parseCli(process.argv.slice(2), { +const { values } = withUsageError(() => parseCli(process.argv.slice(2), { options: { src: { type: "string", default: "docs" }, help: { type: "boolean", short: "h" } }, - unknown: "ignore", - acceptsValue: () => true, stopAt: ["help"], -}); +})); if (values.help) printHelpAndExit(USAGE); const SRC = values.src; diff --git a/scripts/check_regex_safety.mjs b/scripts/check_regex_safety.mjs index 9c4af9fb..9544bfd1 100644 --- a/scripts/check_regex_safety.mjs +++ b/scripts/check_regex_safety.mjs @@ -78,7 +78,7 @@ import * as walk from "acorn-walk"; import fg from "fast-glob"; import { foldConstructedRegexes, moduleExports } from "./lib/regex-fold.mjs"; -import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; // ── Backend selection ──────────────────────────────────────────────────────── @@ -589,16 +589,15 @@ Refuses a regex literal in the tree that can backtrack exponentially. --self-test prove the gate still detects an exponential regex -h, --help print this text and exit`; -const { values } = parseCli(process.argv.slice(2), { +const { values } = withUsageError(() => parseCli(process.argv.slice(2), { options: { shard: { type: "boolean" }, "self-test": { type: "boolean" }, census: { type: "boolean" }, help: { type: "boolean", short: "h" }, }, - unknown: "ignore", stopAt: ["help"], -}); +})); if (values.help) printHelpAndExit(USAGE); if (values.shard) { // Worker half of checkAll(): a slice in on stdin, its verdicts out on diff --git a/scripts/check_symbol_index.mjs b/scripts/check_symbol_index.mjs index c85619cd..e2d324fd 100644 --- a/scripts/check_symbol_index.mjs +++ b/scripts/check_symbol_index.mjs @@ -30,7 +30,7 @@ import { readFile } from "node:fs/promises"; import { GUARDED_SRC } from "../builder/baseline.mjs"; import { checkSymbolBaseline } from "../builder/symbol-baseline.mjs"; import { deriveSymbolIndex, headingsOf, serializeSymbolIndex } from "../builder/symbols.mjs"; -import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { baselineFixture, createProbes, exitOnCrash } from "./lib/gate-probes.mjs"; import { apiSnapshot, isPublicType, parseTwin } from "./lib/twin-api.mjs"; @@ -43,13 +43,10 @@ the .twin declaration scanner, the derivation from the pages and the drift guard -h, --help print this text and exit`; -// Every other argument is ignored. -if (parseCli(process.argv.slice(2), { +if (withUsageError(() => parseCli(process.argv.slice(2), { options: { help: { type: "boolean", short: "h" } }, - unknown: "ignore", - positionals: { min: 0, max: 0 }, stopAt: ["help"], -}).values.help) printHelpAndExit(USAGE); +})).values.help) printHelpAndExit(USAGE); const { check, report } = createProbes("check_symbol_index"); const show = (x) => JSON.stringify(x); diff --git a/scripts/check_tb_registry.mjs b/scripts/check_tb_registry.mjs index d2362da5..89e82b38 100644 --- a/scripts/check_tb_registry.mjs +++ b/scripts/check_tb_registry.mjs @@ -49,7 +49,7 @@ import assert from "node:assert/strict"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { exitOnCrash } from "./lib/gate-probes.mjs"; import * as R from "./lib/tb-registry.mjs"; @@ -63,13 +63,10 @@ HKCU\\Software\\tbharness-selftest. Windows only, and not a gate. -h, --help print this text and exit`; -// Every other argument is ignored. -if (parseCli(process.argv.slice(2), { +if (withUsageError(() => parseCli(process.argv.slice(2), { options: { help: { type: "boolean", short: "h" } }, - unknown: "ignore", - positionals: { min: 0, max: 0 }, stopAt: ["help"], -}).values.help) printHelpAndExit(USAGE); +})).values.help) printHelpAndExit(USAGE); const BASE = "Software\\tbharness-selftest"; const ROOT = BASE + "\\twinBASIC_IDE"; diff --git a/scripts/check_tree_fresh.mjs b/scripts/check_tree_fresh.mjs index 88d1f4f5..7e1a5ed3 100644 --- a/scripts/check_tree_fresh.mjs +++ b/scripts/check_tree_fresh.mjs @@ -73,10 +73,8 @@ const cli = withUsageError( source: { type: "string", multiple: true }, help: { type: "boolean", short: "h" }, }, - acceptsValue: Boolean, stopAt: ["help"], }), - { format: (err) => `unknown arg: ${err.arg}` }, ); if (cli.stopped === "help") { printHelpAndExit( diff --git a/scripts/check_twin_parsers.mjs b/scripts/check_twin_parsers.mjs index fac92cee..fab4a8ad 100644 --- a/scripts/check_twin_parsers.mjs +++ b/scripts/check_twin_parsers.mjs @@ -21,7 +21,7 @@ // - parseTargets (scripts/lib/attributes-doc.mjs), which turns an // `Applicable to:` line into gen_attribute_probes.mjs's targets. -import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { parseTargets } from "./lib/attributes-doc.mjs"; import { createProbes, exitOnCrash } from "./lib/gate-probes.mjs"; import { classify } from "./lib/tb-fences.mjs"; @@ -37,13 +37,10 @@ reference, each a shape one of them once misread. -h, --help print this text and exit`; -// Every other argument is ignored. -if (parseCli(process.argv.slice(2), { +if (withUsageError(() => parseCli(process.argv.slice(2), { options: { help: { type: "boolean", short: "h" } }, - unknown: "ignore", - positionals: { min: 0, max: 0 }, stopAt: ["help"], -}).values.help) printHelpAndExit(USAGE); +})).values.help) printHelpAndExit(USAGE); const { check, report } = createProbes("check_twin_parsers"); const show = (x) => JSON.stringify(x); diff --git a/scripts/compare_trees.mjs b/scripts/compare_trees.mjs index a9111e13..5830b97e 100644 --- a/scripts/compare_trees.mjs +++ b/scripts/compare_trees.mjs @@ -42,7 +42,7 @@ import { spawnSync } from "node:child_process"; import { closeSync, existsSync, openSync } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; -import { parseCli } from "../lib/cli.mjs"; +import { CliError, parseCli } from "../lib/cli.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; const WORK = path.join(REPO_ROOT, ".compare-trees"); @@ -126,14 +126,11 @@ function parseArgs(argv) { keep: { type: "boolean", default: false }, help: { type: "boolean", short: "h" }, }, - positionals: 0, - // A single-dash value such as -1 is taken; a double-dash one is not, - // so a flag with no value never eats the option that follows it. - acceptsValue: (v) => v !== undefined && !v.startsWith("--"), stopAt: ["help"], }); } catch (err) { - usageError(err.code === "missing-value" ? err.message : `unknown argument "${err.arg}"`); + if (!(err instanceof CliError)) throw err; + usageError(err.message); } if (cli.stopped === "help") { process.stdout.write(USAGE); process.exit(0); } diff --git a/scripts/convert_em_dash_separators.mjs b/scripts/convert_em_dash_separators.mjs index c5d8b510..df853002 100644 --- a/scripts/convert_em_dash_separators.mjs +++ b/scripts/convert_em_dash_separators.mjs @@ -45,7 +45,7 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { createMarkdownIt } from "../builder/render.mjs"; -import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { blockRegions, mapLines, splitCodeSpans } from "../lib/markdown.mjs"; import { markdownFiles } from "../lib/markdown-files.mjs"; import { DOCS_DIR } from "../lib/repo-paths.mjs"; @@ -135,11 +135,10 @@ the typographer converts at build time, leaving code as it is. -h, --help print this text and exit`; async function main(argv) { - const { values } = parseCli(argv, { + const { values } = withUsageError(() => parseCli(argv, { options: { check: { type: "boolean" }, help: { type: "boolean", short: "h" } }, - unknown: "ignore", stopAt: ["help"], - }); + })); if (values.help) printHelpAndExit(USAGE); const check = values.check; let files = 0; diff --git a/scripts/crawl_check.mjs b/scripts/crawl_check.mjs index 8a7cc16b..1187a2e1 100644 --- a/scripts/crawl_check.mjs +++ b/scripts/crawl_check.mjs @@ -40,11 +40,10 @@ const { values, positionals } = withUsageError( "skip-external": { type: "boolean" }, help: { type: "boolean", short: "h" }, }, - positionals: { min: 0 }, - acceptsValue: () => true, + positionals: { min: 0, max: 1 }, stopAt: ["help"], }), - { format: (err) => `unknown flag: ${err.arg}` }, + { format: (err) => `${err.message}\n${USAGE}` }, ); if (values.help) printHelpAndExit(USAGE); const startArg = positionals[0]; diff --git a/scripts/gen_attribute_probes.mjs b/scripts/gen_attribute_probes.mjs index d1cdbe34..a5e35c0d 100644 --- a/scripts/gen_attribute_probes.mjs +++ b/scripts/gen_attribute_probes.mjs @@ -38,7 +38,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { parseAttributes, parseTargets } from "./lib/attributes-doc.mjs"; -import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { DOCS_DIR } from "../lib/repo-paths.mjs"; const ATTR_DOC = path.join(DOCS_DIR, "Reference", "Attributes.md"); @@ -53,7 +53,9 @@ diagnostic naming a probe module is a finding. <out_dir> the folder to write the probe project into key.md where to write the key (default: probe-key.md beside <out_dir>) - -h, --help print this text and exit`; + -h, --help print this text and exit + +A folder or key that starts with a dash is given after \`--\`.`; // --------------------------------------------------------------- arguments // An attribute with a mandatory argument needs a value that is itself valid, or @@ -1032,15 +1034,14 @@ const MAIN_TWIN = "' Startup object for the probe project. Does nothing.\n\n" + "Module ProbeMain\n Public Sub Main()\n End Sub\nEnd Module\n"; async function main(argv) { - const { values, positionals } = parseCli(argv, { + const { values, positionals } = withUsageError(() => parseCli(argv, { options: { help: { type: "boolean", short: "h" } }, - unknown: "positional", - positionals: { min: 0 }, + positionals: { min: 0, max: 2 }, stopAt: ["help"], - }); + })); if (values.help) printHelpAndExit(USAGE); if (positionals.length < 1) { - console.log(USAGE); + console.error(USAGE); return 2; } const out = positionals[0]; diff --git a/scripts/pick_a11y_sample.mjs b/scripts/pick_a11y_sample.mjs index 2a338527..43aa409f 100644 --- a/scripts/pick_a11y_sample.mjs +++ b/scripts/pick_a11y_sample.mjs @@ -139,10 +139,8 @@ const cli = withUsageError( budget: { type: "string" }, help: { type: "boolean", short: "h" }, }, - acceptsValue: Boolean, stopAt: ["help"], }), - { format: (err) => `unknown arg: ${err.arg}` }, ); if (cli.stopped === "help") { printHelpAndExit( diff --git a/scripts/sweep_a11y.mjs b/scripts/sweep_a11y.mjs index d7ec9a28..06035f3b 100644 --- a/scripts/sweep_a11y.mjs +++ b/scripts/sweep_a11y.mjs @@ -87,10 +87,8 @@ const cli = withUsageError( "recycle-every": { type: "string" }, help: { type: "boolean", short: "h" }, }, - acceptsValue: Boolean, stopAt: ["help"], }), - { format: (err) => `unknown arg: ${err.arg}` }, ); if (cli.stopped === "help") { printHelpAndExit( diff --git a/scripts/tbbuild.mjs b/scripts/tbbuild.mjs index 14e1bf0a..1e8d6942 100644 --- a/scripts/tbbuild.mjs +++ b/scripts/tbbuild.mjs @@ -91,7 +91,6 @@ const { values, positionals } = withUsageError( hide: { type: "boolean", default: false }, help: { type: "boolean", short: "h", default: false }, }, - unknown: "ignore", positionals: { min: 0, max: 1 }, stopAt: ["help"], }), diff --git a/scripts/tbrun.mjs b/scripts/tbrun.mjs index d6a3e01b..a47e214e 100644 --- a/scripts/tbrun.mjs +++ b/scripts/tbrun.mjs @@ -101,7 +101,7 @@ import { execFileSync } from "node:child_process"; import { existsSync, readFileSync, mkdirSync, statSync, readdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { parseCli, printHelpAndExit } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { click } from "./lib/tb-click.mjs"; import { compilerExe, findIde } from "./lib/tb-install.mjs"; import { BUILD_FAILED, COMPILE_TIMEOUT, TARGETS, attachIde, compileOutcome, keepClears, keptClears, @@ -130,27 +130,28 @@ writes to the DEBUG CONSOLE. --show, --hide as tbbuild's -h, --help print this text and exit`; -const { values, positionals } = parseCli(process.argv.slice(2), { - options: { - port: { type: "string" }, - arch: { type: "string" }, - timeout: { type: "string" }, - quiet: { type: "string" }, - ide: { type: "string" }, - "reap-images": { type: "string" }, - json: { type: "boolean", default: false }, - raw: { type: "boolean", default: false }, - keep: { type: "boolean", default: false }, - "no-reap": { type: "boolean", default: false }, - show: { type: "boolean", default: false }, - hide: { type: "boolean", default: false }, - help: { type: "boolean", short: "h", default: false }, - }, - unknown: "ignore", - positionals: { min: 0, max: 1 }, - acceptsValue: () => true, - stopAt: ["help"], -}); +const { values, positionals } = withUsageError( + () => parseCli(process.argv.slice(2), { + options: { + port: { type: "string" }, + arch: { type: "string" }, + timeout: { type: "string" }, + quiet: { type: "string" }, + ide: { type: "string" }, + "reap-images": { type: "string" }, + json: { type: "boolean", default: false }, + raw: { type: "boolean", default: false }, + keep: { type: "boolean", default: false }, + "no-reap": { type: "boolean", default: false }, + show: { type: "boolean", default: false }, + hide: { type: "boolean", default: false }, + help: { type: "boolean", short: "h", default: false }, + }, + positionals: { min: 0, max: 1 }, + stopAt: ["help"], + }), + { format: (err) => `${err.message}\n${USAGE}` }, +); if (values.help) printHelpAndExit(USAGE); const die = (code, msg) => { console.error(msg); process.exit(code); }; diff --git a/wisdom/PLAN-3.md b/wisdom/PLAN-3.md index 43c1d3f3..fe955dc6 100644 --- a/wisdom/PLAN-3.md +++ b/wisdom/PLAN-3.md @@ -373,7 +373,7 @@ Cross-batch grouping and duplicate detection are part of the merge step — addi ``` node wisdom/wisdom.mjs extract [options] - --threads <dir> Input directory of processed .md files [default: wisdom/data/threads] + --in <dir> Input directory of processed .md files [default: wisdom/data/threads] --out <dir> Output directory for findings [default: wisdom/data/findings] --channel <name> Restrict to threads from this channel name (repeatable) --min-confidence <l> Skip findings below this level: high | medium | low [default: low] diff --git a/wisdom/wisdom.mjs b/wisdom/wisdom.mjs index 6013f0ed..0edb85d3 100644 --- a/wisdom/wisdom.mjs +++ b/wisdom/wisdom.mjs @@ -35,10 +35,8 @@ function parseArgs(argv) { all: { type: 'boolean' }, }, positionals: 0, - unknown: 'error', - acceptsValue: () => true, stopAt: ['help'], - }), { format: (err) => `Unknown option: ${err.arg}`, exitCode: 1 }) + })) if (values.help) printHelpAndExit(USAGE) const flags = { channels: values.channel } @@ -276,5 +274,6 @@ switch (command) { else await runExtract(flags) break default: - printHelpAndExit(USAGE, { stream: 'stderr', exitCode: command ? 1 : 0 }) + if (command) console.error(`unknown command: ${command}`) + printHelpAndExit(USAGE, { stream: 'stderr', exitCode: 2 }) } From fb19556346ad0c5fffd78c8ef2b4218e16386db2 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober <kuba@mareimbrium.org> Date: Wed, 30 Sep 2026 12:24:18 +0200 Subject: [PATCH 14/21] scripts, book, eval, wisdom: a bad value exits 2 --- book/render-book.mjs | 30 +-- builder/PLAN-TOOLING-REVIEW.md | 70 ++++++ builder/command-line.mjs | 19 +- docs/Documentation/Extending.md | 2 +- docs/Documentation/PDF-Generation.md | 4 +- docs/Documentation/Pipeline-Stages.md | 2 +- docs/Documentation/Tools.md | 20 +- docs/Documentation/Wisdom.md | 6 +- eval/README.md | 12 +- eval/build_corpus.mjs | 50 ++++- eval/nav_hops.mjs | 29 +-- eval/run_case.mjs | 47 ++-- eval/search_quality.mjs | 44 ++-- eval/site_search.mjs | 32 +-- lib/cli.mjs | 86 +++++++- scripts/addin_test.mjs | 16 +- scripts/check_a11y_fingerprint.mjs | 15 +- scripts/check_axe_patch_equiv.mjs | 7 +- scripts/check_cli.mjs | 296 +++++++++++++++++++++++--- scripts/check_examples.mjs | 28 +-- scripts/check_links.mjs | 4 +- scripts/check_links_diff.mjs | 40 ++-- scripts/compare_trees.mjs | 11 +- scripts/crawl_check.mjs | 14 +- scripts/pick_a11y_sample.mjs | 10 +- scripts/survey_tooling.mjs | 20 +- scripts/sweep_a11y.mjs | 9 +- scripts/tbbuild.mjs | 32 ++- scripts/tbrun.mjs | 19 +- wisdom/extract/prep.mjs | 7 +- wisdom/wisdom.mjs | 65 +++--- 31 files changed, 769 insertions(+), 277 deletions(-) diff --git a/book/render-book.mjs b/book/render-book.mjs index bf75fbcf..8dff785b 100644 --- a/book/render-book.mjs +++ b/book/render-book.mjs @@ -32,7 +32,7 @@ import { dirname, resolve } from 'node:path'; import { writeFileSync, existsSync } from 'node:fs'; import puppeteer from 'puppeteer'; import { PDFDocument } from 'pdf-lib'; -import { parseCli, printHelpAndExit, withUsageError } from '../lib/cli.mjs'; +import { numberOption, parseCli, printHelpAndExit, withUsageError } from '../lib/cli.mjs'; // Side-effecting imports. Mutate pdf-lib's live module exports // before any pdf-lib operation -- order doesn't matter. See // perf/notes/08-pdf-lib.md. @@ -209,22 +209,26 @@ Renders an HTML book to a PDF with paged.js and headless Chromium. --additional-script <path> a script to inject after paged.js; repeatable -h, --help print this text and exit`; -const { values, positionals } = withUsageError(() => parseCli(process.argv.slice(2), { - options: { - output: { type: 'string', short: 'o' }, - 'outline-tags': { type: 'string', default: 'h1,h2,h3,h4' }, - timeout: { type: 'string', short: 't', default: '0' }, - 'additional-script': { type: 'string', multiple: true }, - help: { type: 'boolean', short: 'h' }, - }, - positionals: { max: 1 }, - stopAt: ['help'], -})); +const { values, positionals, timeoutMs } = withUsageError(() => { + const cli = parseCli(process.argv.slice(2), { + options: { + output: { type: 'string', short: 'o' }, + 'outline-tags': { type: 'string', default: 'h1,h2,h3,h4' }, + timeout: { type: 'string', short: 't', default: '0' }, + 'additional-script': { type: 'string', multiple: true }, + help: { type: 'boolean', short: 'h' }, + }, + positionals: { max: 1 }, + stopAt: ['help'], + }); + if (cli.stopped === 'help') return cli; + // Puppeteer's timer fires at once for more than 2147483647 ms. + return { ...cli, timeoutMs: numberOption(cli.values.timeout, { option: '--timeout', integer: true, min: 0, max: 2147483647 }) }; +}); if (values.help) printHelpAndExit(USAGE); const inputArg = positionals[0]; const outputArg = values.output; const outlineTagsArg = values.outlineTags; -const timeoutMs = parseInt(values.timeout, 10); const additionalScripts = values.additionalScript; if (!inputArg || !outputArg) { console.error(SYNOPSIS); diff --git a/builder/PLAN-TOOLING-REVIEW.md b/builder/PLAN-TOOLING-REVIEW.md index 52a171b8..570c8688 100644 --- a/builder/PLAN-TOOLING-REVIEW.md +++ b/builder/PLAN-TOOLING-REVIEW.md @@ -1034,6 +1034,76 @@ link tools), in the tool's usage-error form. **Verify.** `check_cli.mjs` gains a bad-value case for each. +**Landed** on the owner's choices of 2026-09-30: numbers are read as `Number()` reads them +(so `0x10` and `1e3` pass, `12abc` and blank text do not), with fractions refused where the +value is a count, a port or a millisecond count an API needs whole; 0 only where it has a +stated meaning (`tbdocs`' `--stall-timeout`, `compare_trees`' `--max`, `search_quality`'s +`--worst` and `--failures`, `check_links_diff`'s `--max-lines`, `tbrun`'s `--quiet`, and +`render-book`'s `-t`, which PDF-Generation.md documents as disabling the timeout); +`numberOption`'s one wording everywhere; and every silent conflict refused. `lib/cli.mjs` +gains `choiceOption`, `regexOption`, `urlOption`, `dateOption` (ISO 8601, the day read back +as written, since `Date.parse` takes `12` as a date in 2001 and `2024-02-30` as March) and +`refuseTogether`, and `numberOption` gains `above` (greater than) and loses `message`, whose +one caller, `tbdocs`' `--port`, now takes the default wording. Every check runs straight after +the parse, before an IDE, browser, registry snapshot, request or file removal, in the tool's +usage-error form. The survey's list held, and it had missed more: `crawl_check --concurrency +abc` and `addin_test --jobs abc` looped for ever (the second after its registry snapshot), +`addin_test`'s and `run_case`'s timeouts of 0 or text killed every child at once, `wisdom`'s +`--rate-limit` or `--cap` given text turned the guard off, and `tbdocs --url foo` crashed +partway through the build. Also checked: `tbdocs`' `--url`; `tbbuild`'s and `tbrun`'s `--arch` +through `choiceOption` (they printed the bare usage); `check_a11y_fingerprint`'s `--patches` +and `check_axe_patch_equiv`'s `--patch` against the patch names; `wisdom`'s `--since` no +earlier than 2015-01-01 and its `--min-confidence`. Conflicts refused: `--show` with `--hide` +in `tbbuild`, `tbrun`, `addin_test` and `check_examples`; two of `check_examples`' `--report`, +`--census` and `--propose`, and `--apply` without `--propose`; two of `pick_a11y_sample`'s +modes (the last won); `site_search`'s `--composition` with terms; and `wisdom extract`'s +`--since`, `--all` and `--force`, moved from `prep.mjs` (exit 1) to the parse, and still not +under `--merge`, which reads none of them. `build_corpus` refuses a `--dest` that is or +contains the repository root, the current folder or `--src` (the last beyond the owner's two, +the same harm). Tools.md's refusal sentence covers a value a tool cannot use; its `tbdocs`, +`crawl_check`, `pick_a11y_sample`, the a11y fingerprint and `check_cli` sections, +Pipeline-Stages.md, PDF-Generation.md, Extending.md, Wisdom.md and `eval/README.md` follow. + +`check_cli: 810 probes, all pass` (568 before): 21 cases re-pointed to the new wording (three +`render-book`, `run_case` and `search_quality` cases now stop at the value, before the missing +input they stopped at), 39 probes of the new checkers, 102 bad-value cases in a `BAD_VALUES` +block, each with its empty-folder probe, and one probe gone with `message`. With +`numberOption`'s test made to pass everything through `c43-fault.mjs` in `NODE_OPTIONS`, 88 of +810 fail; with `choiceOption`'s, 16. A Sonnet agent checked every new text against the code; its six findings were fixed, among +them two limits: `crawl_check`'s `--timeout` allowed up to 4294967295 ms, which +`AbortSignal.timeout` accepts but whose timer then fires at once (measured: a 3,000,000,000 ms +signal is aborted within 50 ms, a 2,147,483,647 ms one is not), and `render-book`'s `-t` had +no maximum; both now stop at 2147483647. `urlOption` uses `new URL` in a `try` rather than +`URL.parse`, which needs Node 22.1 where the docs say 22. `compare_trees`: Extending, +PDF-Generation, Pipeline-Stages, Tools and Wisdom online and offline, the search data and +`book.html`. Lint `Checked 172 files`; regex safety `535 +literals + 34 constructed in 130 files ... 500 safe, 69 polynomial, 0 undecided, 0 +exponential; 8 construction(s) not resolvable`; `build.bat`, `check.bat` and `test.bat` +clean. On the owner's next push CI prints `check_cli: 810 probes, all pass` and that +regex-safety line. + +### C72b — `builder, scripts: tbdocs and check_links exit 0, 1 or 2 like every tool` + +**Raised by the owner** (2026-09-30). Every other tool exits 1 when it finds a problem and 2 +when it cannot do its job (a refused command line since C72, a crash since C28). `tbdocs` +exits with a bitmask, 1 for a failed page, diagram, stylesheet, baseline drift, link failure +or crash, 2 for an integrity failure, 3 for both, and 4 for a refused command line (C18, +departure 1); `check_links.mjs` gives 1 for links, 2 for integrity, 3 for both and 4 for its +command line. Nothing reads a bit: every wrapper tests `errorlevel 1`, CI tests non-zero, and +`check_links.mjs:56`'s "so CI can tell" has no reader. Building.md's "1 for link failures" +already leaves out the other failures 1 carries. + +**Change** (the owner's choice, 2026-09-30). Both tools exit 0 clean, 1 when the build or check +found a problem of any kind, and 2 on a refused command line or a crash (`tbdocs`' crash exits +1 today, through `main().catch`); the summary lines already name which check failed. The exit +constants in `tbdocs.mjs`, `serve.mjs`'s use of them, `write.mjs`'s `--dest` refusal, +`check_links`' usage text and header, Building.md, Tools.md's refusal sentence and its `tbdocs` +and `check_links` sections, Pipeline-Stages.md, `check_cli`'s cases and `REFUSALS` entries +that expect 4, and C75's note follow. Departure 1 is superseded. + +**Verify.** `check_cli.mjs`; `build.bat` over a fixture with a broken link and one with an +integrity failure exits 1; a crash through `c43-fault.mjs` exits 2. + ### C73 — `scripts: one meaning each for --json and --src` **L1-8 (R2), L1-9 (R3).** `--json` prints to stdout in `tbbuild`, `tbrun`, `check_examples` diff --git a/builder/command-line.mjs b/builder/command-line.mjs index f24a711f..f6097092 100644 --- a/builder/command-line.mjs +++ b/builder/command-line.mjs @@ -10,7 +10,7 @@ // where they stand: nothing after them is read, and the options returned say // only `help`. -import { CliError, numberOption, parseCli } from "../lib/cli.mjs"; +import { numberOption, parseCli, urlOption } from "../lib/cli.mjs"; export const OPTIONS = { src: { type: "string" }, @@ -115,7 +115,7 @@ export function parseCommandLine(argv) { case "src": args.src = t.value; break; case "dest": args.dest = t.value; break; case "baseurl": args.baseurl = t.value; break; - case "url": args.url = t.value; break; + case "url": urlOption(t.value, { option: "--url" }); args.url = t.value; break; case "dryRun": args.dryRun = true; break; case "noOffline": args.skipOffline = true; break; case "noPdf": args.skipPdf = true; break; @@ -156,21 +156,12 @@ export function parseCommandLine(argv) { break; case "serve": args.serve = true; break; case "port": - args.port = numberOption(t.value, { - option: "--port", integer: true, min: 1, max: 65535, - message: (raw) => `--port expects a port number from 1 to 65535, got: ${raw}`, - }); + args.port = numberOption(t.value, { option: "--port", integer: true, min: 1, max: 65535 }); break; - case "stallTimeout": { + case "stallTimeout": // Seconds, fractions included; 0 disables the watchdog. - const secs = Number(t.value); - if (!Number.isFinite(secs) || secs < 0) { - throw new CliError("bad-number", `--stall-timeout expects seconds (0 disables), got: ${t.value}`, - { option: "--stall-timeout", value: t.value }); - } - args.stallTimeoutMs = secs * 1000; + args.stallTimeoutMs = numberOption(t.value, { option: "--stall-timeout", min: 0 }) * 1000; break; - } } } return args; diff --git a/docs/Documentation/Extending.md b/docs/Documentation/Extending.md index ab4d30a5..bc91d8ac 100644 --- a/docs/Documentation/Extending.md +++ b/docs/Documentation/Extending.md @@ -649,7 +649,7 @@ The split exists so that an edit confined to `docs/` usually has to pay for `che |---|---| | `0` | The checked thing is fine. | | `1` | The checked thing failed. This is the finding. | -| `2` | The harness or the environment failed --- a command line the tool refuses (an unknown flag, a flag without its value or with an empty one, an unexpected argument), an absent tree, an unhandled throw. Nothing was checked. | +| `2` | The harness or the environment failed --- a command line the tool refuses (an unknown flag, a flag without its value or with an empty one, an unexpected argument, a value it cannot use), an absent tree, an unhandled throw. Nothing was checked. | Separating 1 from 2 is what stops a broken gate reading as a clean site, and it has to hold at the top level too. End the script with `main().catch((err) => { console.error(err); process.exit(2); })`, the way `check_a11y.mjs` does, so a crash cannot fall through to node's default exit 1 and be mistaken for a finding. A script that runs at top level, with no `main()`, calls `exitOnCrash()` from `scripts/lib/gate-probes.mjs` before it does anything else; that handler also catches a rejected top-level await. diff --git a/docs/Documentation/PDF-Generation.md b/docs/Documentation/PDF-Generation.md index 176bc5bb..0661053a 100644 --- a/docs/Documentation/PDF-Generation.md +++ b/docs/Documentation/PDF-Generation.md @@ -35,7 +35,7 @@ node book/render-book.mjs <input.html> -o <output.pdf> | `<input.html>` | required | Path to the assembled HTML file (usually `_site-pdf/book.html`). | | `-o` / `--output` | required | Destination PDF path. | | `--outline-tags` | `h1,h2,h3,h4` | Comma-separated heading tags to include in the PDF bookmark tree. | -| `-t` / `--timeout` | `0` (disabled) | Per-operation puppeteer timeout in milliseconds. | +| `-t` / `--timeout` | `0` (disabled) | Per-operation puppeteer timeout in milliseconds, a whole number from 0 to 2147483647. | | `--additional-script` | --- | Inject an extra in-page script after the paged.js bundle. Repeatable. | `book.bat` runs the standard production invocation: @@ -153,7 +153,7 @@ The same fork checks fonts on the same principle: |---|---| | `0` | The PDF was written. | | `1` | A file the run needs is missing --- the input HTML, `lib/paged.browser.js`, `lib/progress-handler.js`, or an `--additional-script` path --- or the render threw. | -| `2` | Bad arguments: an unknown flag, a flag without its value or with an empty one, a second input file, or a missing `<input.html>` or `-o`. | +| `2` | Bad arguments: an unknown flag, a flag without its value or with an empty one, a `-t` that is not a whole number of milliseconds from 0 to 2147483647, a second input file, or a missing `<input.html>` or `-o`. | **`book.bat` propagates all three.** It copies `%ERRORLEVEL%` into a variable immediately after the renderer runs and exits with that variable once `popd` has restored the caller's directory --- the same pattern `build.bat` and `check.bat` already used. A batch file's exit code is otherwise its last command's, and an unguarded `popd` resets `ERRORLEVEL` to `0`; `book.bat` used to end on a bare `popd`, so a failed render always reported success to whatever launched it. A script can check `book.bat`'s own exit code directly now. Calling `node book\render-book.mjs` directly and reading its exit code, or watching for the `saved:` line, remain equally valid. diff --git a/docs/Documentation/Pipeline-Stages.md b/docs/Documentation/Pipeline-Stages.md index 42e7d85e..ac74b02c 100644 --- a/docs/Documentation/Pipeline-Stages.md +++ b/docs/Documentation/Pipeline-Stages.md @@ -972,7 +972,7 @@ The handler table is built from the imported `HANDLERS` constant: |---|---|---| | `OPTIONS` | `{ [flag]: { type, empty? } }` | `tbdocs`'s flags, in the table shape `lib/cli.mjs`'s `parseCli` reads: `"string"` for a flag that takes a value, `"boolean"` for the rest. `--baseurl` alone has `empty: true`, since an empty base URL is the site root. `--no-check`, `--no-offline`, `--no-pdf` and `--no-fetch-assets` are flags of their own, not negations. | | `DEFAULTS` | frozen `BuildOpts` | The options a build takes with no flags given --- the defaults in the `BuildOpts` table below. `fetchAssets` is left out. | -| `parseCommandLine` | `(argv) → BuildOpts` | Reads `argv` (without Node's own two entries) through `parseCli`, then applies the flags in the order given, so a `--no-check` undoes only the check flags before it. Throws a `CliError` whose message `main()` prints before it exits 4: `unknown option: <arg>`, `unexpected argument: <arg>`, `<flag> takes no value`, `<flag> needs a value`, `<flag> needs a non-empty value`, or a `--port` or `--stall-timeout` value that is not one. | +| `parseCommandLine` | `(argv) → BuildOpts` | Reads `argv` (without Node's own two entries) through `parseCli`, then applies the flags in the order given, so a `--no-check` undoes only the check flags before it. Throws a `CliError` whose message `main()` prints before it exits 4: `unknown option: <arg>`, `unexpected argument: <arg>`, `<flag> takes no value`, `<flag> needs a value`, `<flag> needs a non-empty value`, a `--port` or `--stall-timeout` value that is not a number in its range (`--port expects a whole number from 1 to 65535, got: <value>`), or a `--url` that is not an absolute `http` or `https` URL. | ### `tbdocs.mjs` orchestrator diff --git a/docs/Documentation/Tools.md b/docs/Documentation/Tools.md index 01f8c0ba..1dbc3f07 100644 --- a/docs/Documentation/Tools.md +++ b/docs/Documentation/Tools.md @@ -8,7 +8,7 @@ permalink: /Documentation/Development/Tools # Tools and Scripts {: .no_toc } -One-line-per-tool reference for every executable in the documentation repository: the seven Windows batch wrappers at the repository root, the Node and Python scripts under `scripts/` (cross-platform except for [`tbbuild.mjs`](#tbbuild), which drives the twinBASIC IDE), the `tbdocs` orchestrator and its CLI flags, and the PDF render driver. If you are looking for the day-to-day workflow rather than a cheat sheet, the [Building and Deployment](Building) page is the gentler read; if you are modifying the build pipeline itself, the [tbdocs Internals](Builder) page goes one level deeper. Every Node tool answers `--help` or `-h` by printing its usage to standard output and exiting 0, without doing any of its work. A command line a tool cannot use is refused the same way by every Node tool: an unknown flag, a flag without its value or with an empty one, and an unexpected argument are reported on standard error and exit **2** (**4** in `tbdocs` and `check_links`, whose lower codes are a bitmask). A tool that reads its command line through `lib/cli.mjs` and takes search terms, file names or folder names takes one that starts with a dash after `--`. +One-line-per-tool reference for every executable in the documentation repository: the seven Windows batch wrappers at the repository root, the Node and Python scripts under `scripts/` (cross-platform except for [`tbbuild.mjs`](#tbbuild), which drives the twinBASIC IDE), the `tbdocs` orchestrator and its CLI flags, and the PDF render driver. If you are looking for the day-to-day workflow rather than a cheat sheet, the [Building and Deployment](Building) page is the gentler read; if you are modifying the build pipeline itself, the [tbdocs Internals](Builder) page goes one level deeper. Every Node tool answers `--help` or `-h` by printing its usage to standard output and exiting 0, without doing any of its work. A command line a tool cannot use is refused the same way by every Node tool: an unknown flag, a flag without its value or with an empty one, and an unexpected argument are reported on standard error and exit **2** (**4** in `tbdocs` and `check_links`, whose lower codes are a bitmask). So is a value a tool cannot use, before it does any work: a number that is not one or is out of range (a fraction where a whole number is needed), a regular expression that does not compile, a URL that is not an absolute `http` or `https` one, a date that is not ISO 8601, a value outside a fixed set, and options that exclude each other. A tool that reads its command line through `lib/cli.mjs` and takes search terms, file names or folder names takes one that starts with a dash after `--`. * TOC goes here {:toc} @@ -220,7 +220,7 @@ Full invocation: | `--src <path>` | Source root. Default: `docs` relative to the working directory. | | `--dest <path>` | Online-tree destination. Default: `<src>/_site`. The offline tree lands at `<dest>-offline`, the PDF tree at `<dest>-pdf`. The build refuses a destination that is or contains `<src>`, since cleaning it would delete the source. Inside `<src>`, it must be, or be inside, a folder directly under it whose name starts with `_site`, `_serve` or `_pdf`: anywhere else there, its output is read back as source by the next build or by `--serve`'s watcher. | | `--baseurl <prefix>` | Overrides `_config.yml`'s `baseurl`. Used by CI to inject the GitHub Pages base path on fork deployments. | -| `--url <origin>` | Overrides `_config.yml`'s `url`. Used by CI so canonical URLs match the actual deployment origin rather than the configured production host. | +| `--url <origin>` | Overrides `_config.yml`'s `url`; an absolute `http` or `https` URL. Used by CI so canonical URLs match the actual deployment origin rather than the configured production host. | | `--dry-run` | Skip every filesystem write. Useful for benchmarking or validating discovery / compute / render. | | `--no-offline` | Skip the offline tree pass. | | `--no-pdf` | Skip the PDF tree pass. | @@ -234,11 +234,11 @@ Full invocation: | `--update-page-baseline` | Record this build's page and static-file counts in `builder/page-baseline.json` as the drift guard's new baseline, in whichever direction they moved. An ordinary build raises the baseline by itself; only a **fall** needs this flag, because a fall is what the guard exists to catch. See [the page-count drift guard](Building#the-page-count-drift-guard). | | `--update-symbol-baseline` | Record this build's symbol-index URLs in `builder/symbol-baseline.json`, whichever left it. New URLs are recorded by an ordinary build; only a URL the index has **stopped** publishing needs this flag --- and usually needs a pinned heading id instead. See [the symbol index and its drift guard](Building#the-symbol-index). | | `--symbol-gaps <path>` | Write the public symbols no page documents to a JSON file: each one's package, container, name, kind, and the page its container is on. Names a package's `exclude_from_docs:` lists are left out. | -| `--stall-timeout <seconds>` | How long the build waits with no task completing before it gives up, names the outstanding tasks and exits 1. Default: 120. `0` disables the watchdog and returns the build to hanging in silence on a wedged worker. See [when a build stops instead of failing](Building#when-a-build-stops). | +| `--stall-timeout <seconds>` | How long the build waits with no task completing before it gives up, names the outstanding tasks and exits 1. Default: 120; a number of seconds, fractions allowed. `0` disables the watchdog and returns the build to hanging in silence on a wedged worker. See [when a build stops instead of failing](Building#when-a-build-stops). | | `--serve` | Start the long-lived dev server (watch + rebuild + SSE live-reload). Offline and PDF passes are skipped each rebuild. | -| `--port <N>` | HTTP port for `--serve` mode. Default: 4000. | +| `--port <N>` | HTTP port for `--serve` mode, a whole number from 1 to 65535. Default: 4000. | -Exit codes: **0** clean; **1** a link failure, a failed build step, a fall in the page count, a symbol-index URL lost, or a crash; **2** an integrity failure; **3** both. A command-line error --- an unknown flag, an unexpected argument, a flag without its value or with an empty one (`--baseurl` alone accepts one, meaning the site root), or a `--dest` the build refuses --- is reported on standard error and exits **4**, which no check can produce, so it is never read as a broken link. +Exit codes: **0** clean; **1** a link failure, a failed build step, a fall in the page count, a symbol-index URL lost, or a crash; **2** an integrity failure; **3** both. A command-line error --- an unknown flag, an unexpected argument, a flag without its value or with an empty one (`--baseurl` alone accepts one, meaning the site root), a value a flag cannot use (a `--port` that is not a port number, a negative or non-numeric `--stall-timeout`, a `--url` that is not an absolute `http` or `https` URL), or a `--dest` the build refuses --- is reported on standard error and exits **4**, which no check can produce, so it is never read as a broken link. ### check_links.mjs {: #check-links } @@ -271,7 +271,7 @@ Exit code 1 indicates broken links; exit code 2 indicates integrity-only failure node scripts/crawl_check.mjs <start-url> [--concurrency N] [--timeout MS] [--skip-external] -Online link crawler for the deployed site. Starts at `<start-url>`, GETs every same-origin / same-base-path page recursively, extracts every link the build's own check follows (`srcset` and `poster` included), and verifies that each link responds 2xx (HEAD for cross-origin, GET for same-origin). A request that fails before any response arrives, whether its connection is reset or it times out, is tried twice more, each time with the full `--timeout`, before its link is reported broken. The timeout covers a page's body as well as its headers. A page whose body breaks off, or is still arriving when the timeout runs out, is reported broken at once, without a retry, and the part that arrived is not parsed for links. Exits 0 if every link is reachable and every anchor exists, 1 if a link is broken or an anchor is missing, and 2 on a usage error --- a missing start URL, an unknown flag, a flag without its value, or a second argument --- or a crash. Use it after a manual `workflow_dispatch` deploy to verify the published site --- `check_links.mjs` covers the local filesystem; `crawl_check.mjs` covers the live deployed site. +Online link crawler for the deployed site. Starts at `<start-url>`, GETs every same-origin / same-base-path page recursively, extracts every link the build's own check follows (`srcset` and `poster` included), and verifies that each link responds 2xx (HEAD for cross-origin, GET for same-origin). A request that fails before any response arrives, whether its connection is reset or it times out, is tried twice more, each time with the full `--timeout`, before its link is reported broken. The timeout covers a page's body as well as its headers. A page whose body breaks off, or is still arriving when the timeout runs out, is reported broken at once, without a retry, and the part that arrived is not parsed for links. Exits 0 if every link is reachable and every anchor exists, 1 if a link is broken or an anchor is missing, and 2 on a usage error --- a missing start URL or one that is not an absolute `http` or `https` URL, an unknown flag, a flag without its value, a `--concurrency` that is not a whole number of at least 1, a `--timeout` that is not a whole number of milliseconds from 1 to 2147483647, or a second argument --- or a crash. Use it after a manual `workflow_dispatch` deploy to verify the published site --- `check_links.mjs` covers the local filesystem; `crawl_check.mjs` covers the live deployed site. ### check_a11y.mjs {: #check-a11y } @@ -300,7 +300,7 @@ Three details are essential and easy to break. It scans **`_site-offline/`, not Derives the accessibility scan's page list, and checks that it still covers every construct the site uses. The scan reads thirteen pages out of ~1,160, so the page list decides what it can report at all --- and a list that stops being representative fails silently: the rule for a construct no sample page carries simply never runs, and the gate stays green. -The script holds a list of **construct families**: markup shapes some axe rule keys on, each recording the rule that would otherwise have nothing to run on. `--check` (the default, and what `check.bat` and both CI workflows run) verifies every family the site uses is covered by at least one sample page, and exits 1 naming the gaps and the cheapest page that would close each. `--propose` runs a greedy set cover, ranked by measured per-page audit cost, and prints a replacement page list. `--census` reports what each family is, how many pages use it, and which page uses it most. `--fresh` applies to `--propose` alone: by default the set cover is seeded with the current list, so it prints what to *add*, and `--fresh` ignores the current list and covers from scratch --- which is how to ask whether the pages already in the sample still earn their place. +The script holds a list of **construct families**: markup shapes some axe rule keys on, each recording the rule that would otherwise have nothing to run on. `--check` (the default, and what `check.bat` and both CI workflows run) verifies every family the site uses is covered by at least one sample page, and exits 1 naming the gaps and the cheapest page that would close each. `--propose` runs a greedy set cover, ranked by measured per-page audit cost, and prints a replacement page list. `--census` reports what each family is, how many pages use it, and which page uses it most. Two of the three modes together are refused. `--fresh` applies to `--propose` alone: by default the set cover is seeded with the current list, so it prints what to *add*, and `--fresh` ignores the current list and covers from scratch --- which is how to ask whether the pages already in the sample still earn their place. **`--check` cannot report a construct nobody has registered.** It iterates the families that exist and asks whether the sample still covers each, so markup no family describes produces silence --- and that silence is the failure a derived sample exists to prevent. When the docs start using a construct they have not used before, registering the family is a deliberate step nothing will prompt you to take. @@ -554,7 +554,7 @@ Exits 1 on any failed probe, 2 if it cannot run. Verifies `lib/cli.mjs`, the module the tools read their command lines through, and each tool's recorded command-line errors. Nothing else tests how a tool reads its command line, which is how a value flag given no value came to be read as `NaN` or as the next flag. No built tree, no browser, no twinBASIC install; a few seconds. -The module's probes cover what `parseCli` returns and refuses, with a comparison against a strict `node:util` `parseArgs` over the same argument lists, and what `numberOption`, `withUsageError` and `printHelpAndExit` do. The parse is strict for every tool: `parseCli` refuses an unknown option, a boolean flag given a value, a value flag with no value, a positional beyond the count the tool declares, and an empty value unless the option allows one --- only `tbdocs`'s `--baseurl` does. The probes cover each refusal and the `--` that ends the options. They also cover the options `builder/command-line.mjs` returns for `tbdocs`, where `--no-check` makes the order of the flags matter; no case can, since each of those command lines starts a build. +The module's probes cover what `parseCli` returns and refuses, with a comparison against a strict `node:util` `parseArgs` over the same argument lists, and what `numberOption`, `choiceOption`, `regexOption`, `urlOption`, `dateOption`, `refuseTogether`, `withUsageError` and `printHelpAndExit` do. The first five are how a tool reads a value after the parse, and `refuseTogether` refuses options that exclude each other. The parse is strict for every tool: `parseCli` refuses an unknown option, a boolean flag given a value, a value flag with no value, a positional beyond the count the tool declares, and an empty value unless the option allows one --- only `tbdocs`'s `--baseurl` does. The probes cover each refusal and the `--` that ends the options. They also cover the options `builder/command-line.mjs` returns for `tbdocs`, where `--no-check` makes the order of the flags matter; no case can, since each of those command lines starts a build. The recorded cases are invocations that stop while the tool reads its command line, or at its first check of the project, folder, file or install the command line names, each with its exit code and what it prints on each stream: the tool's own words for the error exactly, a crash's only by the line that names the problem, and the opening of a usage text printed after it. Each case runs the tool as a child process, in an empty folder of its own and with `TB_IDE` and `PUPPETEER_EXECUTABLE_PATH` naming files that do not exist, so a case that gets past the command line fails on a different message rather than starting a twinBASIC IDE or a browser. A case belongs here only if the tool stops before doing any work. @@ -600,7 +600,7 @@ Value-equivalence check for the vendored axe source patches. Builds the same col The gate for any change to *what the scan runs*. axe is the site's correctness oracle, which makes it dangerous to tune: a change can make axe see **less** and still report a clean pass. That nearly shipped once --- blocking `just-the-docs.js` looked like a 130 ms win and quietly dropped the colour-contrast node count on one page from 54 to 2. This runs the full page × theme × viewport matrix twice, once under each of two named schemes from `axe-scan.mjs`'s registry, against one build in one process, and diffs the findings audit by audit (violations by `ruleId:nodeCount`, incomplete by rule-id set). -Two limits worth knowing. It compares a candidate against a baseline produced by that same scheme's element set, so it **cannot** detect a change that stops auditing elements entirely --- anything touching viewport, visibility or request blocking has to be argued from source instead. And it compares *which* findings axe produces, never their shape, so a scheme that passes every audit can still crash the reporter. Necessary, not sufficient. Both `--baseline` and `--candidate` default to `production`, so a bare run is already that A/A control --- run it after touching the matrix. +Two limits worth knowing. It compares a candidate against a baseline produced by that same scheme's element set, so it **cannot** detect a change that stops auditing elements entirely --- anything touching viewport, visibility or request blocking has to be argued from source instead. And it compares *which* findings axe produces, never their shape, so a scheme that passes every audit can still crash the reporter. Necessary, not sufficient. Both `--baseline` and `--candidate` default to `production`, so a bare run is already that A/A control --- run it after touching the matrix. Each must name a scheme that `--list` prints, and `--patches` a list of the patches it prints. #### Upgrading axe-core {: #upgrading-axe-core } @@ -614,7 +614,7 @@ The first asks whether the patched bundle still *finds* what the stock one finds Read that diff as **news, not as a regression to be suppressed.** axe ships new and revised WCAG rules between minors, so a bump can legitimately change what the scan reports. The gate exists to make the change visible, not to freeze coverage where it is. -The second asks whether the patched bundle still *computes* what the stock one computes, and it is the half a reader is most likely to skip --- `check.bat` and both CI workflows run it, so a bump that breaks it surfaces as a red PR rather than as something the upgrade asked for. It is not optional for a patch to the colour maths, because the fingerprint gate compares `incomplete` as a rule-id *set*: a colour error that shifted contrast ratios without flipping any pass/fail classification produces the same set and sails through. `--patch` defaults to `plain-color-fields`, the single entry `DEFAULT_PATCHES` carries; name another when adopting a new one. +The second asks whether the patched bundle still *computes* what the stock one computes, and it is the half a reader is most likely to skip --- `check.bat` and both CI workflows run it, so a bump that breaks it surfaces as a red PR rather than as something the upgrade asked for. It is not optional for a patch to the colour maths, because the fingerprint gate compares `incomplete` as a rule-id *set*: a colour error that shifted contrast ratios without flipping any pass/fail classification produces the same set and sails through. `--patch` defaults to `plain-color-fields`, the single entry `DEFAULT_PATCHES` carries; name another, an entry of `SOURCE_PATCHES`, when adopting a new one. A third failure mode needs no gate at all: each substitution inside a patch asserts its target was found, so a bump that moves the code fails loudly rather than silently reverting to the slow path. What none of the three reaches is a result that merely looks wrong. [`check_a11y.mjs --stock-axe`](#check-a11y) injects the unmodified bundle, which says in one command whether the patch is implicated. diff --git a/docs/Documentation/Wisdom.md b/docs/Documentation/Wisdom.md index c294836e..0d062607 100644 --- a/docs/Documentation/Wisdom.md +++ b/docs/Documentation/Wisdom.md @@ -114,7 +114,7 @@ Outputs raw JSON under `wisdom/data/raw/`. Supports incremental runs --- a manif | `--dry-run` | Discover channels/threads; do not fetch messages | | `--force` | Ignore manifest; re-fetch all history | -When the session request cap is reached, the tool exits with code 2 --- re-run to continue where it left off. A command line the tool cannot use --- an unknown command or flag, a flag without its value or with an empty one, an unexpected argument --- is refused on standard error and also exits with code 2, so read the message to tell the two apart. +When the session request cap is reached, the tool exits with code 2 --- re-run to continue where it left off. A command line the tool cannot use --- an unknown command or flag, a flag without its value or with an empty one, an unexpected argument --- is refused on standard error and also exits with code 2, so read the message to tell the two apart. So is a value it cannot use: a `--concurrency` or `--cap` that is not a whole number of at least 1, a `--rate-limit` that is not greater than 0, a `--since` that is not an ISO 8601 date no earlier than 2015-01-01, a `--min-confidence` other than `high`, `medium` or `low`, and two of `extract`'s `--since`, `--all` and `--force` given together (`--merge` reads none of the three). ### Phase 2 --- Process @@ -154,8 +154,8 @@ The prep step also writes two shared reference files that workflow agents read f | `--since <date>` | Only analyse threads created after this date | | `--channel <name>` | Restrict to threads from this channel **name** (repeatable) | | `--min-confidence <level>` | Skip findings below `high`, `medium`, or `low` (default: `low`) | -| `--all` | Bootstrap: process all threads, ignoring state and channel filter | -| `--force` | Re-process threads even if their watermark matches state | +| `--all` | Bootstrap: process all threads, ignoring state and channel filter. Not with `--since` or `--force` | +| `--force` | Re-process threads even if their watermark matches state. Not with `--since` or `--all` | | `--dry-run` | Write the prep file but do not invoke the workflow | | `--merge` | Graft extract-results-\*.json into staging.md and advance state (no agents) | diff --git a/eval/README.md b/eval/README.md index 2d5b88e5..2b9c3c74 100644 --- a/eval/README.md +++ b/eval/README.md @@ -46,6 +46,12 @@ node eval/run_case.mjs --corpus <corpus> --site <snapshot> --protocol repo \ --goal <dir>/UC-56.goal.md --out <dir>/UC-56 ``` +`build_corpus.mjs` empties `--dest` before it writes, so it refuses a `--dest` that is or +contains the repository root, the current folder or `--src`, on stderr with exit 2. +`run_case.mjs` likewise refuses a `--timeout` (minutes) that is not a number greater than 0 +and at most 35791, and a +`--protocol` other than `repo` or `site`. + Each case is **one goal** from [usecases.md](usecases.md), in a file of its own, and nothing else. Never tell the evaluator what the case is testing or that a hazard exists. `run_case.mjs` puts the evaluator-facing part of [protocol.md](protocol.md) in front of the @@ -91,8 +97,10 @@ node eval/nav_hops.mjs '^/tB/Modules/ErrObject/Number$' # hops by link from docs node eval/nav_hops.mjs --from README.md '^/Documentation/Development/Tools$' ``` -Each of these refuses an unknown flag and a flag without its value on stderr and exits 2, and -`transcript.mjs` also refuses a second file. A search term, regex or file name that starts +Each of these refuses an unknown flag and a flag without its value on stderr and exits 2; +`site_search.mjs` also refuses a `--n` that is not a whole number of at least 1 and search +terms given with `--composition`, `nav_hops.mjs` a regular expression that does not +compile, and `transcript.mjs` a second file. A search term, regex or file name that starts with a dash goes after `--`: `node eval/site_search.mjs -- "-1 as an error code"`. diff --git a/eval/build_corpus.mjs b/eval/build_corpus.mjs index c4d844ea..c22914e7 100644 --- a/eval/build_corpus.mjs +++ b/eval/build_corpus.mjs @@ -15,7 +15,7 @@ import fs from "node:fs"; import path from "node:path"; -import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { CliError, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { isOutputTree } from "../lib/markdown-files.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; @@ -94,17 +94,45 @@ const WITHHELD = [ const STUB = "/* [ source withheld for this exercise -- treat this file as unreadable ] */\n"; +// Whether `inner` is `outer` or lies under it; path.relative compares +// case-insensitively on Windows. +function isInside(outer, inner) { + const rel = path.relative(outer, inner); + return rel === "" || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel)); +} + +// build() empties `dest` before it writes anything, so a `dest` that is or +// contains a folder the tool runs from or reads would delete it. +function refuseDest(dest, src) { + const doomed = [ + ["the repository root", REPO_ROOT], + ["the current folder", process.cwd()], + [`--src ${src}`, src], + ]; + for (const [what, folder] of doomed) { + if (isInside(dest, path.resolve(folder))) { + throw new CliError("bad-dest", `refusing --dest ${dest}: it is or contains ${what}, which cleaning it would delete`, { option: "--dest", value: dest }); + } + } +} + function parseArgs(argv) { - const { values } = withUsageError(() => parseCli(argv, { - options: { - src: { type: "string" }, - dest: { type: "string" }, - quiet: { type: "boolean", default: false }, - help: { type: "boolean", short: "h" }, - }, - positionals: 0, - stopAt: ["help"], - })); + const { values } = withUsageError(() => { + const cli = parseCli(argv, { + options: { + src: { type: "string" }, + dest: { type: "string" }, + quiet: { type: "boolean", default: false }, + help: { type: "boolean", short: "h" }, + }, + positionals: 0, + stopAt: ["help"], + }); + if (cli.stopped !== "help" && "dest" in cli.values) { + refuseDest(path.resolve(cli.values.dest), "src" in cli.values ? path.resolve(cli.values.src) : REPO_ROOT); + } + return cli; + }); return { src: "src" in values ? path.resolve(values.src) : REPO_ROOT, dest: "dest" in values ? path.resolve(values.dest) : null, diff --git a/eval/nav_hops.mjs b/eval/nav_hops.mjs index 100cb551..4d6857f8 100644 --- a/eval/nav_hops.mjs +++ b/eval/nav_hops.mjs @@ -32,7 +32,7 @@ import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { parseCli, printHelpAndExit, regexOption, withUsageError } from "../lib/cli.mjs"; import { parseFrontmatter } from "../lib/frontmatter.mjs"; import { blockRegions } from "../lib/markdown.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; @@ -46,20 +46,25 @@ const USAGE = "See eval/README.md."; function parseArgs(argv) { - const { values, positionals } = withUsageError(() => parseCli(argv, { - options: { - from: { type: "string", default: "docs/index.md" }, - src: { type: "string" }, - help: { type: "boolean", short: "h" }, - }, - positionals: { min: 0, max: Infinity }, - stopAt: ["help"], - })); + const { values, positionals, patterns } = withUsageError(() => { + const cli = parseCli(argv, { + options: { + from: { type: "string", default: "docs/index.md" }, + src: { type: "string" }, + help: { type: "boolean", short: "h" }, + }, + positionals: { min: 0, max: Infinity }, + stopAt: ["help"], + }); + if (cli.stopped === "help") return cli; + return { ...cli, patterns: cli.positionals.map((t) => regexOption(t, { option: "<url-regex>", flags: "i" })) }; + }); return { from: values.from, src: "src" in values ? path.resolve(values.src) : REPO_ROOT, help: values.help, targets: positionals, + patterns, }; } @@ -154,8 +159,8 @@ async function main(argv) { const show = (f) => path.relative(o.src, f).split(path.sep).join("/"); let unreachable = 0; - for (const t of o.targets) { - const re = new RegExp(t, "i"); + for (const [i, t] of o.targets.entries()) { + const re = o.patterns[i]; // Breadth-first order: the first reachable match is a nearest one. const hit = [...prev.keys()].find((f) => re.test(pages.urlOf.get(f) ?? "")); if (!hit) { diff --git a/eval/run_case.mjs b/eval/run_case.mjs index ff537c2d..72220eb3 100644 --- a/eval/run_case.mjs +++ b/eval/run_case.mjs @@ -58,7 +58,7 @@ import { spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { choiceOption, numberOption, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { blockRegions } from "../lib/markdown.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; import { printDigest, readTranscript, summarize } from "./transcript.mjs"; @@ -77,23 +77,32 @@ const MEMORY_FILES = ["CLAUDE.md", "CLAUDE.local.md", "AGENTS.md"]; const fwd = (p) => p.split(path.sep).join("/"); function parseArgs(argv) { - const { values } = withUsageError(() => parseCli(argv, { - options: { - corpus: { type: "string" }, - site: { type: "string" }, - goal: { type: "string" }, - out: { type: "string" }, - protocol: { type: "string" }, - claude: { type: "string", default: process.env.EVAL_CLAUDE || "claude" }, - model: { type: "string", default: "sonnet" }, - timeout: { type: "string" }, - smoke: { type: "boolean" }, - "prompt-only": { type: "boolean" }, - help: { type: "boolean", short: "h" }, - }, - positionals: 0, - stopAt: ["help"], - })); + const { values, timeout } = withUsageError(() => { + const cli = parseCli(argv, { + options: { + corpus: { type: "string" }, + site: { type: "string" }, + goal: { type: "string" }, + out: { type: "string" }, + protocol: { type: "string" }, + claude: { type: "string", default: process.env.EVAL_CLAUDE || "claude" }, + model: { type: "string", default: "sonnet" }, + timeout: { type: "string" }, + smoke: { type: "boolean" }, + "prompt-only": { type: "boolean" }, + help: { type: "boolean", short: "h" }, + }, + positionals: 0, + stopAt: ["help"], + }); + if (cli.stopped === "help") return { values: cli.values }; + // Minutes; the timer takes at most 2147483647 ms. + const minutes = "timeout" in cli.values + ? numberOption(cli.values.timeout, { option: "--timeout", above: 0, max: 35791 }) + : 20; + if ("protocol" in cli.values) choiceOption(cli.values.protocol, { option: "--protocol", choices: ["repo", "site"] }); + return { values: cli.values, timeout: minutes }; + }); const o = { corpus: "corpus" in values ? path.resolve(values.corpus) : undefined, site: "site" in values ? path.resolve(values.site) : undefined, @@ -102,7 +111,7 @@ function parseArgs(argv) { protocol: values.protocol, claude: values.claude, model: values.model, - timeout: "timeout" in values ? Number(values.timeout) : 20, + timeout, smoke: values.smoke, promptOnly: values.promptOnly, help: values.help, diff --git a/eval/search_quality.mjs b/eval/search_quality.mjs index 233c65bc..92e0af0c 100644 --- a/eval/search_quality.mjs +++ b/eval/search_quality.mjs @@ -126,7 +126,7 @@ import fs from "node:fs"; import path from "node:path"; import zlib from "node:zlib"; import { performance } from "node:perf_hooks"; -import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { numberOption, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; import { load, buildIndex, search, KIND_WORDS } from "./site_search.mjs"; @@ -134,26 +134,36 @@ import { load, buildIndex, search, KIND_WORDS } from "./site_search.mjs"; // ---------------------------------------------------------------- arg parsing function parseArgs(argv) { - const { values } = withUsageError(() => parseCli(argv, { - options: { - site: { type: "string" }, - save: { type: "string" }, - compare: { type: "string" }, - sample: { type: "string" }, - worst: { type: "string" }, - failures: { type: "string" }, - help: { type: "boolean", short: "h" }, - }, - positionals: 0, - stopAt: ["help"], - })); + const { values, sample, worstN, failures } = withUsageError(() => { + const cli = parseCli(argv, { + options: { + site: { type: "string" }, + save: { type: "string" }, + compare: { type: "string" }, + sample: { type: "string" }, + worst: { type: "string" }, + failures: { type: "string" }, + help: { type: "boolean", short: "h" }, + }, + positionals: 0, + stopAt: ["help"], + }); + if (cli.stopped === "help") return cli; + const v = cli.values; + return { + ...cli, + sample: "sample" in v ? numberOption(v.sample, { option: "--sample", integer: true, min: 1 }) : null, + worstN: "worst" in v ? numberOption(v.worst, { option: "--worst", integer: true, min: 0 }) : 15, + failures: "failures" in v ? numberOption(v.failures, { option: "--failures", integer: true, min: 0 }) : 0, + }; + }); return { site: "site" in values ? path.resolve(values.site) : path.join(REPO_ROOT, "docs/_site"), save: "save" in values ? path.resolve(values.save) : null, compare: "compare" in values ? path.resolve(values.compare) : null, - sample: "sample" in values ? Number(values.sample) : null, - worstN: "worst" in values ? Number(values.worst) : 15, - failures: "failures" in values ? Number(values.failures) : 0, + sample, + worstN, + failures, help: values.help, }; } diff --git a/eval/site_search.mjs b/eval/site_search.mjs index ef6b59f6..9336590b 100644 --- a/eval/site_search.mjs +++ b/eval/site_search.mjs @@ -25,25 +25,33 @@ import { createRequire } from "node:module"; import { pathToFileURL } from "node:url"; import fs from "node:fs"; import path from "node:path"; -import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { CliError, numberOption, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; const require = createRequire(import.meta.url); function parseArgs(argv) { - const { values, positionals } = withUsageError(() => parseCli(argv, { - options: { - site: { type: "string" }, - n: { type: "string" }, - composition: { type: "boolean" }, - help: { type: "boolean", short: "h" }, - }, - positionals: { min: 0 }, - stopAt: ["help"], - })); + const { values, positionals, n } = withUsageError(() => { + const cli = parseCli(argv, { + options: { + site: { type: "string" }, + n: { type: "string" }, + composition: { type: "boolean" }, + help: { type: "boolean", short: "h" }, + }, + positionals: { min: 0 }, + stopAt: ["help"], + }); + if (cli.stopped === "help") return cli; + const n = "n" in cli.values ? numberOption(cli.values.n, { option: "--n", integer: true, min: 1 }) : 8; + if (cli.values.composition && cli.positionals.length) { + throw new CliError("conflict", "--composition takes no search terms", { option: "--composition" }); + } + return { ...cli, n }; + }); return { site: "site" in values ? path.resolve(values.site) : path.join(REPO_ROOT, "docs/_site"), - n: "n" in values ? Number(values.n) : 8, + n, composition: values.composition, help: values.help, terms: positionals, diff --git a/lib/cli.mjs b/lib/cli.mjs index c3c6bc68..3ca60b7c 100644 --- a/lib/cli.mjs +++ b/lib/cli.mjs @@ -2,8 +2,10 @@ // // parseCli() reads an argument list against a table of options and either // returns what it found or throws a CliError that names the problem. A tool -// prints that error and exits with withUsageError(). numberOption() reads a -// number that has to be one, and printHelpAndExit() prints a usage text. +// prints that error and exits with withUsageError(). numberOption(), +// choiceOption(), regexOption(), urlOption() and dateOption() read a value +// after the parse, refuseTogether() refuses options that exclude each other, +// and printHelpAndExit() prints a usage text. // // The parse is strict: an unknown option, a boolean given a value, a value flag // with no value, an empty value and a positional the tool does not take are all @@ -124,22 +126,86 @@ export function parseCli(argv, { options = {}, positionals = 0, stopAt = [] } = } /** - * Reads `value`, the text given to the flag `option`, as a number: a whole one - * when `integer`, between `min` and `max` inclusive. Blank text is not a - * number, though Number("") is 0. Throws a CliError "bad-number", whose message - * is `message(value)` when that is given. + * Reads `value`, the text given to the flag `option`, as Number() reads it: a + * whole number when `integer`, between `min` and `max` inclusive, and greater + * than `above` when that is given. Blank text is not a number, though + * Number("") is 0. Throws a CliError "bad-number". */ -export function numberOption(value, { option, integer = false, min = -Infinity, max = Infinity, message } = {}) { +export function numberOption(value, { option, integer = false, min = -Infinity, max = Infinity, above } = {}) { const n = typeof value === "string" && value.trim() !== "" ? Number(value) : Number.NaN; - if (Number.isFinite(n) && (!integer || Number.isInteger(n)) && n >= min && n <= max) return n; - const range = Number.isFinite(min) && Number.isFinite(max) ? ` from ${min} to ${max}` + if (Number.isFinite(n) && (!integer || Number.isInteger(n)) && n >= min && n <= max && !(n <= above)) return n; + const range = above !== undefined ? ` greater than ${above}${Number.isFinite(max) ? ` and at most ${max}` : ""}` + : Number.isFinite(min) && Number.isFinite(max) ? ` from ${min} to ${max}` : Number.isFinite(min) ? ` of at least ${min}` : Number.isFinite(max) ? ` of at most ${max}` : ""; - const text = message ? message(value) : `${option} expects ${integer ? "a whole number" : "a number"}${range}, got: ${value}`; + const text = `${option} expects ${integer ? "a whole number" : "a number"}${range}, got: ${value}`; throw new CliError("bad-number", text, { option, value }); } +/** + * Returns `value` if it is one of `choices`, and otherwise throws a CliError + * "bad-choice" that lists them. + */ +export function choiceOption(value, { option, choices }) { + if (choices.includes(value)) return value; + const list = choices.length > 1 ? `${choices.slice(0, -1).join(", ")} or ${choices.at(-1)}` : choices[0]; + throw new CliError("bad-choice", `${option} expects ${list}, got: ${value}`, { option, value }); +} + +/** + * Compiles `value` as a regular expression with `flags`, or throws a CliError + * "bad-regex" carrying the compiler's reason. + */ +export function regexOption(value, { option, flags = "" }) { + try { + return new RegExp(value, flags); + } catch (err) { + throw new CliError("bad-regex", `${option} expects a regular expression, got: ${value} (${err.message})`, { option, value }); + } +} + +/** + * Reads `value` as an absolute URL whose scheme is one of `protocols` (each + * with its colon), or throws a CliError "bad-url". + */ +export function urlOption(value, { option, protocols = ["http:", "https:"] }) { + let url = null; + try { + url = new URL(value); + } catch {} + if (url && protocols.includes(url.protocol)) return url; + throw new CliError("bad-url", `${option} expects an absolute ${protocols.map((p) => p.slice(0, -1)).join(" or ")} URL, got: ${value}`, { option, value }); +} + +/** + * Reads `value` as an ISO 8601 date, `YYYY-MM-DD` with an optional time after a + * `T`, and returns its time in milliseconds, no earlier than `min` when that is + * given (a date string). Date.parse alone takes "12" as a date in 2001, and + * 2024-02-30 as the first of March. Throws a CliError "bad-date". + */ +export function dateOption(value, { option, min } = {}) { + const day = value.slice(0, 10); + const real = /^\d{4}-\d{2}-\d{2}(?:T\S+)?$/.test(value) && new Date(Date.parse(day) || 0).toISOString().slice(0, 10) === day; + const ms = real ? Date.parse(value) : Number.NaN; + const floor = min === undefined ? -Infinity : Date.parse(min); + if (Number.isFinite(ms) && ms >= floor) return ms; + const after = min === undefined ? "" : ` no earlier than ${min}`; + throw new CliError("bad-date", `${option} expects an ISO 8601 date (YYYY-MM-DD)${after}, got: ${value}`, { option, value }); +} + +/** + * Throws a CliError "conflict" when more than one of `names`, long option names + * as typed without their dashes, has a value in `values` (parseCli's result, + * keyed by camelCase) other than undefined or false. + */ +export function refuseTogether(values, names) { + const given = names.filter((n) => values[camelCase(n)] !== undefined && values[camelCase(n)] !== false); + if (given.length < 2) return; + const list = given.map((n) => `--${n}`); + throw new CliError("conflict", `${list.slice(0, -1).join(", ")} and ${list.at(-1)} cannot be given together`, { options: list }); +} + // A stream named "stdout" or "stderr", or any object with a write(), which is // how the probes read what was printed. const streamOf = (stream) => (typeof stream === "string" ? process[stream] : stream); diff --git a/scripts/addin_test.mjs b/scripts/addin_test.mjs index 4c83aa35..07e684c9 100644 --- a/scripts/addin_test.mjs +++ b/scripts/addin_test.mjs @@ -53,7 +53,7 @@ import { existsSync, mkdirSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { numberOption, parseCli, printHelpAndExit, refuseTogether, regexOption, withUsageError } from "../lib/cli.mjs"; import { removeTree } from "./lib/tb-ide-copy.mjs"; import { wantShow } from "./lib/tb-ide.mjs"; import { buildNumber, findIde } from "./lib/tb-install.mjs"; @@ -93,10 +93,16 @@ const { values } = withUsageError(() => parseCli(process.argv.slice(2), { })); if (values.help) printHelpAndExit(USAGE); const die = (code, msg) => { console.error(msg); process.exit(code); }; -const only = values.only ? new RegExp(values.only) : null; -const basePort = Number(values.port || 9560); -const jobs = Math.max(1, Number(values.jobs || 2)); -const laneTimeout = Number(values.timeout || 600) * 1000; +// setTimeout takes at most 2147483647 ms, so a lane's timeout is at most 2147483 s. +const { only, basePort, jobs, laneTimeout } = withUsageError(() => { + refuseTogether(values, ["show", "hide"]); + return { + only: values.only ? regexOption(values.only, { option: "--only" }) : null, + basePort: numberOption(values.port ?? "9560", { option: "--port", integer: true, min: 1, max: 65535 }), + jobs: numberOption(values.jobs ?? "2", { option: "--jobs", integer: true, min: 1 }), + laneTimeout: numberOption(values.timeout ?? "600", { option: "--timeout", above: 0, max: 2147483 }) * 1000, + }; +}); const show = wantShow({ show: values.show, hide: values.hide }); // ---------------------------------------------------------------- refusals diff --git a/scripts/check_a11y_fingerprint.mjs b/scripts/check_a11y_fingerprint.mjs index 8831be78..b144fbe9 100644 --- a/scripts/check_a11y_fingerprint.mjs +++ b/scripts/check_a11y_fingerprint.mjs @@ -74,7 +74,7 @@ import { SOURCE_PATCHES, } from "./lib/axe-scan.mjs"; import { withBrowser } from "./lib/browser.mjs"; -import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { choiceOption, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; // ---- CLI ------------------------------------------------------------------ const cli = withUsageError( @@ -131,8 +131,17 @@ let jsonOut = cli.values.json ?? null; let unminified = cli.values.unminified; let patchesArg = cli.values.patches; -const baseline = getScheme(baselineLabel); -const candidate = getScheme(candidateLabel); +const schemeNames = Object.keys(SCHEMES); +const patchNames = Object.keys(SOURCE_PATCHES); +const { baseline, candidate } = withUsageError(() => { + for (const name of patchesArg.split(",").map((x) => x.trim()).filter(Boolean)) { + choiceOption(name, { option: "--patches", choices: patchNames }); + } + return { + baseline: getScheme(choiceOption(baselineLabel, { option: "--baseline", choices: schemeNames })), + candidate: getScheme(choiceOption(candidateLabel, { option: "--candidate", choices: schemeNames })), + }; +}); rootDir = resolve(rootDir); const matrix = buildMatrix({ diff --git a/scripts/check_axe_patch_equiv.mjs b/scripts/check_axe_patch_equiv.mjs index 9524650c..57a433dd 100644 --- a/scripts/check_axe_patch_equiv.mjs +++ b/scripts/check_axe_patch_equiv.mjs @@ -32,10 +32,11 @@ import { VIEWPORTS, gotoPage, newAuditPage, + SOURCE_PATCHES, readAxeSource, } from "./lib/axe-scan.mjs"; import { withBrowser } from "./lib/browser.mjs"; -import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { choiceOption, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; const cli = withUsageError( () => @@ -50,7 +51,9 @@ const cli = withUsageError( if (cli.stopped === "help") { printHelpAndExit("usage: node scripts/check_axe_patch_equiv.mjs [--patch NAME] [-h, --help]"); } -let patchName = cli.values.patch; +const patchName = withUsageError( + () => choiceOption(cli.values.patch, { option: "--patch", choices: Object.keys(SOURCE_PATCHES) }), +); // Runs in the page against whichever bundle was injected. const PROBE = () => { diff --git a/scripts/check_cli.mjs b/scripts/check_cli.mjs index e104aea0..be9ab377 100644 --- a/scripts/check_cli.mjs +++ b/scripts/check_cli.mjs @@ -39,7 +39,9 @@ import { availableParallelism, tmpdir } from "node:os"; import path from "node:path"; import { parseArgs } from "node:util"; import { DEFAULTS, parseCommandLine } from "../builder/command-line.mjs"; -import { CliError, numberOption, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { + CliError, choiceOption, dateOption, numberOption, parseCli, printHelpAndExit, refuseTogether, regexOption, urlOption, withUsageError, +} from "../lib/cli.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; import { createProbes, exitOnCrash } from "./lib/gate-probes.mjs"; @@ -242,8 +244,95 @@ const OPTIONS = { check("numberOption takes a fraction otherwise", numberOption("1.5", { option: "--t", min: 0 }) === 1.5); const nan = cliError(() => numberOption("abc", { option: "--t", min: 0 }), "bad-number"); check("numberOption refuses text that is not a number", nan?.message === "--t expects a number of at least 0, got: abc", show(nan)); - const own = cliError(() => numberOption(undefined, { option: "--t", message: (v) => `--t: ${v}?` }), "bad-number"); - check("numberOption takes the tool's own message", own?.message === "--t: undefined?", show(own)); + const above = { option: "--t", above: 0 }; + const zero = cliError(() => numberOption("0", above), "bad-number"); + check("numberOption above 0 refuses 0, saying greater than 0", zero?.message === "--t expects a number greater than 0, got: 0" && zero.option === "--t" && zero.value === "0", + show(zero)); + check("numberOption above 0 takes 0.5 and refuses a negative", numberOption("0.5", above) === 0.5 && Boolean(cliError(() => numberOption("-1", above), "bad-number"))); + const capped = { option: "--t", above: 0, max: 5 }; + const over = cliError(() => numberOption("6", capped), "bad-number"); + check("numberOption above 0 with a max names both", over?.message === "--t expects a number greater than 0 and at most 5, got: 6", show(over)); + check("numberOption above 0 with a max takes the max itself", numberOption("5", capped) === 5); + const whole = cliError(() => numberOption("0", { option: "--t", integer: true, above: 0 }), "bad-number"); + check("numberOption above 0 for a whole number says so", whole?.message === "--t expects a whole number greater than 0, got: 0", show(whole)); + const atMost = cliError(() => numberOption("10", { option: "--t", max: 9 }), "bad-number"); + check("numberOption with only a max names it", atMost?.message === "--t expects a number of at most 9, got: 10", show(atMost)); + const free = cliError(() => numberOption("x", { option: "--t" }), "bad-number"); + check("numberOption with no range names none", free?.message === "--t expects a number, got: x", show(free)); + check("numberOption reads what Number reads: hex, exponent and padding", + numberOption("0x10", { option: "--t" }) === 16 && numberOption("1e3", { option: "--t" }) === 1000 && numberOption(" 7 ", { option: "--t" }) === 7); + check("numberOption refuses trailing text, blank text, a missing value and Infinity", + ["12abc", " ", "", undefined, "Infinity"].every((v) => cliError(() => numberOption(v, { option: "--t" }), "bad-number"))); +} + +// ------------------------------------------------------------ the other checkers + +{ + const pick = { option: "--oracle", choices: ["fs", "index"] }; + check("choiceOption returns a listed value", choiceOption("index", pick) === "index"); + const bad = cliError(() => choiceOption("x", pick), "bad-choice"); + check("choiceOption refuses another, listing the choices", bad?.message === "--oracle expects fs or index, got: x" && bad.option === "--oracle" && bad.value === "x", + show(bad)); + const three = cliError(() => choiceOption("d", { option: "--c", choices: ["a", "b", "c"] }), "bad-choice"); + check("choiceOption lists three with a comma and or", three?.message === "--c expects a, b or c, got: d", show(three)); + const one = cliError(() => choiceOption("d", { option: "--c", choices: ["a"] }), "bad-choice"); + check("choiceOption names a single choice alone", one?.message === "--c expects a, got: d", show(one)); + check("choiceOption is case-sensitive", Boolean(cliError(() => choiceOption("FS", pick), "bad-choice"))); +} +{ + const re = regexOption("^a+$", { option: "--only", flags: "i" }); + check("regexOption returns the RegExp, with its flags", re instanceof RegExp && re.flags === "i" && re.test("AA"), show(re)); + check("regexOption has no flags by default", regexOption("a", { option: "--only" }).flags === ""); + const bad = cliError(() => regexOption("(", { option: "--only" }), "bad-regex"); + check("regexOption refuses a pattern that does not compile, naming it and the reason", + bad?.option === "--only" && bad.value === "(" && /^--only expects a regular expression, got: \( \(.+\)$/.test(bad.message), show(bad)); + check("regexOption refuses a flag that does not exist", Boolean(cliError(() => regexOption("a", { option: "--only", flags: "q" }), "bad-regex"))); +} +{ + const url = urlOption("https://example.org/a/b?c=1", { option: "--url" }); + check("urlOption returns a URL for an http or https address", url instanceof URL && url.hostname === "example.org" && url.pathname === "/a/b", show(url)); + check("urlOption takes http", urlOption("http://127.0.0.1:9/", { option: "--url" }).port === "9"); + const rel = cliError(() => urlOption("foo", { option: "--url" }), "bad-url"); + check("urlOption refuses text that is not an absolute URL", rel?.message === "--url expects an absolute http or https URL, got: foo" && rel.option === "--url" && rel.value === "foo", + show(rel)); + const mail = cliError(() => urlOption("mailto:x", { option: "--url" }), "bad-url"); + check("urlOption refuses another scheme", mail?.message === "--url expects an absolute http or https URL, got: mailto:x", show(mail)); + check("urlOption refuses a path", Boolean(cliError(() => urlOption("/docs/", { option: "--url" }), "bad-url"))); + check("urlOption takes the schemes it is given", + urlOption("ftp://h/f", { option: "--u", protocols: ["ftp:"] }).protocol === "ftp:" + && cliError(() => urlOption("https://h/", { option: "--u", protocols: ["ftp:"] }), "bad-url")?.message === "--u expects an absolute ftp URL, got: https://h/"); +} +{ + const since = { option: "--since", min: "2015-01-01" }; + check("dateOption returns the time in ms of a date", dateOption("2024-02-29", { option: "--since" }) === Date.parse("2024-02-29")); + check("dateOption takes a time after a T", dateOption("2024-02-03T10:00Z", { option: "--since" }) === Date.parse("2024-02-03T10:00Z")); + const bare = cliError(() => dateOption("12", { option: "--since" }), "bad-date"); + check("dateOption refuses 12, which Date.parse reads as a date in 2001", + bare?.message === "--since expects an ISO 8601 date (YYYY-MM-DD), got: 12" && bare.option === "--since" && bare.value === "12", show(bare)); + const march = cliError(() => dateOption("2024-02-30", { option: "--since" }), "bad-date"); + check("dateOption refuses a day the month does not have", march?.message === "--since expects an ISO 8601 date (YYYY-MM-DD), got: 2024-02-30", show(march)); + check("dateOption refuses 29 February of a common year", Boolean(cliError(() => dateOption("2023-02-29", { option: "--since" }), "bad-date"))); + check("dateOption refuses other shapes", + ["2024-2-3", "2024/02/03", "Feb 3 2024", "", "2024-02-03 10:00", "2024-13-01"].every((v) => cliError(() => dateOption(v, { option: "--since" }), "bad-date"))); + const early = cliError(() => dateOption("2014-12-31", since), "bad-date"); + check("dateOption refuses a date before min, naming it", + early?.message === "--since expects an ISO 8601 date (YYYY-MM-DD) no earlier than 2015-01-01, got: 2014-12-31", show(early)); + check("dateOption takes min itself and a later date", dateOption("2015-01-01", since) === Date.parse("2015-01-01") && dateOption("2024-02-03T10:00Z", since) > Date.parse("2015-01-01")); +} +{ + const names = ["check", "propose", "census"]; + check("refuseTogether takes none of the names given", refuseTogether({}, names) === undefined && refuseTogether({ other: true }, names) === undefined); + check("refuseTogether takes one", refuseTogether({ check: true }, names) === undefined); + const two = cliError(() => refuseTogether({ check: true, census: true }, names), "conflict"); + check("refuseTogether refuses two, naming both", two?.message === "--check and --census cannot be given together" && show(two.options) === show(["--check", "--census"]), + show(two)); + const three = cliError(() => refuseTogether({ check: true, propose: true, census: true }, names), "conflict"); + check("refuseTogether refuses three, naming all", three?.message === "--check, --propose and --census cannot be given together", show(three)); + check("refuseTogether does not count false or undefined", + refuseTogether({ check: true, propose: false, census: undefined }, names) === undefined && refuseTogether({ check: false, propose: false }, names) === undefined); + const keyed = cliError(() => refuseTogether({ rootDir: "a", showAll: true }, ["root-dir", "show-all"]), "conflict"); + check("refuseTogether reads a long name through its camelCase key", keyed?.message === "--root-dir and --show-all cannot be given together", show(keyed)); + check("refuseTogether counts a value that is not false", Boolean(cliError(() => refuseTogether({ check: "x", propose: 0 }, names), "conflict"))); } // ------------------------------------------------------------ printing and exiting @@ -321,7 +410,7 @@ const CASES = [ // "error: ". { tool: "builder/tbdocs.mjs", args: ["--port"], exit: 4, stderr: "--port needs a value\n" }, { tool: "builder/tbdocs.mjs", args: ["--dest", "--no-pdf"], exit: 4, stderr: "--dest needs a value\n" }, - { tool: "builder/tbdocs.mjs", args: ["--port=0"], exit: 4, stderr: "--port expects a port number from 1 to 65535, got: 0\n" }, + { tool: "builder/tbdocs.mjs", args: ["--port=0"], exit: 4, stderr: "--port expects a whole number from 1 to 65535, got: 0\n" }, { tool: "builder/tbdocs.mjs", args: ["--bogus"], exit: 4, stderr: "unknown option: --bogus\n" }, { tool: "scripts/check_links.mjs", args: ["no-such-tree", "--root-dir"], exit: 4, stderr: "error: --root-dir needs a value\n" }, { tool: "scripts/check_links.mjs", args: ["no-such-tree", "--forbid"], exit: 4, stderr: "error: --forbid needs a value\n" }, @@ -384,17 +473,17 @@ const CASES = [ // only its empty list is a case here. { tool: "scripts/tbbuild.mjs", args: [], exit: 2, stderr: /^usage: node scripts\/tbbuild\.mjs / }, { tool: "scripts/tbbuild.mjs", args: ["x.twinproj", "--help"], exit: 0, stdout: /^usage: node scripts\/tbbuild\.mjs / }, - { tool: "scripts/tbbuild.mjs", args: ["x.twinproj", "--arch", "win99"], exit: 2, stderr: /^usage: node scripts\/tbbuild\.mjs / }, - { tool: "scripts/tbbuild.mjs", args: ["x.twinproj", "--port", "1.5"], exit: 2, stderr: /^--port takes a positive whole number\nusage: node scripts\/tbbuild\.mjs / }, - { tool: "scripts/tbbuild.mjs", args: ["x.twinproj", "--timeout", "abc"], exit: 2, stderr: /^--timeout takes a positive number\nusage: node scripts\/tbbuild\.mjs / }, + { tool: "scripts/tbbuild.mjs", args: ["x.twinproj", "--arch", "win99"], exit: 2, stderr: /^--arch expects win32 or win64, got: win99\nusage: node scripts\/tbbuild\.mjs / }, + { tool: "scripts/tbbuild.mjs", args: ["x.twinproj", "--port", "1.5"], exit: 2, stderr: /^--port expects a whole number from 1 to 65535, got: 1\.5\nusage: node scripts\/tbbuild\.mjs / }, + { tool: "scripts/tbbuild.mjs", args: ["x.twinproj", "--timeout", "abc"], exit: 2, stderr: /^--timeout expects a number greater than 0, got: abc\nusage: node scripts\/tbbuild\.mjs / }, { tool: "scripts/tbbuild.mjs", args: ["x.twinproj", "--timeout", "-3"], exit: 2, stderr: /^--timeout needs a value\nusage: node scripts\/tbbuild\.mjs / }, { tool: "scripts/tbbuild.mjs", args: ["x.twinproj", "--port", "0", "--help"], exit: 0, stdout: /^usage: node scripts\/tbbuild\.mjs / }, { tool: "scripts/tbbuild.mjs", args: ["--bogus", "--keep", "x.twinproj"], exit: 2, stderr: /^unknown option: --bogus\nusage: node scripts\/tbbuild\.mjs / }, { tool: "scripts/tbbuild.mjs", args: ["--keep", "x.twinproj"], exit: 2, stderr: "no such project: x.twinproj\n" }, { tool: "scripts/tbrun.mjs", args: [], exit: 2, stderr: /^usage: node scripts\/tbrun\.mjs / }, { tool: "scripts/tbrun.mjs", args: ["no-such-dir", "--help"], exit: 0, stdout: /^usage: node scripts\/tbrun\.mjs / }, - { tool: "scripts/tbrun.mjs", args: ["no-such-dir", "--arch", "win99"], exit: 2, stderr: /^usage: node scripts\/tbrun\.mjs / }, - { tool: "scripts/tbrun.mjs", args: ["--port", "no-such-dir"], exit: 2, stderr: /^usage: node scripts\/tbrun\.mjs / }, + { tool: "scripts/tbrun.mjs", args: ["no-such-dir", "--arch", "win99"], exit: 2, stderr: /^--arch expects win32 or win64, got: win99\nusage: node scripts\/tbrun\.mjs / }, + { tool: "scripts/tbrun.mjs", args: ["--port", "no-such-dir"], exit: 2, stderr: /^--port expects a whole number from 1 to 65535, got: no-such-dir\nusage: node scripts\/tbrun\.mjs / }, { tool: "scripts/tbrun.mjs", args: ["no-such-dir", "--arch"], exit: 2, stderr: /^--arch needs a value\nusage: node scripts\/tbrun\.mjs / }, { tool: "scripts/tbrun.mjs", args: ["no-such-dir", "--arch", ""], exit: 2, stderr: /^--arch needs a non-empty value\nusage: node scripts\/tbrun\.mjs / }, { tool: "scripts/tbrun.mjs", args: ["--bogus", "no-such-dir"], exit: 2, stderr: /^unknown option: --bogus\nusage: node scripts\/tbrun\.mjs / }, @@ -404,8 +493,8 @@ const CASES = [ { tool: "scripts/addin_test.mjs", args: ["--ide", ""], exit: 2, stderr: "--ide needs a non-empty value\n" }, { tool: "scripts/addin_test.mjs", args: ["--ide", "no-such.exe"], exit: 2, stderr: /^no twinBASIC IDE found: pass --ide / }, { tool: "scripts/check_examples.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/check_examples\.mjs \[options\]\n/ }, - { tool: "scripts/check_examples.mjs", args: ["--jobs", "0"], exit: 2, stderr: "check_examples: --jobs takes a positive whole number\n" }, - { tool: "scripts/check_examples.mjs", args: ["--batch", "1.5"], exit: 2, stderr: "check_examples: --batch takes a positive whole number\n" }, + { tool: "scripts/check_examples.mjs", args: ["--jobs", "0"], exit: 2, stderr: "check_examples: --jobs expects a whole number of at least 1, got: 0\n" }, + { tool: "scripts/check_examples.mjs", args: ["--batch", "1.5"], exit: 2, stderr: "check_examples: --batch expects a whole number of at least 1, got: 1.5\n" }, { tool: "scripts/check_examples.mjs", args: ["--jobs", "0", "--help"], exit: 0, stdout: /^usage: node scripts\/check_examples\.mjs \[options\]\n/ }, { tool: "scripts/check_examples.mjs", args: ["--help", "--jobs"], exit: 0, stdout: /^usage: node scripts\/check_examples\.mjs \[options\]\n/ }, { tool: "scripts/census_attributes.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/census_attributes\.mjs \[options\]\n/ }, @@ -445,8 +534,8 @@ const CASES = [ { tool: "scripts/survey_tooling.mjs", args: ["--bogus"], exit: 2, stderr: /^unknown option: --bogus\nusage: node scripts\/survey_tooling\.mjs \[--root DIR\] / }, { tool: "scripts/survey_tooling.mjs", args: ["stray"], exit: 2, stderr: /^unexpected argument: stray\nusage: node scripts\/survey_tooling\.mjs \[--root DIR\] / }, { tool: "scripts/survey_tooling.mjs", args: ["--root"], exit: 2, stderr: /^--root needs a value\nusage: node scripts\/survey_tooling\.mjs \[--root DIR\] / }, - { tool: "scripts/survey_tooling.mjs", args: ["--window", "0"], exit: 2, stderr: /^--window expects a positive integer, got: 0\nusage: node scripts\/survey_tooling\.mjs / }, - { tool: "scripts/survey_tooling.mjs", args: ["--top", "1.5"], exit: 2, stderr: /^--top expects a positive integer, got: 1\.5\nusage: node scripts\/survey_tooling\.mjs / }, + { tool: "scripts/survey_tooling.mjs", args: ["--window", "0"], exit: 2, stderr: /^--window expects a whole number of at least 1, got: 0\nusage: node scripts\/survey_tooling\.mjs / }, + { tool: "scripts/survey_tooling.mjs", args: ["--top", "1.5"], exit: 2, stderr: /^--top expects a whole number of at least 1, got: 1\.5\nusage: node scripts\/survey_tooling\.mjs / }, { tool: "scripts/survey_tooling.mjs", args: ["--window", "0", "--help"], exit: 0, stdout: /^usage: node scripts\/survey_tooling\.mjs \[--root DIR\] / }, { tool: "scripts/check_lint.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/check_lint\.mjs \[--staged\]\n/ }, { tool: "scripts/check_lint.mjs", args: ["--staged", "--staged"], exit: 2, stderr: "check_lint: --staged given more than once\nusage: node scripts/check_lint.mjs [--staged]\n" }, @@ -459,7 +548,7 @@ const CASES = [ { tool: "scripts/compare_trees.mjs", args: ["stray"], exit: 2, stderr: /^compare_trees: unexpected argument: stray\n\nusage: node scripts\/compare_trees\.mjs / }, { tool: "scripts/compare_trees.mjs", args: ["--before"], exit: 2, stderr: /^compare_trees: --before needs a value\n\nusage: node scripts\/compare_trees\.mjs / }, { tool: "scripts/compare_trees.mjs", args: ["--max", "--keep"], exit: 2, stderr: /^compare_trees: --max needs a value\n\nusage: node scripts\/compare_trees\.mjs / }, - { tool: "scripts/compare_trees.mjs", args: ["--max", "1.5"], exit: 2, stderr: /^compare_trees: --max takes a whole number, not "1\.5"\n\nusage: node scripts\/compare_trees\.mjs / }, + { tool: "scripts/compare_trees.mjs", args: ["--max", "1.5"], exit: 2, stderr: /^compare_trees: --max expects a whole number of at least 0, got: 1\.5\n\nusage: node scripts\/compare_trees\.mjs / }, { tool: "scripts/compare_trees.mjs", args: ["--bogus", "--help"], exit: 2, stderr: /^compare_trees: unknown option: --bogus\n\nusage: node scripts\/compare_trees\.mjs / }, { tool: "scripts/compare_trees.mjs", args: ["--before", "--", "x"], exit: 2, stderr: /^compare_trees: --before needs a value\n\nusage: node scripts\/compare_trees\.mjs / }, { tool: "scripts/compare_trees.mjs", args: ["--keep=1"], exit: 2, stderr: /^compare_trees: --keep takes no value\n\nusage: node scripts\/compare_trees\.mjs / }, @@ -483,7 +572,7 @@ const CASES = [ { tool: "book/render-book.mjs", args: ["-o", "--bogus", "a.html"], exit: 2, stderr: "-o needs a value\n" }, { tool: "book/render-book.mjs", args: ["a.html", "-o", "out.pdf", "--bogus"], exit: 2, stderr: "unknown option: --bogus\n" }, { tool: "book/render-book.mjs", args: ["a.html", "-o", "out.pdf", "--outline-tags"], exit: 2, stderr: "--outline-tags needs a value\n" }, - { tool: "book/render-book.mjs", args: ["a.html", "-o", "out.pdf", "-t", "abc"], exit: 1, stderr: /^input not found: .*a\.html\n$/ }, + { tool: "book/render-book.mjs", args: ["a.html", "-o", "out.pdf", "-t", "abc"], exit: 2, stderr: "--timeout expects a whole number from 0 to 2147483647, got: abc\n" }, { tool: "book/render-book.mjs", args: ["-x"], exit: 2, stderr: "unknown option: -x\n" }, { tool: "eval/build_corpus.mjs", args: ["--help"], exit: 0, stdout: /^Usage: node eval\/build_corpus\.mjs --dest <path> / }, { tool: "eval/build_corpus.mjs", args: [], exit: 2, stderr: /^Usage: node eval\/build_corpus\.mjs --dest <path> / }, @@ -511,7 +600,7 @@ const CASES = [ { tool: "eval/run_case.mjs", args: ["--prompt-only=1"], exit: 2, stderr: "--prompt-only takes no value\n" }, { tool: "eval/run_case.mjs", args: ["--corpus"], exit: 2, stderr: "--corpus needs a value\n" }, { tool: "eval/run_case.mjs", args: ["--smoke", "--corpus", "c", "--site", "s", "--out", "o"], exit: 2, stderr: /^missing: .*[\\/]c[\\/]docs, .*search-data\.json, .*lunr\.min\.js\n$/ }, - { tool: "eval/run_case.mjs", args: ["--smoke", "--corpus", "c", "--site", "s", "--out", "o", "--timeout", "abc"], exit: 2, stderr: /^missing: .*[\\/]c[\\/]docs, / }, + { tool: "eval/run_case.mjs", args: ["--smoke", "--corpus", "c", "--site", "s", "--out", "o", "--timeout", "abc"], exit: 2, stderr: "--timeout expects a number greater than 0 and at most 35791, got: abc\n" }, { tool: "eval/run_case.mjs", args: ["--corpus", "c", "--site", "s", "--out", "o", "--protocol", "repo"], exit: 2, stderr: /^Usage: node eval\/run_case\.mjs --corpus <dir> / }, { tool: "eval/run_case.mjs", args: ["--corpus", "c", "--site", "s", "--out", "o", "--protocol", "--smoke"], exit: 2, stderr: "--protocol needs a value\n" }, { tool: "eval/site_search.mjs", args: ["--help"], exit: 0, stdout: /^Usage: node eval\/site_search\.mjs "<query>" / }, @@ -528,7 +617,7 @@ const CASES = [ { tool: "eval/search_quality.mjs", args: ["--help=1"], exit: 2, stderr: "--help takes no value\n" }, { tool: "eval/search_quality.mjs", args: ["-x"], exit: 2, stderr: "unknown option: -x\n" }, { tool: "eval/search_quality.mjs", args: ["--site", "nowhere"], exit: 1, stderr: /^missing .*search-data\.json\nRun build\.bat / }, - { tool: "eval/search_quality.mjs", args: ["--site", "nowhere", "--sample", "abc"], exit: 1, stderr: /^missing .*search-data\.json\nRun build\.bat / }, + { tool: "eval/search_quality.mjs", args: ["--site", "nowhere", "--sample", "abc"], exit: 2, stderr: "--sample expects a whole number of at least 1, got: abc\n" }, { tool: "eval/search_quality.mjs", args: ["--site", "--help"], exit: 2, stderr: "--site needs a value\n" }, { tool: "eval/search_quality.mjs", args: ["--site"], exit: 2, stderr: "--site needs a value\n" }, { tool: "eval/search_quality.mjs", args: ["--site", "nowhere", "--save"], exit: 2, stderr: "--save needs a value\n" }, @@ -579,15 +668,15 @@ const CASES = [ { tool: "builder/tbdocs.mjs", args: ["--dry-run=1"], exit: 4, stderr: "--dry-run takes no value\n" }, { tool: "builder/tbdocs.mjs", args: ["--no-check=1"], exit: 4, stderr: "--no-check takes no value\n" }, { tool: "builder/tbdocs.mjs", args: ["--no-check", "--bogus"], exit: 4, stderr: "unknown option: --bogus\n" }, - { tool: "builder/tbdocs.mjs", args: ["--port", "abc"], exit: 4, stderr: "--port expects a port number from 1 to 65535, got: abc\n" }, + { tool: "builder/tbdocs.mjs", args: ["--port", "abc"], exit: 4, stderr: "--port expects a whole number from 1 to 65535, got: abc\n" }, { tool: "builder/tbdocs.mjs", args: ["--port="], exit: 4, stderr: "--port needs a non-empty value\n" }, { tool: "builder/tbdocs.mjs", args: ["--stall-timeout="], exit: 4, stderr: "--stall-timeout needs a non-empty value\n" }, - { tool: "builder/tbdocs.mjs", args: ["--port=65536"], exit: 4, stderr: "--port expects a port number from 1 to 65535, got: 65536\n" }, - { tool: "builder/tbdocs.mjs", args: ["--port=1.5"], exit: 4, stderr: "--port expects a port number from 1 to 65535, got: 1.5\n" }, - { tool: "builder/tbdocs.mjs", args: ["--port=80", "--port=0"], exit: 4, stderr: "--port expects a port number from 1 to 65535, got: 0\n" }, - { tool: "builder/tbdocs.mjs", args: ["--port=abc", "--port=80"], exit: 4, stderr: "--port expects a port number from 1 to 65535, got: abc\n" }, - { tool: "builder/tbdocs.mjs", args: ["--stall-timeout=-1"], exit: 4, stderr: "--stall-timeout expects seconds (0 disables), got: -1\n" }, - { tool: "builder/tbdocs.mjs", args: ["--stall-timeout=abc"], exit: 4, stderr: "--stall-timeout expects seconds (0 disables), got: abc\n" }, + { tool: "builder/tbdocs.mjs", args: ["--port=65536"], exit: 4, stderr: "--port expects a whole number from 1 to 65535, got: 65536\n" }, + { tool: "builder/tbdocs.mjs", args: ["--port=1.5"], exit: 4, stderr: "--port expects a whole number from 1 to 65535, got: 1.5\n" }, + { tool: "builder/tbdocs.mjs", args: ["--port=80", "--port=0"], exit: 4, stderr: "--port expects a whole number from 1 to 65535, got: 0\n" }, + { tool: "builder/tbdocs.mjs", args: ["--port=abc", "--port=80"], exit: 4, stderr: "--port expects a whole number from 1 to 65535, got: abc\n" }, + { tool: "builder/tbdocs.mjs", args: ["--stall-timeout=-1"], exit: 4, stderr: "--stall-timeout expects a number of at least 0, got: -1\n" }, + { tool: "builder/tbdocs.mjs", args: ["--stall-timeout=abc"], exit: 4, stderr: "--stall-timeout expects a number of at least 0, got: abc\n" }, { tool: "builder/tbdocs.mjs", args: ["--src", ".", "--dest", "."], exit: 4, stderr: /^refusing --dest (.+): it is or contains the source tree \1, which cleaning it would delete\n$/ }, { tool: "builder/tbdocs.mjs", args: ["--src=.", "--dest=sub"], exit: 4, @@ -733,6 +822,165 @@ for (const [tool, [option, { exit = 2, prefix = "", args, empty } = {}]] of Obje } } +// A value a tool reads after the parse is checked straight after it: a number, +// a regular expression, a URL, a date, a choice, an option that excludes +// another. Each of these is refused before the tool starts anything, so the +// case leaves its folder empty. `bad` is the message; a tool that prints its +// usage after it is matched by the message and the opening of that usage. The +// value that shows each rule is the one given: a word, 0, a fraction, a value +// past the largest allowed, or `--x=-1`, the form that reaches the check (a +// separate -1 is refused by the parse as a missing value). +const BAD_VALUES = []; +const bad = (tool, args, stderr, exit = 2) => BAD_VALUES.push({ tool, args, exit, stderr }); +// The message, then the opening of the tool's usage; `blank` when the tool puts +// an empty line between them. +const thenUsage = (message, tool, blank = false) => new RegExp(`^${literal(message)}\\n${blank ? "\\n" : ""}usage: node ${literal(tool)} `); +const NOT_PORT = (v) => `--port expects a whole number from 1 to 65535, got: ${v}`; +const NOT_COUNT = (option, v) => `${option} expects a whole number of at least 1, got: ${v}`; +const NOT_WHOLE = (option, v) => `${option} expects a whole number of at least 0, got: ${v}`; +const NOT_ABOVE_ZERO = (option, v) => `${option} expects a number greater than 0, got: ${v}`; +const NOT_URL = (option, v) => `${option} expects an absolute http or https URL, got: ${v}`; +const START = "http://127.0.0.1:9/"; +const REGEX_REASON = (option, v) => new RegExp(`^${literal(`${option} expects a regular expression, got: ${v} (`)}.+\\)\\n$`); + +// tbdocs and check_links exit 4; tbdocs prints the message alone, check_links after `error: `. +bad("builder/tbdocs.mjs", ["--url", "foo"], NOT_URL("--url", "foo") + "\n", 4); +bad("builder/tbdocs.mjs", ["--url=mailto:x"], NOT_URL("--url", "mailto:x") + "\n", 4); +bad("builder/tbdocs.mjs", ["--stall-timeout", "abc"], "--stall-timeout expects a number of at least 0, got: abc\n", 4); +bad("scripts/check_links.mjs", ["--offline", "--oracle", "x", "no-such-tree"], "error: --oracle expects fs or index, got: x\n", 4); +bad("scripts/check_links.mjs", ["--offline", "--oracle=", "no-such-tree"], "error: --oracle needs a non-empty value\n", 4); + +// tbbuild and tbrun follow the message with their usage. +for (const [tool, first] of [["scripts/tbbuild.mjs", "x.twinproj"], ["scripts/tbrun.mjs", "no-such-dir"]]) { + bad(tool, [first, "--port", "0"], thenUsage(NOT_PORT(0), tool)); + bad(tool, [first, "--port=65536"], thenUsage(NOT_PORT(65536), tool)); + bad(tool, [first, "--port", "abc"], thenUsage(NOT_PORT("abc"), tool)); + bad(tool, [first, "--timeout", "0"], thenUsage(NOT_ABOVE_ZERO("--timeout", 0), tool)); + bad(tool, [first, "--timeout=-1"], thenUsage(NOT_ABOVE_ZERO("--timeout", -1), tool)); + bad(tool, [first, "--arch", "WIN32"], thenUsage("--arch expects win32 or win64, got: WIN32", tool)); + bad(tool, [first, "--show", "--hide"], thenUsage("--show and --hide cannot be given together", tool)); +} +bad("scripts/tbrun.mjs", ["no-such-dir", "--quiet=-1"], thenUsage(NOT_WHOLE("--quiet", -1), "scripts/tbrun.mjs")); +bad("scripts/tbrun.mjs", ["no-such-dir", "--quiet", "1.5"], thenUsage(NOT_WHOLE("--quiet", 1.5), "scripts/tbrun.mjs")); + +bad("scripts/addin_test.mjs", ["--only", "("], REGEX_REASON("--only", "(")); +bad("scripts/addin_test.mjs", ["--port", "0"], NOT_PORT(0) + "\n"); +bad("scripts/addin_test.mjs", ["--port=1.5"], NOT_PORT(1.5) + "\n"); +bad("scripts/addin_test.mjs", ["--jobs", "0"], NOT_COUNT("--jobs", 0) + "\n"); +bad("scripts/addin_test.mjs", ["--jobs=1.5"], NOT_COUNT("--jobs", 1.5) + "\n"); +bad("scripts/addin_test.mjs", ["--timeout", "0"], "--timeout expects a number greater than 0 and at most 2147483, got: 0\n"); +bad("scripts/addin_test.mjs", ["--timeout", "2147484"], "--timeout expects a number greater than 0 and at most 2147483, got: 2147484\n"); +bad("scripts/addin_test.mjs", ["--show", "--hide"], "--show and --hide cannot be given together\n"); + +// check_examples prints "check_examples: " before the message. +{ + const tool = "scripts/check_examples.mjs"; + const say = (message) => `check_examples: ${message}\n`; + bad(tool, ["--jobs", "abc"], say(NOT_COUNT("--jobs", "abc"))); + bad(tool, ["--batch", "0"], say(NOT_COUNT("--batch", 0))); + bad(tool, ["--port", "0"], say(NOT_PORT(0))); + bad(tool, ["--port=65536"], say(NOT_PORT(65536))); + bad(tool, ["--only", "("], new RegExp(`^check_examples: ${literal("--only expects a regular expression, got: ( (")}.+\\)\\n$`)); + bad(tool, ["--apply"], say("--apply needs --propose")); + bad(tool, ["--apply", "--census"], say("--apply needs --propose")); + bad(tool, ["--census", "--propose"], say("--census and --propose cannot be given together")); + bad(tool, ["--report", "survey.json", "--census"], say("--report and --census cannot be given together")); + bad(tool, ["--report", "survey.json", "--census", "--propose"], say("--report, --census and --propose cannot be given together")); + bad(tool, ["--show", "--hide"], say("--show and --hide cannot be given together")); +} + +bad("scripts/survey_tooling.mjs", ["--window", "abc"], thenUsage(NOT_COUNT("--window", "abc"), "scripts/survey_tooling.mjs")); +bad("scripts/survey_tooling.mjs", ["--top=-1"], thenUsage(NOT_COUNT("--top", -1), "scripts/survey_tooling.mjs")); +bad("scripts/compare_trees.mjs", ["--max=-1"], thenUsage(`compare_trees: ${NOT_WHOLE("--max", -1)}`, "scripts/compare_trees.mjs", true)); +bad("scripts/compare_trees.mjs", ["--max", "abc"], thenUsage(`compare_trees: ${NOT_WHOLE("--max", "abc")}`, "scripts/compare_trees.mjs", true)); + +{ + const tool = "scripts/crawl_check.mjs"; + bad(tool, ["--concurrency", "0", START], thenUsage(NOT_COUNT("--concurrency", 0), tool)); + bad(tool, ["--concurrency=1.5", START], thenUsage(NOT_COUNT("--concurrency", 1.5), tool)); + bad(tool, ["--timeout", "0", START], thenUsage("--timeout expects a whole number from 1 to 2147483647, got: 0", tool)); + bad(tool, ["--timeout", "2147483648", START], thenUsage("--timeout expects a whole number from 1 to 2147483647, got: 2147483648", tool)); + bad(tool, ["--timeout", "abc", START], thenUsage("--timeout expects a whole number from 1 to 2147483647, got: abc", tool)); + bad(tool, ["foo"], thenUsage(NOT_URL("<start-url>", "foo"), tool)); + bad(tool, ["mailto:x"], thenUsage(NOT_URL("<start-url>", "mailto:x"), tool)); +} + +bad("scripts/sweep_a11y.mjs", ["--limit", "0"], NOT_COUNT("--limit", 0) + "\n"); +bad("scripts/sweep_a11y.mjs", ["--limit=1.5"], NOT_COUNT("--limit", 1.5) + "\n"); +bad("scripts/sweep_a11y.mjs", ["--recycle-every", "0"], NOT_COUNT("--recycle-every", 0) + "\n"); +bad("scripts/sweep_a11y.mjs", ["--recycle-every", "abc"], NOT_COUNT("--recycle-every", "abc") + "\n"); +bad("scripts/pick_a11y_sample.mjs", ["--budget", "0"], NOT_ABOVE_ZERO("--budget", 0) + "\n"); +bad("scripts/pick_a11y_sample.mjs", ["--budget=-1"], NOT_ABOVE_ZERO("--budget", -1) + "\n"); +bad("scripts/pick_a11y_sample.mjs", ["--check", "--propose"], "--check and --propose cannot be given together\n"); +bad("scripts/pick_a11y_sample.mjs", ["--propose", "--census"], "--propose and --census cannot be given together\n"); +bad("scripts/pick_a11y_sample.mjs", ["--check", "--propose", "--census"], "--check, --propose and --census cannot be given together\n"); +bad("scripts/check_a11y_fingerprint.mjs", ["--baseline", "nope"], /^--baseline expects production, .+, got: nope\n$/); +bad("scripts/check_a11y_fingerprint.mjs", ["--candidate", "nope"], /^--candidate expects production, .+, got: nope\n$/); +bad("scripts/check_a11y_fingerprint.mjs", ["--patches", "nope"], /^--patches expects .+, got: nope\n$/); +bad("scripts/check_a11y_fingerprint.mjs", ["--patches=plain-color-fields,nope"], /^--patches expects .+, got: nope\n$/); +bad("scripts/check_axe_patch_equiv.mjs", ["--patch", "nope"], /^--patch expects .+, got: nope\n$/); +bad("scripts/check_links_diff.mjs", ["--max-lines", "abc"], NOT_WHOLE("--max-lines", "abc") + "\n"); +bad("scripts/check_links_diff.mjs", ["--max-lines=-1"], NOT_WHOLE("--max-lines", -1) + "\n"); +bad("scripts/check_links_diff.mjs", ["--max-lines=1.5"], NOT_WHOLE("--max-lines", 1.5) + "\n"); + +const NOT_MS = (v) => `--timeout expects a whole number from 0 to 2147483647, got: ${v}\n`; +bad("book/render-book.mjs", ["a.html", "-o", "out.pdf", "--timeout=-1"], NOT_MS(-1)); +bad("book/render-book.mjs", ["a.html", "-o", "out.pdf", "-t", "1.5"], NOT_MS(1.5)); +bad("book/render-book.mjs", ["a.html", "-o", "out.pdf", "-t", "2147483648"], NOT_MS(2147483648)); + +bad("eval/run_case.mjs", ["--timeout", "0"], "--timeout expects a number greater than 0 and at most 35791, got: 0\n"); +bad("eval/run_case.mjs", ["--timeout", "35792"], "--timeout expects a number greater than 0 and at most 35791, got: 35792\n"); +bad("eval/run_case.mjs", ["--protocol", "x"], "--protocol expects repo or site, got: x\n"); +bad("eval/search_quality.mjs", ["--sample", "0"], NOT_COUNT("--sample", 0) + "\n"); +bad("eval/search_quality.mjs", ["--worst=-1"], NOT_WHOLE("--worst", -1) + "\n"); +bad("eval/search_quality.mjs", ["--worst", "abc"], NOT_WHOLE("--worst", "abc") + "\n"); +bad("eval/search_quality.mjs", ["--failures=1.5"], NOT_WHOLE("--failures", 1.5) + "\n"); +bad("eval/site_search.mjs", ["--n", "0", "term"], NOT_COUNT("--n", 0) + "\n"); +bad("eval/site_search.mjs", ["--n=1.5", "term"], NOT_COUNT("--n", 1.5) + "\n"); +bad("eval/site_search.mjs", ["--composition", "term"], "--composition takes no search terms\n"); +bad("eval/nav_hops.mjs", ["("], REGEX_REASON("<url-regex>", "(")); +bad("eval/nav_hops.mjs", ["Reference", "[a-"], REGEX_REASON("<url-regex>", "[a-")); + +// build_corpus empties its --dest before it writes, so it refuses one that is or +// contains the repository root, the folder it runs from or --src, checked in +// that order. Every one of these stops at the command line. Each --dest is a +// folder that must never be emptied. +{ + const tool = "eval/build_corpus.mjs"; + const refuse = (dest, what) => new RegExp(`^refusing --dest ${dest}: it is or contains ${what}, which cleaning it would delete\\n$`); + const docs = path.join(REPO_ROOT, "docs"); + bad(tool, ["--dest", REPO_ROOT], refuse(literal(REPO_ROOT), "the repository root")); + bad(tool, ["--dest", path.dirname(REPO_ROOT)], refuse(literal(path.dirname(REPO_ROOT)), "the repository root")); + bad(tool, ["--dest", "."], refuse(".+", "the current folder")); + bad(tool, ["--dest", ".."], refuse(".+", "the current folder")); + bad(tool, ["--src", path.join(docs, "Reference"), "--dest", docs], refuse(literal(docs), literal(`--src ${path.join(docs, "Reference")}`))); + bad(tool, ["--src", docs, "--dest", docs], refuse(literal(docs), literal(`--src ${docs}`))); +} + +// wisdom's command in these is never a real one, so that none can start an +// export, except extract, whose modes are refused before it reads a thing. +{ + const tool = "wisdom/wisdom.mjs"; + const since = (v) => `--since expects an ISO 8601 date (YYYY-MM-DD) no earlier than 2015-01-01, got: ${v}\n`; + bad(tool, ["bogus", "--concurrency", "0"], NOT_COUNT("--concurrency", 0) + "\n"); + bad(tool, ["bogus", "--concurrency=1.5"], NOT_COUNT("--concurrency", 1.5) + "\n"); + bad(tool, ["bogus", "--cap=-1"], NOT_COUNT("--cap", -1) + "\n"); + bad(tool, ["bogus", "--cap", "abc"], NOT_COUNT("--cap", "abc") + "\n"); + bad(tool, ["bogus", "--rate-limit", "0"], NOT_ABOVE_ZERO("--rate-limit", 0) + "\n"); + bad(tool, ["bogus", "--rate-limit", "abc"], NOT_ABOVE_ZERO("--rate-limit", "abc") + "\n"); + bad(tool, ["bogus", "--since", "12"], since("12")); + bad(tool, ["bogus", "--since", "2024-02-30"], since("2024-02-30")); + bad(tool, ["bogus", "--since", "2014-12-31"], since("2014-12-31")); + bad(tool, ["bogus", "--min-confidence", "x"], "--min-confidence expects high, medium or low, got: x\n"); + bad(tool, ["extract", "--all", "--force"], "--all and --force cannot be given together\n"); + bad(tool, ["extract", "--since", "2024-01-01", "--force"], "--since and --force cannot be given together\n"); + bad(tool, ["extract", "--since", "2024-01-01", "--all", "--force"], "--since, --all and --force cannot be given together\n"); +} +for (const made of BAD_VALUES) { + CASES.push(made); + LEAVES_EMPTY.add(made); +} + const TIMEOUT_MS = 30_000; function runCase({ tool, args }, cwd, env) { diff --git a/scripts/check_examples.mjs b/scripts/check_examples.mjs index 5cef5696..78f729c7 100644 --- a/scripts/check_examples.mjs +++ b/scripts/check_examples.mjs @@ -74,7 +74,7 @@ import { import { tmpdir } from "node:os"; import path from "node:path"; -import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { CliError, numberOption, parseCli, printHelpAndExit, refuseTogether, regexOption, withUsageError } from "../lib/cli.mjs"; import { mapLines } from "../lib/markdown.mjs"; import { BODY_SLOTS, CONCAT_KEY, HIDDEN_MARKER, MARKER, RUN_MARKER, SLOTS, classify, @@ -88,8 +88,6 @@ const TEMPLATES = path.join(REPO_ROOT, "test", "example-projects"); // ---------------------------------------------------------------- arguments -const usageError = (why) => { console.error(`check_examples: ${why}`); process.exit(2); }; - const { values } = withUsageError( () => parseCli(process.argv.slice(2), { options: { @@ -137,13 +135,19 @@ Compiles the documentation's own twinBASIC code samples, every tb fence marked if (values.help) printHelpAndExit(USAGE); -// A count or a port that is not a positive whole number is refused: Number() -// makes NaN of anything it cannot read. -function positiveInteger(n, d) { - const v = Number(values[n] ?? d); - if (!Number.isInteger(v) || v < 1) usageError(`--${n} takes a positive whole number`); - return v; -} +// The values are read before anything runs. --report, --census and --propose +// are three modes of one run, and --apply is a part of --propose. +const { only, jobs, basePort, batchSize } = withUsageError(() => { + refuseTogether(values, ["report", "census", "propose"]); + refuseTogether(values, ["show", "hide"]); + if (values.apply && !values.propose) throw new CliError("conflict", "--apply needs --propose", { option: "--apply" }); + return { + only: values.only ? regexOption(values.only, { option: "--only" }) : null, + jobs: numberOption(values.jobs ?? "4", { option: "--jobs", integer: true, min: 1 }), + basePort: numberOption(values.port ?? "9480", { option: "--port", integer: true, min: 1, max: 65535 }), + batchSize: numberOption(values.batch ?? "120", { option: "--batch", integer: true, min: 1 }), + }; +}, { format: (err) => `check_examples: ${err.message}` }); const MODE_CENSUS = values.census; const MODE_PROPOSE = values.propose; @@ -151,10 +155,6 @@ const MODE_REPORT = values.report ?? null; const APPLY = values.apply; const VERBOSE = values.verbose; const AS_JSON = values.json; -const only = values.only ? new RegExp(values.only) : null; -const jobs = positiveInteger("jobs", 4); -const basePort = positiveInteger("port", 9480); -const batchSize = positiveInteger("batch", 120); // A page's template, when its fence does not name one. Inferred from the path // because the package a sample needs is what the page is ABOUT -- stating diff --git a/scripts/check_links.mjs b/scripts/check_links.mjs index 54d59179..55ada85f 100644 --- a/scripts/check_links.mjs +++ b/scripts/check_links.mjs @@ -77,7 +77,7 @@ import { FsOracle, formatLinkReport, formatIntegrityReport, resolve, OUTSIDE_BASEPATH_MARKER, } from "../builder/link-check.mjs"; -import { CliError, parseCli } from "../lib/cli.mjs"; +import { CliError, choiceOption, parseCli } from "../lib/cli.mjs"; // Tree-relative POSIX path, the space check.mjs works and reports in, so // the same tree checked with a relative --root-dir, an absolute one, or @@ -250,7 +250,7 @@ function parseArgs(argv) { checkSitemap: values.checkSitemap, checkSearch: values.checkSearch, checkCanonical: values.checkCanonical, - oracle: values.oracle, + oracle: choiceOption(values.oracle, { option: "--oracle", choices: ["fs", "index"] }), }; return { opts, inputs: positionals }; diff --git a/scripts/check_links_diff.mjs b/scripts/check_links_diff.mjs index 165e36bd..7be00e39 100644 --- a/scripts/check_links_diff.mjs +++ b/scripts/check_links_diff.mjs @@ -79,7 +79,7 @@ import * as path from "node:path"; import { performance } from "node:perf_hooks"; import { runCheck, selfTest as scriptSelfTest } from "./check_links.mjs"; -import { parseCli, withUsageError } from "../lib/cli.mjs"; +import { numberOption, parseCli, withUsageError } from "../lib/cli.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; const BASE_PATH = "/twinBASIC-docs"; @@ -539,24 +539,30 @@ function ensureBasePathTree(dir, allowBuild) { // ── Main ──────────────────────────────────────────────────────────── function parseArgs(argv) { - const { values } = withUsageError(() => parseCli(argv, { - options: { - a: { type: "string", default: "script" }, - b: { type: "string", default: "script" }, - case: { type: "string", multiple: true }, - "max-lines": { type: "string", default: "12" }, - "base-path-tree": { type: "string", default: DEFAULT_BASEPATH_TREE }, - "build-base-path": { type: "boolean" }, - "self-test": { type: "boolean" }, - list: { type: "boolean" }, - verbose: { type: "boolean", short: "v" }, - help: { type: "boolean", short: "h" }, - }, - stopAt: ["help"], - })); + const { values, maxLines } = withUsageError(() => { + const cli = parseCli(argv, { + options: { + a: { type: "string", default: "script" }, + b: { type: "string", default: "script" }, + case: { type: "string", multiple: true }, + "max-lines": { type: "string", default: "12" }, + "base-path-tree": { type: "string", default: DEFAULT_BASEPATH_TREE }, + "build-base-path": { type: "boolean" }, + "self-test": { type: "boolean" }, + list: { type: "boolean" }, + verbose: { type: "boolean", short: "v" }, + help: { type: "boolean", short: "h" }, + }, + stopAt: ["help"], + }); + return { + values: cli.values, + maxLines: cli.stopped === "help" ? undefined : numberOption(cli.values.maxLines, { option: "--max-lines", integer: true, min: 0 }), + }; + }); const o = { a: values.a, b: values.b, cases: values.case, verbose: values.verbose, list: values.list, - maxLines: Number(values.maxLines), basePathTree: values.basePathTree, buildBasePath: values.buildBasePath, + maxLines, basePathTree: values.basePathTree, buildBasePath: values.buildBasePath, selfTest: values.selfTest, help: values.help, }; if (!o.cases.length) o.cases = [...DEFAULT_CASES]; diff --git a/scripts/compare_trees.mjs b/scripts/compare_trees.mjs index 5830b97e..50ad4050 100644 --- a/scripts/compare_trees.mjs +++ b/scripts/compare_trees.mjs @@ -42,7 +42,7 @@ import { spawnSync } from "node:child_process"; import { closeSync, existsSync, openSync } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; -import { CliError, parseCli } from "../lib/cli.mjs"; +import { CliError, numberOption, parseCli } from "../lib/cli.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; const WORK = path.join(REPO_ROOT, ".compare-trees"); @@ -118,27 +118,24 @@ function parseArgs(argv) { const tbdocs = sep === -1 ? [] : argv.slice(sep + 1); let cli; + let max; try { cli = parseCli(head, { options: { before: { type: "string", default: "HEAD" }, - max: { type: "string" }, + max: { type: "string", default: "20" }, keep: { type: "boolean", default: false }, help: { type: "boolean", short: "h" }, }, stopAt: ["help"], }); + if (cli.stopped !== "help") max = numberOption(cli.values.max, { option: "--max", integer: true, min: 0 }); } catch (err) { if (!(err instanceof CliError)) throw err; usageError(err.message); } if (cli.stopped === "help") { process.stdout.write(USAGE); process.exit(0); } - let max = 20; - if (cli.values.max !== undefined) { - max = Number(cli.values.max); - if (!Number.isInteger(max) || max < 0) usageError(`--max takes a whole number, not "${cli.values.max}"`); - } return { before: cli.values.before, keep: cli.values.keep, max, tbdocs }; } diff --git a/scripts/crawl_check.mjs b/scripts/crawl_check.mjs index 1187a2e1..4d2b9983 100644 --- a/scripts/crawl_check.mjs +++ b/scripts/crawl_check.mjs @@ -19,7 +19,7 @@ import { Parser } from "htmlparser2"; import { forEachLink } from "../builder/link-check.mjs"; import { splitFragment } from "../builder/url.mjs"; -import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { numberOption, parseCli, printHelpAndExit, urlOption, withUsageError } from "../lib/cli.mjs"; const USAGE = `usage: node scripts/crawl_check.mjs <start-url> [--concurrency N] [--timeout MS] [--skip-external] [-h, --help] @@ -47,15 +47,21 @@ const { values, positionals } = withUsageError( ); if (values.help) printHelpAndExit(USAGE); const startArg = positionals[0]; -const concurrency = Number(values.concurrency); -const timeoutMs = Number(values.timeout); const skipExternal = values.skipExternal; if (!startArg) { console.error(USAGE); process.exit(2); } -const startUrl = new URL(startArg); +// AbortSignal.timeout's timer fires at once for more than 2147483647 ms. +const { concurrency, timeoutMs, startUrl } = withUsageError( + () => ({ + concurrency: numberOption(values.concurrency, { option: "--concurrency", integer: true, min: 1 }), + timeoutMs: numberOption(values.timeout, { option: "--timeout", integer: true, min: 1, max: 2147483647 }), + startUrl: urlOption(startArg, { option: "<start-url>" }), + }), + { format: (err) => `${err.message}\n${USAGE}` }, +); const origin = startUrl.origin; const basePath = startUrl.pathname.endsWith("/") ? startUrl.pathname : startUrl.pathname + "/"; diff --git a/scripts/pick_a11y_sample.mjs b/scripts/pick_a11y_sample.mjs index 43aa409f..f8555ae9 100644 --- a/scripts/pick_a11y_sample.mjs +++ b/scripts/pick_a11y_sample.mjs @@ -48,7 +48,7 @@ import { DEFAULT_ROOT_DIR, REPO_ROOT, SAMPLE_PAGES, discoverPages, median, pad, splitStubs, } from "./lib/axe-scan.mjs"; import { exitOnCrash } from "./lib/gate-probes.mjs"; -import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { numberOption, parseCli, printHelpAndExit, refuseTogether, withUsageError } from "../lib/cli.mjs"; // A crash exits 2, where 1 is a coverage gap. exitOnCrash(); @@ -148,11 +148,13 @@ if (cli.stopped === "help") { + " [--root-dir DIR] [--sweep FILE] [--budget MS] [-h, --help]", ); } -const modeTokens = cli.tokens.filter((t) => t.key === "check" || t.key === "propose" || t.key === "census"); -let mode = modeTokens.length ? modeTokens[modeTokens.length - 1].key : "check"; +const budget = withUsageError(() => { + refuseTogether(cli.values, ["check", "propose", "census"]); + return cli.values.budget !== undefined ? numberOption(cli.values.budget, { option: "--budget", above: 0 }) : Infinity; +}); +const mode = ["check", "propose", "census"].find((m) => cli.values[m]) ?? "check"; let rootDir = cli.values.rootDir; let sweepPath = cli.values.sweep; -let budget = cli.values.budget !== undefined ? parseFloat(cli.values.budget) : Infinity; let fresh = cli.values.fresh; rootDir = resolve(rootDir); diff --git a/scripts/survey_tooling.mjs b/scripts/survey_tooling.mjs index 5749e3bb..d0dd068c 100644 --- a/scripts/survey_tooling.mjs +++ b/scripts/survey_tooling.mjs @@ -52,7 +52,7 @@ import { builtinModules } from "node:module"; import path from "node:path"; import * as acorn from "acorn"; import * as walk from "acorn-walk"; -import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { numberOption, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; const TOOLING_DIRS = ["builder", "scripts", "lib", "book", "eval", "wisdom", "test", "perf"]; @@ -90,20 +90,16 @@ const { values } = withUsageError( { format: (err) => `${err.message}\n${USAGE}` }, ); if (values.help) printHelpAndExit(USAGE); -const WINDOW = positiveInt("window", values.window); -const TOP = positiveInt("top", values.top); +const { WINDOW, TOP } = withUsageError( + () => ({ + WINDOW: numberOption(values.window, { option: "--window", integer: true, min: 1 }), + TOP: numberOption(values.top, { option: "--top", integer: true, min: 1 }), + }), + { format: (err) => `${err.message}\n${USAGE}` }, +); const ROOT = path.resolve(values.root ?? REPO_ROOT); const listed = (f) => values.includePerf || !f.startsWith(LAB); -function positiveInt(name, raw) { - const n = Number(raw); - if (!Number.isInteger(n) || n < 1) { - console.error(`--${name} expects a positive integer, got: ${raw}\n${USAGE}`); - process.exit(2); - } - return n; -} - function gitFiles(...args) { try { return execFileSync("git", ["ls-files", ...args], { cwd: ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }) diff --git a/scripts/sweep_a11y.mjs b/scripts/sweep_a11y.mjs index 06035f3b..e39a4160 100644 --- a/scripts/sweep_a11y.mjs +++ b/scripts/sweep_a11y.mjs @@ -62,7 +62,7 @@ import { splitStubs, } from "./lib/axe-scan.mjs"; import { withBrowser } from "./lib/browser.mjs"; -import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { numberOption, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; // The production scheme, read from the one registry check_a11y.mjs reads -- // same bundle, same patches, same run options. A survey run against a @@ -103,13 +103,16 @@ let rootDir = cli.values.rootDir; let themeArg = cli.values.theme; let viewportArg = cli.values.viewport; let filter = cli.values.filter ?? null; -let limit = cli.values.limit !== undefined ? parseInt(cli.values.limit, 10) : Infinity; let outPath = cli.values.out ?? null; let resume = cli.values.resume; let reportOnly = cli.values.report; if (reportOnly) resume = true; let stockAxe = cli.values.stockAxe; -let recycleEvery = cli.values.recycleEvery !== undefined ? parseInt(cli.values.recycleEvery, 10) : 100; + +const { limit, recycleEvery } = withUsageError(() => ({ + limit: cli.values.limit !== undefined ? numberOption(cli.values.limit, { option: "--limit", integer: true, min: 1 }) : Infinity, + recycleEvery: numberOption(cli.values.recycleEvery ?? "100", { option: "--recycle-every", integer: true, min: 1 }), +})); rootDir = resolve(rootDir); outPath = resolve(outPath ?? join(REPO_ROOT, "perf/results/a11y-sweep.jsonl")); diff --git a/scripts/tbbuild.mjs b/scripts/tbbuild.mjs index 1e8d6942..3dabf0f0 100644 --- a/scripts/tbbuild.mjs +++ b/scripts/tbbuild.mjs @@ -51,7 +51,7 @@ // front of you". import { existsSync, statSync } from "node:fs"; import path from "node:path"; -import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { choiceOption, numberOption, parseCli, printHelpAndExit, refuseTogether, withUsageError } from "../lib/cli.mjs"; import { findIde } from "./lib/tb-install.mjs"; import { COMPILE_TIMEOUT, TARGETS, attachIde, compileOutcome, launchIde, setBuildTarget, shutdownIde, summaryLine, waitForCompile, wantShow } from "./lib/tb-ide.mjs"; @@ -72,12 +72,13 @@ Compiles a packed .twinproj in the twinBASIC IDE and prints its diagnostics. (default: hidden, unless TBBUILD_SHOW is set) -h, --help print this text and exit`; -function usage(why) { - if (why) console.error(why); +function usage() { console.error(USAGE); process.exit(2); } +const usageError = { format: (err) => `${err.message}\n${USAGE}` }; + const { values, positionals } = withUsageError( () => parseCli(process.argv.slice(2), { options: { @@ -94,35 +95,32 @@ const { values, positionals } = withUsageError( positionals: { min: 0, max: 1 }, stopAt: ["help"], }), - { format: (err) => `${err.message}\n${USAGE}` }, + usageError, ); if (values.help) printHelpAndExit(USAGE); -// A number that is not positive, or a port that is not whole, is refused too. -// Anything Number() cannot read is NaN, and a NaN timeout ends +// The values are read before anything starts. A NaN timeout would end // waitForCompile's loop before its first pass, which then reports that the IDE // never opened the project. -function positive(n, d, { whole = false } = {}) { - const v = Number(values[n] ?? d); - if (!(v > 0) || (whole && !Number.isInteger(v))) { - usage(`--${n} takes a positive ${whole ? "whole " : ""}number`); - } - return v; -} +const { port, arch, timeout } = withUsageError(() => { + refuseTogether(values, ["show", "hide"]); + return { + port: numberOption(values.port ?? "9333", { option: "--port", integer: true, min: 1, max: 65535 }), + arch: choiceOption(values.arch ?? TARGETS[0], { option: "--arch", choices: TARGETS }), + timeout: numberOption(values.timeout ?? String(COMPILE_TIMEOUT / 1000), { option: "--timeout", above: 0 }) * 1000, + }; +}, usageError); // An install path is a home directory, so it is never hardcoded here: pass // --ide, set TB_IDE, or let tb-install find the newest BETA on the Desktop, // which is where the IDE's own zip tells people to unpack it. const IDE = findIde(values.ide); -const port = positive("port", 9333, { whole: true }); -const arch = values.arch ?? TARGETS[0]; -const timeout = positive("timeout", COMPILE_TIMEOUT / 1000) * 1000; const asJson = values.json; const keep = values.keep; const show = wantShow({ show: values.show, hide: values.hide }); const proj = positionals[0]; -if (!proj || !TARGETS.includes(arch)) usage(); +if (!proj) usage(); // Refuse anything that is not a .twinproj, rather than discovering it two // minutes later. A source directory is the tempting mistake -- it is what // `tbrun` takes -- and handing one to the IDE does not fail: the IDE starts, diff --git a/scripts/tbrun.mjs b/scripts/tbrun.mjs index a47e214e..8ac27d80 100644 --- a/scripts/tbrun.mjs +++ b/scripts/tbrun.mjs @@ -101,7 +101,7 @@ import { execFileSync } from "node:child_process"; import { existsSync, readFileSync, mkdirSync, statSync, readdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { choiceOption, numberOption, parseCli, printHelpAndExit, refuseTogether, withUsageError } from "../lib/cli.mjs"; import { click } from "./lib/tb-click.mjs"; import { compilerExe, findIde } from "./lib/tb-install.mjs"; import { BUILD_FAILED, COMPILE_TIMEOUT, TARGETS, attachIde, compileOutcome, keepClears, keptClears, @@ -156,9 +156,18 @@ if (values.help) printHelpAndExit(USAGE); const die = (code, msg) => { console.error(msg); process.exit(code); }; -const arch = values.arch || TARGETS[0]; +// The values are read before anything starts. +const { port, arch, timeoutMs, quietMs } = withUsageError(() => { + refuseTogether(values, ["show", "hide"]); + return { + port: numberOption(values.port ?? "9346", { option: "--port", integer: true, min: 1, max: 65535 }), + arch: choiceOption(values.arch ?? TARGETS[0], { option: "--arch", choices: TARGETS }), + timeoutMs: numberOption(values.timeout ?? "120", { option: "--timeout", above: 0 }) * 1000, + quietMs: numberOption(values.quiet ?? "2500", { option: "--quiet", integer: true, min: 0 }), + }; +}, { format: (err) => `${err.message}\n${USAGE}` }); -if (!positionals.length || !TARGETS.includes(arch)) die(2, USAGE); +if (!positionals.length) die(2, USAGE); const srcDir = path.resolve(positionals[0]); if (!existsSync(srcDir) || !statSync(srcDir).isDirectory()) { @@ -171,10 +180,6 @@ if (!existsSync(srcDir) || !statSync(srcDir).isDirectory()) { const settingsPath = path.join(srcDir, "Settings"); if (!existsSync(settingsPath)) die(2, `no Settings file in ${srcDir}`); -const port = Number(values.port || 9346); -const timeoutMs = Number(values.timeout || 120) * 1000; -const quietMs = Number(values.quiet || 2500); - // Images a probe can leave behind through COM activation. Office is the set that // prompted this; --reap-images replaces the list for anything else. Only out-of- // process (LocalServer32) servers can outlive the probe at all -- an in-process diff --git a/wisdom/extract/prep.mjs b/wisdom/extract/prep.mjs index 9af71b8e..1c95037f 100644 --- a/wisdom/extract/prep.mjs +++ b/wisdom/extract/prep.mjs @@ -20,12 +20,9 @@ export async function runExtract(flags) { process.exit(1) } - // Mode resolution: --since, --all, --force are mutually exclusive primary modes. + // Mode resolution: --since, --all, --force are mutually exclusive primary + // modes; wisdom.mjs refuses two of them on the command line. const modeFlags = [flags.since && 'since', flags.all && 'all', flags.force && 'force'].filter(Boolean) - if (modeFlags.length > 1) { - process.stderr.write(`[wisdom] --since, --all, and --force are mutually exclusive (got: ${modeFlags.join(', ')})\n`) - process.exit(1) - } const mode = modeFlags[0] || 'incremental' // Build docs sitemap diff --git a/wisdom/wisdom.mjs b/wisdom/wisdom.mjs index 0edb85d3..240973b2 100644 --- a/wisdom/wisdom.mjs +++ b/wisdom/wisdom.mjs @@ -3,7 +3,7 @@ import { mkdirSync, existsSync } from 'node:fs' import { join, dirname } from 'node:path' import { fileURLToPath } from 'node:url' -import { parseCli, printHelpAndExit, withUsageError } from '../lib/cli.mjs' +import { choiceOption, dateOption, numberOption, parseCli, printHelpAndExit, refuseTogether, withUsageError } from '../lib/cli.mjs' import { loadConfig } from './config.mjs' import { readJsonFile, writeFileAtomic } from './files.mjs' import { createClient, CapReachedError, timestampToSnowflake, EXIT_CAP_REACHED } from './discord/api.mjs' @@ -14,29 +14,46 @@ import { runExtract, runMerge } from './extract/prep.mjs' const __dirname = dirname(fileURLToPath(import.meta.url)) +// No Discord message is older than this. +const DISCORD_EPOCH = '2015-01-01' + function parseArgs(argv) { const [command, ...rest] = argv.slice(2) if (command === '--help' || command === '-h') printHelpAndExit(USAGE) - const { values } = withUsageError(() => parseCli(rest, { - options: { - help: { type: 'boolean', short: 'h' }, - guild: { type: 'string' }, - channel: { type: 'string', multiple: true }, - since: { type: 'string' }, - in: { type: 'string' }, - out: { type: 'string' }, - concurrency: { type: 'string' }, - 'rate-limit': { type: 'string' }, - cap: { type: 'string' }, - 'min-confidence': { type: 'string' }, - force: { type: 'boolean' }, - 'dry-run': { type: 'boolean' }, - merge: { type: 'boolean' }, - all: { type: 'boolean' }, - }, - positionals: 0, - stopAt: ['help'], - })) + const { values, concurrency, rateLimit, cap } = withUsageError(() => { + const cli = parseCli(rest, { + options: { + help: { type: 'boolean', short: 'h' }, + guild: { type: 'string' }, + channel: { type: 'string', multiple: true }, + since: { type: 'string' }, + in: { type: 'string' }, + out: { type: 'string' }, + concurrency: { type: 'string' }, + 'rate-limit': { type: 'string' }, + cap: { type: 'string' }, + 'min-confidence': { type: 'string' }, + force: { type: 'boolean' }, + 'dry-run': { type: 'boolean' }, + merge: { type: 'boolean' }, + all: { type: 'boolean' }, + }, + positionals: 0, + stopAt: ['help'], + }) + if (cli.stopped === 'help') return cli + const v = cli.values + if ('since' in v) dateOption(v.since, { option: '--since', min: DISCORD_EPOCH }) + if ('minConfidence' in v) choiceOption(v.minConfidence, { option: '--min-confidence', choices: ['high', 'medium', 'low'] }) + // --merge grafts results already on disk and reads none of the three modes. + if (command === 'extract' && !v.merge) refuseTogether(v, ['since', 'all', 'force']) + return { + ...cli, + concurrency: 'concurrency' in v ? numberOption(v.concurrency, { option: '--concurrency', integer: true, min: 1 }) : undefined, + rateLimit: 'rateLimit' in v ? numberOption(v.rateLimit, { option: '--rate-limit', above: 0 }) : undefined, + cap: 'cap' in v ? numberOption(v.cap, { option: '--cap', integer: true, min: 1 }) : undefined, + } + }) if (values.help) printHelpAndExit(USAGE) const flags = { channels: values.channel } @@ -44,9 +61,9 @@ function parseArgs(argv) { if ('since' in values) flags.since = values.since if ('in' in values) flags.in = values.in if ('out' in values) flags.out = values.out - if ('concurrency' in values) flags.concurrency = parseInt(values.concurrency, 10) - if ('rateLimit' in values) flags.rateLimit = parseFloat(values.rateLimit) - if ('cap' in values) flags.cap = parseInt(values.cap, 10) + if (concurrency !== undefined) flags.concurrency = concurrency + if (rateLimit !== undefined) flags.rateLimit = rateLimit + if (cap !== undefined) flags.cap = cap if ('minConfidence' in values) flags.minConfidence = values.minConfidence if (values.force) flags.force = true if (values.dryRun) flags.dryRun = true From 0c7e446ba8405fe7301183353fef40af448d3c5b Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober <kuba@mareimbrium.org> Date: Wed, 30 Sep 2026 12:31:09 +0200 Subject: [PATCH 15/21] builder, scripts: tbdocs and check_links exit 0, 1 or 2 like every tool --- .github/workflows/checks.yml | 4 +- builder/PLAN-TOOLING-REVIEW.md | 23 +++++- builder/check.mjs | 4 +- builder/command-line.mjs | 2 +- builder/dot.mjs | 2 +- builder/scss.mjs | 2 +- builder/serve.mjs | 9 +-- builder/tbdocs.mjs | 55 +++++++------- builder/write.mjs | 2 +- docs/Documentation/Builder.md | 6 +- docs/Documentation/Building.md | 6 +- docs/Documentation/Pipeline-Stages.md | 8 +-- docs/Documentation/Tools.md | 10 +-- scripts/check_cli.mjs | 100 +++++++++++++------------- scripts/check_links.mjs | 45 +++++++----- 15 files changed, 153 insertions(+), 125 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 85a28b39..e779c1e2 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -95,8 +95,8 @@ jobs: # links the offline rewrite missed), and _site-pdf/book.html # (informational). A failing check never aborts the build -- a # broken link still produces a site worth inspecting -- so the - # step fails on the exit code: 1 for link failures, 2 for - # integrity failures, 3 for both. + # step fails on the exit code: 1 when the check found a problem, + # 2 when the build could not run. run: node builder/tbdocs.mjs --src docs --no-fetch-assets --check-audit-index # The gates both workflows run, in one list: see # .github/actions/run-gates/action.yml, which check_ci_workflows.mjs diff --git a/builder/PLAN-TOOLING-REVIEW.md b/builder/PLAN-TOOLING-REVIEW.md index 570c8688..319d721a 100644 --- a/builder/PLAN-TOOLING-REVIEW.md +++ b/builder/PLAN-TOOLING-REVIEW.md @@ -227,7 +227,8 @@ is implemented. 1. **A command-line error in `tbdocs` cannot exit 2 (L1-4).** Fixed by C18, which gives a command-line error one value outside the bitmask in both `tbdocs` and `check_links.mjs`, - whose own argument errors the review did not list. + whose own argument errors the review did not list. Superseded by C72b, which drops the + bitmask in both tools, so a command-line error exits 2 as in every tool. 2. **`builder/` cannot import `isOutputTree` (L2-1).** `serve.mjs` is in `builder/`, which must not import `scripts/` (`render.mjs:383`), and `isOutputTree` is in `scripts/lib/markdown-files.mjs`. C12 moves that module into the new top-level `lib/` @@ -1104,6 +1105,26 @@ that expect 4, and C75's note follow. Departure 1 is superseded. **Verify.** `check_cli.mjs`; `build.bat` over a fixture with a broken link and one with an integrity failure exits 1; a crash through `c43-fault.mjs` exits 2. +**Landed.** `tbdocs.mjs` exports `EXIT_FOUND` (1) and `EXIT_ERROR` (2) in place of +`EXIT_FAILED`, `EXIT_INTEGRITY` and `EXIT_COMMAND_LINE`, and `failBuild()` sets 1 where it ORed +a bit. 2 is a refused command line (every `CliError`, and `write.mjs`'s `--dest` refusal), a +crash through `main().catch`, and so also the stall watchdog, which throws there (it exited +1); `serve.mjs` exits 2 on a failed start and on a port in use, both of which exited 1. +`check_links.mjs` has the same two constants; `--no-fail` still forces 0 on findings alone; it +gained `exitOnCrash`, so a throw exits 2 where Node gave 1; and a run of several commands +separated by `/sep/` exits with the highest code among them where it took the first non-zero. +Nothing read a bit (the wrappers test `errorlevel 1`, CI and `compare_trees` test non-zero). +Comments in `check.mjs`, `command-line.mjs`, `dot.mjs`, `scss.mjs`, `write.mjs` and the +`checks.yml` build step (a comment only, so CI shows nothing new), `check_links`' help text, +Tools.md, Building.md, Builder.md and Pipeline-Stages.md follow; `PLAN-12.md` and +`PLAN-checks.md` are design records and keep their codes. The fixture build (`--src +test/fixtures/check-src --check`) exits 1 where it exited 3; the same build with a throw put +into `runBuild` through `c43-fault.mjs` exits 2; `tbdocs --bogus` and `check_links --bogus` +exit 2. `check_cli: 810 probes, all pass`, with every `tbdocs` and `check_links` case +expecting 2 and the `REFUSALS` overrides of 4 gone. `compare_trees`: Builder, Building, +Pipeline-Stages and Tools online and offline, the search data and `book.html`. Lint, regex +safety and the a11y line unchanged; `build.bat`, `check.bat` and `test.bat` clean. + ### C73 — `scripts: one meaning each for --json and --src` **L1-8 (R2), L1-9 (R3).** `--json` prints to stdout in `tbbuild`, `tbrun`, `check_examples` diff --git a/builder/check.mjs b/builder/check.mjs index ffc75e88..3267444f 100644 --- a/builder/check.mjs +++ b/builder/check.mjs @@ -504,8 +504,8 @@ export function findingsFor(r) { // broken links as failures here even though it never fails a build. linksFailed: r.broken.length > 0 || forbiddenCount > 0, // An error means the check did not complete. formatReport counts it - // as an integrity failure and the process exits 2; omitting it here - // let --check-findings report `false` for a run that exited 2. + // as an integrity failure and the process exits 1; omitting it here + // let --check-findings report `false` for a run that exited 1. integrityFailed: integrityCount > 0 || r.errors.length > 0, }; } diff --git a/builder/command-line.mjs b/builder/command-line.mjs index f6097092..dc93bf22 100644 --- a/builder/command-line.mjs +++ b/builder/command-line.mjs @@ -1,7 +1,7 @@ // tbdocs's command line: the option table, the defaults, and the parse. // // parseCommandLine(argv) returns the options runBuild() and runServe() read, -// or throws a CliError whose message is what tbdocs prints before it exits 4. +// or throws a CliError whose message is what tbdocs prints before it exits 2. // The table is lib/cli.mjs's strict one: an unknown option, a positional, a // boolean given a value, a value flag given none (or one that starts with a // dash) and an empty value, except --baseurl's, are all refused. Flags are diff --git a/builder/dot.mjs b/builder/dot.mjs index b5ffeebc..5e937657 100644 --- a/builder/dot.mjs +++ b/builder/dot.mjs @@ -33,7 +33,7 @@ // correct SVG beats a fresh wrong one. // - CONTENT (one .dot has a syntax error, gv.dot throws): warn + keep // that diagram's old SVG + continue the rest of the batch. The -// orchestrator (tbdocs.mjs) sets exit bit EXIT_FAILED on the +// orchestrator (tbdocs.mjs) exits EXIT_FOUND (1) on the // returned `failed` count so a broken diagram surfaces in CI. import { promises as fs } from "node:fs"; diff --git a/builder/scss.mjs b/builder/scss.mjs index ed01db43..41ca000b 100644 --- a/builder/scss.mjs +++ b/builder/scss.mjs @@ -29,7 +29,7 @@ // SETUP -- sass not installed: throw. There is no pre-compiled fallback; // `npm install` is the fix. The error message points there. // CONTENT -- SCSS syntax error: warn, return { failed: true }. The caller -// sets exit bit EXIT_FAILED so CI surfaces it. The site still +// exits EXIT_FOUND (1) so CI surfaces it. The site still // renders but without the just-the-docs theme (the previous // build's CSS lingers under <destRoot>/, if any). // diff --git a/builder/serve.mjs b/builder/serve.mjs index 9ccf0059..8a2b30c1 100644 --- a/builder/serve.mjs +++ b/builder/serve.mjs @@ -14,7 +14,7 @@ import { readFile, stat, watch } from "node:fs/promises"; import { existsSync } from "node:fs"; import path from "node:path"; import { isOutputTree } from "../lib/markdown-files.mjs"; -import { runBuild, createWorkerPool, EXIT_FAILED, EXIT_COMMAND_LINE } from "./tbdocs.mjs"; +import { runBuild, createWorkerPool, EXIT_ERROR } from "./tbdocs.mjs"; const MIME = { ".html": "text/html; charset=utf-8", @@ -230,8 +230,9 @@ export async function runServe(opts) { } catch (err) { console.error("serve: initial build failed:", describeBuildError(err)); await pool.destroy(); - // A --dest the build refuses is a command-line error, as in tbdocs's main(). - process.exit(err?.commandLine ? EXIT_COMMAND_LINE : EXIT_FAILED); + // A --dest the build refuses is a command-line error and any other throw a + // crash; both exit EXIT_ERROR, as in tbdocs's main(). + process.exit(EXIT_ERROR); } const staticHandler = createStaticHandler(destRoot); @@ -248,7 +249,7 @@ export async function runServe(opts) { server.on("error", (err) => { if (err.code === "EADDRINUSE") { console.error(`serve: port ${port} already in use. Pass --port <other> to choose another, or stop the process bound to ${port}.`); - process.exit(EXIT_FAILED); + process.exit(EXIT_ERROR); } throw err; }); diff --git a/builder/tbdocs.mjs b/builder/tbdocs.mjs index 44600a69..32558f59 100644 --- a/builder/tbdocs.mjs +++ b/builder/tbdocs.mjs @@ -30,9 +30,10 @@ // origin -- e.g. https://kubao.github.io -- so canonical URLs match // the actual deployment instead of the configured production host). // -// Exit codes: 0 clean; 1 a link failure, a failed build step, a page-count -// or symbol-baseline drop, or a crash; 2 an integrity failure; 3 both. A -// command-line error, a --dest the build refuses included, exits 4. +// Exit codes, as in every tool: 0 clean; 1 the build or its check found a +// problem (a link or integrity failure, a failed build step, a page-count or +// symbol-baseline drop); 2 it could not do its job (a command-line error, a +// --dest the build refuses included, or a crash). import { promises as fs } from "node:fs"; import os from "node:os"; @@ -95,20 +96,17 @@ import { const CPU_WORKER_URL = new URL("./cpu-worker.mjs", import.meta.url); const PACKAGE_API_PATH = new URL("./package-api.json", import.meta.url); -// The exit codes in the header. The link and integrity bits are the ones -// scripts/check_links.mjs sets, so CI can tell a broken link from malformed -// output; EXIT_FAILED also carries every other failure. The command-line -// value is outside both bits, so a mistyped flag never reads as a broken -// link. -export const EXIT_FAILED = 1; -export const EXIT_INTEGRITY = 2; -export const EXIT_COMMAND_LINE = 4; - -// Sets `bit` in the exit code and keeps the bits already set, so a build -// that fails two ways reports both. runBuild sets the exit code only through -// this; an assignment would report the last failure alone. -function failBuild(bit) { - process.exitCode = (process.exitCode ?? 0) | bit; +// The exit codes in the header. EXIT_FOUND is every problem the build reports +// and still writes a site for; EXIT_ERROR is a refused command line or a crash, +// where the build did not finish its job, so a mistyped flag never reads as a +// broken link. +export const EXIT_FOUND = 1; +export const EXIT_ERROR = 2; + +// Marks the build failed. runBuild sets the exit code only through this, so +// no later step can clear it. +function failBuild() { + process.exitCode = EXIT_FOUND; } // ── Task graph ──────────────────────────────────────────────────────────────── @@ -454,7 +452,7 @@ const TASKS = { for (const f of out.files) { if (!known.has(f.srcRel)) state.staticFiles.push(f); } - if (out.failed > 0) failBuild(EXIT_FAILED); + if (out.failed > 0) failBuild(); }, }, @@ -1373,8 +1371,8 @@ export async function runBuild(opts) { if (dotStats.failed > 0) parts.push(`failed ${dotStats.failed}`); console.log(`dot: ${parts.join(", ")} of ${dotStats.processed} SVG(s)`); } - if (dotStats.failed > 0) failBuild(EXIT_FAILED); - if (scssResult.failed) failBuild(EXIT_FAILED); + if (dotStats.failed > 0) failBuild(); + if (scssResult.failed) failBuild(); const flushStats = results.get("flushJoin"); const assetStats = results.get("writeAssets"); @@ -1467,11 +1465,10 @@ export async function runBuild(opts) { console.log(` ${pc.bold("check:")}`); process.stdout.write(checkResult.text); process.stdout.write(recheck.text); - if (checkResult.linksFailed) failBuild(EXIT_FAILED); - if (checkResult.integrityFailed || recheck.failed) failBuild(EXIT_INTEGRITY); + if (checkResult.linksFailed || checkResult.integrityFailed || recheck.failed) failBuild(); } else if (recheck.failed) { process.stdout.write(recheck.text); - failBuild(EXIT_INTEGRITY); + failBuild(); } console.log(scheduler.summary()); @@ -1493,7 +1490,7 @@ export async function runBuild(opts) { force: !!opts.updatePageBaseline, }); if (drift.text) process.stdout.write(drift.text); - if (drift.failed) failBuild(EXIT_FAILED); + if (drift.failed) failBuild(); // The same guard over the URLs tB/symbols.json has published, which an // installed IDE help add-in holds a copy of -- see symbol-baseline.mjs. @@ -1505,17 +1502,17 @@ export async function runBuild(opts) { force: !!opts.updateSymbolBaseline, }); if (lost.text) process.stdout.write(lost.text); - if (lost.failed) failBuild(EXIT_FAILED); + if (lost.failed) failBuild(); } return { pages, staticFiles, site, destRoot }; } // A command-line error is reported by its message alone and exits -// EXIT_COMMAND_LINE. write.mjs marks its --dest refusal, which runBuild makes +// EXIT_ERROR. write.mjs marks its --dest refusal, which runBuild makes // before any task runs, with `commandLine` for the same exit. async function main() { - const opts = withUsageError(() => parseCommandLine(process.argv.slice(2)), { exitCode: EXIT_COMMAND_LINE }); + const opts = withUsageError(() => parseCommandLine(process.argv.slice(2)), { exitCode: EXIT_ERROR }); if (opts.help) printHelpAndExit(USAGE); if (opts.serve) { const { runServe } = await import("./serve.mjs"); @@ -1530,13 +1527,13 @@ if (isEntry) { main().catch((err) => { if (err?.commandLine) { console.error(err.message); - process.exit(EXIT_COMMAND_LINE); + process.exit(EXIT_ERROR); } // A stall report is the diagnostic; the Error wrapping it carries a // stack pointing at the watchdog's own setInterval, which tells the // reader nothing and buries the part that does. if (err?.stalled && err.cause?.message) console.error(err.cause.message); else console.error(err); - process.exit(EXIT_FAILED); + process.exit(EXIT_ERROR); }); } diff --git a/builder/write.mjs b/builder/write.mjs index 46e932f2..85321742 100644 --- a/builder/write.mjs +++ b/builder/write.mjs @@ -135,7 +135,7 @@ export function isUnderProject(destRoot) { // discover skips but the watcher does not rebuilds on its own writes. // Cleaning a destination that is or contains the source tree deletes the // source. runBuild calls this before discover, which is the first to fail. -// The refusal is marked as a command-line error, which tbdocs exits 4 on. +// The refusal is marked as a command-line error, which tbdocs exits 2 on. export function assertDestinationClearOfSource(srcRoot, destRoot) { const refuse = (message) => Object.assign(new Error(message), { commandLine: true }); const rel = path.relative(srcRoot, destRoot); diff --git a/docs/Documentation/Builder.md b/docs/Documentation/Builder.md index 92a9cbc9..b0df44e3 100644 --- a/docs/Documentation/Builder.md +++ b/docs/Documentation/Builder.md @@ -588,8 +588,8 @@ The build aborts or flips the exit code under a handful of conditions: - **Page-count drift.** `runBuild()` ends by comparing this build's inventory against `builder/page-baseline.json` --- a committed file holding the page and static-file counts of the last build anyone committed. A rise rewrites it and says so; a fall fails the build. `discover()` returns {{tbdocs:pages}} pages today --- every `.md` and `.html` under `docs/` with a parseable frontmatter block, after `_config.yml`'s `exclude:`. It used to be `if (pages.length < 836)`, a constant written when the site had 836 pages, which by then left a margin of more than seventy: a collapse alarm rather than a drift check, and one that would not have fired on a repeat of the 37-page `AppGlobalClassObject/_App/` loss that [Authoring](Authoring#what-may-live-in-docs) describes. See [the page-count drift guard](Building#the-page-count-drift-guard) for why the baseline is a file rather than a tighter constant. What reports a single page depends on how it went missing: a page that loses its frontmatter --- a UTF-8 BOM in front of the `---`, or any line before it --- is reclassified as a static file, and the publish-policy sweep below then aborts the build naming the path, because `.md` is in neither extension set; a page dropped by an `exclude:` pattern is never seen at all, and the only report is the link check flagging broken links into it, so a page nothing links to disappears without a message. Nav integrity covers the remaining case only indirectly --- it fires when a *parent* disappears and strands its children, not when a leaf does. - **SAB structural validation.** `verifySchedulerSAB(TASKS, views, idMapping)` runs immediately after allocation. A misconfigured `expected`/`perWorkerDeps` list, a duplicate task name, or a successor edge to an unknown task aborts the build before any task runs. -- **DOT render failure.** Per-diagram failures retain the previous SVG and continue the batch so every broken diagram appears in one run; the orchestrator sets exit bit 1 (`EXIT_FAILED`) from the failure count. -- **SCSS compile failure.** The light/dark workers warn with the source location and continue with `failed: true`; the joiner sets exit bit 1 (`EXIT_FAILED`). Existing `_site/` CSS lingers. +- **DOT render failure.** Per-diagram failures retain the previous SVG and continue the batch so every broken diagram appears in one run; the orchestrator sets exit code 1 (`EXIT_FOUND`) from the failure count. +- **SCSS compile failure.** The light/dark workers warn with the source location and continue with `failed: true`; the joiner sets exit code 1 (`EXIT_FOUND`). Existing `_site/` CSS lingers. - **Nav integrity.** Orphan or ambiguous `parent:` declarations throw inside `nav.execute()`, which aborts the build via `Scheduler._abort()`. - **Unpublishable file type.** Every non-page under `docs/` is copied into the output verbatim, so `publish-policy.mjs` holds an allowlist of types that may be published and throws on anything else --- in `discover` over the static-file inventory, naming the source path before a byte is written, and again in `dispatch` over each tree's derived inventory, which is the only sweep that sees redirect stubs, vendored theme assets and the generated auxiliaries. Neither is behind `--check`: a build run with checks off is exactly when nothing else is looking. This one aborts rather than setting an exit code, on the opposite reasoning to the link check below --- a broken link leaves a tree worth inspecting, a tree carrying a private key does not. `SOURCE_EXTENSIONS` and `BUILD_EXTENSIONS` are separate sets so the build can emit `sitemap.xml` and `search-data.json` without blessing a stray `docs/secrets.json`; [`scripts/check_publish_policy.mjs`](Tools#check-publish-policy) asserts they stay separate. - **Redirect collision.** A `redirect_from:` entry becomes a stub page at that URL, so two ways of claiming one URL are refused in `deriveRedirectStubs()`: an entry pointing at a URL some page already publishes at, and two pages declaring the same entry. Both name the source file on each side --- a stub silently overwriting a real page, or one of two stubs silently winning, would be invisible in the output. @@ -598,7 +598,7 @@ The build aborts or flips the exit code under a handful of conditions: - **A page the book manifest does not mention.** Not a failure: the book is complete for the manifest it was given. `bookCoverage()` compares every page with `_book.yml`'s entries and its `left_out:` list, and the summary prints a `book:` warning for a page in neither, a page in both, an entry that matches no page, and a landing or foreword URL that names no page. It runs only when the book is built, and [`check_book_coverage.mjs`](Tools#check-book-coverage) holds each finding to a probe. See [Book Configuration](Book-Configuration#pages-left-out-of-the-book). - **Worker crash.** A worker handler that throws posts `{ taskFailed, message, stack }` to main; the scheduler calls `_abort()`, the build rejects, and the orchestrator reports the error with the task name in the message. - **A worker that never returns at all.** The one failure with no error to report: a handler stuck in an unbounded loop, an exponentially backtracking regex or a promise that never settles posts nothing, so its successors' dependency counts never fall, `_remaining` never reaches zero, and the scheduler's promise never settles. Nothing in the SAB protocol can see it --- the scheduler is waiting on a message that is not coming. `Scheduler` therefore runs a `setInterval` watchdog: if no task completes for `--stall-timeout` seconds (default 120, `0` disables), it aborts with `{ stalled: true }` and prints the outstanding tasks split three ways --- claimed by a worker that never returned (the cause), runnable but unclaimed (including an `F_PIN_TO_PRED` task whose lane is the wedged one), and blocked on a predecessor (the consequence). A `render:` or `flush:` chunk additionally prints its source pages, through an optional `describe()` on the task def that nothing else reads. `Worker.terminate()` does end a thread spinning inside a regex, so the abort really ends the process. Under `--serve` the pool outlives a rebuild, so `serve.mjs` replaces the whole pool when it sees the `stalled` flag rather than identifying the wedged lane --- the SAB records the lane a task *completed* on, not the one that claimed it. See [when a build stops instead of failing](Building#when-a-build-stops) for the reader-facing form. -- **Link and integrity check** (`--check`). Deliberately the one failure that does *not* abort: a broken link still produces a valid site you want on disk to inspect, unlike a nav ambiguity, where the output itself would be wrong. The check tasks collect findings and `runBuild()` sets the exit code afterwards --- 1 for link failures, 2 for integrity failures, 3 for both, OR'd into whatever the build's own failures already claimed. +- **Link and integrity check** (`--check`). Deliberately the one failure that does *not* abort: a broken link still produces a valid site you want on disk to inspect, unlike a nav ambiguity, where the output itself would be wrong. The check tasks collect findings and `runBuild()` sets the exit code afterwards --- 1 for any link or integrity failure, the same code as every other failed step the build reports. A refused command line or a crash exits 2 instead. - **Incomplete parallel results.** Six checks along the chunk-merge path refuse to carry on with a piece missing: `renderJoin` asserts that every page has rendered content, `render:i`'s merge rejects a page the build does not know, the search index refuses both a page without content and a chunk that never arrived, the book refuses a chapter whose content is absent (as opposed to empty, which is legitimate), and a link-check chunk that errored fails the run instead of printing and passing. None can fire while the task graph is wired correctly. They exist because when it *was* wrong, every one of those places quietly skipped instead --- see below. ### Dependency counts order the work, not the build state diff --git a/docs/Documentation/Building.md b/docs/Documentation/Building.md index 35374751..8d02504f 100644 --- a/docs/Documentation/Building.md +++ b/docs/Documentation/Building.md @@ -166,7 +166,7 @@ that will never return: an unbounded loop, a promise that never settles, or a graph can notice by itself --- the wedged task's successors are waiting on a message that is not coming --- so the build has a watchdog. When no task has completed for two minutes it abandons the run, prints what was outstanding, and -exits 1: +exits 2: BUILD STALLED -- no task completed for 121s. 13 of 335 tasks outstanding. @@ -247,7 +247,7 @@ The link check is part of the build. `build.bat` passes `--check-audit-index`, a It covers all three trees --- `_site/` (the online tree), `_site-offline/` (the `file://`-browsable mirror, which also carries `--forbid 'https://docs.twinbasic.com'` so a surviving live-site link is flagged: the offline mirror should never navigate back to the live docs site), and `_site-pdf/book.html` (informational, and listing as `OUT OF BOOK` every link that leaves the book for the website). Every tree is also checked for HTML well-formedness, duplicate `id`s, anchor resolution, accessibility hints and remote `<img src>`; the online tree adds sitemap, search-index and canonical-URL integrity. The same check runs in CI on every pull request and on every push to `staging`. -A failing check does not abort the build --- a broken link still produces a site worth looking at --- so it sets the exit code instead: 1 for link failures, 2 for integrity failures, 3 for both. +A failing check does not abort the build --- a broken link still produces a site worth looking at --- so it sets the exit code to 1 instead, whether the check found link failures, integrity failures or both. The summary lines say which. [`scripts/check_links.mjs`](Tools#check-links) is still the tool for a tree this build did not produce: a release zip, a bisect, someone else's artifact. @@ -511,7 +511,7 @@ At render time, any markdown image reference to a build-local `.svg` is replaced The renderer calls `@hpcc-js/wasm-graphviz` directly: one WASM module load (~50 ms) covers the whole batch, then each diagram is a synchronous `gv.dot(src)` call. No headless browser and no Chromium dependency for diagrams. Two failure modes are handled distinctly: - **Setup failures** (`@hpcc-js/wasm-graphviz` not installed, WASM load fails) emit a one-line warning, retain the existing on-disk SVGs, and let the build exit 0 --- a fresh checkout without `npm install` still builds against the committed SVGs. -- **Content failures** (broken DOT syntax, render throws) emit the error verbatim, leave that diagram's previous SVG in place, continue rendering the rest of the batch, and set exit bit 1 so CI catches the bad diagram. +- **Content failures** (broken DOT syntax, render throws) emit the error verbatim, leave that diagram's previous SVG in place, continue rendering the rest of the batch, and set exit code 1 so CI catches the bad diagram. In serve mode the watcher ignores any `.svg` that has a `.dot` sibling. The `.dot` is the source of truth; the `.svg` is the build artifact the renderer emits back under `srcRoot`. Without the filter, each `.dot` edit would fire two rebuilds (one on the edit, one on the SVG write) and the browser would reload twice for one user change. diff --git a/docs/Documentation/Pipeline-Stages.md b/docs/Documentation/Pipeline-Stages.md index ac74b02c..11710103 100644 --- a/docs/Documentation/Pipeline-Stages.md +++ b/docs/Documentation/Pipeline-Stages.md @@ -192,7 +192,7 @@ vendorAssets.expected = ["discover"] vendorAssets.execute() → { videos, images, files, fetched, failed } ``` -Calls `vendorAssets(srcRoot, pages, { baseurl, allowFetch })` from `vendor-assets.mjs`. Scans the discovered markdown for YouTube video markers and GitHub user-attachment URLs, downloads anything not already committed into `docs/assets/thumbnails/` or `docs/assets/attachments/`, and hands the new files to the static-file copy pass. Idempotent --- a file already present is never re-fetched --- and the artifacts are committed to git exactly like the generated DOT SVGs. `submit()` puts the two lookup maps on `state.site` (where `dispatch` picks them up for the render workers), appends new descriptors to `state.staticFiles`, and sets exit bit 1 (`EXIT_FAILED`) if any fetch failed. +Calls `vendorAssets(srcRoot, pages, { baseurl, allowFetch })` from `vendor-assets.mjs`. Scans the discovered markdown for YouTube video markers and GitHub user-attachment URLs, downloads anything not already committed into `docs/assets/thumbnails/` or `docs/assets/attachments/`, and hands the new files to the static-file copy pass. Idempotent --- a file already present is never re-fetched --- and the artifacts are committed to git exactly like the generated DOT SVGs. `submit()` puts the two lookup maps on `state.site` (where `dispatch` picks them up for the render workers), appends new descriptors to `state.staticFiles`, and sets exit code 1 (`EXIT_FOUND`) if any fetch failed. `markdownInit` and `writeAssets` both depend on this: the render plugins need the maps to rewrite a marker into a local poster frame, and the copy pass needs the files. @@ -499,7 +499,7 @@ checkReport.execute({ linkJoin, checkBook }) → void Formats every tree's result, decides the exit code, and optionally writes the machine-readable findings. -- **Exit code** follows the same scheme as `check_links.mjs`, so CI can tell the two apart: `1` link failures, `2` integrity failures, `3` both. Set through `failBuild`, which ORs each bit into `process.exitCode`, never by throwing. +- **Exit code** follows the same scheme as `check_links.mjs`: `1` when the check found a link or an integrity failure, whichever it was; the summary lines name which. Set through `failBuild`, which marks the build failed in `process.exitCode`, never by throwing. - **`--check-findings <path>`** writes the findings as JSON for [`check_links_diff.mjs`](Tools#check-links-diff) to diff against the standalone script's. Written *before* the exit code is decided, so a failing check still produces the file that says what it found. - **`--check-audit-index`** additionally diffs the tree index the build derived from its own records against what actually landed on disk. This is the one failure mode the two-checker findings comparison structurally cannot see: a *missing* index entry turns a working link into a reported break, which is loud, but a *spurious* one makes the oracle answer "exists" for a path that 404s in production, and on a clean site nothing links to a path that does not exist, so nothing would ever notice. Cost is one `readdir` per tree. @@ -972,14 +972,14 @@ The handler table is built from the imported `HANDLERS` constant: |---|---|---| | `OPTIONS` | `{ [flag]: { type, empty? } }` | `tbdocs`'s flags, in the table shape `lib/cli.mjs`'s `parseCli` reads: `"string"` for a flag that takes a value, `"boolean"` for the rest. `--baseurl` alone has `empty: true`, since an empty base URL is the site root. `--no-check`, `--no-offline`, `--no-pdf` and `--no-fetch-assets` are flags of their own, not negations. | | `DEFAULTS` | frozen `BuildOpts` | The options a build takes with no flags given --- the defaults in the `BuildOpts` table below. `fetchAssets` is left out. | -| `parseCommandLine` | `(argv) → BuildOpts` | Reads `argv` (without Node's own two entries) through `parseCli`, then applies the flags in the order given, so a `--no-check` undoes only the check flags before it. Throws a `CliError` whose message `main()` prints before it exits 4: `unknown option: <arg>`, `unexpected argument: <arg>`, `<flag> takes no value`, `<flag> needs a value`, `<flag> needs a non-empty value`, a `--port` or `--stall-timeout` value that is not a number in its range (`--port expects a whole number from 1 to 65535, got: <value>`), or a `--url` that is not an absolute `http` or `https` URL. | +| `parseCommandLine` | `(argv) → BuildOpts` | Reads `argv` (without Node's own two entries) through `parseCli`, then applies the flags in the order given, so a `--no-check` undoes only the check flags before it. Throws a `CliError` whose message `main()` prints before it exits 2: `unknown option: <arg>`, `unexpected argument: <arg>`, `<flag> takes no value`, `<flag> needs a value`, `<flag> needs a non-empty value`, a `--port` or `--stall-timeout` value that is not a number in its range (`--port expects a whole number from 1 to 65535, got: <value>`), or a `--url` that is not an absolute `http` or `https` URL. | ### `tbdocs.mjs` orchestrator | Symbol | Signature | Description | |---|---|---| | `runBuild` | `(opts) → Promise<{ pages, staticFiles, site, destRoot }>` | Runs the full pipeline. Allocates the SAB, spawns or reuses the pool, sends `init` to every worker, awaits `scheduler.start(ctx)`, logs the summary, injects the Gantt chart, returns the final state. | -| `EXIT_FAILED`, `EXIT_INTEGRITY`, `EXIT_COMMAND_LINE` | `number` | `1`, `2` and `4`: the exit bits for a link failure or any other failed step and for an integrity failure, and the value for a command-line error, outside both bits. `runBuild` sets a bit through a private `failBuild(bit)`, which ORs it into `process.exitCode`, so a build that fails two ways exits with both bits. `serve.mjs` exits with the same values. | +| `EXIT_FOUND`, `EXIT_ERROR` | `number` | `1` and `2`: the exit code for a problem the build reports (a link or integrity failure, or any other failed step), and the one for a command-line error or a crash. `runBuild` marks the build failed through a private `failBuild()`, which sets `process.exitCode` to `EXIT_FOUND`. `serve.mjs` exits with `EXIT_ERROR` when its initial build throws or its port is in use. | | `createWorkerPool` | `() → WorkerPool` | Factory for `serve.mjs`. Lets the dev server construct one pool at startup and pass it to every `runBuild()` call without importing `WorkerPool` itself. | `BuildOpts` fields: diff --git a/docs/Documentation/Tools.md b/docs/Documentation/Tools.md index 1dbc3f07..6741d339 100644 --- a/docs/Documentation/Tools.md +++ b/docs/Documentation/Tools.md @@ -8,7 +8,7 @@ permalink: /Documentation/Development/Tools # Tools and Scripts {: .no_toc } -One-line-per-tool reference for every executable in the documentation repository: the seven Windows batch wrappers at the repository root, the Node and Python scripts under `scripts/` (cross-platform except for [`tbbuild.mjs`](#tbbuild), which drives the twinBASIC IDE), the `tbdocs` orchestrator and its CLI flags, and the PDF render driver. If you are looking for the day-to-day workflow rather than a cheat sheet, the [Building and Deployment](Building) page is the gentler read; if you are modifying the build pipeline itself, the [tbdocs Internals](Builder) page goes one level deeper. Every Node tool answers `--help` or `-h` by printing its usage to standard output and exiting 0, without doing any of its work. A command line a tool cannot use is refused the same way by every Node tool: an unknown flag, a flag without its value or with an empty one, and an unexpected argument are reported on standard error and exit **2** (**4** in `tbdocs` and `check_links`, whose lower codes are a bitmask). So is a value a tool cannot use, before it does any work: a number that is not one or is out of range (a fraction where a whole number is needed), a regular expression that does not compile, a URL that is not an absolute `http` or `https` one, a date that is not ISO 8601, a value outside a fixed set, and options that exclude each other. A tool that reads its command line through `lib/cli.mjs` and takes search terms, file names or folder names takes one that starts with a dash after `--`. +One-line-per-tool reference for every executable in the documentation repository: the seven Windows batch wrappers at the repository root, the Node and Python scripts under `scripts/` (cross-platform except for [`tbbuild.mjs`](#tbbuild), which drives the twinBASIC IDE), the `tbdocs` orchestrator and its CLI flags, and the PDF render driver. If you are looking for the day-to-day workflow rather than a cheat sheet, the [Building and Deployment](Building) page is the gentler read; if you are modifying the build pipeline itself, the [tbdocs Internals](Builder) page goes one level deeper. Every Node tool answers `--help` or `-h` by printing its usage to standard output and exiting 0, without doing any of its work. A command line a tool cannot use is refused the same way by every Node tool: an unknown flag, a flag without its value or with an empty one, and an unexpected argument are reported on standard error and exit **2**. So is a value a tool cannot use, before it does any work: a number that is not one or is out of range (a fraction where a whole number is needed), a regular expression that does not compile, a URL that is not an absolute `http` or `https` one, a date that is not ISO 8601, a value outside a fixed set, and options that exclude each other. A tool that reads its command line through `lib/cli.mjs` and takes search terms, file names or folder names takes one that starts with a dash after `--`. * TOC goes here {:toc} @@ -227,18 +227,18 @@ Full invocation: | `--tolerate-missing-images` | Downgrade Phase 8's missing-image error to a warning. Use when the source tree is mid-edit and may temporarily reference an image that does not yet exist. | | `--fetch-assets` / `--no-fetch-assets` | Force remote-asset vendoring on or off. Without either flag, the build downloads missing YouTube thumbnails and GitHub user-attachment images on a dev machine, and refuses to download anything when `$CI` is set --- a referenced but uncommitted asset is a hard build error there. See [Authoring Pages](Authoring#committing-downloaded-assets). | | `--profile-offline` | Print per-substep timing for the offline tree pass. | -| `--check` | Run the link and site-integrity check over the HTML the build already holds in memory, across every tree it produced. A failing check does not abort the build; it sets the exit code --- 1 for link failures, 2 for integrity failures, 3 for both. | +| `--check` | Run the link and site-integrity check over the HTML the build already holds in memory, across every tree it produced. A failing check does not abort the build; it sets the exit code to 1, and its summary lines say whether links, integrity or both failed. | | `--no-check` | Turn the check off again. Flags are read in order, so this wins over a `--check` baked into `build.bat`. | | `--check-audit-index` | Implies `--check`, and additionally diffs the tree index the build derives from its own records against what landed on disk. A spurious entry makes the link oracle answer "exists" for a path that 404s in production, and nothing else would notice. This is what `build.bat` passes. | | `--check-findings <path>` | Implies `--check`, and writes the findings as JSON for a tool to read. Used by [`scripts/check_links_diff.mjs`](#check-links-diff). | | `--update-page-baseline` | Record this build's page and static-file counts in `builder/page-baseline.json` as the drift guard's new baseline, in whichever direction they moved. An ordinary build raises the baseline by itself; only a **fall** needs this flag, because a fall is what the guard exists to catch. See [the page-count drift guard](Building#the-page-count-drift-guard). | | `--update-symbol-baseline` | Record this build's symbol-index URLs in `builder/symbol-baseline.json`, whichever left it. New URLs are recorded by an ordinary build; only a URL the index has **stopped** publishing needs this flag --- and usually needs a pinned heading id instead. See [the symbol index and its drift guard](Building#the-symbol-index). | | `--symbol-gaps <path>` | Write the public symbols no page documents to a JSON file: each one's package, container, name, kind, and the page its container is on. Names a package's `exclude_from_docs:` lists are left out. | -| `--stall-timeout <seconds>` | How long the build waits with no task completing before it gives up, names the outstanding tasks and exits 1. Default: 120; a number of seconds, fractions allowed. `0` disables the watchdog and returns the build to hanging in silence on a wedged worker. See [when a build stops instead of failing](Building#when-a-build-stops). | +| `--stall-timeout <seconds>` | How long the build waits with no task completing before it gives up, names the outstanding tasks and exits 2. Default: 120; a number of seconds, fractions allowed. `0` disables the watchdog and returns the build to hanging in silence on a wedged worker. See [when a build stops instead of failing](Building#when-a-build-stops). | | `--serve` | Start the long-lived dev server (watch + rebuild + SSE live-reload). Offline and PDF passes are skipped each rebuild. | | `--port <N>` | HTTP port for `--serve` mode, a whole number from 1 to 65535. Default: 4000. | -Exit codes: **0** clean; **1** a link failure, a failed build step, a fall in the page count, a symbol-index URL lost, or a crash; **2** an integrity failure; **3** both. A command-line error --- an unknown flag, an unexpected argument, a flag without its value or with an empty one (`--baseurl` alone accepts one, meaning the site root), a value a flag cannot use (a `--port` that is not a port number, a negative or non-numeric `--stall-timeout`, a `--url` that is not an absolute `http` or `https` URL), or a `--dest` the build refuses --- is reported on standard error and exits **4**, which no check can produce, so it is never read as a broken link. +Exit codes: **0** clean; **1** the build or its check found a problem: a link or integrity failure, a failed build step, a fall in the page count, or a symbol-index URL lost; **2** the build could not do its job, from a crash (a stalled build included) or a command-line error. A command-line error --- an unknown flag, an unexpected argument, a flag without its value or with an empty one (`--baseurl` alone accepts one, meaning the site root), a value a flag cannot use (a `--port` that is not a port number, a negative or non-numeric `--stall-timeout`, a `--url` that is not an absolute `http` or `https` URL), or a `--dest` the build refuses --- is reported on standard error and exits **2**, so it is never read as a broken link. ### check_links.mjs {: #check-links } @@ -265,7 +265,7 @@ Offline (filesystem-only) link checker plus optional integrity checks. Multiple | `--check-canonical` | Assert each page's canonical URL matches its location. | | `--no-fail` | Downgrade failures to informational output (exit 0 even with broken links). | -Exit code 1 indicates broken links; exit code 2 indicates integrity-only failures (the integrity checks share the same SAX parse pass as link extraction). Exit code 4 is a command-line error --- no arguments, an unknown flag, a flag without its value or with an empty one, no `--offline`, or no input --- reported on standard error, and no check can produce it. The script dedupes `(target, fragment)` so each unique filesystem check fires exactly once regardless of how many pages link to the same target --- on the current tree (~733k link occurrences, ~12k unique targets across 1,127 HTML files / 124 MB) each pass runs in ~2.2 seconds on a development box. +Exit code 1 indicates a check found a problem, broken links or integrity failures or both (the integrity checks share the same SAX parse pass as link extraction); the summary lines say which. `--no-fail` turns it into 0. Exit code 2 means the check could not run: a command-line error --- no arguments, an unknown flag, a flag without its value or with an empty one, no `--offline`, or no input --- reported on standard error, or a crash. `--no-fail` does not change it. The script dedupes `(target, fragment)` so each unique filesystem check fires exactly once regardless of how many pages link to the same target --- on the current tree (~733k link occurrences, ~12k unique targets across 1,127 HTML files / 124 MB) each pass runs in ~2.2 seconds on a development box. ### crawl_check.mjs diff --git a/scripts/check_cli.mjs b/scripts/check_cli.mjs index be9ab377..28bf8cb9 100644 --- a/scripts/check_cli.mjs +++ b/scripts/check_cli.mjs @@ -403,17 +403,17 @@ function capture(fn) { const CASES = [ // Recorded in C47, from the behaviour C18 settled: a command-line error in - // tbdocs and check_links exits 4, outside the 1/2/3 bitmask of the link and - // integrity checks, so it never reads as a broken link. A value flag has no + // tbdocs and check_links exits 2, as in every tool, and never reads as a + // finding, which exits 1. A value flag has no // value at the end of the list or before another flag, and --port is a whole // number from 1 to 65535. check_links prints its errors on stderr, after // "error: ". - { tool: "builder/tbdocs.mjs", args: ["--port"], exit: 4, stderr: "--port needs a value\n" }, - { tool: "builder/tbdocs.mjs", args: ["--dest", "--no-pdf"], exit: 4, stderr: "--dest needs a value\n" }, - { tool: "builder/tbdocs.mjs", args: ["--port=0"], exit: 4, stderr: "--port expects a whole number from 1 to 65535, got: 0\n" }, - { tool: "builder/tbdocs.mjs", args: ["--bogus"], exit: 4, stderr: "unknown option: --bogus\n" }, - { tool: "scripts/check_links.mjs", args: ["no-such-tree", "--root-dir"], exit: 4, stderr: "error: --root-dir needs a value\n" }, - { tool: "scripts/check_links.mjs", args: ["no-such-tree", "--forbid"], exit: 4, stderr: "error: --forbid needs a value\n" }, + { tool: "builder/tbdocs.mjs", args: ["--port"], exit: 2, stderr: "--port needs a value\n" }, + { tool: "builder/tbdocs.mjs", args: ["--dest", "--no-pdf"], exit: 2, stderr: "--dest needs a value\n" }, + { tool: "builder/tbdocs.mjs", args: ["--port=0"], exit: 2, stderr: "--port expects a whole number from 1 to 65535, got: 0\n" }, + { tool: "builder/tbdocs.mjs", args: ["--bogus"], exit: 2, stderr: "unknown option: --bogus\n" }, + { tool: "scripts/check_links.mjs", args: ["no-such-tree", "--root-dir"], exit: 2, stderr: "error: --root-dir needs a value\n" }, + { tool: "scripts/check_links.mjs", args: ["no-such-tree", "--forbid"], exit: 2, stderr: "error: --forbid needs a value\n" }, // Recorded in C47, from the behaviour C17 settled: in the four harness tools // that check, a value flag with no value, at the end or before another flag, @@ -504,7 +504,7 @@ const CASES = [ { tool: "scripts/gen_attribute_probes.mjs", args: [], exit: 2, stderr: /^Generate a twinBASIC probe project for Reference\/Attributes\.md applicability\.\n/ }, // Recorded in C50, for the gates and link tools. check_links prints its - // errors on stderr, after "error: ", and exits 4. Every one of them refuses + // errors on stderr, after "error: ", and exits 2. Every one of them refuses // an unknown option, a stray argument, a value given to a flag that takes // none, and a value flag with no value, and crawl_check, compare_trees and // survey_tooling follow the message with their usage. check_lint prints its @@ -513,11 +513,11 @@ const CASES = [ // only flags of their own, and none has a case beyond --help, -h and the // generated ones below. { tool: "scripts/check_links.mjs", args: ["--help"], exit: 0, stdout: /^Usage: node check_links\.mjs \[options\] <inputs\.\.\.>\n/ }, - { tool: "scripts/check_links.mjs", args: ["no-such-tree"], exit: 4, stderr: "error: --offline is required. Online (network) checking is not implemented by this tool.\n" }, - { tool: "scripts/check_links.mjs", args: ["--offline"], exit: 4, stderr: "error: at least one input file or directory is required\n" }, - { tool: "scripts/check_links.mjs", args: ["--offline", "--bogus", "no-such-tree"], exit: 4, stderr: "error: unknown option: --bogus\n" }, - { tool: "scripts/check_links.mjs", args: ["--offline", "-x", "--bogus=1"], exit: 4, stderr: "error: unknown option: -x\n" }, - { tool: "scripts/check_links.mjs", args: ["--offline", "--root-dir", "--forbid"], exit: 4, stderr: "error: --root-dir needs a value\n" }, + { tool: "scripts/check_links.mjs", args: ["no-such-tree"], exit: 2, stderr: "error: --offline is required. Online (network) checking is not implemented by this tool.\n" }, + { tool: "scripts/check_links.mjs", args: ["--offline"], exit: 2, stderr: "error: at least one input file or directory is required\n" }, + { tool: "scripts/check_links.mjs", args: ["--offline", "--bogus", "no-such-tree"], exit: 2, stderr: "error: unknown option: --bogus\n" }, + { tool: "scripts/check_links.mjs", args: ["--offline", "-x", "--bogus=1"], exit: 2, stderr: "error: unknown option: -x\n" }, + { tool: "scripts/check_links.mjs", args: ["--offline", "--root-dir", "--forbid"], exit: 2, stderr: "error: --root-dir needs a value\n" }, { tool: "scripts/check_links_diff.mjs", args: ["--help"], exit: 0, stdout: /^Usage: node scripts\/check_links_diff\.mjs \[options\]\n/ }, { tool: "scripts/check_links_diff.mjs", args: [], exit: 2, stderr: /^error: --a and --b are both 'script', which compares nothing\.\n/ }, { tool: "scripts/check_links_diff.mjs", args: ["--bogus"], exit: 2, stderr: "unknown option: --bogus\n" }, @@ -645,41 +645,41 @@ const CASES = [ { tool: "wisdom/wisdom.mjs", args: ["bogus", "--guild", "x", "--bogus"], exit: 2, stderr: "unknown option: --bogus\n" }, // Recorded in C52, for tbdocs, with C47's four above. Every command-line - // error exits 4. A value flag refuses a missing value and one that starts + // error exits 2. A value flag refuses a missing value and one that starts // with a dash, "--" included; an unknown option is refused as given, as is a // positional, and a boolean given a value. Each --port and --stall-timeout // is checked where it stands, so a bad one fails even when a later one is - // good. A --dest the build refuses exits 4 too, after the command line has + // good. A --dest the build refuses exits 2 too, after the command line has // been read. --baseurl takes an empty value, meaning the site root; the // other value flags refuse one. - { tool: "builder/tbdocs.mjs", args: ["--src"], exit: 4, stderr: "--src needs a value\n" }, - { tool: "builder/tbdocs.mjs", args: ["--url"], exit: 4, stderr: "--url needs a value\n" }, - { tool: "builder/tbdocs.mjs", args: ["--check-findings"], exit: 4, stderr: "--check-findings needs a value\n" }, - { tool: "builder/tbdocs.mjs", args: ["--symbol-gaps", "--serve"], exit: 4, stderr: "--symbol-gaps needs a value\n" }, - { tool: "builder/tbdocs.mjs", args: ["--dest", "--"], exit: 4, stderr: "--dest needs a value\n" }, - { tool: "builder/tbdocs.mjs", args: ["--stall-timeout"], exit: 4, stderr: "--stall-timeout needs a value\n" }, - { tool: "builder/tbdocs.mjs", args: ["--stall-timeout", "-1"], exit: 4, stderr: "--stall-timeout needs a value\n" }, - { tool: "builder/tbdocs.mjs", args: ["foo"], exit: 4, stderr: "unexpected argument: foo\n" }, - { tool: "builder/tbdocs.mjs", args: ["-"], exit: 4, stderr: "unexpected argument: -\n" }, - { tool: "builder/tbdocs.mjs", args: ["-x"], exit: 4, stderr: "unknown option: -x\n" }, - { tool: "builder/tbdocs.mjs", args: ["-xy"], exit: 4, stderr: "unknown option: -xy\n" }, + { tool: "builder/tbdocs.mjs", args: ["--src"], exit: 2, stderr: "--src needs a value\n" }, + { tool: "builder/tbdocs.mjs", args: ["--url"], exit: 2, stderr: "--url needs a value\n" }, + { tool: "builder/tbdocs.mjs", args: ["--check-findings"], exit: 2, stderr: "--check-findings needs a value\n" }, + { tool: "builder/tbdocs.mjs", args: ["--symbol-gaps", "--serve"], exit: 2, stderr: "--symbol-gaps needs a value\n" }, + { tool: "builder/tbdocs.mjs", args: ["--dest", "--"], exit: 2, stderr: "--dest needs a value\n" }, + { tool: "builder/tbdocs.mjs", args: ["--stall-timeout"], exit: 2, stderr: "--stall-timeout needs a value\n" }, + { tool: "builder/tbdocs.mjs", args: ["--stall-timeout", "-1"], exit: 2, stderr: "--stall-timeout needs a value\n" }, + { tool: "builder/tbdocs.mjs", args: ["foo"], exit: 2, stderr: "unexpected argument: foo\n" }, + { tool: "builder/tbdocs.mjs", args: ["-"], exit: 2, stderr: "unexpected argument: -\n" }, + { tool: "builder/tbdocs.mjs", args: ["-x"], exit: 2, stderr: "unknown option: -x\n" }, + { tool: "builder/tbdocs.mjs", args: ["-xy"], exit: 2, stderr: "unknown option: -xy\n" }, { tool: "builder/tbdocs.mjs", args: ["-h"], exit: 0, stdout: /^usage: node builder\/tbdocs\.mjs \[options\]\n/ }, { tool: "builder/tbdocs.mjs", args: ["--help"], exit: 0, stdout: /^usage: node builder\/tbdocs\.mjs \[options\]\n/ }, - { tool: "builder/tbdocs.mjs", args: ["--dry-run=1"], exit: 4, stderr: "--dry-run takes no value\n" }, - { tool: "builder/tbdocs.mjs", args: ["--no-check=1"], exit: 4, stderr: "--no-check takes no value\n" }, - { tool: "builder/tbdocs.mjs", args: ["--no-check", "--bogus"], exit: 4, stderr: "unknown option: --bogus\n" }, - { tool: "builder/tbdocs.mjs", args: ["--port", "abc"], exit: 4, stderr: "--port expects a whole number from 1 to 65535, got: abc\n" }, - { tool: "builder/tbdocs.mjs", args: ["--port="], exit: 4, stderr: "--port needs a non-empty value\n" }, - { tool: "builder/tbdocs.mjs", args: ["--stall-timeout="], exit: 4, stderr: "--stall-timeout needs a non-empty value\n" }, - { tool: "builder/tbdocs.mjs", args: ["--port=65536"], exit: 4, stderr: "--port expects a whole number from 1 to 65535, got: 65536\n" }, - { tool: "builder/tbdocs.mjs", args: ["--port=1.5"], exit: 4, stderr: "--port expects a whole number from 1 to 65535, got: 1.5\n" }, - { tool: "builder/tbdocs.mjs", args: ["--port=80", "--port=0"], exit: 4, stderr: "--port expects a whole number from 1 to 65535, got: 0\n" }, - { tool: "builder/tbdocs.mjs", args: ["--port=abc", "--port=80"], exit: 4, stderr: "--port expects a whole number from 1 to 65535, got: abc\n" }, - { tool: "builder/tbdocs.mjs", args: ["--stall-timeout=-1"], exit: 4, stderr: "--stall-timeout expects a number of at least 0, got: -1\n" }, - { tool: "builder/tbdocs.mjs", args: ["--stall-timeout=abc"], exit: 4, stderr: "--stall-timeout expects a number of at least 0, got: abc\n" }, - { tool: "builder/tbdocs.mjs", args: ["--src", ".", "--dest", "."], exit: 4, + { tool: "builder/tbdocs.mjs", args: ["--dry-run=1"], exit: 2, stderr: "--dry-run takes no value\n" }, + { tool: "builder/tbdocs.mjs", args: ["--no-check=1"], exit: 2, stderr: "--no-check takes no value\n" }, + { tool: "builder/tbdocs.mjs", args: ["--no-check", "--bogus"], exit: 2, stderr: "unknown option: --bogus\n" }, + { tool: "builder/tbdocs.mjs", args: ["--port", "abc"], exit: 2, stderr: "--port expects a whole number from 1 to 65535, got: abc\n" }, + { tool: "builder/tbdocs.mjs", args: ["--port="], exit: 2, stderr: "--port needs a non-empty value\n" }, + { tool: "builder/tbdocs.mjs", args: ["--stall-timeout="], exit: 2, stderr: "--stall-timeout needs a non-empty value\n" }, + { tool: "builder/tbdocs.mjs", args: ["--port=65536"], exit: 2, stderr: "--port expects a whole number from 1 to 65535, got: 65536\n" }, + { tool: "builder/tbdocs.mjs", args: ["--port=1.5"], exit: 2, stderr: "--port expects a whole number from 1 to 65535, got: 1.5\n" }, + { tool: "builder/tbdocs.mjs", args: ["--port=80", "--port=0"], exit: 2, stderr: "--port expects a whole number from 1 to 65535, got: 0\n" }, + { tool: "builder/tbdocs.mjs", args: ["--port=abc", "--port=80"], exit: 2, stderr: "--port expects a whole number from 1 to 65535, got: abc\n" }, + { tool: "builder/tbdocs.mjs", args: ["--stall-timeout=-1"], exit: 2, stderr: "--stall-timeout expects a number of at least 0, got: -1\n" }, + { tool: "builder/tbdocs.mjs", args: ["--stall-timeout=abc"], exit: 2, stderr: "--stall-timeout expects a number of at least 0, got: abc\n" }, + { tool: "builder/tbdocs.mjs", args: ["--src", ".", "--dest", "."], exit: 2, stderr: /^refusing --dest (.+): it is or contains the source tree \1, which cleaning it would delete\n$/ }, - { tool: "builder/tbdocs.mjs", args: ["--src=.", "--dest=sub"], exit: 4, + { tool: "builder/tbdocs.mjs", args: ["--src=.", "--dest=sub"], exit: 2, stderr: /^refusing --dest (.+)[\\/]sub: it is inside the source tree, so a build would read its output back as source, or serve would rebuild on its own writes\. Use a folder directly under \1 whose name starts with _site, _serve, _pdf, or one inside such a folder, or one outside \1\.\n$/ }, ]; @@ -747,14 +747,14 @@ for (const [tool, start] of Object.entries(HELP_TOOLS)) { // Recorded in C72. Every tool refuses an unknown flag and an empty value at // the parse, so each has a case for the first and, where it has a value // option, for the second: `tool: [option, extras]`, the option given as -// `--option=`. Both exit 2, or 4 in tbdocs and check_links, print the refusal on +// `--option=`. Both exit 2, print the refusal on // stderr and nothing on stdout. `prefix` is the text a tool puts before its // message. `args` replaces `--bogus` where the tool must never get further than // the parse: convert_em_dash_separators would rewrite docs/ but for --check, and // wisdom needs a command that is not a real one. A case the table above already // holds, the same tool with the same arguments, is not added again. const REFUSALS = { - "builder/tbdocs.mjs": ["src", { exit: 4 }], + "builder/tbdocs.mjs": ["src"], "book/render-book.mjs": ["output"], "wisdom/wisdom.mjs": ["guild", { args: ["bogus", "--bogus"], empty: ["bogus", "--guild="] }], "eval/build_corpus.mjs": ["dest"], @@ -778,7 +778,7 @@ const REFUSALS = { "scripts/check_examples.mjs": ["only", { prefix: "check_examples: " }], "scripts/check_gate_lists.mjs": [null], "scripts/check_impexp_parity.mjs": [null], - "scripts/check_links.mjs": ["root-dir", { exit: 4, prefix: "error: " }], + "scripts/check_links.mjs": ["root-dir", { prefix: "error: " }], "scripts/check_links_diff.mjs": ["a"], "scripts/check_lint.mjs": [null, { prefix: "check_lint: " }], "scripts/check_page_baseline.mjs": [null], @@ -843,12 +843,12 @@ const NOT_URL = (option, v) => `${option} expects an absolute http or https URL, const START = "http://127.0.0.1:9/"; const REGEX_REASON = (option, v) => new RegExp(`^${literal(`${option} expects a regular expression, got: ${v} (`)}.+\\)\\n$`); -// tbdocs and check_links exit 4; tbdocs prints the message alone, check_links after `error: `. -bad("builder/tbdocs.mjs", ["--url", "foo"], NOT_URL("--url", "foo") + "\n", 4); -bad("builder/tbdocs.mjs", ["--url=mailto:x"], NOT_URL("--url", "mailto:x") + "\n", 4); -bad("builder/tbdocs.mjs", ["--stall-timeout", "abc"], "--stall-timeout expects a number of at least 0, got: abc\n", 4); -bad("scripts/check_links.mjs", ["--offline", "--oracle", "x", "no-such-tree"], "error: --oracle expects fs or index, got: x\n", 4); -bad("scripts/check_links.mjs", ["--offline", "--oracle=", "no-such-tree"], "error: --oracle needs a non-empty value\n", 4); +// tbdocs prints the message alone, check_links after `error: `. +bad("builder/tbdocs.mjs", ["--url", "foo"], NOT_URL("--url", "foo") + "\n"); +bad("builder/tbdocs.mjs", ["--url=mailto:x"], NOT_URL("--url", "mailto:x") + "\n"); +bad("builder/tbdocs.mjs", ["--stall-timeout", "abc"], "--stall-timeout expects a number of at least 0, got: abc\n"); +bad("scripts/check_links.mjs", ["--offline", "--oracle", "x", "no-such-tree"], "error: --oracle expects fs or index, got: x\n"); +bad("scripts/check_links.mjs", ["--offline", "--oracle=", "no-such-tree"], "error: --oracle needs a non-empty value\n"); // tbbuild and tbrun follow the message with their usage. for (const [tool, first] of [["scripts/tbbuild.mjs", "x.twinproj"], ["scripts/tbrun.mjs", "no-such-dir"]]) { diff --git a/scripts/check_links.mjs b/scripts/check_links.mjs index 55ada85f..bd0027ce 100644 --- a/scripts/check_links.mjs +++ b/scripts/check_links.mjs @@ -53,10 +53,10 @@ // Integrity checks (--check-html, --check-a11y, --check-ids, // --check-sitemap, --check-search, --check-remote-assets): // These share the existing htmlparser2 SAX parse pass -- no -// second file read. Exit codes are a bitwise pair so CI can tell -// the two apart: 0 clean, 1 link failures, 2 integrity failures, -// 3 both. --no-fail forces 0. A command-line error exits 4, which -// no check can produce, so it is never read as a failed check. +// second file read. Exit codes, as in every tool: 0 clean, 1 a link +// or integrity failure was found, 2 the check could not run (a +// command-line error or a crash), so it is never read as a failed +// check. --no-fail forces 0 over findings, not over a code 2. import * as fs from "node:fs"; import * as os from "node:os"; @@ -78,6 +78,7 @@ import { resolve, OUTSIDE_BASEPATH_MARKER, } from "../builder/link-check.mjs"; import { CliError, choiceOption, parseCli } from "../lib/cli.mjs"; +import { exitOnCrash } from "./lib/gate-probes.mjs"; // Tree-relative POSIX path, the space check.mjs works and reports in, so // the same tree checked with a relative --root-dir, an absolute one, or @@ -187,12 +188,11 @@ Integrity checks (share the existing htmlparser2 SAX parse pass): Exit codes: 0 All checks passed. - 1 Link / forbidden-prefix check failed. - 2 Integrity check failed (no link failures). - 3 Both link and integrity checks failed. - 4 Command-line error, reported on stderr: no arguments, an unknown - option, a flag without its value or with an empty one, no - --offline, or no input. + 1 A link, forbidden-prefix or integrity check failed. The summary + lines say which. + 2 The check could not run. Either a command-line error, reported on + stderr (no arguments, an unknown option, a flag without its value + or with an empty one, no --offline, or no input), or a crash. Inputs are files or directories; directories are searched recursively for *.html. @@ -300,10 +300,15 @@ function collectAllRelFiles(rootStr) { return rels; } +// EXIT_FOUND: the check found a problem. EXIT_ERROR: it could not run, a +// refused command line or a crash. +const EXIT_FOUND = 1; +const EXIT_ERROR = 2; + // Run a single check pass. All output is collected into a buffer; // nothing is written to stdout/stderr. Returns { output, exitCode }, and -// for a command-line error (exit 4) an empty output and `error`, the line for -// stderr. +// for a command-line error (EXIT_ERROR) an empty output and `error`, the line +// for stderr. // // With `structured: true` the result additionally carries a `findings` // object -- builder/check.mjs's findingsFor, the same conclusions the @@ -314,7 +319,7 @@ export function runCheck(argv, { structured = false } = {}) { const buf = []; const write = (s) => buf.push(s); - const commandLineError = (error) => ({ output: "", exitCode: 4, error }); + const commandLineError = (error) => ({ output: "", exitCode: EXIT_ERROR, error }); let parsed; try { @@ -471,10 +476,10 @@ export function runCheck(argv, { structured = false } = {}) { write(`Integrity: ${integrityIssueCount} issue(s)\n`); } - // Exit codes: 1 = link failures, 2 = integrity failures, 3 = both. + // Exit code: EXIT_FOUND for a link or an integrity failure, 0 otherwise. const linksFailed = r.broken.length > 0 || forbiddenCount > 0; const integrityFailed = integrityIssueCount > 0; - let exitCode = (linksFailed ? 1 : 0) | (integrityFailed ? 2 : 0); + let exitCode = linksFailed || integrityFailed ? EXIT_FOUND : 0; if (opts.noFail) exitCode = 0; if (!structured) return { output: buf.join(""), exitCode }; @@ -597,6 +602,10 @@ if (!isMainThread && workerData?.argv) { const result = runCheck(workerData.argv); parentPort.postMessage(result); } else if (isEntry) { + // A throw nothing here catches, a rejected top-level await included, is a + // crash: it exits EXIT_ERROR rather than Node's own 1, which means "found". + exitOnCrash(); + const rawArgv = process.argv.slice(2); if (rawArgv.includes("-h") || rawArgv.includes("--help")) { @@ -622,7 +631,7 @@ if (!isMainThread && workerData?.argv) { if (segments.length === 0) { printHelp(process.stderr); - process.exit(4); + process.exit(EXIT_ERROR); } if (segments.length === 1) { @@ -670,10 +679,10 @@ if (!isMainThread && workerData?.argv) { const r = settled[i].value; process.stdout.write(r.output); if (r.error) process.stderr.write(`${r.error}\n`); - if (r.exitCode !== 0 && exitCode === 0) exitCode = r.exitCode; + exitCode = Math.max(exitCode, r.exitCode); } else { process.stdout.write(`INTERNAL ERROR: ${settled[i].reason}\n`); - if (exitCode === 0) exitCode = 1; + exitCode = EXIT_ERROR; } } From 66cf917aac500839fbf01f3a601e1c802d8074ad Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober <kuba@mareimbrium.org> Date: Wed, 30 Sep 2026 12:54:00 +0200 Subject: [PATCH 16/21] scripts, eval, perf: one meaning each for --json and --src --- builder/PLAN-TOOLING-REVIEW.md | 30 ++++++++++++++++++++ docs/Documentation/Tools.md | 8 +++--- eval/README.md | 2 +- eval/build_corpus.mjs | 24 ++++++++-------- eval/nav_hops.mjs | 20 +++++++------- perf/ab-axe.mjs | 4 +-- perf/probe-axe-dom.mjs | 6 ++-- perf/probe-axe-scaling.mjs | 6 ++-- scripts/build_package_api.mjs | 44 +++++++++++++++--------------- scripts/census_attributes.mjs | 18 ++++++------ scripts/check_a11y_fingerprint.mjs | 14 +++++----- scripts/check_cli.mjs | 28 +++++++++++-------- 12 files changed, 119 insertions(+), 85 deletions(-) diff --git a/builder/PLAN-TOOLING-REVIEW.md b/builder/PLAN-TOOLING-REVIEW.md index 319d721a..ca365b51 100644 --- a/builder/PLAN-TOOLING-REVIEW.md +++ b/builder/PLAN-TOOLING-REVIEW.md @@ -1138,6 +1138,27 @@ follow. **Verify.** `check_cli.mjs`; `git grep` finds no old spelling in the documents or scripts. +**Landed** on the owner's choices of 2026-09-30. `check_a11y_fingerprint`'s file-taking +`--json FILE` is `--out FILE`, as in `census_attributes`, `build_package_api`, `sweep_a11y` +and `run_case`. The package-tree `--src <dir>` of `census_attributes` and `build_package_api` is +`--exported <dir>`. The survey found a third meaning the review had missed: in +`eval/build_corpus.mjs` and `eval/nav_hops.mjs`, `--src` names a repository root that holds +`docs/`, so a reader following `tbdocs` would pass `docs` and get `docs/docs`. Both now take +`--repo`, and `build_corpus`'s `--dest` refusal names `--repo`. The owner also asked for the +three `perf/` rigs, which are outside the review's scope and which no gate runs. +`probe-axe-dom` and `probe-axe-scaling` take `--out FILE`. `ab-axe` already had `--out DIR` +for its output root, and its `--json` did nothing (see Found), so the flag is deleted there. +`--json` is now always a boolean that prints to stdout, and `--src` is always a docs root +(`tbdocs`, `check_publish_policy`). An old spelling is refused as an unknown option, with no +hint. Tools.md and `eval/README.md` follow. WIP.A11y.md and WIP.Harness.md cite neither +spelling, so they are unchanged. `builder/REVIEW-USECASES-16969e5.md:329` keeps `--src`, +because it is a record of that round. `check_cli: 816 probes, all pass` (810 before): +eight re-pointed cases and two re-pointed `build_corpus` refusals, one case per renamed option +showing that the old spelling is refused, and `check_a11y_fingerprint --out` without a value. +The `git grep` for the old spellings finds only those refusal cases. `compare_trees`: Tools +online and offline, the search data and `book.html`. Lint stays at `Checked 172 files` +(`perf/` is not linted). On the owner's next push, CI prints `check_cli: 816 probes, all pass`. + ### C74 — `scripts: one exit-code table per tool, and no code with two meanings` **Decision (e).** Each tool's usage text and its Tools.md entry get one table of exit codes. @@ -1536,6 +1557,11 @@ text, gains a Landed note, and the correction is listed here, as in the last rev takes the second. `tbdocs`' `builder/command-line.mjs` changed too, and the empty-value case exists only for a tool with a value option (26 of 45), so it landed as `builder, scripts, book, eval, wisdom: a refused command line exits 2`. See C72's Landed note. +- **C73: `--src` had a third meaning, and `perf/` came in.** The entry renames only the + review's three options. `eval/build_corpus.mjs` and `eval/nav_hops.mjs` also take a `--src`, + meaning a repository root, and at the owner's choice (2026-09-30) they take `--repo` now. The + three `perf/` rigs' `--json FILE` changed too, so C73 landed as `scripts, eval, perf: one + meaning each for --json and --src`. See C73's Landed note. ## Found while implementing @@ -1822,6 +1848,10 @@ Defects the review did not have, found by building something this plan asks for. - **`wisdom/PLAN-3.md` listed `--threads <dir>` for `extract`**, which takes `--in`; ignored before, refused once C72 lands. Fixed in `builder, scripts, book, eval, wisdom: a refused command line exits 2`. +- **`perf/ab-axe.mjs`'s `--json FILE` did nothing**, found while landing C73: `9c722f17` + removed the write it fed and left the flag, so `jsonOut` was set and never read. The rig + writes `per-rule-measures.json` into its `--out DIR`. The flag is deleted, at the owner's + choice. Fixed in `scripts, eval, perf: one meaning each for --json and --src`. ## Open questions diff --git a/docs/Documentation/Tools.md b/docs/Documentation/Tools.md index 6741d339..cfdb1e7f 100644 --- a/docs/Documentation/Tools.md +++ b/docs/Documentation/Tools.md @@ -596,7 +596,7 @@ Value-equivalence check for the vendored axe source patches. Builds the same col node scripts/check_a11y_fingerprint.mjs [--candidate <scheme>] [--baseline <scheme>] [--patches <name>] [--unminified] [--root-dir <path>] [--pages <list>] - [--theme <t>] [--viewport <v>] [--json] + [--theme <t>] [--viewport <v>] [--out <file>] The gate for any change to *what the scan runs*. axe is the site's correctness oracle, which makes it dangerous to tune: a change can make axe see **less** and still report a clean pass. That nearly shipped once --- blocking `just-the-docs.js` looked like a 130 ms win and quietly dropped the colour-contrast node count on one page from 54 to 2. This runs the full page × theme × viewport matrix twice, once under each of two named schemes from `axe-scan.mjs`'s registry, against one build in one process, and diffs the findings audit by audit (violations by `ruleId:nodeCount`, incomplete by rule-id set). @@ -651,7 +651,7 @@ Measures Inter's advance widths in a browser and writes `builder/inter-metrics.j Writes `builder/package-api.json`: every type the packages of a twinBASIC install declare, public or not, and the public members of each with their kinds. The [symbol index](Building#the-symbol-index) takes its entries from the pages and this file annotates them --- the kind of a member documented on a page of its own, an enumeration's values, the interface a CoClass's members are declared on --- and says which public symbols no page documents. Development tooling like [`build_dot_metrics.mjs`](#build-dot-metrics): the JSON is committed and the build never runs the generator, because running it needs a twinBASIC install, so it is Windows-only in the way [`census_attributes.mjs`](#census-attributes) is. Run it when the reference is re-indexed against a newer build, and commit the result with the pages. -It shares [`census_attributes.mjs`](#census-attributes)'s export and cache, and takes the same `--ide`, `--src`, `--cache` and `--refresh` flags; `--out` writes elsewhere. Packages are keyed by the name code uses for them --- the project name, which is not always the folder's: TwinBasicAssertions is `Assert`, and the three CEF builds are one `cefPackage`, whose APIs the tool checks are identical. Exits 0 when written or up to date, 1 when `--check` finds the file stale, and 2 when the install or an export cannot be read. +It shares [`census_attributes.mjs`](#census-attributes)'s export and cache, and takes the same `--ide`, `--exported`, `--cache` and `--refresh` flags; `--out` writes elsewhere. Packages are keyed by the name code uses for them --- the project name, which is not always the folder's: TwinBasicAssertions is `Assert`, and the three CEF builds are one `cefPackage`, whose APIs the tool checks are identical. Exits 0 when written or up to date, 1 when `--check` finds the file stale, and 2 when the install or an export cannot be read. ### convert_em_dash_separators.mjs {: #convert-em-dash-separators } @@ -1057,7 +1057,7 @@ It also writes a key naming the `Attributes.md` line each probe came from, besid ### census_attributes.mjs {: #census-attributes } - node scripts/census_attributes.mjs [--ide <install>] [--src <dir>] [--cache <dir>] + node scripts/census_attributes.mjs [--ide <install>] [--exported <dir>] [--cache <dir>] [--refresh] [--samples] [--attr <name>] [--json] [--out <file>] [--dump-sites <file>] [--quiet] @@ -1072,7 +1072,7 @@ Grouping is by enclosing construct *and* declaration keyword, because the keywor | Flag | Effect | |---|---| | `--ide <install>` | The install root to census. Defaults to `$TB_IDE`, else the newest `twinBASIC_IDE_BETA_*` on the Desktop. | -| `--src <dir>` | Census an already-exported tree and skip the export entirely. | +| `--exported <dir>` | Census an already-exported tree and skip the export entirely. | | `--cache <dir>` | Where exports are kept. Defaults to a per-build folder under the system temp directory. | | `--refresh` | Re-export even when the cache already holds this build. | | `--samples` | Also census `projects/` and `addins/`, not only `packages/`. | diff --git a/eval/README.md b/eval/README.md index 2b9c3c74..fa550fa2 100644 --- a/eval/README.md +++ b/eval/README.md @@ -47,7 +47,7 @@ node eval/run_case.mjs --corpus <corpus> --site <snapshot> --protocol repo \ ``` `build_corpus.mjs` empties `--dest` before it writes, so it refuses a `--dest` that is or -contains the repository root, the current folder or `--src`, on stderr with exit 2. +contains the repository root, the current folder or `--repo`, on stderr with exit 2. `run_case.mjs` likewise refuses a `--timeout` (minutes) that is not a number greater than 0 and at most 35791, and a `--protocol` other than `repo` or `site`. diff --git a/eval/build_corpus.mjs b/eval/build_corpus.mjs index c22914e7..ef12938a 100644 --- a/eval/build_corpus.mjs +++ b/eval/build_corpus.mjs @@ -8,7 +8,7 @@ // because a capable evaluator that quietly reads the source measures how good // the source is, reports a clean pass, and tells you nothing about the docs. // -// node eval/build_corpus.mjs [--dest <path>] [--src <path>] [--quiet] +// node eval/build_corpus.mjs [--dest <path>] [--repo <path>] [--quiet] // // See eval/README.md for how a round uses it. @@ -103,11 +103,11 @@ function isInside(outer, inner) { // build() empties `dest` before it writes anything, so a `dest` that is or // contains a folder the tool runs from or reads would delete it. -function refuseDest(dest, src) { +function refuseDest(dest, repo) { const doomed = [ ["the repository root", REPO_ROOT], ["the current folder", process.cwd()], - [`--src ${src}`, src], + [`--repo ${repo}`, repo], ]; for (const [what, folder] of doomed) { if (isInside(dest, path.resolve(folder))) { @@ -120,7 +120,7 @@ function parseArgs(argv) { const { values } = withUsageError(() => { const cli = parseCli(argv, { options: { - src: { type: "string" }, + repo: { type: "string" }, dest: { type: "string" }, quiet: { type: "boolean", default: false }, help: { type: "boolean", short: "h" }, @@ -129,12 +129,12 @@ function parseArgs(argv) { stopAt: ["help"], }); if (cli.stopped !== "help" && "dest" in cli.values) { - refuseDest(path.resolve(cli.values.dest), "src" in cli.values ? path.resolve(cli.values.src) : REPO_ROOT); + refuseDest(path.resolve(cli.values.dest), "repo" in cli.values ? path.resolve(cli.values.repo) : REPO_ROOT); } return cli; }); return { - src: "src" in values ? path.resolve(values.src) : REPO_ROOT, + repo: "repo" in values ? path.resolve(values.repo) : REPO_ROOT, dest: "dest" in values ? path.resolve(values.dest) : null, quiet: values.quiet, help: values.help, @@ -162,24 +162,24 @@ function classify(rel) { return { kind: "stubbed", ext: ext || base }; } -function* walk(dir, srcRoot) { +function* walk(dir, repoRoot) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const abs = path.join(dir, entry.name); - const rel = path.relative(srcRoot, abs).split(path.sep).join("/"); + const rel = path.relative(repoRoot, abs).split(path.sep).join("/"); if (isExcluded(rel)) continue; - if (entry.isDirectory()) yield* walk(abs, srcRoot); + if (entry.isDirectory()) yield* walk(abs, repoRoot); else if (entry.isFile()) yield { abs, rel }; } } -function build({ src, dest, quiet }) { +function build({ repo, dest, quiet }) { fs.rmSync(dest, { recursive: true, force: true }); fs.mkdirSync(dest, { recursive: true }); const counts = { readable: new Map(), stubbed: new Map(), binary: 0, withheld: [] }; const bump = (m, k) => m.set(k, (m.get(k) ?? 0) + 1); - for (const { abs, rel } of walk(src, src)) { + for (const { abs, rel } of walk(repo, repo)) { const c = classify(rel); if (c.kind === "binary") { counts.binary++; continue; } if (c.kind === "withheld") { counts.withheld.push({ rel, why: c.why }); continue; } @@ -233,7 +233,7 @@ function report(dest, counts) { const opts = parseArgs(process.argv.slice(2)); if (opts.help || !opts.dest) { printHelpAndExit( - "Usage: node eval/build_corpus.mjs --dest <path> [--src <path>] [--quiet] [-h, --help]\n\n" + + "Usage: node eval/build_corpus.mjs --dest <path> [--repo <path>] [--quiet] [-h, --help]\n\n" + "Mirrors the repository with every non-prose file replaced by an unreadable\n" + "stub, so a documentation evaluation cannot silently read the implementation.\n" + "See eval/README.md.", diff --git a/eval/nav_hops.mjs b/eval/nav_hops.mjs index 4d6857f8..99f8085f 100644 --- a/eval/nav_hops.mjs +++ b/eval/nav_hops.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node // The navigation channel, measured from the links rather than from a report. // -// node eval/nav_hops.mjs [--from <page>] [--src <root>] <url-regex> [...] +// node eval/nav_hops.mjs [--from <page>] [--repo <root>] <url-regex> [...] // // Breadth-first from a start page --- docs/index.md, the published welcome page, // unless --from names another, such as README.md for a repo-protocol case --- @@ -40,7 +40,7 @@ import { REPO_ROOT } from "../lib/repo-paths.mjs"; const SITE_HOST = /^https?:\/\/docs\.twinbasic\.com/i; const USAGE = - "Usage: node eval/nav_hops.mjs [--from <page>] [--src <root>] [-h, --help] <url-regex> [...]\n\n" + + "Usage: node eval/nav_hops.mjs [--from <page>] [--repo <root>] [-h, --help] <url-regex> [...]\n\n" + "Shortest path by links from the start page (default docs/index.md) to the first page\n" + "whose permalink matches each regex. A regex that starts with a dash goes after --.\n" + "See eval/README.md."; @@ -50,7 +50,7 @@ function parseArgs(argv) { const cli = parseCli(argv, { options: { from: { type: "string", default: "docs/index.md" }, - src: { type: "string" }, + repo: { type: "string" }, help: { type: "boolean", short: "h" }, }, positionals: { min: 0, max: Infinity }, @@ -61,7 +61,7 @@ function parseArgs(argv) { }); return { from: values.from, - src: "src" in values ? path.resolve(values.src) : REPO_ROOT, + repo: "repo" in values ? path.resolve(values.repo) : REPO_ROOT, help: values.help, targets: positionals, patterns, @@ -73,12 +73,12 @@ const pageKey = (url) => decodeURI(url.replace(/[#?].*$/, "").replace(/\.(html|md)$/i, "").replace(/\/+$/, "")).toLowerCase() || "/"; /** Every page under docs/: permalink by file, and file by permalink or redirect alias. */ -async function loadPages(src) { - // The walker comes from this repository, never from --src: a corpus built by +async function loadPages(repo) { + // The walker comes from this repository, never from --repo: a corpus built by // eval/build_corpus.mjs holds every script only as an unreadable stub, so // importing it from there failed with "markdownFiles is not a function". const { markdownFiles } = await import(pathToFileURL(path.join(REPO_ROOT, "lib/markdown-files.mjs")).href); - const docs = path.join(src, "docs"); + const docs = path.join(repo, "docs"); const urlOf = new Map(); const byKey = new Map(); const aliases = []; @@ -138,12 +138,12 @@ async function main(argv) { "Git Bash converted them. Run with MSYS_NO_PATHCONV=1 set, or from another shell."); return 2; } - const start = path.resolve(o.src, o.from); + const start = path.resolve(o.repo, o.from); if (!fs.existsSync(start)) { console.error(`no start page: ${start}`); return 2; } - const pages = await loadPages(o.src); + const pages = await loadPages(o.repo); const prev = new Map([[start, null]]); const queue = [start]; @@ -157,7 +157,7 @@ async function main(argv) { } } - const show = (f) => path.relative(o.src, f).split(path.sep).join("/"); + const show = (f) => path.relative(o.repo, f).split(path.sep).join("/"); let unreachable = 0; for (const [i, t] of o.targets.entries()) { const re = o.patterns[i]; diff --git a/perf/ab-axe.mjs b/perf/ab-axe.mjs index 4f2f9597..43dcc6a6 100644 --- a/perf/ab-axe.mjs +++ b/perf/ab-axe.mjs @@ -98,7 +98,6 @@ let pagePath = '/tB/Core/Select-Case.html'; let theme = 'light'; let viewport = 'desktop'; let rootDir = DEFAULT_ROOT_DIR; -let jsonOut = null; let top = 0; let perRule = false; let noOnly = false; @@ -121,7 +120,6 @@ for (let i = 0; i < args.length; i++) { else if (a === '--theme') theme = args[++i]; else if (a === '--viewport') viewport = args[++i]; else if (a === '--root-dir') rootDir = resolve(args[++i]); - else if (a === '--json') jsonOut = args[++i]; else if (a === '--per-rule') perRule = true; else if (a === '--top') top = parseInt(args[++i], 10); else if (a === '--no-only') noOnly = true; @@ -139,7 +137,7 @@ for (let i = 0; i < args.length; i++) { console.error(' [--page PATH] [--theme T] [--viewport V] [--root-dir DIR]'); console.error(' [--per-rule] [--top N] [--no-only] [--iters N]'); console.error(' [--in-page-warmup N]'); - console.error(' [--warmup N] [--reuse-browser] [--json FILE]'); + console.error(' [--warmup N] [--reuse-browser]'); console.error(' [--light-trace] # ~40 MB -> ~3 MB per trace; no Blink columns'); console.error(' [--minified] [--no-affinity]'); console.error(''); diff --git a/perf/probe-axe-dom.mjs b/perf/probe-axe-dom.mjs index 78953ed1..0ae7dfed 100644 --- a/perf/probe-axe-dom.mjs +++ b/perf/probe-axe-dom.mjs @@ -13,7 +13,7 @@ // node probe-axe-dom.mjs --rules color-contrast,target-size // node probe-axe-dom.mjs --schemes no-html,no-selectors // node probe-axe-dom.mjs --page /tB/Core/Dim.html --theme dark -// node probe-axe-dom.mjs --json out.json +// node probe-axe-dom.mjs --out out.json // // Requires build.bat to have produced an up-to-date docs/_site-offline/. @@ -57,12 +57,12 @@ for (let i = 0; i < args.length; i++) { else if (a === '--schemes') schemesArg = args[++i]; else if (a === '--all-rules') allRules = true; else if (a === '--root-dir') rootDir = resolve(args[++i]); - else if (a === '--json') jsonOut = args[++i]; + else if (a === '--out') jsonOut = args[++i]; else if (a === '--top') top = parseInt(args[++i], 10); else if (a === '-h' || a === '--help') { console.error('usage: node probe-axe-dom.mjs [--page P] [--theme T] [--viewport V]'); console.error(' [--rules a,b | --all-rules] [--schemes a,b]'); - console.error(' [--top N] [--json FILE] [--root-dir DIR]'); + console.error(' [--top N] [--out FILE] [--root-dir DIR]'); console.error(''); console.error(` schemes: ${Object.keys(SCHEMES).join(', ')}`); process.exit(0); diff --git a/perf/probe-axe-scaling.mjs b/perf/probe-axe-scaling.mjs index 1571e8a2..c24d68a6 100644 --- a/perf/probe-axe-scaling.mjs +++ b/perf/probe-axe-scaling.mjs @@ -28,7 +28,7 @@ // node probe-axe-scaling.mjs // node probe-axe-scaling.mjs --targets 2000,4000,8000,16000 --iters 3 // node probe-axe-scaling.mjs --rules color-contrast # curve for one rule -// node probe-axe-scaling.mjs --json scaling.json +// node probe-axe-scaling.mjs --out scaling.json // node probe-axe-scaling.mjs --targets 2380,9475 --cpu-profile prof/ # Phase 2 // // Requires build.bat to have produced an up-to-date docs/_site-offline/. @@ -79,7 +79,7 @@ for (let i = 0; i < args.length; i++) { else if (a === '--rules') rulesArg = args[++i]; else if (a === '--scheme') schemeArg = args[++i]; else if (a === '--root-dir') rootDir = resolve(args[++i]); - else if (a === '--json') jsonOut = args[++i]; + else if (a === '--out') jsonOut = args[++i]; else if (a === '--keep') keep = true; else if (a === '--cpu-profile') cpuProfileDir = resolve(args[++i]); else if (a === '--cpu-sampling') cpuSampling = parseInt(args[++i], 10); @@ -88,7 +88,7 @@ for (let i = 0; i < args.length; i++) { console.error('usage: node probe-axe-scaling.mjs [--page P] [--targets N,N,N] [--iters N]'); console.error(' [--rules a,b | --scheme NAME] [--theme T]'); console.error(' [--viewport V]'); - console.error(' [--json FILE] [--keep] [--no-affinity]'); + console.error(' [--out FILE] [--keep] [--no-affinity]'); console.error(' [--cpu-profile DIR] [--cpu-sampling US]'); console.error(''); console.error(' --cpu-profile writes one .cpuprofile per size. Comparing the bottom-up'); diff --git a/scripts/build_package_api.mjs b/scripts/build_package_api.mjs index 0962a363..3b82e269 100644 --- a/scripts/build_package_api.mjs +++ b/scripts/build_package_api.mjs @@ -5,13 +5,13 @@ // node scripts/build_package_api.mjs # regenerate builder/package-api.json // node scripts/build_package_api.mjs --check # fail if it is stale // -// --ide <path> the install root, or its twinBASIC.exe (default: $TB_IDE, -// else the newest Desktop\twinBASIC_IDE_BETA_<n>) -// --src <dir> read an existing export of the packages instead -// --cache <dir> where exports are kept (default %TEMP%\tb-census\beta-<n>, -// shared with scripts/census_attributes.mjs) -// --refresh export again even if the cache has this build -// --out <file> write somewhere other than builder/package-api.json +// --ide <path> the install root, or its twinBASIC.exe (default: $TB_IDE, +// else the newest Desktop\twinBASIC_IDE_BETA_<n>) +// --exported <dir> read an existing export of the packages instead +// --cache <dir> where exports are kept (default %TEMP%\tb-census\beta-<n>, +// shared with scripts/census_attributes.mjs) +// --refresh export again even if the cache has this build +// --out <file> write somewhere other than builder/package-api.json // // Exit codes: 0 written (or up to date, with --check), 1 stale (--check), 2 the // tool failed. @@ -56,20 +56,20 @@ const USAGE = `usage: node scripts/build_package_api.mjs [options] Records the public API of the packages a twinBASIC install ships, as the package half of the documentation's symbol index, in builder/package-api.json. - --check fail if the file is stale, instead of writing it - --ide <path> the install root, or its twinBASIC.exe (default: $TB_IDE, - else the newest Desktop\\twinBASIC_IDE_BETA_<n>) - --src <dir> read an existing export of the packages instead - --cache <dir> where exports are kept (default %TEMP%\\tb-census\\beta-<n>) - --refresh export again even if the cache has this build - --out <file> write somewhere other than builder/package-api.json - -h, --help print this text and exit`; + --check fail if the file is stale, instead of writing it + --ide <path> the install root, or its twinBASIC.exe (default: $TB_IDE, + else the newest Desktop\\twinBASIC_IDE_BETA_<n>) + --exported <dir> read an existing export of the packages instead + --cache <dir> where exports are kept (default %TEMP%\\tb-census\\beta-<n>) + --refresh export again even if the cache has this build + --out <file> write somewhere other than builder/package-api.json + -h, --help print this text and exit`; const { values } = withUsageError(() => parseCli(process.argv.slice(2), { options: { ide: { type: "string" }, - src: { type: "string" }, + exported: { type: "string" }, cache: { type: "string" }, out: { type: "string" }, refresh: { type: "boolean", default: false }, @@ -82,11 +82,11 @@ if (values.help) printHelpAndExit(USAGE); const die = (code, msg) => { console.error(msg); process.exit(code); }; function sources() { - // --src takes a folder of exports, or a cache holding `packages\` and more: + // --exported takes a folder of exports, or a cache holding `packages\` and more: // a folder with a Settings file is an export, and one without is looked into. - const src = values.src; - if (src) { - if (!existsSync(src) || !statSync(src).isDirectory()) die(2, `not a directory: ${src}`); + const exported = values.exported; + if (exported) { + if (!existsSync(exported) || !statSync(exported).isDirectory()) die(2, `not a directory: ${exported}`); const packages = []; const look = (dir, depth) => { for (const e of readdirSync(dir, { withFileTypes: true })) { @@ -96,8 +96,8 @@ function sources() { else if (depth > 0) look(d, depth - 1); } }; - look(src, 1); - if (!packages.length) die(2, `no exported package under ${src}`); + look(exported, 1); + if (!packages.length) die(2, `no exported package under ${exported}`); return { build: null, packages }; } // --ide and TB_IDE may name the install root or the executable in it. diff --git a/scripts/census_attributes.mjs b/scripts/census_attributes.mjs index d2215c18..7b43467a 100644 --- a/scripts/census_attributes.mjs +++ b/scripts/census_attributes.mjs @@ -76,7 +76,7 @@ const { values } = withUsageError(() => parseCli(process.argv.slice(2), { options: { ide: { type: "string" }, - src: { type: "string" }, + exported: { type: "string" }, cache: { type: "string" }, attr: { type: "string" }, out: { type: "string" }, @@ -99,7 +99,7 @@ declaration keyword and by enclosing construct. --ide <path> twinBASIC install root (default: $TB_IDE, else the newest %USERPROFILE%/Desktop/twinBASIC_IDE_BETA_*) - --src <dir> census an already-exported tree and do not export + --exported <dir> census an already-exported tree and do not export --cache <dir> where exports are kept (default: %TEMP%/tb-census/beta-<n>) --refresh re-export even if the cache already has this build --samples also census projects/ and addins/, not just packages/ @@ -500,21 +500,21 @@ function collectTwinFiles(dir, out = []) { function main() { let projects, install = null, build = "n/a"; - const srcDir = values.src; + const exportedDir = values.exported; - if (srcDir) { - if (!existsSync(srcDir) || !statSync(srcDir).isDirectory()) die(2, `not a directory: ${srcDir}`); - install = srcDir; - projects = readdirSync(srcDir, { withFileTypes: true }) + if (exportedDir) { + if (!existsSync(exportedDir) || !statSync(exportedDir).isDirectory()) die(2, `not a directory: ${exportedDir}`); + install = exportedDir; + projects = readdirSync(exportedDir, { withFileTypes: true }) .filter((e) => e.isDirectory()) .flatMap((e) => { - const g = path.join(srcDir, e.name); + const g = path.join(exportedDir, e.name); const inner = readdirSync(g, { withFileTypes: true }).filter((x) => x.isDirectory()); return inner.length ? inner.map((x) => ({ name: x.name, dir: path.join(g, x.name) })) : [{ name: e.name, dir: g }]; }); - if (!projects.length) projects = [{ name: path.basename(srcDir), dir: srcDir }]; + if (!projects.length) projects = [{ name: path.basename(exportedDir), dir: exportedDir }]; } else { install = findInstall(); build = buildNumberOf(install); diff --git a/scripts/check_a11y_fingerprint.mjs b/scripts/check_a11y_fingerprint.mjs index b144fbe9..13981c4b 100644 --- a/scripts/check_a11y_fingerprint.mjs +++ b/scripts/check_a11y_fingerprint.mjs @@ -45,7 +45,7 @@ // --patches NAME,NAME apply source patches to the CANDIDATE bundle // --unminified inject axe.js rather than axe.min.js (the source // patches need the unminified bundle) -// --json FILE write both fingerprint lists + the diff +// --out FILE write both fingerprint lists + the diff // --list print the scheme registry and exit // // Git Bash on Windows rewrites a leading-slash argument into a Windows path, @@ -87,7 +87,7 @@ const cli = withUsageError( theme: { type: "string", default: "both" }, viewport: { type: "string", default: "both" }, pages: { type: "string" }, - json: { type: "string" }, + out: { type: "string" }, unminified: { type: "boolean", default: false }, patches: { type: "string", default: "" }, list: { type: "boolean" }, @@ -117,7 +117,7 @@ if (cli.stopped === "help") { printHelpAndExit( "usage: node scripts/check_a11y_fingerprint.mjs [--baseline SCHEME] " + "[--candidate SCHEME] [--root-dir DIR] [--theme T] [--viewport V] " + - "[--pages P,P] [--json FILE] [--unminified] [--patches NAME,NAME] [--list] [-h, --help]" + "[--pages P,P] [--out FILE] [--unminified] [--patches NAME,NAME] [--list] [-h, --help]" ); } @@ -127,7 +127,7 @@ let rootDir = cli.values.rootDir; let themeArg = cli.values.theme; let viewportArg = cli.values.viewport; let pagesArg = cli.values.pages !== undefined ? cli.values.pages.split(",") : null; -let jsonOut = cli.values.json ?? null; +let outFile = cli.values.out ?? null; let unminified = cli.values.unminified; let patchesArg = cli.values.patches; @@ -284,9 +284,9 @@ async function main() { } } - if (jsonOut) { + if (outFile) { writeFileSync( - resolve(jsonOut), + resolve(outFile), JSON.stringify( { axeCore: axeVersion(), @@ -299,7 +299,7 @@ async function main() { 2 ) ); - console.log(`\nwrote ${resolve(jsonOut)}`); + console.log(`\nwrote ${resolve(outFile)}`); } if (mismatches === 0) { diff --git a/scripts/check_cli.mjs b/scripts/check_cli.mjs index 28bf8cb9..17d3a3e4 100644 --- a/scripts/check_cli.mjs +++ b/scripts/check_cli.mjs @@ -423,9 +423,11 @@ const CASES = [ { tool: "scripts/check_examples.mjs", args: ["--jobs"], exit: 2, stderr: "check_examples: --jobs needs a value\n" }, { tool: "scripts/check_examples.mjs", args: ["--port", "--json"], exit: 2, stderr: "check_examples: --port needs a value\n" }, { tool: "scripts/census_attributes.mjs", args: ["--out"], exit: 2, stderr: "--out needs a value\n" }, - { tool: "scripts/census_attributes.mjs", args: ["--src", "--json"], exit: 2, stderr: "--src needs a value\n" }, + { tool: "scripts/census_attributes.mjs", args: ["--exported", "--json"], exit: 2, stderr: "--exported needs a value\n" }, + { tool: "scripts/census_attributes.mjs", args: ["--src", "x"], exit: 2, stderr: "unknown option: --src\n" }, { tool: "scripts/build_package_api.mjs", args: ["--out"], exit: 2, stderr: "--out needs a value\n" }, - { tool: "scripts/build_package_api.mjs", args: ["--src", "--check"], exit: 2, stderr: "--src needs a value\n" }, + { tool: "scripts/build_package_api.mjs", args: ["--exported", "--check"], exit: 2, stderr: "--exported needs a value\n" }, + { tool: "scripts/build_package_api.mjs", args: ["--src", "x"], exit: 2, stderr: "unknown option: --src\n" }, // Recorded in C48, for the a11y and diagram tools. A value flag given // nothing, at the end or as "", or followed by another flag, is refused. @@ -443,6 +445,8 @@ const CASES = [ { tool: "scripts/check_a11y_fingerprint.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/check_a11y_fingerprint\.mjs / }, { tool: "scripts/check_a11y_fingerprint.mjs", args: ["--bogus"], exit: 2, stderr: "unknown option: --bogus\n" }, { tool: "scripts/check_a11y_fingerprint.mjs", args: ["--pages"], exit: 2, stderr: "--pages needs a value\n" }, + { tool: "scripts/check_a11y_fingerprint.mjs", args: ["--out"], exit: 2, stderr: "--out needs a value\n" }, + { tool: "scripts/check_a11y_fingerprint.mjs", args: ["--json", "x"], exit: 2, stderr: "unknown option: --json\n" }, { tool: "scripts/check_a11y_fingerprint.mjs", args: ["--theme", "drak"], exit: 2, stderr: 'unknown --theme "drak"; expected one of light, dark or both\n' }, { tool: "scripts/check_axe_patch_equiv.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/check_axe_patch_equiv\.mjs / }, { tool: "scripts/check_axe_patch_equiv.mjs", args: ["--bogus"], exit: 2, stderr: "unknown option: --bogus\n" }, @@ -581,16 +585,18 @@ const CASES = [ { tool: "eval/build_corpus.mjs", args: ["--help", "--bogus"], exit: 0, stdout: /^Usage: node eval\/build_corpus\.mjs --dest <path> / }, { tool: "eval/build_corpus.mjs", args: ["--quiet=1"], exit: 2, stderr: "--quiet takes no value\n" }, { tool: "eval/build_corpus.mjs", args: ["-hq"], exit: 0, stdout: /^Usage: node eval\/build_corpus\.mjs --dest <path> / }, - { tool: "eval/build_corpus.mjs", args: ["--src"], exit: 2, stderr: "--src needs a value\n" }, + { tool: "eval/build_corpus.mjs", args: ["--repo"], exit: 2, stderr: "--repo needs a value\n" }, + { tool: "eval/build_corpus.mjs", args: ["--src", "x"], exit: 2, stderr: "unknown option: --src\n" }, { tool: "eval/nav_hops.mjs", args: ["--help"], exit: 0, stdout: /^Usage: node eval\/nav_hops\.mjs \[--from <page>\] / }, { tool: "eval/nav_hops.mjs", args: [], exit: 2, stderr: /^Usage: node eval\/nav_hops\.mjs \[--from <page>\] / }, { tool: "eval/nav_hops.mjs", args: ["--from", "nope.md", "x"], exit: 2, stderr: /^no start page: .*[\\/]nope\.md\n$/ }, - { tool: "eval/nav_hops.mjs", args: ["--src", "nowhere", "--bogus"], exit: 2, stderr: "unknown option: --bogus\n" }, - { tool: "eval/nav_hops.mjs", args: ["--src", "nowhere", "--", "--bogus"], exit: 2, stderr: /^no start page: .*[\\/]nowhere[\\/]docs[\\/]index\.md\n$/ }, - { tool: "eval/nav_hops.mjs", args: ["--help=1", "--src", "nowhere"], exit: 2, stderr: "--help takes no value\n" }, - { tool: "eval/nav_hops.mjs", args: ["--from", "--src", "x"], exit: 2, stderr: "--from needs a value\n" }, + { tool: "eval/nav_hops.mjs", args: ["--repo", "nowhere", "--bogus"], exit: 2, stderr: "unknown option: --bogus\n" }, + { tool: "eval/nav_hops.mjs", args: ["--repo", "nowhere", "--", "--bogus"], exit: 2, stderr: /^no start page: .*[\\/]nowhere[\\/]docs[\\/]index\.md\n$/ }, + { tool: "eval/nav_hops.mjs", args: ["--help=1", "--repo", "nowhere"], exit: 2, stderr: "--help takes no value\n" }, + { tool: "eval/nav_hops.mjs", args: ["--from", "--repo", "x"], exit: 2, stderr: "--from needs a value\n" }, + { tool: "eval/nav_hops.mjs", args: ["--src", "x", "y"], exit: 2, stderr: "unknown option: --src\n" }, { tool: "eval/nav_hops.mjs", args: ["--", "C:/x"], exit: 2, stderr: "these patterns arrived as Windows paths: C:/x\nGit Bash converted them. Run with MSYS_NO_PATHCONV=1 set, or from another shell.\n" }, - { tool: "eval/nav_hops.mjs", args: ["--src"], exit: 2, stderr: "--src needs a value\n" }, + { tool: "eval/nav_hops.mjs", args: ["--repo"], exit: 2, stderr: "--repo needs a value\n" }, { tool: "eval/nav_hops.mjs", args: ["x", "--from"], exit: 2, stderr: "--from needs a value\n" }, { tool: "eval/run_case.mjs", args: ["--help"], exit: 0, stdout: /^Usage: node eval\/run_case\.mjs --corpus <dir> / }, { tool: "eval/run_case.mjs", args: [], exit: 2, stderr: /^Usage: node eval\/run_case\.mjs --corpus <dir> / }, @@ -942,7 +948,7 @@ bad("eval/nav_hops.mjs", ["("], REGEX_REASON("<url-regex>", "(")); bad("eval/nav_hops.mjs", ["Reference", "[a-"], REGEX_REASON("<url-regex>", "[a-")); // build_corpus empties its --dest before it writes, so it refuses one that is or -// contains the repository root, the folder it runs from or --src, checked in +// contains the repository root, the folder it runs from or --repo, checked in // that order. Every one of these stops at the command line. Each --dest is a // folder that must never be emptied. { @@ -953,8 +959,8 @@ bad("eval/nav_hops.mjs", ["Reference", "[a-"], REGEX_REASON("<url-regex>", "[a-" bad(tool, ["--dest", path.dirname(REPO_ROOT)], refuse(literal(path.dirname(REPO_ROOT)), "the repository root")); bad(tool, ["--dest", "."], refuse(".+", "the current folder")); bad(tool, ["--dest", ".."], refuse(".+", "the current folder")); - bad(tool, ["--src", path.join(docs, "Reference"), "--dest", docs], refuse(literal(docs), literal(`--src ${path.join(docs, "Reference")}`))); - bad(tool, ["--src", docs, "--dest", docs], refuse(literal(docs), literal(`--src ${docs}`))); + bad(tool, ["--repo", path.join(docs, "Reference"), "--dest", docs], refuse(literal(docs), literal(`--repo ${path.join(docs, "Reference")}`))); + bad(tool, ["--repo", docs, "--dest", docs], refuse(literal(docs), literal(`--repo ${docs}`))); } // wisdom's command in these is never a real one, so that none can start an From a9202519090042606a216aa6f5a3cb9aea005f97 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober <kuba@mareimbrium.org> Date: Wed, 30 Sep 2026 14:02:24 +0200 Subject: [PATCH 17/21] builder, scripts, book, eval, wisdom: one exit-code table per tool --- WIP.Build.md | 5 +- WIP.Harness.md | 11 +- WIP.md | 4 +- addin-test.bat | 3 +- book/render-book.mjs | 19 ++- builder/PLAN-TOOLING-REVIEW.md | 72 +++++++++++ builder/command-line.mjs | 9 +- builder/tbdocs.mjs | 15 ++- docs/Documentation/Extending.md | 6 +- docs/Documentation/PDF-Generation.md | 21 +-- docs/Documentation/Tools.md | 171 +++++++++++++++++-------- docs/Documentation/Wisdom.md | 6 +- eval/README.md | 18 ++- eval/build_corpus.mjs | 12 +- eval/nav_hops.mjs | 7 +- eval/run_case.mjs | 16 ++- eval/search_quality.mjs | 14 +- eval/site_search.mjs | 11 +- eval/transcript.mjs | 8 +- lib/cli.mjs | 16 ++- scripts/addin_test.mjs | 19 ++- scripts/build_dot_metrics.mjs | 10 +- scripts/build_package_api.mjs | 14 +- scripts/census_attributes.mjs | 12 +- scripts/check_a11y.mjs | 7 +- scripts/check_a11y_fingerprint.mjs | 9 +- scripts/check_axe_patch_equiv.mjs | 11 +- scripts/check_book_coverage.mjs | 11 +- scripts/check_ci_workflows.mjs | 12 +- scripts/check_cli.mjs | 34 +++-- scripts/check_code_regions.mjs | 11 +- scripts/check_dot_fit.mjs | 10 +- scripts/check_examples.mjs | 14 +- scripts/check_gate_lists.mjs | 11 +- scripts/check_impexp_parity.mjs | 16 ++- scripts/check_links.mjs | 23 ++-- scripts/check_links_diff.mjs | 14 +- scripts/check_lint.mjs | 15 ++- scripts/check_page_baseline.mjs | 11 +- scripts/check_pdf_shims_equiv.mjs | 18 ++- scripts/check_publish_policy.mjs | 10 +- scripts/check_regex_safety.mjs | 16 ++- scripts/check_symbol_index.mjs | 11 +- scripts/check_tb_registry.mjs | 16 ++- scripts/check_tree_fresh.mjs | 15 ++- scripts/check_twin_parsers.mjs | 14 +- scripts/compare_trees.mjs | 9 +- scripts/convert_em_dash_separators.mjs | 13 +- scripts/crawl_check.mjs | 7 +- scripts/gen_attribute_probes.mjs | 10 +- scripts/lib/gate-probes.mjs | 15 +-- scripts/pick_a11y_sample.mjs | 10 +- scripts/survey_tooling.mjs | 10 +- scripts/sweep_a11y.mjs | 10 +- scripts/tbbuild.mjs | 20 ++- scripts/tbrun.mjs | 29 ++++- wisdom/discord/api.mjs | 2 +- wisdom/extract/prep.mjs | 6 +- wisdom/process/thread.mjs | 4 +- wisdom/wisdom.mjs | 23 +++- 60 files changed, 716 insertions(+), 260 deletions(-) diff --git a/WIP.Build.md b/WIP.Build.md index 6e34b590..86aad956 100644 --- a/WIP.Build.md +++ b/WIP.Build.md @@ -154,7 +154,7 @@ Older notes under `builder/PLAN-*.md` still place `check_publish_policy.mjs` and **The link and integrity check runs inside the build.** `build.bat` passes `--check-audit-index`, which implies `--check`, and the check walks the HTML on the worker lanes that produced it -- both trees' final strings are already decoded and in memory at `flush()`, so the ~270 MB the two trees weigh is never written out only to be read back. It also audits the tree index the build derives from its own records against what landed on disk -- the one direction the two-checker comparison structurally cannot see, since a spurious entry makes the oracle answer "exists" for a path that 404s in production. It catches broken intra-site links, missing pages, malformed `redirect_from` entries (the most common breakage when adding new pages or moving content between sections), duplicate ids, remote `<img src>`, badly nested tags, sitemap and search-index gaps, canonical mismatches, and (via a forbidden-prefix rule on the offline tree) any extracted link that still points at the live docs site after the offlinify rewrite. A clean `build.bat && check.bat` is the bar for "ready to commit". -A failing check never aborts the build: a broken link still produces a site you want on disk to inspect. It sets the exit code instead, using the same scheme `check_links.mjs` has always used -- 1 for link failures, 2 for integrity failures, 3 for both -- so CI can tell them apart. +A failing check never aborts the build: a broken link still produces a site you want on disk to inspect. It sets the exit code to 1 instead, whether the check found link failures, integrity failures or both (the summary lines say which), and keeps 2 for a build that could not do its job: the same scheme `check_links.mjs` uses. A 2 means a refused command line, a stall or a crash. The remote-asset rule fails the run on any `<img src>` resolving off-box (`http://`, `https://`, or protocol-relative `//host`). In the build it is unconditional -- `checkRemoteAssets: true` on both trees in `builder/check.mjs`'s `TREES` -- and is *not* reachable by a flag: `tbdocs` rejects `--check-remote-assets` as an unknown argument. That name belongs to the standalone `scripts/check_links.mjs`, where it is opt-in. The PDF pass over `book.html` is informational, so enforcement comes from the `_site/` pass -- every page in the book is also in `_site/`, making it a superset. The check is deliberately scoped to `<img>` only; `<iframe>` is untouched. @@ -388,7 +388,8 @@ node scripts/check_tree_fresh.mjs --tree docs/_site-pdf --marker book.html **`--marker` is what makes that work on this tree.** The script identifies a tree by its `index.html`, which every output tree has *except* `_site-pdf/` --- that one holds a single `book.html`. Exit codes are the script's: **2** when the tree is -absent, **1** when it is older than `docs/` or `builder/`. +absent, **1** when it is older than `docs/` or `builder/`. The renderer that runs after it +has no 1, so `book.bat`'s 1 means a stale tree (or a failed `npm install`) and nothing about the render. > **One batch detail that is easy to get wrong:** `%ERRORLEVEL%` inside a parenthesised `if errorlevel 1 (...)` > block expands when the block is **parsed**, not when it runs, so the value diff --git a/WIP.Harness.md b/WIP.Harness.md index 37dd7dc9..29568a48 100644 --- a/WIP.Harness.md +++ b/WIP.Harness.md @@ -445,6 +445,11 @@ Four smaller things it knows, each of which cost a run: starts, so the probe's first `Debug.Cls` erases the build log from `[BUILD] Starting...` on. `event_clearDebugConsole` writes an empty line after its clear, so the record of a second `Debug.Cls` begins with one. +- **`tbrun` exits 4 when the compiler crashed**, as `tbbuild` does. + A crash and a failed build after a clean compile are different faults with different + remedies (rerun the second, isolate the probe for the first), and `check_examples` already + isolated a sample on `tbbuild`'s 4. `tbrun` exits 3 for no output at all, and 2 for a + compile that never settled, which `tbbuild` reports as 3. A reader of the console that is not `tbrun` should **compare the whole console before and after, not read on from an index**: new text can be appended to an entry that is still open. @@ -1068,7 +1073,7 @@ lane's folder before the first lane starts, so the lanes inherit `TB_REGISTRY_OW leave the registry alone, and `finishTidy` once the last has ended. Then it checks rather than trusts: no project-state or recent-list entry may name a lane's folder, a second sweep of the remembered build targets must find none, and the add-ins' settings must be as -recorded. Any failure is exit code 2. +recorded. Any failure is exit code 3, which wins over a lane's failure (1): the registry is what to repair. **An add-in's own settings are the runner's too.** `SaveSetting` writes under `HKCU\Software\VB and VBA Program Settings\<app>`, the same key as any installed copy of the @@ -1208,8 +1213,8 @@ probe lanes: recent list, two of them and then 21, identical both times. - With Global Search settings planted beforehand (Match case on, and one extra value), the lane began with every option off, and the key came back exactly, the extra value - included. With `settings` taken out of `lanes.mjs`, the run failed with exit code 2 and - named `GlobalSearchAddIn`. + included. With `settings` taken out of `lanes.mjs`, the run failed and named + `GlobalSearchAddIn`. - An `--only` that matches nothing was refused with exit code 2, and so, until P6 was answered, was a DLL in a stand-in `%APPDATA%`; now the lanes run beside it, and the P6 lane's IDE loaded its own probe alone. `--timeout 8` ended both lanes mid-build, and left diff --git a/WIP.md b/WIP.md index 26b67d72..658e2339 100644 --- a/WIP.md +++ b/WIP.md @@ -139,7 +139,7 @@ node scripts/tbrun.mjs <exported-source-dir> # what does it print - **It runs the IDE on a private Windows desktop**, so it cannot seize focus mid-sentence. Set `TBBUILD_SHOW=1` while working interactively and leave it unset for unattended runs --- a wedged IDE nobody can see is the failure that costs an afternoon. - **One project per IDE**, 8--11 seconds each and flat in project size. Reusing a live IDE for a second project wedges it, so the cold start is the unit of work, not overhead to optimise away. Concurrency is how to go faster: distinct `--port` values give distinct DevTools ports, user-data folders and desktops. - **Keep a probe that might crash the compiler in a project of its own.** twinBASIC runs the compiler in the same process as user code, so one bad probe can take the run down and cost the other thirty their answer. -- **`tbrun` takes an exported tree, not a `.twinproj`**, because it has to pin `project.buildPath` in its own staged copy --- a project still on the default template opens a native Save dialog that is invisible on the private desktop, and the build simply never happens while every health check says the IDE is fine. The probe is a module with a `[RunAfterBuild]` Sub, and must start with `Debug.Cls`. +- **`tbrun` takes an exported tree, not a `.twinproj`**, because it has to pin `project.buildPath` in its own staged copy --- a project still on the default template opens a native Save dialog that is invisible on the private desktop, and the build simply never happens while every health check says the IDE is fine. The probe is a module with a `[RunAfterBuild]` Sub, and must start with `Debug.Cls`. Its exit codes: 0 the probe ran and its output was captured, 1 the project has compile errors, 2 the harness failed or the build did after a clean compile, 3 no output, 4 the compiler crashed, as `tbbuild` reports it. - **A census is evidence, not applicability.** The corpus not using an attribute somewhere does not mean the compiler refuses it there, and the reverse also holds. Only a probe settles that. - **End an IDE by its pid, never by image name.** `taskkill /IM twinBASIC.exe` ends every other run's IDE, another session's included, and the user's own. `tbbuild --keep` prints the pid for this reason. @@ -455,7 +455,7 @@ Why the report separates the wedged task from the merely blocked ones, and why - `book.bat` — renders the PDF from `docs\_site-pdf\book.html` via `node book\render-book.mjs` into `docs\_pdf\twinBASIC Book.pdf`. Run `build.bat` first to populate `_site-pdf/`; `book.bat` refuses a tree older than its sources rather than rendering the previous book (see [The book refuses a stale source tree](WIP.Build.md#the-book-refuses-a-stale-source-tree)). - `examples.bat` — compiles the documentation's own twinBASIC code samples, every `tb` fence marked `check_build`, and reports the ones the compiler refuses against the line in the page they came from. Needs a twinBASIC install and Windows, so it is outside every gate and outside CI; ~120 s over the 1,129 samples marked as of 2026-09-25. Two modes need no compiler at all: `--census` classifies every fence and says how many classifiable ones are still unmarked, and `--report <survey.json>` groups a saved `--propose --json` survey by diagnostic, section and unresolved name. `--propose` itself does compile. See [Compiling the reference's own code samples](#compiling-the-references-own-code-samples) and [WIP.ExamplesBuild.md](WIP.ExamplesBuild.md). -- `addin-test.bat` — tests IDE add-ins by operating an IDE: every lane in `test/addin/lanes.mjs` builds the add-ins it tests into a private copy of the install, opens a project and checks what the add-in does. Outside every gate and outside CI for the same reasons as `examples.bat`; ~140 s for the ten lanes today: Samples 10 and 15, and the eight probe lanes behind Stage 2's answers in [WIP.HelpAddin.md](WIP.HelpAddin.md). Exit 0 every lane passed and the registry is as it was found, 1 a lane failed, 2 the harness failed or could not put the registry back. See [Driving the twinBASIC compiler](#driving-the-twinbasic-compiler) for its rules. +- `addin-test.bat` — tests IDE add-ins by operating an IDE: every lane in `test/addin/lanes.mjs` builds the add-ins it tests into a private copy of the install, opens a project and checks what the add-in does. Outside every gate and outside CI for the same reasons as `examples.bat`; ~140 s for the ten lanes today: Samples 10 and 15, and the eight probe lanes behind Stage 2's answers in [WIP.HelpAddin.md](WIP.HelpAddin.md). Exit 0 every lane passed and the registry is as it was found, 1 a lane failed, 2 the harness failed, 3 the registry or a work folder was not put back. See [Driving the twinBASIC compiler](#driving-the-twinbasic-compiler) for its rules. Three generators sit outside that loop and produce committed artifacts rather than build output — none runs during a build, and none is needed for one. `python scripts/build_fonts.py` rebuilds the subset webfaces under `docs/assets/fonts/` and needs a network connection; `node scripts/build_dot_metrics.mjs` regenerates `builder/inter-metrics.json` from those webfaces and needs only a browser. See [Typography](#typography). `node scripts/build_package_api.mjs` regenerates `builder/package-api.json`, the packages' declared API that the build's symbol index (`tB/symbols.json`, for the IDE help add-in) is annotated from; it needs a twinBASIC install, so **run it when the reference is re-indexed against a newer build** and commit it with the pages. See [WIP.HelpAddin.md, Stage 3](WIP.HelpAddin.md#stage-3-the-symbol-index-generated-by-the-docs-build). diff --git a/addin-test.bat b/addin-test.bat index 6afcf4db..ba72520f 100644 --- a/addin-test.bat +++ b/addin-test.bat @@ -21,7 +21,8 @@ rem addin-test.bat --port 9600 lanes on ports 9600, 9601, ... rem addin-test.bat --jobs 1 one lane at a time rem rem Exit: 0 every lane passed and the registry is as it was found, 1 a lane -rem failed, 2 the harness failed or could not put the registry back. +rem failed, 2 the harness could not run, 3 the registry or a work folder was +rem not put back. node scripts/addin_test.mjs %* @rem popd resets ERRORLEVEL, so capture it first -- otherwise a failing lane @rem would report success to whatever called addin-test.bat. diff --git a/book/render-book.mjs b/book/render-book.mjs index 8dff785b..cf646ee4 100644 --- a/book/render-book.mjs +++ b/book/render-book.mjs @@ -32,7 +32,7 @@ import { dirname, resolve } from 'node:path'; import { writeFileSync, existsSync } from 'node:fs'; import puppeteer from 'puppeteer'; import { PDFDocument } from 'pdf-lib'; -import { numberOption, parseCli, printHelpAndExit, withUsageError } from '../lib/cli.mjs'; +import { exitOnCrash, numberOption, parseCli, printHelpAndExit, withUsageError } from '../lib/cli.mjs'; // Side-effecting imports. Mutate pdf-lib's live module exports // before any pdf-lib operation -- order doesn't matter. See // perf/notes/08-pdf-lib.md. @@ -194,6 +194,8 @@ import { parallelSave } from './lib/parallel-deflate.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); +exitOnCrash(); + // --- arg parsing -------------------------------------------------------- // A missing input or output prints the first line alone. @@ -207,7 +209,12 @@ Renders an HTML book to a PDF with paged.js and headless Chromium. --outline-tags <tags> headings to put in the PDF outline (default h1,h2,h3,h4) -t, --timeout <ms> per-operation timeout in milliseconds; 0 disables (default 0) --additional-script <path> a script to inject after paged.js; repeatable - -h, --help print this text and exit`; + -h, --help print this text and exit + +Exit codes: + 0 the PDF was written + 2 a refused command line, an input or script that does not exist, a render that + failed, or a crash`; const { values, positionals, timeoutMs } = withUsageError(() => { const cli = parseCli(process.argv.slice(2), { @@ -241,7 +248,7 @@ const outlineTags = outlineTagsArg.split(',').map(s => s.trim()).filter(Boolean) if (!existsSync(inputPath)) { console.error(`input not found: ${inputPath}`); - process.exit(1); + process.exit(2); } const pagedScriptPath = resolve(__dirname, 'lib', 'paged.browser.js'); @@ -249,14 +256,14 @@ const progressScriptPath = resolve(__dirname, 'lib', 'progress-handler.js'); for (const p of [pagedScriptPath, progressScriptPath]) { if (!existsSync(p)) { console.error(`required file not found: ${p}`); - process.exit(1); + process.exit(2); } } for (const s of additionalScripts) { const p = resolve(process.cwd(), s); if (!existsSync(p)) { console.error(`additional script not found: ${p}`); - process.exit(1); + process.exit(2); } } @@ -452,7 +459,7 @@ try { console.log(`total: ${fmtMs(Date.now() - t0)}`); } catch (err) { console.error('[render-book] error:', err); - exitCode = 1; + exitCode = 2; } finally { await browser.close(); } diff --git a/builder/PLAN-TOOLING-REVIEW.md b/builder/PLAN-TOOLING-REVIEW.md index ca365b51..8802c00e 100644 --- a/builder/PLAN-TOOLING-REVIEW.md +++ b/builder/PLAN-TOOLING-REVIEW.md @@ -1169,6 +1169,61 @@ user must act on. **Verify.** Each table checked against the code; `check_cli.mjs` for the codes it can reach; `addin-test.bat` green (a harness run). +**Landed** on the owner's choices of 2026-09-30. Each tool's usage text ends with one +`Exit codes:` block, a line per code, and its Tools.md section ends with the same codes on one +`Exit codes:` line. The six eval tools and `wisdom` have no Tools.md section, so theirs are in +`eval/README.md` and Wisdom.md. `impexp.mjs` keeps the table it shares with `impexp.py`, +which `check_impexp_parity` holds the two editions to, and Tools.md points to it. The +convention is 0 clean, 1 a finding, 2 the tool could not do its job, with a tool's own codes +above 2. Three codes that meant two things are split: `addin_test` exits **3** when the +registry or a work folder was not put back, which wins over a failed lane; `wisdom` exits +**3** when it reaches its request cap (re-run to continue), so 2 is left for a refused command +line; `tbrun` exits **4** when the compiler crashed, as `tbbuild` does. A compile that never +settled stays 2 in `tbrun`, whose 3 is "no output". Several tools exited 1 when they could +not run at all, and now exit 2: `render-book` for a missing input or support file and for a +render that threw, so it has no 1; `site_search` and `search_quality` with no search index; +`wisdom` when an earlier phase has not run; and `run_case` when not signed in. A crash exits 2 +in every tool. `exitOnCrash` moved from `scripts/lib/gate-probes.mjs` to `lib/cli.mjs`, which +`eval/`, `wisdom/`, `book/` and `builder/` may import, and its 16 importers followed. +Seventeen tools gained it, and it is installed only at the entry point in `tbdocs`, +`site_search` and `transcript`, which other modules import. A Sonnet agent then checked every +table against the code. Four of its eight findings were fixed here: +- `tbdocs --serve` exited 1 on a server error other than a port in use, and on a failed + watcher, both thrown outside `main()`. +- `wisdom` crashed with 2 when it reached the cap during discovery; that now exits 3, as it + does in the member and message fetches. +- `census_attributes`' table named a failed export, which leaves the package out of the + census and does not stop the run. +- A comment in `check_cli`. + +Three more are in Found; the eighth was wording. The docs agent also found `WIP.Build.md` +still giving `tbdocs`' old 1/2/3 check codes, which C72b had left behind, and `serve.bat` +returning 0 whatever `tbdocs` returns, which Tools.md now states and C75 fixes. + +`check_cli: 860 probes, all pass` (816 before). 44 of the new probes check that a tool's +`--help` output ends with exactly one exit-code table; there is one per tool but `impexp`. +Five cases were re-pointed from 1 to 2: `site_search` twice, `search_quality`, and +`transcript` twice, whose unreadable input is a crash. With a tool's table faulted through +`c43-fault.mjs` in `NODE_OPTIONS`, only that tool's table probe fails, in three cases: the +heading renamed, a code line malformed, and a second heading. `addin-test.bat`: `10 of 10 +lane(s) ran: 10 passed`, `registry: put back (20 project-state, 21 recent-list and 3 +association writes)`. `compare_trees`: Extending, PDF-Generation, Tools and Wisdom, online and +offline, the search data and `book.html`. Lint stays at `Checked 172 files`; regex safety +`536 literals + 34 constructed in 130 files ... 501 safe, 69 polynomial, 0 undecided, 0 +exponential; 8 construction(s) not resolvable` (the table pattern is the new literal); +`build.bat`, `check.bat` and `test.bat` clean. On the owner's next push CI prints +`check_cli: 860 probes, all pass` and that regex-safety line. + +### C74a — `scripts: addin_test puts the registry back after a crash` + +**Found while landing C74** (the owner's choice, 2026-09-30; it lands after C75). A crash +after `addin_test` has recorded the registry exits 2 through `exitOnCrash`, and nothing puts +the registry or the settings back, though the tool's table gives 3 for a registry that was not +put back. **Change.** The crash path restores what the run recorded, as the end of a run does, +and exits 3 if that fails. **Verify.** A throw put in after the snapshot through +`c43-fault.mjs`, with the kit's `reg-snap.mjs` before and after: identical registry, exit 2; a +throw from the restore as well: exit 3. + ### C75 — `serve.bat: return tbdocs's exit code` **A6-5 (R3).** `serve.bat` does not pass its child's exit code back, unlike the other @@ -1562,6 +1617,11 @@ text, gains a Landed note, and the correction is listed here, as in the last rev meaning a repository root, and at the owner's choice (2026-09-30) they take `--repo` now. The three `perf/` rigs' `--json FILE` changed too, so C73 landed as `scripts, eval, perf: one meaning each for --json and --src`. See C73's Landed note. +- **C74: the codes changed as well as their tables.** The entry splits one code with two + meanings. At the owner's choice (2026-09-30) it split three (`addin_test`, `wisdom`, + `tbrun`), moved every could-not-run case that exited 1 to 2, and gave every tool a crash + handler. That touched `builder/`, `book/`, `eval/` and `wisdom/`, so it landed as `builder, + scripts, book, eval, wisdom: one exit-code table per tool`. See C74's Landed note. ## Found while implementing @@ -1852,6 +1912,18 @@ Defects the review did not have, found by building something this plan asks for. removed the write it fed and left the flag, so `jsonOut` was set and never read. The rig writes `per-rule-measures.json` into its `--out DIR`. The flag is deleted, at the owner's choice. Fixed in `scripts, eval, perf: one meaning each for --json and --src`. +- **A crash in `addin_test` after the registry snapshot leaves the registry unrestored**, + found while landing C74. It exits 2, where the table gives 3 for a registry that was not + put back. C74a fixes it. +- **`scripts/lib/axe-scan.mjs:220-230` throws while it loads**, when `STATE_AUDITS` names a + page that `SAMPLE_PAGES` lacks or an unknown state. That runs before any crash handler, so + the five a11y tools exit 1, which `check_a11y` gives for a violation. Found while landing + C74, and left at the owner's choice. +- **A failed self-test probe exits 1 in `check_gate_lists`**, but 2 in `check_ci_workflows` + and `check_regex_safety`. Found while landing C74, and left at the owner's choice. +- **A crash in a `check_regex_safety --shard` worker exits 1**. The parent reports it as a + failed shard and exits 2, so no user sees the 1. Found while landing C74, and left at the + owner's choice. ## Open questions diff --git a/builder/command-line.mjs b/builder/command-line.mjs index dc93bf22..4bf4d2fa 100644 --- a/builder/command-line.mjs +++ b/builder/command-line.mjs @@ -73,7 +73,14 @@ given, and a flag that takes a value takes it as the next argument or as --port <N> port for --serve (default 4000) --stall-timeout <seconds> give up when no task completes for this long (default 120; 0 disables) - -h, --help print this text and exit`; + -h, --help print this text and exit + +Exit codes: + 0 nothing to report; with --serve, the server was stopped with Ctrl+C + 1 the build or its check found a problem: a link or integrity failure, a failed + build step, a fall in the page count, or a symbol-index URL lost + 2 a refused command line (a --dest the build refuses included), a build stopped by + the stall watchdog, with --serve a failed first build or a port in use, or a crash`; // fetchAssets is left out: absent, the build downloads unless $CI is set. export const DEFAULTS = Object.freeze({ diff --git a/builder/tbdocs.mjs b/builder/tbdocs.mjs index 32558f59..9c3b245e 100644 --- a/builder/tbdocs.mjs +++ b/builder/tbdocs.mjs @@ -30,10 +30,12 @@ // origin -- e.g. https://kubao.github.io -- so canonical URLs match // the actual deployment instead of the configured production host). // -// Exit codes, as in every tool: 0 clean; 1 the build or its check found a -// problem (a link or integrity failure, a failed build step, a page-count or -// symbol-baseline drop); 2 it could not do its job (a command-line error, a -// --dest the build refuses included, or a crash). +// Exit codes, as in every tool: 0 clean (with --serve, stopped with Ctrl+C); +// 1 the build or its check found a problem (a link or integrity failure, a +// failed build step, a page-count or symbol-baseline drop); 2 it could not do +// its job (a command-line error, a --dest the build refuses included, the +// stall watchdog, with --serve a failed first build or a port in use, or a +// crash). import { promises as fs } from "node:fs"; import os from "node:os"; @@ -43,7 +45,7 @@ import { fileURLToPath } from "node:url"; import yaml from "js-yaml"; import pc from "picocolors"; -import { printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { exitOnCrash, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; import { parseCommandLine, USAGE } from "./command-line.mjs"; @@ -1524,6 +1526,9 @@ async function main() { const isEntry = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; if (isEntry) { + // main().catch covers what main() awaits; this covers a throw from an event + // handler, such as the server's or the watcher's under --serve. + exitOnCrash(); main().catch((err) => { if (err?.commandLine) { console.error(err.message); diff --git a/docs/Documentation/Extending.md b/docs/Documentation/Extending.md index bc91d8ac..29f92cac 100644 --- a/docs/Documentation/Extending.md +++ b/docs/Documentation/Extending.md @@ -649,9 +649,11 @@ The split exists so that an edit confined to `docs/` usually has to pay for `che |---|---| | `0` | The checked thing is fine. | | `1` | The checked thing failed. This is the finding. | -| `2` | The harness or the environment failed --- a command line the tool refuses (an unknown flag, a flag without its value or with an empty one, an unexpected argument, a value it cannot use), an absent tree, an unhandled throw. Nothing was checked. | +| `2` | The harness or the environment failed --- a command line the tool refuses (an unknown flag, a flag without its value or with an empty one, an unexpected argument, a value it cannot use), an absent tree, a crash. Nothing was checked. | -Separating 1 from 2 is what stops a broken gate reading as a clean site, and it has to hold at the top level too. End the script with `main().catch((err) => { console.error(err); process.exit(2); })`, the way `check_a11y.mjs` does, so a crash cannot fall through to node's default exit 1 and be mistaken for a finding. A script that runs at top level, with no `main()`, calls `exitOnCrash()` from `scripts/lib/gate-probes.mjs` before it does anything else; that handler also catches a rejected top-level await. +Separating 1 from 2 is what stops a broken gate reading as a clean site, and it has to hold at the top level too. Node's own exit for an uncaught exception is 1, which reads as a finding. So every tool calls `exitOnCrash()` from `lib/cli.mjs` before it does anything else; it makes an uncaught exception, or a rejection nothing awaits, print the error and exit 2, and it also catches a rejected top-level await. A script with a `main()` may still end with `main().catch((err) => { console.error(err); process.exit(2); })`, the way `check_a11y.mjs` does. + +The rule is not limited to gates. Every tool under `builder/`, `scripts/`, `book/`, `eval/` and `wisdom/` reports a finding as 1 where it has findings, and everything that stops it doing its job as 2. A tool with more outcomes to tell apart adds codes above 2 --- `tbbuild` and `tbrun` use 3 and 4, `addin_test` and `wisdom` use 3 --- and `impexp` keeps a table of its own, shared with its Python edition. **Each tool ends its `--help` text with an `Exit codes:` block, one line per code, and that block is the source of truth**: [Tools and Scripts](Tools) states the same codes once in each tool's entry, and a change to one goes into the other. **Say what a pass covered.** Nearly every gate in both wrappers does: `check_dot_fit.mjs` gives the diagram count, `pick_a11y_sample.mjs --check` the sample size and the number of construct families in use, `check_publish_policy.mjs` the probe counts on both sides, `check_code_regions.mjs` the number of files swept, the number whose code regions moved and the number of fences found, `check_a11y.mjs` the page × theme × viewport product it audited. A gate silent on success says nothing about whether it examined anything, which is the state a gate that has quietly stopped working also reports. diff --git a/docs/Documentation/PDF-Generation.md b/docs/Documentation/PDF-Generation.md index 0661053a..91d068fd 100644 --- a/docs/Documentation/PDF-Generation.md +++ b/docs/Documentation/PDF-Generation.md @@ -81,7 +81,7 @@ The renderer relays three kinds of in-browser fault, each with its own prefix: - **`[request failed] <url> <errorText>`** --- Chromium could not load a resource. Under `file://` a file missing from `_site-pdf/` appears here as `net::ERR_FILE_NOT_FOUND` with its path, which is the quickest way to spot an incomplete Phase 8 tree. - **`[page error] <message>`** --- an uncaught exception inside the page. -- **`[render-book] error: <error>`** --- the top-level catch. It closes the browser and sets the exit code to 1. +- **`[render-book] error: <error>`** --- the top-level catch. It closes the browser and sets the exit code to 2. A paged.js stylesheet fetch that fails arrives as `error on LINK: <url>`. paged.js throws an undecorated `ProgressEvent` there; the driver unwraps it so the message carries the URL. @@ -121,7 +121,7 @@ The gate compares against everything under `docs/` and `builder/`, including fil 2. your cache path is incorrectly configured (which is: <cache path>). For (2), check out our guide on configuring puppeteer at https://pptr.dev/guides/configuration. -`<version>` is the Chrome build the installed `puppeteer` pins and `<cache path>` is the machine's own; the rest is fixed text from puppeteer. Nothing prefixes it, because `puppeteer.launch()` runs above the driver's `try` block --- the throw is an unhandled rejection, not something the driver catches and reports. The exit code is 1. +`<version>` is the Chrome build the installed `puppeteer` pins and `<cache path>` is the machine's own; the rest is fixed text from puppeteer. Nothing prefixes it, because `puppeteer.launch()` runs above the driver's `try` block --- the throw is an unhandled rejection, not something the driver catches and reports. The exit code is 2, because the driver treats a crash like any other failed render. **`book.bat`'s `npm install` does not fix this.** It runs only when `node_modules\puppeteer\package.json` is absent, and that file says nothing about the browser. `puppeteer`'s postinstall script is what downloads Chromium, so an install run with `--ignore-scripts` or with `PUPPETEER_SKIP_DOWNLOAD` set, or a puppeteer cache cleared afterwards, leaves the package in place and the browser missing --- the test passes and the launch still fails. Install the browser yourself; see [Building and Deployment](Building#requirements). @@ -147,25 +147,26 @@ The same fork checks fonts on the same principle: ### Exit codes -`render-book.mjs` has three: +`render-book.mjs` has two, and no 1: | Code | Meaning | |---|---| | `0` | The PDF was written. | -| `1` | A file the run needs is missing --- the input HTML, `lib/paged.browser.js`, `lib/progress-handler.js`, or an `--additional-script` path --- or the render threw. | -| `2` | Bad arguments: an unknown flag, a flag without its value or with an empty one, a `-t` that is not a whole number of milliseconds from 0 to 2147483647, a second input file, or a missing `<input.html>` or `-o`. | +| `2` | The run could not produce the PDF: a refused command line (an unknown flag, a flag without its value or with an empty one, a `-t` that is not a whole number of milliseconds from 0 to 2147483647, a second input file, or a missing `<input.html>` or `-o`), a file the run needs that does not exist (the input HTML, `lib/paged.browser.js`, `lib/progress-handler.js`, or an `--additional-script` path), a render that threw, or a crash. | -**`book.bat` propagates all three.** It copies `%ERRORLEVEL%` into a variable immediately after the renderer runs and exits with that variable once `popd` has restored the caller's directory --- the same pattern `build.bat` and `check.bat` already used. A batch file's exit code is otherwise its last command's, and an unguarded `popd` resets `ERRORLEVEL` to `0`; `book.bat` used to end on a bare `popd`, so a failed render always reported success to whatever launched it. A script can check `book.bat`'s own exit code directly now. Calling `node book\render-book.mjs` directly and reading its exit code, or watching for the `saved:` line, remain equally valid. +`node book/render-book.mjs --help` ends with the same codes. -**`book.bat`'s own pre-flight refusals never reach the renderer, and they reuse the same two numbers.** [`check_tree_fresh.mjs`](Tools#check-tree-fresh) exits 2 for an absent `_site-pdf/` and 1 for a stale one, and a failed `npm install` exits 1. So what a script sees from `book.bat` is: +**`book.bat` propagates both.** It copies `%ERRORLEVEL%` into a variable immediately after the renderer runs and exits with that variable once `popd` has restored the caller's directory --- the same pattern `build.bat` and `check.bat` already used. A batch file's exit code is otherwise its last command's, and an unguarded `popd` resets `ERRORLEVEL` to `0`; `book.bat` used to end on a bare `popd`, so a failed render always reported success to whatever launched it. A script can check `book.bat`'s own exit code directly now. Calling `node book\render-book.mjs` directly and reading its exit code, or watching for the `saved:` line, remain equally valid. + +**`book.bat`'s own pre-flight refusals never reach the renderer, and they share the renderer's 2.** [`check_tree_fresh.mjs`](Tools#check-tree-fresh) exits 2 for an absent `_site-pdf/` and 1 for a stale one, and a failed `npm install` exits 1. So what a script sees from `book.bat` is: | Code | Sources | |---|---| | `0` | The PDF was written. | -| `1` | A stale `_site-pdf/`, a failed `npm install`, or a failed render. | -| `2` | `_site-pdf/` is absent. | +| `1` | A stale `_site-pdf/`, or a failed `npm install`. | +| `2` | `_site-pdf/` is absent, or the render failed. | -Code 2 is unambiguous: the renderer's own 2 means bad arguments, and `book.bat` passes it a fixed argument list. Code 1 is not, and stderr separates the cases --- a pre-flight refusal prints one message beginning `check_tree_fresh:` and nothing runs after it, so any further output means the gate passed. +Code 1 is unambiguous, because the renderer has no 1. Code 2 is not, and stderr separates the cases --- a pre-flight refusal prints one message beginning `check_tree_fresh:` and nothing runs after it, so any further output means the gate passed. One case runs the other way and is worth stating on its own: **`build.bat && book.bat` skips the render whenever the link and integrity check reports anything.** That check sets a non-zero exit code while still writing a complete tree, so `&&` suppresses the book over a broken link that has no bearing on it. Run the two as separate statements. diff --git a/docs/Documentation/Tools.md b/docs/Documentation/Tools.md index cfdb1e7f..0720bbfc 100644 --- a/docs/Documentation/Tools.md +++ b/docs/Documentation/Tools.md @@ -8,7 +8,7 @@ permalink: /Documentation/Development/Tools # Tools and Scripts {: .no_toc } -One-line-per-tool reference for every executable in the documentation repository: the seven Windows batch wrappers at the repository root, the Node and Python scripts under `scripts/` (cross-platform except for [`tbbuild.mjs`](#tbbuild), which drives the twinBASIC IDE), the `tbdocs` orchestrator and its CLI flags, and the PDF render driver. If you are looking for the day-to-day workflow rather than a cheat sheet, the [Building and Deployment](Building) page is the gentler read; if you are modifying the build pipeline itself, the [tbdocs Internals](Builder) page goes one level deeper. Every Node tool answers `--help` or `-h` by printing its usage to standard output and exiting 0, without doing any of its work. A command line a tool cannot use is refused the same way by every Node tool: an unknown flag, a flag without its value or with an empty one, and an unexpected argument are reported on standard error and exit **2**. So is a value a tool cannot use, before it does any work: a number that is not one or is out of range (a fraction where a whole number is needed), a regular expression that does not compile, a URL that is not an absolute `http` or `https` one, a date that is not ISO 8601, a value outside a fixed set, and options that exclude each other. A tool that reads its command line through `lib/cli.mjs` and takes search terms, file names or folder names takes one that starts with a dash after `--`. +One-line-per-tool reference for every executable in the documentation repository: the seven Windows batch wrappers at the repository root, the Node and Python scripts under `scripts/` (cross-platform except for [`tbbuild.mjs`](#tbbuild), which drives the twinBASIC IDE), the `tbdocs` orchestrator and its CLI flags, and the PDF render driver. If you are looking for the day-to-day workflow rather than a cheat sheet, the [Building and Deployment](Building) page is the gentler read; if you are modifying the build pipeline itself, the [tbdocs Internals](Builder) page goes one level deeper. Every Node tool answers `--help` or `-h` by printing its usage to standard output and exiting 0, without doing any of its work. A command line a tool cannot use is refused the same way by every Node tool: an unknown flag, a flag without its value or with an empty one, and an unexpected argument are reported on standard error and exit **2**. So is a crash. So is a value a tool cannot use, before it does any work: a number that is not one or is out of range (a fraction where a whole number is needed), a regular expression that does not compile, a URL that is not an absolute `http` or `https` one, a date that is not ISO 8601, a value outside a fixed set, and options that exclude each other. A tool that reads its command line through `lib/cli.mjs` and takes search terms, file names or folder names takes one that starts with a dash after `--`. * TOC goes here {:toc} @@ -25,12 +25,14 @@ POSIX: node builder/tbdocs.mjs --src docs --check-audit-index [extra tbdocs flags] -Renders the documentation. Wraps `node builder/tbdocs.mjs --src docs --check-audit-index` and forwards extra arguments through `%*`. Produces `_site/`, `_site-offline/`, and `_site-pdf/`, modulo the `--no-offline` / `--no-pdf` flags and the `also_build_offline` / `also_build_pdf` keys in `_config.yml`. +Renders the documentation. Wraps `node builder/tbdocs.mjs --src docs --check-audit-index` and forwards extra arguments through `%*`. Produces `_site/`, `_site-offline/`, and `_site-pdf/`, modulo the `--no-offline` / `--no-pdf` flags and the `also_build_offline` / `also_build_pdf` keys in `_config.yml`. It returns [`tbdocs`](#tbdocs)'s exit code as it is. `--check-audit-index` is the part of that invocation most easily lost in transcription, and losing it is silent: it implies `--check`, so a bare `node builder/tbdocs.mjs --src docs` writes the same three trees, runs no link check at all, and reports success --- a check that never ran has nothing to report. There is no fixed build time worth quoting here, because every run prints its own (`Done in …`, with the page and static-file counts). What that number tracks is page count, core count, and which passes ran: the check, the offline mirror and the PDF tree are each part of the total, and `--no-check`, `--no-offline` and `--no-pdf` each remove one. +Exit codes: **0** nothing to report; **1** the build or its check found a problem: a link or integrity failure, a failed build step, a fall in the page count, or a symbol-index URL lost; **2** a refused command line, a build stopped by the stall watchdog, or a crash. + ### serve.bat serve.bat [extra tbdocs flags] @@ -41,6 +43,8 @@ POSIX: Starts a long-lived dev process. Wraps `node builder/tbdocs.mjs --src docs --serve` and forwards extra arguments through `%*`. After an initial build, an HTTP server binds to port 4000 (pass `--port <N>` to use a different port), a recursive source-tree watcher fires a debounced rebuild on each change, and a browser connected to the page auto-reloads via SSE on each successful rebuild. Offline and PDF passes are skipped each rebuild. Ctrl+C exits cleanly. **Only failures (4xx, 5xx, server exceptions) are logged** --- successful requests are silent. The watcher covers `docs/` and the worker pool is reused across rebuilds, so **an edit under `builder/` does not reach a running preview** and needs a restart --- see [why `serve.bat` does not show a builder change](Extending#serve-does-not-reload). +Exit codes: always **0**. `serve.bat` ends with `popd`, which resets the code, so it does not return `tbdocs`'s **2** for a failed first build or a port already in use; run `node builder/tbdocs.mjs --src docs --serve` to see it. + ### check.bat check.bat @@ -59,6 +63,8 @@ Requires `build.bat` to have run first. POSIX --- four commands, not one, chaine && node scripts/pick_a11y_sample.mjs --check \ && node scripts/check_a11y.mjs +Exit codes: **0** every step passed; otherwise the code of the step that stopped the run, as that step's entry gives it. + One of the four does not mean the same thing locally as it does in CI, on any platform. [`check_a11y.mjs`](#check-a11y)'s `target-size` rule measures rendered boxes, and an inline element's measured height is the content area of whatever `system-ui` resolves to on the machine running the scan --- which is why both workflows install `fonts-liberation` and the site's padding is calibrated against the smallest face in that band. A local pass does not predict the runner's, and it errs in the unhelpful direction: larger metrics clear controls that CI then fails. See [Building and Deployment](Building#fonts-liberation-installed-on-purpose) for the measurements. ### test.bat @@ -101,6 +107,8 @@ POSIX: && node scripts/check_impexp_parity.mjs \ && node scripts/check_axe_patch_equiv.mjs +Exit codes: **0** every step passed; otherwise the code of the step that stopped the run, as that step's entry gives it. + **Twelve of the fifteen cannot be affected by an edit confined to `docs/`**, which is why they are separate from `check.bat`. Run this one when the change touches `builder/`, `scripts/`, `lib/`, `book/`, `eval/`, `wisdom/` or `test/`, the site's scripts in `docs/assets/js/`, a wrapper, or a workflow. Both CI workflows run all fifteen unconditionally, so skipping it locally cannot let a tooling regression reach `staging`. The three exceptions are [`check_code_regions.mjs`](#check-code-regions), [`check_gate_lists.mjs`](#check-gate-lists), which reads this page, and [`check_lint.mjs`](#check-lint), which lints the site's scripts in `docs/assets/js/`. The first is worth knowing in detail. Its corpus sweep tokenises every markdown file under `docs/`, so a page that provokes a rewrite into altering a code region fails it. Its fixed probes are a different matter: they run against their own sources whatever the tree holds, and they cover the *mirror* fault, where a rewrite silently stops firing. The sweep cannot see that one --- text the rewrite skipped is stashed and restored unchanged, so every region still matches. Add a page with an unusual code construct and run `test.bat`, but read the built page too. @@ -131,6 +139,8 @@ The `mkdir` is not housekeeping. `render-book.mjs` writes the PDF with a plain f **Do not chain the two as `build.bat && book.bat`.** `build.bat` sets a non-zero exit code when the link or integrity check finds something, and still writes all three trees --- the finding is a report, not an abort. `&&` reads only the exit code, so a broken link anywhere on the site cancels the render, for a reason that has nothing to do with the book. The terminal ends on the link findings and no PDF, which reads as a render that failed rather than as one that never started. Run them as two separate commands; see [Building and Deployment](Building#the-double-ampersand-trap). +Exit codes: **0** the PDF was written; **1** the tree is stale, or `npm install` failed; **2** there is no built tree, or the render failed. [`check_tree_fresh.mjs`](#check-tree-fresh) runs first and `book.bat` returns its code as it is; [`render-book.mjs`](#bookrender-bookmjs) has no 1 of its own. + #### The pre-flight freshness check {: #book-preflight } @@ -144,7 +154,7 @@ Leaving the question to the renderer does not cover it either. `render-book.mjs` `--marker book.html` is required here, and `book.bat` is the only caller that passes it. The script identifies a tree by its `index.html`, which every output tree has except `_site-pdf/` --- that one holds a single `book.html`. Without the flag, `--tree docs/_site-pdf` looked for an `index.html` that never exists and exited 2, so `--tree` was there all along and could not actually be pointed at this tree. -**The pre-flight has its own exit codes, and they collide with the renderer's.** `check_tree_fresh.mjs` exits **2** when the tree is absent and **1** when it is stale, and `book.bat` hands whichever it got straight back to its caller. [`render-book.mjs`](#bookrender-bookmjs) afterwards uses **1** for a missing input or a failed render and **2** for a bad argument, so the number alone does not say which half of `book.bat` failed. The message does --- every pre-flight failure is prefixed `check_tree_fresh:`, and these are the two it prints: +**A 2 does not say which half of `book.bat` failed; the message does.** The pre-flight returns 2 for an absent tree and the renderer returns 2 for a failed render, and `book.bat` hands whichever it got straight back to its caller. A 1 is unambiguous: a stale tree, or a failed `npm install`. Every pre-flight failure is prefixed `check_tree_fresh:`, and these are the two it prints: check_tree_fresh: docs/_site-pdf/book.html does not exist. Run build.bat first -- there is no built tree to check. @@ -168,7 +178,7 @@ Compiles the documentation's own twinBASIC code samples --- every ` ```tb ` fenc **It is not one of the gates, and it must not become one.** It is absent from `build.bat`, `check.bat`, `test.bat` and both CI workflows, for three reasons that are not going to change: it needs a twinBASIC install, where `npm install` has to remain sufficient to build the docs; it needs Windows, a private desktop and a CDP-reachable WebView2, none of which exists on the CI box; and an IDE cold start is 8 to 11 seconds against a whole site build's four. It is run by a person, deliberately, which is the same arrangement [`sweep_a11y.mjs`](#sweep-a11y) already has. -Exit codes: **0** clean, **1** a sample does not compile, **2** the harness failed. +Exit codes: those of [`check_examples.mjs`](#check-examples), returned as they are: **0** clean, **1** a sample does not compile, **2** the harness failed. ### addin-test.bat {: #addin-testbat } @@ -183,7 +193,7 @@ Tests twinBASIC IDE add-ins by machine: it builds each add-in under test, loads **It is not one of the gates either**, and for the reasons `examples.bat` is not: it needs a twinBASIC install, and it needs Windows, a private desktop and a CDP-reachable WebView2. It is absent from `build.bat`, `check.bat`, `test.bat` and both CI workflows. -Exit codes: **0** every lane passed and the registry is as it was found, **1** a lane failed, **2** the harness failed or could not put the registry back. +Exit codes: those of [`addin_test.mjs`](#addin-test), returned as they are: **0** every lane passed and the registry is as it was found, **1** a lane failed, **2** the harness failed, **3** the registry or a work folder was not put back. ## CLI tools @@ -238,7 +248,9 @@ Full invocation: | `--serve` | Start the long-lived dev server (watch + rebuild + SSE live-reload). Offline and PDF passes are skipped each rebuild. | | `--port <N>` | HTTP port for `--serve` mode, a whole number from 1 to 65535. Default: 4000. | -Exit codes: **0** clean; **1** the build or its check found a problem: a link or integrity failure, a failed build step, a fall in the page count, or a symbol-index URL lost; **2** the build could not do its job, from a crash (a stalled build included) or a command-line error. A command-line error --- an unknown flag, an unexpected argument, a flag without its value or with an empty one (`--baseurl` alone accepts one, meaning the site root), a value a flag cannot use (a `--port` that is not a port number, a negative or non-numeric `--stall-timeout`, a `--url` that is not an absolute `http` or `https` URL), or a `--dest` the build refuses --- is reported on standard error and exits **2**, so it is never read as a broken link. +A command-line error --- an unknown flag, an unexpected argument, a flag without its value or with an empty one (`--baseurl` alone accepts one, meaning the site root), a value a flag cannot use (a `--port` that is not a port number, a negative or non-numeric `--stall-timeout`, a `--url` that is not an absolute `http` or `https` URL), or a `--dest` the build refuses --- is reported on standard error before any work starts, so it is never read as a broken link. + +Exit codes: **0** nothing to report (with `--serve`, the server was stopped with Ctrl+C); **1** the build or its check found a problem: a link or integrity failure, a failed build step, a fall in the page count, or a symbol-index URL lost; **2** a refused command line (a `--dest` the build refuses included), a build stopped by the stall watchdog, with `--serve` a failed first build or a port in use, or a crash. ### check_links.mjs {: #check-links } @@ -265,13 +277,18 @@ Offline (filesystem-only) link checker plus optional integrity checks. Multiple | `--check-canonical` | Assert each page's canonical URL matches its location. | | `--no-fail` | Downgrade failures to informational output (exit 0 even with broken links). | -Exit code 1 indicates a check found a problem, broken links or integrity failures or both (the integrity checks share the same SAX parse pass as link extraction); the summary lines say which. `--no-fail` turns it into 0. Exit code 2 means the check could not run: a command-line error --- no arguments, an unknown flag, a flag without its value or with an empty one, no `--offline`, or no input --- reported on standard error, or a crash. `--no-fail` does not change it. The script dedupes `(target, fragment)` so each unique filesystem check fires exactly once regardless of how many pages link to the same target --- on the current tree (~733k link occurrences, ~12k unique targets across 1,127 HTML files / 124 MB) each pass runs in ~2.2 seconds on a development box. +A finding can be a broken link, an integrity failure or both (the integrity checks share the same SAX parse pass as link extraction); the summary lines say which. `--no-fail` turns a finding's 1 into 0, and changes nothing else. The script dedupes `(target, fragment)` so each unique filesystem check fires exactly once regardless of how many pages link to the same target --- on the current tree (~733k link occurrences, ~12k unique targets across 1,127 HTML files / 124 MB) each pass runs in ~2.2 seconds on a development box. + +Exit codes: **0** every check passed, or `--no-fail` turned the findings into 0; **1** a link, forbidden-prefix or integrity check failed (with `/sep/` segments, the highest code of any segment); **2** the check could not run: a refused command line (no arguments, an unknown option, a flag without its value, no `--offline`, or no input), or a crash. ### crawl_check.mjs +{: #crawl-check } node scripts/crawl_check.mjs <start-url> [--concurrency N] [--timeout MS] [--skip-external] -Online link crawler for the deployed site. Starts at `<start-url>`, GETs every same-origin / same-base-path page recursively, extracts every link the build's own check follows (`srcset` and `poster` included), and verifies that each link responds 2xx (HEAD for cross-origin, GET for same-origin). A request that fails before any response arrives, whether its connection is reset or it times out, is tried twice more, each time with the full `--timeout`, before its link is reported broken. The timeout covers a page's body as well as its headers. A page whose body breaks off, or is still arriving when the timeout runs out, is reported broken at once, without a retry, and the part that arrived is not parsed for links. Exits 0 if every link is reachable and every anchor exists, 1 if a link is broken or an anchor is missing, and 2 on a usage error --- a missing start URL or one that is not an absolute `http` or `https` URL, an unknown flag, a flag without its value, a `--concurrency` that is not a whole number of at least 1, a `--timeout` that is not a whole number of milliseconds from 1 to 2147483647, or a second argument --- or a crash. Use it after a manual `workflow_dispatch` deploy to verify the published site --- `check_links.mjs` covers the local filesystem; `crawl_check.mjs` covers the live deployed site. +Online link crawler for the deployed site. Starts at `<start-url>`, GETs every same-origin / same-base-path page recursively, extracts every link the build's own check follows (`srcset` and `poster` included), and verifies that each link responds 2xx (HEAD for cross-origin, GET for same-origin). A request that fails before any response arrives, whether its connection is reset or it times out, is tried twice more, each time with the full `--timeout`, before its link is reported broken. The timeout covers a page's body as well as its headers. A page whose body breaks off, or is still arriving when the timeout runs out, is reported broken at once, without a retry, and the part that arrived is not parsed for links. A command line it refuses includes a missing start URL or one that is not an absolute `http` or `https` URL, a `--concurrency` that is not a whole number of at least 1, a `--timeout` that is not a whole number of milliseconds from 1 to 2147483647, and a second argument. Use it after a manual `workflow_dispatch` deploy to verify the published site --- `check_links.mjs` covers the local filesystem; `crawl_check.mjs` covers the live deployed site. + +Exit codes: **0** every link is reachable and every anchor exists, **1** a link is broken or an anchor is missing, **2** a refused command line, or a crash. ### check_a11y.mjs {: #check-a11y } @@ -281,7 +298,7 @@ Online link crawler for the deployed site. Starts at `<start-url>`, GETs every s Automated accessibility scan of the built site, and the last of `check.bat`'s four steps. Loads `axe-core` into headless Chromium (via `puppeteer`) and runs it against thirteen sample pages in both themes at two viewports, plus two state audits that open a disclosure first --- 60 audits in all. The page list is derived rather than hand-maintained, and [`scripts/pick_a11y_sample.mjs`](#pick-a11y-sample) is what keeps it representative. -**This script is the reporting front end, not the scan.** What the scan *is* --- the page list, the themes and viewports, the blocked requests, the axe run options, the vendored source patches and the state audits --- lives in [`scripts/lib/axe-scan.mjs`](#axe-scan), which `check_a11y.mjs`, `sweep_a11y.mjs` and `check_a11y_fingerprint.mjs` all share. Change the scan there, not here. The scan uses the `wcag2a`, `wcag2aa`, `wcag21a`, `wcag21aa`, and `wcag22aa` rule tags, plus the `heading-order` best-practice rule. All five WCAG tags must be listed because axe matches tags literally, with no version rollup --- a rule tagged only `wcag21aa` does not match `wcag22aa`, even though WCAG 2.2 AA is a superset of 2.1 AA. Exits 1 if any page has a violation and 2 on an internal error; incomplete (needs-review) results are reported but do not fail the run. +**This script is the reporting front end, not the scan.** What the scan *is* --- the page list, the themes and viewports, the blocked requests, the axe run options, the vendored source patches and the state audits --- lives in [`scripts/lib/axe-scan.mjs`](#axe-scan), which `check_a11y.mjs`, `sweep_a11y.mjs` and `check_a11y_fingerprint.mjs` all share. Change the scan there, not here. The scan uses the `wcag2a`, `wcag2aa`, `wcag21a`, `wcag21aa`, and `wcag22aa` rule tags, plus the `heading-order` best-practice rule. All five WCAG tags must be listed because axe matches tags literally, with no version rollup --- a rule tagged only `wcag21aa` does not match `wcag22aa`, even though WCAG 2.2 AA is a superset of 2.1 AA. Incomplete (needs-review) results are reported but do not fail the run. | Flag | Effect | |---|---| @@ -293,6 +310,8 @@ Automated accessibility scan of the built site, and the last of `check.bat`'s fo Three details are essential and easy to break. It scans **`_site-offline/`, not `_site/`**: the online tree's root-absolute asset URLs (`/assets/css/…`) resolve to nothing under `file://`, so every page would load unstyled and every colour-contrast result would be a meaningless black-on-white pass --- the offline tree uses relative asset paths and renders for real. It scans **each page in both themes**, because dark mode is a separate palette (applied via `[data-theme=dark]`) and a light-mode pass says nothing about it. And it **blocks the search index** (`search-data.js` + `lunr.min.js`) while scanning: every page pulls in ~3.2 MB of index that never reaches the DOM axe walks, so aborting it cuts the run from ~27 s to ~9 s with identical results. `just-the-docs.js` is deliberately not blocked --- it installs the search combobox ARIA, and blocking it would make axe see less. Requires `build.bat` to have produced an up-to-date `_site-offline/`. +Exit codes: **0** no page has a violation (incomplete checks are reported but do not fail), **1** at least one page has a violation, **2** the scan could not run: a refused command line, or a crash. + ### pick_a11y_sample.mjs {: #pick-a11y-sample } @@ -300,7 +319,7 @@ Three details are essential and easy to break. It scans **`_site-offline/`, not Derives the accessibility scan's page list, and checks that it still covers every construct the site uses. The scan reads thirteen pages out of ~1,160, so the page list decides what it can report at all --- and a list that stops being representative fails silently: the rule for a construct no sample page carries simply never runs, and the gate stays green. -The script holds a list of **construct families**: markup shapes some axe rule keys on, each recording the rule that would otherwise have nothing to run on. `--check` (the default, and what `check.bat` and both CI workflows run) verifies every family the site uses is covered by at least one sample page, and exits 1 naming the gaps and the cheapest page that would close each. `--propose` runs a greedy set cover, ranked by measured per-page audit cost, and prints a replacement page list. `--census` reports what each family is, how many pages use it, and which page uses it most. Two of the three modes together are refused. `--fresh` applies to `--propose` alone: by default the set cover is seeded with the current list, so it prints what to *add*, and `--fresh` ignores the current list and covers from scratch --- which is how to ask whether the pages already in the sample still earn their place. +The script holds a list of **construct families**: markup shapes some axe rule keys on, each recording the rule that would otherwise have nothing to run on. `--check` (the default, and what `check.bat` and both CI workflows run) verifies every family the site uses is covered by at least one sample page, and names the gaps and the cheapest page that would close each. `--propose` runs a greedy set cover, ranked by measured per-page audit cost, and prints a replacement page list. `--census` reports what each family is, how many pages use it, and which page uses it most. Two of the three modes together are refused. `--fresh` applies to `--propose` alone: by default the set cover is seeded with the current list, so it prints what to *add*, and `--fresh` ignores the current list and covers from scratch --- which is how to ask whether the pages already in the sample still earn their place. **`--check` cannot report a construct nobody has registered.** It iterates the families that exist and asks whether the sample still covers each, so markup no family describes produces silence --- and that silence is the failure a derived sample exists to prevent. When the docs start using a construct they have not used before, registering the family is a deliberate step nothing will prompt you to take. @@ -316,6 +335,8 @@ Then run `--check`. If the new family is uncovered it names the cheapest page th which is the path `--check` names for you. Two things follow from that second edit. Run [`scripts/sweep_a11y.mjs`](#sweep-a11y) once over the whole site, because the sample can only ever report on the markup it contains, and the sweep is what says what the new construct is doing on the pages that already have it. And do not expect [`check_a11y_fingerprint.mjs`](#check-a11y-fingerprint) to vouch for it: it compares a candidate against a baseline produced by the same page set, so a change to *which* pages are walked is its documented blind spot. +Exit codes: **0** the mode ran (with `--check`, every construct family in use is covered); **1** with `--check`, a construct family has no sample page, or a `SAMPLE_PAGES` entry is not in the built tree; **2** a refused command line, no built tree (run `build.bat` first), or a crash. + ### check_links_diff.mjs {: #check-links-diff } @@ -325,7 +346,7 @@ which is the path `--check` names for you. Two things follow from that second ed node scripts/check_links_diff.mjs --list node scripts/check_links_diff.mjs --self-test -Differential harness for the link checker. The check has two front ends --- the standalone [`scripts/check_links.mjs`](#check-links), which reads a tree from disk, and the build's own `--check` pass, which checks the pages it holds in memory against an index of what it wrote, in chunks across its workers. Both run the same functions in `builder/check.mjs` and differ only in how they read the tree, which is still enough to hide a fault, because **a checker that silently checks less reports a clean pass**. This runs both over the same bytes and diffs their findings category by category, across the nine finding categories plus the per-run counts. Exits 0 when the two sides agree, 1 on a difference, 2 on a harness error. +Differential harness for the link checker. The check has two front ends --- the standalone [`scripts/check_links.mjs`](#check-links), which reads a tree from disk, and the build's own `--check` pass, which checks the pages it holds in memory against an index of what it wrote, in chunks across its workers. Both run the same functions in `builder/check.mjs` and differ only in how they read the tree, which is still enough to hide a fault, because **a checker that silently checks less reports a clean pass**. This runs both over the same bytes and diffs their findings category by category, across the nine finding categories plus the per-run counts. Two registries decide what a run actually does, and `--list` prints both. **Sides** are the implementations being compared, named by `--a` and `--b`: @@ -364,6 +385,8 @@ Both CI workflows run the harness, and neither runs it over the real site. `chec The fixtures have their own document, and it is the one to read before editing them: [`test/README.md`](https://github.com/twinbasic/documentation/blob/main/test/README.md) covers what each page under `check-src/` is there to provoke, and the hard-coded per-category counts (`FIXTURE_EXPECTED`, `FIXTURE_BUILT_ONLINE`, `FIXTURE_BUILT_OFFLINE`) that are asserted after every run, so a fixture that stops provoking a category fails loudly instead of quietly returning to empty-against-empty. It also covers the hazard that catches people out: **the fixture is built by the real `tbdocs`, so a template change can turn this gate red without anyone touching the fixture or the checker.** Adding the self-hosted fonts put two `<link rel="preload">` tags on every page, `check-src/` had no `assets/fonts/`, and its `broken` count went from 3 to 9. The fix for that shape of failure is to add the stub asset the template now expects --- never to raise the expected count, which dilutes a category the fixture exists to hold at an exact number. +Exit codes: **0** the two sides agree in every case; **1** the sides differ, a fixture's category counts drifted, or `--self-test` failed; **2** the comparison could not run: a refused command line, an unknown side or case, `--a` equal to `--b`, a failed build, or a crash. + ### axe-scan.mjs {: #axe-scan } @@ -382,14 +405,18 @@ A clean build only says **nothing in `docs/` is currently refused**, which is al It also checks that the refusal *message* for a `.md` still names a fault that can happen, which is a narrower thing than it sounds. The message tells the reader the opening `---` must be the first line, and that is the right advice only because the two causes that come to mind first are handled elsewhere: a UTF-8 BOM is stripped before parsing, and malformed YAML aborts with its own error. An earlier draft named the BOM and would have sent every reader hunting for something that cannot occur, so all three behaviours are now asserted against real files --- nothing else in the repository covers them. -No browser, no built tree, ~40 ms, which is why it is `test.bat`'s first step. Run it after touching `builder/publish-policy.mjs`. Exits 1 naming each failed assertion. +No browser, no built tree, ~40 ms, which is why it is `test.bat`'s first step. Run it after touching `builder/publish-policy.mjs`. Each failed assertion is named. + +Exit codes: **0** every assertion held, and the source tree holds no file the allowlist refuses; **1** an assertion failed, or the source tree holds a file the allowlist refuses; **2** the gate could not run: a refused command line, or a crash. ### check_tree_fresh.mjs {: #check-tree-fresh } node scripts/check_tree_fresh.mjs [--tree DIR] [--source DIR ...] -`check.bat`'s first gate. Refuses a built tree older than the sources that produced it, by comparing the newest mtime under the source tree against the built tree's `index.html`. The build's own output trees under `docs/` are not sources, and which folders those are comes from `lib/markdown-files.mjs`, the list [`check_code_regions.mjs`](#check-code-regions) walks by. Without it, editing a page and running `check.bat` without rebuilding audits the *previous* build and passes --- a green run that says nothing about the change just made. CI never hits this because it builds in the same job; a development box hits it whenever the two commands run out of order. Exits 0 when the tree is current, 1 when stale (naming `build.bat`), 2 when the tree is absent. +`check.bat`'s first gate. Refuses a built tree older than the sources that produced it, by comparing the newest mtime under the source tree against the built tree's `index.html`. The build's own output trees under `docs/` are not sources, and which folders those are comes from `lib/markdown-files.mjs`, the list [`check_code_regions.mjs`](#check-code-regions) walks by. Without it, editing a page and running `check.bat` without rebuilding audits the *previous* build and passes --- a green run that says nothing about the change just made. CI never hits this because it builds in the same job; a development box hits it whenever the two commands run out of order. The message for a stale tree names `build.bat`. + +Exit codes: **0** the tree is at least as new as its inputs; **1** the tree is stale (run `build.bat`); **2** the check could not run: a refused command line, no built tree or marker file, or a crash. ### check_dot_fit.mjs {: #check-dot-fit } @@ -398,6 +425,8 @@ No browser, no built tree, ~40 ms, which is why it is `test.bat`'s first step. R Renders every committed diagram with the real webfont and fails if a label sits outside the box Graphviz drew for it. Graphviz lays out boxes from a width table while the browser paints text with an actual font --- two measurements of the same string that nothing inside the build compares. When they disagree the SVG is still well-formed and the build still green; the only symptom is a label hanging past its edge. Twenty-seven labels across three diagrams shipped that way, on pages that had passed the full accessibility sweep, because axe does not evaluate SVG `<text>` geometry either. `builder/dot-metrics.mjs` fixed the cause; this proves it stayed fixed. Needs a browser, which is why it lives in `check.bat` rather than the build. Run it after touching any `.dot`, `builder/dot-metrics.mjs`, or `builder/inter-metrics.json`. +Exit codes: **0** every diagram's text fits its boxes, or no diagram was found; **1** the text of at least one diagram sits outside its box; **2** the gate could not run: a refused command line, no browser, or a crash. + ### check_regex_safety.mjs {: #check-regex-safety } @@ -419,7 +448,9 @@ It gates on **exponential only**. recheck also reports polynomial blowup, and ab Two sets of probes run inside the normal pass rather than behind `--self-test`, because a green line saying *no exponential regex* is otherwise indistinguishable from a gate that has stopped detecting them. Eight are regexes with known answers in both directions, including the three this repository actually shipped. Fourteen more cover the folding: eight constructions that must resolve to an exact pattern, and six that must be refused with a reason --- a folder that quietly resolves nothing moves every construction into the unresolved list and the run still passes. -Exits 1 on an exponential finding. Exits 2 when the gate itself failed --- a file that would not parse, a regex recheck could not analyse, a probe that came back wrong, or a crash --- because each of those leaves something unchecked; a 2 wins over a 1 when both happen in one run. That is the [convention for a gate's exit codes](Extending#conventions), which this gate predates and followed only from round 7 of the use-case evaluation: until then it returned 1 for everything. +A failure of the gate itself is a 2 rather than a 1, because each of those leaves something unchecked; a 2 wins over a 1 when both happen in one run. That is the [convention for a gate's exit codes](Extending#conventions), which this gate predates and followed only from round 7 of the use-case evaluation: until then it returned 1 for everything. + +Exit codes: **0** no regex can backtrack exponentially (with `--self-test`, every probe was classified correctly); **1** a regex can backtrack exponentially; **2** the gate could not run, and 2 wins over 1: a refused command line, a file it could not parse, a regex it could not analyse, a probe that came back wrong (also `--self-test`), or a crash. ### check_code_regions.mjs {: #check-code-regions } @@ -442,7 +473,9 @@ It is also the gate on `lib/markdown.mjs` and `lib/frontmatter.mjs`, the modules `--verbose` prints the first few altered regions of each failing file, before and after. `--self-test` replaces the normal run rather than adding to it, so neither the probes nor the sweep runs: it de-indents the body of one small fence by hand and passes only if the comparison notices. That proves the comparator can still see a change, and nothing more --- it runs no rewrite at all. -Exits 1 when a code region differs, when a probe's admonition is not rewritten, when any other probe fails or the two parses disagree on a page, or when `--self-test`'s de-indent goes unnoticed, and 2 when the gate itself cannot run. [When `test.bat` fails in `check_code_regions`](Extending#code-regions-altered) says what to change. +[When `test.bat` fails in `check_code_regions`](Extending#code-regions-altered) says what to change. + +Exit codes: **0** no code region was altered, and every probe passed (with `--self-test`, the comparison detects the altered region); **1** a code region was altered, a probe failed or the two parses disagree (with `--self-test`, the comparison missed the altered region); **2** the gate could not run: a refused command line, or a crash. ### check_gate_lists.mjs {: #check-gate-lists } @@ -461,7 +494,9 @@ Three things follow from how it works. **The wrapper is the source of truth**, n When it fires on a count that is merely a subset --- *three cheaper gates run first* --- the fix is to delete the number rather than correct it. The command block or the linked list beneath it already states it, and a number nothing derives is a number that goes stale. The script's header names what the sweep deliberately does not see. -Its probes ride along in the ordinary run rather than hiding behind `--self-test`, because a green line from a gate that has stopped detecting looks exactly like a green line from a working one. Thirteen of the nineteen cover the sweep. Twelve are sentences that were published at the commit round 4 reviewed; the thirteenth puts a heading-shaped line in a code fence, as in [Wisdom](Wisdom)'s `staging.md` example, inside a wrapper's section, because such a line starts no section. Exits 1 on a disagreement or a failed probe, 2 if it cannot run. +Its probes ride along in the ordinary run rather than hiding behind `--self-test`, because a green line from a gate that has stopped detecting looks exactly like a green line from a working one. Thirteen of the nineteen cover the sweep. Twelve are sentences that were published at the commit round 4 reviewed; the thirteenth puts a heading-shaped line in a code fence, as in [Wisdom](Wisdom)'s `staging.md` example, inside a wrapper's section, because such a line starts no section. + +Exit codes: **0** the wrappers match the gate lists, every stated count agrees, and every probe passed; **1** a list or a stated count disagrees, or a probe failed; **2** the gate could not run: a refused command line, or a crash. ### check_ci_workflows.mjs {: #check-ci-workflows } @@ -474,7 +509,9 @@ The gates both workflows share are one composite action, `.github/actions/run-ga The differences that are meant are listed in the script, each with where it is recorded: `check_tree_fresh.mjs` runs only locally, because CI builds the tree in the same job; the two `check_links_diff.mjs` fixture steps run only in CI, one of them only in `checks.yml`; and the deploy build adds `--url` and `--baseurl`. CI may also interleave the two wrappers' gates, as long as each wrapper's own order holds. Anything else is a finding, and so is an allowance that no longer matches anything. -Its probes ride along in every run: each plants one defect in a small synthetic set of wrappers, workflows and actions --- a missing gate, a step no wrapper runs, two gates swapped, changed arguments, a build flag lost or added, a gate missing from the shared action, a workflow that stops calling it --- and requires exactly the findings it should produce. Pure text: no browser, no built tree. Exits 0 clean, 1 on a finding, 2 when a probe fails or the gate cannot run. +Its probes ride along in every run: each plants one defect in a small synthetic set of wrappers, workflows and actions --- a missing gate, a step no wrapper runs, two gates swapped, changed arguments, a build flag lost or added, a gate missing from the shared action, a workflow that stops calling it --- and requires exactly the findings it should produce. Pure text: no browser, no built tree. + +Exit codes: **0** both workflows run every gate the wrappers run, and its own probes pass; **1** a workflow differs from the wrappers (a finding is listed); **2** the gate could not run: a refused command line, a failed probe, or a crash. ### check_lint.mjs {: #check-lint } @@ -484,7 +521,7 @@ Its probes ride along in every run: each plants one defect in a small synthetic Runs Biome, pinned to an exact version, over the tooling: `builder/`, `scripts/`, `lib/`, `book/`, `eval/`, `wisdom/`, `test/` and the site's two scripts in `docs/assets/js/`, less the exceptions that `biome.jsonc` at the repository root lists and explains. The rules are the ones that find defects --- Biome's correctness and suspicious groups --- and none about style; the configuration names the few it turns off, each with its reason. Moving and deleting code leaves unused imports and undeclared names behind, and nothing else reads the tooling for them. No browser, no built tree, a fraction of a second. -**Warnings fail as well as errors.** Biome reports an unused import or variable as a warning, and exits 0 on warnings, so a plain `npx biome lint` passes a file full of them. The gate also refuses to pass when Biome could not lint. Biome exits 1 for a broken `biome.jsonc`, as it does for a finding, and 0 for a scope that matches no script at all, so the gate reads the summary Biome writes beside its usual output to tell these apart. Exits 0 clean, 1 on a finding, 2 when Biome could not lint or, over the whole scope, checked no script. +**Warnings fail as well as errors.** Biome reports an unused import or variable as a warning, and exits 0 on warnings, so a plain `npx biome lint` passes a file full of them. The gate also refuses to pass when Biome could not lint. Biome exits 1 for a broken `biome.jsonc`, as it does for a finding, and 0 for a scope that matches no script at all, so the gate reads the summary Biome writes beside its usual output to tell these apart. Lint before every commit that touches one of those folders, or let the pre-commit hook do it. `.githooks/pre-commit` runs this gate with `--staged`, on the scripts the commit adds or changes, as they are in the working tree, and runs nothing else. Biome skips the staged scripts its scope excludes, and a commit that stages no script returns before Biome starts. Enable the hook in a clone with: @@ -492,12 +529,16 @@ Lint before every commit that touches one of those folders, or let the pre-commi A clone without the hook is still checked, because `test.bat` and both CI workflows run this gate over the whole scope. `npx biome lint --write` applies the fixes Biome marks safe. The fixes it offers for an unused import or variable are marked unsafe and need `--unsafe` as well, so read the diff after applying them. +Exit codes: **0** Biome found nothing (with `--staged`, also when no script is staged, so nothing was linted); **1** Biome found an error or a warning; **2** the gate could not lint: a refused command line, git or Biome failing to run, Biome checking no script over the whole scope, or a crash. + ### search.test.mjs {: #search-test } node --test test/search.test.mjs -Unit tests for the site search, run by Node's own test runner rather than as a script under `scripts/`. The first group builds search entries from small synthetic pages through `builder/search.mjs` and checks what each entry holds: the split at headings, the folding of generic sections such as See Also into the member they belong to, index marks, the join with the symbol index, and output that is the same byte for byte from one build to the next. The build's own check sees only which URLs the index covers. The rest are guards that the copies of the search client's query code still agree --- the online client under `builder/vendor/just-the-docs/`, the offline client in `builder/offline.mjs` and the replica in `eval/site_search.mjs`, all three or two of them --- and that the online client's index, built in slices, is the index lunr builds in one call. No browser, no built tree, well under a second. Exits 1 when a test fails. +Unit tests for the site search, run by Node's own test runner rather than as a script under `scripts/`. The first group builds search entries from small synthetic pages through `builder/search.mjs` and checks what each entry holds: the split at headings, the folding of generic sections such as See Also into the member they belong to, index marks, the join with the symbol index, and output that is the same byte for byte from one build to the next. The build's own check sees only which URLs the index covers. The rest are guards that the copies of the search client's query code still agree --- the online client under `builder/vendor/just-the-docs/`, the offline client in `builder/offline.mjs` and the replica in `eval/site_search.mjs`, all three or two of them --- and that the online client's index, built in slices, is the index lunr builds in one call. No browser, no built tree, well under a second. + +Exit codes: **0** every test passed, **1** a test failed. ### check_page_baseline.mjs {: #check-page-baseline } @@ -510,7 +551,7 @@ The guard says nothing on a healthy tree, so every ordinary build sounds exactly Two probes look redundant and are the two that caught real bugs while the guard was being written. A **foreign source root must be ignored**: [`check_links_diff.mjs`](#check-links-diff) builds a three-page fixture tree, and a baseline keyed to nothing met it with *905 pages missing*. And **CI must refuse a missing baseline** rather than create one, because a run that wrote the file would record whatever drop it had been asked to catch. -Exits 1 on any failed probe, 2 if it cannot run. +Exit codes: **0** every probe passed, **1** a probe failed, **2** the gate could not run: a refused command line, or a crash. ### check_book_coverage.mjs {: #check-book-coverage } @@ -523,7 +564,7 @@ The warnings say nothing when every page has an entry --- in a part, or in `left Eight probes give each of the five findings a fault to report: a page with no entry, a page in the book and in `left_out:`, an entry of each kind that selects no page, and a landing or foreword URL that names none. The other four hold the opposite: a consistent manifest reports nothing, and the three pages the book carries without a selector naming them --- a chaptered part's landing, a foreword, and the book page itself --- are never reported. Dropping any one emission site from `bookCoverage()` fails most of the twelve at once, and the probe named after that site says which. -Exits 1 on any failed probe, 2 if it cannot run. +Exit codes: **0** every probe passed, **1** a probe failed, **2** the gate could not run: a refused command line, or a crash. ### check_symbol_index.mjs {: #check-symbol-index } @@ -534,7 +575,7 @@ Verifies the [symbol index](Building#the-symbol-index) still places each kind of A build that indexes the reference cleanly says nothing about the rules that did not fire on it, so each rule is asserted against the case that made it necessary. The `.twin` scanner's: a `Type` whose `Sub`s have bodies, an `Interface` line inside a `CoClass`, `[Hidden]` on a module whose members are global, a `$` name escaped in brackets. The derivation's: a member on a page of its own and under a heading, an inherited member found on its declaring type's page, a page filed under one module and declared in another, a `$` form, a `## Properties` heading on a type that has a `Properties` property, and the ellipsis the typographer puts in a Core page's heading. And the guard's: a lost anchor fails and is named, and CI never writes the list. -Exits 1 on any failed probe, 2 if it cannot run. +Exit codes: **0** every probe passed, **1** a probe failed, **2** the gate could not run: a refused command line, or a crash. ### check_twin_parsers.mjs {: #check-twin-parsers } @@ -545,7 +586,7 @@ Verifies the scanners that read twinBASIC source and the attribute reference sti The modifier words that may precede a declaration keyword are one list, in `scripts/lib/twin-declarations.mjs`, and a word missing from it makes the keyword after it invisible. So each word is run through all three scanners that use the list: the attribute census's `declarationKind`, `scripts/lib/twin-api.mjs`'s `parseTwin` and `scripts/lib/tb-fences.mjs`'s `classify`. The census's declaration kinds are asserted too, including an inline block comment before the keyword and a `Const` kept apart from a variable, and so are the targets `parseTargets` in `scripts/lib/attributes-doc.mjs` reads from an `Applicable to:` line, including the phrases that must be matched before the line is split on commas and "and". -Exits 1 on any failed probe, 2 if it cannot run. +Exit codes: **0** every probe passed, **1** a probe failed, **2** the gate could not run: a refused command line, or a crash. ### check_cli.mjs {: #check-cli } @@ -558,7 +599,7 @@ The module's probes cover what `parseCli` returns and refuses, with a comparison The recorded cases are invocations that stop while the tool reads its command line, or at its first check of the project, folder, file or install the command line names, each with its exit code and what it prints on each stream: the tool's own words for the error exactly, a crash's only by the line that names the problem, and the opening of a usage text printed after it. Each case runs the tool as a child process, in an empty folder of its own and with `TB_IDE` and `PUPPETEER_EXECUTABLE_PATH` naming files that do not exist, so a case that gets past the command line fails on a different message rather than starting a twinBASIC IDE or a browser. A case belongs here only if the tool stops before doing any work. -Exits 1 on any failed probe or case, 2 if it cannot run. +Exit codes: **0** every probe and recorded case passed, **1** a probe or a recorded case failed, **2** the gate could not run: a refused command line, or a crash. ### check_pdf_shims_equiv.mjs {: #check-pdf-shims-equiv } @@ -569,7 +610,7 @@ Verifies that the book's [pdf-lib patches](Fixes/PDFLib) write what pdf-lib itse The document is written by the gate, without pdf-lib, so the forms the shims' parsers branch on are known to be in it: names with `#` escapes, numbers in every lexical form, a classic cross-reference table, and an incremental update with an object stream and a cross-reference stream. The change mirrors `render-book.mjs`'s and adds what reaches the rest of the shims: text drawn on a page that has just been given a new key, which moves the page's entries in `fast-dict-onebuf`'s buffer and must keep the page's two flags with them, a page inserted and one removed, objects parsed early and edited late, and a call of each patched method the book does not make, its result written into the document so that the comparison checks it. The created document reaches the factories that build a page tree and a catalog. Each member of pdf-lib that a shim puts a function into is checked against `PATCHES`, a list in the gate. A listed member that is not patched fails it, and so does a patched member that is not listed: a patch applied to a copy of a class leaves pdf-lib's own member as it was. Each listed member's function must run, unless the list marks the member as one neither document reaches and says why, and a marked member that runs fails the gate as well, so the marks stay true. A shim none of whose functions runs is reported whole, since the documents then no longer test it, or the book does not need it. On a difference, that document's shimmed side runs again with each shim alone and with each left out, and the report names the shims that make it. -Exits 1 on a difference, a shim or listed member that did not run, or a patched member that is not as listed, 2 if it cannot run. +Exit codes: **0** the shims write what stock pdf-lib writes, and every shim and patched member is reached and as listed; **1** a pair of files differs, a shim or patched member is no longer reached, or a patched member is not as listed; **2** the check could not run: a refused command line, a failure of the check itself, or a crash. ### check_impexp_parity.mjs {: #check-impexp-parity } @@ -580,14 +621,16 @@ Verifies that the two editions of the [impexp tool](#impexp), `scripts/impexp.mj Without Python 3.6 or later on the `PATH` (it tries `python3`, then `python`, then `py -3` on Windows), the gate prints `SKIPPED` and exits 0, so `test.bat` passes on a machine without Python. When `CI` is `true`, as GitHub sets it, the same case fails instead: CI must compare the two. -Exits 1 on a difference or a failed built-in test, 2 if it cannot run or finds no Python in CI. +Exit codes: **0** the two editions agree, or the check was skipped because no Python was found; **1** the editions differ, or a built-in test failed; **2** the check could not run: a refused command line, no Python when `CI=true`, or a crash. ### check_axe_patch_equiv.mjs {: #check-axe-patch-equiv } node scripts/check_axe_patch_equiv.mjs [--patch NAME] -Value-equivalence check for the vendored axe source patches. Builds the same colours under the stock and patched bundles and compares every derived value `color-contrast` consumes. This is the companion to the [fingerprint gate](#check-a11y-fingerprint), and both are needed: the fingerprint gate compares `incomplete` as a rule-id *set*, so a colour error that shifted contrast ratios without flipping any pass/fail classification would sail straight through it. Run it before adopting a new `SOURCE_PATCHES` entry and after **every** axe-core upgrade --- the patches are pinned to the bundle's current text, and an upgrade needs this gate *and* the fingerprint gate, never one of the two. See [Upgrading axe-core](#upgrading-axe-core) for the sequence. Exits 0 equivalent, 1 a value differs, 2 harness error. +Value-equivalence check for the vendored axe source patches. Builds the same colours under the stock and patched bundles and compares every derived value `color-contrast` consumes. This is the companion to the [fingerprint gate](#check-a11y-fingerprint), and both are needed: the fingerprint gate compares `incomplete` as a rule-id *set*, so a colour error that shifted contrast ratios without flipping any pass/fail classification would sail straight through it. Run it before adopting a new `SOURCE_PATCHES` entry and after **every** axe-core upgrade --- the patches are pinned to the bundle's current text, and an upgrade needs this gate *and* the fingerprint gate, never one of the two. See [Upgrading axe-core](#upgrading-axe-core) for the sequence. + +Exit codes: **0** the patched bundle gives the same colour values as stock axe, **1** at least one colour value differs, **2** the check could not run: a refused command line, or a crash. ### check_a11y_fingerprint.mjs {: #check-a11y-fingerprint } @@ -602,6 +645,8 @@ The gate for any change to *what the scan runs*. axe is the site's correctness o Two limits worth knowing. It compares a candidate against a baseline produced by that same scheme's element set, so it **cannot** detect a change that stops auditing elements entirely --- anything touching viewport, visibility or request blocking has to be argued from source instead. And it compares *which* findings axe produces, never their shape, so a scheme that passes every audit can still crash the reporter. Necessary, not sufficient. Both `--baseline` and `--candidate` default to `production`, so a bare run is already that A/A control --- run it after touching the matrix. Each must name a scheme that `--list` prints, and `--patches` a list of the patches it prints. +Exit codes: **0** every fingerprint is identical, or `--list` printed the schemes; **1** at least one fingerprint differs; **2** the check could not run: a refused command line, or a crash. + #### Upgrading axe-core {: #upgrading-axe-core } @@ -627,6 +672,8 @@ A third failure mode needs no gate at all: each substitution inside a patch asse The full-site accessibility sweep: every page, both themes, both viewports --- 3,476 audits, roughly 20 minutes. The thirteen-page sample exists because this is too slow for a commit gate, but the sample can only report on constructs it carries, and when the sample was six hand-picked pages this sweep found **six violation classes on 54 pages**, every one in a construct the sample could not see. Run it after any change that moves type metrics or page structure, and when adding a construct family to [`pick_a11y_sample.mjs`](#pick-a11y-sample). Note it audits every page with disclosures **closed** only; the open-state coverage is the sample scan's `STATE_AUDITS`. +Exit codes: **0** no accessibility violation was found, **1** the sweep found at least one violation, **2** a refused command line (a bad `--theme` or `--viewport` included), or a crash. + ### build_fonts.py {: #build-fonts } @@ -643,6 +690,8 @@ Regenerates the subset webfonts under `docs/assets/fonts/` from pinned upstream Measures Inter's advance widths in a browser and writes `builder/inter-metrics.json`, the table `builder/dot-metrics.mjs` installs into Graphviz before any layout runs. The widths are measured from the committed `.woff2` files rather than read out of the font binary, because the browser's shaped advance is the number the layout has to match. Development tooling; the JSON is committed and the build never runs the generator. Run it after [`build_fonts.py`](#build-fonts) touches Inter --- forgetting is not silent, but it surfaces as [`check_dot_fit.mjs`](#check-dot-fit) failing rather than as anything naming the metrics. It measures Inter by name, so giving the diagrams a different face means editing this script, not only rerunning it; see [Changing a typeface](Builder#changing-a-typeface). +Exit codes: **0** the table was written or is unchanged (with `--check`, it is current); **1** with `--check`, the table is stale; **2** a refused command line, a browser that would not start, or a crash. + ### build_package_api.mjs {: #build-package-api } @@ -651,7 +700,9 @@ Measures Inter's advance widths in a browser and writes `builder/inter-metrics.j Writes `builder/package-api.json`: every type the packages of a twinBASIC install declare, public or not, and the public members of each with their kinds. The [symbol index](Building#the-symbol-index) takes its entries from the pages and this file annotates them --- the kind of a member documented on a page of its own, an enumeration's values, the interface a CoClass's members are declared on --- and says which public symbols no page documents. Development tooling like [`build_dot_metrics.mjs`](#build-dot-metrics): the JSON is committed and the build never runs the generator, because running it needs a twinBASIC install, so it is Windows-only in the way [`census_attributes.mjs`](#census-attributes) is. Run it when the reference is re-indexed against a newer build, and commit the result with the pages. -It shares [`census_attributes.mjs`](#census-attributes)'s export and cache, and takes the same `--ide`, `--exported`, `--cache` and `--refresh` flags; `--out` writes elsewhere. Packages are keyed by the name code uses for them --- the project name, which is not always the folder's: TwinBasicAssertions is `Assert`, and the three CEF builds are one `cefPackage`, whose APIs the tool checks are identical. Exits 0 when written or up to date, 1 when `--check` finds the file stale, and 2 when the install or an export cannot be read. +It shares [`census_attributes.mjs`](#census-attributes)'s export and cache, and takes the same `--ide`, `--exported`, `--cache` and `--refresh` flags; `--out` writes elsewhere. Packages are keyed by the name code uses for them --- the project name, which is not always the folder's: TwinBasicAssertions is `Assert`, and the three CEF builds are one `cefPackage`, whose APIs the tool checks are identical. + +Exit codes: **0** the file was written (with `--check`, it is up to date); **1** with `--check`, the file is stale; **2** a refused command line, no install, an export that failed, packages that declare different APIs under one name, or a crash. ### convert_em_dash_separators.mjs {: #convert-em-dash-separators } @@ -659,7 +710,9 @@ It shares [`census_attributes.mjs`](#census-attributes)'s export and cache, and node scripts/convert_em_dash_separators.mjs # rewrite in place node scripts/convert_em_dash_separators.mjs --check # report, change nothing -Normalises literal en-dash / em-dash characters in markdown source under `docs/` to the ASCII source forms markdown-it's typographer converts at build time (`--` for en-dash, `---` for em-dash). The site forbids literal `–` / `—` in source --- this is the canonical fixer if any slip back in. Skips what the site's parser reads as code --- fences, indented code blocks and HTML blocks, found through `lib/markdown.mjs` --- and inline code spans, and preserves each file's existing line endings. Its probes run in [`check_code_regions.mjs`](#check-code-regions). `--check` reports what it would change and exits non-zero without writing, so it can serve as a gate. +Normalises literal en-dash / em-dash characters in markdown source under `docs/` to the ASCII source forms markdown-it's typographer converts at build time (`--` for en-dash, `---` for em-dash). The site forbids literal `–` / `—` in source --- this is the canonical fixer if any slip back in. Skips what the site's parser reads as code --- fences, indented code blocks and HTML blocks, found through `lib/markdown.mjs` --- and inline code spans, and preserves each file's existing line endings. Its probes run in [`check_code_regions.mjs`](#check-code-regions). `--check` reports what it would change without writing, so it can serve as a gate. + +Exit codes: **0** the dashes were converted (with `--check`, there were none); **1** with `--check`, a file holds a literal dash; **2** a refused command line, or a crash. ### survey_tooling.mjs {: #survey-tooling } @@ -670,7 +723,9 @@ Normalises literal en-dash / em-dash characters in markdown source under `docs/` Measures the repository's own tooling for repetition and structure: code duplicated between files, found token by token so that two copies differing only in names still match; top-level functions defined under one name in several files; how the command-line tools read their arguments; packages imported without being declared in `package.json`; and the import graph --- the imports that cross from one directory to another, the files nothing imports, and the most imported modules. `builder/PLAN-TOOLING-REVIEW.md` records its summary at the commit the tooling review started from, and the review's last phase runs it again to compare. -It is not a gate, and nothing runs it: take a measurement before and after a piece of refactoring. It reads only the files git tracks, so a scratch file never changes a number. `--root` measures another checkout, such as a worktree at an older commit that does not contain the script. `perf/` is measured, but it is counted separately in the summary and left out of the listings unless `--include-perf` is given. Exits 0, or 2 on a bad argument or a folder that is not a git checkout. +It is not a gate, and nothing runs it: take a measurement before and after a piece of refactoring. It reads only the files git tracks, so a scratch file never changes a number. `--root` measures another checkout, such as a worktree at an older commit that does not contain the script. `perf/` is measured, but it is counted separately in the summary and left out of the listings unless `--include-perf` is given. + +Exit codes: **0** the survey was printed, **2** a refused command line, a folder that is not a git checkout, or a crash. ### compare_trees.mjs {: #compare-trees } @@ -684,7 +739,9 @@ Builds the site twice and compares the online, offline and PDF trees file by fil Both builds run from git worktrees under `.compare-trees/` at the repository root, which is gitignored, and neither touches the index or the working tree. Building the working tree in place would not do: under `core.autocrlf` a fresh checkout writes CRLF where files a tool has rewritten hold LF, and every file the build copies verbatim would then differ. Both builds run `tbdocs --no-fetch-assets` with `CI=1`, so the committed baselines are read and never written. -Three regions differ between any two builds and are replaced before the comparison: the build's own timings in `assets/images/gantt.svg`, the same chart inlined into the [Build Info](BuildInfo) page, and the PDF title page's build line, which holds the build date and the commit. Everything else must match. A run takes about ten seconds on the development box. It is not a gate, and nothing runs it. Exits 0 when the trees match, 1 when they differ, and 2 when the tool failed; a failed run leaves `.compare-trees/` for inspection, and the next run removes it. +Three regions differ between any two builds and are replaced before the comparison: the build's own timings in `assets/images/gantt.svg`, the same chart inlined into the [Build Info](BuildInfo) page, and the PDF title page's build line, which holds the build date and the commit. Everything else must match. A run takes about ten seconds on the development box. It is not a gate, and nothing runs it. A failed run leaves `.compare-trees/` for inspection, and the next run removes it. + +Exit codes: **0** the trees match; **1** the trees differ; **2** a refused command line, a git command or a build that failed to produce its tree, or a crash. ### tbbuild.mjs {: #tbbuild } @@ -707,8 +764,6 @@ twinBASIC has no command-line build. The compiler executable's whole surface is | `--keep` | Leave the IDE running afterwards. The IDE's registry entries for the project are then left as they are, because the IDE is still writing them. | | `--show` / `--hide` | Put the IDE on your own desktop where you can watch it, or on a private one where it cannot take focus. Hidden is the default unless `TBBUILD_SHOW` is set to something other than `0`, `false` or `no`; the two flags override that for one invocation. | -Exit codes: **0** clean, **1** the project has errors, **2** the harness failed, **3** the compile never settled, **4** the project crashes the compiler. - **It runs the IDE on a private Windows desktop, and that is not decoration.** The IDE calls `HostForceFocus()` from its own `window.onload`, so it takes the keyboard whatever window style it starts with --- `start /min` was tried and the window still came to the front. A process on another desktop has no foreground to take, and the compile does not care whether anything is on screen. Hidden by default has one real cost. A wedged IDE on a private desktop is invisible to the person debugging it, and the only way to see anything is to run it again visible. Export `TBBUILD_SHOW=1` for a session you are working through interactively, and leave it unset for unattended runs. **One IDE handles one project.** Loading a second project into a running IDE wedges it, so a fresh IDE per project is the design rather than a convenience. It costs roughly 8 to 11 seconds each on a development box and is flat in project size, because what is being paid for is IDE startup and not compilation. Concurrency is the way to make a batch of probes fast: distinct `--port` values give distinct DevTools ports, user-data folders and desktops, so instances do not collide. Keep a question that might crash the compiler in a project of its own, so the answer is attributable and one bad probe cannot cost the rest of the batch its run. @@ -719,6 +774,8 @@ Exit codes: **0** clean, **1** the project has errors, **2** the harness failed, Four files under `scripts/lib/` belong to it and are never run directly. `tb-ide.mjs` holds the mechanics `tbbuild.mjs` and `tbrun.mjs` share: starting the IDE, attaching to it, waiting for the compile, and reading the diagnostics and the DEBUG CONSOLE. `tb-registry.mjs` records and restores the registry entries described above, through .NET's registry API by way of PowerShell, because `reg.exe` mangles any path holding a character outside the console code page; [`check_tb_registry.mjs`](#check-tb-registry) is its self-test. `tb-cdp.mjs` is a minimal CDP client over Node's global `WebSocket`, raw rather than puppeteer because a pending `alert()` blocks the renderer and puppeteer's `connect()` handshake talks to the renderer --- so it hangs on precisely the state you need to recover from. Every call it makes has a time limit, so a blocked page ends a run with a message rather than holding it forever. `tb-launch.ps1` holds the Win32 calls Node cannot make without a native FFI addon: `CreateDesktop` and `CreateProcess` with `STARTUPINFO.lpDesktop` for the private desktop, and the job object described above. It is the only PowerShell file under `scripts/`, and it is not executed as a file: `tb-ide.mjs` reads the text and passes it through `-EncodedCommand`, so the execution policy never comes into it and nobody has to be told to bypass one. +Exit codes: **0** the project compiled without errors; **1** the project has errors; **2** a refused command line (a path that is not a `.twinproj` included), no IDE, an IDE that did not start or expose a debug port, or a crash; **3** the compile never settled: the IDE did not report the project open, or its diagnostics did not match its status bar; **4** the project crashes the compiler. + ### tbrun.mjs {: #tbrun } @@ -755,14 +812,14 @@ build log, and the linker writes there *after* the build, so a probe that does n first comes back interleaved with `[LINKER]` lines. The script warns when a probe omits it, and warns again when there is no `[RunAfterBuild]` at all. -**A build that fails after a clean compile exits 2**, with the IDE's build log printed as the -reason. The probe never runs then, so the console still holds that log --- `[BUILD] failed`, +**A build that fails after a clean compile is a failed run**, with the IDE's build log printed +as the reason. The probe never runs then, so the console still holds that log --- `[BUILD] failed`, often after `[TYPELIB] failed to finalize typelibrary` --- and `tbrun` used to return it as the -probe's output, with exit 0. Run it again: both failures seen so far passed on a second run. -A `[RunAfterBuild]` Sub that fails code generation exits 2 the same way: the build succeeds, +probe's output, as a success. Run it again: both failures seen so far passed on a second run. +A `[RunAfterBuild]` Sub that fails code generation is a failed run the same way: the build succeeds, the console adds `[LINKER] compilation (codegen) error detected in '<module>.<procedure>'`, and nothing in the Sub runs, `Debug.Cls` included. A procedure the probe *calls* that fails -code generation exits 2 as well. Its error line is written before the probe's first +code generation is a failed run as well. Its error line is written before the probe's first statement, so the probe's `Debug.Cls` erases it, and the probe stops at the call. `tbrun` keeps what each clear erases, so it names that line and prints the output up to the call. @@ -798,11 +855,6 @@ comes back as `A&`. | `--reap-images <a,b>` | Replace the harvested image list. Default is the Office suite. | | `--show` / `--hide` | As for [`tbbuild.mjs`](#tbbuild): your own desktop or a private one, with `TBBUILD_SHOW` setting the default. | -Exit codes: **0** captured output, **1** the project has compile errors (the diagnostics are -printed), **2** the harness failed or the build did after a clean compile, **3** no output: -nothing reached the console before the timeout, or the probe ran and printed nothing after its -last `Debug.Cls`. - **A probe that activates a COM server can leak one per run.** `CreateObject("Excel.Application")` is activated by DCOM, so the `EXCEL.EXE` that appears is a child of `svchost.exe` rather than of anything the harness started --- no tree kill reaches it. Each activation is its own @@ -833,6 +885,8 @@ writes. **A probe builds for the target `--arch` names**, whatever the IDE remem the option, a kept IDE switched to `win64` made every later run on the same port build 64-bit, and nothing said so. +Exit codes: **0** the probe ran and its output was captured; **1** the project has compile errors (the diagnostics are printed); **2** a refused command line (a source folder that is missing or has no `Settings` file included), no IDE or compiler, an IDE that did not start, a compile that never settled, a build that failed after a clean compile, a probe that never ran or stopped at a procedure that failed code generation, or a crash; **3** no output: the console held none before the timeout, or the probe printed none after its last `Debug.Cls`; **4** the compiler crashed, or restarted twice, while compiling the project. + ### addin_test.mjs {: #addin-test } @@ -873,9 +927,6 @@ together. | `--ide <path>` | The `twinBASIC.exe` to copy, found as for [`tbbuild.mjs`](#tbbuild). | | `--show` / `--hide` | As for [`tbbuild.mjs`](#tbbuild). | -Exit codes: **0** every lane passed and the registry is as it was found, **1** a lane -failed, **2** the harness failed or could not put the registry back. - **It leaves the registry as it found it, and checks.** It puts back the IDE's own entries as `tbbuild` does, and also the settings the add-ins under test save with `SaveSetting`. Those are stored under `HKCU\Software\VB and VBA Program Settings\<name>`, which any installed copy @@ -899,6 +950,8 @@ it takes that folder from the IDE, which builds its path from the `APPDATA` envi variable. Every IDE a lane starts has an `APPDATA` inside the lane's work folder, and a lane fails if its IDE's add-in folder turns out to be anywhere else. +Exit codes: **0** every lane passed, and the registry is as it was found; **1** a lane failed, or the run was interrupted; **2** the harness could not run: a refused command line, no IDE, no matching lane, a registry it could not record, or a crash; **3** the registry or a work folder was not put back (see the lines above), which wins over a 1 because the registry is what to repair. + ### check_tb_registry.mjs {: #check-tb-registry } @@ -921,9 +974,9 @@ the module refuses to sweep outside the temp folder or restore a key near the ro registry. It deletes the scratch key when it ends. It is not a gate and is not in `test.bat`, because it needs Windows and a real registry and -the CI runners have neither. Run it by hand after changing `tb-registry.mjs`. Exit code -**0** when every check holds, **1** when one does not, **2** when something else stops it, -such as PowerShell failing. +the CI runners have neither. Run it by hand after changing `tb-registry.mjs`. + +Exit codes: **0** every assertion held, **1** an assertion failed, **2** the test could not run to its end: a refused command line, PowerShell failing, or a crash. ### check_examples.mjs {: #check-examples } @@ -967,7 +1020,7 @@ reports. |---|---| | `--only <regex>` | Restrict to pages whose path matches. The path is page-relative, as in `^Reference/Core`. | | `--census` | Classify every `tb` fence and print the table --- how many are whole files, procedures, statement runs, and how many are fragments no wrapper can rescue --- then the fences marked `inert` by reason, and finally the **undecided** ones: classifiable, unmarked, and not inert. That last number is the backlog; the inert count is not. No compiler, no IDE, well under a second. | -| `--propose` | Compile the unmarked samples too, and list the ones that would pass. A survey, so it exits 0 whatever it finds. It ends with the same grouping `--report` prints. | +| `--propose` | Compile the unmarked samples too, and list the ones that would pass. A survey: an unmarked sample that fails does not fail the run, though a marked one still does. It ends with the same grouping `--report` prints. | | `--apply` | With `--propose`, add the marker to the fences that passed. It only ever adds the bare flag, only to a fence that compiled in that very run, and never to one that already carries markup --- so a re-run is a no-op. Read the diff. | | `--report <file>` | Group the findings of a survey saved with `--propose --json`: by diagnostic, by section, by the name that did not resolve, by wrapper, and by page. No compiler --- the survey holds every page and line it names, so the slow run happens once and the grouping is what gets iterated on. | | `--jobs <n>` | Concurrent IDE lanes. Default 4. Each lane has its own port, its own workspace and its own private desktop. | @@ -978,8 +1031,6 @@ reports. | `--verbose` | Report warnings as well as errors. Only errors ever fail the run. | | `--json` | One object on stdout; every report line moves to stderr. | -Exit codes: **0** clean, **1** a sample does not compile, **2** the harness failed. - **Templates live in `test/example-projects/`**, one directory per template, each an exported project tree --- a `Settings` file and a `Sources/` folder. `console` is the default; `packages` references every package the IDE ships and is what a page under @@ -1031,6 +1082,8 @@ a new slot goes. `tb-install.mjs` finds the IDE and the compiler beside it, and with the two IDE-driving tools so the three cannot come to disagree about where an install is. +Exit codes: **0** every marked sample compiles, or none is marked (`--report` always, and `--propose` when it found only unmarked samples that fail, which is advisory); **1** a marked sample does not compile, a marker is misused, a template does not compile, or the compiler crashed on a project (the report names each); **2** the harness could not run: a refused command line, a failed self-test probe, no IDE or compiler, an unreadable `--report` file, a work folder it could not clear, or a crash. + ### gen_attribute_probes.mjs {: #gen-attribute-probes } @@ -1052,7 +1105,9 @@ It also writes a key naming the `Attributes.md` line each probe came from, besid twinBASIC_win32.exe import AttributeProbes.twinproj <out_dir> --overwrite -**That command's exit code is `0` after every failure it reports**, so a script that packs a tree and then builds it will happily compile the previous `.twinproj`. The one failure it does not report --- a tree holding an embedded package --- exits `999`. Test the last line of its output for `... DONE` instead; [Import/Export Tool](../../Features/Packages/Import-Export-Tool#checking-the-result) has the caveat in full and a batch-file form of the test. The standalone [`impexp.mjs`](#impexp) takes the same command, and its exit code does say whether it worked. Re-run the generator after editing `Attributes.md`. Exits 0, or 2 with usage when given no output directory. +**That command's exit code is `0` after every failure it reports**, so a script that packs a tree and then builds it will happily compile the previous `.twinproj`. The one failure it does not report --- a tree holding an embedded package --- exits `999`. Test the last line of its output for `... DONE` instead; [Import/Export Tool](../../Features/Packages/Import-Export-Tool#checking-the-result) has the caveat in full and a batch-file form of the test. The standalone [`impexp.mjs`](#impexp) takes the same command, and its exit code does say whether it worked. Re-run the generator after editing `Attributes.md`. + +Exit codes: **0** the probe project and the key were written, **2** a refused command line (no output directory included), or a crash. ### census_attributes.mjs {: #census-attributes } @@ -1081,7 +1136,9 @@ Grouping is by enclosing construct *and* declaration keyword, because the keywor | `--json` | Emit JSON instead of Markdown. | | `--out <file>` | Write to a file instead of standard output. | -The report ends with what the scanner could not resolve, and **that section is expected to be empty**. A census that quietly buckets its own confusion publishes a wrong number with nothing to notice it by, so an unresolved site is reported as a scanner bug rather than absorbed. Reaching zero took handling several things this corpus does that a simpler sweep gets wrong: attributes spanning lines (`[Description("..." & vbCrLf & _` accounts for 3.8% of all attribute lines), comma-separated lists, arguments containing commas, escaped identifiers that look exactly like attributes (`[_HiddenModule].Foo`, and Enum members genuinely named `[A4 Portrait]`), comments in four different positions, and block-tracking traps such as a UDT field called `Type As Long` or a module named `[_HiddenModule]`. Exits 0 once a report is produced, or 2 if no install or source tree can be found. +The report ends with what the scanner could not resolve, and **that section is expected to be empty**. A census that quietly buckets its own confusion publishes a wrong number with nothing to notice it by, so an unresolved site is reported as a scanner bug rather than absorbed. Reaching zero took handling several things this corpus does that a simpler sweep gets wrong: attributes spanning lines (`[Description("..." & vbCrLf & _` accounts for 3.8% of all attribute lines), comma-separated lists, arguments containing commas, escaped identifiers that look exactly like attributes (`[_HiddenModule].Foo`, and Enum members genuinely named `[A4 Portrait]`), comments in four different positions, and block-tracking traps such as a UDT field called `Type As Long` or a module named `[_HiddenModule]`. + +Exit codes: **0** the report was produced, **2** a refused command line, no install, an install with no compiler or no package project, or a crash (a package that fails to export is left out of the census). ### impexp.mjs and impexp.py {: #impexp } @@ -1091,10 +1148,12 @@ The report ends with what the scanner could not resolve, and **that section is e node scripts/impexp.mjs settings|licence|changelog|readme <project> node scripts/impexp.mjs --self-test -Standalone `.twinproj` / `.twinpack` unpacker and packer, with the compiler executable's own command line: the same six commands, the project file first, and `--overwrite` required to replace anything. `scripts/impexp.py` is the same tool, run as `python scripts/impexp.py ...`; the two editions print the same output and write byte-identical project files, which [`check_impexp_parity.mjs`](#check-impexp-parity) checks. Neither has dependencies; the Node edition needs Node 18+, the Python edition Python 3.6+. The exit code says what happened --- `0` done, `3` refused to overwrite, `6` done with a warning, and four more --- so a caller need not read the output; [Import/Export Tool](../../Features/Packages/Import-Export-Tool#checking-the-result) has the table. `--self-test` needs nothing but the script, and adds a round trip of `indexer/sample.twinpack` when run from this repository. +Standalone `.twinproj` / `.twinpack` unpacker and packer, with the compiler executable's own command line: the same six commands, the project file first, and `--overwrite` required to replace anything. `scripts/impexp.py` is the same tool, run as `python scripts/impexp.py ...`; the two editions print the same output and write byte-identical project files, which [`check_impexp_parity.mjs`](#check-impexp-parity) checks. Neither has dependencies; the Node edition needs Node 18+, the Python edition Python 3.6+. The exit code says what happened, so a caller need not read the output. `--self-test` needs nothing but the script, and adds a round trip of `indexer/sample.twinpack` when run from this repository. **Neither is build tooling.** They are published downloads: `_config.yml`'s `bundle_extra` copies both into `Features/Packages/downloads/`, and [Import/Export Tool](../../Features/Packages/Import-Export-Tool) offers them to readers as the two editions of one tool. That is why `impexp.py` is one of only two `.py` files in a repository whose tooling is otherwise all Node --- porting it would delete a deliberate offering rather than tidy anything up. The `bundle_extra` exemption is by exact path, so moving either file breaks the download; see [`check_publish_policy.mjs`](#check-publish-policy). +Exit codes: the table in [Import/Export Tool](../../Features/Packages/Import-Export-Tool#checking-the-result) gives every code. This tool keeps its own codes, which the two editions share and which are not those of the other tools here. + ### render-book.mjs {: #bookrender-bookmjs } @@ -1110,6 +1169,8 @@ Key options used by `book.bat`: | `--outline-tags h1,h2,h3,h4` | Heading levels to include in the PDF outline / bookmarks. | | `--additional-script <path>` | Path to a script injected before paged.js runs. `book.bat` passes `perf\detach-pages.js`, which hides each finalised page from Chromium's layout tree and restores them all before `page.pdf()` runs, dropping render time from ~104s to ~51s on a 1,638-page book by sidestepping paged.js's quadratic overflow walker. | +Exit codes: **0** the PDF was written; **2** a refused command line, an input or script that does not exist, a render that failed, or a crash. There is no 1. + ## Configuration files The build pipeline also reads a handful of declarative files. They are not executable but the build's behaviour depends on them. diff --git a/docs/Documentation/Wisdom.md b/docs/Documentation/Wisdom.md index 0d062607..6a8cad94 100644 --- a/docs/Documentation/Wisdom.md +++ b/docs/Documentation/Wisdom.md @@ -97,6 +97,8 @@ The extract step automatically partitions large thread sets into batches of 200, All commands run from the repository root via `node wisdom/wisdom.mjs <command> [options]`. +Exit codes: **0** the command finished, or a dry run did; **2** a refused command line, input that an earlier command should have written (run that command first), or a crash; **3** the request cap was reached (re-run to continue). + ### Phase 1 --- Export Fetches messages from Discord channels and forum threads. @@ -114,7 +116,7 @@ Outputs raw JSON under `wisdom/data/raw/`. Supports incremental runs --- a manif | `--dry-run` | Discover channels/threads; do not fetch messages | | `--force` | Ignore manifest; re-fetch all history | -When the session request cap is reached, the tool exits with code 2 --- re-run to continue where it left off. A command line the tool cannot use --- an unknown command or flag, a flag without its value or with an empty one, an unexpected argument --- is refused on standard error and also exits with code 2, so read the message to tell the two apart. So is a value it cannot use: a `--concurrency` or `--cap` that is not a whole number of at least 1, a `--rate-limit` that is not greater than 0, a `--since` that is not an ISO 8601 date no earlier than 2015-01-01, a `--min-confidence` other than `high`, `medium` or `low`, and two of `extract`'s `--since`, `--all` and `--force` given together (`--merge` reads none of the three). +When the session request cap is reached, the tool stops --- re-run to continue where it left off. A command line the tool cannot use --- an unknown command or flag, a flag without its value or with an empty one, an unexpected argument --- is refused on standard error. So is a value it cannot use: a `--concurrency` or `--cap` that is not a whole number of at least 1, a `--rate-limit` that is not greater than 0, a `--since` that is not an ISO 8601 date no earlier than 2015-01-01, a `--min-confidence` other than `high`, `medium` or `low`, and two of `extract`'s `--since`, `--all` and `--force` given together (`--merge` reads none of the three). ### Phase 2 --- Process @@ -213,7 +215,7 @@ Also defines `runConcurrent(items, concurrency, fn)` --- a simple worker-pool: s - **Auth detection.** Probes `/users/@me` with `Bot <token>` first; on failure, retries with the bare token (user-token auth). Sets `tier` to `'bot'` or `'user'`, which selects the rate-limit profile from config. - **Rate limiter.** Enforces `requests_per_second` via a minimum inter-request delay. User-tier adds +/-20% jitter. Also reads Discord's `X-RateLimit-Remaining` / `X-RateLimit-Reset-After` headers and sleeps when a route bucket is exhausted. -- **Session cap.** Throws `CapReachedError` when `queryCount` reaches the configured cap. The caller catches this and exits with code 2; re-running resumes via the export manifest. +- **Session cap.** Throws `CapReachedError` when `queryCount` reaches the configured cap. The caller catches this and exits with code 3; re-running resumes via the export manifest. - **429 handling.** On HTTP 429, reads `retry_after` from the response body and recursively retries. - **Snowflake utilities.** `snowflakeToTimestamp()` and `timestampToSnowflake()` convert between Discord snowflake IDs and Unix-millis timestamps using BigInt arithmetic (shift by 22 bits + the Discord epoch). diff --git a/eval/README.md b/eval/README.md index fa550fa2..cb0d6139 100644 --- a/eval/README.md +++ b/eval/README.md @@ -36,6 +36,8 @@ node eval/search_quality.mjs --compare eval/search_baseline.json # measure a node eval/search_quality.mjs --failures 20 # what misses rank 1 ``` +`search_quality.mjs` exit codes: **0** the measurement ran, whatever it found; **2** a refused command line, a site with no search index (run `build.bat` first), or a crash. + ## Running a round ```sh @@ -47,11 +49,13 @@ node eval/run_case.mjs --corpus <corpus> --site <snapshot> --protocol repo \ ``` `build_corpus.mjs` empties `--dest` before it writes, so it refuses a `--dest` that is or -contains the repository root, the current folder or `--repo`, on stderr with exit 2. +contains the repository root, the current folder or `--repo`, on stderr. `run_case.mjs` likewise refuses a `--timeout` (minutes) that is not a number greater than 0 and at most 35791, and a `--protocol` other than `repo` or `site`. +`build_corpus.mjs` exit codes: **0** the corpus was built; **2** a refused command line (a `--dest` that is or contains the repository, the working folder or `--repo` included), or a crash. + Each case is **one goal** from [usecases.md](usecases.md), in a file of its own, and nothing else. Never tell the evaluator what the case is testing or that a hazard exists. `run_case.mjs` puts the evaluator-facing part of [protocol.md](protocol.md) in front of the @@ -61,11 +65,13 @@ out. The runner starts the evaluator as its own `claude -p` process, so the Claude Code CLI has to be installed and signed in (`claude auth status`). **Never run an evaluator as a subagent instead** --- [the next section](#why-an-evaluator-is-a-separate-process) says why. Run -`--smoke` first: it checks the evaluator's isolation in one short session and exits 1 if -any of it fails. Each case leaves the prompt it was given, its whole session as +`--smoke` first: it checks the evaluator's isolation in one short session and fails if +any of it does. Each case leaves the prompt it was given, its whole session as stream-json, its report and a `.meta.json` recording the model and the Claude Code version, and prints the session's digest --- see [Reading the results](#reading-the-results). +`run_case.mjs` exit codes: **0** the run finished (with `--smoke`, every check passed); **1** the run timed out, or `claude` exited with an error or wrote no report (with `--smoke`, a check failed); **2** a refused command line, a corpus or site file that is missing, a memory file in the corpus, `claude` not installed or not signed in, or a crash. + **Pin the Claude Code build when a round re-runs an earlier one.** `--claude <exe>` names it; without it the runner takes whatever `claude` is on `PATH`, which changes with every install. Round 10 found 2.1.212 there, where rounds 8 and 9 had run the desktop app's bundled 2.1.280, @@ -97,13 +103,17 @@ node eval/nav_hops.mjs '^/tB/Modules/ErrObject/Number$' # hops by link from docs node eval/nav_hops.mjs --from README.md '^/Documentation/Development/Tools$' ``` -Each of these refuses an unknown flag and a flag without its value on stderr and exits 2; +Each of these refuses an unknown flag and a flag without its value on stderr; `site_search.mjs` also refuses a `--n` that is not a whole number of at least 1 and search terms given with `--composition`, `nav_hops.mjs` a regular expression that does not compile, and `transcript.mjs` a second file. A search term, regex or file name that starts with a dash goes after `--`: `node eval/site_search.mjs -- "-1 as an error code"`. +- `site_search.mjs` exit codes: **0** the query ran, even with no results; **2** a refused command line, a site with no search index (run `build.bat` first), or a crash. +- `transcript.mjs` exit codes: **0** the digest was printed; **2** a refused command line, a session file that cannot be read, or a crash. +- `nav_hops.mjs` exit codes: **0** every target is reachable by links; **1** a target is not reachable by links from the start page; **2** a refused command line (no targets, a pattern Git Bash turned into a Windows path included), no start page, or a crash. + ## Why an evaluator is a separate process Rounds 1--7 ran each evaluator as a subagent of the session orchestrating the round. **A diff --git a/eval/build_corpus.mjs b/eval/build_corpus.mjs index ef12938a..1fd70ef7 100644 --- a/eval/build_corpus.mjs +++ b/eval/build_corpus.mjs @@ -15,10 +15,12 @@ import fs from "node:fs"; import path from "node:path"; -import { CliError, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { CliError, exitOnCrash, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { isOutputTree } from "../lib/markdown-files.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; +exitOnCrash(); + // --------------------------------------------------------------------------- // What stays readable is an ALLOWLIST, and that is the whole design. // @@ -236,8 +238,12 @@ if (opts.help || !opts.dest) { "Usage: node eval/build_corpus.mjs --dest <path> [--repo <path>] [--quiet] [-h, --help]\n\n" + "Mirrors the repository with every non-prose file replaced by an unreadable\n" + "stub, so a documentation evaluation cannot silently read the implementation.\n" + - "See eval/README.md.", - opts.help ? {} : { stream: "stderr", exitCode: 2 }, + "See eval/README.md.\n\n" + + "Exit codes:\n" + + " 0 the corpus was built\n" + + " 2 a refused command line (a --dest that is or contains the repository, the\n" + + " working folder or --repo included), or a crash", + opts.help ? {} :{ stream: "stderr", exitCode: 2 }, ); } build(opts); diff --git a/eval/nav_hops.mjs b/eval/nav_hops.mjs index 99f8085f..6844c9d8 100644 --- a/eval/nav_hops.mjs +++ b/eval/nav_hops.mjs @@ -43,7 +43,12 @@ const USAGE = "Usage: node eval/nav_hops.mjs [--from <page>] [--repo <root>] [-h, --help] <url-regex> [...]\n\n" + "Shortest path by links from the start page (default docs/index.md) to the first page\n" + "whose permalink matches each regex. A regex that starts with a dash goes after --.\n" + - "See eval/README.md."; + "See eval/README.md.\n\n" + + "Exit codes:\n" + + " 0 every target is reachable by links\n" + + " 1 a target is not reachable by links from the start page\n" + + " 2 a refused command line (no targets, a pattern Git Bash turned into a Windows\n" + + " path included), no start page, or a crash"; function parseArgs(argv) { const { values, positionals, patterns } = withUsageError(() => { diff --git a/eval/run_case.mjs b/eval/run_case.mjs index 72220eb3..46cb8750 100644 --- a/eval/run_case.mjs +++ b/eval/run_case.mjs @@ -17,8 +17,10 @@ // check instead of a case, on the site-entry protocol, and asserts what came // back. Run it once per round, before the cases. See eval/README.md. // -// Exit: 0 the run finished (--smoke: every check passed), 1 it did not -// (--smoke: a check failed), 2 it could not start. +// Exit: 0 the run finished (--smoke: every check passed), 1 it did not (timed +// out, claude failed; --smoke: a check failed), 2 it could not start (a refused +// command line, a missing corpus or site file, claude not installed or not +// signed in) or crashed. // // ---------------------------------------------------------------- why // @@ -263,7 +265,13 @@ const USAGE = " [--timeout <min>] [--prompt-only] [-h, --help]\n" + " node eval/run_case.mjs --smoke --corpus <dir> --site <snapshot> --out <prefix>\n\n" + "Runs one use-case evaluator as an isolated Claude Code process and audits its\n" + - "session. See eval/README.md."; + "session. See eval/README.md.\n\n" + + "Exit codes:\n" + + " 0 the run finished; with --smoke, every check passed\n" + + " 1 the run timed out, or claude exited with an error or wrote no report; with\n" + + " --smoke, a check failed\n" + + " 2 a refused command line, a corpus or site file that is missing, a memory file in\n" + + " the corpus, claude not installed or not signed in, or a crash"; async function main(argv) { const o = parseArgs(argv); @@ -313,7 +321,7 @@ async function main(argv) { if (s.result?.is_error && /authenticat/i.test(s.report)) { console.log("\nclaude is not signed in: run `claude auth login`, then run this again."); - return 1; + return 2; } if (run.timedOut) { console.log(`\ntimed out after ${o.timeout} min`); diff --git a/eval/search_quality.mjs b/eval/search_quality.mjs index 92e0af0c..3490a2ed 100644 --- a/eval/search_quality.mjs +++ b/eval/search_quality.mjs @@ -17,8 +17,8 @@ // node eval/search_quality.mjs --failures 20 # queries not at rank 1 // // Exit code is 0 whatever the measurement finds: this is a measuring tool, -// not a pass/fail check (scripts/ is for those). A command line it refuses -// exits 2, and a site with no search index 1. +// not a pass/fail check (scripts/ is for those). A command line it refuses, a +// site with no search index, and a crash exit 2. // // GROUND TRUTH // @@ -126,11 +126,13 @@ import fs from "node:fs"; import path from "node:path"; import zlib from "node:zlib"; import { performance } from "node:perf_hooks"; -import { numberOption, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { exitOnCrash, numberOption, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; import { load, buildIndex, search, KIND_WORDS } from "./site_search.mjs"; +exitOnCrash(); + // ---------------------------------------------------------------- arg parsing function parseArgs(argv) { @@ -706,7 +708,11 @@ function main() { if (opts.help) { printHelpAndExit( "Usage: node eval/search_quality.mjs [--site docs/_site] [--save file] " + - "[--compare file] [--worst N] [--sample N] [--failures N] [-h, --help]\n\nSee the header comment in this file." + "[--compare file] [--worst N] [--sample N] [--failures N] [-h, --help]\n\nSee the header comment in this file.\n\n" + + "Exit codes:\n" + + " 0 the measurement ran, whatever it found\n" + + " 2 a refused command line, a site with no search index (run build.bat first),\n" + + " or a crash" ); } diff --git a/eval/site_search.mjs b/eval/site_search.mjs index 9336590b..d9b51609 100644 --- a/eval/site_search.mjs +++ b/eval/site_search.mjs @@ -25,7 +25,7 @@ import { createRequire } from "node:module"; import { pathToFileURL } from "node:url"; import fs from "node:fs"; import path from "node:path"; -import { CliError, numberOption, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { CliError, exitOnCrash, numberOption, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; const require = createRequire(import.meta.url); @@ -323,7 +323,7 @@ export function load(site) { `missing ${path.relative(REPO_ROOT, p)}\n` + "Run build.bat (or `node builder/tbdocs.mjs --src docs`) first." ); - process.exit(1); + process.exit(2); } } const lunr = loadLunr(lunrPath); @@ -532,13 +532,18 @@ function composition(docs) { // Only run the CLI when this file is executed directly -- eval/search_quality.mjs // imports load()/buildIndex()/search() from here and must not trigger it. if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + exitOnCrash(); const opts = parseArgs(process.argv.slice(2)); if (opts.help || (!opts.composition && !opts.terms.length)) { printHelpAndExit( 'Usage: node eval/site_search.mjs "<query>" [--n <count>] [--site <path>] [-h, --help]\n' + " node eval/site_search.mjs --composition\n\n" + "Queries the built site's real lunr index with the real query logic.\n" + - "A term that starts with a dash goes after --. See eval/README.md.", + "A term that starts with a dash goes after --. See eval/README.md.\n\n" + + "Exit codes:\n" + + " 0 the query ran, even with no results\n" + + " 2 a refused command line, a site with no search index (run build.bat first),\n" + + " or a crash", opts.help ? {} : { stream: "stderr", exitCode: 2 }, ); } diff --git a/eval/transcript.mjs b/eval/transcript.mjs index 0b079d59..be6782fb 100644 --- a/eval/transcript.mjs +++ b/eval/transcript.mjs @@ -24,7 +24,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { exitOnCrash, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; /** Every event in a stream-json session, in order. */ export function readTranscript(file) { @@ -191,7 +191,10 @@ export function printDigest(s, { calls = false, report = false } = {}) { const USAGE = "Usage: node eval/transcript.mjs <case.jsonl> [--calls] [--report] [-h, --help]\n\n" + "Summarises an evaluator's session and audits the order of its channels.\n" + - "A file name that starts with a dash goes after --. See eval/README.md."; + "A file name that starts with a dash goes after --. See eval/README.md.\n\n" + + "Exit codes:\n" + + " 0 the digest was printed\n" + + " 2 a refused command line, a session file that cannot be read, or a crash"; function main(argv) { const { values, positionals } = withUsageError(() => parseCli(argv, { @@ -210,5 +213,6 @@ function main(argv) { } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + exitOnCrash(); main(process.argv.slice(2)); } diff --git a/lib/cli.mjs b/lib/cli.mjs index 3ca60b7c..2d62e1eb 100644 --- a/lib/cli.mjs +++ b/lib/cli.mjs @@ -5,7 +5,8 @@ // prints that error and exits with withUsageError(). numberOption(), // choiceOption(), regexOption(), urlOption() and dateOption() read a value // after the parse, refuseTogether() refuses options that exclude each other, -// and printHelpAndExit() prints a usage text. +// and printHelpAndExit() prints a usage text. exitOnCrash() makes a crash exit +// 2, not Node's 1. // // The parse is strict: an unknown option, a boolean given a value, a value flag // with no value, an empty value and a positional the tool does not take are all @@ -232,3 +233,16 @@ export function printHelpAndExit(text, { stream = "stdout", exitCode = 0, exit = streamOf(stream).write(line(text)); return exit(exitCode); } + +/** + * Installs the crash handler: an uncaught exception, or a rejection nothing + * awaits, prints the error and exits 2. A crash is the tool failing to do its + * job, not a finding, which is exit 1; Node's own exit for one is 1. A tool that + * runs at top level has no main().catch to do it, and this handler also catches + * a rejected top-level await. It installs when called, never on import, so a + * module that can be imported as well as run calls it only when it is the entry + * point. + */ +export function exitOnCrash() { + process.on("uncaughtException", (err) => { console.error(err); process.exit(2); }); +} diff --git a/scripts/addin_test.mjs b/scripts/addin_test.mjs index 07e684c9..4c5f155f 100644 --- a/scripts/addin_test.mjs +++ b/scripts/addin_test.mjs @@ -14,7 +14,9 @@ // --show / --hide as tbbuild's // // Exit: 0 every lane passed and the registry is as it was found, 1 a lane -// failed, 2 the harness failed or could not put the registry back. +// failed or the run was interrupted, 2 the harness could not run (a refused +// command line included) or crashed, 3 the registry or the work folders could +// not be put back (the registry is what to repair, so 3 wins over 1). // // Not a gate, for the reasons examples.bat is not one: it needs Windows and a // twinBASIC install. addin-test.bat is the wrapper. @@ -53,7 +55,7 @@ import { existsSync, mkdirSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { numberOption, parseCli, printHelpAndExit, refuseTogether, regexOption, withUsageError } from "../lib/cli.mjs"; +import { exitOnCrash, numberOption, parseCli, printHelpAndExit, refuseTogether, regexOption, withUsageError } from "../lib/cli.mjs"; import { removeTree } from "./lib/tb-ide-copy.mjs"; import { wantShow } from "./lib/tb-ide.mjs"; import { buildNumber, findIde } from "./lib/tb-install.mjs"; @@ -62,6 +64,8 @@ import { alive, deleteSettings, finishTidy, ideLists, norm, restoreKeys, SETTING snapshotKeys, startTidy, subkeyNames, sweepArchitectureMemory } from "./lib/tb-registry.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; +exitOnCrash(); + const SUITE = path.join(REPO_ROOT, "test", "addin"); const USAGE = `usage: node scripts/addin_test.mjs [--only REGEX] [--port N] [--jobs N] [--timeout S] [--ide <twinBASIC.exe>] [--show|--hide] [-h, --help] @@ -76,7 +80,14 @@ process of its own with its own IDE copy, DevTools port and work folder. --ide <path> the twinBASIC.exe to copy (default: $TB_IDE, else the newest twinBASIC_IDE_BETA_* on the Desktop) --show, --hide as tbbuild's - -h, --help print this text and exit`; + -h, --help print this text and exit + +Exit codes: + 0 every lane passed, and the registry is as it was found + 1 a lane failed, or the run was interrupted + 2 the harness could not run: a refused command line, no IDE, no matching lane, a + registry it could not record, or a crash + 3 the registry or a work folder was not put back; see the lines above`; const { values } = withUsageError(() => parseCli(process.argv.slice(2), { options: { @@ -296,4 +307,4 @@ console.log(problems.length console.log(`${results.length} of ${lanes.length} lane(s) ran: ${results.length - failed.length} passed` + (failed.length ? `, ${failed.length} failed (${failed.map((r) => r.lane.name).join(", ")})` : "") + (interrupted ? "; interrupted" : "")); -process.exit(problems.length ? 2 : failed.length || interrupted ? 1 : 0); +process.exit(problems.length ? 3 : failed.length || interrupted ? 1 : 0); diff --git a/scripts/build_dot_metrics.mjs b/scripts/build_dot_metrics.mjs index b9cb082c..a0dac51f 100644 --- a/scripts/build_dot_metrics.mjs +++ b/scripts/build_dot_metrics.mjs @@ -34,9 +34,8 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { withBrowser } from "./lib/browser.mjs"; -import { exitOnCrash } from "./lib/gate-probes.mjs"; import { openInterPage } from "./lib/inter-page.mjs"; -import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { exitOnCrash, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; // A crash exits 2, where 1 is --check finding the table stale. @@ -48,7 +47,12 @@ Regenerates builder/inter-metrics.json, the Inter width table that Graphviz is given, by measuring the font in a browser. --check fail if the table is stale, instead of writing it - -h, --help print this text and exit`; + -h, --help print this text and exit + +Exit codes: + 0 the table was written or is unchanged; with --check, it is current + 1 with --check, the table is stale + 2 a refused command line, a browser that would not start, or a crash`; const OUT = path.join(REPO_ROOT, "builder", "inter-metrics.json"); diff --git a/scripts/build_package_api.mjs b/scripts/build_package_api.mjs index 3b82e269..a07e37d0 100644 --- a/scripts/build_package_api.mjs +++ b/scripts/build_package_api.mjs @@ -14,7 +14,7 @@ // --out <file> write somewhere other than builder/package-api.json // // Exit codes: 0 written (or up to date, with --check), 1 stale (--check), 2 the -// tool failed. +// tool could not do its job (a refused command line included) or crashed. // // Dev tooling, not part of the render pipeline, in the same way as // scripts/build_dot_metrics.mjs: tbdocs reads builder/package-api.json and never @@ -46,9 +46,11 @@ import path from "node:path"; import { buildNumber, findIde } from "./lib/tb-install.mjs"; import { defaultCache, exportPackages, packageName } from "./lib/tb-packages.mjs"; import { apiSnapshot, parsePackage } from "./lib/twin-api.mjs"; -import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { exitOnCrash, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; +exitOnCrash(); + const OUT = path.join(REPO_ROOT, "builder", "package-api.json"); const USAGE = `usage: node scripts/build_package_api.mjs [options] @@ -63,7 +65,13 @@ half of the documentation's symbol index, in builder/package-api.json. --cache <dir> where exports are kept (default %TEMP%\\tb-census\\beta-<n>) --refresh export again even if the cache has this build --out <file> write somewhere other than builder/package-api.json - -h, --help print this text and exit`; + -h, --help print this text and exit + +Exit codes: + 0 the file was written; with --check, it is up to date + 1 with --check, the file is stale + 2 a refused command line, no install, an export that failed, packages that + declare different APIs under one name, or a crash`; const { values } = withUsageError(() => parseCli(process.argv.slice(2), { diff --git a/scripts/census_attributes.mjs b/scripts/census_attributes.mjs index 7b43467a..9c33f064 100644 --- a/scripts/census_attributes.mjs +++ b/scripts/census_attributes.mjs @@ -3,7 +3,8 @@ // // node scripts/census_attributes.mjs [options] // -// The options, and the exit codes, are in USAGE below, which --help prints. +// The options, and the exit codes (0 the report was produced, 2 the tool could +// not do its job or crashed), are in USAGE below, which --help prints. // // ---------------------------------------------------------------- why this // @@ -67,9 +68,11 @@ import { parseAttributes } from "./lib/attributes-doc.mjs"; import { findIde } from "./lib/tb-install.mjs"; import { defaultCache, exportPackages, packageName } from "./lib/tb-packages.mjs"; import { MODIFIERS, declarationKind, decomment } from "./lib/twin-declarations.mjs"; -import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { exitOnCrash, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { DOCS_DIR } from "../lib/repo-paths.mjs"; +exitOnCrash(); + const ATTR_DOC = path.join(DOCS_DIR, "Reference", "Attributes.md"); const { values } = withUsageError(() => @@ -111,7 +114,10 @@ declaration keyword and by enclosing construct. --quiet suppress progress on stderr -h, --help print this text and exit -Exit codes: 0 report produced, 2 the harness failed.`; +Exit codes: + 0 the report was produced + 2 a refused command line, no install, an install with no compiler or no package + project, or a crash (a package that fails to export is left out of the census)`; if (values.help) printHelpAndExit(USAGE); diff --git a/scripts/check_a11y.mjs b/scripts/check_a11y.mjs index 8140b106..9bbb32dc 100644 --- a/scripts/check_a11y.mjs +++ b/scripts/check_a11y.mjs @@ -72,7 +72,12 @@ Scans the sample pages of the built offline site with axe-core against WCAG --viewport V desktop, mobile or both (default both) --stock-axe run stock axe, without the source patches --minified with --stock-axe, run the minified axe build - -h, --help print this text and exit`; + -h, --help print this text and exit + +Exit codes: + 0 no page has a violation; incomplete checks are reported but do not fail + 1 at least one page has a violation + 2 the scan could not run: a refused command line, or a crash`; const { values } = withUsageError( () => diff --git a/scripts/check_a11y_fingerprint.mjs b/scripts/check_a11y_fingerprint.mjs index 13981c4b..ab4f35be 100644 --- a/scripts/check_a11y_fingerprint.mjs +++ b/scripts/check_a11y_fingerprint.mjs @@ -53,7 +53,7 @@ // MSYS_NO_PATHCONV=1, or use PowerShell / cmd, where it passes through intact. // // Requires `build.bat` to have produced an up-to-date _site-offline/. -// Exit codes: 0 identical, 1 fingerprints differ, 2 harness error. +// Exit codes: 0 identical, 1 fingerprints differ, 2 a refused command line or a crash. import { writeFileSync } from "node:fs"; import { resolve } from "node:path"; @@ -117,7 +117,12 @@ if (cli.stopped === "help") { printHelpAndExit( "usage: node scripts/check_a11y_fingerprint.mjs [--baseline SCHEME] " + "[--candidate SCHEME] [--root-dir DIR] [--theme T] [--viewport V] " + - "[--pages P,P] [--out FILE] [--unminified] [--patches NAME,NAME] [--list] [-h, --help]" + "[--pages P,P] [--out FILE] [--unminified] [--patches NAME,NAME] [--list] [-h, --help]\n" + + "\n" + + "Exit codes:\n" + + " 0 every fingerprint is identical, or --list printed the schemes\n" + + " 1 at least one fingerprint differs\n" + + " 2 the check could not run: a refused command line, or a crash" ); } diff --git a/scripts/check_axe_patch_equiv.mjs b/scripts/check_axe_patch_equiv.mjs index 57a433dd..92a6ec36 100644 --- a/scripts/check_axe_patch_equiv.mjs +++ b/scripts/check_axe_patch_equiv.mjs @@ -18,7 +18,7 @@ // // Usage: node scripts/check_axe_patch_equiv.mjs [--patch NAME] // -// Exit codes: 0 equivalent, 1 a value differs, 2 harness error. +// Exit codes: 0 equivalent, 1 a value differs, 2 a refused command line or a crash. // // One difference is expected and allowed: `plain-color-fields` turns the six // private fields into own properties, so `Object.keys(color)` returns them. @@ -49,7 +49,14 @@ const cli = withUsageError( }), ); if (cli.stopped === "help") { - printHelpAndExit("usage: node scripts/check_axe_patch_equiv.mjs [--patch NAME] [-h, --help]"); + printHelpAndExit( + "usage: node scripts/check_axe_patch_equiv.mjs [--patch NAME] [-h, --help]\n" + + "\n" + + "Exit codes:\n" + + " 0 the patched bundle gives the same colour values as stock axe\n" + + " 1 at least one colour value differs\n" + + " 2 the check could not run: a refused command line, or a crash" + ); } const patchName = withUsageError( () => choiceOption(cli.values.patch, { option: "--patch", choices: Object.keys(SOURCE_PATCHES) }), diff --git a/scripts/check_book_coverage.mjs b/scripts/check_book_coverage.mjs index c3ecd13f..6d6db645 100644 --- a/scripts/check_book_coverage.mjs +++ b/scripts/check_book_coverage.mjs @@ -20,8 +20,8 @@ // node scripts/check_book_coverage.mjs import { resolveBookChapters, bookCoverage, formatBookCoverage } from "../builder/book.mjs"; -import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; -import { createProbes, exitOnCrash } from "./lib/gate-probes.mjs"; +import { exitOnCrash, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { createProbes } from "./lib/gate-probes.mjs"; exitOnCrash(); @@ -30,7 +30,12 @@ const USAGE = `usage: node scripts/check_book_coverage.mjs [-h, --help] Checks that each of the book-coverage warnings of builder/book.mjs still fires, against pages and a manifest built in memory. - -h, --help print this text and exit`; + -h, --help print this text and exit + +Exit codes: + 0 every probe passed + 1 a probe failed + 2 the gate could not run: a refused command line, or a crash`; if (withUsageError(() => parseCli(process.argv.slice(2), { options: { help: { type: "boolean", short: "h" } }, diff --git a/scripts/check_ci_workflows.mjs b/scripts/check_ci_workflows.mjs index f6542835..438f9297 100644 --- a/scripts/check_ci_workflows.mjs +++ b/scripts/check_ci_workflows.mjs @@ -24,14 +24,13 @@ // // node scripts/check_ci_workflows.mjs // -// Exit codes: 0 clean, 1 a finding, 2 a probe failed or the gate crashed. +// Exit codes: 0 clean, 1 a finding, 2 a refused command line, a failed probe or a crash. import { readFileSync } from "node:fs"; import path from "node:path"; import yaml from "js-yaml"; -import { exitOnCrash } from "./lib/gate-probes.mjs"; import { buildArgs, gateSteps, workflowSteps } from "./lib/gate-roster.mjs"; -import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { exitOnCrash, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; exitOnCrash(); @@ -41,7 +40,12 @@ const USAGE = `usage: node scripts/check_ci_workflows.mjs [-h, --help] Checks that both CI workflows run every gate the wrappers run, with the same arguments and in the same order. - -h, --help print this text and exit`; + -h, --help print this text and exit + +Exit codes: + 0 both workflows run every gate the wrappers run, and its own probes pass + 1 a workflow differs from the wrappers: a finding is listed + 2 the gate could not run: a refused command line, a failed probe, or a crash`; if (withUsageError(() => parseCli(process.argv.slice(2), { options: { help: { type: "boolean", short: "h" } }, diff --git a/scripts/check_cli.mjs b/scripts/check_cli.mjs index 17d3a3e4..a9c34659 100644 --- a/scripts/check_cli.mjs +++ b/scripts/check_cli.mjs @@ -40,10 +40,10 @@ import path from "node:path"; import { parseArgs } from "node:util"; import { DEFAULTS, parseCommandLine } from "../builder/command-line.mjs"; import { - CliError, choiceOption, dateOption, numberOption, parseCli, printHelpAndExit, refuseTogether, regexOption, urlOption, withUsageError, + CliError, choiceOption, dateOption, exitOnCrash, numberOption, parseCli, printHelpAndExit, refuseTogether, regexOption, urlOption, withUsageError, } from "../lib/cli.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; -import { createProbes, exitOnCrash } from "./lib/gate-probes.mjs"; +import { createProbes } from "./lib/gate-probes.mjs"; exitOnCrash(); @@ -52,7 +52,12 @@ const USAGE = `usage: node scripts/check_cli.mjs [-h, --help] Tests lib/cli.mjs, the command-line parser, and runs the recorded command-line cases of every tool, each in an empty folder with no IDE and no browser. - -h, --help print this text and exit`; + -h, --help print this text and exit + +Exit codes: + 0 every probe and recorded case passed + 1 a probe or a recorded case failed + 2 the gate could not run: a refused command line, or a crash`; if (withUsageError(() => parseCli(process.argv.slice(2), { options: { help: { type: "boolean", short: "h" } }, @@ -568,7 +573,8 @@ const CASES = [ // Every tool here answers -h and --help on stdout with exit 0 (C71), and // reads nothing after it. The command in the wisdom cases is never a real // one, so that none can start an export. render-book's missing input file is - // not a usage error and exits 1, as does a --site that holds no search index. + // not a usage error, but exits 2 as one does, and so does a --site that holds + // no search index. { tool: "book/render-book.mjs", args: ["--help"], exit: 0, stdout: /^usage: node render-book\.mjs <input\.html> / }, { tool: "book/render-book.mjs", args: [], exit: 2, stderr: "usage: node render-book.mjs <input.html> -o <output.pdf> [--outline-tags ...] [-t ms] [--additional-script path]...\n" }, { tool: "book/render-book.mjs", args: ["a.html", "b.html"], exit: 2, stderr: "unexpected argument: b.html\n" }, @@ -612,9 +618,9 @@ const CASES = [ { tool: "eval/site_search.mjs", args: ["--help"], exit: 0, stdout: /^Usage: node eval\/site_search\.mjs "<query>" / }, { tool: "eval/site_search.mjs", args: [], exit: 2, stderr: /^Usage: node eval\/site_search\.mjs "<query>" / }, { tool: "eval/site_search.mjs", args: ["--site", "nowhere", "--bogus"], exit: 2, stderr: "unknown option: --bogus\n" }, - { tool: "eval/site_search.mjs", args: ["--site", "nowhere", "--", "--bogus"], exit: 1, stderr: /^missing .*search-data\.json\nRun build\.bat / }, + { tool: "eval/site_search.mjs", args: ["--site", "nowhere", "--", "--bogus"], exit: 2, stderr: /^missing .*search-data\.json\nRun build\.bat / }, { tool: "eval/site_search.mjs", args: ["--help=1", "--site", "nowhere"], exit: 2, stderr: "--help takes no value\n" }, - { tool: "eval/site_search.mjs", args: ["--composition", "--site", "nowhere"], exit: 1, stderr: /^missing .*search-data\.json\nRun build\.bat / }, + { tool: "eval/site_search.mjs", args: ["--composition", "--site", "nowhere"], exit: 2, stderr: /^missing .*search-data\.json\nRun build\.bat / }, { tool: "eval/site_search.mjs", args: ["--site"], exit: 2, stderr: "--site needs a value\n" }, { tool: "eval/search_quality.mjs", args: ["--help"], exit: 0, stdout: /^Usage: node eval\/search_quality\.mjs \[--site docs\/_site\] / }, { tool: "eval/search_quality.mjs", args: ["--bogus"], exit: 2, stderr: "unknown option: --bogus\n" }, @@ -622,7 +628,7 @@ const CASES = [ { tool: "eval/search_quality.mjs", args: ["--help", "--bogus"], exit: 0, stdout: /^Usage: node eval\/search_quality\.mjs \[--site docs\/_site\] / }, { tool: "eval/search_quality.mjs", args: ["--help=1"], exit: 2, stderr: "--help takes no value\n" }, { tool: "eval/search_quality.mjs", args: ["-x"], exit: 2, stderr: "unknown option: -x\n" }, - { tool: "eval/search_quality.mjs", args: ["--site", "nowhere"], exit: 1, stderr: /^missing .*search-data\.json\nRun build\.bat / }, + { tool: "eval/search_quality.mjs", args: ["--site", "nowhere"], exit: 2, stderr: /^missing .*search-data\.json\nRun build\.bat / }, { tool: "eval/search_quality.mjs", args: ["--site", "nowhere", "--sample", "abc"], exit: 2, stderr: "--sample expects a whole number of at least 1, got: abc\n" }, { tool: "eval/search_quality.mjs", args: ["--site", "--help"], exit: 2, stderr: "--site needs a value\n" }, { tool: "eval/search_quality.mjs", args: ["--site"], exit: 2, stderr: "--site needs a value\n" }, @@ -633,10 +639,10 @@ const CASES = [ { tool: "eval/transcript.mjs", args: ["nope.jsonl", "--help"], exit: 0, stdout: /^Usage: node eval\/transcript\.mjs <case\.jsonl> / }, { tool: "eval/transcript.mjs", args: ["--bogus"], exit: 2, stderr: "unknown option: --bogus\n" }, { tool: "eval/transcript.mjs", args: ["--help=1"], exit: 2, stderr: "--help takes no value\n" }, - { tool: "eval/transcript.mjs", args: ["nope.jsonl"], exit: 1, stderr: /Error: ENOENT: no such file or directory, open '[^']*nope\.jsonl'\r?\n/ }, + { tool: "eval/transcript.mjs", args: ["nope.jsonl"], exit: 2, stderr: /Error: ENOENT: no such file or directory, open '[^']*nope\.jsonl'\r?\n/ }, { tool: "eval/transcript.mjs", args: ["--bogus", "nope.jsonl"], exit: 2, stderr: "unknown option: --bogus\n" }, { tool: "eval/transcript.mjs", args: ["-x"], exit: 2, stderr: "unknown option: -x\n" }, - { tool: "eval/transcript.mjs", args: ["--", "-x"], exit: 1, stderr: /Error: ENOENT: no such file or directory, open '(?:[^']*[\\/])?-x'\r?\n/ }, + { tool: "eval/transcript.mjs", args: ["--", "-x"], exit: 2, stderr: /Error: ENOENT: no such file or directory, open '(?:[^']*[\\/])?-x'\r?\n/ }, { tool: "eval/transcript.mjs", args: ["a.jsonl", "b.jsonl"], exit: 2, stderr: "unexpected argument: b.jsonl\n" }, { tool: "wisdom/wisdom.mjs", args: [], exit: 2, stderr: /^Usage: node wisdom\/wisdom\.mjs <command> \[options\]\n/ }, { tool: "wisdom/wisdom.mjs", args: ["--help"], exit: 0, stdout: /^Usage: node wisdom\/wisdom\.mjs <command> \[options\]\n/ }, @@ -748,8 +754,17 @@ for (const [tool, start] of Object.entries(HELP_TOOLS)) { if (CASES.some((c) => c.tool === tool && c.args.length === 1 && c.args[0] === flag)) continue; CASES.push({ tool, args: [flag], exit: 0, stdout: new RegExp(`^${opening}`) }); } + if (tool === "scripts/impexp.mjs") continue; + CASES.find((c) => c.tool === tool && c.args.length === 1 && c.args[0] === "--help").exitCodes = true; } +// Every usage text ends with its tool's one table of exit codes: a line +// `Exit codes:`, then a line for each code, ` <code> <meaning>`, a long +// meaning wrapped under itself. impexp keeps the table it shares with +// impexp.py, which check_impexp_parity holds the two editions to. +const EXIT_TABLE = /\nExit codes:\n(?: {2}\d {2}[^\n]*\n| {5}[^\n]*\n)+$/; +const oneExitTable = (text) => EXIT_TABLE.test(text) && text.split("Exit codes:").length === 2; + // Recorded in C72. Every tool refuses an unknown flag and an empty value at // the parse, so each has a case for the first and, where it has a value // option, for the second: `tool: [option, extras]`, the option given as @@ -1031,6 +1046,7 @@ try { check(`${label}: exit ${c.exit}, ${c.stdout ? "stdout" : "stderr"}`, ok, `expected exit ${c.exit}, stdout ${expectation(c.stdout)}, stderr ${expectation(c.stderr)}\n` + `got exit ${got.exit}, stdout ${clip(got.stdout)}, stderr ${clip(got.stderr)}`); + if (c.exitCodes) check(`${label}: ends with one table of exit codes`, oneExitTable(got.stdout), `got stdout ${clip(got.stdout.slice(-400))}`); if (got.left) check(`${label}: leaves its folder empty`, got.left.length === 0, `appeared in the folder: ${show(got.left)}`); }); } finally { diff --git a/scripts/check_code_regions.mjs b/scripts/check_code_regions.mjs index c4f472a8..71ef3bfc 100644 --- a/scripts/check_code_regions.mjs +++ b/scripts/check_code_regions.mjs @@ -6,7 +6,7 @@ // node scripts/check_code_regions.mjs --self-test # prove it still detects // // Exit: 0 clean, 1 a code region changed or a probe failed, 2 the gate itself -// could not run. +// could not run (a refused command line, or a crash). // // WHY THIS EXISTS // @@ -454,7 +454,14 @@ runs the probes of the modules that decide what is code. --verbose print the detail of every finding --self-test prove the comparison still detects an altered code region - -h, --help print this text and exit`; + -h, --help print this text and exit + +Exit codes: + 0 no code region was altered, and every probe passed (--self-test: the comparison + detects the altered region) + 1 a code region was altered, a probe failed or the two parses disagree (--self-test: + the comparison missed the altered region) + 2 the gate could not run: a refused command line, or a crash`; async function main(argv) { const { values } = withUsageError(() => parseCli(argv, { diff --git a/scripts/check_dot_fit.mjs b/scripts/check_dot_fit.mjs index 2e98f2f9..b73c7414 100644 --- a/scripts/check_dot_fit.mjs +++ b/scripts/check_dot_fit.mjs @@ -27,9 +27,8 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { listDotSources } from "../builder/dot.mjs"; import { withBrowser } from "./lib/browser.mjs"; -import { exitOnCrash } from "./lib/gate-probes.mjs"; import { openInterPage } from "./lib/inter-page.mjs"; -import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { exitOnCrash, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { DOCS_DIR, REPO_ROOT } from "../lib/repo-paths.mjs"; exitOnCrash(); @@ -40,7 +39,12 @@ Checks that the text of every committed DOT diagram still fits the boxes Graphviz drew for it, by measuring the text in a browser. --verbose also print the tolerance under each diagram that fits - -h, --help print this text and exit`; + -h, --help print this text and exit + +Exit codes: + 0 every diagram's text fits its boxes, or no diagram was found + 1 the text of at least one diagram sits outside its box + 2 the gate could not run: a refused command line, no browser, or a crash`; // A label may sit this far past its box edge before it counts as a failure. // Kerning is the irreducible part: a per-character table cannot express it, diff --git a/scripts/check_examples.mjs b/scripts/check_examples.mjs index 78f729c7..c9feba10 100644 --- a/scripts/check_examples.mjs +++ b/scripts/check_examples.mjs @@ -8,7 +8,8 @@ // node scripts/check_examples.mjs --propose --apply # ...and mark the ones that pass // node scripts/check_examples.mjs --report survey.json # group a saved survey // -// Exit: 0 clean, 1 a sample does not compile, 2 the harness failed. +// Exit: 0 clean, 1 a sample does not compile, 2 the harness could not run (a refused +// command line, no IDE, or a crash). // // ------------------------------------------------------------------ why // @@ -131,7 +132,16 @@ Compiles the documentation's own twinBASIC code samples, every tb fence marked --show, --hide as tbbuild's --verbose also print warnings, not only errors --json one JSON object instead of a report - -h, --help print this text and exit`; + -h, --help print this text and exit + +Exit codes: + 0 every marked sample compiles, or none is marked; --report always, and --propose + when it found only unmarked samples that fail (advisory) + 1 a marked sample does not compile, a marker is misused, a template does not + compile, or the compiler crashed on a project; the report names each + 2 the harness could not run: a refused command line, a failed self-test probe, no + IDE or compiler, an unreadable --report file, a work folder it could not clear, + or a crash`; if (values.help) printHelpAndExit(USAGE); diff --git a/scripts/check_gate_lists.mjs b/scripts/check_gate_lists.mjs index b79dcd78..9bca884d 100644 --- a/scripts/check_gate_lists.mjs +++ b/scripts/check_gate_lists.mjs @@ -75,8 +75,8 @@ // design notes and frozen audit snapshots, and rewriting one to match a later // change destroys the only thing it is for. // -// Exit codes: 0 clean, 1 a list or a stated count disagrees, 2 the gate could -// not run. +// Exit codes: 0 clean, 1 a list or a stated count disagrees or a probe failed, 2 the +// gate could not run (a refused command line, or a crash). import { readFile, readdir } from "node:fs/promises"; import path from "node:path"; @@ -558,7 +558,12 @@ and that no developer page states a gate count that disagrees with them. --verbose print every probe and every wrapper that agrees --self-test run the probes only, to prove the check still detects a wrong list - -h, --help print this text and exit`; + -h, --help print this text and exit + +Exit codes: + 0 the wrappers match the gate lists, every stated count agrees, and every probe passed + 1 a list or a stated count disagrees, or a probe failed + 2 the gate could not run: a refused command line, or a crash`; async function main(argv) { const { values } = withUsageError(() => parseCli(argv, { diff --git a/scripts/check_impexp_parity.mjs b/scripts/check_impexp_parity.mjs index ed2f58d6..e3c8224f 100644 --- a/scripts/check_impexp_parity.mjs +++ b/scripts/check_impexp_parity.mjs @@ -20,15 +20,14 @@ // node scripts/check_impexp_parity.mjs // // Exit codes: 0 the same, or skipped outside CI; 1 a difference or a failed -// built-in test; 2 the check itself failed, or found no Python in CI. +// built-in test; 2 a refused command line, no Python in CI, or a crash. import { spawn, spawnSync } from "node:child_process"; import { cpSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; -import { exitOnCrash } from "./lib/gate-probes.mjs"; +import { exitOnCrash, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; exitOnCrash(); @@ -40,9 +39,14 @@ if (cli.stopped === "help") { Runs the built-in tests of scripts/impexp.mjs and scripts/impexp.py, and one sequence of commands through each, comparing exit codes, printed output and -written files. Without Python 3.6 or later it reports the check skipped and -exits 0, or fails when CI=true. Exit 0 the same or skipped, 1 a difference or -a failed test, 2 the check itself failed or found no Python in CI.`); +written files. Without Python 3.6 or later it reports the check skipped, or +fails when CI=true. + +Exit codes: + 0 the two editions agree, or the check was skipped because no Python was found + 1 the editions differ, or a built-in test failed + 2 the check could not run: a refused command line, no Python when CI=true, or a + crash`); } const TOOL = "check_impexp_parity"; diff --git a/scripts/check_links.mjs b/scripts/check_links.mjs index bd0027ce..8ba5edb8 100644 --- a/scripts/check_links.mjs +++ b/scripts/check_links.mjs @@ -77,8 +77,7 @@ import { FsOracle, formatLinkReport, formatIntegrityReport, resolve, OUTSIDE_BASEPATH_MARKER, } from "../builder/link-check.mjs"; -import { CliError, choiceOption, parseCli } from "../lib/cli.mjs"; -import { exitOnCrash } from "./lib/gate-probes.mjs"; +import { CliError, choiceOption, exitOnCrash, parseCli } from "../lib/cli.mjs"; // Tree-relative POSIX path, the space check.mjs works and reports in, so // the same tree checked with a relative --root-dir, an absolute one, or @@ -129,8 +128,9 @@ Options: URL prefix. The bare prefix and 'prefix/' are exempt (intentional "go to live site" links). Repeatable. - --no-fail Always exit 0, even if errors are found. - Errors are still printed. Useful for + --no-fail Exit 0 when the check finds errors. Errors + are still printed. A command-line error or + a crash still exits 2. Useful for informational checks that should not block. --oracle fs|index How to answer "does this path exist". 'index' walks --root-dir once and answers @@ -186,16 +186,15 @@ Integrity checks (share the existing htmlparser2 SAX parse pass): URL. Catches canonical URLs that include --base-path / the wrong baseurl. -Exit codes: - 0 All checks passed. - 1 A link, forbidden-prefix or integrity check failed. The summary - lines say which. - 2 The check could not run. Either a command-line error, reported on - stderr (no arguments, an unknown option, a flag without its value - or with an empty one, no --offline, or no input), or a crash. - Inputs are files or directories; directories are searched recursively for *.html. + +Exit codes: + 0 every check passed, or --no-fail turned the findings into 0 + 1 a link, forbidden-prefix or integrity check failed; the summary lines say + which (with /sep/ segments, the highest code of any segment) + 2 the check could not run: a refused command line (no arguments, an unknown + option, a flag without its value, no --offline, or no input), or a crash `); } diff --git a/scripts/check_links_diff.mjs b/scripts/check_links_diff.mjs index 7be00e39..446646d1 100644 --- a/scripts/check_links_diff.mjs +++ b/scripts/check_links_diff.mjs @@ -32,7 +32,8 @@ // --base-path-tree check a tree built with a --baseurl prefix // --build-base-path P the base path to build that tree with // -// Exits 0 when the two sides agree, 1 on a difference, 2 on a harness error. +// Exits 0 when the two sides agree, 1 on a difference, 2 when the comparison could not +// run (a refused command line, a failed build, or a crash). // node scripts/check_links_diff.mjs --case online --case book -v // node scripts/check_links_diff.mjs --list // @@ -79,9 +80,11 @@ import * as path from "node:path"; import { performance } from "node:perf_hooks"; import { runCheck, selfTest as scriptSelfTest } from "./check_links.mjs"; -import { numberOption, parseCli, withUsageError } from "../lib/cli.mjs"; +import { exitOnCrash, numberOption, parseCli, withUsageError } from "../lib/cli.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; +exitOnCrash(); + const BASE_PATH = "/twinBASIC-docs"; const DEFAULT_BASEPATH_TREE = "docs/_site-basepath"; const FIXTURE_SRC = "test/fixtures/check-src"; @@ -583,7 +586,12 @@ function printHelp() { --list list cases and sides, then exit -v, --verbose print per-case finding counts even when clean -h, --help print this text and exit -`); + +Exit codes: + 0 the two sides agree in every case + 1 the sides differ, a fixture's category counts drifted, or --self-test failed + 2 the comparison could not run: a refused command line, an unknown side or + case, --a equal to --b, a failed build, or a crash`); } // Guard on the guard. Everything below reduces to "the two sides agreed", diff --git a/scripts/check_lint.mjs b/scripts/check_lint.mjs index 2e88874d..d42803b8 100644 --- a/scripts/check_lint.mjs +++ b/scripts/check_lint.mjs @@ -28,16 +28,15 @@ // node scripts/check_lint.mjs # the whole scope: test.bat and CI // node scripts/check_lint.mjs --staged # the staged scripts: the hook // -// Exit codes: 0 clean, 1 a finding, 2 Biome could not lint or, over the whole -// scope, checked no script. +// Exit codes: 0 clean, 1 a finding, 2 a refused command line, Biome could not lint or, +// over the whole scope, checked no script, or a crash. import { spawnSync } from "node:child_process"; import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; -import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; -import { exitOnCrash } from "./lib/gate-probes.mjs"; +import { exitOnCrash, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; exitOnCrash(); @@ -55,7 +54,13 @@ Runs the pinned Biome over the tooling and the site's scripts, and fails on a warning as well as an error. --staged lint only the scripts the next commit adds or changes - -h, --help print this text and exit`; + -h, --help print this text and exit + +Exit codes: + 0 Biome found nothing (--staged: or no script is staged, so nothing was linted) + 1 Biome found an error or a warning + 2 the gate could not lint: a refused command line, git or Biome failing to run, + Biome checking no script over the whole scope, or a crash`; const cli = withUsageError( () => parseCli(process.argv.slice(2), { diff --git a/scripts/check_page_baseline.mjs b/scripts/check_page_baseline.mjs index 69c7cfae..07697302 100644 --- a/scripts/check_page_baseline.mjs +++ b/scripts/check_page_baseline.mjs @@ -26,8 +26,8 @@ import { readFile } from "node:fs/promises"; import { GUARDED_SRC } from "../builder/baseline.mjs"; import { checkPageBaseline } from "../builder/page-baseline.mjs"; -import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; -import { baselineFixture, createProbes, exitOnCrash } from "./lib/gate-probes.mjs"; +import { exitOnCrash, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { baselineFixture, createProbes } from "./lib/gate-probes.mjs"; exitOnCrash(); @@ -36,7 +36,12 @@ const USAGE = `usage: node scripts/check_page_baseline.mjs [-h, --help] Checks that the page-count drift guard of builder/page-baseline.mjs still refuses what it exists to refuse, against a scratch baseline file. - -h, --help print this text and exit`; + -h, --help print this text and exit + +Exit codes: + 0 every probe passed + 1 a probe failed + 2 the gate could not run: a refused command line, or a crash`; if (withUsageError(() => parseCli(process.argv.slice(2), { options: { help: { type: "boolean", short: "h" } }, diff --git a/scripts/check_pdf_shims_equiv.mjs b/scripts/check_pdf_shims_equiv.mjs index 08cc2d8f..f3bd95b4 100644 --- a/scripts/check_pdf_shims_equiv.mjs +++ b/scripts/check_pdf_shims_equiv.mjs @@ -37,7 +37,8 @@ // node scripts/check_pdf_shims_equiv.mjs // // Exit codes: 0 the same, 1 a difference, a shim or patched member not reached -// or a patched member not as PATCHES lists it, 2 the check itself failed. +// or a patched member not as PATCHES lists it, 2 a refused command line, a failure of +// the check itself, or a crash. import { spawn } from "node:child_process"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; @@ -45,8 +46,7 @@ import { availableParallelism, tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { deflateSync, inflateSync } from "node:zlib"; -import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; -import { exitOnCrash } from "./lib/gate-probes.mjs"; +import { exitOnCrash, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; exitOnCrash(); @@ -59,9 +59,15 @@ if (cli.stopped === "help") { Loads, changes and saves one PDF with stock pdf-lib and with the book's pdf-lib shims, and creates and saves another, compares each pair of files object by object, streams inflated, and checks the members of pdf-lib the shims patch -against the list in this file. Exit 0 the same, 1 a difference, a shim or -patched member the documents no longer reach, or a patched member not as -listed, 2 the check itself failed.`); +against the list in this file. + +Exit codes: + 0 the shims write what stock pdf-lib writes, and every shim and patched member + is reached and as listed + 1 a pair of files differs, a shim or patched member is no longer reached, or a + patched member is not as listed + 2 the check could not run: a refused command line, a failure of the check + itself, or a crash`); } const TOOL = "check_pdf_shims_equiv"; diff --git a/scripts/check_publish_policy.mjs b/scripts/check_publish_policy.mjs index 99e42ca4..f42e4864 100644 --- a/scripts/check_publish_policy.mjs +++ b/scripts/check_publish_policy.mjs @@ -22,8 +22,7 @@ import { publishPolicyFor, unpublishableSourceFiles, unpublishableTreePaths, SOURCE_EXTENSIONS, BUILD_EXTENSIONS, } from "../builder/publish-policy.mjs"; -import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; -import { exitOnCrash } from "./lib/gate-probes.mjs"; +import { exitOnCrash, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; exitOnCrash(); @@ -33,7 +32,12 @@ Checks that the publish allowlist of builder/publish-policy.mjs still refuses the file types it should, and that the source tree holds nothing it refuses. --src DIR the source tree to check (default docs) - -h, --help print this text and exit`; + -h, --help print this text and exit + +Exit codes: + 0 every assertion held, and the source tree holds no file the allowlist refuses + 1 an assertion failed, or the source tree holds a file the allowlist refuses + 2 the gate could not run: a refused command line, or a crash`; const { values } = withUsageError(() => parseCli(process.argv.slice(2), { options: { src: { type: "string", default: "docs" }, help: { type: "boolean", short: "h" } }, diff --git a/scripts/check_regex_safety.mjs b/scripts/check_regex_safety.mjs index 9544bfd1..83f3e075 100644 --- a/scripts/check_regex_safety.mjs +++ b/scripts/check_regex_safety.mjs @@ -61,9 +61,9 @@ // node scripts/check_regex_safety.mjs --self-test # prove it still detects // // Exits 0 clean, 1 on an exponential regex, 2 when the gate itself failed -// -- a file it could not parse, a regex recheck could not analyse, a probe -// that came back wrong, or a throw. Each of those leaves something -// unchecked, so it must not read as either a clean tree or a finding. +// -- a refused command line, a file it could not parse, a regex recheck could +// not analyse, a probe that came back wrong, or a throw. Each of those leaves +// something unchecked, so it must not read as either a clean tree or a finding. import { spawn } from "node:child_process"; import { createRequire } from "node:module"; @@ -587,7 +587,15 @@ Refuses a regex literal in the tree that can backtrack exponentially. --census list the full classification of every regex --self-test prove the gate still detects an exponential regex - -h, --help print this text and exit`; + -h, --help print this text and exit + +Exit codes: + 0 no regex can backtrack exponentially (--self-test: every probe was classified + correctly) + 1 a regex can backtrack exponentially + 2 the gate could not run, and 2 wins over 1: a refused command line, a file it + could not parse, a regex it could not analyse, a probe that came back wrong + (also --self-test), or a crash`; const { values } = withUsageError(() => parseCli(process.argv.slice(2), { options: { diff --git a/scripts/check_symbol_index.mjs b/scripts/check_symbol_index.mjs index e2d324fd..d2609837 100644 --- a/scripts/check_symbol_index.mjs +++ b/scripts/check_symbol_index.mjs @@ -30,8 +30,8 @@ import { readFile } from "node:fs/promises"; import { GUARDED_SRC } from "../builder/baseline.mjs"; import { checkSymbolBaseline } from "../builder/symbol-baseline.mjs"; import { deriveSymbolIndex, headingsOf, serializeSymbolIndex } from "../builder/symbols.mjs"; -import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; -import { baselineFixture, createProbes, exitOnCrash } from "./lib/gate-probes.mjs"; +import { exitOnCrash, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { baselineFixture, createProbes } from "./lib/gate-probes.mjs"; import { apiSnapshot, isPublicType, parseTwin } from "./lib/twin-api.mjs"; exitOnCrash(); @@ -41,7 +41,12 @@ const USAGE = `usage: node scripts/check_symbol_index.mjs [-h, --help] Checks that the symbol index still places each kind of symbol, from fixtures: the .twin declaration scanner, the derivation from the pages and the drift guard. - -h, --help print this text and exit`; + -h, --help print this text and exit + +Exit codes: + 0 every probe passed + 1 a probe failed + 2 the gate could not run: a refused command line, or a crash`; if (withUsageError(() => parseCli(process.argv.slice(2), { options: { help: { type: "boolean", short: "h" } }, diff --git a/scripts/check_tb_registry.mjs b/scripts/check_tb_registry.mjs index 89e82b38..2de4b3f2 100644 --- a/scripts/check_tb_registry.mjs +++ b/scripts/check_tb_registry.mjs @@ -2,8 +2,9 @@ // // node scripts/check_tb_registry.mjs // -// Exit: 0 every assertion held, 1 one did not, 2 something else threw, such as -// PowerShell failing, so the test could not run to its end. +// Exit: 0 every assertion held, 1 one did not, 2 a refused command line, or +// something else threw, such as PowerShell failing, so the test could not run to its +// end. // // NOT A GATE, and it must not join test.bat: it needs Windows and a real // registry, and the CI runners are Ubuntu while the rule for test.bat is that @@ -49,8 +50,7 @@ import assert from "node:assert/strict"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; -import { exitOnCrash } from "./lib/gate-probes.mjs"; +import { exitOnCrash, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import * as R from "./lib/tb-registry.mjs"; // A crash exits 2; the catch below passes on everything but a failed assertion. @@ -61,7 +61,13 @@ const USAGE = `usage: node scripts/check_tb_registry.mjs [-h, --help] Tests the harness's registry tidy in scripts/lib/tb-registry.mjs, under HKCU\\Software\\tbharness-selftest. Windows only, and not a gate. - -h, --help print this text and exit`; + -h, --help print this text and exit + +Exit codes: + 0 every assertion held + 1 an assertion failed + 2 the test could not run to its end: a refused command line, PowerShell failing, + or a crash`; if (withUsageError(() => parseCli(process.argv.slice(2), { options: { help: { type: "boolean", short: "h" } }, diff --git a/scripts/check_tree_fresh.mjs b/scripts/check_tree_fresh.mjs index 7e1a5ed3..520a3eba 100644 --- a/scripts/check_tree_fresh.mjs +++ b/scripts/check_tree_fresh.mjs @@ -16,7 +16,8 @@ // node scripts/check_tree_fresh.mjs [--tree DIR] [--marker FILE] [--source DIR ...] // // Exits 0 when the tree is at least as new as its inputs, 1 when it is -// stale (naming build.bat), 2 when the tree is absent. +// stale (naming build.bat), 2 when the tree is absent, the command line is refused or +// the check crashes. // // `--marker` names the file inside the tree whose mtime stands for the // build. It defaults to index.html, which every tree has EXCEPT @@ -29,10 +30,12 @@ import { readdirSync, statSync, existsSync } from "node:fs"; import { join, resolve, relative, sep } from "node:path"; -import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { exitOnCrash, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { isOutputTree } from "../lib/markdown-files.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; +exitOnCrash(); + // Output trees live under docs/, so walking docs/ naively would compare // the build against itself and always pass. They are skipped at the top of // each source root by the prefix list the markdown walk uses, in @@ -78,7 +81,13 @@ const cli = withUsageError( ); if (cli.stopped === "help") { printHelpAndExit( - "usage: node scripts/check_tree_fresh.mjs [--tree DIR] [--marker FILE] [--source DIR ...] [-h, --help]", + "usage: node scripts/check_tree_fresh.mjs [--tree DIR] [--marker FILE] [--source DIR ...] [-h, --help]\n" + + "\n" + + "Exit codes:\n" + + " 0 the tree is at least as new as its inputs\n" + + " 1 the tree is stale; run build.bat\n" + + " 2 the check could not run: a refused command line, no built tree or marker file,\n" + + " or a crash", ); } let tree = cli.values.tree; diff --git a/scripts/check_twin_parsers.mjs b/scripts/check_twin_parsers.mjs index fab4a8ad..85e3ece4 100644 --- a/scripts/check_twin_parsers.mjs +++ b/scripts/check_twin_parsers.mjs @@ -4,7 +4,8 @@ // // node scripts/check_twin_parsers.mjs // -// Exits 0 when every probe passes, 1 when one fails, 2 on a crash. It reads +// Exits 0 when every probe passes, 1 when one fails, 2 on a refused command line or a +// crash. It reads // nothing from the tree: every probe is a fixed input. // // Each of these has shipped a silent misparse, and none says so when it @@ -21,9 +22,9 @@ // - parseTargets (scripts/lib/attributes-doc.mjs), which turns an // `Applicable to:` line into gen_attribute_probes.mjs's targets. -import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { exitOnCrash, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { parseTargets } from "./lib/attributes-doc.mjs"; -import { createProbes, exitOnCrash } from "./lib/gate-probes.mjs"; +import { createProbes } from "./lib/gate-probes.mjs"; import { classify } from "./lib/tb-fences.mjs"; import { parseTwin } from "./lib/twin-api.mjs"; import { MODIFIERS, declarationKind } from "./lib/twin-declarations.mjs"; @@ -35,7 +36,12 @@ const USAGE = `usage: node scripts/check_twin_parsers.mjs [-h, --help] Runs the probes of the scanners that read twinBASIC source and the attribute reference, each a shape one of them once misread. - -h, --help print this text and exit`; + -h, --help print this text and exit + +Exit codes: + 0 every probe passed + 1 a probe failed + 2 the gate could not run: a refused command line, or a crash`; if (withUsageError(() => parseCli(process.argv.slice(2), { options: { help: { type: "boolean", short: "h" } }, diff --git a/scripts/compare_trees.mjs b/scripts/compare_trees.mjs index 50ad4050..90216b1f 100644 --- a/scripts/compare_trees.mjs +++ b/scripts/compare_trees.mjs @@ -36,7 +36,8 @@ // A run that fails keeps .compare-trees/ so its logs can be read; the next // run removes it before starting. // -// Exit codes: 0 the trees match, 1 they differ, 2 the tool failed. +// Exit codes: 0 the trees match, 1 they differ, 2 the tool could not do its job +// (a refused command line, a git command or a build that failed, or a crash). import { spawnSync } from "node:child_process"; import { closeSync, existsSync, openSync } from "node:fs"; @@ -101,7 +102,11 @@ online, offline and PDF trees byte for byte. -h, --help print this text and exit -- everything after it is passed to both tbdocs builds -Exit codes: 0 the trees match, 1 they differ, 2 the tool failed. +Exit codes: + 0 the trees match + 1 the trees differ + 2 a refused command line, a git command or a build that failed to produce its + tree, or a crash `; function usageError(message) { diff --git a/scripts/convert_em_dash_separators.mjs b/scripts/convert_em_dash_separators.mjs index df853002..12a2913b 100644 --- a/scripts/convert_em_dash_separators.mjs +++ b/scripts/convert_em_dash_separators.mjs @@ -6,7 +6,8 @@ // node scripts/convert_em_dash_separators.mjs --check # report, change nothing // // Exit codes: 0 nothing to report, or converted; 1 --check found a literal -// dash; 2 the tool failed, so that a crash cannot read as a finding. +// dash; 2 a refused command line or a crash, so that a crash cannot read as a +// finding. // // The typographer (enabled in builder/render.mjs) renders: // @@ -45,11 +46,10 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { createMarkdownIt } from "../builder/render.mjs"; -import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { exitOnCrash, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { blockRegions, mapLines, splitCodeSpans } from "../lib/markdown.mjs"; import { markdownFiles } from "../lib/markdown-files.mjs"; import { DOCS_DIR } from "../lib/repo-paths.mjs"; -import { exitOnCrash } from "./lib/gate-probes.mjs"; const EM_DASH = "—"; const EN_DASH = "–"; @@ -132,7 +132,12 @@ Rewrites literal en- and em-dashes in docs/ markdown source to the ASCII forms the typographer converts at build time, leaving code as it is. --check report the files that hold a literal dash and change nothing - -h, --help print this text and exit`; + -h, --help print this text and exit + +Exit codes: + 0 the dashes were converted, or with --check there were none + 1 with --check, a file holds a literal dash + 2 a refused command line, or a crash`; async function main(argv) { const { values } = withUsageError(() => parseCli(argv, { diff --git a/scripts/crawl_check.mjs b/scripts/crawl_check.mjs index 4d2b9983..605ec8ed 100644 --- a/scripts/crawl_check.mjs +++ b/scripts/crawl_check.mjs @@ -29,7 +29,12 @@ and every anchor exists. --concurrency N requests at once (default 10) --timeout MS give up on a request after this long (default 15000) --skip-external do not check links to other sites - -h, --help print this text and exit`; + -h, --help print this text and exit + +Exit codes: + 0 every link is reachable and every anchor exists + 1 a link is broken or an anchor is missing + 2 a refused command line, or a crash`; const { values, positionals } = withUsageError( () => diff --git a/scripts/gen_attribute_probes.mjs b/scripts/gen_attribute_probes.mjs index a5e35c0d..4c730ae7 100644 --- a/scripts/gen_attribute_probes.mjs +++ b/scripts/gen_attribute_probes.mjs @@ -38,9 +38,11 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { parseAttributes, parseTargets } from "./lib/attributes-doc.mjs"; -import { parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { exitOnCrash, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { DOCS_DIR } from "../lib/repo-paths.mjs"; +exitOnCrash(); + const ATTR_DOC = path.join(DOCS_DIR, "Reference", "Attributes.md"); const USAGE = `Generate a twinBASIC probe project for Reference/Attributes.md applicability. @@ -55,7 +57,11 @@ diagnostic naming a probe module is a finding. key.md where to write the key (default: probe-key.md beside <out_dir>) -h, --help print this text and exit -A folder or key that starts with a dash is given after \`--\`.`; +A folder or key that starts with a dash is given after \`--\`. + +Exit codes: + 0 the probe project and the key were written + 2 a refused command line, or a crash`; // --------------------------------------------------------------- arguments // An attribute with a mandatory argument needs a value that is itself valid, or diff --git a/scripts/lib/gate-probes.mjs b/scripts/lib/gate-probes.mjs index b4a5d321..0ca7e675 100644 --- a/scripts/lib/gate-probes.mjs +++ b/scripts/lib/gate-probes.mjs @@ -1,21 +1,12 @@ -// What the gates' self-tests share: the crash handler, the probe accumulator -// and its report, and a scratch baseline file for the two drift guards. +// What the gates' self-tests share: the probe accumulator and its report, and a +// scratch baseline file for the two drift guards. // -// Every export here does its work when called, never on import, so a module -// that can be imported as well as run -- convert_em_dash_separators.mjs is one -// -- installs nothing in the process that imports it. +// Every export here does its work when called, never on import. import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -// A crash is the harness failing, not a finding: exit 2, as Extending.md's gate -// conventions require. A script that runs at top level has no main().catch to -// do it; this handler also catches a rejected top-level await. -export function exitOnCrash() { - process.on("uncaughtException", (err) => { console.error(err); process.exit(2); }); -} - // A probe list for the gate `tool`. `check(name, ok, detail)` records one probe; // `report()` prints a line for each, with a failed probe's detail under it, // every line of the detail indented alike, then a summary, and returns the exit diff --git a/scripts/pick_a11y_sample.mjs b/scripts/pick_a11y_sample.mjs index f8555ae9..94c6cef1 100644 --- a/scripts/pick_a11y_sample.mjs +++ b/scripts/pick_a11y_sample.mjs @@ -47,8 +47,7 @@ import { resolve, join, relative, sep } from "node:path"; import { DEFAULT_ROOT_DIR, REPO_ROOT, SAMPLE_PAGES, discoverPages, median, pad, splitStubs, } from "./lib/axe-scan.mjs"; -import { exitOnCrash } from "./lib/gate-probes.mjs"; -import { numberOption, parseCli, printHelpAndExit, refuseTogether, withUsageError } from "../lib/cli.mjs"; +import { exitOnCrash, numberOption, parseCli, printHelpAndExit, refuseTogether, withUsageError } from "../lib/cli.mjs"; // A crash exits 2, where 1 is a coverage gap. exitOnCrash(); @@ -145,7 +144,12 @@ const cli = withUsageError( if (cli.stopped === "help") { printHelpAndExit( "usage: node scripts/pick_a11y_sample.mjs [--check|--propose|--census] [--fresh]\n" - + " [--root-dir DIR] [--sweep FILE] [--budget MS] [-h, --help]", + + " [--root-dir DIR] [--sweep FILE] [--budget MS] [-h, --help]\n\n" + + "Exit codes:\n" + + " 0 the mode ran; with --check, every construct family in use is covered\n" + + " 1 with --check, a construct family has no sample page, or a SAMPLE_PAGES entry\n" + + " is not in the built tree\n" + + " 2 a refused command line, no built tree (run build.bat first), or a crash", ); } const budget = withUsageError(() => { diff --git a/scripts/survey_tooling.mjs b/scripts/survey_tooling.mjs index d0dd068c..07a273bb 100644 --- a/scripts/survey_tooling.mjs +++ b/scripts/survey_tooling.mjs @@ -52,9 +52,11 @@ import { builtinModules } from "node:module"; import path from "node:path"; import * as acorn from "acorn"; import * as walk from "acorn-walk"; -import { numberOption, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { exitOnCrash, numberOption, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; import { REPO_ROOT } from "../lib/repo-paths.mjs"; +exitOnCrash(); + const TOOLING_DIRS = ["builder", "scripts", "lib", "book", "eval", "wisdom", "test", "perf"]; const VENDORED = [/^book\/lib\/paged\.browser\.js$/, /^builder\/vendor\//]; const LAB = "perf/"; @@ -71,7 +73,11 @@ files git tracks. It is a measurement taken by hand and is not a gate. --window N tokens two places must share to count as a clone (default 60) --top N list at most N clone regions (default 45) --include-perf list what involves perf/ too - -h, --help print this text and exit`; + -h, --help print this text and exit + +Exit codes: + 0 the survey was printed + 2 a refused command line, a folder that is not a git checkout, or a crash`; const { values } = withUsageError( () => diff --git a/scripts/sweep_a11y.mjs b/scripts/sweep_a11y.mjs index e39a4160..24d4e1c1 100644 --- a/scripts/sweep_a11y.mjs +++ b/scripts/sweep_a11y.mjs @@ -62,7 +62,9 @@ import { splitStubs, } from "./lib/axe-scan.mjs"; import { withBrowser } from "./lib/browser.mjs"; -import { numberOption, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; +import { exitOnCrash, numberOption, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs"; + +exitOnCrash(); // The production scheme, read from the one registry check_a11y.mjs reads -- // same bundle, same patches, same run options. A survey run against a @@ -95,7 +97,11 @@ if (cli.stopped === "help") { "usage: node scripts/sweep_a11y.mjs [--theme T] [--viewport V] [--filter SUBSTR]\n" + " [--limit N] [--out FILE] [--resume] [--report]\n" + " [--stock-axe] [--root-dir DIR] [--recycle-every N]\n" - + " [-h, --help]", + + " [-h, --help]\n\n" + + "Exit codes:\n" + + " 0 no accessibility violation was found\n" + + " 1 the sweep found at least one violation\n" + + " 2 a refused command line (a bad --theme or --viewport included), or a crash", ); } diff --git a/scripts/tbbuild.mjs b/scripts/tbbuild.mjs index 3dabf0f0..c40dcf24 100644 --- a/scripts/tbbuild.mjs +++ b/scripts/tbbuild.mjs @@ -23,8 +23,9 @@ // Default: hidden, unless TBBUILD_SHOW is set -- // export that for a session you are watching. // -// Exit codes: 0 clean, 1 the project has errors, 2 the harness failed, -// 3 the compile never settled, 4 the project crashes the compiler. +// Exit codes: 0 clean, 1 the project has errors, 2 the harness could not run (a +// refused command line included) or crashed, 3 the compile never settled, +// 4 the project crashes the compiler. // // ---------------------------------------------------------------- why this // @@ -51,12 +52,14 @@ // front of you". import { existsSync, statSync } from "node:fs"; import path from "node:path"; -import { choiceOption, numberOption, parseCli, printHelpAndExit, refuseTogether, withUsageError } from "../lib/cli.mjs"; +import { choiceOption, exitOnCrash, numberOption, parseCli, printHelpAndExit, refuseTogether, withUsageError } from "../lib/cli.mjs"; import { findIde } from "./lib/tb-install.mjs"; import { COMPILE_TIMEOUT, TARGETS, attachIde, compileOutcome, launchIde, setBuildTarget, shutdownIde, summaryLine, waitForCompile, wantShow } from "./lib/tb-ide.mjs"; import { finishTidy, startTidy } from "./lib/tb-registry.mjs"; +exitOnCrash(); + const USAGE = `usage: node scripts/tbbuild.mjs <project.twinproj> [--ide <twinBASIC.exe>] [--port N] [--arch win32|win64] [--timeout S] [--json] [--keep] [--show|--hide] [-h, --help] Compiles a packed .twinproj in the twinBASIC IDE and prints its diagnostics. @@ -70,7 +73,16 @@ Compiles a packed .twinproj in the twinBASIC IDE and prints its diagnostics. --keep leave the IDE running; its pid is printed as \`ide-pid: N\` --show, --hide show the IDE on the desktop, or keep it on a private one (default: hidden, unless TBBUILD_SHOW is set) - -h, --help print this text and exit`; + -h, --help print this text and exit + +Exit codes: + 0 the project compiled without errors + 1 the project has errors + 2 a refused command line (a path that is not a .twinproj included), no IDE, an IDE + that did not start or expose a debug port, or a crash + 3 the compile never settled: the IDE did not report the project open, or its + diagnostics did not match its status bar + 4 the project crashes the compiler`; function usage() { console.error(USAGE); diff --git a/scripts/tbrun.mjs b/scripts/tbrun.mjs index 8ac27d80..8d897c4d 100644 --- a/scripts/tbrun.mjs +++ b/scripts/tbrun.mjs @@ -18,11 +18,13 @@ // --show / --hide as tbbuild's // // Exit: 0 captured output, 1 the project has compile errors, 2 the harness -// failed -- a build that fails after a clean compile included, and a +// could not run (a refused command line included), a compile never settled, or +// it crashed -- a build that fails after a clean compile included, and a // [RunAfterBuild] Sub that fails code generation, since the probe never runs, // and a procedure the probe calls that fails it, since the probe stops at the // call -- 3 no output: the build produced none in the console before the -// timeout, or the probe ran and printed none after its last Debug.Cls. +// timeout, or the probe ran and printed none after its last Debug.Cls -- 4 the +// compiler crashed, or restarted twice, while compiling the project. // // ---------------------------------------------------------------- why // @@ -101,7 +103,7 @@ import { execFileSync } from "node:child_process"; import { existsSync, readFileSync, mkdirSync, statSync, readdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { choiceOption, numberOption, parseCli, printHelpAndExit, refuseTogether, withUsageError } from "../lib/cli.mjs"; +import { choiceOption, exitOnCrash, numberOption, parseCli, printHelpAndExit, refuseTogether, withUsageError } from "../lib/cli.mjs"; import { click } from "./lib/tb-click.mjs"; import { compilerExe, findIde } from "./lib/tb-install.mjs"; import { BUILD_FAILED, COMPILE_TIMEOUT, TARGETS, attachIde, compileOutcome, keepClears, keptClears, @@ -110,6 +112,8 @@ import { BUILD_FAILED, COMPILE_TIMEOUT, TARGETS, attachIde, compileOutcome, keep import { laneProjectId, stageProject } from "./lib/tb-project.mjs"; import { finishTidy, startTidy } from "./lib/tb-registry.mjs"; +exitOnCrash(); + const USAGE = `usage: node scripts/tbrun.mjs <source-dir> [--ide <twinBASIC.exe>] [--port N] [--arch win32|win64] [--timeout S] [--quiet MS] [--json] [--raw] [--keep] [--no-reap] [--reap-images a,b] [--show|--hide] [-h, --help] Builds an exported twinBASIC source tree in the IDE, runs it, and prints what it @@ -128,7 +132,18 @@ writes to the DEBUG CONSOLE. --reap-images a,b comma-separated image names to harvest (default: the Office suite) --show, --hide as tbbuild's - -h, --help print this text and exit`; + -h, --help print this text and exit + +Exit codes: + 0 the probe ran and its output was captured + 1 the project has compile errors; the diagnostics are printed + 2 a refused command line (a source folder that is missing or has no Settings file + included), no IDE or compiler, an IDE that did not start, a compile that never + settled, a build that failed after a clean compile, a probe that never ran or + stopped at a procedure that failed code generation, or a crash + 3 no output: the console held none before the timeout, or the probe printed none + after its last Debug.Cls + 4 the compiler crashed, or restarted twice, while compiling the project`; const { values, positionals } = withUsageError( () => parseCli(process.argv.slice(2), { @@ -285,7 +300,7 @@ const tidy = values.keep ? null : startTidy({ prefixes: [work] }); let ideRun = null; // A failure before the console is read: said on stdout, as it was when this // phase was tbbuild's output relayed, and ended with tbbuild's meaning of 1 -// (compile errors) or 2 (anything else). +// (compile errors) or 4 (the compiler crashed), and 2 for anything else. function failBuild(code, text) { process.stdout.write(text + "\n"); shutdown(); @@ -306,7 +321,7 @@ if (!cdp) failBuild(2, "the IDE never exposed a debug port"); let outcome = compileOutcome( await waitForCompile(cdp, { project: projPath, timeout: COMPILE_TIMEOUT }), { name: projPath }); -if (!outcome.ok) failBuild(2, outcome.message); +if (!outcome.ok) failBuild(outcome.code === 4 ? 4 : 2, outcome.message); // The target, set on every run, win32 included (setBuildTarget says why). The // probe runs in the compiler that builds it, so under win64 it runs in the @@ -321,7 +336,7 @@ try { } if (target.waited) { outcome = compileOutcome(target.waited, { name: projPath }); - if (!outcome.ok) failBuild(2, outcome.message); + if (!outcome.ok) failBuild(outcome.code === 4 ? 4 : 2, outcome.message); } } catch (e) { failBuild(2, e.message); diff --git a/wisdom/discord/api.mjs b/wisdom/discord/api.mjs index 1ce54467..bbd188aa 100644 --- a/wisdom/discord/api.mjs +++ b/wisdom/discord/api.mjs @@ -7,7 +7,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)) const API_BASE = 'https://discord.com/api/v10' const DISCORD_EPOCH = 1420070400000n -export const EXIT_CAP_REACHED = 2 +export const EXIT_CAP_REACHED = 3 export function snowflakeToTimestamp(snowflake) { return Number((BigInt(snowflake) >> 22n) + DISCORD_EPOCH) diff --git a/wisdom/extract/prep.mjs b/wisdom/extract/prep.mjs index 1c95037f..7fdcbfec 100644 --- a/wisdom/extract/prep.mjs +++ b/wisdom/extract/prep.mjs @@ -17,7 +17,7 @@ export async function runExtract(flags) { if (!existsSync(threadsDir)) { process.stderr.write('[wisdom] Threads directory not found — run process first\n') - process.exit(1) + process.exit(2) } // Mode resolution: --since, --all, --force are mutually exclusive primary @@ -222,7 +222,7 @@ export async function runMerge(flags) { if (!existsSync(outDir)) { process.stderr.write('[wisdom] Findings directory not found\n') - process.exit(1) + process.exit(2) } // Determine mode from the prep / manifest file (whichever exists) @@ -247,7 +247,7 @@ export async function runMerge(flags) { if (!files.length) { process.stderr.write('[wisdom] No result files (extract-results-*.json) to merge\n') - process.exit(1) + process.exit(2) } let allAdditions = [] diff --git a/wisdom/process/thread.mjs b/wisdom/process/thread.mjs index 27b1c3c7..f8ef5526 100644 --- a/wisdom/process/thread.mjs +++ b/wisdom/process/thread.mjs @@ -16,7 +16,7 @@ export async function runProcess(flags) { const guildPath = join(inDir, 'guild.json') if (!existsSync(guildPath)) { process.stderr.write('[wisdom] guild.json not found — run export first\n') - process.exit(1) + process.exit(2) } const channels = JSON.parse(readFileSync(guildPath, 'utf-8')) const { tagMap, channelMap } = buildLookups(channels) @@ -29,7 +29,7 @@ export async function runProcess(flags) { const threadsDir = join(inDir, 'threads') if (!existsSync(threadsDir)) { process.stderr.write('[wisdom] No threads directory found — run export first\n') - process.exit(1) + process.exit(2) } const files = readdirSync(threadsDir).filter(f => f.endsWith('.json')).sort() diff --git a/wisdom/wisdom.mjs b/wisdom/wisdom.mjs index 240973b2..53937188 100644 --- a/wisdom/wisdom.mjs +++ b/wisdom/wisdom.mjs @@ -3,7 +3,7 @@ import { mkdirSync, existsSync } from 'node:fs' import { join, dirname } from 'node:path' import { fileURLToPath } from 'node:url' -import { choiceOption, dateOption, numberOption, parseCli, printHelpAndExit, refuseTogether, withUsageError } from '../lib/cli.mjs' +import { choiceOption, dateOption, exitOnCrash, numberOption, parseCli, printHelpAndExit, refuseTogether, withUsageError } from '../lib/cli.mjs' import { loadConfig } from './config.mjs' import { readJsonFile, writeFileAtomic } from './files.mjs' import { createClient, CapReachedError, timestampToSnowflake, EXIT_CAP_REACHED } from './discord/api.mjs' @@ -12,6 +12,8 @@ import { fetchMessages, appendMessages, loadManifest, saveManifest, highestSnowf import { runProcess } from './process/thread.mjs' import { runExtract, runMerge } from './extract/prep.mjs' +exitOnCrash() + const __dirname = dirname(fileURLToPath(import.meta.url)) // No Discord message is older than this. @@ -109,8 +111,17 @@ async function runExport(flags) { const client = await createClient(config) // Discovery (always runs in full — picks up new channels/threads on incremental runs) - const { allChannels, textChannels, forumChannels, threads } = - await discoverChannels(client, config, flags.channels.length ? flags.channels : null) + let discovered + try { + discovered = await discoverChannels(client, config, flags.channels.length ? flags.channels : null) + } catch (err) { + if (err instanceof CapReachedError) { + process.stderr.write('[wisdom] Cap reached during discovery\n') + process.exit(EXIT_CAP_REACHED) + } + throw err + } + const { allChannels, textChannels, forumChannels, threads } = discovered if (flags.dryRun) { process.stderr.write( @@ -275,6 +286,12 @@ Extract options: Default mode is incremental: only threads whose last_message_id or message_count has changed since the last successful merge are re-extracted. --since, --all, and --force are mutually exclusive primary modes. + +Exit codes: + 0 the command finished, or a dry run did + 2 a refused command line, input that an earlier command should have written (run + that command first), or a crash + 3 the request cap was reached; re-run to continue ` const { command, flags } = parseArgs(process.argv) From aff7f46e301b1f2a35c52f8757a214accffd8340 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober <kuba@mareimbrium.org> Date: Wed, 30 Sep 2026 14:07:54 +0200 Subject: [PATCH 18/21] docs: Tools.md names --exported for the package tools --- builder/PLAN-TOOLING-REVIEW.md | 5 ++++- docs/Documentation/Tools.md | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/builder/PLAN-TOOLING-REVIEW.md b/builder/PLAN-TOOLING-REVIEW.md index 8802c00e..5c13f186 100644 --- a/builder/PLAN-TOOLING-REVIEW.md +++ b/builder/PLAN-TOOLING-REVIEW.md @@ -1155,7 +1155,10 @@ spelling, so they are unchanged. `builder/REVIEW-USECASES-16969e5.md:329` keeps because it is a record of that round. `check_cli: 816 probes, all pass` (810 before): eight re-pointed cases and two re-pointed `build_corpus` refusals, one case per renamed option showing that the old spelling is refused, and `check_a11y_fingerprint --out` without a value. -The `git grep` for the old spellings finds only those refusal cases. `compare_trees`: Tools +The `git grep` for the old spellings finds only those refusal cases. It missed one sentence, +Tools.md's note that the two package tools run anywhere when given an export, which kept +`--src`. That was found while landing C75, and fixed in `docs: Tools.md names --exported for +the package tools`, because folding it into this commit would have rewritten history. `compare_trees`: Tools online and offline, the search data and `book.html`. Lint stays at `Checked 172 files` (`perf/` is not linted). On the owner's next push, CI prints `check_cli: 816 probes, all pass`. diff --git a/docs/Documentation/Tools.md b/docs/Documentation/Tools.md index 0720bbfc..bb040c06 100644 --- a/docs/Documentation/Tools.md +++ b/docs/Documentation/Tools.md @@ -15,7 +15,7 @@ One-line-per-tool reference for every executable in the documentation repository ## Batch wrappers at the repository root {: #batch-wrappers } -All seven sit at the repository root, beside `package.json` --- not under `docs/`. Each uses `@pushd "%~dp0"` to run from that root regardless of where it is invoked from, and each entry below gives the POSIX equivalent of what it runs. Those equivalents have no `pushd` in front of them, so **run them from the repository root** --- `tbdocs`'s `--src docs`, [`check_publish_policy.mjs`](#check-publish-policy)'s default source root, and every path handed to [`render-book.mjs`](#bookrender-bookmjs) are all resolved against the working directory. `examples.bat` and `addin-test.bat` are the exceptions to "each entry below gives the POSIX equivalent": they need a twinBASIC install and drive the IDE, so they are Windows-only, and neither is part of the site build. Four other tools are Windows-specific for the same reason and are likewise not part of it: [`scripts/tbbuild.mjs`](#tbbuild) and [`scripts/tbrun.mjs`](#tbrun), which drive the twinBASIC IDE, and [`census_attributes.mjs`](#census-attributes) and [`build_package_api.mjs`](#build-package-api), which run the twinBASIC compiler's `export` verb --- though those two are cross-platform when given an already-exported tree with `--src`. Nothing else in the repository is: `tbdocs` and every gate in both wrappers is a Node script, and CI runs all of them on `ubuntu-latest` except [`check_tree_fresh.mjs`](#check-tree-fresh), which guards against a failure mode CI cannot have. +All seven sit at the repository root, beside `package.json` --- not under `docs/`. Each uses `@pushd "%~dp0"` to run from that root regardless of where it is invoked from, and each entry below gives the POSIX equivalent of what it runs. Those equivalents have no `pushd` in front of them, so **run them from the repository root** --- `tbdocs`'s `--src docs`, [`check_publish_policy.mjs`](#check-publish-policy)'s default source root, and every path handed to [`render-book.mjs`](#bookrender-bookmjs) are all resolved against the working directory. `examples.bat` and `addin-test.bat` are the exceptions to "each entry below gives the POSIX equivalent": they need a twinBASIC install and drive the IDE, so they are Windows-only, and neither is part of the site build. Four other tools are Windows-specific for the same reason and are likewise not part of it: [`scripts/tbbuild.mjs`](#tbbuild) and [`scripts/tbrun.mjs`](#tbrun), which drive the twinBASIC IDE, and [`census_attributes.mjs`](#census-attributes) and [`build_package_api.mjs`](#build-package-api), which run the twinBASIC compiler's `export` verb --- though those two are cross-platform when given an already-exported tree with `--exported`. Nothing else in the repository is: `tbdocs` and every gate in both wrappers is a Node script, and CI runs all of them on `ubuntu-latest` except [`check_tree_fresh.mjs`](#check-tree-fresh), which guards against a failure mode CI cannot have. ### build.bat From 45f3aaed59075a84c44d43ae12e11ff0ca1500b9 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober <kuba@mareimbrium.org> Date: Wed, 30 Sep 2026 14:09:47 +0200 Subject: [PATCH 19/21] serve.bat: return tbdocs's exit code --- builder/PLAN-TOOLING-REVIEW.md | 11 +++++++++++ docs/Documentation/Tools.md | 2 +- serve.bat | 4 ++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/builder/PLAN-TOOLING-REVIEW.md b/builder/PLAN-TOOLING-REVIEW.md index 5c13f186..86b006c2 100644 --- a/builder/PLAN-TOOLING-REVIEW.md +++ b/builder/PLAN-TOOLING-REVIEW.md @@ -1236,6 +1236,17 @@ wrappers, which capture it before `popd`. **Verify.** A `serve.bat` that cannot start, because its port is taken, exits non-zero. +**Landed.** `serve.bat` captures `tbdocs`'s exit code before `popd`, as `build.bat` and the +other wrappers do, so a failed first build, a port in use, a refused command line and a crash +come back as 2. Tools.md's `serve.bat` section had said, since C74, that it always returns 0; +it now gives `tbdocs`'s codes. The port has to be held on every interface: `tbdocs` listens +on all of them, and a server bound to 127.0.0.1 alone does not clash with it on Windows (the +first attempt served for two minutes). With port 4395 held by a `net` server on all +interfaces, and `--dest docs/_serve-c75`, HEAD's `serve.bat` printed `serve: port 4395 +already in use` and exited 0; the working one prints the same and exits 2. The scratch +`--dest` and the copy of HEAD's wrapper were removed. `compare_trees`: Tools, online and +offline, the search data and `book.html`. Lint, `check_cli` and regex safety are unchanged. + ## Phase 4: splits Decision 2: a split is taken only where the evidence says good practice calls for it, and diff --git a/docs/Documentation/Tools.md b/docs/Documentation/Tools.md index bb040c06..2bde51aa 100644 --- a/docs/Documentation/Tools.md +++ b/docs/Documentation/Tools.md @@ -43,7 +43,7 @@ POSIX: Starts a long-lived dev process. Wraps `node builder/tbdocs.mjs --src docs --serve` and forwards extra arguments through `%*`. After an initial build, an HTTP server binds to port 4000 (pass `--port <N>` to use a different port), a recursive source-tree watcher fires a debounced rebuild on each change, and a browser connected to the page auto-reloads via SSE on each successful rebuild. Offline and PDF passes are skipped each rebuild. Ctrl+C exits cleanly. **Only failures (4xx, 5xx, server exceptions) are logged** --- successful requests are silent. The watcher covers `docs/` and the worker pool is reused across rebuilds, so **an edit under `builder/` does not reach a running preview** and needs a restart --- see [why `serve.bat` does not show a builder change](Extending#serve-does-not-reload). -Exit codes: always **0**. `serve.bat` ends with `popd`, which resets the code, so it does not return `tbdocs`'s **2** for a failed first build or a port already in use; run `node builder/tbdocs.mjs --src docs --serve` to see it. +Exit codes: `tbdocs`'s own, as the other wrappers return theirs: **0** the server was stopped with Ctrl+C, **2** a refused command line, a failed first build, a port already in use, or a crash. ### check.bat diff --git a/serve.bat b/serve.bat index 338100fe..8607048d 100644 --- a/serve.bat +++ b/serve.bat @@ -1,3 +1,7 @@ @pushd "%~dp0" node builder\tbdocs.mjs --src docs --serve %* +@rem popd resets ERRORLEVEL, so capture it first -- otherwise a server that +@rem could not start (a failed first build, a port in use) would report success. +@set "TBDOCS_ERR=%ERRORLEVEL%" @popd +@exit /b %TBDOCS_ERR% From a199224d32ed598ed6c5a7c67c5bc86217870c49 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober <kuba@mareimbrium.org> Date: Wed, 30 Sep 2026 14:21:49 +0200 Subject: [PATCH 20/21] scripts: addin_test puts the registry back after a crash --- builder/PLAN-TOOLING-REVIEW.md | 20 +++++ docs/Documentation/Tools.md | 5 +- scripts/addin_test.mjs | 139 +++++++++++++++++++++------------ 3 files changed, 112 insertions(+), 52 deletions(-) diff --git a/builder/PLAN-TOOLING-REVIEW.md b/builder/PLAN-TOOLING-REVIEW.md index 86b006c2..68d25357 100644 --- a/builder/PLAN-TOOLING-REVIEW.md +++ b/builder/PLAN-TOOLING-REVIEW.md @@ -1227,6 +1227,26 @@ and exits 3 if that fails. **Verify.** A throw put in after the snapshot through `c43-fault.mjs`, with the kit's `reg-snap.mjs` before and after: identical registry, exit 2; a throw from the restore as well: exit 3. +**Landed.** Once `addin_test` has recorded the registry and the add-ins' settings, it replaces +`exitOnCrash`'s handler with its own. A crash prints its error, ends the lanes as Ctrl+C does, +waits up to 10 s for them to close, and then runs the same put-back as the end of a run: +`putBack()`, now a function both paths call, restores the IDE's entries and the settings, +checks that nothing names a lane's folder, and deletes the work folders. The run exits 2 if +that found no problem and 3 if it found one, and a crash during the put-back exits 3 at once. +If the lanes' ending lets `runAll` return, the main path waits and leaves the exit to the +handler. A failure to record the settings, which already put the registry back, now exits 3 +when that fails, not 2. The usage text's 2 and 3 lines and Tools.md's section say so. With +`--only sample15` between two `reg-snap.mjs` snapshots, through `c43-fault.mjs` (whose hooks +now take a list of faults), the kit's `c74a-faults.mjs` gave: a throw before the lanes start, +exit 2; a throw from a lane's output while its IDE runs, exit 2 after `registry: put back (2 +project-state, 21 recent-list and 3 association writes)`; the same with the put-back made to +report a problem, exit 3; the same with a throw inside the put-back after the restore, exit 3. +The registry was identical before and after in all four (sha256 `41c09aafafe70eac`). +`addin-test.bat`: `10 of 10 lane(s) ran: 10 passed`, `registry: put back (20 project-state, 21 +recent-list and 3 association writes)`. `compare_trees`: Tools, online and offline, the search +data and `book.html`. Lint, `check_cli` (860) and regex safety unchanged; `build.bat`, +`check.bat` and `test.bat` clean. + ### C75 — `serve.bat: return tbdocs's exit code` **A6-5 (R3).** `serve.bat` does not pass its child's exit code back, unlike the other diff --git a/docs/Documentation/Tools.md b/docs/Documentation/Tools.md index 2bde51aa..9e8d2c02 100644 --- a/docs/Documentation/Tools.md +++ b/docs/Documentation/Tools.md @@ -935,7 +935,8 @@ of the same add-in shares, so a lane names its add-ins' application names in `la names them, so that its add-ins start from their defaults, and put back at the end. Two lanes that name the same one never run at once. Afterwards it confirms that no entry names a lane's folder and that the settings are as found, and reports any new application key that no lane -named. Pressing Ctrl+C ends the lanes and still puts everything back. +named. Pressing Ctrl+C ends the lanes and still puts everything back, and so does a crash of +the runner once it has recorded the registry. **An add-in under test starts no browser.** Every IDE the harness starts, including those of `tbbuild`, `tbrun` and `check_examples`, has the environment variable `TB_ADDIN_TEST` set to @@ -950,7 +951,7 @@ it takes that folder from the IDE, which builds its path from the `APPDATA` envi variable. Every IDE a lane starts has an `APPDATA` inside the lane's work folder, and a lane fails if its IDE's add-in folder turns out to be anywhere else. -Exit codes: **0** every lane passed, and the registry is as it was found; **1** a lane failed, or the run was interrupted; **2** the harness could not run: a refused command line, no IDE, no matching lane, a registry it could not record, or a crash; **3** the registry or a work folder was not put back (see the lines above), which wins over a 1 because the registry is what to repair. +Exit codes: **0** every lane passed, and the registry is as it was found; **1** a lane failed, or the run was interrupted; **2** the harness could not run: a refused command line, no IDE, no matching lane, a registry it could not record, or a crash after which the registry was put back; **3** the registry or a work folder was not put back (see the lines above), at the end of a run or after a crash, which wins over a 1 because the registry is what to repair. ### check_tb_registry.mjs {: #check-tb-registry } diff --git a/scripts/addin_test.mjs b/scripts/addin_test.mjs index 4c5f155f..fe21647c 100644 --- a/scripts/addin_test.mjs +++ b/scripts/addin_test.mjs @@ -86,8 +86,9 @@ Exit codes: 0 every lane passed, and the registry is as it was found 1 a lane failed, or the run was interrupted 2 the harness could not run: a refused command line, no IDE, no matching lane, a - registry it could not record, or a crash - 3 the registry or a work folder was not put back; see the lines above`; + registry it could not record, or a crash after which the registry was put back + 3 the registry or a work folder was not put back, at the end of a run or after a + crash; see the lines above`; const { values } = withUsageError(() => parseCli(process.argv.slice(2), { options: { @@ -162,17 +163,41 @@ try { // named: an add-in saving settings nobody told the runner about. appsBefore = subkeyNames(SETTINGS_ROOT).map((n) => n.toLowerCase()); } catch (e) { - finishTidy(tidy); - die(2, `could not record the add-ins' settings: ${e.message}`); + const tidied = finishTidy(tidy); + die(tidied ? 2 : 3, `could not record the add-ins' settings: ${e.message}`); } +let interrupted = false; +const children = new Set(); + +// A crash from here on still puts back what the run recorded, as the end of a +// run does: the lanes are ended first, as Ctrl+C ends them, so that no IDE +// writes to the registry after it is put back. It exits 3 if the registry or a +// work folder was not put back, and 2 if it was. A crash while putting it back +// leaves the registry as it happens to be, so it exits 3 at once. +let crashed = false, putBackStarted = false, putBackResult = null; +process.removeAllListeners("uncaughtException"); +process.on("uncaughtException", (err) => { + console.error(err); + if (putBackResult) process.exit(putBackResult.problems.length ? 3 : 2); + if (crashed || putBackStarted) process.exit(3); + crashed = true; + for (const child of children) child.kill(); + const deadline = Date.now() + 10_000; + const wait = setInterval(() => { + if (children.size && Date.now() < deadline) return; + clearInterval(wait); + const { tidied, problems } = putBack(); + console.error(registryLine(tidied, problems)); + process.exit(problems.length ? 3 : 2); + }, 100); +}); + // ---------------------------------------------------------------- the lanes console.log(`addin-test: BETA ${buildNumber(ide) ?? "?"}, ${lanes.length} lane(s) on ports ` + `${basePort}-${basePort + lanes.length - 1}, ${Math.min(jobs, lanes.length)} at a time`); -let interrupted = false; -const children = new Set(); process.on("SIGINT", () => { if (interrupted) return; interrupted = true; @@ -247,63 +272,77 @@ async function runAll() { } const results = await runAll(); +// A crash ended the lanes, so runAll returned; the crash handler puts the +// registry back and exits, and its timer keeps the process alive until then. +if (crashed) await new Promise(() => {}); // ---------------------------------------------------------------- putting it back -const problems = []; -const tidied = finishTidy(tidy); -if (!tidied) problems.push("the IDE's registry entries could not be put back (see the warning above)"); -try { - if (apps.length) restoreKeys(settingsBefore); -} catch (e) { - problems.push(`the add-ins' settings could not be put back: ${e.message}`); -} +// Called once every lane has ended, at the end of a run or after a crash. +function putBack() { + putBackStarted = true; + const problems = []; + const tidied = finishTidy(tidy); + if (!tidied) problems.push("the IDE's registry entries could not be put back (see the warning above)"); + try { + if (apps.length) restoreKeys(settingsBefore); + } catch (e) { + problems.push(`the add-ins' settings could not be put back: ${e.message}`); + } -// The check that the run left nothing: an entry naming a lane's folder in the -// IDE's lists, a build target remembered for one, or an add-in's settings that -// differ from what was recorded. Another session's IDE that is open meanwhile -// can write its own copy of the recent list back, which is the one way an -// entry could return (WIP.Harness.md). -const folders = lanes.map((l) => norm(l.work) + "\\"); -try { - const lists = ideLists(); - const left = [...lists.projectState, ...lists.recentlyOpened] - .filter((p) => p && folders.some((f) => norm(p).startsWith(f))); - if (left.length) problems.push(`the IDE's lists still name the lanes' folders: ${left.join(", ")}`); - const targets = sweepArchitectureMemory(lanes.map((l) => l.work)); - if (targets) problems.push(`${targets} build target(s) were still remembered for the lanes' folders`); - const settingsAfter = snapshotSettings(); - for (let i = 0; i < apps.length; i++) { - if (JSON.stringify(settingsAfter[i]) !== JSON.stringify(settingsBefore[i])) { - problems.push(`the settings of ${apps[i]} are not as they were found`); + // The check that the run left nothing: an entry naming a lane's folder in the + // IDE's lists, a build target remembered for one, or an add-in's settings that + // differ from what was recorded. Another session's IDE that is open meanwhile + // can write its own copy of the recent list back, which is the one way an + // entry could return (WIP.Harness.md). + const folders = lanes.map((l) => norm(l.work) + "\\"); + try { + const lists = ideLists(); + const left = [...lists.projectState, ...lists.recentlyOpened] + .filter((p) => p && folders.some((f) => norm(p).startsWith(f))); + if (left.length) problems.push(`the IDE's lists still name the lanes' folders: ${left.join(", ")}`); + const targets = sweepArchitectureMemory(lanes.map((l) => l.work)); + if (targets) problems.push(`${targets} build target(s) were still remembered for the lanes' folders`); + const settingsAfter = snapshotSettings(); + for (let i = 0; i < apps.length; i++) { + if (JSON.stringify(settingsAfter[i]) !== JSON.stringify(settingsBefore[i])) { + problems.push(`the settings of ${apps[i]} are not as they were found`); + } } + const named = apps.map((a) => a.toLowerCase()); + const created = subkeyNames(SETTINGS_ROOT) + .filter((n) => !appsBefore.includes(n.toLowerCase()) && !named.includes(n.toLowerCase())); + if (created.length) { + problems.push(`HKCU\\${SETTINGS_ROOT} gained ${created.map((n) => `"${n}"`).join(", ")} during the ` + + "run, which no lane names in test/addin/lanes.mjs. An add-in under test that saves settings " + + "must be named there, or its settings stay behind; the key is left as it is"); + } + } catch (e) { + problems.push(`could not check the registry: ${e.message}`); } - const named = apps.map((a) => a.toLowerCase()); - const created = subkeyNames(SETTINGS_ROOT) - .filter((n) => !appsBefore.includes(n.toLowerCase()) && !named.includes(n.toLowerCase())); - if (created.length) { - problems.push(`HKCU\\${SETTINGS_ROOT} gained ${created.map((n) => `"${n}"`).join(", ")} during the ` + - "run, which no lane names in test/addin/lanes.mjs. An add-in under test that saves settings " + - "must be named there, or its settings stay behind; the key is left as it is"); + + // A folder that will not delete is held open by a process that outlived its + // lane, which is worth hearing about (WIP.Harness.md, The IDE runs inside a job). + for (const l of lanes) { + try { removeTree(l.work); } + catch (e) { problems.push(`${l.work} could not be deleted (${e.code}): is a process of its lane still running?`); } } -} catch (e) { - problems.push(`could not check the registry: ${e.message}`); + putBackResult = { tidied, problems }; + return putBackResult; } -// A folder that will not delete is held open by a process that outlived its -// lane, which is worth hearing about (WIP.Harness.md, The IDE runs inside a job). -for (const l of lanes) { - try { removeTree(l.work); } - catch (e) { problems.push(`${l.work} could not be deleted (${e.code}): is a process of its lane still running?`); } +function registryLine(tidied, problems) { + const settingsNote = apps.length ? `, and the settings of ${apps.join(", ")} as found` : ""; + return problems.length + ? `registry and work folders: ${problems.length} problem(s)\n ${problems.join("\n ")}` + : `registry: put back (${tidied.projectState} project-state, ${tidied.recentlyOpened} recent-list ` + + `and ${tidied.association ?? "no"} association writes); nothing names the lanes' folders${settingsNote}`; } +const { tidied, problems } = putBack(); const failed = results.filter((r) => r.code !== 0); -const settingsNote = apps.length ? `, and the settings of ${apps.join(", ")} as found` : ""; console.log(""); -console.log(problems.length - ? `registry and work folders: ${problems.length} problem(s)\n ${problems.join("\n ")}` - : `registry: put back (${tidied.projectState} project-state, ${tidied.recentlyOpened} recent-list ` + - `and ${tidied.association ?? "no"} association writes); nothing names the lanes' folders${settingsNote}`); +console.log(registryLine(tidied, problems)); console.log(`${results.length} of ${lanes.length} lane(s) ran: ${results.length - failed.length} passed` + (failed.length ? `, ${failed.length} failed (${failed.map((r) => r.lane.name).join(", ")})` : "") + (interrupted ? "; interrupted" : "")); From 02eb1670adff2f6e5f58d83d4e307ad83953a4d2 Mon Sep 17 00:00:00 2001 From: Kuba Sunderland-Ober <kuba@mareimbrium.org> Date: Wed, 30 Sep 2026 14:26:50 +0200 Subject: [PATCH 21/21] builder: cut Phase 3's landed entries in the tooling plan --- builder/PLAN-TOOLING-REVIEW.md | 491 ++++----------------------------- 1 file changed, 53 insertions(+), 438 deletions(-) diff --git a/builder/PLAN-TOOLING-REVIEW.md b/builder/PLAN-TOOLING-REVIEW.md index 68d25357..b6922c9c 100644 --- a/builder/PLAN-TOOLING-REVIEW.md +++ b/builder/PLAN-TOOLING-REVIEW.md @@ -170,7 +170,9 @@ C22a–C22j, C25a–C25f, C27a and C27b) were cut the same day, and their full t file as it stood before `builder: cut Phase 1's landed entries in the tooling plan`. Those of Phase 2 (C31–C70, with C32a, C41a–C41c, C51a, C51b, C65a–C65e, C67a and C67b) were cut on 2026-09-28, and their full text is in this file as it stood before `builder: cut Phase 2's -landed entries in the tooling plan`. A pointer below to a cut entry's Landed note means that +landed entries in the tooling plan`. Those of Phase 3 (C71–C75, with C72a, C72b and C74a) were +cut on 2026-09-30, and their full text is in this file as it stood before `builder: cut +Phase 3's landed entries in the tooling plan`. A pointer below to a cut entry's Landed note means that text. Line numbers are the review's, at `fe9ce12b`, and move as the commits land. ### The organising idea @@ -369,22 +371,11 @@ saved-segment check looks for a `BUILD_FAILED` line the same way. ### C17 — `scripts: harness CLIs reject a missing value; tbbuild finds its project` -**Carried forward.** In `tbbuild`, `check_examples`, `census_attributes` and -`build_package_api` (and in `tbdocs`, under C18), a value flag given no value, whether at the end of -the command line or followed immediately by another flag, is a usage error. This matches -Node's strict `parseArgs` (confirmed on Node 24.13, which also accepts a lone `-` as a value), -so `lib/cli.mjs` (C47) needs no behaviour change here when C49 migrates these tools onto it. -Ports and counts must be whole numbers; a timeout may be any positive number. The check runs -before `--help` is handled. +Landed. ### C18 — `builder, scripts: a command-line error exits outside the link bitmask` -**Carried forward.** A command-line error in `tbdocs` and in `check_links.mjs` exits 4, a -value outside the existing 1 (link failure) / 2 (integrity failure) / 3 (both) bitmask. This -covers a value flag given no value or followed by another flag, an out-of-range `--port`, and -(from C13a) a `--dest` that overlaps the source tree. C47, C49, C52 and C60 build on this -value; Phase 3 (C71, C72) treats these two tools as the exception to "an unknown flag or a bad -value exits 2." +Landed. ### C19 — `scripts: close the browser on every exit path, through lib/browser.mjs` @@ -660,52 +651,15 @@ every other argument. ### C49 — `scripts: the harness tools parse through lib/cli.mjs` -**Carried forward.** `tbbuild`, `check_examples`, `census_attributes` and -`build_package_api` take the default `acceptsValue` and print a `CliError` on stderr with exit -2: `tbbuild` the message and then its usage line, `check_examples` with a `check_examples: ` -prefix, the other two the message alone. `tbbuild` and `check_examples` check their numbers -before `--help`. `tbrun` and `addin_test` take `acceptsValue: () => true` and read each value -as `values.x || default`, so a value flag at the end of the list, or given `""`, gets its -default and any other argument after it is its value; C72 removes this. All six ignore an -unknown flag (`unknown: "ignore"`), and `tbbuild` and `tbrun` take one positional and ignore -a second. `gen_attribute_probes` takes `unknown: "positional"` with no maximum, so a bare -`--help` is still its output folder (C71). `check_examples` prints its help through -`printHelpAndExit`; `census_attributes` prints the slice of its own header comment with -`console.log`; `build_package_api` has no `--help`; `check_tb_registry` reads no arguments. +Landed. ### C50 — `scripts: the gates and link tools parse through lib/cli.mjs` -**Carried forward.** The four gates that read their flags with `includes` -(`check_regex_safety`, `check_code_regions`, `check_gate_lists`, -`convert_em_dash_separators`) and `check_publish_policy` take `unknown: "ignore"`; -`check_publish_policy` reads `--src` with `acceptsValue: () => true`, so given last it is -undefined. `check_links` keeps its collect-and-warn handling of unknown flags (`unknown: -"ignore"`, `acceptsValue: (v) => v !== undefined`, a missing value reported as `--x requires a -value`, and a warning list rebuilt from the kept tokens' indexes, since an unknown `--flag` -without `=` takes the positional after it along); C72 removes it. `check_links_diff` and -`crawl_check` take `acceptsValue: () => true`, so a value flag given last is `undefined` -(`NaN` once read as a number), and turn every `CliError` into their own words, `unknown -argument: X` and `unknown flag: X`. `compare_trees` splits its list at the first `--` before -parsing, and parses the rest with its old value guard (`v !== undefined && !v.startsWith("--")`) -and `stopAt: ["help"]`. `check_lint` refuses any error and any token beyond `--staged`. -`survey_tooling` parses through `parseCli`'s strict rules, with its usage line after each -error. +Landed. ### C51 — `book, eval, wisdom: parse through lib/cli.mjs` -**Carried forward.** `render-book`, `build_corpus`, the `eval/` scripts (`run_case`, -`search_quality`, `nav_hops`, `site_search`, `transcript`) and `wisdom` take `acceptsValue: () -=> true` and convert a value only when one was given, so a trailing value flag still fails as -it did (a path `TypeError`, a `NaN`, a `split` of undefined). Five refuse an unknown argument -through `withUsageError`: `render-book` with `unknown arg: X` (exit 2), `build_corpus` with -`unknown argument: X` (1), `run_case` with `unknown argument: X` (2), `search_quality` with -`unrecognised argument: X` (1) and `wisdom` with `Unknown option: X` (1). `nav_hops`, -`site_search` and `transcript` take `unknown: "positional"`, since an unknown flag was a -pattern, a search term or an ignored argument to them. `transcript` declares `--help` without -`-h`, because a lone `-h` is its file argument, so `-h` alone exits 0 and `--help` alone exits -1 until C71. `wisdom`'s command is its first argument, whatever that is, and the rest is parsed -against one table for all three commands. `printHelpAndExit` prints each tool's help, keeping -each one's exit-code condition, and `wisdom`'s dispatch default prints to stderr. +Landed. ### C51a — `scripts: check_cli's transcript -x case passes on Linux` @@ -717,13 +671,7 @@ Landed. ### C52 — `builder: tbdocs parses through lib/cli.mjs` -**Carried forward.** `builder/command-line.mjs` exports `OPTIONS`, `DEFAULTS` and -`parseCommandLine(argv)`. `tbdocs` is already strict: it parses with `unknown: "error"`, no -positionals and the default value rule, through `withUsageError` with exit 4 (C18). A refusal -reads `Unknown argument: <arg>` as given, a missing value `--x needs a value`, and an argument -after `--` is refused under its own name. `--stall-timeout` keeps a hand check, because -`numberOption` refuses the blank value that `--stall-timeout=` gives, and that disables the -watchdog. +Landed. *`builder/`'s helpers, defined twice: C53–C60.* @@ -869,403 +817,69 @@ expectations, the usage texts and Tools.md together. ### C71 — `scripts, book, eval, wisdom: --help prints usage to stdout and exits 0` -**L1-6 (R2), A5-1's help half, A10-1.** `--help` is handled four ways, and twelve tools ignore -it. `gen_attribute_probes.mjs:1147-1158` takes a bare `--help` as its output folder and creates -`--help/Sources`; `render-book.mjs:210-220` rejects it as unknown; `transcript.mjs` exits 1. - -**Change.** Every tool prints its usage to stdout and exits 0, with no side effect. - -**Verify.** `check_cli.mjs` gains a `--help` case for every tool, safe to run for all of them -once this lands; no file or folder appears. - -**Landed.** Every Node tool, 45 in all (the `tbdocs` builder, `render-book`, `wisdom`, the six -`eval/` tools and every command under `scripts/`, the seven that read no arguments included), -answers `--help` and `-h` by printing its usage to stdout and exiting 0 (the owner's four -choices, 2026-09-28: every tool; the parse stops at `--help`; `-h` everywhere; a short usage -text where there was none). Each declares `help` with `short: "h"` and `stopAt: ["help"]`, so -arguments before it are read as before and nothing after it is, and answers it straight after -the parse, before any number, project or install check. The seven argument-less gates parse -with `unknown: "ignore"` and no positionals, so every other argument is still ignored. -`builder/command-line.mjs` exports `USAGE` and returns `{ ...DEFAULTS, help: true }` when the -parse stops at help. 28 tools gained or rewrote a `USAGE` constant: the usage line, one sentence, -the options; no exit codes, which are C74's. `check_lint` and `render-book` keep their pinned -one-line error message as a `SYNOPSIS` that `USAGE` starts with; `tbbuild`, `tbrun`, -`crawl_check` and `survey_tooling` print the whole `USAGE` on their usage errors, which keep -their stream and exit code. `transcript`'s `-h` is help, not a file name. `wisdom` answers -`--help` as its command or after one. Unchanged: `impexp.mjs`, already so, and `check_links`, -whose raw-argument test already answered both; a bare invocation, or a missing project or -input, keeps its old answer everywhere. `check_regex_safety`'s internal `--shard` is not in -its usage. Two Found items folded in: `census_attributes` now prints a `USAGE` with -`--dump-sites` (its header points there), and `check_links`' help says a missing sitemap or -search file prints a warning. Tools.md's introduction says every Node tool answers `--help` -(`build_fonts.py` reads no arguments). - -`check_cli` gains `HELP_TOOLS`, which adds a `--help` and a `-h` case for every tool the -table does not already hold (61 cases), and a probe after every case whose arguments hold -`--help` or `-h` that its scratch folder is still empty (104); 24 existing cases changed their -expectation, among them `tbbuild x.twinproj --port 0 --help`, `check_examples --jobs 0 ---help`, `build_corpus -hq` (the parse stops at the `h` of the group) and `wisdom bogus ---help`, all now usage on stdout and exit 0; `compare_trees --bogus --help` still exits 2. -`check_cli: 423 probes, all pass` (258 before). With `gen_attribute_probes`' `help` option -removed through `c43-fault.mjs` in `NODE_OPTIONS`, it fails 4 probes, two of them the empty -folder (`--help`, `--help-2`, `--help-explore`, `probe-key.md` appeared, and the same for `-h`). -A Sonnet agent checked each new usage text against its code; the eight wrong claims and five -missing options it found were fixed. `compare_trees`: Tools online and offline, the search -data and `book.html`. Lint `Checked 172 files`; regex safety `528 literals + 30 constructed in -130 files ... 489 safe, 69 polynomial, 0 undecided, 0 exponential` (the new ones all safe); -`build.bat`, `check.bat` and `test.bat` clean. On the owner's next push CI prints -`check_cli: 423 probes, all pass` and the regex-safety line above. +**Carried forward.** Every Node tool answers `--help` and `-h` by printing its `USAGE` to +stdout and exiting 0. Each declares `help` with `short: "h"` and `stopAt: ["help"]`, answers it +straight after the parse, before any number, project or install check, and has a `USAGE` +constant (the usage line, one sentence, the options, and the `Exit codes:` block). A new tool +is added to `HELP_TOOLS` in `scripts/check_cli.mjs`, which checks that its help exits 0 and +leaves its scratch folder empty. ### C72 — `scripts, book, eval, wisdom: an unknown flag or a bad value exits 2` -**L1-7, L1-5 (R2), A5-1's typo half, A10-1.** Eleven tools ignore an unknown flag, -`check_links.mjs` warns, eight refuse it with 2, some throw to 1 or 2, and `wisdom` refuses it -with 1. `check_links.mjs:307-314` still tolerates flags "passed through via check.bat's %*", -and the comment at `:406-412` still says `check.bat` passes it arguments; `check.bat` no -longer calls it (`PLAN-checks.md:14`). `tbrun` and `addin_test` -substitute their default for an explicit empty value. - -**Change.** Strict parsing everywhere: an unknown flag or a bad value is an error on stderr, -with exit 2, or C18's value in the two link tools. `check_links.mjs`'s tolerance goes, and -both comments are corrected. Any exception a tool keeps is stated in its usage text and in Tools.md. - -**Verify.** `check_cli.mjs` gains an unknown-flag case and an empty-value case for every tool. - -**Landed** as `builder, scripts, book, eval, wisdom: a refused command line exits 2` (see -"Where the plan was wrong"), on the owner's four choices of 2026-09-29: strict everywhere, -with a term that starts with a dash given after `--`; an empty value refused by `lib/cli.mjs`; -every usage error reported one way; the values a tool reads after the parse left to C72a. -`parseCli` has lost `unknown`, `acceptsValue` and the `ignored` result, and refuses an -unknown option, a boolean given a value, a value flag with none, an empty value (code -`empty-value`, `--x needs a non-empty value`) unless the option's spec says `empty: true`, -and a positional beyond the tool's count. Only `tbdocs`' `--baseurl` allows an empty value -(the site root), so `--stall-timeout=` is refused where it was 0. Every tool but the four -already strict (`check_impexp_parity`, `check_pdf_shims_equiv`, `survey_tooling` and -`impexp.mjs`, which has its own parser) drops its leniency: the gates that ignored every argument but `--help`, the harness tools that -ignored an unknown flag, `tbrun`'s and `addin_test`'s empty value taking the default, -`check_publish_policy`'s, `crawl_check`'s and the `eval/` tools' flag at the end taken as -`undefined`, the a11y tools' flag taken as the value before it. `crawl_check` takes one start -URL, `transcript` one file (simply the positional), `gen_attribute_probes` a folder and a key; -`nav_hops`, `site_search`, `transcript` and `gen_attribute_probes` say in their usage that a -term, file or folder starting with a dash goes after `--`, and so does `eval/protocol.md` for -the evaluator's `site-search`. `check_links`' tolerance goes whole: the unrecognised-argument -warning, the rule that an unknown `--flag` took the positional after it, and `--threads` -(accepted and unused; nothing passed it). The plan's `check_links.mjs:307-314` and `:406-412` -no longer held the `check.bat` comments, which went with C50's migration. - -Every usage error goes to stderr with exit 2, or 4 in `tbdocs` and `check_links`, in the -`CliError`'s own words (`unknown option: --bogus`, `--theme needs a value`, `unexpected -argument: x`). The rewordings that misnamed the fault went: the a11y tools' `unknown arg:` -for a missing value, `crawl_check`'s `unknown flag:`, `check_links_diff`'s and the -`eval/` tools' `unknown argument:`, `search_quality`'s `unrecognised argument:`, `wisdom`'s -`Unknown option:`, `tbdocs`' `Unknown argument:`. A tool's name prefix stays -(`check_examples: `, `compare_trees: `, `check_lint: `, `check_links`' `error: `), and so does -a usage text printed after the message; `check_lint` names the fault before its synopsis -line, where it printed the synopsis alone. `build_corpus`, `search_quality`, `site_search`, -`transcript` and `wisdom` exit 2 where they exited 1; `wisdom` with no command exits 2 where it -exited 0, and names an unknown command; a missing required argument prints the usage on -stderr in `gen_attribute_probes`, `nav_hops`, `run_case`, `site_search`, `transcript` and -`build_corpus`; `check_links` writes its command-line errors, and its usage after no -arguments, to stderr. `render-book`'s missing input stays exit 1: it is not a usage error. -`wisdom`'s 2 is now also its request cap's code, a code with two meanings for C74. Tools.md -states the rule once beside the `--help` sentence and in the `tbdocs`, `check_links`, -`crawl_check` and `check_cli` sections; Extending.md's, PDF-Generation.md's and Wisdom.md's -exit tables, Pipeline-Stages.md's `command-line.mjs` table, `eval/README.md`, WIP.md's `check_cli` row and `wisdom/PLAN-3.md` (whose -`extract` listed `--threads` for `--in`) follow. Two Found items close with it: -`build_corpus --dest ""` removed the current folder, and `convert_em_dash_separators --chek` -rewrote `docs/`. - -`check_cli: 568 probes, all pass` (423 before). The probes of `unknown` and `acceptsValue` -became strict ones (an unknown letter in a short group, a dash-led positional after `--`, a -fault after `--help` not read while one before it is), with empty-value probes (separate, -inline, short, `multiple`, and `empty: true`); the comparison with a strict `parseArgs` -leaves out the empty values, which it accepts. Every case whose tool changed was re-pointed -rather than dropped, but for `check_links`' two warning cases, which went with the warning: -an ignored flag's case keeps its later failure with the flag removed (`tbbuild --keep -x.twinproj`), a term case moves after `--`. A `REFUSALS` table, -beside `HELP_TOOLS` and checked against it, adds an unknown-flag case for all 45 tools and an -empty-value case for the 26 with a value option (`convert_em_dash_separators --check --bogus`, -`wisdom bogus --bogus`, so a regression does no work), and every such case also checks that -its folder stays empty. With `lib/cli.mjs`'s unknown-option refusal turned into a `continue` -through `c43-fault.mjs` in `NODE_OPTIONS`, 69 of 568 fail. A Sonnet agent checked every new -text against the code: its eleven findings in the comments, Tools.md, `eval/README.md`, -Pipeline-Stages.md and this note were fixed, and its note that exit-code texts leave out a -refused command line is C74's; its twelfth, that Extending.md's gate table should give -`check_links`' 4, was wrong, since no wrapper runs `check_links`. `compare_trees`: Extending, Pipeline-Stages, -PDF-Generation, Tools and Wisdom online and offline, the search data and `book.html`. Lint -`Checked 172 files`; regex safety unchanged at `528 literals + 30 constructed in 130 files -... 489 safe, 69 polynomial, 0 undecided, 0 exponential`; `build.bat`, `check.bat` and -`test.bat` clean. On the owner's next push CI prints `check_cli: 568 probes, all pass`. +**Carried forward.** Every tool parses through `lib/cli.mjs`'s `parseCli` (`tbdocs` through +`builder/command-line.mjs`, which wraps it), and the parse is strict: an unknown option, a +boolean given a value, a value option with none, an empty value unless the option's spec says +`empty: true` (only `tbdocs`' `--baseurl`), and a positional beyond the tool's count are each a +`CliError`, reported on stderr with exit 2 through `withUsageError`. A term that starts with a +dash goes after `--`. A new tool needs an unknown-flag case in `REFUSALS`, and an empty-value +case if it has a value option, in `scripts/check_cli.mjs`, which checks `REFUSALS` against +`HELP_TOOLS`. ### C72a — `scripts, book, eval, wisdom: a bad value exits 2` -**Split from C72** (the owner, 2026-09-29). C72 makes the parse strict; the values each tool -reads after the parse are still unchecked. A survey of the code for C72 found these (read -before editing, not run): - -- **Numbers read with `Number`, `parseInt` or `parseFloat` and never checked**, so text gives - `NaN` and `12abc` gives 12: `crawl_check`'s `--concurrency` and `--timeout` (a `NaN` timeout - reports every link broken; 0 workers check nothing and pass); `sweep_a11y`'s `--limit` (a - `NaN` sweeps nothing) and `--recycle-every`; `pick_a11y_sample`'s `--budget` (`NaN` makes it - unlimited); `run_case`'s `--timeout` (`NaN` or 0 kills its `claude` child at once); - `search_quality`'s `--sample`, `--worst` and `--failures`; `site_search`'s `-n`; `wisdom`'s - `--concurrency`, `--rate-limit` and `--cap`; `tbrun`'s `--port`, `--timeout` and `--quiet`; - `addin_test`'s `--port`, `--jobs` and `--timeout`; `render-book`'s `-t`; - `check_links_diff`'s `--max-lines`. The checks that exist accept `0x10` and `1e3` - (`tbbuild`, `check_examples`, `survey_tooling`, `compare_trees`, `tbdocs`'s - `--stall-timeout`), and `tbbuild`'s `--port` has no upper bound. -- **Regexes that crash with exit 1**: `addin_test`'s and `check_examples`' `--only`; a bad - `nav_hops` term exits 2 with a stack. -- **A URL**: `crawl_check`'s start URL, uncaught at module level, exit 1 with a stack. -- **Fixed sets and dates**: `check_links`' `--oracle` (anything but `index` is `fs`); - `wisdom`'s `--min-confidence` and `--since` (`Date.parse` gives `NaN`); - `check_a11y_fingerprint`'s `--baseline` and `--candidate` (an unknown scheme throws - uncaught, exit 1); `run_case`'s `--protocol`. -- **Conflicts that pass silently**: `site_search`'s `--composition` with terms (the terms are - ignored); `pick_a11y_sample`'s `--check` with `--propose` (the last wins). -- **A destination removed before writing**: `build_corpus` removes its `--dest` recursively - (`build_corpus.mjs:148`), so `--dest .` deletes the current folder and `--dest ..` can - delete the repository. It refuses a `--dest` that is the repository, contains it, or - contains the current folder, as `tbdocs` refuses a `--dest` that overlaps its source (the - owner, 2026-09-30). - -**Change.** Every number through `numberOption`, and every regex, URL, date and value from a -fixed set checked straight after the parse, refused on stderr with exit 2 (C18's 4 in the two -link tools), in the tool's usage-error form. - -**Verify.** `check_cli.mjs` gains a bad-value case for each. - -**Landed** on the owner's choices of 2026-09-30: numbers are read as `Number()` reads them -(so `0x10` and `1e3` pass, `12abc` and blank text do not), with fractions refused where the -value is a count, a port or a millisecond count an API needs whole; 0 only where it has a -stated meaning (`tbdocs`' `--stall-timeout`, `compare_trees`' `--max`, `search_quality`'s -`--worst` and `--failures`, `check_links_diff`'s `--max-lines`, `tbrun`'s `--quiet`, and -`render-book`'s `-t`, which PDF-Generation.md documents as disabling the timeout); -`numberOption`'s one wording everywhere; and every silent conflict refused. `lib/cli.mjs` -gains `choiceOption`, `regexOption`, `urlOption`, `dateOption` (ISO 8601, the day read back -as written, since `Date.parse` takes `12` as a date in 2001 and `2024-02-30` as March) and -`refuseTogether`, and `numberOption` gains `above` (greater than) and loses `message`, whose -one caller, `tbdocs`' `--port`, now takes the default wording. Every check runs straight after -the parse, before an IDE, browser, registry snapshot, request or file removal, in the tool's -usage-error form. The survey's list held, and it had missed more: `crawl_check --concurrency -abc` and `addin_test --jobs abc` looped for ever (the second after its registry snapshot), -`addin_test`'s and `run_case`'s timeouts of 0 or text killed every child at once, `wisdom`'s -`--rate-limit` or `--cap` given text turned the guard off, and `tbdocs --url foo` crashed -partway through the build. Also checked: `tbdocs`' `--url`; `tbbuild`'s and `tbrun`'s `--arch` -through `choiceOption` (they printed the bare usage); `check_a11y_fingerprint`'s `--patches` -and `check_axe_patch_equiv`'s `--patch` against the patch names; `wisdom`'s `--since` no -earlier than 2015-01-01 and its `--min-confidence`. Conflicts refused: `--show` with `--hide` -in `tbbuild`, `tbrun`, `addin_test` and `check_examples`; two of `check_examples`' `--report`, -`--census` and `--propose`, and `--apply` without `--propose`; two of `pick_a11y_sample`'s -modes (the last won); `site_search`'s `--composition` with terms; and `wisdom extract`'s -`--since`, `--all` and `--force`, moved from `prep.mjs` (exit 1) to the parse, and still not -under `--merge`, which reads none of them. `build_corpus` refuses a `--dest` that is or -contains the repository root, the current folder or `--src` (the last beyond the owner's two, -the same harm). Tools.md's refusal sentence covers a value a tool cannot use; its `tbdocs`, -`crawl_check`, `pick_a11y_sample`, the a11y fingerprint and `check_cli` sections, -Pipeline-Stages.md, PDF-Generation.md, Extending.md, Wisdom.md and `eval/README.md` follow. - -`check_cli: 810 probes, all pass` (568 before): 21 cases re-pointed to the new wording (three -`render-book`, `run_case` and `search_quality` cases now stop at the value, before the missing -input they stopped at), 39 probes of the new checkers, 102 bad-value cases in a `BAD_VALUES` -block, each with its empty-folder probe, and one probe gone with `message`. With -`numberOption`'s test made to pass everything through `c43-fault.mjs` in `NODE_OPTIONS`, 88 of -810 fail; with `choiceOption`'s, 16. A Sonnet agent checked every new text against the code; its six findings were fixed, among -them two limits: `crawl_check`'s `--timeout` allowed up to 4294967295 ms, which -`AbortSignal.timeout` accepts but whose timer then fires at once (measured: a 3,000,000,000 ms -signal is aborted within 50 ms, a 2,147,483,647 ms one is not), and `render-book`'s `-t` had -no maximum; both now stop at 2147483647. `urlOption` uses `new URL` in a `try` rather than -`URL.parse`, which needs Node 22.1 where the docs say 22. `compare_trees`: Extending, -PDF-Generation, Pipeline-Stages, Tools and Wisdom online and offline, the search data and -`book.html`. Lint `Checked 172 files`; regex safety `535 -literals + 34 constructed in 130 files ... 500 safe, 69 polynomial, 0 undecided, 0 -exponential; 8 construction(s) not resolvable`; `build.bat`, `check.bat` and `test.bat` -clean. On the owner's next push CI prints `check_cli: 810 probes, all pass` and that -regex-safety line. +**Carried forward.** A tool checks each value it reads after the parse straight after the +parse, before it starts an IDE or browser, records a registry, makes a request or removes a +file, and refuses a bad one on stderr with exit 2, in its usage-error form. It does so through +`lib/cli.mjs`'s `numberOption` (read as `Number()` reads, so `0x10` passes and `12abc` does +not), `choiceOption`, `regexOption`, `urlOption`, `dateOption` and `refuseTogether`, and a +new option that takes a number, regex, URL, date or fixed set needs a case in `BAD_VALUES` in +`scripts/check_cli.mjs`. ### C72b — `builder, scripts: tbdocs and check_links exit 0, 1 or 2 like every tool` -**Raised by the owner** (2026-09-30). Every other tool exits 1 when it finds a problem and 2 -when it cannot do its job (a refused command line since C72, a crash since C28). `tbdocs` -exits with a bitmask, 1 for a failed page, diagram, stylesheet, baseline drift, link failure -or crash, 2 for an integrity failure, 3 for both, and 4 for a refused command line (C18, -departure 1); `check_links.mjs` gives 1 for links, 2 for integrity, 3 for both and 4 for its -command line. Nothing reads a bit: every wrapper tests `errorlevel 1`, CI tests non-zero, and -`check_links.mjs:56`'s "so CI can tell" has no reader. Building.md's "1 for link failures" -already leaves out the other failures 1 carries. - -**Change** (the owner's choice, 2026-09-30). Both tools exit 0 clean, 1 when the build or check -found a problem of any kind, and 2 on a refused command line or a crash (`tbdocs`' crash exits -1 today, through `main().catch`); the summary lines already name which check failed. The exit -constants in `tbdocs.mjs`, `serve.mjs`'s use of them, `write.mjs`'s `--dest` refusal, -`check_links`' usage text and header, Building.md, Tools.md's refusal sentence and its `tbdocs` -and `check_links` sections, Pipeline-Stages.md, `check_cli`'s cases and `REFUSALS` entries -that expect 4, and C75's note follow. Departure 1 is superseded. - -**Verify.** `check_cli.mjs`; `build.bat` over a fixture with a broken link and one with an -integrity failure exits 1; a crash through `c43-fault.mjs` exits 2. - -**Landed.** `tbdocs.mjs` exports `EXIT_FOUND` (1) and `EXIT_ERROR` (2) in place of -`EXIT_FAILED`, `EXIT_INTEGRITY` and `EXIT_COMMAND_LINE`, and `failBuild()` sets 1 where it ORed -a bit. 2 is a refused command line (every `CliError`, and `write.mjs`'s `--dest` refusal), a -crash through `main().catch`, and so also the stall watchdog, which throws there (it exited -1); `serve.mjs` exits 2 on a failed start and on a port in use, both of which exited 1. -`check_links.mjs` has the same two constants; `--no-fail` still forces 0 on findings alone; it -gained `exitOnCrash`, so a throw exits 2 where Node gave 1; and a run of several commands -separated by `/sep/` exits with the highest code among them where it took the first non-zero. -Nothing read a bit (the wrappers test `errorlevel 1`, CI and `compare_trees` test non-zero). -Comments in `check.mjs`, `command-line.mjs`, `dot.mjs`, `scss.mjs`, `write.mjs` and the -`checks.yml` build step (a comment only, so CI shows nothing new), `check_links`' help text, -Tools.md, Building.md, Builder.md and Pipeline-Stages.md follow; `PLAN-12.md` and -`PLAN-checks.md` are design records and keep their codes. The fixture build (`--src -test/fixtures/check-src --check`) exits 1 where it exited 3; the same build with a throw put -into `runBuild` through `c43-fault.mjs` exits 2; `tbdocs --bogus` and `check_links --bogus` -exit 2. `check_cli: 810 probes, all pass`, with every `tbdocs` and `check_links` case -expecting 2 and the `REFUSALS` overrides of 4 gone. `compare_trees`: Builder, Building, -Pipeline-Stages and Tools online and offline, the search data and `book.html`. Lint, regex -safety and the a11y line unchanged; `build.bat`, `check.bat` and `test.bat` clean. +**Carried forward.** `tbdocs.mjs` and `scripts/check_links.mjs` each define `EXIT_FOUND` (1, +the build or check found a problem of any kind) and `EXIT_ERROR` (2, a refused command line, +a crash, or `tbdocs`' stall watchdog), and neither exits 3 or 4; `--no-fail` still forces 0 on +findings alone. ### C73 — `scripts: one meaning each for --json and --src` -**L1-8 (R2), L1-9 (R3).** `--json` prints to stdout in `tbbuild`, `tbrun`, `check_examples` -and `census_attributes`, and takes a file in `check_a11y_fingerprint.mjs`. `--src` is the -documentation root in `tbdocs` and `check_publish_policy`, and the exported package tree in -`census_attributes` and `build_package_api`. - -**Change.** The odd ones out are renamed: `check_a11y_fingerprint.mjs`'s file option and the -two package-tree options get names of their own. WIP.A11y.md, WIP.Harness.md and Tools.md -follow. - -**Verify.** `check_cli.mjs`; `git grep` finds no old spelling in the documents or scripts. - -**Landed** on the owner's choices of 2026-09-30. `check_a11y_fingerprint`'s file-taking -`--json FILE` is `--out FILE`, as in `census_attributes`, `build_package_api`, `sweep_a11y` -and `run_case`. The package-tree `--src <dir>` of `census_attributes` and `build_package_api` is -`--exported <dir>`. The survey found a third meaning the review had missed: in -`eval/build_corpus.mjs` and `eval/nav_hops.mjs`, `--src` names a repository root that holds -`docs/`, so a reader following `tbdocs` would pass `docs` and get `docs/docs`. Both now take -`--repo`, and `build_corpus`'s `--dest` refusal names `--repo`. The owner also asked for the -three `perf/` rigs, which are outside the review's scope and which no gate runs. -`probe-axe-dom` and `probe-axe-scaling` take `--out FILE`. `ab-axe` already had `--out DIR` -for its output root, and its `--json` did nothing (see Found), so the flag is deleted there. -`--json` is now always a boolean that prints to stdout, and `--src` is always a docs root -(`tbdocs`, `check_publish_policy`). An old spelling is refused as an unknown option, with no -hint. Tools.md and `eval/README.md` follow. WIP.A11y.md and WIP.Harness.md cite neither -spelling, so they are unchanged. `builder/REVIEW-USECASES-16969e5.md:329` keeps `--src`, -because it is a record of that round. `check_cli: 816 probes, all pass` (810 before): -eight re-pointed cases and two re-pointed `build_corpus` refusals, one case per renamed option -showing that the old spelling is refused, and `check_a11y_fingerprint --out` without a value. -The `git grep` for the old spellings finds only those refusal cases. It missed one sentence, -Tools.md's note that the two package tools run anywhere when given an export, which kept -`--src`. That was found while landing C75, and fixed in `docs: Tools.md names --exported for -the package tools`, because folding it into this commit would have rewritten history. `compare_trees`: Tools -online and offline, the search data and `book.html`. Lint stays at `Checked 172 files` -(`perf/` is not linted). On the owner's next push, CI prints `check_cli: 816 probes, all pass`. +**Carried forward.** `--json` is a boolean that prints to stdout, and `--out FILE` names an +output file (`check_a11y_fingerprint`, `census_attributes`, `build_package_api`, `sweep_a11y`, +`run_case`). `--src` is a documentation root (`tbdocs`, `check_publish_policy`), `--exported` +the exported package tree (`census_attributes`, `build_package_api`) and `--repo` a repository +root that holds `docs/` (`eval/build_corpus.mjs`, `eval/nav_hops.mjs`). An old spelling is +refused as an unknown option, so Tools.md and `eval/README.md` must use these names. ### C74 — `scripts: one exit-code table per tool, and no code with two meanings` -**Decision (e).** Each tool's usage text and its Tools.md entry get one table of exit codes. -A code that means two things is split: `addin_test.mjs`'s 2 covers both a harness that failed -and a registry it could not restore (L1's notes in the ledger), and the second is the one the -user must act on. - -**Verify.** Each table checked against the code; `check_cli.mjs` for the codes it can reach; -`addin-test.bat` green (a harness run). - -**Landed** on the owner's choices of 2026-09-30. Each tool's usage text ends with one -`Exit codes:` block, a line per code, and its Tools.md section ends with the same codes on one -`Exit codes:` line. The six eval tools and `wisdom` have no Tools.md section, so theirs are in -`eval/README.md` and Wisdom.md. `impexp.mjs` keeps the table it shares with `impexp.py`, -which `check_impexp_parity` holds the two editions to, and Tools.md points to it. The -convention is 0 clean, 1 a finding, 2 the tool could not do its job, with a tool's own codes -above 2. Three codes that meant two things are split: `addin_test` exits **3** when the -registry or a work folder was not put back, which wins over a failed lane; `wisdom` exits -**3** when it reaches its request cap (re-run to continue), so 2 is left for a refused command -line; `tbrun` exits **4** when the compiler crashed, as `tbbuild` does. A compile that never -settled stays 2 in `tbrun`, whose 3 is "no output". Several tools exited 1 when they could -not run at all, and now exit 2: `render-book` for a missing input or support file and for a -render that threw, so it has no 1; `site_search` and `search_quality` with no search index; -`wisdom` when an earlier phase has not run; and `run_case` when not signed in. A crash exits 2 -in every tool. `exitOnCrash` moved from `scripts/lib/gate-probes.mjs` to `lib/cli.mjs`, which -`eval/`, `wisdom/`, `book/` and `builder/` may import, and its 16 importers followed. -Seventeen tools gained it, and it is installed only at the entry point in `tbdocs`, -`site_search` and `transcript`, which other modules import. A Sonnet agent then checked every -table against the code. Four of its eight findings were fixed here: -- `tbdocs --serve` exited 1 on a server error other than a port in use, and on a failed - watcher, both thrown outside `main()`. -- `wisdom` crashed with 2 when it reached the cap during discovery; that now exits 3, as it - does in the member and message fetches. -- `census_attributes`' table named a failed export, which leaves the package out of the - census and does not stop the run. -- A comment in `check_cli`. - -Three more are in Found; the eighth was wording. The docs agent also found `WIP.Build.md` -still giving `tbdocs`' old 1/2/3 check codes, which C72b had left behind, and `serve.bat` -returning 0 whatever `tbdocs` returns, which Tools.md now states and C75 fixes. - -`check_cli: 860 probes, all pass` (816 before). 44 of the new probes check that a tool's -`--help` output ends with exactly one exit-code table; there is one per tool but `impexp`. -Five cases were re-pointed from 1 to 2: `site_search` twice, `search_quality`, and -`transcript` twice, whose unreadable input is a crash. With a tool's table faulted through -`c43-fault.mjs` in `NODE_OPTIONS`, only that tool's table probe fails, in three cases: the -heading renamed, a code line malformed, and a second heading. `addin-test.bat`: `10 of 10 -lane(s) ran: 10 passed`, `registry: put back (20 project-state, 21 recent-list and 3 -association writes)`. `compare_trees`: Extending, PDF-Generation, Tools and Wisdom, online and -offline, the search data and `book.html`. Lint stays at `Checked 172 files`; regex safety -`536 literals + 34 constructed in 130 files ... 501 safe, 69 polynomial, 0 undecided, 0 -exponential; 8 construction(s) not resolvable` (the table pattern is the new literal); -`build.bat`, `check.bat` and `test.bat` clean. On the owner's next push CI prints -`check_cli: 860 probes, all pass` and that regex-safety line. +**Carried forward.** Every tool exits 0 clean, 1 a finding, 2 the tool could not do its job +(a refused command line, a missing input, a crash), and its own codes above 2: `addin_test` +3 (the registry or a work folder was not put back, after a crash too), `wisdom` 3 (the +request cap), `tbbuild` 3 (the compile never settled) and 4 (the compiler crashed), `tbrun` 3 +(no output) and 4 (the compiler crashed), and `impexp` 3 to 6 (the table it shares with +`impexp.py`, which has no `Exit codes:` probe). Each tool's usage text ends with one `Exit codes:` block, +which `check_cli` checks, and each Tools.md section (`eval/README.md` and Wisdom.md for the +tools without one) ends with the same codes on one `Exit codes:` line. `exitOnCrash` is in +`lib/cli.mjs`; a tool that other modules import installs it at its entry point only. ### C74a — `scripts: addin_test puts the registry back after a crash` -**Found while landing C74** (the owner's choice, 2026-09-30; it lands after C75). A crash -after `addin_test` has recorded the registry exits 2 through `exitOnCrash`, and nothing puts -the registry or the settings back, though the tool's table gives 3 for a registry that was not -put back. **Change.** The crash path restores what the run recorded, as the end of a run does, -and exits 3 if that fails. **Verify.** A throw put in after the snapshot through -`c43-fault.mjs`, with the kit's `reg-snap.mjs` before and after: identical registry, exit 2; a -throw from the restore as well: exit 3. - -**Landed.** Once `addin_test` has recorded the registry and the add-ins' settings, it replaces -`exitOnCrash`'s handler with its own. A crash prints its error, ends the lanes as Ctrl+C does, -waits up to 10 s for them to close, and then runs the same put-back as the end of a run: -`putBack()`, now a function both paths call, restores the IDE's entries and the settings, -checks that nothing names a lane's folder, and deletes the work folders. The run exits 2 if -that found no problem and 3 if it found one, and a crash during the put-back exits 3 at once. -If the lanes' ending lets `runAll` return, the main path waits and leaves the exit to the -handler. A failure to record the settings, which already put the registry back, now exits 3 -when that fails, not 2. The usage text's 2 and 3 lines and Tools.md's section say so. With -`--only sample15` between two `reg-snap.mjs` snapshots, through `c43-fault.mjs` (whose hooks -now take a list of faults), the kit's `c74a-faults.mjs` gave: a throw before the lanes start, -exit 2; a throw from a lane's output while its IDE runs, exit 2 after `registry: put back (2 -project-state, 21 recent-list and 3 association writes)`; the same with the put-back made to -report a problem, exit 3; the same with a throw inside the put-back after the restore, exit 3. -The registry was identical before and after in all four (sha256 `41c09aafafe70eac`). -`addin-test.bat`: `10 of 10 lane(s) ran: 10 passed`, `registry: put back (20 project-state, 21 -recent-list and 3 association writes)`. `compare_trees`: Tools, online and offline, the search -data and `book.html`. Lint, `check_cli` (860) and regex safety unchanged; `build.bat`, -`check.bat` and `test.bat` clean. +Landed. ### C75 — `serve.bat: return tbdocs's exit code` -**A6-5 (R3).** `serve.bat` does not pass its child's exit code back, unlike the other -wrappers, which capture it before `popd`. - -**Change.** The same idiom. - -**Verify.** A `serve.bat` that cannot start, because its port is taken, exits non-zero. - -**Landed.** `serve.bat` captures `tbdocs`'s exit code before `popd`, as `build.bat` and the -other wrappers do, so a failed first build, a port in use, a refused command line and a crash -come back as 2. Tools.md's `serve.bat` section had said, since C74, that it always returns 0; -it now gives `tbdocs`'s codes. The port has to be held on every interface: `tbdocs` listens -on all of them, and a server bound to 127.0.0.1 alone does not clash with it on Windows (the -first attempt served for two minutes). With port 4395 held by a `net` server on all -interfaces, and `--dest docs/_serve-c75`, HEAD's `serve.bat` printed `serve: port 4395 -already in use` and exited 0; the working one prints the same and exits 2. The scratch -`--dest` and the copy of HEAD's wrapper were removed. `compare_trees`: Tools, online and -offline, the search data and `book.html`. Lint, `check_cli` and regex safety are unchanged. +Landed. ## Phase 4: splits @@ -1964,7 +1578,8 @@ Defects the review did not have, found by building something this plan asks for. Each is settled in the commit named, on the recommendation given there, unless the owner decides otherwise: -- the exit value for a command-line error in `tbdocs` and `check_links.mjs`: C18 recommends 4; +- the exit value for a command-line error in `tbdocs` and `check_links.mjs`: C18 made it 4, + and C72b settled it at 2, as in every tool; - Biome or ESLint: C05's evaluation decides; - whether `test.bat` without Python fails or skips `check_impexp_parity.mjs` loudly: C70, decision (b)'s open question, which the owner settled on 2026-09-25: it skips, loudly;