`/`` 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, `` 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 `` 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 `` or `` 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("` 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 `` or `` (`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 `` holding an empty cell;
-one holding a void tag, beside a `` holding one; a raw `` holding ` `; a raw
-`` 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:` runs through the fourth path,
-so its fault covers the `FAILED` write. Every faulted build exits 1 in about a second with
-`task 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 []` 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 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: `) 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.
-*The book's pdf-lib shims (decision (c)): C66–C69.*
+### C65c — `book: fast-parse-number reads a decimal as Number() does`
-### C66 — `book: check_pdf_shims_equiv.mjs, the shims against stock pdf-lib`
+Landed.
-**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`.
+### C65d — `book: fast-dict-onebuf builds new pages and page trees in its buffer`
-**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.
+Landed.
-**Verify.** Passes; a deliberately broken shim fails it, and the report names the shim. CI
-waits for the owner's push.
+### C65e — `docs: Fixes.md stops counting the pdf-lib shims`
-### C67 — `book: one module for pdf-lib's internal requires`
+Landed.
-**A9-5 (R3).** The `createRequire` and `require('pdf-lib/cjs/...').default` block is repeated
-in nine production shims.
+*The book's pdf-lib shims (decision (c)): C66–C69.*
-**Change.** `book/lib/pdf-lib-internals.mjs`, which the nine import.
+### C66 — `book: check_pdf_shims_equiv.mjs, the shims against stock pdf-lib`
-**Verify.** `check_pdf_shims_equiv.mjs`; `book.bat` renders with the same page count and
-outline.
+**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`.
-### C68 — `book: the two onebuf shims share their range machinery`
+### C67 — `book: one module for pdf-lib's internal requires`
-**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.
+Landed.
-**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.
+### C67a — `book: check_pdf_shims_equiv checks each member the shims patch`
-**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.
+**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.
-### C69 — `book: each pdf-lib shim checks what it overwrites`
+### C67b — `book: the shim gate reaches the members it marks`
-**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`).
+Landed.
-**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.
+### C68 — `book: the two onebuf shims share their range machinery`
-**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.
-*impexp (decision (b)): C70.*
+### C69 — `book: each pdf-lib shim checks what it overwrites`
-### C70 — `scripts: check_impexp_parity.mjs, the two impexp editions compared`
+**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).
-**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.
+*impexp (decision (b)): C70.*
-**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.
+### C70 — `scripts: check_impexp_parity.mjs, the two impexp editions compared`
-**Verify.** Passes; a copy of one edition with one output line changed fails it. CI waits for
-the owner's push.
+**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
@@ -2615,61 +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.
+**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.
+**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`.
-**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.
+### C72a — `scripts, book, eval, wisdom: a bad value exits 2`
-**Verify.** `check_cli.mjs` gains an unknown-flag case and an empty-value case for every tool.
+**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`.
-### C73 — `scripts: one meaning each for --json and --src`
+### C72b — `builder, scripts: tbdocs and check_links exit 0, 1 or 2 like every tool`
-**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`.
+**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.
-**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.
+### C73 — `scripts: one meaning each for --json and --src`
-**Verify.** `check_cli.mjs`; `git grep` finds no old spelling in the documents or scripts.
+**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).
+**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.
-### C75 — `serve.bat: return tbdocs's exit code`
+### C74a — `scripts: addin_test puts the registry back after a crash`
-**A6-5 (R3).** `serve.bat` does not pass its child's exit code back, unlike the other
-wrappers, which capture it before `popd`.
+Landed.
-**Change.** The same idiom.
+### C75 — `serve.bat: return tbdocs's exit code`
-**Verify.** A `serve.bat` that cannot start, because its port is taken, exits non-zero.
+Landed.
## Phase 4: splits
@@ -3024,6 +1234,43 @@ 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.
+- **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.
+- **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.
+- **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.
+- **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.
+- **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
Defects the review did not have, found by building something this plan asks for.
@@ -3253,13 +1500,86 @@ 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`.
+- **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`.
+- **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`.
+- **`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`.
+- **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.
+- **`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`.
+- **`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 ` 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`.
+- **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
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;
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 490268a0..4bf4d2fa 100644
--- a/builder/command-line.mjs
+++ b/builder/command-line.mjs
@@ -1,19 +1,21 @@
// 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 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.
+// 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";
+import { numberOption, parseCli, urlOption } 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" },
@@ -32,8 +34,54 @@ 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 source root (default docs)
+ --dest online-tree destination (default /_site, or
+ /_serve with --serve); the offline tree is
+ -offline, the PDF tree -pdf
+ --baseurl override _config.yml's baseurl; an empty value is the
+ site root
+ --url 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 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 write the public symbols no page documents to a JSON file
+ --serve start the dev server: watch, rebuild, live-reload
+ --port port for --serve (default 4000)
+ --stall-timeout give up when no task completes for this long
+ (default 120; 0 disables)
+ -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({
src: "docs",
@@ -61,26 +109,20 @@ 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 });
- } 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 };
- for (const t of parse(argv).tokens) {
+ 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.
+ 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;
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;
@@ -121,21 +163,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": {
- // Not numberOption, which refuses blank: --stall-timeout= is 0.
- 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;
+ case "stallTimeout":
+ // Seconds, fractions included; 0 disables the watchdog.
+ args.stallTimeoutMs = numberOption(t.value, { option: "--stall-timeout", min: 0 }) * 1000;
break;
- }
}
}
return args;
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 /, 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 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 701dac5e..9c3b245e 100644
--- a/builder/tbdocs.mjs
+++ b/builder/tbdocs.mjs
@@ -7,10 +7,11 @@
// [--check | --no-check] [--check-audit-index]
// [--check-findings ] [--serve] [--port ]
// [--update-page-baseline] [--update-symbol-baseline]
-// [--symbol-gaps ] [--stall-timeout ]
+// [--symbol-gaps ] [--stall-timeout ] [-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
@@ -29,9 +30,12 @@
// 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 (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";
@@ -41,10 +45,10 @@ import { fileURLToPath } from "node:url";
import yaml from "js-yaml";
import pc from "picocolors";
-import { withUsageError } from "../lib/cli.mjs";
+import { exitOnCrash, 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";
@@ -94,20 +98,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 ────────────────────────────────────────────────────────────────
@@ -453,7 +454,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();
},
},
@@ -1372,8 +1373,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");
@@ -1466,11 +1467,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());
@@ -1492,7 +1492,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.
@@ -1504,17 +1504,18 @@ 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");
await runServe(opts);
@@ -1525,16 +1526,19 @@ 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);
- 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 8b409a89..b0df44e3 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.
@@ -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 04b4b09f..8d02504f 100644
--- a/docs/Documentation/Building.md
+++ b/docs/Documentation/Building.md
@@ -80,6 +80,8 @@ 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_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:
@@ -164,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.
@@ -245,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 ` `; 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.
@@ -509,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/Extending.md b/docs/Documentation/Extending.md
index 5280a973..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 --- 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, 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/Fixes-PDFLib.md b/docs/Documentation/Fixes-PDFLib.md
index b5c1fff2..832cc5ce 100644
--- a/docs/Documentation/Fixes-PDFLib.md
+++ b/docs/Documentation/Fixes-PDFLib.md
@@ -11,8 +11,14 @@ 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.
+
+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.
+
* TOC goes here
{:toc}
@@ -26,9 +32,9 @@ 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`.
+**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
@@ -66,7 +72,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.
@@ -90,7 +96,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`
@@ -99,7 +105,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.
@@ -123,6 +130,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/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.
diff --git a/docs/Documentation/PDF-Generation.md b/docs/Documentation/PDF-Generation.md
index 5bff3203..91d068fd 100644
--- a/docs/Documentation/PDF-Generation.md
+++ b/docs/Documentation/PDF-Generation.md
@@ -35,7 +35,7 @@ node book/render-book.mjs -o
| `` | 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:
@@ -81,7 +81,7 @@ The renderer relays three kinds of in-browser fault, each with its own prefix:
- **`[request failed] `** --- 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] `** --- an uncaught exception inside the page.
-- **`[render-book] error: `** --- the top-level catch. It closes the browser and sets the exit code to 1.
+- **`[render-book] 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: `. 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: ).
For (2), check out our guide on configuring puppeteer at https://pptr.dev/guides/configuration.
-`` is the Chrome build the installed `puppeteer` pins and `` 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.
+`` is the Chrome build the installed `puppeteer` pins and `` 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 unrecognised flag, or a missing `` 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 `` 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/Pipeline-Stages.md b/docs/Documentation/Pipeline-Stages.md
index f5abffbc..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 `** 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.
@@ -970,16 +970,16 @@ 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: ` needs a value`, `Unknown argument: `, 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 2: `unknown option: `, `unexpected argument: `, ` takes no value`, ` needs a value`, ` 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: `), 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 908d6bce..9e8d2c02 100644
--- a/docs/Documentation/Tools.md
+++ b/docs/Documentation/Tools.md
@@ -8,14 +8,14 @@ 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. 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}
## 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
@@ -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 ` 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: `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
check.bat
@@ -59,13 +63,15 @@ 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
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. 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.
@@ -79,7 +85,9 @@ 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, patch the members of pdf-lib it lists, and run.
+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:
@@ -95,9 +103,13 @@ 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_impexp_parity.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`.
+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.
@@ -127,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 }
@@ -140,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.
@@ -164,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 }
@@ -179,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
@@ -216,25 +230,27 @@ Full invocation:
| `--src ` | Source root. Default: `docs` relative to the working directory. |
| `--dest ` | Online-tree destination. Default: `/_site`. The offline tree lands at `-offline`, the PDF tree at `-pdf`. The build refuses a destination that is or contains ``, since cleaning it would delete the source. Inside ``, 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 ` | Overrides `_config.yml`'s `baseurl`. Used by CI to inject the GitHub Pages base path on fork deployments. |
-| `--url ` | Overrides `_config.yml`'s `url`. Used by CI so canonical URLs match the actual deployment origin rather than the configured production host. |
+| `--url ` | 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. |
| `--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 ` | 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 ` | 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 ` | 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 ` | 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 ` | HTTP port for `--serve` mode. Default: 4000. |
+| `--port ` | HTTP port for `--serve` mode, a whole number from 1 to 65535. Default: 4000. |
+
+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** 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** 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 }
@@ -261,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 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.
+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 [--concurrency N] [--timeout MS] [--skip-external]
-Online link crawler for the deployed site. Starts at ``, 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 ``, 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 }
@@ -277,7 +298,7 @@ Online link crawler for the deployed site. Starts at ``, 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 |
|---|---|
@@ -289,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 }
@@ -296,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. `--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.
@@ -312,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 }
@@ -321,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`:
@@ -360,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 ` ` 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 }
@@ -378,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 }
@@ -394,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 `` 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 }
@@ -415,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 }
@@ -438,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 }
@@ -457,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 }
@@ -470,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 }
@@ -480,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:
@@ -488,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 }
@@ -506,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 }
@@ -519,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 }
@@ -530,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 }
@@ -541,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 }
@@ -550,18 +595,42 @@ 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`, `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.
+
+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 }
+
+ 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, 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 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.
-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.
+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.
-Exits 1 on any failed probe or case, 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.
+
+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 }
@@ -570,11 +639,13 @@ Value-equivalence check for the vendored axe source patches. Builds the same col
node scripts/check_a11y_fingerprint.mjs [--candidate ] [--baseline ]
[--patches ] [--unminified]
[--root-dir ] [--pages ]
- [--theme ] [--viewport ] [--json]
+ [--theme ] [--viewport ] [--out ]
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.
+
+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 }
@@ -588,7 +659,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.
@@ -601,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 }
@@ -617,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 }
@@ -625,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`, `--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.
+
+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 }
@@ -633,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 }
@@ -644,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 }
@@ -658,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 }
@@ -681,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.
@@ -693,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 }
@@ -729,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 '.'`,
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.
@@ -772,11 +855,6 @@ comes back as `A&`.
| `--reap-images ` | 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
@@ -807,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 }
@@ -847,9 +927,6 @@ together.
| `--ide ` | 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\`, which any installed copy
@@ -858,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
@@ -873,6 +951,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 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 }
@@ -895,9 +975,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 }
@@ -941,7 +1021,7 @@ reports.
|---|---|
| `--only ` | 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 ` | 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 ` | Concurrent IDE lanes. Default 4. Each lane has its own port, its own workspace and its own private desktop. |
@@ -952,8 +1032,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
@@ -1005,6 +1083,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 }
@@ -1026,12 +1106,14 @@ It also writes a key naming the `Attributes.md` line each probe came from, besid
twinBASIC_win32.exe import AttributeProbes.twinproj --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 }
- node scripts/census_attributes.mjs [--ide ] [--src ] [--cache ]
+ node scripts/census_attributes.mjs [--ide ] [--exported ] [--cache ]
[--refresh] [--samples] [--attr ]
[--json] [--out ] [--dump-sites ] [--quiet]
@@ -1046,7 +1128,7 @@ Grouping is by enclosing construct *and* declaration keyword, because the keywor
| Flag | Effect |
|---|---|
| `--ide ` | The install root to census. Defaults to `$TB_IDE`, else the newest `twinBASIC_IDE_BETA_*` on the Desktop. |
-| `--src ` | Census an already-exported tree and skip the export entirely. |
+| `--exported ` | Census an already-exported tree and skip the export entirely. |
| `--cache ` | 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/`. |
@@ -1055,7 +1137,9 @@ Grouping is by enclosing construct *and* declaration keyword, because the keywor
| `--json` | Emit JSON instead of Markdown. |
| `--out ` | 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 }
@@ -1065,10 +1149,12 @@ 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, 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 }
@@ -1084,6 +1170,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 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 38e8ade6..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 [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.
+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
@@ -154,8 +156,8 @@ The prep step also writes two shared reference files that workflow agents read f
| `--since ` | Only analyse threads created after this date |
| `--channel ` | Restrict to threads from this channel **name** (repeatable) |
| `--min-confidence ` | 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) |
@@ -213,7 +215,7 @@ Also defines `runConcurrent(items, concurrency, fn)` --- a simple worker-pool: s
- **Auth detection.** Probes `/users/@me` with `Bot ` 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 16c6cbd9..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
@@ -46,6 +48,14 @@ node eval/run_case.mjs --corpus --site --protocol repo \
--goal /UC-56.goal.md --out /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 `--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
@@ -55,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 ` 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,
@@ -91,6 +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;
+`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 5223ece3..1fd70ef7 100644
--- a/eval/build_corpus.mjs
+++ b/eval/build_corpus.mjs
@@ -8,17 +8,19 @@
// 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 ] [--src ] [--quiet]
+// node eval/build_corpus.mjs [--dest ] [--repo ] [--quiet]
//
// See eval/README.md for how a round uses it.
import fs from "node:fs";
import path from "node:path";
-import { 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.
//
@@ -94,20 +96,47 @@ 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, repo) {
+ const doomed = [
+ ["the repository root", REPO_ROOT],
+ ["the current folder", process.cwd()],
+ [`--repo ${repo}`, repo],
+ ];
+ 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,
- unknown: "error",
- acceptsValue: () => true,
- }), { format: (err) => `unknown argument: ${err.arg}`, exitCode: 1 });
+ const { values } = withUsageError(() => {
+ const cli = parseCli(argv, {
+ options: {
+ repo: { 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), "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,
@@ -135,24 +164,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; }
@@ -206,11 +235,15 @@ function report(dest, counts) {
const opts = parseArgs(process.argv.slice(2));
if (opts.help || !opts.dest) {
printHelpAndExit(
- "Usage: node eval/build_corpus.mjs --dest [--src ] [--quiet]\n\n" +
+ "Usage: node eval/build_corpus.mjs --dest [--repo ] [--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.",
- { exitCode: opts.help ? 0 : 1 },
+ "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 2566534f..6844c9d8 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 ] [--src ] [...]
+// node eval/nav_hops.mjs [--from ] [--repo ] [...]
//
// 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 ---
@@ -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, 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";
@@ -40,26 +40,36 @@ 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 ] [--src ] [...]\n\n" +
+ "Usage: node eval/nav_hops.mjs [--from ] [--repo ] [-h, --help] [...]\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.\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 } = 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,
+ const { values, positionals, patterns } = withUsageError(() => {
+ const cli = parseCli(argv, {
+ options: {
+ from: { type: "string", default: "docs/index.md" },
+ repo: { 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: "", flags: "i" })) };
});
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,
};
}
@@ -68,12 +78,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 = [];
@@ -122,7 +132,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.
@@ -132,12 +143,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];
@@ -151,10 +162,10 @@ 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 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/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 8ef63d8a..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
//
@@ -58,7 +60,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,24 +79,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,
- unknown: "error",
- acceptsValue: () => true,
- }), { format: (err) => `unknown argument: ${err.arg}`, exitCode: 2 });
+ 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,
@@ -103,7 +113,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,
@@ -252,16 +262,23 @@ function runClaude(o, prompt, cwd, binDir) {
const USAGE =
"Usage: node eval/run_case.mjs --corpus --site --protocol \n" +
" --goal --out [--claude ] [--model ]\n" +
- " [--timeout ] [--prompt-only]\n" +
+ " [--timeout ] [--prompt-only] [-h, --help]\n" +
" node eval/run_case.mjs --smoke --corpus --site --out \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);
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")];
@@ -304,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 3383e4c2..3490a2ed 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, a
+// site with no search index, and a crash exit 2.
//
// GROUND TRUTH
//
@@ -125,35 +126,46 @@ 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 { 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) {
- 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,
- unknown: "error",
- acceptsValue: () => true,
- }), { format: (err) => `unrecognised argument: ${err.arg}`, exitCode: 1 });
+ 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,
};
}
@@ -696,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]\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 c68a2123..d9b51609 100644
--- a/eval/site_search.mjs
+++ b/eval/site_search.mjs
@@ -25,26 +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 } 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);
function parseArgs(argv) {
- const { values, positionals } = parseCli(argv, {
- options: {
- site: { type: "string" },
- n: { type: "string" },
- composition: { type: "boolean" },
- help: { type: "boolean", short: "h" },
- },
- positionals: { max: Infinity },
- unknown: "positional",
- acceptsValue: () => true,
+ 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,
@@ -316,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);
@@ -525,14 +532,19 @@ 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 "" [--n ] [--site ]\n' +
+ 'Usage: node eval/site_search.mjs "" [--n ] [--site ] [-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.\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 43c4e09e..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 } 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) {
@@ -188,31 +188,31 @@ export function printDigest(s, { calls = false, report = false } = {}) {
return a;
}
+const USAGE =
+ "Usage: node eval/transcript.mjs [--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.\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 } = parseCli(argv, {
+ const { values, positionals } = withUsageError(() => 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" },
+ help: { type: "boolean", short: "h" },
},
- positionals: { max: Infinity },
- unknown: "positional",
- });
- const file = positionals.find((a) => !a.startsWith("--"));
- const help = values.help || positionals.includes("-h");
- if (!file || help) {
- printHelpAndExit(
- "Usage: node eval/transcript.mjs [--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 },
- );
- }
+ positionals: { min: 0, max: 1 },
+ stopAt: ["help"],
+ }));
+ if (values.help) printHelpAndExit(USAGE);
+ const [file] = positionals;
+ if (!file) printHelpAndExit(USAGE, { stream: "stderr", exitCode: 2 });
printDigest(summarize(readTranscript(file)), { calls: values.calls, report: values.report });
}
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 59bfe0c8..2d62e1eb 100644
--- a/lib/cli.mjs
+++ b/lib/cli.mjs
@@ -2,21 +2,22 @@
//
// 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(),
+// choiceOption(), regexOption(), urlOption() and dateOption() read a value
+// after the parse, refuseTogether() refuses options that exclude each other,
+// and printHelpAndExit() prints a usage text. exitOnCrash() makes a crash exit
+// 2, not Node's 1.
//
-// 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 +38,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 +114,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,26 +123,90 @@ 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 };
}
/**
- * 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);
@@ -195,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/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/addin_test.mjs b/scripts/addin_test.mjs
index b5afa44b..fe21647c 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 { parseCli } 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,9 +64,33 @@ 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 { values } = parseCli(process.argv.slice(2), {
+const USAGE = `usage: node scripts/addin_test.mjs [--only REGEX] [--port N] [--jobs N] [--timeout S] [--ide ] [--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 only the lanes whose name matches
+ --port base DevTools port (default 9560); the lanes get n, n+1, ...
+ --jobs lanes at once (default 2)
+ --timeout a lane still running after this long is ended (default 600)
+ --ide 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
+
+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, at the end of a run or after a
+ crash; see the lines above`;
+
+const { values } = withUsageError(() => parseCli(process.argv.slice(2), {
options: {
only: { type: "string" },
port: { type: "string" },
@@ -73,22 +99,22 @@ 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 ] [--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));
-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
@@ -137,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;
@@ -222,64 +272,78 @@ 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" : ""));
-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 b7b1ea13..a0dac51f 100644
--- a/scripts/build_dot_metrics.mjs
+++ b/scripts/build_dot_metrics.mjs
@@ -34,14 +34,26 @@
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 } 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.
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
+
+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");
// Graphviz stores widths as `short`, in the family's own em units. The Times
@@ -57,7 +69,12 @@ 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 = withUsageError(() => parseCli(process.argv.slice(2), {
+ options: { check: { type: "boolean" }, help: { type: "boolean", short: "h" } },
+ 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..a07e37d0 100644
--- a/scripts/build_package_api.mjs
+++ b/scripts/build_package_api.mjs
@@ -5,16 +5,16 @@
// node scripts/build_package_api.mjs # regenerate builder/package-api.json
// node scripts/build_package_api.mjs --check # fail if it is stale
//
-// --ide the install root, or its twinBASIC.exe (default: $TB_IDE,
-// else the newest Desktop\twinBASIC_IDE_BETA_)
-// --src read an existing export of the packages instead
-// --cache where exports are kept (default %TEMP%\tb-census\beta-,
-// shared with scripts/census_attributes.mjs)
-// --refresh export again even if the cache has this build
-// --out write somewhere other than builder/package-api.json
+// --ide the install root, or its twinBASIC.exe (default: $TB_IDE,
+// else the newest Desktop\twinBASIC_IDE_BETA_)
+// --exported read an existing export of the packages instead
+// --cache where exports are kept (default %TEMP%\tb-census\beta-,
+// shared with scripts/census_attributes.mjs)
+// --refresh export again even if the cache has this build
+// --out 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,32 +46,55 @@ 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 { 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]
+
+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 the install root, or its twinBASIC.exe (default: $TB_IDE,
+ else the newest Desktop\\twinBASIC_IDE_BETA_)
+ --exported read an existing export of the packages instead
+ --cache where exports are kept (default %TEMP%\\tb-census\\beta-)
+ --refresh export again even if the cache has this build
+ --out write somewhere other than builder/package-api.json
+ -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), {
options: {
ide: { type: "string" },
- src: { type: "string" },
+ exported: { type: "string" },
cache: { type: "string" },
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() {
- // --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 })) {
@@ -81,8 +104,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 568ec5b1..9c33f064 100644
--- a/scripts/census_attributes.mjs
+++ b/scripts/census_attributes.mjs
@@ -3,18 +3,8 @@
//
// node scripts/census_attributes.mjs [options]
//
-// --ide twinBASIC install root (default: $TB_IDE, else the
-// newest %USERPROFILE%/Desktop/twinBASIC_IDE_BETA_*)
-// --src census an already-exported tree and do not export
-// --cache 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 restrict the report to one attribute
-// --json emit JSON instead of markdown
-// --out 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 (0 the report was produced, 2 the tool could
+// not do its job or crashed), are in USAGE below, which --help prints.
//
// ---------------------------------------------------------------- why this
//
@@ -74,21 +64,22 @@
// 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 { 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(() =>
parseCli(process.argv.slice(2), {
options: {
ide: { type: "string" },
- src: { type: "string" },
+ exported: { type: "string" },
cache: { type: "string" },
attr: { type: "string" },
out: { type: "string" },
@@ -97,19 +88,38 @@ 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 twinBASIC install root (default: $TB_IDE, else the
+ newest %USERPROFILE%/Desktop/twinBASIC_IDE_BETA_*)
+ --exported census an already-exported tree and do not export
+ --cache where exports are kept (default: %TEMP%/tb-census/beta-)
+ --refresh re-export even if the cache already has this build
+ --samples also census projects/ and addins/, not just packages/
+ --attr restrict the report to one attribute
+ --json emit JSON instead of markdown
+ --out write the report to a file instead of stdout
+ --dump-sites 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 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);
// ------------------------------------------------------------- the install
// Found as every harness tool finds it, by scripts/lib/tb-install.mjs's
@@ -496,21 +506,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.mjs b/scripts/check_a11y.mjs
index 415eebe1..9bbb32dc 100644
--- a/scripts/check_a11y.mjs
+++ b/scripts/check_a11y.mjs
@@ -60,7 +60,24 @@ 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
+
+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(
() =>
@@ -71,11 +88,12 @@ 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..ab4f35be 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,
@@ -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";
@@ -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(
@@ -87,16 +87,14 @@ 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" },
help: { type: "boolean", short: "h" },
},
- acceptsValue: Boolean,
stopAt: ["list", "help"],
}),
- { format: (err) => `unknown arg: ${err.arg}` },
);
if (cli.stopped === "list") {
@@ -119,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] [--json FILE] [--unminified] [--list]"
+ "[--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"
);
}
@@ -129,12 +132,21 @@ 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;
-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({
@@ -277,9 +289,9 @@ async function main() {
}
}
- if (jsonOut) {
+ if (outFile) {
writeFileSync(
- resolve(jsonOut),
+ resolve(outFile),
JSON.stringify(
{
axeCore: axeVersion(),
@@ -292,7 +304,7 @@ async function main() {
2
)
);
- console.log(`\nwrote ${resolve(jsonOut)}`);
+ console.log(`\nwrote ${resolve(outFile)}`);
}
if (mismatches === 0) {
diff --git a/scripts/check_axe_patch_equiv.mjs b/scripts/check_axe_patch_equiv.mjs
index 0ef00354..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.
@@ -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(
() =>
@@ -44,15 +45,22 @@ 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]");
+ 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"
+ );
}
-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_book_coverage.mjs b/scripts/check_book_coverage.mjs
index 13f3833b..6d6db645 100644
--- a/scripts/check_book_coverage.mjs
+++ b/scripts/check_book_coverage.mjs
@@ -20,10 +20,28 @@
// node scripts/check_book_coverage.mjs
import { resolveBookChapters, bookCoverage, formatBookCoverage } from "../builder/book.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();
+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
+
+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" } },
+ 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..438f9297 100644
--- a/scripts/check_ci_workflows.mjs
+++ b/scripts/check_ci_workflows.mjs
@@ -24,16 +24,34 @@
//
// 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 { exitOnCrash, parseCli, printHelpAndExit, withUsageError } 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
+
+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" } },
+ 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 ffec5871..a9c34659 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,13 +14,16 @@
// 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.
//
+// 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,17 +34,36 @@
// 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";
import { DEFAULTS, parseCommandLine } from "../builder/command-line.mjs";
-import { CliError, numberOption, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs";
+import {
+ 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();
+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
+
+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" } },
+ stopAt: ["help"],
+})).values.help) printHelpAndExit(USAGE);
+
const { check, report } = createProbes("check_cli");
const show = (x) => JSON.stringify(x);
const caught = (fn) => {
@@ -103,21 +125,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");
@@ -137,26 +156,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"],
@@ -178,8 +220,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"],
];
@@ -191,7 +233,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
@@ -207,8 +249,95 @@ check("unknown takes only its three values", caught(() => parseCli([], { unknown
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
@@ -265,7 +394,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"])
@@ -278,17 +408,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 on stdout, and requires a value
- // only at the end: before a flag, it takes the flag as the value.
- { 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" },
+ // number from 1 to 65535. check_links prints its errors on stderr, after
+ // "error: ".
+ { 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,
@@ -298,260 +428,580 @@ 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" },
-
- // 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" },
- { 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/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.
+ // 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 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 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: ["--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 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/pick_a11y_sample.mjs", args: ["--help"], exit: 0, stderr: /^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: ["--bogus"], exit: 2, stderr: "unknown arg: --bogus\n" },
- { tool: "scripts/sweep_a11y.mjs", args: ["--limit"], exit: 2, stderr: "unknown arg: --limit\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 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 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. 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.
+ // 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: 2, stderr: /^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", "--help"], exit: 0, stdout: /^usage: 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: 2, stderr: /^--port takes a positive whole number\nusage: 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: ["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: 2, stderr: /^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: ["--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/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: /^--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 / },
+ { 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: "--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" },
- { 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"], 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/ },
+ { 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/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.
+ { 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, 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 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
+ // 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\] \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: 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: "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 / },
- { tool: "scripts/crawl_check.mjs", args: ["--help"], exit: 2, stderr: "unknown flag: --help\n" },
- { 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 / },
- { 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: ["--help"], exit: 0, stdout: /^usage: node scripts\/crawl_check\.mjs / },
+ { 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 / },
+ { tool: "scripts/crawl_check.mjs", args: ["--timeout", "5", "-x"], exit: 2, stderr: /^unknown option: -x\nusage: node scripts\/crawl_check\.mjs / },
+ { tool: "scripts/crawl_check.mjs", args: ["--skip-external=1"], exit: 2, stderr: /^--skip-external takes no value\nusage: node scripts\/crawl_check\.mjs / },
+ { tool: "scripts/crawl_check.mjs", args: ["--concurrency", "--bogus"], exit: 2, stderr: /^--concurrency needs a value\nusage: node scripts\/crawl_check\.mjs / },
+ { 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: ["--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: ["--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 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: 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: 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: ["--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" },
+ { 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 [\] / },
- { 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: ["--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: 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 --help, 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" },
+ { 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. 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, 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 ] / },
{ tool: "book/render-book.mjs", args: [], exit: 2, stderr: "usage: node render-book.mjs -o [--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 / },
- { 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", "-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: ["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: 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 / },
- { tool: "eval/build_corpus.mjs", args: [], exit: 1, stdout: /^Usage: node eval\/build_corpus\.mjs --dest / },
- { 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: ["--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: ["--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: [], exit: 2, stderr: /^Usage: node eval\/build_corpus\.mjs --dest / },
+ { 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 / },
+ { 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 / },
+ { 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 \] / },
- { tool: "eval/nav_hops.mjs", args: [], exit: 2, stdout: /^Usage: node eval\/nav_hops\.mjs \[--from \] / },
+ { tool: "eval/nav_hops.mjs", args: [], exit: 2, stderr: /^Usage: node eval\/nav_hops\.mjs \[--from \] / },
{ 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: ["--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: ["--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