diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..5441e169 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Git hooks stay LF even where core.autocrlf checks files out CRLF. Git for +# Windows runs a CRLF hook without trouble, but a POSIX Git -- WSL on a +# Windows checkout, say -- hands the hook to the kernel, which reads the +# carriage return after #!/bin/sh as part of the interpreter's name. +.githooks/* text eol=lf diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 00000000..b27d8a09 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,11 @@ +#!/bin/sh +# Lints the scripts this commit stages with the pinned Biome, through +# scripts/check_lint.mjs --staged: the check test.bat and both CI workflows +# run over the whole scope, on the files being committed. It runs nothing +# else, and a commit that stages no script returns before Biome starts. +# +# Enable it in a clone: git config core.hooksPath .githooks +# +# Git runs a hook from the top of the working tree. .gitattributes keeps this +# file LF in every checkout, for a POSIX Git's sake: see the reason there. +exec node scripts/check_lint.mjs --staged diff --git a/.github/actions/run-gates/action.yml b/.github/actions/run-gates/action.yml new file mode 100644 index 00000000..3c5cbbd4 --- /dev/null +++ b/.github/actions/run-gates/action.yml @@ -0,0 +1,180 @@ +# The gate steps both workflows run, in their order, after the build. +# +# checks.yml runs them on every pull request, before a merge; tbdocs-gh-pages.yml +# runs them on every push to `staging`, before it deploys -- which makes that +# the one place a change pushed straight to `staging`, or a manual dispatch, +# meets them. One list serves both, so a gate cannot be added to one workflow +# and forgotten in the other; scripts/check_ci_workflows.mjs checks this list +# against test.bat and check.bat. Each gate is its own step, shown as its own +# group in the job's log. +# +# Needs the repository checked out, dependencies installed, Chromium +# installed, and the site built into docs/_site*, as both workflows do before +# calling it. +name: Run the gates +description: The gate steps both workflows share, checked against test.bat and check.bat by check_ci_workflows.mjs. +runs: + using: composite + steps: + # The build is the only pass over the site's HTML. This step is NOT a + # second one: it runs the differential harness against a nine-file + # synthetic tree that carries one fault of every kind, so + # scripts/check_links.mjs -- still the tool for a tree the build did not + # produce, and the oracle the fused path is defined against -- cannot rot + # unnoticed. ~0.3 s, no site files touched. + # + # The full script-vs-fused comparison over the real trees stays a manual + # gate (`check_links_diff.mjs --a script --b fused`): running it here would + # mean checking every page twice, which is exactly what folding the check + # into the build removed. + - name: Verify the standalone link checker (check_links_diff.mjs) + shell: bash + run: node scripts/check_links_diff.mjs --case fixture --a script --b index + # The publish allowlist (builder/publish-policy.mjs) is enforced inside + # the build, so a green build already says nothing unpublishable is in + # docs/. It does NOT say the allowlist still refuses anything: one widened + # until it refuses nothing reports the same clean pass. This asserts the + # refusals against named probes -- a stray .bak, a .pem, a scratch .md + # with no frontmatter. No browser, no built tree, ~40 ms. + - name: Verify the publish allowlist (check_publish_policy.mjs) + shell: bash + run: node scripts/check_publish_policy.mjs + # The two gate lists on Tools.md against the wrappers that run them, plus + # every gate count stated in prose in README.md or under + # docs/Documentation/. Documented gate counts have rotted three times; the + # third was round 3's own fix pass, on the two pages the first version of + # this gate did not read. Pure text, ~50 ms. + - name: Verify the documented gate lists (check_gate_lists.mjs) + shell: bash + run: node scripts/check_gate_lists.mjs + # Both workflows, and this action, against the wrappers: every gate + # test.bat and check.bat run, with the same arguments and in the same + # order, and a build with build.bat's flags. See the script's header for + # the differences it allows. No browser, no built tree. + - name: Verify the workflows run the wrappers' gates (check_ci_workflows.mjs) + shell: bash + run: node scripts/check_ci_workflows.mjs + # Biome, pinned, over the tooling, with the rules that find defects and + # none about style (biome.jsonc). Moving and deleting code leaves unused + # imports and undeclared names behind, and nothing else reads the tooling + # for them. Warnings fail as well as errors, since Biome reports an unused + # import as a warning and exits 0 on one; and a scope that matches no + # script fails rather than passing. npm ci installs the Linux binary. No + # browser, no built tree, ~0.25 s. + - name: Lint the tooling (check_lint.mjs) + shell: bash + run: node scripts/check_lint.mjs + # A regex that backtracks exponentially does not fail a build, it stops + # one: the corpus passes until a page happens to contain the trigger, and + # then a render worker sits inside String.replace forever. VOID_TAGS_RE + # shipped that way for as long as no alt text contained a slash, and its + # first fix was still exponential on a subtler witness. Nothing else here + # can see it, because there is nothing to see until the content changes. + # + # Reads regex literals AND every `new RegExp(...)` the source decides the + # arguments of. That second half is not a refinement: assembling a pattern + # from shared string constants is ordinary JavaScript, and for as long as + # this read literals only it was also a way out of the gate. Six such + # regexes went into one gate unseen, one of them polynomial. + # + # Gates on exponential only -- about a fifth of the patterns here are + # polynomial, nearly all the ordinary `]*>` shape on bounded input, + # and a gate that fails on day one gets switched off. The self-test probes + # run inside the same pass, classification and folding both, so a green + # line here cannot be a gate that has stopped detecting. No browser, no + # built tree, a few seconds sharded across the runner's CPUs. + # + # recheck's native backend is an optional dependency resolved per + # platform; if it is missing the script says so and falls back to the + # pure-JS implementation, which is slower but reaches the same verdict on + # every probe. + - name: Verify regex safety (check_regex_safety.mjs) + shell: bash + run: node scripts/check_regex_safety.mjs + # render.mjs applies several kramdown-parity rewrites to RAW markdown, + # before anything has been parsed, so none of them can tell prose from + # code -- on a site whose subject matter is code. Four defects of that + # shape shipped: `{% raw %}` stripped inside fences, admonition bodies + # losing the indentation of the code they contain, `Items[1](a, b)` + # percent-encoded inside a fence, and a YAML sample's closing `---` + # deleted with the line above it promoted to a heading. + # + # No other gate can see any of it: the corruption is inside , and + # the link, integrity, publish and axe checks all pass over it. Tokenise + # the source, apply the real rewrite chain, re-tokenise, and compare the + # literal regions. Probes ride along in the same run so a clean corpus + # cannot be mistaken for a working gate. No browser, no built tree. + - name: Verify code regions survive the pre-render rewrites (check_code_regions.mjs) + shell: bash + run: node scripts/check_code_regions.mjs + # The page-count drift guard reports nothing on a healthy tree, so a green + # build says exactly what a guard that had stopped working says. These + # probes make the other assertion, against a scratch baseline rather than + # the committed one. The first replays the defect that motivated it: 37 + # pages of AppGlobalClassObject lost to a blanket exclude rule, under a + # guard that knew only a floor of 836 against a real 908. No browser, no + # built tree. + - name: Verify the page-count drift guard (check_page_baseline.mjs) + shell: bash + run: node scripts/check_page_baseline.mjs + # The book-coverage warnings say nothing when every page has an entry in + # docs/_book.yml, which is also all a check that had stopped working would + # say. Before they existed, whole sections dropped out of the PDF without a + # word. These probes give each of the five findings a fault to report, on + # a manifest and pages built in memory. No browser, no built tree. + - name: Verify the book-coverage warnings (check_book_coverage.mjs) + shell: bash + run: node scripts/check_book_coverage.mjs + # The symbol index (tB/symbols.json) is read by the IDE help add-in. A + # build that indexes the reference cleanly says nothing about the rules + # that did not fire on it, so these probes assert each against the case + # that made it necessary -- the .twin scanner's traps, the rules placing a + # symbol on a page or heading, and the drift guard refusing a URL the index + # stopped publishing. Fixtures only: no tree, no install. + - name: Verify the symbol index and its drift guard (check_symbol_index.mjs) + shell: bash + run: node scripts/check_symbol_index.mjs + # Graphviz sizes each node box from a width table; the browser paints the + # label with a real font. Nothing in the build compares the two, so a + # mismatch ships as text hanging outside its box on a green build -- which + # is how 27 labels across three diagrams went out, through a full + # accessibility sweep, unreported. axe does not evaluate SVG + # geometry either. + # + # After the build on purpose: builder/dot.mjs rewrites a stale .svg from + # its .dot in place, so this measures the bytes about to be deployed, not + # the ones that were committed. It loads Inter from the committed .woff2 by + # @font-face rather than relying on an installed face, so unlike the + # target-size rules it measures the same here as on a dev box. ~1 s. + - name: Verify DOT diagram fit (check_dot_fit.mjs) + shell: bash + run: node scripts/check_dot_fit.mjs + # check_a11y.mjs injects a PATCHED axe bundle (plain-color-fields, -26 % on + # a realistic page set -- see builder/PLAN-axe-perf.md), so the patch has + # to be verified before its results are trusted. The patch asserts its + # substitution targets and so fails loudly if an axe-core bump moves the + # code; this catches the other case, where the text still matches but the + # colour maths has changed. The fingerprint gate cannot see that -- it + # compares `incomplete` as a rule-id set. + # + # Cheap (one page, two bundles), and Chromium is already installed. The + # change that bumps axe-core is exactly when it earns its place. + - name: Verify axe source patch (check_axe_patch_equiv.mjs) + shell: bash + run: node scripts/check_axe_patch_equiv.mjs + # The scan is thirteen pages of ~1,160, so its page list decides what it + # can report at all. This fails when the site grows a markup construct no + # sample page carries -- the drift that let the previous six-page sample + # report a clean pass while 54 pages had violations in constructs it never + # saw. No browser, ~1 s. + - name: Verify a11y sample coverage (pick_a11y_sample.mjs) + shell: bash + run: node scripts/pick_a11y_sample.mjs --check + # Matches check.bat: the build's own link check gates this, and a link + # failure short-circuits before the (slower) browser scan runs. Scans + # _site-offline/, whose relative asset paths actually resolve -- _site/ + # uses root-absolute URLs that render unstyled here, making every + # colour-contrast result meaningless. + - name: Accessibility check (check_a11y.mjs) + shell: bash + run: node scripts/check_a11y.mjs diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 69e8f802..6ca64c04 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -98,155 +98,23 @@ jobs: # step fails on the exit code: 1 for link failures, 2 for # integrity failures, 3 for both. run: node builder/tbdocs.mjs --src docs --no-fetch-assets --check-audit-index - # The build above is the only pass over the site's HTML. This step is - # NOT a second one: it runs the differential harness against a - # nine-file synthetic tree that carries one fault of every kind, so - # scripts/check_links.mjs -- still the tool for a tree the build did - # not produce, and the oracle the fused path is defined against -- - # cannot rot unnoticed. ~0.3 s, no site files touched. - # - # The full script-vs-fused comparison over the real trees stays a - # manual gate (`check_links_diff.mjs --a script --b fused`): running - # it here would mean checking every page twice, which is exactly what - # folding the check into the build removed. - - name: Verify the standalone link checker (check_links_diff.mjs) - run: node scripts/check_links_diff.mjs --case fixture --a script --b index - # The step above compares two front ends over a hand-written tree. - # This one compares the script against the BUILD's own checker, over - # a three-page tree the build produces from test/fixtures/check-src. - # That is the pass the harness exists for and the one nothing - # exercised: `--b fused` skipped the synthetic `fixture` case every - # time, because the build cannot check a tree it did not write. A - # regression in builder/check.mjs that stopped REPORTING a category - # would have left every gate green. + # The gates both workflows run, in one list: see + # .github/actions/run-gates/action.yml, which check_ci_workflows.mjs + # checks against test.bat and check.bat. Each gate is its own group in + # this step's log. + - name: Run the gates + uses: ./.github/actions/run-gates + # The action's first step compares the standalone link checker's two + # front ends over a hand-written tree. This one compares the script + # against the BUILD's own checker, over a three-page tree the build + # produces from test/fixtures/check-src. That is the pass the harness + # exists for and the one nothing exercised: `--b fused` skipped the + # synthetic `fixture` case every time, because the build cannot check a + # tree it did not write. A regression in builder/check.mjs that stopped + # REPORTING a category would have left every gate green. # # One extra three-page build, ~1 s. It is here and not in the deploy # workflow because this is the PR gate: catching it before a merge is # the point, and the deploy workflow has a site to ship. - name: Verify the build's own link checker (check_links_diff.mjs) run: node scripts/check_links_diff.mjs --case fixture-built --case fixture-built-offline --a script --b fused - # The publish allowlist (builder/publish-policy.mjs) is enforced - # inside the build above, so a green build already says nothing - # unpublishable is in docs/. It does NOT say the allowlist still - # refuses anything: one widened until it refuses nothing reports the - # same clean pass. This asserts the refusals against named probes -- - # a stray .bak, a .pem, a scratch .md with no frontmatter. No - # browser, no built tree, ~40 ms. - - name: Verify the publish allowlist (check_publish_policy.mjs) - run: node scripts/check_publish_policy.mjs - # The two gate lists on Tools.md against the wrappers that run - # them, plus every gate count stated in prose in README.md or - # under docs/Documentation/. Documented gate counts have rotted - # three times; the third was round 3's own fix pass, on the two - # pages the first version of this gate did not read. Pure text, - # ~50 ms. - - name: Verify the documented gate lists (check_gate_lists.mjs) - run: node scripts/check_gate_lists.mjs - # A regex that backtracks exponentially does not fail a build, it - # stops one: the corpus passes until a page happens to contain the - # trigger, and then a render worker sits inside String.replace - # forever. VOID_TAGS_RE shipped that way for as long as no alt text - # contained a slash, and its first fix was still exponential on a - # subtler witness. Nothing else here can see it, because there is - # nothing to see until the content changes. - # - # Reads regex literals AND every `new RegExp(...)` the source - # decides the arguments of. That second half is not a refinement: - # assembling a pattern from shared string constants is ordinary - # JavaScript, and for as long as this read literals only it was - # also a way out of the gate. Six such regexes went into one gate - # unseen, one of them polynomial. - # - # Gates on exponential only -- about a fifth of the patterns here - # are polynomial, nearly all the ordinary `]*>` shape on - # bounded input, and a gate that fails on day one gets switched - # off. The self-test probes run inside the same pass, classification - # and folding both, so a green line here cannot be a gate that has - # stopped detecting. No browser, no built tree, a few seconds - # sharded across the runner's CPUs. - # - # recheck's native backend is an optional dependency resolved per - # platform; if it is missing the script says so and falls back to - # the pure-JS implementation, which is slower but reaches the same - # verdict on every probe. - - name: Verify regex safety (check_regex_safety.mjs) - run: node scripts/check_regex_safety.mjs - # render.mjs applies several kramdown-parity rewrites to RAW markdown, - # before anything has been parsed, so none of them can tell prose from - # code -- on a site whose subject matter is code. Four defects of that - # shape shipped: `{% raw %}` stripped inside fences, admonition bodies - # losing the indentation of the code they contain, `Items[1](a, b)` - # percent-encoded inside a fence, and a YAML sample's closing `---` - # deleted with the line above it promoted to a heading. - # - # No other gate can see any of it: the corruption is inside , - # and the link, integrity, publish and axe checks all pass over it. - # Tokenise the source, apply the real rewrite chain, re-tokenise, and - # compare the literal regions. Probes ride along in the same run so a - # clean corpus cannot be mistaken for a working gate. No browser, no - # built tree. - - name: Verify code regions survive the pre-render rewrites (check_code_regions.mjs) - run: node scripts/check_code_regions.mjs - # The page-count drift guard reports nothing on a healthy tree, so a - # green build says exactly what a guard that had stopped working says. - # These probes make the other assertion, against a scratch baseline - # rather than the committed one. The first replays the defect that - # motivated it: 37 pages of AppGlobalClassObject lost to a blanket - # exclude rule, under a guard that knew only a floor of 836 against a - # real 908. No browser, no built tree. - - name: Verify the page-count drift guard (check_page_baseline.mjs) - run: node scripts/check_page_baseline.mjs - # The book-coverage warnings say nothing when every page has an entry - # in docs/_book.yml, which is also all a check that had stopped working - # would say. Before they existed, whole sections dropped out of the PDF - # without a word. These probes give each of the five findings a fault - # to report, on a manifest and pages built in memory. No browser, no - # built tree. - - name: Verify the book-coverage warnings (check_book_coverage.mjs) - run: node scripts/check_book_coverage.mjs - # The symbol index (tB/symbols.json) is read by the IDE help add-in. A - # build that indexes the reference cleanly says nothing about the rules - # that did not fire on it, so these probes assert each against the case - # that made it necessary -- the .twin scanner's traps, the rules placing - # a symbol on a page or heading, and the drift guard refusing a URL the - # index stopped publishing. Fixtures only: no tree, no install. - - name: Verify the symbol index and its drift guard (check_symbol_index.mjs) - run: node scripts/check_symbol_index.mjs - # Graphviz sizes each node box from a width table; the browser paints - # the label with a real font. Nothing in the build compares the two, so - # a mismatch ships as text hanging outside its box on a green build -- - # which is how 27 labels across three diagrams went out, through a full - # accessibility sweep, unreported. axe does not evaluate SVG - # geometry either. - # - # Safe on CI's font set: it loads Inter from the committed .woff2 by - # @font-face rather than relying on an installed face, so unlike the - # target-size rules it measures the same here as on a dev box. - - name: Verify DOT diagram fit (check_dot_fit.mjs) - run: node scripts/check_dot_fit.mjs - # check_a11y.mjs injects a PATCHED axe bundle (plain-color-fields, - # -26 % on a realistic page set -- see builder/PLAN-axe-perf.md), so the - # patch has to be verified before its results are trusted. The patch - # asserts its substitution targets and so fails loudly if an axe-core - # bump moves the code; this catches the other case, where the text still - # matches but the colour maths has changed. The fingerprint gate cannot - # see that -- it compares `incomplete` as a rule-id set. - # - # Cheap (one page, two bundles) and Chromium is already installed. The - # PR that bumps axe-core is exactly when it earns its place. - - name: Verify axe source patch (check_axe_patch_equiv.mjs) - run: node scripts/check_axe_patch_equiv.mjs - # The scan is thirteen pages of ~1,160, so its page list decides what it can - # report at all. This fails when the site grows a markup construct no - # sample page carries -- the drift that let the previous six-page sample - # report a clean pass while 54 pages had violations in constructs it never - # saw. No browser, ~1 s. - - name: Verify a11y sample coverage (pick_a11y_sample.mjs) - run: node scripts/pick_a11y_sample.mjs --check - # Matches check.bat: the build's own link check gates this, and a - # link failure short-circuits before the (slower) browser scan runs. - # Scans _site-offline/, whose relative asset paths actually resolve -- - # _site/ uses root-absolute URLs that render unstyled here, making - # every colour-contrast result meaningless. - - name: Accessibility check (check_a11y.mjs) - run: node scripts/check_a11y.mjs diff --git a/.github/workflows/tbdocs-gh-pages.yml b/.github/workflows/tbdocs-gh-pages.yml index 687a5c85..04028a69 100644 --- a/.github/workflows/tbdocs-gh-pages.yml +++ b/.github/workflows/tbdocs-gh-pages.yml @@ -94,102 +94,14 @@ jobs: # online tree gets a base path -- the offline tree's links are all # relative after the rewrite. run: node builder/tbdocs.mjs --src docs --url '${{ steps.pages.outputs.origin }}' --baseurl '${{ steps.pages.outputs.base_path }}' --no-fetch-assets --check-audit-index - # Not a second pass over the site: a nine-file synthetic tree carrying - # one fault of every kind, so scripts/check_links.mjs -- still the - # tool for a tree the build did not produce -- cannot rot unnoticed. - # ~0.3 s. See the same step in checks.yml, which additionally runs the - # comparison against the build's own checker. - - name: Verify the standalone link checker (check_links_diff.mjs) - run: node scripts/check_links_diff.mjs --case fixture --a script --b index - # The publish allowlist (builder/publish-policy.mjs) is enforced - # inside the build above, so a green build already says nothing - # unpublishable is in docs/. It does NOT say the allowlist still - # refuses anything: one widened until it refuses nothing reports the - # same clean pass. This asserts the refusals against named probes -- - # a stray .bak, a .pem, a scratch .md with no frontmatter. No - # browser, no built tree, ~40 ms. - - name: Verify the publish allowlist (check_publish_policy.mjs) - run: node scripts/check_publish_policy.mjs - # The two gate lists on Tools.md against the wrappers that run - # them, plus every gate count stated in prose in README.md or - # under docs/Documentation/. Documented gate counts have rotted - # three times; the third was round 3's own fix pass, on the two - # pages the first version of this gate did not read. Pure text, - # ~50 ms. - - name: Verify the documented gate lists (check_gate_lists.mjs) - run: node scripts/check_gate_lists.mjs - # An exponentially backtracking regex does not fail a build, it - # stops one -- a render worker sits inside String.replace forever - # the first time a page contains the trigger. VOID_TAGS_RE shipped - # that way, and so did its first fix. See the same step in - # checks.yml for what it gates on and why only exponential. - # - # Here for the same reason check_dot_fit.mjs is: checks.yml runs - # only on pull requests, so this is the one gate a regex merged by - # a direct push to `staging` would otherwise never meet. No browser - # and no built tree, so its position relative to the build does not - # matter. - - name: Verify regex safety (check_regex_safety.mjs) - run: node scripts/check_regex_safety.mjs - # The pre-render rewrites in render.mjs run over raw markdown and - # cannot tell prose from code; four defects of that shape shipped, - # and nothing else looks inside . See the same step in - # checks.yml. Here for the same reason the gate above is: checks.yml - # runs only on pull requests, so this is the one place a rewrite - # merged by a direct push to `staging` would meet it. No browser and - # no built tree. - - name: Verify code regions survive the pre-render rewrites (check_code_regions.mjs) - run: node scripts/check_code_regions.mjs - # The page-count drift guard reports nothing on a healthy tree, so a - # green build says exactly what a guard that had stopped working says. - # These probes make the other assertion, against a scratch baseline - # rather than the committed one. The first replays the defect that - # motivated it: 37 pages of AppGlobalClassObject lost to a blanket - # exclude rule, under a guard that knew only a floor of 836 against a - # real 908. No browser, no built tree. - - name: Verify the page-count drift guard (check_page_baseline.mjs) - run: node scripts/check_page_baseline.mjs - # The book-coverage warnings say nothing when every page has an entry - # in docs/_book.yml, which is also all a check that had stopped working - # would say. Before they existed, whole sections dropped out of the PDF - # without a word. These probes give each of the five findings a fault - # to report, on a manifest and pages built in memory. No browser, no - # built tree. - - name: Verify the book-coverage warnings (check_book_coverage.mjs) - run: node scripts/check_book_coverage.mjs - # The symbol index's rules and drift guard, against fixtures. See the - # same step in checks.yml. - - name: Verify the symbol index and its drift guard (check_symbol_index.mjs) - run: node scripts/check_symbol_index.mjs - # Graphviz sizes each node box from a width table; the browser paints the - # label with a real font, and nothing in the build compares the two -- so a - # mismatch ships as text hanging outside its box on a green build. See the - # same step in checks.yml for how 27 labels once went out that way. - # - # checks.yml only runs on pull requests, so this is the one gate a diagram - # merged by a direct push to `staging` -- or by a manual dispatch -- would - # otherwise never meet. - # - # After the build on purpose: builder/dot.mjs rewrites a stale .svg from - # its .dot in place, so this measures the bytes about to be deployed, not - # the ones that were committed. Chromium is already installed above, and - # the check loads Inter from the committed .woff2 rather than an installed - # face, so it measures the same here as on a dev box. Five diagrams, ~1 s. - - name: Verify DOT diagram fit (check_dot_fit.mjs) - run: node scripts/check_dot_fit.mjs - # check_a11y.mjs injects a PATCHED axe bundle; verify the patch is still - # value-preserving before trusting what it reports. See the same step in - # checks.yml for why the fingerprint gate does not cover this. - - name: Verify axe source patch (check_axe_patch_equiv.mjs) - run: node scripts/check_axe_patch_equiv.mjs - # Fails when the site grows a construct no sample page covers; see the - # same step in checks.yml. No browser, ~1 s. - - name: Verify a11y sample coverage (pick_a11y_sample.mjs) - run: node scripts/pick_a11y_sample.mjs --check - # Same gate as check.bat and the PR workflow. Chromium is already - # installed above for the PDF render, so this costs only the scan. - - name: Accessibility check (check_a11y.mjs) - run: node scripts/check_a11y.mjs + # The gates both workflows run, in one list: see + # .github/actions/run-gates/action.yml, which check_ci_workflows.mjs + # checks against test.bat and check.bat. checks.yml runs only on pull + # requests, so this is the one place a change pushed straight to + # `staging`, or a manual dispatch, meets them. Each gate is its own + # group in this step's log. + - name: Run the gates + uses: ./.github/actions/run-gates - name: Render book PDF run: | mkdir -p _pdf diff --git a/.gitignore b/.gitignore index ec33af39..6f3687b4 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,10 @@ indexer/.packages/ # Temporary session handoff notes. Root-anchored: only the repo-root file. /HANDOFF.md +# scripts/compare_trees.mjs: the before side's git worktree, both built +# trees and both build logs. Removed after a clean run unless --keep. +/.compare-trees/ + # scripts/check_links_diff.mjs builds test/fixtures/check-src into these. /test/fixtures/_out/ /test/fixtures/_out-offline/ diff --git a/BUGS-TO-REPORT.md b/BUGS-TO-REPORT.md index 074407d8..e1e99eb9 100644 --- a/BUGS-TO-REPORT.md +++ b/BUGS-TO-REPORT.md @@ -427,7 +427,7 @@ file is written and the run ends `... DONE`. **What does not reproduce it:** a long *input* path to `export`, and any output folder short enough that no file path reaches 260 characters and no folder path 248. -**Found by** the attribute census, `builder/census_attributes.mjs`, pointed at a cache folder +**Found by** the attribute census, `scripts/census_attributes.mjs`, pointed at a cache folder inside a deep working directory: `WebView2Package` and the three `cefPackage` versions came back `... FAILED` while the other twelve packages exported. The census used to trust `export`'s exit code, so until it tested for `... DONE` it would have scanned those partial diff --git a/WIP.Build.md b/WIP.Build.md index 6741f86d..3b8d47e6 100644 --- a/WIP.Build.md +++ b/WIP.Build.md @@ -25,6 +25,18 @@ every time. `byName` breaks ties on `srcRel` now. Two builds of a commit are byte-identical except for `BuildInfo.html` and `gantt.svg`, which record build timings and cannot be. +**`scripts/compare_trees.mjs` is the check that relies on it.** It builds a +commit and the working tree from two git worktrees and compares all three trees +byte for byte, replacing those two regions and the PDF title page's build line, +which also differs when the sides are different commits or were built on +different days. Run it after any change to `builder/` that should leave the +output alone; a change meant to alter the output is checked the same way, and +what it reports should be the intended differences and nothing else. Build the +working tree from a checkout, never in place: under `core.autocrlf` a file a +tool has rewritten holds LF where a fresh checkout writes CRLF, and every file +the build copies verbatim then differs, which is what the tool's first version +found. + ### A hung build times out and says where it hung Readers get this at [When a build stops instead of @@ -78,7 +90,7 @@ Historical engineering notes from the Jekyll era --- the original build pipeline ### Tooling is JavaScript, and the two remaining `.py` files each have a reason -Everything under `scripts/`, `builder/`, `book/`, `eval/` and `wisdom/` is Node.js. +Everything under `scripts/`, `builder/`, `lib/`, `book/`, `eval/` and `wisdom/` is Node.js. One trap the ports away from Python left behind: **a tool that rewrites a file must preserve its line endings byte-exactly.** Python's `Path.read_text` / `write_text` round-trip applies universal-newline translation, rewriting any LF file it touches to @@ -114,6 +126,11 @@ The full account of the JavaScript port of `build_fonts.py` --- what works, the build defect that blocks it, the evidence, the root cause in `hb-config.hh`, and what the port must check for when it happens --- is in [WIP.Fonts.md](WIP.Fonts.md). +`lib/` — modules that every other tooling folder may import, and that import none of them. +`builder/` may not import `scripts/`, so code that both need lives here; +[lib/README.md](lib/README.md) states the rule, and `biome.jsonc` refuses an import that +breaks either one. + `wisdom/` — Discord knowledge-harvesting tool (three-phase: export → process → extract). Plans in `wisdom/PLAN-{1,2,3}.md`; implementation under `wisdom/`. Uses only Node.js built-in APIs. Running it is [WIP.Wisdom.md](WIP.Wisdom.md). `eval/` — use-case evaluation of the developer documentation. `build_corpus.mjs` mirrors the @@ -383,7 +400,7 @@ serve mode --- which runs neither pass --- recreated both, empty, on every rebui now prepares `_serve` alone, and the two were deleted. All four were empty, so nothing had gone wrong yet; a file planted in one made the old script call a fresh tree stale. It now skips the top-level folders that `isOutputTree` in -[scripts/lib/markdown-files.mjs](scripts/lib/markdown-files.mjs) names --- the +[lib/markdown-files.mjs](lib/markdown-files.mjs) names --- the prefix list the markdown walk uses --- and keeps only `.git` and `node_modules` as names of its own. @@ -422,7 +439,7 @@ running preview deletes and rewrites on every rebuild --- and died with `ENOENT` when a folder vanished under it. `test.bat` failed that way on 2026-09-23. Two other tools carried their own copies of the same walk, and one of them did not skip the output trees at all, so all three now call -[scripts/lib/markdown-files.mjs](scripts/lib/markdown-files.mjs), which skips +[lib/markdown-files.mjs](lib/markdown-files.mjs), which skips `_site*`, `_serve*` and `_pdf*` before entering them. Measured against a live preview: the old walk hit `ENOENT` during a rebuild, while the new one opens 142 folders, none of them inside an output tree, returns the same 910 files, and @@ -732,10 +749,10 @@ six are `safe`, and all six are checked on every run. ### The regex-safety gate [scripts/check_regex_safety.mjs](scripts/check_regex_safety.mjs) parses every -`.mjs` under `builder/`, `scripts/`, `book/`, `eval/` and `wisdom/` with acorn, -takes the regex literals *and* every `new RegExp(...)` whose arguments the source -decides, and refuses any that can backtrack exponentially. In `test.bat` and both -CI workflows; ~5 s, no browser, no built tree. +`.mjs` under `builder/`, `scripts/`, `lib/`, `book/`, `eval/` and `wisdom/` with +acorn, takes the regex literals *and* every `new RegExp(...)` whose arguments the +source decides, and refuses any that can backtrack exponentially. In `test.bat` +and both CI workflows; ~5 s, no browser, no built tree. ```sh node scripts/check_regex_safety.mjs # the gate diff --git a/WIP.ExamplesBuild.md b/WIP.ExamplesBuild.md index 56a0d932..50ec527c 100644 --- a/WIP.ExamplesBuild.md +++ b/WIP.ExamplesBuild.md @@ -11,12 +11,12 @@ Reader-facing documentation is the [`check_examples.mjs` entry](docs/Documentation/Tools.md) in Tools.md and [Checking that a sample compiles](docs/Documentation/Authoring.md) in Authoring.md. -**1,119 samples are marked today** and the gate over them takes ~110 s, in 41 projects. That +**1,129 samples are marked as of 2026-09-25** and the gate over them takes ~120 s, in 43 projects. That is up from 401, and the arithmetic of how it got there is [the second pass](#the-second-pass-604-to-818), which is also where the one claim this file got badly wrong is corrected. -Of the 1,171 `tb` fences, 1,119 are marked and compile, 52 are `inert` with a recorded +Of the 1,181 `tb` fences, 1,129 are marked and compile, 52 are `inert` with a recorded reason, and **none is undecided**, so the backlog the census measures is empty. No fence is an `excerpt` any more: [the excerpts](#the-excerpts-25-to-none) were the last group that could be made to build. Every gain @@ -82,7 +82,7 @@ probe in `check_examples.mjs`: `Type_Initialize`, `Type_Assignment` and `Type_Conversion`, which is what `Features/Language/UDTs.md` is about. But a UDT *field* may also be called `Type As Long`, which reads as an opener that never closes --- the trap - `builder/census_attributes.mjs` records paying for, so every opener demands a name after + `scripts/census_attributes.mjs` records paying for, so every opener demands a name after the keyword. - **`Overridable` is a modifier**, with 32 uses in the shipped packages and 3 in `docs/`. Leaving it out of the list cost three fences, and they came back as *"End Function closing diff --git a/WIP.Harness.md b/WIP.Harness.md index d34c18dc..4ee68712 100644 --- a/WIP.Harness.md +++ b/WIP.Harness.md @@ -12,7 +12,7 @@ Read it before changing `scripts/tbbuild.mjs`, `scripts/tbrun.mjs`, `scripts/lib/tb-launch.ps1`, `scripts/lib/tb-registry.mjs`, `scripts/lib/tb-ide-copy.mjs`, `scripts/lib/tb-project.mjs`, `scripts/lib/tb-addin.mjs`, `scripts/lib/tb-operate.mjs`, `scripts/lib/tb-lane.mjs`, -anything under `test/addin/`, or `builder/census_attributes.mjs`, and before +anything under `test/addin/`, or `scripts/census_attributes.mjs`, and before concluding anything about twinBASIC syntax from a sweep of exported sources. ## Getting at the `.twin` sources @@ -72,19 +72,16 @@ minutes, against a question that four documentation pages could not settle betwe ## Censusing every attribute at once -[builder/census_attributes.mjs](builder/census_attributes.mjs) --- which sits under -`builder/` by deliberate placement rather than because it renders anything; it is listed in -`check_tree_fresh.mjs`'s `IGNORED_FILES` for exactly that reason, so editing it does not -mark every output tree stale --- does the export above for +[scripts/census_attributes.mjs](scripts/census_attributes.mjs) does the export above for every package of the current install and reports, per attribute, **which enclosing construct and which kind of declaration it decorates**. No arguments needed; it finds the newest `twinBASIC_IDE_BETA_*` the same way `tbbuild` does, caches the export by build number, and re-uses it. ```sh -node builder/census_attributes.mjs --out census.md -node builder/census_attributes.mjs --attr Hidden # one attribute -node builder/census_attributes.mjs --attr Hidden --dump-sites sites.json +node scripts/census_attributes.mjs --out census.md +node scripts/census_attributes.mjs --attr Hidden # one attribute +node scripts/census_attributes.mjs --attr Hidden --dump-sites sites.json ``` Against BETA 983: **661 files, 9,701 attribute sites, 55 distinct attributes**, and every @@ -229,6 +226,15 @@ machine, and which is the same policy [BOOKPLAN.md](BOOKPLAN.md) records blockin --- never comes into it, and no `-ExecutionPolicy Bypass` has to be recommended to anyone. Its inputs arrive as environment variables, so there is no argument quoting to get wrong. +A launch that fails prints no pid, and its cause as one line on stderr, which `launchIde` +reports. Two things used to hide the cause. With its streams redirected, PowerShell writes +progress records and errors to stderr as CLIXML, as `tb-registry.mjs` also found (below), so +every failed launch read `#< CLIXML`; the script now silences progress and writes a failure +itself, as plain UTF-8 text. And a Win32 error read from PowerShell is not the call's: +PowerShell makes calls of its own before the next statement runs, and a `CreateProcess` that +had set 3 was reported as 203, "The system could not find the environment option that was +entered". Each call is now made, and its error read, in C#. + Seven things about the harness were learned by getting them wrong, and each is a comment in the file now: @@ -418,6 +424,16 @@ Four smaller things it knows, each of which cost a run: twice in round 8's fix pass --- five runs going at once on ports 9740--9744, and both passed when repeated. It now exits 2 on a `[BUILD] failed` or `[LINKER] FAILED` line, which the probe's own `Debug.Cls` would have erased. What made the type library fail was not isolated. + Since the tooling review's C16 it exits 2 on any line `buildProject`'s `BUILD_FAILED` + matches, which adds `[BUILD] ERROR` and `[LINKER] compilation (codegen) error`. The second + was measured: a `[RunAfterBuild]` Sub that shifts a `Single` (BUGS-TO-REPORT.md) builds with + `[LINKER] SUCCESS`, the console adds `[BUILD] Executing 'DocSamples.Probe.Run'...` and the + codegen line, and nothing in the Sub runs, so `tbrun` had returned that log with exit 0. +- **A callee's code-generation failure is invisible.** When the failing shift is in a + procedure the probe calls, the codegen line naming that procedure comes straight after the + `[BUILD] Executing` line, before the probe's first statement runs. The probe's `Debug.Cls` + erases it, the probe prints what comes before the call and stops there, and `tbrun` exits 0 + with that partial output. Measured with and without `Debug.Cls` on BETA 983; not fixed. A reader of the console that is not `tbrun` should **compare the whole console before and after, not read on from an index**: new text can be appended to an entry that is still open. @@ -938,7 +954,9 @@ Seven things about it were learned, the first six on the samples: file, such as an add-in's `Editors.Open`, and `afterReveal` does the same wait for anything else. Measured: `xyz` typed at 3:1 of `Haystack.twin`, 0.3 s after opening it at 4:9, went in as `x` at 3:1 and `zy` at 4:9; after the fixed `openFile` it went in as `xyz` - at 3:1. The IDE's side of it is in BUGS-TO-REPORT.md. + at 3:1. When the IDE is still revealing lines 10 s later, `openFile`, `setCursor` and + `select` throw, naming the file and the place, rather than go on while the cursor can still + move; `afterReveal` itself returns `false`. The IDE's side of it is in BUGS-TO-REPORT.md. **The connection itself changed in three ways.** They were the gaps item 1 found in `tbbuild`, and they matter more once a harness clicks into dialogs on purpose: diff --git a/WIP.HelpAddin.md b/WIP.HelpAddin.md index 472f4bc9..c2e78fdc 100644 --- a/WIP.HelpAddin.md +++ b/WIP.HelpAddin.md @@ -734,7 +734,7 @@ at `/tB/Modules/Collection`, not under VBRUN. The index is generated instead. `/tB/Core/Attributes#`. - **From the packages:** the public API from an `export` of the shipped packages --- the export and its cache already exist in - [builder/census_attributes.mjs](builder/census_attributes.mjs). That supplies each + [scripts/census_attributes.mjs](scripts/census_attributes.mjs). That supplies each symbol's package, container and kind, and lists public symbols that have no page, which measures documentation coverage as a side effect. **It must also supply each class's default interface**, because that is what the compiler names a class's members by (P5): diff --git a/WIP.md b/WIP.md index 678cd8e0..800d8275 100644 --- a/WIP.md +++ b/WIP.md @@ -130,7 +130,7 @@ because an install path contains a username. ```sh "$TB/bin/twinBASIC_win32.exe" export ".twinproj" "C:\out\dir\" --overwrite -node builder/census_attributes.mjs --out census.md # every attribute, by enclosing construct +node scripts/census_attributes.mjs --out census.md # every attribute, by enclosing construct node scripts/tbbuild.mjs C:/probe/Thing.twinproj # does it compile node scripts/tbrun.mjs # what does it print ``` @@ -374,7 +374,7 @@ must never do: [WIP.Typography.md](WIP.Typography.md). ### Tooling is JavaScript -Everything under `scripts/`, `builder/`, `book/`, `eval/` and `wisdom/` is +Everything under `scripts/`, `builder/`, `lib/`, `book/`, `eval/` and `wisdom/` is Node.js, and a new tool joins them there. Three files are not, each for a stated reason rather than by oversight: `scripts/impexp.py` is a published download offered to readers rather than tooling, `scripts/build_fonts.py` stays Python @@ -415,7 +415,7 @@ over them, because a `tb` fence is something `check_code_regions.mjs` protects t the compiler now. A sample opts in by carrying `check_build` in its fence info string; the tool works out what to generate around it, packs many samples into one project, builds them through `tbbuild` on concurrent lanes, and reports each diagnostic against the line in the -page it came from. **1,119 samples are marked and the run takes about 110 seconds.** +page it came from. **1,129 samples are marked as of 2026-09-25, and the run takes about 120 seconds.** It is **never** wired into `build.bat`, `check.bat`, `test.bat` or CI: it needs a twinBASIC install, which `npm install` is not, and Windows with a private desktop and a @@ -452,10 +452,10 @@ Why the report separates the wedged task from the merely blocked ones, and why - `build.bat` — runs `node builder\tbdocs.mjs --src docs --check-audit-index` (which implies `--check`) and produces three trees in one pass: the online copy at `_site/`, a `file://`-browsable copy at `_site-offline/`, and the sparse pagedjs source at `_site-pdf/`. The offline pass adds ~700 ms and the PDF pass adds ~150 ms on top of the ~2 s online build. Toggle `also_build_offline` / `also_build_pdf` in `_config.yml` (or pass `--no-offline` / `--no-pdf`) to skip a sibling output. `--check` adds ~1.7 s and runs the link + integrity check over the HTML while it is still in worker memory; `build.bat --no-check` gets a plain build. - `serve.bat` — runs `tbdocs --serve`: initial build, then a long-lived process with watcher, debounced rebuilds, and SSE-driven browser auto-reload. Writes to `docs/_serve/` (disjoint from `build.bat`'s `_site*/`) and skips the offline + PDF passes — so a one-off `build.bat` for the PDF or offline mirror doesn't disturb the live preview. Ctrl+C to stop. - `check.bat` — the gates that read the built site: a freshness check that refuses a stale tree (`scripts/check_tree_fresh.mjs`), the DOT diagram fit check (`scripts/check_dot_fit.mjs`), the a11y sample-coverage check (`scripts/pick_a11y_sample.mjs --check`), then the accessibility check (`scripts/check_a11y.mjs`). The link + integrity check moved into `build.bat`. ~37 s. -- `test.bat` — the tests the *toolchain* has to pass: the publish-allowlist self-test (`scripts/check_publish_policy.mjs`), the gate-list check (`scripts/check_gate_lists.mjs`), the regex-safety gate (`scripts/check_regex_safety.mjs`), the code-region gate (`scripts/check_code_regions.mjs`), the page-count drift-guard probes (`scripts/check_page_baseline.mjs`), the book-coverage probes (`scripts/check_book_coverage.mjs`), the symbol-index probes (`scripts/check_symbol_index.mjs`), and the axe source-patch verification (`scripts/check_axe_patch_equiv.mjs`). ~8 s. See [What belongs in test.bat rather than check.bat](WIP.Build.md#what-belongs-in-testbat-rather-than-checkbat). +- `test.bat` — the tests the *toolchain* has to pass: the publish-allowlist self-test (`scripts/check_publish_policy.mjs`), the gate-list check (`scripts/check_gate_lists.mjs`), the CI-workflow roster check (`scripts/check_ci_workflows.mjs`), the lint gate (`scripts/check_lint.mjs`), the regex-safety gate (`scripts/check_regex_safety.mjs`), the code-region gate (`scripts/check_code_regions.mjs`), the page-count drift-guard probes (`scripts/check_page_baseline.mjs`), the book-coverage probes (`scripts/check_book_coverage.mjs`), the symbol-index probes (`scripts/check_symbol_index.mjs`), and the axe source-patch verification (`scripts/check_axe_patch_equiv.mjs`). ~8 s. See [What belongs in test.bat rather than check.bat](WIP.Build.md#what-belongs-in-testbat-rather-than-checkbat). - `book.bat` — renders the PDF from `docs\_site-pdf\book.html` via `node book\render-book.mjs` into `docs\_pdf\twinBASIC Book.pdf`. Run `build.bat` first to populate `_site-pdf/`; `book.bat` refuses a tree older than its sources rather than rendering the previous book (see [The book refuses a stale source tree](WIP.Build.md#the-book-refuses-a-stale-source-tree)). -- `examples.bat` — compiles the documentation's own twinBASIC code samples, every `tb` fence marked `check_build`, and reports the ones the compiler refuses against the line in the page they came from. Needs a twinBASIC install and Windows, so it is outside every gate and outside CI; ~110 s over the 1,119 samples marked today. Two modes need no compiler at all: `--census` classifies every fence and says how many classifiable ones are still unmarked, and `--report ` groups a saved `--propose --json` survey by diagnostic, section and unresolved name. `--propose` itself does compile. See [Compiling the reference's own code samples](#compiling-the-references-own-code-samples) and [WIP.ExamplesBuild.md](WIP.ExamplesBuild.md). +- `examples.bat` — compiles the documentation's own twinBASIC code samples, every `tb` fence marked `check_build`, and reports the ones the compiler refuses against the line in the page they came from. Needs a twinBASIC install and Windows, so it is outside every gate and outside CI; ~120 s over the 1,129 samples marked as of 2026-09-25. Two modes need no compiler at all: `--census` classifies every fence and says how many classifiable ones are still unmarked, and `--report ` groups a saved `--propose --json` survey by diagnostic, section and unresolved name. `--propose` itself does compile. See [Compiling the reference's own code samples](#compiling-the-references-own-code-samples) and [WIP.ExamplesBuild.md](WIP.ExamplesBuild.md). - `addin-test.bat` — tests IDE add-ins by operating an IDE: every lane in `test/addin/lanes.mjs` builds the add-ins it tests into a private copy of the install, opens a project and checks what the add-in does. Outside every gate and outside CI for the same reasons as `examples.bat`; ~140 s for the ten lanes today: Samples 10 and 15, and the eight probe lanes behind Stage 2's answers in [WIP.HelpAddin.md](WIP.HelpAddin.md). Exit 0 every lane passed and the registry is as it was found, 1 a lane failed, 2 the harness failed or could not put the registry back. See [Driving the twinBASIC compiler](#driving-the-twinbasic-compiler) for its rules. Three generators sit outside that loop and produce committed artifacts rather than build output — none runs during a build, and none is needed for one. `python scripts/build_fonts.py` rebuilds the subset webfaces under `docs/assets/fonts/` and needs a network connection; `node scripts/build_dot_metrics.mjs` regenerates `builder/inter-metrics.json` from those webfaces and needs only a browser. See [Typography](#typography). `node scripts/build_package_api.mjs` regenerates `builder/package-api.json`, the packages' declared API that the build's symbol index (`tB/symbols.json`, for the IDE help add-in) is annotated from; it needs a twinBASIC install, so **run it when the reference is re-indexed against a newer build** and commit it with the pages. See [WIP.HelpAddin.md, Stage 3](WIP.HelpAddin.md#stage-3-the-symbol-index-generated-by-the-docs-build). @@ -470,12 +470,18 @@ build.bat && check.bat On the dev box that is ~4 s of build against ~37 s of check, of which the axe scan is ~20 s. [builder/PLAN-checks.md](builder/PLAN-checks.md) records how the link checker got folded into the build's task graph, what it cost and what it saved; the axe follow-ons are designed there but not implemented. -**If the change touched `builder/`, `scripts/`, `book/`, `eval/` or `wisdom/`, run `test.bat` as well** --- another ~8 s. Six of its eight gates cannot be affected by a content edit at all. **Two can.** `check_gate_lists.mjs` is the easy one to predict: it reads `README.md` and every page under `docs/Documentation/`, so an edit to any developer page that states a gate count can fail it. **`check_code_regions.mjs` is the one worth understanding**, and which half of it a content edit reaches is worth keeping straight. Its corpus sweep has `ROOT = /docs` and tokenises all 906 markdown files, so a page that provokes a rewrite into *altering* a code region fails it --- that half is content-dependent. Its fixed probes are not: they run against their own sources whatever the tree holds, and they cover the **mirror** fault, where a rewrite silently stops firing. The sweep structurally cannot see that one, because text the rewrite skipped is stashed and restored unchanged and every region still matches. So run `test.bat` after adding an unusual code construct --- a fence whose contents include a fence marker, a 4-space indented block, an admonition wrapping a fence --- and read the built page as well, because for the mirror fault the gate is asserting that the stasher still works rather than checking your page: +**If the change touched `builder/`, `scripts/`, `lib/`, `book/`, `eval/`, `wisdom/`, `test/`, the site's scripts in `docs/assets/js/`, a wrapper or a workflow, run `test.bat` as well** --- another ~8 s. Seven of its ten gates cannot be affected by an edit under `docs/` at all. **Three can.** `check_lint.mjs` lints the site's two scripts in `docs/assets/js/` along with the tooling. `check_gate_lists.mjs` is the easy one to predict: it reads `README.md` and every page under `docs/Documentation/`, so an edit to any developer page that states a gate count can fail it. **`check_code_regions.mjs` is the one worth understanding**, and which half of it a content edit reaches is worth keeping straight. Its corpus sweep has `ROOT = /docs` and tokenises all 906 markdown files, so a page that provokes a rewrite into *altering* a code region fails it --- that half is content-dependent. Its fixed probes are not: they run against their own sources whatever the tree holds, and they cover the **mirror** fault, where a rewrite silently stops firing. The sweep structurally cannot see that one, because text the rewrite skipped is stashed and restored unchanged and every region still matches. So run `test.bat` after adding an unusual code construct --- a fence whose contents include a fence marker, a 4-space indented block, an admonition wrapping a fence --- and read the built page as well, because for the mirror fault the gate is asserting that the stasher still works rather than checking your page: ```sh build.bat && check.bat && test.bat ``` +**If the change touched `builder/`, compare the output as well.** `node +scripts/compare_trees.mjs` builds `HEAD` and the working tree from two git +worktrees and compares the three trees byte for byte, in about ten seconds. A +refactor must come out identical; any other change should differ exactly where +it meant to and nowhere else. See [WIP.Build.md](WIP.Build.md#the-pipeline). + ### The gates, and where their internals are [Tools and Scripts](docs/Documentation/Tools.md) owns the authoritative lists, @@ -491,12 +497,15 @@ wrapper: | `build.bat` | page-count baseline | a rise rewrites `builder/page-baseline.json` and says so; a fall fails the build | | `build.bat` | symbol-index URLs | every URL `tB/symbols.json` has published is still in it: a new one rewrites `builder/symbol-baseline.json`, a lost one --- most often a reworded member heading --- fails the build | | `build.bat` | nav integrity | every nav-visible `parent:` resolves to exactly one page | +| `build.bat` | Gantt sections | every task handed to the build's Gantt chart has a section, and the chart has a place for it: a band for a main-thread task, a colour for a worker's | | `check.bat` | `check_tree_fresh` | the tree is not older than the sources that produced it | | `check.bat` | `check_dot_fit` | every diagram label sits inside the box Graphviz drew for it | | `check.bat` | `pick_a11y_sample --check`, `check_a11y` | see [WIP.A11y.md](WIP.A11y.md) | | `test.bat` | `check_code_regions` | no source or HTML rewrite altered a code region | | `test.bat` | `check_regex_safety` | no regex in the tree can backtrack exponentially | | `test.bat` | `check_symbol_index` | the symbol index still places each kind of symbol, from fixtures | +| `test.bat` | `check_ci_workflows` | both CI workflows run every wrapper gate, with the same arguments and order, and build with `build.bat`'s flags | +| `test.bat` | `check_lint` | Biome finds nothing in the tooling, warnings included, and checked at least one script | | `test.bat` | `check_publish_policy`, `check_gate_lists`, `check_page_baseline`, `check_book_coverage`, `check_axe_patch_equiv` | the gates on the gates | **A gate belongs in `test.bat` rather than `check.bat` if it would still mean @@ -505,7 +514,10 @@ about what a gate interrogates, not about what it happens to open. Both CI workflows run every one of these as its own step, unconditionally and without invoking the `.bat` files --- so skipping `test.bat` locally changes -what a content edit costs you, never what reaches `staging`. +what a content edit costs you, never what reaches `staging`. The steps are one +list, the composite action `.github/actions/run-gates/action.yml`, which both +workflows call: **a new gate goes into its wrapper, that action and Tools.md's +list**, and `check_ci_workflows.mjs` fails `test.bat` until CI matches. The nav integrity check ([builder/nav.mjs](builder/nav.mjs)) runs during COMPUTE and aborts the build on two failure modes, both otherwise silent: @@ -522,6 +534,12 @@ inline `` is content. Favor concise one-line git commit messages. +**Lint before every commit:** `node scripts/check_lint.mjs`, a fraction of a second. It +runs Biome over the tooling and the site's two scripts, and fails on a warning as well as an +error, because Biome reports an unused import as a warning. `test.bat` and CI run it too. +The pre-commit hook in `.githooks/` runs it on the staged scripts and nothing else; enable it +in a clone with `git config core.hooksPath .githooks`. + **A bug in twinBASIC itself goes in [BUGS-TO-REPORT.md](BUGS-TO-REPORT.md)**, which is a queue rather than a record: an entry is deleted once it has been filed upstream. Each one carries the build it was seen on and a *narrowed* reproduction --- the compiler crash @@ -533,7 +551,7 @@ they are. - Don't commit `.claude/` or `CLAUDE.md` — both gitignored. (`WIP.md` is committed; `CLAUDE.md` is just a local `@WIP.md` import shim.) - Don't touch `_site/` or `_site-offline/` (build outputs, gitignored). -- **Don't walk `docs/` for its markdown with a private `readdir`.** Call `markdownFiles` from [scripts/lib/markdown-files.mjs](scripts/lib/markdown-files.mjs), which never enters the build's output trees. A walk that does enter them crashes whenever a running `serve.bat` rewrites `_serve`; see [The code-region gate](WIP.Build.md#the-code-region-gate). Any other walk of `docs/` decides what is an output tree with the same module's `isOutputTree`, as `check_tree_fresh.mjs` does, rather than a list of its own. +- **Don't walk `docs/` for its markdown with a private `readdir`.** Call `markdownFiles` from [lib/markdown-files.mjs](lib/markdown-files.mjs), which never enters the build's output trees. A walk that does enter them crashes whenever a running `serve.bat` rewrites `_serve`; see [The code-region gate](WIP.Build.md#the-code-region-gate). Any other walk of `docs/` decides what is an output tree with the same module's `isOutputTree`, as `check_tree_fresh.mjs` does, rather than a list of its own. - **Don't judge rendered styling by opening a built page as a `file://` URL in the in-app browser pane.** It does not apply the page's stylesheets, so everything renders unstyled and any conclusion about colour, spacing, layout or contrast drawn from it is worthless. Use `serve.bat`, which serves over HTTP at localhost and renders for real. The confusing part is that `file://` is fine *through puppeteer* -- `scripts/check_a11y.mjs`, `scripts/sweep_a11y.mjs` and the `perf/` rigs all load `_site-offline/` over `file://` and get correct computed styles, which is the entire reason the offline tree exists (see [Site integrity check](#site-integrity-check)). So: puppeteer for measuring, `serve.bat` for looking. Never the preview pane on a `file://` path. - Don't write literal en-dash `–` or em-dash `—` in `docs/` markdown source. Use `--` (renders as en-dash) or `---` (renders as em-dash) — markdown-it's typographer does the conversion at build time. `scripts/convert_em_dash_separators.mjs` normalises any strays. - **Never write or edit a file with a shell heredoc.** No `cat > file <<'EOF'`, no diff --git a/biome.jsonc b/biome.jsonc new file mode 100644 index 00000000..58a96bf4 --- /dev/null +++ b/biome.jsonc @@ -0,0 +1,101 @@ +{ + // Biome as the repository's linter (builder/PLAN-TOOLING-REVIEW.md, + // decision 4): rules that find defects -- the correctness and suspicious + // groups -- and nothing about style. The formatter stays off until the + // review's last phase. scripts/check_lint.mjs runs this configuration. + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "root": true, + "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, + "files": { + "includes": [ + "builder/**/*.mjs", + "scripts/**/*.mjs", + "lib/**/*.mjs", + "book/**/*.mjs", + "book/lib/progress-handler.js", + "eval/**/*.mjs", + "wisdom/**/*.mjs", + "test/**/*.mjs", + "docs/assets/js/*.js", + // Vendored code, and the two pagedjs-cli ports the review treats as + // vendored: attributed and unmodified. + "!builder/vendor", + "!book/lib/paged.browser.js", + "!book/lib/outline.mjs", + "!book/lib/postprocesser.mjs", + // A Workflow script, not a module: the agent Workflow engine runs it as + // a function body, so it ends in a top-level `return`, which no module + // parser accepts. check_regex_safety.mjs still reads its regexes, through + // acorn's allowReturnOutsideFunction. + "!wisdom/extract/workflow.mjs", + // Trees the gates read as data. + "!test/fixtures" + ] + }, + "formatter": { "enabled": false }, + "assist": { "enabled": false }, + "linter": { + "enabled": true, + "rules": { + "preset": "none", + "correctness": { "preset": "recommended" }, + "suspicious": { + "preset": "recommended", + // `while ((m = re.exec(s)))` is how this tree walks a regex's matches. + "noAssignInExpressions": "off", + // Strings here hold GitHub Actions, PowerShell and JavaScript source + // whose `${...}` is meant literally. + "noTemplateCurlyInString": "off" + } + } + }, + "overrides": [ + { + // ES5-style scripts that ship to readers as written. + "includes": ["docs/assets/js/*.js"], + "linter": { "rules": { "correctness": { "noInnerDeclarations": "off" } } } + }, + { + // builder/ must not depend on scripts/. check_tree_fresh.mjs watches + // docs/ and builder/ as the inputs that decide the built bytes, and would + // not see a change to a module the build imported from scripts/. Biome + // files the rule under style; here it guards a dependency. + "includes": ["builder/**/*.mjs"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [{ "group": ["**/scripts/**"], "message": "builder/ must not depend on scripts/." }] + } + } + } + } + } + }, + { + // lib/ holds modules every other part of the tooling may import + // (lib/README.md), so it imports none of them; a lib/ module that reached + // into scripts/ would take builder/ there with it. + "includes": ["lib/**/*.mjs"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": ["**/builder/**", "**/scripts/**", "**/book/**", "**/eval/**", "**/wisdom/**", "**/test/**"], + "message": "lib/ must not import builder/, scripts/, book/, eval/, wisdom/ or test/." + } + ] + } + } + } + } + } + } + ] +} diff --git a/book/lib/fast-dict-array.mjs b/book/lib/fast-dict-array.mjs deleted file mode 100644 index 5f709859..00000000 --- a/book/lib/fast-dict-array.mjs +++ /dev/null @@ -1,328 +0,0 @@ -// Replace PDFDict's backing Map with a flat alternating array -// [k0, v0, k1, v1, ...]. -// -// Motivation. The sampling heap profile of the process phase (see -// "Profiling pdf-lib heap allocation" in perf/README.md) put `Map` -// constructors and `Map.prototype.set` at 50 % of total allocations -// -- ~63 MB combined -- with ~80 % of that traffic coming from one -// site: fastParseDict's per-dict accumulator -// ([fast-parse-dict.mjs:62](docs/lib/fast-parse-dict.mjs:62)). -// -// const dict = new Map(); // 24 MB of Map() constructors -// while (...) { -// const key = this.parseName(); -// const value = this.parseObject(); -// dict.set(key, value); // 38 MB of Map.set entries -// } -// ... PDFDict.fromMapWithContext(dict, this.context); -// -// Each parsed dict pays for one Map header + one hash-table backing -// arena + one bucket allocation per entry. PDF dicts are tiny (typical -// has <= 10 entries, often 2-3), so the hash-table overhead is pure -// loss vs a linear scan -- and the Map's amortized O(1) lookup buys -// nothing because nobody iterates a parsed dict enough times for the -// hash to pay back. -// -// The fix: store entries in a flat array. One allocation per dict -// (the array itself; the inline alternating layout avoids any per- -// entry bucket alloc). Lookup is a linear scan, which beats Map.get -// at this size class on every V8 microbench I've seen. -// -// Mechanism. We do three things: -// -// 1. Patch PDFDict.prototype.{keys, values, entries, set, get, has, -// delete, asMap, clone, toString, sizeInBytes, copyBytesInto} so -// `this.dict` is read as a flat array instead of a Map. -// sizeInBytes / copyBytesInto subsume fast-dict-iter.mjs (no -// Map.forEach + thisArg context object needed; iteration is just -// `for (let i = 0; i < arr.length; i += 2)`). -// -// 2. Patch PDFDict.withContext, PDFDict.fromMapWithContext, and the -// parallel fromMapWithContext / withContextAndPages helpers on -// PDFCatalog / PDFPageTree / PDFPageLeaf, plus PDFPageLeaf's -// clone() which constructs `new Map()` directly. Each of these is -// rewritten to produce / accept a flat array; the Map argument is -// converted at the seam (rare-path cost, only a few dicts per -// document hit these factories). -// -// 3. Patch PDFObjectParser.prototype.parseDict so the parser's hot -// inner loop accumulates into a flat array directly (no Map(), no -// Map.set). The Type-sentinel dispatch at the tail becomes a -// short linear scan over the array; on dicts that have a /Type -// entry it's the first or second key (PDF convention), so the -// scan is effectively O(1). This subsumes fast-parse-dict.mjs. -// -// Compatibility. Every consumer of `dict.dict.X` inside pdf-lib -// (ViewerPreferences, AppearanceCharacteristics, PDFAcroField, -// PDFAcroChoice, PDFAcroText, PDFAcroForm, PDFAnnotation, -// PDFWidgetAnnotation, BorderStyle, PDFStreamWriter, PDFCrossRefStream, -// PDFObjectCopier, PDFXRefStreamParser, etc.) goes through -// PDFDict.prototype methods (.set / .get / .has / .delete / .entries / -// .lookup), all of which we re-implement to read the array. Nobody in -// the codebase touches `dict.dict` expecting a Map iterator -- grep -// confirmed. `asMap()` still returns a fresh `new Map(...)` for any -// caller that genuinely wants a Map view. -// -// This shim is mutually exclusive with --fast-parse-dict and -// --fast-dict-iter: both are subsumed and would re-install the -// Map-based methods if loaded afterwards. measure.mjs enforces this. -// -// Side-effecting import. Import once before any pdf-lib operation: -// -// import "./lib/fast-dict-array.mjs"; -// -// Idempotent -- repeated imports do nothing after the first. - -import { createRequire } from 'node:module'; - -const require = createRequire(import.meta.url); -const PDFDict = require('pdf-lib/cjs/core/objects/PDFDict.js').default; -const PDFCatalog = require('pdf-lib/cjs/core/structures/PDFCatalog.js').default; -const PDFPageTree = require('pdf-lib/cjs/core/structures/PDFPageTree.js').default; -const PDFPageLeaf = require('pdf-lib/cjs/core/structures/PDFPageLeaf.js').default; -const PDFName = require('pdf-lib/cjs/core/objects/PDFName.js').default; -const PDFNull = require('pdf-lib/cjs/core/objects/PDFNull.js').default; -const PDFObjectParser = require('pdf-lib/cjs/core/parser/PDFObjectParser.js').default; -const CharCodes = require('pdf-lib/cjs/core/syntax/CharCodes.js').default; - -// Captured canonical PDFNames for the parser's Type-dispatch tail. -// Pool-dedup ([PDFName.js:18,100]) guarantees reference equality with -// whatever the parser sees inside the dict. -const TypeName = PDFName.of('Type'); -const CatalogName = PDFName.of('Catalog'); -const PagesName = PDFName.of('Pages'); -const PageName = PDFName.of('Page'); - -// Map -> flat array. Called at the seam from the factories below; not -// on the hot parse path. -function mapToArray(map) { - const arr = new Array(map.size * 2); - let i = 0; - for (const [k, v] of map) { arr[i++] = k; arr[i++] = v; } - return arr; -} - -// Linear scan for the index of `key` in [k0, v0, k1, v1, ...]; returns -// the key-slot index, or -1 if absent. -function indexOfKey(arr, key) { - for (let i = 0, len = arr.length; i < len; i += 2) { - if (arr[i] === key) return i; - } - return -1; -} - -if (!PDFDict.prototype.__fastDictArrayInstalled) { - - // ---- PDFDict.prototype -------------------------------------------- - - PDFDict.prototype.keys = function () { - const arr = this.dict; - const out = new Array(arr.length >> 1); - for (let i = 0, j = 0, len = arr.length; i < len; i += 2, j++) out[j] = arr[i]; - return out; - }; - - PDFDict.prototype.values = function () { - const arr = this.dict; - const out = new Array(arr.length >> 1); - for (let i = 1, j = 0, len = arr.length; i < len; i += 2, j++) out[j] = arr[i]; - return out; - }; - - PDFDict.prototype.entries = function () { - const arr = this.dict; - const out = new Array(arr.length >> 1); - for (let i = 0, j = 0, len = arr.length; i < len; i += 2, j++) { - out[j] = [arr[i], arr[i + 1]]; - } - return out; - }; - - PDFDict.prototype.set = function (key, value) { - const arr = this.dict; - const idx = indexOfKey(arr, key); - if (idx >= 0) { - arr[idx + 1] = value; - } else { - arr.push(key, value); - } - }; - - PDFDict.prototype.get = function (key, preservePDFNull) { - if (preservePDFNull === undefined) preservePDFNull = false; - const arr = this.dict; - const idx = indexOfKey(arr, key); - if (idx < 0) return undefined; - const value = arr[idx + 1]; - if (value === PDFNull && !preservePDFNull) return undefined; - return value; - }; - - PDFDict.prototype.has = function (key) { - const arr = this.dict; - const idx = indexOfKey(arr, key); - if (idx < 0) return false; - const value = arr[idx + 1]; - return value !== undefined && value !== PDFNull; - }; - - PDFDict.prototype.delete = function (key) { - const arr = this.dict; - const idx = indexOfKey(arr, key); - if (idx < 0) return false; - arr.splice(idx, 2); - return true; - }; - - PDFDict.prototype.asMap = function () { - const arr = this.dict; - const m = new Map(); - for (let i = 0, len = arr.length; i < len; i += 2) m.set(arr[i], arr[i + 1]); - return m; - }; - - PDFDict.prototype.clone = function (context) { - const ctx = context || this.context; - const cloned = this.dict.slice(); - return new PDFDict(cloned, ctx); - }; - - PDFDict.prototype.toString = function () { - const arr = this.dict; - let s = '<<\n'; - for (let i = 0, len = arr.length; i < len; i += 2) { - s += arr[i].toString() + ' ' + arr[i + 1].toString() + '\n'; - } - return s + '>>'; - }; - - PDFDict.prototype.sizeInBytes = function () { - const arr = this.dict; - let size = 5; - for (let i = 0, len = arr.length; i < len; i += 2) { - size += arr[i].sizeInBytes() + arr[i + 1].sizeInBytes() + 2; - } - return size; - }; - - PDFDict.prototype.copyBytesInto = function (buffer, offset) { - const initialOffset = offset; - buffer[offset++] = CharCodes.LessThan; - buffer[offset++] = CharCodes.LessThan; - buffer[offset++] = CharCodes.Newline; - const arr = this.dict; - for (let i = 0, len = arr.length; i < len; i += 2) { - offset += arr[i].copyBytesInto(buffer, offset); - buffer[offset++] = CharCodes.Space; - offset += arr[i + 1].copyBytesInto(buffer, offset); - buffer[offset++] = CharCodes.Newline; - } - buffer[offset++] = CharCodes.GreaterThan; - buffer[offset++] = CharCodes.GreaterThan; - return offset - initialOffset; - }; - - // ---- PDFDict factories -------------------------------------------- - - PDFDict.withContext = function (context) { - return new PDFDict([], context); - }; - PDFDict.fromMapWithContext = function (map, context) { - return new PDFDict(mapToArray(map), context); - }; - - // ---- Subclass factories ------------------------------------------- - // PDFCatalog.withContextAndPages builds a fresh 2-entry Map; just - // hand it the equivalent 2-entry array. - - PDFCatalog.withContextAndPages = function (context, pages) { - return new PDFCatalog( - [PDFName.of('Type'), CatalogName, PagesName, pages], - context, - ); - }; - PDFCatalog.fromMapWithContext = function (map, context) { - return new PDFCatalog(mapToArray(map), context); - }; - - PDFPageTree.fromMapWithContext = function (map, context) { - return new PDFPageTree(mapToArray(map), context); - }; - - PDFPageLeaf.fromMapWithContext = function (map, context, autoNormalizeCTM) { - return new PDFPageLeaf(mapToArray(map), context, autoNormalizeCTM); - }; - // PDFPageLeaf.prototype.clone constructs `new Map()` explicitly, - // then copies via this.entries() + clone.set(); since clone.set is - // PDFDict.prototype.set (now array-aware), it works as long as - // fromMapWithContext receives an empty Map and converts it. - // mapToArray(new Map()) yields []; nothing to patch here. - - // ---- PDFObjectParser.prototype.parseDict -------------------------- - // Subsumes fast-parse-dict.mjs: no `new Map()`, no `dict.set(...)` - // in the hot inner loop. The Type-sentinel dispatch at the tail is - // a short linear scan; PDF convention places /Type first, so it's - // effectively O(1) per dict. - - // Initial capacity for the per-dict accumulator. NOT a scratch - // buffer (the array isn't reused across calls -- it's allocated - // fresh each dict, filled with parsed entries, and handed to the - // PDFDict constructor where it lives as `pdfDict.dict` for the - // document's lifetime). Just a pre-sized initial capacity that - // skips push-grow's reallocation chain. - // - // Histogram from the book parse (see instrument-parsedict.mjs): - // 5-entry dicts dominate (52 %, exactly 10 push slots), 4-entry - // next (28 %, 8 slots), long tail to 7-8 entries. INITIAL_SLOTS = - // 10 is exact-fit for the median case; smaller dicts (2/3/4 - // entries) waste a few slots, larger ones (7+) take one growth - // via push. Cuts ~70 bytes of FixedArray-header allocation per - // dict vs INITIAL_SLOTS=16 -- on 261 k dict invocations that - // adds up. - const INITIAL_SLOTS = 10; - PDFObjectParser.prototype.parseDict = function fastParseDictArray() { - const bytes = this.bytes; - bytes.assertNext(CharCodes.LessThan); - bytes.assertNext(CharCodes.LessThan); - this.skipWhitespaceAndComments(); - const arr = new Array(INITIAL_SLOTS); - let len = 0; - while (!bytes.done() && - bytes.peek() !== CharCodes.GreaterThan && - bytes.peekAhead(1) !== CharCodes.GreaterThan) { - const key = this.parseName(); - const value = this.parseObject(); - if (len < INITIAL_SLOTS) { - arr[len] = key; - arr[len + 1] = value; - } else { - // Rare overflow path: set length to current len so push - // appends at the right offset, then grow naturally. - arr.length = len; - arr.push(key, value); - } - len += 2; - this.skipWhitespaceAndComments(); - } - this.skipWhitespaceAndComments(); - bytes.assertNext(CharCodes.GreaterThan); - bytes.assertNext(CharCodes.GreaterThan); - arr.length = len; - - // Type-sentinel dispatch. Inline-scan for TypeName; in practice - // it's at arr[0] or arr[2]. - let Type; - for (let i = 0; i < len; i += 2) { - if (arr[i] === TypeName) { Type = arr[i + 1]; break; } - } - if (Type === CatalogName) return new PDFCatalog(arr, this.context); - if (Type === PagesName) return new PDFPageTree(arr, this.context); - if (Type === PageName) return new PDFPageLeaf(arr, this.context); - return new PDFDict(arr, this.context); - }; - - PDFDict.prototype.__fastDictArrayInstalled = true; - // Mark the subsumed shims as installed so a redundant load is a no-op. - PDFDict.prototype.__fastDictIterInstalled = true; - PDFObjectParser.prototype.__fastParseDictInstalled = true; -} diff --git a/book/lib/fast-dict-iter.mjs b/book/lib/fast-dict-iter.mjs deleted file mode 100644 index 1d2a6cb8..00000000 --- a/book/lib/fast-dict-iter.mjs +++ /dev/null @@ -1,81 +0,0 @@ -// Replace pdf-lib's PDFDict.sizeInBytes and PDFDict.copyBytesInto -- both of -// which materialize a fresh Array of [key, value] tuples via this.entries() -// on every call -- with versions that iterate the underlying Map in place. -// -// The upstream entries() helper -// ([PDFDict.js:22](node_modules/pdf-lib/cjs/core/objects/PDFDict.js:22)) is: -// -// PDFDict.prototype.entries = function () { -// return Array.from(this.dict.entries()); -// }; -// -// Per call that is: one MapIterator + one outer Array + one fresh -// [key, value] tuple per entry (allocated by the iterator itself). The save -// path fires both consumers on every dict (sizeInBytes to measure first, -// then copyBytesInto to write), so on the book that's ~100 k Array.from -// calls feeding the GC; PDFDict.entries was the largest non-GC row in the -// process profile (~10 % of process self-time) and (garbage collector) sat -// at the top. -// -// Map.prototype.forEach((value, key) => ...) calls back with positional -// arguments and never allocates a tuple. The two consumers don't need the -// tuple form -- they immediately destructure -- so swapping is local. -// -// We do NOT touch PDFDict.prototype.entries itself: clone() and toString() -// still call it and rely on the Array-of-tuples contract. Those paths fire -// rarely (clone on incremental updates only, toString in debug output) and -// aren't worth the contract churn. -// -// Side-effecting import. Import once before any pdf-lib save: -// -// import "./lib/fast-dict-iter.mjs"; -// -// Idempotent -- repeated imports do nothing after the first. - -import { createRequire } from 'node:module'; - -const require = createRequire(import.meta.url); -const PDFDict = require('pdf-lib/cjs/core/objects/PDFDict.js').default; -const CharCodes = require('pdf-lib/cjs/core/syntax/CharCodes.js').default; - -// Callbacks are module-level (not closures) so Map.forEach reuses the same -// function reference on every call instead of allocating a fresh context -// per invocation. Per-call state is threaded through forEach's `thisArg` -// (one small object alloc per call, instead of one closure context plus -// one heap cell for the captured `offset` mutation). -function _sizeInBytesEntry(value, key) { - this.s += key.sizeInBytes() + value.sizeInBytes() + 2; -} - -function _copyBytesIntoEntry(value, key) { - const buf = this.buf; - let off = this.off; - off += key.copyBytesInto(buf, off); - buf[off++] = CharCodes.Space; - off += value.copyBytesInto(buf, off); - buf[off++] = CharCodes.Newline; - this.off = off; -} - -if (!PDFDict.prototype.__fastDictIterInstalled) { - PDFDict.prototype.sizeInBytes = function () { - const ctx = { s: 5 }; - this.dict.forEach(_sizeInBytesEntry, ctx); - return ctx.s; - }; - - PDFDict.prototype.copyBytesInto = function (buffer, offset) { - const initialOffset = offset; - buffer[offset++] = CharCodes.LessThan; - buffer[offset++] = CharCodes.LessThan; - buffer[offset++] = CharCodes.Newline; - const ctx = { buf: buffer, off: offset }; - this.dict.forEach(_copyBytesIntoEntry, ctx); - offset = ctx.off; - buffer[offset++] = CharCodes.GreaterThan; - buffer[offset++] = CharCodes.GreaterThan; - return offset - initialOffset; - }; - - PDFDict.prototype.__fastDictIterInstalled = true; -} diff --git a/book/lib/fast-indirect-objects.mjs b/book/lib/fast-indirect-objects.mjs index 9058414f..ebb740d6 100644 --- a/book/lib/fast-indirect-objects.mjs +++ b/book/lib/fast-indirect-objects.mjs @@ -18,7 +18,7 @@ // indirect objects, discarding each intermediate arena to GC. // // PDFRefs are overwhelmingly gen=0 (revisions / incremental updates -// are the only gen!=0 producers, and they're rare). fast-refs.mjs +// are the only gen!=0 producers, and they're rare). fast-refs-class.mjs // already exploits this on the key side -- a dense array indexed by // objectNumber for the PDFRef pool, Map fallback for gen!=0. This // shim does the same on the value side for PDFContext.indirectObjects. diff --git a/book/lib/fast-parse-dict.mjs b/book/lib/fast-parse-dict.mjs deleted file mode 100644 index 203549c8..00000000 --- a/book/lib/fast-parse-dict.mjs +++ /dev/null @@ -1,87 +0,0 @@ -// Hoist the four sentinel PDFName.of calls out of -// PDFObjectParser.prototype.parseDict. -// -// The upstream parseDict -// ([PDFObjectParser.js:141](node_modules/pdf-lib/cjs/core/parser/PDFObjectParser.js:141)) -// ends every dict it parses with a Type-dispatch tail: -// -// var Type = dict.get(PDFName.of('Type')); -// if (Type === PDFName.of('Catalog')) return PDFCatalog.fromMapWithContext(...); -// else if (Type === PDFName.of('Pages')) return PDFPageTree.fromMapWithContext(...); -// else if (Type === PDFName.of('Page')) return PDFPageLeaf.fromMapWithContext(...); -// else return PDFDict.fromMapWithContext(...); -// -// That's 4 PDFName.of calls per dict, even on the overwhelming -// majority (resource dicts, font descriptors, content-stream dicts) -// that have no /Type entry at all. With --fast-decode-name in -// effect each call collapses to a Map.get on fastCache, but -// fastOf is still the #4 row in process.cpuprofile (~80 ms, -// 5.2 %). -// -// PDFName instances are pool-deduped -// ([PDFName.js:18,100](node_modules/pdf-lib/cjs/core/objects/PDFName.js:18)) -// so the sentinel "Type" / "Catalog" / "Pages" / "Page" PDFNames -// are reference-stable for the entire load. Capture them once at -// shim-load time and substitute direct constants for the four -// PDFName.of calls inside parseDict. The rest of the function -// body is preserved verbatim -- same loop, same dict.set, same -// dispatch shape. -// -// Mechanism: PDFObjectParser isn't re-exported by pdf-lib's index, -// so we reach in through the CJS internals via createRequire (same -// shape as fast-parse-number.mjs / fast-dict-iter.mjs). Mutating -// PDFObjectParser.prototype.parseDict is global -- every parser -// instance created after this shim loads picks it up. -// -// Side-effecting import. Import once before PDFDocument.load runs: -// -// import "./lib/fast-parse-dict.mjs"; -// -// Idempotent -- repeated imports do nothing after the first. - -import { createRequire } from 'node:module'; - -const require = createRequire(import.meta.url); -const PDFObjectParser = require('pdf-lib/cjs/core/parser/PDFObjectParser.js').default; -const PDFName = require('pdf-lib/cjs/core/objects/PDFName.js').default; -const PDFDict = require('pdf-lib/cjs/core/objects/PDFDict.js').default; -const PDFCatalog = require('pdf-lib/cjs/core/structures/PDFCatalog.js').default; -const PDFPageTree = require('pdf-lib/cjs/core/structures/PDFPageTree.js').default; -const PDFPageLeaf = require('pdf-lib/cjs/core/structures/PDFPageLeaf.js').default; -const CharCodes = require('pdf-lib/cjs/core/syntax/CharCodes.js').default; - -// Capture canonical PDFName instances. Pool-dedup guarantees the -// parser would have built === these even if the original parseDict -// were still in play. -const TypeName = PDFName.of('Type'); -const CatalogName = PDFName.of('Catalog'); -const PagesName = PDFName.of('Pages'); -const PageName = PDFName.of('Page'); - -if (!PDFObjectParser.prototype.__fastParseDictInstalled) { - PDFObjectParser.prototype.parseDict = function fastParseDict() { - const bytes = this.bytes; - bytes.assertNext(CharCodes.LessThan); - bytes.assertNext(CharCodes.LessThan); - this.skipWhitespaceAndComments(); - const dict = new Map(); - while (!bytes.done() && - bytes.peek() !== CharCodes.GreaterThan && - bytes.peekAhead(1) !== CharCodes.GreaterThan) { - const key = this.parseName(); - const value = this.parseObject(); - dict.set(key, value); - this.skipWhitespaceAndComments(); - } - this.skipWhitespaceAndComments(); - bytes.assertNext(CharCodes.GreaterThan); - bytes.assertNext(CharCodes.GreaterThan); - const Type = dict.get(TypeName); - if (Type === CatalogName) return PDFCatalog.fromMapWithContext(dict, this.context); - if (Type === PagesName) return PDFPageTree.fromMapWithContext(dict, this.context); - if (Type === PageName) return PDFPageLeaf.fromMapWithContext(dict, this.context); - return PDFDict.fromMapWithContext(dict, this.context); - }; - - PDFObjectParser.prototype.__fastParseDictInstalled = true; -} diff --git a/book/lib/fast-parse-object.mjs b/book/lib/fast-parse-object.mjs index e573dc44..33bcbfa4 100644 --- a/book/lib/fast-parse-object.mjs +++ b/book/lib/fast-parse-object.mjs @@ -36,7 +36,7 @@ // // Mechanism: PDFObjectParser isn't re-exported from pdf-lib's index, // so we reach in through the CJS internals via createRequire (same -// shape as fast-parse-dict.mjs). Mutating +// shape as fast-sync-load.mjs). Mutating // PDFObjectParser.prototype.parseObject is global -- every parser // instance created after this shim loads picks it up. // diff --git a/book/lib/fast-refs-class.mjs b/book/lib/fast-refs-class.mjs index c1c11e29..f88b29d4 100644 --- a/book/lib/fast-refs-class.mjs +++ b/book/lib/fast-refs-class.mjs @@ -1,6 +1,8 @@ -// fast-refs variant: use a class-style constructor for stable hidden class. +// Replaces PDFRef.of: a dense-array cache for gen=0 refs, built with a +// class-style constructor for a stable hidden class. // -// fast-refs.mjs builds PDFRef instances with +// The shim this replaced, fast-refs.mjs (since deleted; see +// "fast-refs-class" in perf/notes/08-pdf-lib.md), built PDFRef instances with // `Object.create(PDFRef.prototype) + fresh.objectNumber = ... + fresh.gen = ...`. // V8 treats objects built that way as transitioning through intermediate // hidden-class maps as each property is added, and the result is roughly @@ -29,17 +31,20 @@ // raw, aligned to 16 B by V8 -- versus 12 + 2*4 = 20 B raw, aligned to // 24 B for a 2-slot instance. Saves 8 B per gen=0 PDFRef * ~226 k unique // = ~1.8 MB heap on the book. -// -// Mutually exclusive with --fast-refs in the harness. import { PDFRef } from 'pdf-lib'; -// ---- helpers (same as fast-refs.mjs, see commentary there) ------------- +// ---- helpers ----------------------------------------------------------- +// Write n's decimal representation into buffer starting at offset. +// No allocations. Returns the number of bytes written. n must be a +// non-negative integer. function _writeUint(buffer, offset, n) { if (n < 10) { buffer[offset] = 0x30 + n; return 1; } + // Count digits. let m = n, d = 0; while (m > 0) { d++; m = (m / 10) | 0; } + // Write digits backwards. for (let i = d - 1; i >= 0; i--) { buffer[offset + i] = 0x30 + (n % 10); n = (n / 10) | 0; @@ -47,6 +52,8 @@ function _writeUint(buffer, offset, n) { return d; } +// Non-allocating decimal digit count for non-negative integers. +// Ladder catches the common small-number cases without arithmetic. function _digitCount(n) { if (n < 10) return 1; if (n < 100) return 2; diff --git a/book/lib/fast-refs.mjs b/book/lib/fast-refs.mjs deleted file mode 100644 index beeb76ad..00000000 --- a/book/lib/fast-refs.mjs +++ /dev/null @@ -1,140 +0,0 @@ -// Replace pdf-lib's PDFRef.of pool lookup with a dense-array cache -// for the generation=0 case (the overwhelmingly common one), AND -// drop the per-instance `tag` string entirely. -// -// The upstream implementation -// (node_modules/pdf-lib/cjs/core/objects/PDFRef.js) keys its pool by -// a freshly-built string ` R` on every call: -// -// var tag = objectNumber + " " + generationNumber + " R"; -// var instance = pool.get(tag); -// -// On the book we see ~1.2 M PDFRef.of calls per load, 82 % of them -// with gen=0; each call allocates the tag string before Map.get can -// hash it. That's ~330 ms of self-time on the process-phase profile -// plus measurable GC pressure. -// -// Shim part 1: dense array indexed by objectNumber for the gen=0 branch. -// Plain array indexing, no string alloc, no Map hash. On a gen=0 cache -// miss we construct the PDFRef directly via -// `Object.create(PDFRef.prototype)` plus manual field init, skipping -// both the ENFORCER check and the upstream `pool.set(tag, instance)`. -// -// Shim part 2: drop the per-instance `tag` field. Upstream caches -// ` R` on each PDFRef so toString / sizeInBytes / -// copyBytesInto can read it back. After fast-array-onebuf shipped, -// the heap profile showed PDFParser.parseIndirectObjectHeader sitting -// at 13.7 MB (25 % of total). The attribution chain (via -// perf/find-heap-callers.mjs): -// -// parseIndirectObjectHeader → skipJibberish (14.2 MB) -// → matchIndirectObjectHeader (try/catch wrapper) -// → parseIndirectObjectHeader → fastOf -// -// skipJibberish runs after every successful indirect object parse and -// speculatively calls matchIndirectObjectHeader to detect the next -// `N M obj` header. On valid PDFs the speculation always succeeds, so -// fastOf fires once per indirect-object boundary, populating the -// dense-array cache. The subsequent "real" parseIndirectObject then -// hits the cache. V8 inlines fastOf at this call site (small + hot -// from speculation) so the attribution lands on the caller -- 13.7 MB -// of which was the tag-string allocation (`objectNumber + ' 0 R'`): -// V8 builds 1-2 intermediate concat strings + the final ~25-35 B -// tag, ~150 k times. -// -// Eliminating the `tag` field collapses all of that. The prototype -// methods now compute their results from objectNumber / generationNumber -// directly. copyBytesInto writes digits straight into the output buffer -// with a no-allocation _writeUint helper; sizeInBytes returns -// digitCount(obj) + digitCount(gen) + 3 (for " " + " R"); toString -// builds on demand (only used for debug, no caching needed). -// -// gen != 0 PDFRefs constructed via the upstream path still have `tag` -// set by the upstream constructor -- our overrides ignore the field, -// so the tag string is allocated-then-wasted. gen != 0 is ~18 % of refs -// at ~50 K instances; the waste is bounded and not worth patching the -// constructor for. -// -// gen != 0 cache lookups (pdf-lib's xref-stream bookkeeping where -// "generation" encodes an in-ObjStm index per PDF 1.5 spec, see -// PDFXRefStreamParser.js:74-80) still pass through the original -// PDFRef.of -- their Map pool is harmless at gen!=0's volume. -// -// Side-effecting import. Import once before any pdf-lib operation. -// Idempotent. - -import { PDFRef } from "pdf-lib"; - -// Write n's decimal representation into buffer starting at offset. -// No allocations. Returns the number of bytes written. n must be a -// non-negative integer. -function _writeUint(buffer, offset, n) { - if (n < 10) { buffer[offset] = 0x30 + n; return 1; } - // Count digits. - let m = n, d = 0; - while (m > 0) { d++; m = (m / 10) | 0; } - // Write digits backwards. - for (let i = d - 1; i >= 0; i--) { - buffer[offset + i] = 0x30 + (n % 10); - n = (n / 10) | 0; - } - return d; -} - -// Non-allocating decimal digit count for non-negative integers. -// Ladder catches the common small-number cases without arithmetic. -function _digitCount(n) { - if (n < 10) return 1; - if (n < 100) return 2; - if (n < 1000) return 3; - if (n < 10000) return 4; - if (n < 100000) return 5; - if (n < 1000000) return 6; - let d = 0; - while (n > 0) { d++; n = (n / 10) | 0; } - return d; -} - -if (!PDFRef.__fastPoolInstalled) { - const original = PDFRef.of; - const pool0 = []; - PDFRef.of = function fastOf(objectNumber, generationNumber) { - if (generationNumber === undefined || generationNumber === 0) { - const existing = pool0[objectNumber]; - if (existing) return existing; - // Direct construction -- skip ENFORCER check, skip upstream pool.set, - // skip the per-instance `tag` string (the prototype methods now - // compute their results from objectNumber / generationNumber). - const fresh = Object.create(PDFRef.prototype); - fresh.objectNumber = objectNumber; - fresh.generationNumber = 0; - pool0[objectNumber] = fresh; - return fresh; - } - return original.call(PDFRef, objectNumber, generationNumber); - }; - - // Replace the upstream prototype methods to ignore `tag` entirely. - // Works for both gen=0 (tag is absent) and gen!=0 (tag is set by - // upstream's constructor but ignored). - - PDFRef.prototype.toString = function () { - return this.objectNumber + ' ' + this.generationNumber + ' R'; - }; - - PDFRef.prototype.sizeInBytes = function () { - return _digitCount(this.objectNumber) + _digitCount(this.generationNumber) + 3; - }; - - PDFRef.prototype.copyBytesInto = function (buffer, offset) { - const start = offset; - offset += _writeUint(buffer, offset, this.objectNumber); - buffer[offset++] = 0x20; // ' ' - offset += _writeUint(buffer, offset, this.generationNumber); - buffer[offset++] = 0x20; // ' ' - buffer[offset++] = 0x52; // 'R' - return offset - start; - }; - - PDFRef.__fastPoolInstalled = true; -} diff --git a/book/lib/fast-sync-load.mjs b/book/lib/fast-sync-load.mjs index 1109247d..4a81f97f 100644 --- a/book/lib/fast-sync-load.mjs +++ b/book/lib/fast-sync-load.mjs @@ -77,8 +77,8 @@ const { Keywords } = require('pdf-lib/cjs/core/syntax/Keywords.js'); const { toUint8Array, copyStringIntoBuffer, last } = require('pdf-lib/cjs/utils/index.js'); // Pool-deduped PDFName instances are reference-stable for the whole -// load (see fast-parse-dict.mjs for the same trick). Capture the three -// sentinels parseIndirectObject's Type-dispatch needs. +// load. Capture the three sentinels parseIndirectObject's Type-dispatch +// needs. const TypeName = PDFName.of('Type'); const ObjStmName = PDFName.of('ObjStm'); const XRefName = PDFName.of('XRef'); @@ -128,7 +128,7 @@ if (!PDFParser.prototype.__fastSyncLoadInstalled) { const initialOffset = this.bytes.offset(); try { this.parseIndirectObject(); - } catch (e) { + } catch { this.bytes.moveTo(initialOffset); this.tryToParseInvalidIndirectObject(); } diff --git a/book/lib/measure-pass.mjs b/book/lib/measure-pass.mjs index 293e6887..1fc49c84 100644 --- a/book/lib/measure-pass.mjs +++ b/book/lib/measure-pass.mjs @@ -345,7 +345,7 @@ export class Measurer { if (tag === 1 && IsNumeric[buf[this.pos]]) { const v = this.parseNumberOrRefCapture(); - if (!isNaN(v)) this._stLength[d] = v; + if (!Number.isNaN(v)) this._stLength[d] = v; } else if (tag === 2 && buf[this.pos] === SLASH) { if (this._isNameAt(this.pos + 1, 'ObjStm')) this._stIsObjStm[d] = 1; this.pos++; @@ -353,10 +353,10 @@ export class Measurer { this.numNames++; } else if (tag === 3 && IsNumeric[buf[this.pos]]) { const v = this.parseNumberOrRefCapture(); - if (!isNaN(v)) this._stN[d] = v; + if (!Number.isNaN(v)) this._stN[d] = v; } else if (tag === 4 && IsNumeric[buf[this.pos]]) { const v = this.parseNumberOrRefCapture(); - if (!isNaN(v)) this._stFirst[d] = v; + if (!Number.isNaN(v)) this._stFirst[d] = v; } else { this.parseObject(); } @@ -371,7 +371,7 @@ export class Measurer { } parseArray() { - const d = this._depth++; + this._depth++; if (this._depth > this.maxRecursionDepth) this.maxRecursionDepth = this._depth; this.pos++; diff --git a/book/render-book.mjs b/book/render-book.mjs index 427586b0..c2fd8231 100644 --- a/book/render-book.mjs +++ b/book/render-book.mjs @@ -43,7 +43,7 @@ import { PDFDocument } from 'pdf-lib'; // copyBytesInto compute from objectNumber / generationNumber // directly via _writeUint + _digitCount helpers). Replaces the // `Object.create(PDFRef.prototype) + property writes` pattern of -// the older fast-refs.mjs shim, which V8 routes through the +// the older fast-refs shim (since deleted), which V8 routes through the // slow-property path: PDFRef ended up at ~60 B/instance vs // PDFName's ~31 B (`new PDFName(...)`-built). The constructor // gives V8 a stable hidden class from the first instance and @@ -54,8 +54,8 @@ import { PDFDocument } from 'pdf-lib'; // trimmed parseIndirectObjectHeader by ~4.3 MB. Same prototype // methods, same instanceof semantics; the only change is the // construction style. See "fast-refs-class" in -// perf/notes/08-pdf-lib.md. fast-refs.mjs stays in the tree as -// an A/B baseline (mutex-checked in measure.mjs). +// perf/notes/08-pdf-lib.md, which also records the measurements +// for fast-refs; git history has its code. // fast-inflate -- swaps pako.inflate for node:zlib.inflateSync // on the one pdf-lib call site that uses it // (PDFCrossRefStreamParser during load). Negligible cost shift, @@ -92,9 +92,9 @@ import { PDFDocument } from 'pdf-lib'; // beyond fast-dict-array. See "One-buffer PDFDict" in // perf/notes/08-pdf-lib.md. // -// Earlier dict-shape shims (fast-dict-array, fast-dict-iter, -// fast-parse-dict) stay in the tree as A/B baselines but are -// mutually exclusive with --fast-dict-onebuf in measure.mjs. +// The earlier dict-shape shims it replaced (fast-dict-array, +// fast-dict-iter, fast-parse-dict) were deleted; their measurements +// are in perf/notes/08-pdf-lib.md, and git history has the code. // fast-parse-object -- replace PDFObjectParser.prototype.parseObject // with a first-byte-dispatch version that gates the three // matchKeyword (true / false / null) scans behind a byte check. diff --git a/builder/PLAN-TOOLING-REVIEW.md b/builder/PLAN-TOOLING-REVIEW.md new file mode 100644 index 00000000..c18512c5 --- /dev/null +++ b/builder/PLAN-TOOLING-REVIEW.md @@ -0,0 +1,2307 @@ +# Tooling review: strategy, decisions and status + +A software-engineering review of the repository's own tooling: `tbdocs`, the gates, the +compiler harness, the book renderer and the smaller tools. It asks about factoring and +repetition, and about sound design against hacks. That makes it a different kind of review +from [REVIEW-c9f2dfe0-1b6922b.md](REVIEW-c9f2dfe0-1b6922b.md), which asked whether one +range of commits was correct. + +The findings are in [REVIEW-TOOLING-fe9ce12b.md](REVIEW-TOOLING-fe9ce12b.md). The commit +plan that addresses them is under [Execution](#execution), with a coverage table that maps +every finding to its commit. + +## Status + +- 2026-09-25: strategy agreed; review passes started against `fe9ce12b` on `staging`. +- 2026-09-25: all fourteen passes reported; verification running. Decisions 1 and 5 were + settled further while the passes ran (below), and the four superseded pdf-lib shims were + deleted. The passes also raised a theme the review will lead with: markdown is repeatedly + processed as text, each tool deciding privately what counts as code. +- 2026-09-25: the review is written, [REVIEW-TOOLING-fe9ce12b.md](REVIEW-TOOLING-fe9ce12b.md), + with its evidence beside it in [REVIEW-TOOLING-fe9ce12b/](REVIEW-TOOLING-fe9ce12b/README.md). + The owner accepted all seven of its decisions as recommended (listed below). Decision (f), + the `staging.md` content slip, is already fixed. Next: the commit plan, added to this file. +- 2026-09-25: the commit plan is written, under [Execution](#execution): 87 commits in seven + phases. Planning against the tree turned up ten places where a finding's fix, one of its + facts, or a detail of this charter had to change; see + [Where this plan departs from the review](#where-this-plan-departs-from-the-review). + No commit of it has landed yet; C01 is next. The owner confirmed the four commands that + needed it (C05, C08, C09 and C40) the same day, C08 on condition that the hook runs Biome + and nothing else. + +## Decisions + +Made on 2026-09-25, before the review started. + +1. **`perf/` is a lab notebook, and nothing moves out of it.** It is not reviewed, linted + or formatted. *Amended during the review:* the book build loads one file from it at run + time, `perf/detach-pages.js`. Moving that file would break five lab scripts that load it + from beside themselves, and touch several pages, so it stays; its header and + `perf/README.md` now say that the book build depends on it. Four superseded pdf-lib + shims that only `perf/` loaded (`fast-refs`, `fast-dict-array`, `fast-dict-iter`, + `fast-parse-dict`) were deleted from `book/lib/` rather than moved: pdf-lib is pinned at + its final release, so the comparisons they served are settled, their measurements are + in `perf/notes/08-pdf-lib.md`, and git keeps the code. +2. **Fix in place first.** Splitting a large module is a separate pass after the in-place + work, taken only where the evidence says good practice calls for it. The review records + split candidates; it does not propose splits as fixes. +3. **The before-and-after tree comparison becomes a committed tool**, because every future + `builder/` refactor needs it. +4. **A linter and a formatter run before every commit**, as a matter of course, with a gate + in `test.bat` and both CI workflows as the backstop. **The linter comes first** (Phase + 0): it is most useful while code is being consolidated, when moved and deleted code + leaves unused imports and undeclared names behind. **Formatting is a separate, last + pass** (Phase 6), once every fix from the review is in. The review's citations stay + valid while the fixes are made, and the one formatting commit touches only code that + survived them. +5. **`impexp.py` and `impexp.mjs` both stay**: both are published for readers' + convenience. **`build_fonts.py` stays**: a JavaScript port was evaluated recently and is + blocked by harfbuzzjs ([WIP.Fonts.md](../WIP.Fonts.md)). **The standalone link checker + stays, as a thin wrapper**: decided on A4's evidence. `scripts/check_links.mjs` keeps + its command line and its own reading of a tree from disk, and calls the functions + `builder/check.mjs` exports for everything else; `check_links_diff.mjs` then compares + one implementation reading a tree two ways. +6. **A gate checks that both CI workflows run the same gates as the wrappers**, rather than + generating the workflows and wrappers from one list. + +Taken on the review's recommendations, 2026-09-25; the letters are the review's: + +- **(a) One shared markdown module, in a new top-level `lib/`**: markdown-it block regions, + one tested inline-code splitter, and a frontmatter splitter on js-yaml 4. gray-matter, and + the js-yaml 3 it bundles, are dropped. The five rewriting sites move onto it before the ten + scanning sites. +- **(b) A parity gate for `impexp.mjs` and `impexp.py`**, run unconditionally in CI. Whether + `test.bat` then requires Python or reports the gate as skipped, loudly, is settled when the + gate is written. +- **(c) Load-time checks in each pdf-lib shim, and an equivalence test against stock + pdf-lib**, modelled on the axe patch and `check_axe_patch_equiv.mjs`. +- **(d) A composite CI action for the two workflows' shared steps**, after the roster gate of + decision 6. +- **(e) Command lines:** Phase 2 builds `scripts/lib/cli.mjs` on `node:util` `parseArgs` with no + change in behaviour; Phase 3 converges on `impexp.mjs`'s discipline (`--help` to stdout and + exit 0, errors to stderr and exit 2, one table of exit codes per tool). +- **(f) The `staging.md` content slip is fixed now**, as data, ahead of the tooling. +- **(g) The pinning policy is stated** (exact where the code patches a dependency or relies on + its internals, caret otherwise), and Builder.md's stale Dependencies section is fixed. + +## Scope + +| Area | Size | Treatment | +|---|---|---| +| `builder/` (tbdocs) | ~16.5k lines | full review | +| `scripts/`, `scripts/lib/` | ~18.7k | full review | +| `book/` renderer and pdf-lib patches | ~4.4k, not counting the 33k-line paged.js fork | full review | +| `test/addin/`, `eval/`, `wisdom/` | 1.5k, 1.0k, 2.4k | lighter review | +| the `.bat` wrappers and both workflows | ~0.9k | reviewed together, as the list of gates | +| `docs/assets/js/` | 0.4k | lighter review; it ships to readers | +| `perf/` | 8.8k, 49 files | not reviewed (decision 1) | +| vendored code: `book/lib/paged.browser.js`, `builder/vendor/` | | not reviewed; only whether our changes to it are recorded | + +## Baseline survey + +Taken at `fe9ce12b` with [`scripts/survey_tooling.mjs`](../scripts/survey_tooling.mjs), to +be re-run when the work is done: a token-level clone detector (identifiers and literals +normalised, windows of 60 tokens) and an import graph over the 185 first-party JavaScript +files, `perf/` included and vendored code excluded. `--summary` prints the first six rows; +`--root` measures a checkout of `fe9ce12b` itself, which does not contain the script. + +| Measure | At `fe9ce12b` | +|---|---| +| clone regions of 60+ tokens | 680, of which 318 do not involve `perf/` | +| top-level function names defined in two or more files | 77, of which 57 outside `perf/` | +| command-line tools outside `perf/` that read `process.argv` | 32, none using `node:util` `parseArgs` | +| tools with a private copy of `flag`/`opt`/`die` | 6, with `opt` in three different versions | +| packages imported but not declared | 2: `picocolors` (installed for `@babel/code-frame`), `pako` (for `pdf-lib`) | +| clone regions by pair of areas: `builder`/`scripts`, `scripts`/`scripts`, `builder`/`builder` | 55, 95, 39 | + +Observed by hand at the same commit: + +| Observation | At `fe9ce12b` | +|---|---| +| places that state which gates run | 5 that execute (three `.bat` files, two workflows), plus Tools.md | +| lint or format tooling | none | +| line endings | LF in the repository; CRLF in 118 of 140 working-tree files under `core.autocrlf=true`; no `.gitattributes` | +| quote style | double in `builder/`, `scripts/`, `test/`, `eval/`; single in `book/`, `wisdom/`, `perf/` | + +## How the review is run + +The review ran as fourteen passes against `fe9ce12b`: ten area passes (`A1`–`A10`), each over +one part of the tree, and four lens passes (`L1`–`L4`), each following one concern across the +whole tree. A verifier re-checked every R1 and R2 citation, and the orchestrator merged +findings more than one pass reported. The method (each pass's exact scope, what counted as a +finding, the acceptable-workaround test, and the output format) is recorded in +[REVIEW-TOOLING-fe9ce12b.md](REVIEW-TOOLING-fe9ce12b.md); the review is finished and that +record does not change. + +The commit entries below still cite the review's notation: + +- **Finding IDs**: `-`, the pass's label (`A1` through `A10`, `L1` through `L4`) and + the finding's number within it, for example `A7-1` or `L3-1`. A `/` joins IDs the review + merged as one finding. +- **Severity, by cost**: **R1** has already produced a divergence or a defect, or will on the + next ordinary change. **R2** makes every change in its area slower or riskier. **R3** is + local untidiness. + +## Oracles + +The existing gates check the site, not how the tools behave inside, so each refactor is +compared before and after: + +| Tool | Comparison | +|---|---| +| `tbdocs` | `scripts/compare_trees.mjs` (C02, decision 3): build before and after into scratch `--dest` folders with `--no-fetch-assets`; the three trees must match byte for byte. The first step is to show that two builds of one commit already match. Known differences are normalised, each for a stated reason, rather than excluded: the build's own timings in `assets/images/gantt.svg` and in the copy of that chart inlined into `Documentation/Development/BuildInfo.html` in the online and offline trees, and the commit and date on the PDF title page, which differ only when the two sides are different commits. | +| gates | The same output on the real tree; and a changed gate must still fail when its original defect is put back. | +| harness | The `examples.bat` summary unchanged (1,129 samples as of 2026-09-25) and `addin-test.bat` green, against the local BETA 983. Never two harness runs at once. | +| book | Page count, outline and extracted text unchanged; the PDF's bytes include timestamps. From C66, `check_pdf_shims_equiv.mjs`, which compares the shimmed pdf-lib with stock, is the oracle for the shims. | +| command lines | From C47, `check_cli.mjs`'s recorded cases: each migrated tool's exit code, stream and message for the invocations that stop during argument parsing. | +| CI | For a commit that changes a workflow or the composite action: a dispatch of `checks.yml` on `origin`, and the deploy workflow's own run on the next push of `staging`. A dispatch of the deploy workflow cuts a GitHub release, so it is not a test. Pushing is the owner's call, so these commits wait for it. | + +## Execution + +Each phase lands as commits on `staging`, the working branch, which is merged upstream when a +chunk of work is done. The commits below are numbered in the order they are meant to land. A +commit that needs a follow-up takes a letter (C07a) rather than renumbering the rest. From now +on, a commit's **Landed** note is written in full in the commit that lands it, where git +history keeps it; at the end of each phase, that phase's landed entries are cut to what later +work still needs. The landed entries of C01–C18 and C13a were cut this way on 2026-09-26; +their full text is in this file as it stood before the commit `builder: cut the tooling +plan's landed entries to what later work needs`. Line numbers are the +review's, at `fe9ce12b`, and move as the commits land. + +### The organising idea + +Three things decide the order. + +**Nothing is refactored before the oracles exist.** Most commits below claim to change no +behaviour, and such a claim is only as good as the comparison behind it. Phase 0 builds the +tree comparison and shows that two builds of one commit already agree, then the roster gate +and the linter, before any finding is touched. + +**Defects are fixed in place before code is shared.** Phase 2 promises no change in +behaviour. A defect still present when its tool moves onto a shared module is either +preserved by the move, where the comparison approves it, or fixed inside the move, where the +comparison reports a difference that is not a regression. So Phase 1 fixes, in place and +each against its own oracle, every R1 defect whose fix does not wait on a Phase 2 module, and +Phase 2's comparisons have one right answer: identical. Twelve of the twenty R1 findings are +closed by the end of Phase 1, and L3-3's silent drop is made loud there. The other seven are +duplications whose fix is the shared module itself. Five of them change nothing on today's +content (A3-1, A3-3, A8-1, A10-2, L4-10), and the other two are conventions rather than wrong +results: A5-1's ignored typo and A6-1's indentation. + +**Rewriters before scanners, `tbdocs` last, the roster gate before the shared action.** +Decision (a) moves the five markdown rewriters before the ten scanners, because a wrong region +in a rewriter corrupts committed content. Decision (e) migrates `tbdocs`'s command line last. +Decision (d) builds the composite action only once the roster gate can check it, and building +both before the first new gate means every later gate is registered once and checked from +then on. + +### Phase order + +| Phase | Commits | Why here | +|---|---|---| +| 0: process and oracles | C01–C08 | Every later commit is judged by them. | +| 1: remove, relocate, fix in place | C09–C30 | Dead code goes before anyone factors it; two moves the later modules need; the defects that need no Phase 2 module, so that Phase 2's comparisons have one right answer. | +| 2: shared code, no behaviour change | C31–C70 | The review's duplications, one shared module at a time, each adopted under the tree comparison or the tool's own oracle. | +| 3: conventions users see | C71–C75 | Behaviour changes on purpose, once every tool parses its command line through one module. | +| 4: splits | C76–C81 | Only where the evidence holds (decision 2). | +| 5: documentation and measurement | C82–C83 | Written against the code as it then is. | +| 6: formatting | C84–C87 | Last, so the one mechanical commit touches only code that survived. | + +Four commits wait for the owner before a command runs: C05, C09 and C40 change installed +packages, and C08 changes this clone's git configuration. All four were confirmed on +2026-09-25, C08 on condition that the hook runs Biome and nothing else. The commits that change a workflow +or add a gate to one (C03, C04, C06, C47, C62, C66, C70, C87) wait for the owner's next push +before CI can confirm them. + +### Where this plan departs from the review + +Planning against the tree turned up places where a finding's stated fix does not fit the code, +or where the charter's own details were short. None changes a decision; each changes how one +is implemented. + +1. **A command-line error in `tbdocs` cannot exit 2 (L1-4).** Fixed by C18, which gives a + command-line error one value outside the bitmask in both `tbdocs` and `check_links.mjs`, + whose own argument errors the review did not list. +2. **`builder/` cannot import `isOutputTree` (L2-1).** `serve.mjs` is in `builder/`, which + must not import `scripts/` (`render.mjs:383`), and `isOutputTree` is in + `scripts/lib/markdown-files.mjs`. C12 moves that module into the new top-level `lib/` + first. For the same reason `cli.mjs`, onto which decision (e) migrates `tbdocs`, and the + repository-root helper, whose nineteen callers (L2-5) include two in `builder/`, go in + `lib/` rather than `scripts/lib/`. `lib/` holds modules that any part of the tree may + import, and it imports none of them. +3. **`withBrowser` goes in `scripts/lib/browser.mjs`, not `axe-scan.mjs` (L3-1).** The two + diagram tools need the same browser lifecycle (A5-5) and have no other reason to load the + accessibility module. +4. **The tree comparison normalises three regions, not two.** Folded into the Oracles table + above, which now carries the detail. +5. **The pinning policy has to cover the linter (decision (g)).** C01 states the policy to + include the case a review-worded pin would miss: exact also where a new version would + change a gate's verdict on unchanged code, which is why Biome is pinned exact though it + patches nothing. +6. **A dispatch of the deploy workflow is not a test.** It cuts a GitHub release; folded into + the Oracles table's CI row. +7. **The command-line defects are fixed before `cli.mjs` exists (L1-2, L1-3, A7-5).** The + review's fix for each is the shared module, but decision (e) makes Phase 2 change no + behaviour, so C17 fixes them in place first. L3-3 likewise gets a loud failure in Phase 1 + (C26), ahead of the fence-aware split (C36). +8. **`check_tb_registry.mjs`'s missing crash handler changes an exit code** (a crash goes + from 1 to 2; L1-11), so it is fixed with A5-2's two tools in Phase 1 (C28), not in the + helper commit that must change nothing (C43). +9. **L1-10 closes as a side effect.** `node:util` `parseArgs` accepts `--name=value` for + every option and cannot be told not to, so this is the one behaviour change Phase 2's + migrations make, and it only adds a form. +10. **Three of the review's facts were wrong**, found by proofreading this plan against the + source (A8-2, fixed in C10; A2-1, fixed in C14; L4-10, whose corrected figure, 119 probes, + is stated in C61). + +## Phase 0: process and oracles + +Before any finding is fixed. + +### C01 — `docs: state the dependency pinning policy, and correct Builder.md's list` + +**Carried forward.** The pinning policy (decision (g)): exact where the code patches the +dependency or relies on its internals, or where a new version would change a gate's verdict on +unchanged code (the reason `@biomejs/biome`, C05, is pinned exact though it patches nothing); +caret otherwise. `docs/Documentation/Builder.md`'s Dependencies section is the corrected, +authoritative list, and a commit that changes `package.json` updates it in the same commit (see +The bar for each commit). + +### C02 — `scripts: compare_trees.mjs, the built trees before and after a change` + +Landed. + +### C03 — `scripts: check_ci_workflows.mjs, the workflows against the wrappers' gates` + +Landed. + +### C04 — `ci: one composite action for the gates both workflows run` + +Landed. + +### C05 — `lint: Biome, correctness rules only, and the fixes it finds` + +**Carried forward.** The linter is Biome, pinned exact (2.5.14); ESLint was not needed, since +no finding required separate treatment for Node modules, the two browser scripts in +`docs/assets/js/`, or `page.evaluate` callback bodies. Scope: `builder/`, `scripts/`, `book/`, +`eval/`, `wisdom/`, `test/`, `docs/assets/js/`, and `lib/` since C12, excluding `perf/`, the vendored code, the +generated JSON (the two baselines, `package-api.json`, `inter-metrics.json`), +`package-lock.json`, and every Markdown, SCSS, YAML and `.bat` file. The formatter stays off +until Phase 6 (decision 4). + +### C06 — `scripts: check_lint.mjs, a lint gate in test.bat and CI` + +Landed. + +### C07 — `scripts: convert_em_dash_separators exits 2 on a crash` + +**Carried forward.** A crash handler is installed by a call inside a module's +entry-point guard (`process.argv[1]` against `import.meta.url`), never as a side effect of +import: the module is written to stay importable, and a process-wide handler installed on +import would change how the importing process ends on a crash. C43's shared helper keeps that +property. + +### C08 — `githooks: a pre-commit hook that runs Biome on the staged files` + +**Carried forward.** The pre-commit hook (`.githooks/pre-commit`) runs `node +scripts/check_lint.mjs --staged` and nothing else, on the owner's decision; enabled per clone +with `git config core.hooksPath .githooks`. `--staged` lints only the files `git diff --cached +--diff-filter=ACMR` reports, with `--no-errors-on-unmatched`, so staging nothing in the lint +scope is clean; the whole-scope run keeps its own floor of one script. + +## Phase 1: remove, relocate, and fix in place + +Done ahead of this phase, during the review: the four superseded pdf-lib shims deleted +(`90624841`), `perf/detach-pages.js` marked as code the book build loads (`26eefeeb`), and the +`staging.md` content slip fixed (`e0d127f0`, decision (f)). + +### C09 — `deps: declare picocolors and pako, which the code imports directly` + +Landed. + +### C10 — `scripts: move census_attributes.mjs out of builder/` + +**Carried forward.** `census_attributes.mjs` moved from `builder/` to `scripts/`, beside +`build_package_api.mjs` (C38 relies on both readers now living in `scripts/`). + +### C11 — `scripts: census_attributes finds the install through tb-install` + +Landed. + +### C12 — `lib: move markdown-files.mjs to a top-level lib/` + +**Carried forward.** `markdown-files.mjs` (`markdownFiles`, `isOutputTree`) moved from +`scripts/lib/` to the new top-level `lib/`, which any part of the tree may import and which +imports none of them (see departure 2). `biome.jsonc` enforces the second half of that rule on +`lib/**/*.mjs` with a `noRestrictedImports` override refusing `builder/`, `scripts/`, `book/`, +`eval/`, `wisdom/` and `test/`. + +### C13 — `builder, eval: decide what is an output tree with isOutputTree` + +Landed. + +### C13a — `builder: refuse a --dest that overlaps the source tree` + +Landed. + +### C14 — `builder: delete what the retired diff tools left behind` + +Landed. + +### C15 — `builder, wisdom: delete precomputeSeo and schemas.mjs; unexport kramdownSlug` + +Landed. + +### C16 — `scripts: tbrun recognises all five failed-build shapes` + +**Carried forward.** `tb-ide.mjs` exports `BUILD_FAILED`, the pattern matching all five +failed-build log shapes; `tbrun.mjs` reports a build failure by matching against it. C25a's +saved-segment check looks for a `BUILD_FAILED` line the same way. + +### C17 — `scripts: harness CLIs reject a missing value; tbbuild finds its project` + +**Carried forward.** In `tbbuild`, `check_examples`, `census_attributes` and +`build_package_api` (and in `tbdocs`, under C18), a value flag given no value, whether at the end of +the command line or followed immediately by another flag, is a usage error. This matches +Node's strict `parseArgs` (confirmed on Node 24.13, which also accepts a lone `-` as a value), +so `lib/cli.mjs` (C47) needs no behaviour change here when C49 migrates these tools onto it. +Ports and counts must be whole numbers; a timeout may be any positive number. The check runs +before `--help` is handled. + +### C18 — `builder, scripts: a command-line error exits outside the link bitmask` + +**Carried forward.** A command-line error in `tbdocs` and in `check_links.mjs` exits 4, a +value outside the existing 1 (link failure) / 2 (integrity failure) / 3 (both) bitmask. This +covers a value flag given no value or followed by another flag, an out-of-range `--port`, and +(from C13a) a `--dest` that overlaps the source tree. C47, C49, C52 and C60 build on this +value; Phase 3 (C71, C72) treats these two tools as the exception to "an unknown flag or a bad +value exits 2." + +### C19 — `scripts: close the browser on every exit path, through lib/browser.mjs` + +**L3-1 (R1).** `check_a11y.mjs` launches Chromium (`:167`) and closes it only on success +(`:218`), and `main().catch` exits 2 without closing it (`:244-247`). The comment at `:229` +names the path that throws: a `PAGE_STATES` applier's failed assertion, which throws by +design. On Linux, where CI runs this gate, Chromium stays up for the rest of the job. The +three sibling tools guard their launch. + +**Change.** `scripts/lib/browser.mjs` takes `axe-scan.mjs`'s `launchBrowser` and +`LAUNCH_ARGS` (`:258-264`), with their reasons, and adds `withBrowser(fn, options)`, which +closes the browser in a `finally`. `axe-scan.mjs` re-exports `launchBrowser`, so its +importers need not change. `check_a11y.mjs`, `check_a11y_fingerprint.mjs`, `sweep_a11y.mjs` +and `check_axe_patch_equiv.mjs` use `withBrowser`. C44 moves the two diagram tools onto the +same module. + +**Verify.** With an applier made to fail, a Chromium started by the run is left over before +the change and none after. Count by the Puppeteer cache path in each process's command +line, never by image name, since the owner's own Chrome has the same one. `check_a11y.mjs`'s +findings unchanged; `check_axe_patch_equiv.mjs` passes. + +**Landed.** The premise was wrong; the change stands, restated (see [Where the plan was +wrong](#where-the-plan-was-wrong)). At HEAD, with `"details.section-links"` changed to +`"details.section-links-x"` in the first `PAGE_STATES` applier, `check_a11y.mjs` exits 2 after +8 s with the applier's error and leaves no Chromium running. `@puppeteer/browsers` subscribes +to Node's `exit` event for every launch (`lib/launch.js:178`) and kills the browser there +synchronously (`:232`): `taskkill /pid /T /F` on Windows (`:268`), a `SIGKILL` of the +browser's detached process group elsewhere (`:151`, `:283`). Linux was read, not measured. +What the failed run left was the browser's temporary profile, one +`%TEMP%\puppeteer_dev_chrome_profile-*` folder of 4.3 MB. Puppeteer deletes it only when the +browser process's own `exit` event arrives (`puppeteer-core`'s `BrowserLauncher.js:64`, `:82`), +and `process.exit` ends Node before that. A clean run left none. On 2026-09-26 the owner kept +C19 as planned and had the 211 such folders then in `%TEMP%` (389 MB, dated February to +September 2026) deleted. + +`scripts/lib/browser.mjs` holds `launchBrowser`, `LAUNCH_ARGS` and `withBrowser(fn, +options)`. `LAUNCH_ARGS` is no longer exported, since nothing imported it. `axe-scan.mjs` +re-exports `launchBrowser` for the four `perf/` rigs that import it, and drops its `puppeteer` +import; `builder/link-check.mjs`'s comment, which said `axe-scan.mjs` owns puppeteer, now says +it loads puppeteer through `browser.mjs`. In `check_a11y.mjs`, `buildMatrix` now runs before +the launch instead of after it. +`sweep_a11y.mjs`'s header said `--recycle-every` restarts the browser; the code opens a fresh +tab, as its own comment says, and the header now says so too. + +After the change the same failing run exits 2 with the same error and leaves no folder, and +no Chromium. `check.bat`'s a11y line is unchanged: `13 pages x 2 theme(s) x 2 viewport(s) + 8 +state audit(s) checked: 0 violation(s), 42 incomplete check(s)`. `check_a11y_fingerprint.mjs`'s +self-test reports `60/60 audits identical -- gate PASSES` before and after. `sweep_a11y.mjs +--limit 4 --recycle-every 2 --theme light --viewport desktop` writes four records that match +HEAD's apart from `runMs`. `check_axe_patch_equiv.mjs` reports `20/20 colour values +identical`. + +### C20 — `a11y: validate --theme and --viewport wherever a matrix is built` + +**L1-1 (R1).** `check_a11y.mjs:82-95`'s `pick()` was written after `--theme drak` labelled a +light run "drak"; `sweep_a11y.mjs:120-121` and `check_a11y_fingerprint.mjs:134-135`, the gate +for an axe upgrade, never received it. `buildMatrix` labels the report from the unvalidated +string and `gotoPage` applies it; the dark CSS matches only `[data-theme=dark]`, so an +unknown value renders light under a report that says otherwise. + +**Change.** `pick()` moves into `axe-scan.mjs`, beside `THEMES` and `VIEWPORTS`, and all three +tools use it. `buildMatrix` also refuses an unknown value, as the backstop for a future +caller. + +**Verify.** `--theme drak` and `--viewport tiny` fail with a usage error in all three tools; +`check_a11y.mjs`'s findings unchanged. + +**Landed.** Reproduced first, after C19. `sweep_a11y.mjs --theme drak --viewport desktop +--limit 1` exited 0 and recorded `/404.html [drak, desktop]`. `--theme light --viewport tiny` +also exited 0, recording `[light, tiny]`: `setViewport(undefined)` does not throw, so the audit +ran at a size nobody chose. `check_a11y_fingerprint.mjs --pages /404.html` reported `1/1 +audits identical -- gate PASSES` with either value. + +`pick()` moved from `check_a11y.mjs` into `axe-scan.mjs`, exported, beside `THEMES`, and its +comment gained the viewport case. All three tools call it at module level, so the usage error +comes before any browser starts. `buildMatrix` throws for a theme not in `THEMES` or a +viewport that is not an own key of `VIEWPORTS` (`Object.hasOwn`, so `toString` is refused +too). `sweep_a11y.mjs` builds its own matrix, so the backstop covers the other two. + +The six cases now exit 2 with one line each, `unknown --theme "drak"; expected one of light, +dark or both` or `unknown --viewport "tiny"; expected one of desktop, mobile or both`. A +scratch probe of `buildMatrix` got 60 entries by default and 30 for one theme or one +viewport, and a throw for `drak`, `tiny` and `toString`. Valid single values still run: +`sweep_a11y.mjs --theme dark --viewport mobile --limit 1` recorded `/404.html [dark, mobile]`, +and the fingerprint self-test with the same values passed. `check.bat`'s a11y line is +unchanged. + +### C21 — `builder: the Gantt chart draws Check, vendorAssets and Other` + +**A1-1 (R1).** `gantt.mjs:39-49` draws only `Seeds`, `Spine` (which also takes `Render`) +and `Write`, so +`checkBook` and `checkReport` (section `Check`) and `vendorAssets` (no `GANTT_SECTION` entry; +`tbdocs.mjs:539-562`) are drawn on no build, while their durations still stretch the time +axis. `COLORS.Other` (`:12`) is never used, and `Builder.md:410` says a task with no section +falls into an "Other" bucket. + +**Change.** `mainSections` gains `Check` and `Other`, and `vendorAssets` gets a +`GANTT_SECTION` entry. + +**Verify.** The built `gantt.svg` names `checkBook`, `checkReport` and `vendorAssets` after +the change and not before. The tree comparison identical apart from its normalised Gantt +regions. `Builder.md:410` re-read against the result. + +**Landed.** Before, both built `gantt.svg` files (at 0ba42460) named none of the three tasks +and had no Check band. After, each names all three once and has a Check band, and so do both +copies inlined into `BuildInfo.html`. `vendorAssets` charts in Spine: it runs on the main +thread after `discover`, and `markdownInit` waits for it. Check's colour is a pink (`#e59ac6` +light, `#b35c8c` dark); its label contrast is in the range of the other bars', and both themes +were looked at through puppeteer. + +**At the owner's request, a task the chart cannot draw fails the build**, so `Other` was not +added: nothing can reach it, and `COLORS.Other` and its `.gb-other` rules are gone. +`groupGanttTimings` throws for a task with no section, and `renderGantt` for a main-thread +task whose section has no band or a worker task whose section has no colour. With each defect put back +by a scratch edit, the check fixture's build exited 1 with `gantt: task vendorAssets has no +section; add it to GANTT_SECTION`, and with `gantt: the chart has no place for task checkBook +in section "Check"`. Without `--check` the Check tasks are not charted, so the second fails +only a checked build, which `build.bat` and both CI workflows are. WIP.md's gate table gains +the check beside nav integrity; Tools.md lists no build-internal checks, so nothing is +registered there. + +Docs: Builder.md's `Other` sentence rewritten and `vendorAssets` moved from Seeds to Spine in +its section list; Extending.md's row on Gantt sections rewritten, its counts corrected (32 +static tasks, 30 in the map, none setting `ganttSection` on its definition, where it said 31, +28 and `dispatch`); Pipeline-Stages.md's `ganttSection` values gain `Check` and `renderGantt`'s +row says it throws; BuildInfo.md's alt text says five bands. `Builder.md:410`, re-read (now +`:409-413`): the `Other` sentence was the one the entry named; the paragraph's claim that +boot timings form a row group of their own, and more in the section lists above it, is wrong +and is recorded under Found while implementing. + +`compare_trees` replaces the chart whole, so it cannot see this change; the built chart is the +oracle. It exits 1 on the four edited pages, online and offline, the search data and +`book.html`, and BuildInfo.html's difference is its alt text. `check.bat`'s a11y line is +unchanged (0 violations, 42 incomplete), though BuildInfo.html is in the sample and the chart +gained four labels. `test.bat` and lint pass. + +### C22 — `scripts: crawl_check follows every link attribute the build checks` + +**A4-3 (R1).** `crawl_check.mjs:91-108`, the only checker that runs against the deployed +site, handles five tag and attribute pairs, where `link-check.mjs:38-60`'s `LINK_ATTR_TABLE` +has 21 tags and 26 pairs. It never follows `srcset`, `poster`, `cite`, `formaction`, +`action`, `data` or `longdesc`. + +**Change.** Export `LINK_ATTR_TABLE` and `splitSrcset` from `link-check.mjs`, and let the +table decide `crawl_check.mjs`'s tag handling; its HTTP concerns (concurrency, redirects, +HEAD then GET) stay its own. This is separate from decision 5, which covers the two +filesystem checkers only. + +**Verify.** Run it before and after against `serve.bat`'s local server if it takes a base +URL, and otherwise ask before crawling the live site. The after run requests the `srcset` and +`poster` targets, and reports nothing the before run did not, apart from any link in those +attributes that is actually broken. + +**Landed**, with one change of shape. Exporting the table and `splitSrcset` would have left a +second copy of the loop over them in `crawl_check.mjs`, including which attribute is a +srcset. So `link-check.mjs` exports one function instead, `forEachLink(name, attribs, fn)`, +which walks the table and splits a srcset; `extractFromHtml` and `crawl_check`'s tag handler +both call it, and the table and `splitSrcset` stay private. `crawl_check`'s id capture is +unchanged. Tools.md's paragraph says it follows every link the build's check follows. + +`extractFromHtml` gives the same results: a scratch script ran HEAD's copy and the edited one +over every `.html` file in the three built trees, the check fixture's trees and the fixture +below (2,414 files, 1,777,900 links), with every option on and with every option off, and no +field of any result differed. `check_links_diff.mjs --a script --b fused` found no +differences across 6 cases. + +The tool takes a start URL, so both runs used a scratch static server on `127.0.0.1` that +resolves a path as `serve.mjs` does and, as GitHub Pages does, redirects a folder URL without +its trailing slash to the slash form. Without the redirect, 626 links came back broken, all +from folder pages fetched without the slash; an agent confirmed the redirect on both live +sites (a 301 with an absolute `Location`), and found no GitHub documentation of it. Node's +server also closes an idle keep-alive socket after 5 s, which failed 30 fetches until the +scratch server kept its sockets longer. + +Against the built site, with `--skip-external`, before and after: 1,247 pages crawled, 3,228 +unique links, 0 broken, 0 missing anchors, the two reports identical apart from the elapsed +time. The site uses none of the newly followed attributes: nothing in `_site` has a `srcset`, +`poster`, `cite`, `action`, `data` or `longdesc`. So the fixture carried the test: one page +with a missing target for each of the table's 26 pairs, 28 URLs in all, since both srcsets +list two. Before, 5 broken (`a`, `link`, `img src`, `script`, `iframe`); after, all 28, the +srcset and poster targets included. `compare_trees`: Tools.html online and offline, the search +data and `book.html`, nothing else. Two defects found on the way are recorded under Found +while implementing. + +### C22a — `scripts: crawl_check sets its exit code instead of calling process.exit` + +**Found while verifying C22; the owner asked on 2026-09-26 for it to be fixed before C23** +(see Found while implementing). `crawl_check.mjs` ended `main()` with `process.exit()` straight +after printing its report, and its crash handler called `process.exit(2)`. On Windows (Node +24.13.0) that can abort on a libuv assertion, `!(handle->flags & UV_HANDLE_CLOSING)` in +`src\win\async.c:76`: the report is complete, and the exit code is 0xC0000409 (Git Bash shows +127) instead of 1. + +**Change.** `main()` sets `process.exitCode` and returns, and the crash handler sets it to 2. +The two usage exits stay, since they run before any fetch. The header and Tools.md's +paragraph give all three exit codes: a missing anchor exits 1 as well. + +**Landed**, with one addition. With `process.exit` gone, a crawl of the site printed its +report at 66 s and never exited: idle, its CPU time flat, 90 connections to the server still +established, until it was stopped 269 s later. `crawlOne` GETs every same-site URL but read +the body only of an HTML page answered 2xx, and an unread body keeps its connection busy; +`process.exit` had been cutting that short. `discardBody` now cancels every body that is not +read, in `crawlOne` and after `checkUrl`'s HEAD and GET. + +The assertion does not need unread bodies. On scratch probes against the fixture server, 28 +concurrent fetches followed by `process.exit(1)` aborted 10 times of 10 with or without +cancelling the bodies, and one fetch, read or unread, exited 1 all 10 times. What else it +needs is not established: HEAD's crawl of the site reaches `process.exit` with those 90 +connections open, and did not abort in C22's four runs. + +Against the C22 fixture, through the kit's static server with `--skip-external`: before, 5 +runs of 5 aborted after reporting 28 broken; after, 10 of 10 exit 1 with 28 broken and no +assertion, the process ending 25–30 ms after its report. Against the site, three runs after: +exit 0, 1,247 pages crawled, 3,228 unique links, 1,247 status checks, 0 broken, 0 missing +anchors, as in C22's runs, in 66 to 79 s, ending 26–43 ms after the report. A crash +mid-crawl, injected by a preload that makes `Response#url` throw from its 200th read so that +`crawlOne` throws outside its `try` blocks with other fetches in flight: HEAD's copy aborted +5 of 5 with 0xC0000409, and after, 5 of 5 exit 2, about 40 ms after the error. An unknown flag +and a missing URL exit 2, as before. `compare_trees`: Tools.html online and offline, the search +data and `book.html`, nothing else. + +### C22b — `builder: serve.bat redirects a folder URL to its trailing slash` + +**Found while verifying C22; the owner asked on 2026-09-26 for it to be fixed before C23** +(see Found while implementing). `serve.mjs`'s static handler answered a folder URL without its +trailing slash with the folder's `index.html`, where GitHub Pages answers 301 to the slash +form. A browser then resolves the page's relative links against the parent folder, one level +too high. The built site links 74 folder pages without the slash, e.g. +`../../tB/Modules/Collection` from Permanent-Links, so a `serve.bat` preview reached through +one of those links shows a page whose links are broken. + +**Change.** When the only file that matches is the folder's `index.html` and the URL path +lacks its slash, answer 301 to the path plus `/`, keeping any query string. + +**Landed.** `resolveFile` returns `{ file }` or `{ redirect }`. The `Location` is built from +the folder under the destination rather than from the request, percent-encoded segment by +segment, so a request for `//tB/Packages` redirects to `/tB/Packages/` and not to a host named +`tB`. The redirect carries the page's `no-store` cache header, so a browser does not keep it +after a folder page becomes a single-file one. A URL that names a page is served as before: +`/tB/Core/Dim` from `Dim.html`. No page under `docs/Documentation/` describes how the serve +resolves a URL. + +Verified on a test serve (port 4393, `--dest docs/_serve-c22b`, through a temporary +`.claude/launch.json` entry; both removed afterwards). Before, `/tB/Packages`, +`/tB/Modules/Interaction` and `/tB/Packages/CEF` answered 200. After, each answers 301 to its +slash form and the slash forms 200; `/tB/Packages?x=1&y=2` redirects to +`/tB/Packages/?x=1&y=2`, and a temporary folder named `Ä b` to `/%C3%84%20b/`. +`crawl_check --skip-external` against HEAD's serve: 1,850 pages crawled, 3,831 unique links, +623 broken, of which 603 were HTTP errors, such as a 404 for `/Core/Attributes`, and 20 were +`fetch failed`, and 2 missing anchors. After: 1,247 pages crawled, 3,228 unique links and 0 +missing anchors, as in C22's crawl of `_site`, and 20 broken, every one a `fetch failed` +caused by `read ECONNRESET`, and none an HTTP error. The resets are a separate defect, +recorded under Found while implementing: with the serve's keep-alive timeout raised as a +scratch experiment, two crawls of two had none. `compare_trees`: identical. + +### C22c — `docs: Builder.md's task sections match the chart and the task graph` + +**Found while re-reading Builder.md's Gantt paragraph for C21; the owner asked on 2026-09-26 +for it to be fixed before C23** (see Found while implementing). "Task DAG by section" says the +chart's five sections organise its discussion, and disagreed with the chart and with `TASKS`: +the lists put `discover` in Seeds and `warmInit` and `renderEnvInit` in Seeds and Render; the +Seeds discussion said a seed has no predecessors and listed `scss`, `prepDest` and +`prepPageDirs`, which have; the Spine sketch drew `loadData → highlighterInit` off `discover`; +and the Gantt paragraph put the start-up bars in a row group of their own, and a lane's bars +in completion order. + +**Change.** The five lists follow `GANTT_SECTION`, and a sentence says `warmInit` and +`renderEnvInit` are in none of them. Each task's bullet and its row in What runs where move +under its chart section; the two per-lane start-up tasks are described under Render. The +Seeds introduction says which seeds wait for another task, the Spine sketch is redrawn from +`expected`, and the Gantt paragraph says where the bands and the start-up bars are drawn. + +**Landed**, with three additions. The Render sketch drew the `flush:i` column feeding +`renderJoin`; it is redrawn with each `render:i` feeding `renderJoin` and each `flush:i` +feeding `flushJoin`, as `dispatch` wires them. `vendorAssets`, in the Spine list, had neither +a bullet nor a row in What runs where, and gains both, from Pipeline-Stages.md's section. And +the table, which says it lists every task, had no row for the three Check tasks. Every edge +in the two sketches was checked against `expected` and `dispatch`'s dynamic edges (the Spine +sketch leaves out `deriveRedirects → dispatch`, which `markdownInit` implies), every section +against `GANTT_SECTION`, and the Gantt paragraph against `gantt.mjs` (the four bands, a lane's +bars sorted by `workerStart`, the `cold` / `warm` / `env` labels) and `tbdocs.mjs` (no +cold-start bars on a rebuild; the `Join` tasks skipped). A scratch script confirmed the +sketches' vertical connectors line up. `scheduler-dag.dot` already draws `config → +highlighterInit → loadData`. Extending.md's account of the four surfaces still holds. +Pipeline-Stages.md files the same tasks by stage rather than by chart section, which is +recorded under Found while implementing. `compare_trees`: Builder.html online and offline, the +search data and `book.html`, nothing else. + +### C22d — `scripts: crawl_check retries a request that fails before any response` + +**Found while verifying C22b; the owner asked on 2026-09-26 for it to be fixed before C23, +with two retries** (see Found while implementing). A crawl of `serve.bat` reported about 20 +links broken with `fetch failed`, each caused by `read ECONNRESET`: the serve closes an idle +keep-alive connection after Node's default 5 s, `fetch` reuses one just as it closes, and +`crawl_check` reported the first failure as the link's. + +**Change.** `fetchWithTimeout`, which every request goes through (`crawlOne`'s GET, +`checkUrl`'s HEAD and its GET after a 405 or 501), becomes `fetchWithRetry`: when `fetch` +rejects, whether reset or timed out, it tries twice more, each attempt with the full +`--timeout`, and throws the last error. An HTTP error status is a response and is not +retried, and neither is a failure while reading a body. The header and Tools.md's paragraph +say so. + +**Landed.** A test serve (port 4393, `--dest docs/_serve-c22d`, through a temporary +`.claude/launch.json` entry; both removed afterwards), crawled with `--skip-external` through +the kit's `crawl-tally.mjs`: before, exit 1 with 10 broken, every one a `fetch failed` from +`read ECONNRESET` (the twelfth session's crawls had 20, 21 and 20); after, two crawls, each +exit 0, 1,247 pages crawled, 3,228 unique links, 0 broken and 0 missing anchors, while the +preload logged 20 failed attempts in each, all `read ECONNRESET`. A scratch server, the kit's +`c22d-retry.mjs`, resets the first two requests to `/r2` and `/h2` and the first three to +`/r3` and `/h3`, and never answers `/slow`; the crawl runs with `--timeout 1000`, and the `r` +paths are same-origin GETs, the others cross-origin HEADs. HEAD's copy requested each path +once and reported all five. After, each path was requested three times: `/r2` and `/h2` +succeeded on the third, and `/r3`, `/h3` and `/slow` were reported, the last as `timeout`. The +C22 fixture through the kit's static server: three runs of three exit 1 with 28 broken, as +before. A host that never answers now costs three timeouts, 45 s at the default, before its +link is reported. `compare_trees`: Tools.html online and offline, the search data and +`book.html`, nothing else. + +### C22e — `docs: Pipeline-Stages.md's task sections match the chart and the task graph` + +**Found while fixing Builder.md's copy of the same lists in C22c; the owner asked on +2026-09-26 for it to be fixed before C23** (see Found while implementing). Extending.md says +each task's `###` heading on the page sits under the numbered section matching its Gantt +section, and eight did not: Section 1 (Seed tasks) held `scss`, `dot`, `prepDest` and +`prepPageDirs`, Section 2 (Spine) `loadData` and `dispatch`, and Section 3 (Render fan-out) +`flush:i` and `flushJoin`. Section 1 also held `warmInit`, which the chart draws as a start-up +bar in each worker's row, and its introduction said its tasks have no predecessors. + +**Change.** Each `###` section moves under its chart section, in Builder.md's order, and +`warmInit` joins `renderEnvInit` under Render, as in Builder.md; the four introductions say +what each section now holds. No heading's text changes, so every anchor keeps its id. +Extending.md's rule gains the case the chart gives no section: a per-lane start-up task goes +under Render. + +**Landed**, with three additions. `markdownInit`'s `expected` line lacked `deriveRedirects`, +which it waits for only to count the redirect stubs for the `{{tbdocs:...}}` counts +(`tbdocs.mjs:598-599`), and its prose did not mention the counts; both are corrected, and so +is `deriveRedirects`'s list of consumers. `renderJoin` unblocks `symbolIndex` as well as +`searchData` and `writePdf`, and `flushJoin` unblocks `linkJoin` as well as `writeAux` and +`writePdf`. And `highlighterInit` gains the `expected` block that every other task with a +predecessor has. A scratch script moved the sections and would not write unless the region's +non-blank lines came out the same multiset; `git diff --color-moved` shows no other line +changed. The kit's `c22e-verify.mjs` checks the page against `tbdocs.mjs`: each block's +section against `GANTT_SECTION`, with `render:i` in Render and `flush:i` in Write as +`dispatch.submit` gives them and the two start-up tasks in Render; each `expected` line +against `TASKS`; and a block for every static task. On HEAD's page it reports 10 problems, +nine misplaced blocks and `markdownInit`'s line; after, none. `build.bat`'s link check passes, +so no link into the page lost its anchor. `compare_trees`: Extending.html and +Pipeline-Stages.html online and offline, the search data and `book.html`, nothing else. Two +defects found on the way are recorded under Found while implementing. + +### C22f — `docs: export tables for counts.mjs and page-baseline.mjs` + +**Found while fixing Pipeline-Stages.md's task sections in C22e; the owner asked on +2026-09-26 for it to be fixed before C23** (see Found while implementing). The page says its +second half covers every module, with the full export table for each, and had none for +`builder/counts.mjs` or `builder/page-baseline.mjs`. + +**Change.** A `counts.mjs` section after `render.mjs`'s, since `countPlugin` is the last +plugin `createMarkdownIt` applies, and a `page-baseline.mjs` section after +`symbol-baseline.mjs`'s, the other drift guard: every export, in the order the module declares +them, in the neighbouring tables' form. + +**Landed.** A Sonnet agent drafted both tables from the modules, and every row was checked +against the source; five were corrected. `validateCountNames` returns a message per unknown +reference, not per name, and offers the nearest known name only within an edit distance of +three (`counts.mjs:251-258`). `countPlugin` runs after `replacements` because its core rule is +pushed last, not because it is the last plugin. `checkPageBaseline`'s row is rewritten so that +each case reads on its own (`page-baseline.mjs:120-175`). `GUARDED_SRC` is passed in the two +scripts' probes rather than building fixtures. And `deriveCounts`'s folder-style indexes are +reference pages, not only classes. Nothing imports `COUNT_NAMES`, which is recorded under +Found while implementing. `compare_trees`: Pipeline-Stages.html online and offline, the search +data and `book.html`, nothing else. + +### C22g — `builder: tbdocs.mjs's task-graph comment points to TASKS and the docs` + +**Found while checking C22e's edges; the owner asked on 2026-09-26 for it to be fixed before +C23, with a pointer rather than a correction** (see Found while implementing). The comment +above `TASKS` described the graph in prose, and contradicted `TASKS` in three places. + +**Change.** The prose goes. The comment says that `TASKS`, with the `render:i` and `flush:i` +tasks `dispatch.submit` adds, is the graph, and names Pipeline-Stages.md and +`scheduler-dag.dot` as its descriptions; the sentence on `runBuild()` stays. No page cites the +comment. + +**Landed.** A comment-only change: `compare_trees` identical, lint clean. + +### C22h — `builder: delete counts.mjs's unused COUNT_NAMES` + +**Found while checking C22f's table; the owner asked on 2026-09-26 for it to be deleted +before C23** (see Found while implementing). `COUNT_NAMES` called `deriveCounts` with an empty +state at module load, and nothing read it: `validateCountNames` checks a page against the +keys of the counts it is given, and `countPlugin` substitutes from the same object. + +**Change.** The export goes, with its row in Pipeline-Stages.md's table and the clause of the +`deriveCounts` row that named it. It was the only call that omitted `extra`, so `extra`'s +default and the `?? 0` behind `redirectStubs` go too, and the JSDoc and the table's signature +make `extra` required; `tbdocs.mjs:606`, the one caller left, always passes it. Extending.md +says instead that the returned object's keys are the names a page may use. + +**Landed.** `compare_trees`: Extending.html and Pipeline-Stages.html online and offline, the +search data and `book.html`, nothing else. Lint clean. + +### C22i — `scripts: crawl_check reports a page whose body cannot be read` + +**Found while implementing C22d; the owner asked on 2026-09-26 for it to be fixed before C23, +with no retry** (see Found while implementing). `crawlOne` recorded a same-site page as +reachable before reading its body, and returned in silence when the read failed: the page's +links were never extracted, its ids never indexed, and the report said nothing. + +**Change.** When the read fails, `crawlOne` records the page broken, with its status and the +error prefixed `body:`, and returns. It does not retry: the server has answered, and C22d's +retries already cover the common reset, which comes before any response. The part of the body +that arrived is not parsed. The header and Tools.md's paragraph say so. + +**Landed.** The kit's `c22i-body.mjs` serves `/`, linking `/cut` and `/ok`; `/cut` answers +200 `text/html` with a `Content-Length` of 5000, sends a link to `/missing` and closes the +socket 50 ms later. HEAD's copy: exit 0, 3 pages crawled, 2 unique links, 3 status checks, 0 +broken and 0 missing anchors. After: exit 1, the same counts with 1 broken, `[ERR body: +terminated] http://localhost:4395/cut`, where `terminated` is undici's message. Both requested +each of `/`, `/cut` and `/ok` once and `/missing` never. The kit's `c22d-retry.mjs` gives +C22d's result unchanged: each failing path requested three times, `/r2` and `/h2` recovering, +and `/r3`, `/h3` and `/slow` reported. The C22 fixture through the kit's static server: three +runs of three exit 1 with 28 broken. `compare_trees`: Tools.html online and offline, the +search data and `book.html`, nothing else. Lint clean. + +### C22j — `scripts: crawl_check's --timeout covers a page's body` + +**Found while implementing C22i; the owner asked on 2026-09-26 for it to be fixed before +C23** (see Found while implementing). `fetchWithRetry` cleared its timer once `fetch` +resolved, which is when the headers arrive, so `--timeout` did not bound the read of a page's +body, and a body that stalled after its headers held the crawl until undici gave up. + +**Change.** Each attempt passes `AbortSignal.timeout(timeoutMs)` as its signal, so the timeout +runs on through the body. Such a signal fails with a `TimeoutError`, not an `AbortError`, so +the three places that record an error take its text from one function, `errorText`: +`timeout` for a timeout, the error's message otherwise. A body still arriving when the time +runs out is reported `body: timeout`, without a retry, as C22i decided for a body that breaks +off. The header, the retry comment and Tools.md's paragraph say so. + +**Landed.** The kit's `c22i-stall.mjs` serves `/`, linking `/stall` and `/ok`; `/stall` +answers 200 `text/html` with a `Content-Length` of 5000, sends a link to `/missing`, then +sends nothing and keeps the socket open. With `--timeout 1000`, C22i's commit exited 1 after +305.6 s, reporting `[ERR body: terminated]` for `/stall`; after, it exits 1 after 1.2 s, +reporting `[ERR body: timeout]`. Both requested each of `/`, `/stall` and `/ok` once and +`/missing` never. `c22i-body.mjs` still reports `body: terminated` for `/cut`; +`c22d-retry.mjs` gives C22d's result unchanged, `/slow` reported as `timeout`; and the C22 +fixture gives three runs of three exit 1 with 28 broken. A crawl of the built site through +the kit's static server: exit 0, 1,247 pages crawled, 3,228 unique links, 0 broken and 0 +missing anchors in 88.8 s, so the default 15 s, which now covers each body, stopped no page. +`compare_trees`: Tools.html online and offline, the search data and `book.html`, nothing +else. Lint clean. + +### C23 — `scripts: check_examples restores the registry after a spawn failure` + +**L3-2 (R2)**, with V4's note that `check_examples.mjs` has no process-level handler at all. +`buildStaged`'s spawn (`:601-612`) has no `'error'` listener and waits for `'exit'`. A spawn +failure is then an uncaught exception at the emitter, outside both `main()`'s catch and +`main().catch`, so the step that restores the tbIDE registry never runs. + +**Change.** Listen for `'error'` and wait for `'close'`, as `addin_test.mjs:180,185` and +`check_regex_safety.mjs:347-348` do. An `uncaughtException` and `unhandledRejection` handler +restores the registry and exits 2. + +**Verify.** With the spawn pointed at a missing executable (a scratch edit), the run exits 2 +and the registry is as it was found. The `examples.bat` summary unchanged (a harness run). + +**Landed.** Waiting for `'close'` also means `out` holds all of tbbuild's output when its JSON +is parsed, which `'exit'` did not promise. The handler is `die`, installed at the bottom +beside `main().catch`, whose body it takes over; the probes' `fakeLane` already has a +parameter named `crash`. A scratch edit pointed the spawn at `C:\no-such-folder\node.exe`, +run with `--jobs 1 --only "^Reference/Core/"` (186 samples from 87 pages in 12 projects). +HEAD: exit 1 after 3.6 s, on Node's own report of the uncaught `spawn +C:\no-such-folder\node.exe ENOENT`, which `examples.bat` reads as a sample that does not +compile, and nothing tidied. After: exit 2 after 3.4 s, `check_examples: spawn +C:\no-such-folder\node.exe ENOENT`, from `main()`'s catch around the lanes, which finishes +the tidy. A scratch throw from `process.nextTick` and a scratch rejection that nothing +awaits, each in `buildStaged` before the spawn, exit 2 through `die` with the error printed. +A read-only snapshot of the keys the tidy covers (`reg export` of the IDE's settings key and +the two association keys) came out identical around every run; in the failing runs no IDE +starts, so HEAD leaves the registry as found as well, and the full run is what shows the tidy +still puts it back. `examples.bat`: exit 0 after 122.2 s, `1129 sample(s), 1129 compile, 0 +finding(s), 120.2s -- clean`, from 597 pages in 43 projects on 4 lanes, the snapshot +identical before and after. A lane that fails while another builds was checked as well, +because the catch then tidies while the other lane's IDE still runs, which `finishTidy`'s +comment forbids: with lane 1 failing 6 s in on two lanes, the run exited 2 after 9.8 s, no +tbbuild or IDE process was left, and the snapshot was identical, because Node ends the +children it spawned when it exits, and they end theirs (`tb-ide.mjs:145-150`). +`compare_trees` identical. Lint clean. + +### C24 — `scripts: tb-operate stops on an afterReveal timeout` + +**A7-2 (R2).** `afterReveal` (`tb-operate.mjs:421-429`) returns `false` on a timeout, and +`openFile` (`:453`), `setCursor` (`:461`) and `select` (`:475`) discard the result. That +reopens the cursor-reset race the function exists to prevent: `:401-409` records the measured +`"xyz"` to `"zy"` corruption. + +**Change.** Each call site checks the result, retries once, then throws naming the file and +position. + +**Verify.** `addin-test.bat` green, all ten lanes (a harness run). + +**Landed**, without the retry: waiting again only makes the wait longer, which is what +`afterReveal`'s `timeout` is for, and opening the file again would start a new reveal (see +Where the plan was wrong). The three call sites share one unexported helper, `settledAt`, +which throws naming the file and the place; `setCursor` and `select` are not given the file +and read it from `editorState`. `afterReveal` still returns `false` on a timeout, for a +caller that waits after an add-in's `Editors.Open`; nothing in the tree calls it but the +three. The kit's `c24-reveal.mjs` drives the three against a fake connection whose reveal +window never closes. HEAD's copy returned from each after 10.1 s, and `setCursor` and +`select` then placed the cursor anyway; after, each throws after 10.1 s, as in `the IDE was +still revealing lines 10 s later, so the cursor could still move: +/AddinHost/Sources/Haystack.twin at 4:9`, and places nothing. With the window closed, each +returns at once and places the cursor as before. WIP.Harness.md's paragraph on the 700 ms +says so. `addin-test.bat`: exit 0 after 131.4 s, `10 of 10 lane(s) ran: 10 passed`, with +`registry: put back (20 project-state, 21 recent-list and 3 association writes)`. +`compare_trees` identical. Lint clean. + +### C25 — `scripts: tbbuild always tidies; correct tb-registry's -Command note` + +**A7-9, A7-6 (R3).** `tbbuild`'s shutdown skips its tidy step when the IDE handle was never +set, which is safe only by an invariant inside `tb-launch.ps1`; `tbrun` always tidies. And +`tb-registry.mjs:49-50` says `tbrun`'s snapshot uses `-EncodedCommand`, where `tbrun.mjs:376` +uses `-Command` with a fixed literal. + +**Change.** `tbbuild` tidies on every shutdown path, as `tbrun` does. The comment is +corrected; the code is safe as it stands. + +**Verify.** `tbbuild` on a probe, and `tbbuild` given a missing `--ide`, both leave the +registry as found (harness runs). + +**Landed.** `shutdown` runs without an IDE only after a `launchIde` that throws, as it does +when the DevTools port stays taken or a hidden launch prints no pid. It now returns early only +under `--keep`: `shutdownIde` does nothing without an IDE, as `tbrun` already relies on, and +`finishTidy` writes only where a value differs. The kit's `c25-reglog.mjs`, preloaded, logs +each registry request a run makes and what each write request changed, and `c25-run.mjs` +brackets a run with `reg-snap.mjs`'s read-only snapshots. With `--ide C:\nope\twinBASIC.exe`, +HEAD exited 2 after 1.4 s having made `startTidy`'s three reads and no other request; after, +it exited 2 after 2.3 s with `finishTidy` run as well: `restoreProjects` wrote 0 values, +`restoreKeys` changed 0, and the build targets were read and left alone. No IDE started, so +the snapshot was identical around both. `tbbuild` on a probe (the `console` template, +packed): exit 0 after 10.7 s, `--- 0 error(s), 0 warning(s), 0 hint(s), 0 info`, with 1 +project-state and 1 recent-list value put back, the snapshot identical before and after, and +no IDE or compiler process left. `tb-registry.mjs`'s comment names only `tb-launch.ps1` as +passed the same way. `compare_trees` identical. Lint clean. + +### C25a — `scripts: tbrun reports a codegen failure that Debug.Cls erased` + +**Found while verifying C16; the owner chose this fix on 2026-09-25** (see Found while +implementing). When a procedure the probe calls fails code generation, the compiler logs +`[LINKER] compilation (codegen) error` straight after `[BUILD] Executing +'..'...`. The probe's first statement, `Debug.Cls`, erases that line, +and the probe prints up to the call and stops. `tbrun` exits 0 with the partial output. + +**Change.** Before it presses Build, `tbrun` wraps the IDE page's global +`clearDebugConsole()`, so each call first saves the lines it is about to erase, read the +way `readConsole` reads them. In BETA 983's `ide/main.js` the compiler's clear event +(`event_clearDebugConsole`) and the Clear command both call that function by name, so the +wrapper sees every clear. After the run, a `BUILD_FAILED` line in a saved segment after the +last `[BUILD] Executing` line makes `tbrun` exit 2, naming that line and printing the +partial output. An IDE page without `clearDebugConsole` is refused, as one without +`dataNodes` is today. Probes keep `Debug.Cls`: nothing asked of a probe changes. `tbrun`'s +header, Tools.md's paragraph and WIP.Harness.md's bullet on failed builds name the case. + +**Verify.** Harness runs, one at a time: probe C (the shift in a procedure the probe calls), +before `exit 0` with `before` as its output, after `exit 2` naming the codegen line; probe A +(the shift in the `[RunAfterBuild]` Sub) still `exit 2`; a clean probe still `exit 0` with +its output; and a clean probe that calls `Debug.Cls` twice `exit 0`, since a saved segment +with no failure line in it is not a failure. + +### C25b — `scripts: tbbuild refuses a named IDE that is not there` + +**Found while implementing C25; the owner asked on 2026-09-26 for it to be fixed before +C25a**, as were C25c and C25d (see Found while implementing). Of the six tools that call +`findIde`, `tbbuild` alone launched a named IDE without checking that it exists. `tbrun`, +`addin_test` and `build_package_api` refuse one with exit 2, `census_attributes` looks for +its `packages` folder, and `check_examples` for the compiler beside it. A wrong `--ide` or +`TB_IDE` was found out only by the launch, which reported it in CLIXML (C25c) or, under +`--show`, crashed (C25d). + +**Change.** The check that refuses a missing IDE also refuses a named one that is not there, +before `startTidy`, naming the path: `no twinBASIC IDE at : pass --ide ...`, exit 2. + +**Landed.** With `--ide C:\nope\twinBASIC.exe`, and with `TB_IDE` naming the same path and no +`--ide`, `tbbuild` exits 2 after 0.1 s with that line and makes no registry request (the kit's +`c25-run.mjs`); at C25's commit it exited 2 after 2.3 s, with the launch's CLIXML, after a +full tidy. `tbbuild` on the probe, with the IDE found on the Desktop: exit 0 after 13.8 s, +`--- 0 error(s), 0 warning(s), 0 hint(s), 0 info`, the snapshot identical before and after. +`compare_trees` identical. Lint clean. + +### C25c — `scripts: tb-launch.ps1 reports why a launch failed, in plain text` + +**Found while implementing C25** (see Found while implementing). When a hidden launch failed, +`launchIde` relayed `tb-launch.ps1`'s stderr, which PowerShell writes in CLIXML when its +streams are redirected: the message read `#< CLIXML` and a line of XML, with the cause inside +an `` record. The cause was wrong as well. `Fail` read the Win32 error after +PowerShell had made calls of its own, which replace it: for `C:\nope\twinBASIC.exe` it +reported 203, "The system could not find the environment option that was entered", where a +C# read straight after the same `CreateProcess` gave 3. `tbbuild`, `tbrun` and the add-in +test lanes all launch through it. + +**Change.** Every Win32 call moves into a C# helper that throws, with the error read straight +after the call: `Desktop`, `KillOnCloseJob`, `Start`, `Assign`, which still ends the +suspended process when it cannot go into the job, and `Resume`. Progress is silenced, and a +`trap` writes a failure's innermost message as one line of UTF-8 on stderr and exits 1. +Setting the job's limit in C# drops the PowerShell workaround of copying the nested struct +out and back. WIP.Harness.md's paragraph on the launcher says why. + +**Landed.** The kit's `c25c-launch.mjs` runs a copy of the script as `tb-ide.mjs` does, +without an IDE. HEAD's wrote CLIXML to stderr in every case, a successful launch included, +for its progress record. After: `C:\nope\twinBASIC.exe` gives `CreateProcess failed: The +directory name is invalid`, because the working folder, the missing `C:\nope`, is checked +first; the install folder gives `Access is denied`, and `C:\Windows\twinBASIC.exe` `The system +cannot find the file specified`. Each is the only line on stderr, with exit 1. A short-lived +`node` child, with the job and without, prints its pid and nothing on stderr. A scratch +variant that passes `Assign` a null job gives `AssignProcessToJobObject failed: The handle is +invalid` and leaves no suspended child; another shows the UTF-8 line is needed, since without +it `éü` arrives as `��`. `tbbuild --ide` naming the install folder: exit 2 after 2.4 s with +that line, the registry as found. `tbbuild` on the probe: exit 0 after 10.7 s, clean. The +encoded script is 24,296 characters, against 20,872 before; a command line stops at 32,767. +`examples.bat`: exit 0, `1129 sample(s), 1129 compile, 0 finding(s), 124.1s -- clean`. +`addin-test.bat`: exit 0 after 130 s, `10 of 10 lane(s) ran: 10 passed`, `registry: put back +(20 project-state, 21 recent-list and 3 association writes)`. `compare_trees` identical. Lint +clean. + +### C25d — `scripts: launchIde under --show reports a spawn that fails` + +**Found while implementing C25** (see Found while implementing). `launchIde`'s `--show` branch +spawned the IDE with no `'error'` listener and returned at once. A spawn that fails is +reported by an `'error'` event, not a throw, so `launchIde` returned a handle with no pid, and +the event then ended the process on Node's report of an unhandled `'error'`, with exit 1, +which `tbbuild` and `tbrun` define as compile errors. Nothing after it ran, the tidy +included. + +**Change.** The branch waits for the child's `'spawn'` or `'error'` event before it returns, +and a failed spawn throws `could not start the IDE: `, which the callers' +catch reports with exit 2. The doc comment says a launch that fails throws, hidden or not. + +**Landed.** The kit's `c25d-show.mjs` calls `launchIde` with `show: true` and no IDE. HEAD's +copy returned `pid undefined` for `C:\nope\twinBASIC.exe` and for the install folder, and the +process then died on the unhandled `'error'` with exit 1; for a one-second `node` child it +returned a live pid. After: the first two throw `could not start the IDE: spawn +ENOENT`, since libuv reports a folder as not found too, and the child's live pid is returned +as before. `tbbuild --show` with the install folder as `--ide`: exit 2 after 2.3 s with that +line, after a tidy that wrote nothing, the registry as found; at C25's commit it exited 1 on +Node's report. No IDE was started on the desktop: the success path is the `node` child's. +`compare_trees` identical. Lint clean. + +### C26 — `wisdom: parseStaging refuses a chunk it cannot place` + +**L3-3 (R1)**, the half that needs no shared module. `parseStaging` (`merger.mjs:114-129`) +splits on any line equal to `---`, and `parseSection` returns `null` for a chunk that does +not start with `## ` (`:162-168`), which the caller drops (`:147-148,152-153`). A bare `---` +inside a fenced sample therefore drops the rest of its section and the section's +`finding_ids` line without a word, against the header's promise never to drop reviewer +content (`:111-112`). Today's file produces no such chunk. + +**Change.** A chunk that does not start with `## ` is an error naming its line. C36 then +stops a fenced `---` from making such a chunk; this check stays as the guard for any other +malformed one. + +**Verify.** A synthetic file with a `---` inside a fence: before, the tail disappears; after, +the run fails naming the line. The real `staging.md` parses to the same 1,160 sections and +serialises to the same bytes as before. + +### C27 — `wisdom: write manifest.json and denied.json atomically` + +**A10-5 (R2).** `saveManifest` (`wisdom/discord/messages.mjs:10-12`) and `wisdom.mjs:146` +write with a plain `writeFileSync`, and `loadManifest` parses without a guard, beside +`wisdom/extract/state.mjs:62-75`, which writes to a temp file and renames it. + +**Change.** Both writes use the temp-and-rename write `state.mjs` already has, shared within +`wisdom/`; a file that does not parse is reported by name. + +**Verify.** With the rename made to throw (a scratch edit), the previous file survives +intact; a truncated manifest gives an error that names it. + +### C28 — `scripts: exit 2 on a crash in three tools that exit 1` + +**A5-2 (R2), and the `check_tb_registry.mjs` half of L1-11.** The convention +(`Extending.md:640-648`) keeps 1 for a finding and 2 for a crash. `pick_a11y_sample.mjs`'s +`discover()` (`:159-169`) throws uncaught on a missing tree and exits 1, the same as a +coverage gap (`:310`), and it runs in `check.bat`. `build_dot_metrics.mjs` has no catch +around its browser work, so a crash exits 1, which is also its STALE result. +`check_tb_registry.mjs` exits 1 for a crash and for a failure alike. + +**Change.** The convention's crash handler, as `check_dot_fit.mjs:31-34` has it, in all +three, exiting 2. C43 folds the handlers into one helper. + +**Verify.** In each, a forced crash exits 2 and a real finding still exits 1. +`check_tb_registry.mjs`'s fixtures pass (a harness run). + +### C29 — `test.bat: cite check_gate_lists for the gate's history` + +**A6-2 (R1).** `test.bat:34-43` tells the gate's history in a way that neither +`check_gate_lists.mjs`'s header (about `:30-44`) nor `Tools.md:435-437` supports, and those +two agree with each other. + +**Change.** Trim the comment to a citation of the header, matching the file's other seven +comments. + +**Verify.** Re-read against both accounts; `check_gate_lists.mjs` and +`check_ci_workflows.mjs` pass. + +### C30 — `scripts: tidy check_links_diff's and check_publish_policy's failures` + +**L2-6, L3-6 (R3).** `check_publish_policy.mjs:152-189` and `check_links_diff.mjs:651-750` +remove their scratch folders only on success, where four other tools do it in a `finally`. +And `check_links_diff.mjs`'s `fusedBuild` (`:426,432`) and `ensureBasePathTree` +(`:518,525`) call `spawnSync` unguarded, so a failure reaches the terminal as a stack trace +at exit 1 rather than as the tool's `error:` line at 2. + +**Change.** A `finally` for both scratch folders; both spawns report through the tool's error +path. + +**Verify.** A forced failure in each leaves no scratch folder and prints the tool's `error:` +line with exit 2. `check_links_diff.mjs --self-test` and CI's fixture cases unchanged. + +## Phase 2: shared code, in place, with no change in behaviour + +Each commit's oracle must show no difference: the tree comparison for `builder/`, a gate's own +output and probes for a gate, the harness summaries for the harness, and from C47 +`check_cli.mjs`'s recorded cases for a command line. A difference is a regression unless the +entry names it as intended. + +The groups run in the order below. Within the markdown group the rewriters (C32–C36) come +before the scanners (C37–C41), as decision (a) requires. The command-line migrations end with +`tbdocs` (C52), as decision (e) requires. C55 comes before C56, which uses it, and C66 before +the shim refactors it checks. + +*The markdown module (decision (a)): C31–C41.* + +### C31 — `lib: markdown.mjs and frontmatter.mjs, with their probes` + +**Decision (a).** The module the inventory sketched (`markdown-inventory.md`, Task 3), built +on what it measured: block tokens have `.map` line ranges, blockquote and list prefixes +included; inline tokens have none; frontmatter must be split off before markdown-it sees a +page, which otherwise reads it as a rule and a setext heading; a block-only parse of the +whole corpus takes about 34 ms. + +**Change.** + +- `lib/markdown.mjs`: `blockRegions(src, {md})`, every fence, code block and HTML block with + its line range, from a block-only parse; `maskCode(src, {indented})`, the mask, rewrite and + restore helper, with markdown-it's CommonMark opener rules, the info-string rule that + `maskCodeRegions` lacks included; `splitCodeSpans(line)`, the one tested backtick and tilde + run scanner, since markdown-it gives inline spans no offsets; `splitOnMarker(src, + isMarker)`, sections split on a marker line outside any code region; and a line-splice + helper that keeps each line's ending, which `convert_em_dash_separators.mjs` and + `check_examples.mjs` both do by hand. +- The regions come from the caller's markdown-it instance when it passes one. `render.mjs` + passes the site's, because its plugins (the definition-list one among them) change what + counts as a block; other callers get a bare `html: true` instance. +- `lib/frontmatter.mjs`: `parseFrontmatter(raw)` strips a BOM, splits off the `---` block, + parses it with `js-yaml` 4, and leaves the content's bytes untouched. +- The probes ride along in `check_code_regions.mjs`, which becomes the gate on the module as + well as on the rewrites: A3-1's shape (a ```` ```abc`def ```` fence holding a + `> [!NOTE]`), fences inside blockquotes and list items, the 16 code-span cases V1 used for + L4-7, CRLF input, a BOM, a fenced `---`, and frontmatter that markdown-it would read as a + heading. + +**Verify.** The probes. Over every page under `docs/`, `blockRegions` finds the same 1,371 +fences as `check_code_regions.mjs`'s own pass over the tokens. + +### C32 — `render: the pre-render rewrites ask lib/markdown what is code` + +**A3-1 (R1), first half.** `maskCodeRegions` (`render.mjs:141-175`) accepts a backtick fence +whose info string holds a backtick, and `stashCodeFences` (`:1704-1730`) refuses it, as +CommonMark does. Reproduced: ```` ```abc`def ```` is protected by one and exposed to the +other, and a `> [!NOTE]` inside it becomes a live admonition. `check_code_regions.mjs` cannot +see this class of fault. + +**Change.** `applyPreRenderRewrites` masks through `maskCode` and `splitCodeSpans`, with the +site's instance and `indented: false` as today. `maskCodeRegions` and `maskInlineCode` go; +`counts.mjs`'s `findCountRefs`, which reuses the mask, follows. + +**Verify.** The tree comparison identical, since no page holds the shape. +`check_code_regions.mjs` clean, its mirror-fault probes included. + +### C33 — `render: admonitions find their fences through lib/markdown` + +**A3-1, second half.** `rewriteAdmonitions` protects fences with its own `stashCodeFences`. +It runs after the mask is restored, so it must recognise a fence inside an admonition with +its `> ` markers still on; `blockRegions`' ranges include those prefixes. + +**Change.** `stashCodeFences` goes, and `rewriteAdmonitions` skips the ranges `blockRegions` +reports. A3-1's reproduction becomes a probe in `check_code_regions.mjs` against the whole +pre-render chain. + +**Verify.** The tree comparison identical. `check_code_regions.mjs`'s `ADMONITION_PROBES` +(five variants, `Attributes.md`'s literal fence strings among them) and the new probe pass; +putting back a private opener test fails the new one. + +### C34 — `scripts: convert_em_dash_separators reads code regions from lib/` + +**L3-4, L4-7, merged into A3-1.** Its `FENCE_OPEN_RE` (`:59-60`) is byte for byte +`maskCodeRegions`' pattern, with the same gap, and `splitInlineCode` (`:74-99`) is a second +copy of the code-span scan, equivalent today; the tool's own comment (`:70-73`) records a bug +it already shipped in that scan. + +**Change.** Code regions from `blockRegions`, code spans from `splitCodeSpans`, line endings +kept by the module's splice. The file's accepted gap for two-space list-item fences +(`Reference/Core/Get.md`, `Option.md`) closes, because markdown-it recognises those fences. + +**Verify.** `--check` over `docs/` exits 0 before and after. A seeded probe file with CRLF +endings, a doubled-backtick span and a list-item fence has only its prose converted, and +keeps its endings. + +### C35 — `scripts: check_examples' marker splice uses lib/markdown's line splice` + +**Inventory site A4.** `applyMarkers` (`check_examples.mjs:1123-1154`) already finds its line +through markdown-it and re-verifies it before editing; only the split, edit and rejoin that +keeps CRLF is private. + +**Change.** The splice comes from `lib/markdown.mjs`; the re-verification stays. + +**Verify.** An equivalence run in a scratch script: the old and new splice give identical +files for every `tb` fence opener in `docs/`. + +### C36 — `wisdom: parseStaging splits only on real section boundaries` + +**L3-3 (R1), second half.** After C26 a fenced `---` makes the run fail instead of dropping +content; this makes it not a boundary at all. + +**Change.** `parseStaging` splits with `splitOnMarker(src, line => line === "---")`, and +`serializeStaging` writes back through the same module. + +**Verify.** The real `staging.md` parses to the same 1,160 sections, with the same headings +and metadata, and serialises to the same bytes as before. C26's synthetic file now keeps its +section's tail and metadata. + +### C37 — `scripts: check_gate_lists' sections ignore fenced headings` + +**Inventory site B3.** `splitSections` (`:251-264`) starts a section at any line matching +`/^#{1,6}\s/`, so the fenced `staging.md` example at `docs/Documentation/Wisdom.md:304-305` +splits `### staging.md format` in two. No verdict changes today only because the phantom +section states no gate count. + +**Change.** Sections through `splitOnMarker`, so a heading-shaped line inside a fence starts +none. A probe: a fenced `## ` line inside a section that states a count. + +**Verify.** The gate's verdict and its 18 probes unchanged; the new probe fails with the old +splitter. + +### C38 — `scripts: one Attributes.md reader for census and the probe generator` + +**Inventory sites B1, B2.** `census_attributes.mjs`'s `documentedAttributes` (`:377-392`) and +`gen_attribute_probes.mjs`'s `parseAttributes` (`:66-90`) read `docs/Reference/Attributes.md` +line by line with near-identical patterns and no fence exclusion, so a future example showing +the page's own `Syntax:` format inside a fence would be read as an attribute. Both are in +`scripts/` since C10. + +**Change.** One reader in `scripts/lib/`, over `blockRegions`, that skips fenced lines; both +tools use it. + +**Verify.** A scratch script: both tools' parsed attribute lists identical before and after on +the real page, and a fenced `Syntax:` line ignored. + +### C39 — `builder, eval: counts, run_case and nav_hops skip code` + +**Inventory sites B4, B5, B8, B9.** `counts.mjs`'s `countAttributeAnchors` (`:112-116`) and +`countEnumerations` (`:130-140`) apply regexes to raw source. `eval/run_case.mjs`'s +`evaluatorProtocol` (`:99-110`) cuts `eval/protocol.md` at the first bare `---` and a +heading, and the text it cuts becomes the evaluator's system prompt. `eval/nav_hops.mjs`'s +`hrefs` (`:86-94`) strips fences with a regex that recognises 1,353 of the corpus's 1,371; +the ones it misses are indented or use four backticks. None misfires today. + +**Change.** Each finds its lines through `blockRegions`. + +**Verify.** The tree comparison identical (the counts); `evaluatorProtocol` returns the same +text; `hrefs` returns the same links for every page. + +### C40 — `builder, eval: frontmatter through lib/frontmatter; drop gray-matter` + +**Decision (a)'s frontmatter half.** `gray-matter@4.0.3` bundles its own `js-yaml@3.15.2`, +while `data.mjs`, `tbdocs.mjs` and `check_publish_policy.mjs` parse the configuration with +`js-yaml@4.3.2`: page frontmatter and site configuration go through two major versions of one +library. + +**Change.** `discover.mjs` (`:94-117`, which strips the BOM itself because `matter.test()` +does not) and `eval/nav_hops.mjs` use `parseFrontmatter`. `gray-matter` leaves +`package.json` and Builder.md's Dependencies, after the **owner's confirmation** for +`npm uninstall`. + +**Verify.** Every page's parsed frontmatter deep-equal under both parsers, and the tree +comparison identical. Compare before uninstalling, since the before side needs `gray-matter`; +rebuild once after. + +### C41 — `wisdom: read pages and threads through lib/` + +**A10-2 (R1), A10-3 (R2).** Three frontmatter readers disagree: `wisdom/extract/sitemap.mjs:69-87` +has no BOM strip and no type coercion, `prep.mjs:343-388` coerces arrays, booleans and +numbers, and `discover.mjs` strips a BOM, after the AppGlobalClassObject incident. No page +has a BOM today. `sitemap.mjs:61-67`'s `walk()` repeats the markdown walker, and its recorded +reason, "no dependency on `builder/`" (`wisdom/PLAN-3.md:404`), never applied to a walker +that depends on nothing. + +**Change.** Both readers use `parseFrontmatter`, and `sitemap.mjs` lists its pages with +`lib/markdown-files.mjs`. + +**Verify.** Every page's and every harvested thread's parsed frontmatter deep-equal before and +after, with each difference resolved on purpose. Two are likely: YAML gives numbers and dates +where `sitemap.mjs` gave strings, and it refuses an unquoted `: ` inside a value, which a +harvested Discord title may hold. If a thread file has one, the harvester that writes these +files quotes its values, and the existing files are fixed in the same commit. The walker +returns the same files. + +*The link checker, the gates' scaffolding, the browser tools and the repository root: +C42–C46.* + +### C42 — `scripts: check_links.mjs becomes a thin wrapper over builder/check.mjs` + +**Decision 5, A4-2 (R2), A4-1 (R3).** `check_links.mjs:602-634`'s `buildFindings` repeats +`check.mjs:422-449`'s `findingsFor`, and `statSafe` is in both (`link-check.mjs:455-457`, +`check_links.mjs:151-153`). The comments that justify the pair (`check.mjs:410-414`, and +`check_links.mjs`'s header, `:6-14`) describe two independent implementations for +`check_links_diff.mjs` to compare, which decision 5 replaces with one implementation read two +ways. + +**Change.** `check_links.mjs` keeps its command line and its own reading of a tree from disk, +and calls `check.mjs`'s `checkChunk`, `joinChunks`, `findingsFor` and `formatReport` for the +rest. `buildFindings` and its `statSafe` go. Both comments are rewritten to say what +`check_links_diff.mjs` now compares: a tree read from disk against the same tree held in +memory, through one implementation. + +**Verify.** `check_links_diff.mjs --a script --b fused` agrees on every case, and its +`--self-test` passes. `check_links.mjs` prints the same report on `docs/_site` and +`docs/_site-offline` before and after. CI's fixture cases unchanged. + +### C43 — `scripts: lib/gate-probes.mjs for the gates' probes and crash handler` + +**A6-1 / L1-11 / L4-13 (R1).** A probe accumulator and report loop in +`check_page_baseline.mjs` (`:38-44,128-139`), `check_book_coverage.mjs` (`:81-87,158-170`) and +`check_symbol_index.mjs` (`:40-45,361-370`); a crash handler in those three and in +`check_publish_policy.mjs:29`; `withBaseline` in `check_page_baseline.mjs:46-55` and +`check_symbol_index.mjs:314-323`. Only `check_book_coverage.mjs:162` re-indents a multi-line +detail. + +**Change.** `scripts/lib/gate-probes.mjs` holds the accumulator, the report, the crash handler +and `withBaseline`. Probes stay unconditional, and the exit code for a failed probe is a +parameter: 1 for these gates, 2 where probes guard a separate sweep. The three gates and +`check_publish_policy.mjs` adopt it, and so do the handlers C07 and C28 added. C07's sits +inside `convert_em_dash_separators.mjs`'s entry-point guard because that module is +importable, so the shared handler is installed by a call, never as a side effect of the import. +`check_gate_lists.mjs` and `check_regex_safety.mjs` adopt it only if the fit is exact. The +re-indenting becomes the shared behaviour: the one change in output, and only in gate text. + +**Verify.** Each adopting gate's probe count and verdict unchanged; a broken probe still fails +it; a forced crash exits 2. + +### C44 — `scripts: the dot tools share one launch, host page and source list` + +**A5-5, A5-6 / L2-3 (R2).** `check_dot_fit.mjs:76-87` and `build_dot_metrics.mjs:57-68` +build the same Inter host page and the same launch, and neither explains +`--allow-file-access-from-files`. `check_dot_fit.mjs:49-67`'s `findDotSvgs` repeats +`builder/dot.mjs:135-155`'s `listDotSources`, which is not exported. + +**Change.** Both launch through `scripts/lib/browser.mjs` (C19), whose file-access option +adds the flag and states its reason once; the host page is built in one place; +`listDotSources` is exported and `check_dot_fit.mjs` uses it, keeping the broad `_` and `.` +skip, which is safe today. + +**Verify.** `check_dot_fit.mjs`'s output unchanged; `build_dot_metrics.mjs` regenerates +`builder/inter-metrics.json` byte for byte; both listings name the same files. + +### C45 — `a11y: one page discovery and stub ceiling for the sampler and the sweep` + +**A5-3 / L4-9, A5-4 (R2, R3).** `pick_a11y_sample.mjs` (`:122,159-169,184`, `STUB_CEILING`) +and `sweep_a11y.mjs` (`:78,129-153`, `STUB_TAG_CEILING`) each discover pages, count tags and +apply a ceiling of 100, though `--propose` reads the JSONL the sweep writes, so the two must +agree. `pad` and `median` are identical in both. + +**Change.** One discovery, ceiling, `pad` and `median`, in `axe-scan.mjs`, which both already +import. + +**Verify.** `pick_a11y_sample.mjs --census` and `--propose` output unchanged; a sweep over two +pages writes records of the same shape. + +### C46 — `lib: one repository root for every tool` + +**L2-5 (R2).** Nineteen files derive the repository root inline, in about five different +expressions; `axe-scan.mjs:29` exports `REPO_ROOT`, and two files import it. `Extending.md:654` +says nearly all use one expression, and three do. + +**Change.** `lib/repo-paths.mjs` exports the root, and the few paths several tools build from +it. The nineteen use it, `builder/`'s two included, which a `scripts/lib/` module could not +serve; `axe-scan.mjs` re-exports it for its two importers. `Extending.md:654` states the +convention as it now is. + +**Verify.** The tree comparison identical; `test.bat` and `check.bat` clean; +`check_examples.mjs --census`, and each `eval/` and `wisdom/` tool's cheapest mode, unchanged. + +*Command lines (decision (e)): C47–C52.* + +### C47 — `lib: cli.mjs on node:util parseArgs, and check_cli.mjs` + +**Decision (e), L1-13 (R2).** Nothing tests any tool's argument handling, which is how L1-1, +L1-2, L1-3 and L1-6 shipped. + +**Change.** + +- `lib/cli.mjs`, in `lib/` because `tbdocs` migrates onto it (departure 2), to L1's + specification in the ledger: `parseCli(argv, {options, positionals})` over `parseArgs` + with `strict`, `allowPositionals` and `tokens`, camelCase names and positional-count + checks; `withUsageError(fn, {stream, exitCode})`, which keeps each tool's message, stream + and code; `numberOption()`; and `printHelpAndExit(text, {stream, exitCode})`, which keeps + each tool's current help behaviour until Phase 3. The options that repeat (`--forbid`, + `--source`, `--case`, `--additional-script`, `--channel`) are `multiple`. +- `scripts/check_cli.mjs`, a `test.bat` gate: the module's own probes, and a table of cases + per migrated tool (arguments, exit code, stream, and a pattern for the message), recorded + from each tool's behaviour before it migrates. Only invocations that stop during argument + parsing qualify. They run as child processes, in parallel, each with a timeout, and with the + IDE and browser locations pointed at nothing, so a case that got past parsing fails instead + of starting either. +- Registered in `test.bat`, the composite action, Tools.md's list and WIP.md. + +**Verify.** The probes; changing one recorded expectation fails the gate; the roster gate +passes. CI waits for the owner's push. + +### C48 — `scripts: the a11y and diagram tools parse through lib/cli.mjs` + +**A5-1 (R1).** Eight hand-written loops, diverged three ways. `check_a11y.mjs:63-79` answers +`--help` with "unknown arg" and exit 2, where `pick_a11y_sample.mjs:144-147` and +`sweep_a11y.mjs:106-110` print usage to stderr and exit 0; `check_dot_fit.mjs:47` and +`build_dot_metrics.mjs:55` test with `.includes()` and ignore a mistyped flag; +`check_a11y_fingerprint.mjs`, `check_axe_patch_equiv.mjs` and `check_tree_fresh.mjs` print +usage to stdout. + +**Change.** All eight parse through `parseCli`, each keeping today's behaviour, the ignored +typo included, which C72 removes. Their cases go into `check_cli.mjs` first. + +**Verify.** `check_cli.mjs`'s cases for the eight, recorded before and passing after; +`check.bat` unchanged. + +### C49 — `scripts: the harness tools parse through lib/cli.mjs` + +**A7-5 (R2)'s `die()` half, and L1-2's copies.** `tbbuild`, `tbrun` and `addin_test` each +have a `flag()` and `opt()` pair and a `die()`, in three shapes (V3's fifth note); +`check_examples`, `census_attributes`, `build_package_api`, `gen_attribute_probes` and +`check_tb_registry` parse by hand as well. + +**Change.** All eight through `parseCli`, keeping today's behaviour, C17's fixes included; +`tbrun` and `addin_test` still substitute their default for an empty value until C72. + +**Verify.** `check_cli.mjs`'s cases; the `examples.bat` summary and `addin-test.bat` +unchanged (harness runs, one at a time). + +### C50 — `scripts: the gates and link tools parse through lib/cli.mjs` + +**Decision (e); L1-2's fourth variant.** The rest of `scripts/`. + +**Change.** `check_links.mjs`, whose collect-and-warn handling of unknown flags stays custom +code until C72; `check_links_diff.mjs`; `crawl_check.mjs`; `check_publish_policy.mjs`, whose +inline `opt` is L1-2's fourth variant; `check_regex_safety.mjs`, with its internal `--shard` +flag; `check_code_regions.mjs`; `check_gate_lists.mjs`; `convert_em_dash_separators.mjs`; and +`survey_tooling.mjs`. Each keeps today's behaviour. + +**Verify.** `check_cli.mjs`'s cases; `test.bat` and `check.bat` unchanged; +`check_links_diff.mjs --a script --b fused` agrees. + +### C51 — `book, eval, wisdom: parse through lib/cli.mjs` + +**A10-1, L1-12 (R3).** `render-book.mjs:204-225` parses by hand and rejects `--help`; +`eval/`'s four parsers differ on unknown arguments and exit codes, and `transcript.mjs:190-198` +exits 1 on a bare `--help`; "usage, then an exit code chosen by whether help was asked" is +repeated six times across `eval/` and `wisdom/`. + +**Change.** `render-book.mjs` and the four `eval/` scripts through `parseCli`, with +`printHelpAndExit` replacing the six copies, each keeping today's behaviour. `wisdom.mjs`'s +subcommands need code the module does not have, so it migrates only if the fit is clean, and +otherwise stays as it is with a comment saying why. + +**Verify.** `check_cli.mjs`'s cases; `book.bat` renders; each `eval/` script's cheapest mode +unchanged. + +### C52 — `builder: tbdocs parses through lib/cli.mjs` + +**A1-8 (R3), last, as decision (e) says.** `tbdocs.mjs`'s parser (`:91-194`) is neither +exported nor tested, and `--no-check` resets other flags, so order matters. + +**Change.** The option table moves out of `tbdocs.mjs` into a module that exports it. The +order-dependent resets read `parseCli`'s tokens in order. `--name=value`, which today works +for only some of its flags (L1-10), then works for all of them. + +**Verify.** `check_cli.mjs`'s cases for `tbdocs`, C18's exit value and the `--no-check` +ordering included; the tree comparison identical; `build.bat`, `serve.bat` and the CI build +steps behave as before. + +*`builder/`'s helpers, defined twice: C53–C60.* + +### C53 — `builder: one URL module` + +**A3-3 / L4-6 (R1), A9-8 / A2-6 (R2), A2-5, and A3-5's `splitFragment` (R3).** `seo.mjs:121-148` +and `template.mjs:917-936` each define `absoluteUrl` and `relativeUrl`, and they disagree +three ways: a forced leading slash; protocol-relative `//host`, which `seo.mjs`'s scheme-only +`isAbsoluteUrl` misses and prefixes; and `null` against `""` for a non-string. +`normalizeBaseurl` is byte-identical in `book.mjs:303-307` and `offline-rewrite.mjs:232-236`, +and `book.mjs:300-302` justifies its copy by the retired Jekyll plugin layout. `encodeSpaces` +(`search.mjs:204`, `template.mjs:925`) and `splitFragment` (`render.mjs:1593`, +`crawl_check.mjs:51`) are each written twice. + +**Change.** `builder/url.mjs` with one of each. The two URL helpers take the semantics that is +right for `//host` and for a non-string, keeping a forced leading slash only where a call site +needs it. `crawl_check.mjs` imports `splitFragment` from it. + +**Verify.** The tree comparison identical, and again with `--baseurl /docs`: a non-empty base +URL is where the two helpers disagree, and this site's is empty. + +### C54 — `builder: one module for the HTML, XML and RegExp escapers` + +**A3-2 / L3-5, A2-4 (R2).** Seven HTML escapers of two kinds: `&<>` in `render.mjs:2230-2233`, +`highlight.mjs:251-254` (matching Rouge, a recorded reason) and `gantt.mjs:213`; `&<>"'` in +`render.mjs:2225-2228`, `template.mjs:993-998` and `:999-1001` (identical bodies under two +names), and `sitemap.mjs:106-113`. `escapeHtml` names both kinds, and markdown-it has a third +function of that name. `render.mjs:1437-1450`'s `headingTocHtml` escapes `text` tokens with +one kind and `code_inline` tokens with the other. `escapeRegExp` is identical in +`render.mjs:2235-2237`, `offline-rewrite.mjs:239-241` and `book.mjs:212-214`. + +**Change.** `builder/escape.mjs` holds one escaper of each kind, named for what it escapes +(the three-character one for Rouge parity), and one `escapeRegExp`; every copy imports from +it. `headingTocHtml` escapes both token kinds alike. + +**Verify.** The tree comparison identical, since no heading in a table of contents holds an +apostrophe or quote today; a scratch page with one renders the same text in the heading and +in the table of contents. `check_regex_safety.mjs` still recognises the escaper, which it does +by shape. + +### C55 — `builder: one code/pre guard and replaceOutsideCode` + +**A9-7 (R2).** The ``/`
` 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.
+
+### 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.
+
+### 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.
+
+### 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.
+
+### 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.
+
+### 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.
+
+*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).
+
+### 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.
+
+### 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).
+
+### 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).
+
+### 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).
+
+*The book's pdf-lib shims (decision (c)): C66–C69.*
+
+### C66 — `book: check_pdf_shims_equiv.mjs, the shims against stock pdf-lib`
+
+**A9-2 (R2).** No test compares shimmed pdf-lib output with stock; the only comparisons are
+one-off notes in `perf/notes/08-pdf-lib.md`.
+
+**Change.** A `test.bat` gate modelled on `check_axe_patch_equiv.mjs`. The same document is
+loaded, changed and saved by stock pdf-lib in a child process and by pdf-lib with the thirteen
+shims installed here, and the two results are compared object by object, with streams
+decompressed, since a different deflate can give different bytes for the same content. The
+document is generated in the gate to reach every shimmed path (parsing, arrays and
+dictionaries, the parallel deflate, the inflate replacement), so the gate needs no built tree.
+Registered in the composite action, Tools.md and WIP.md.
+
+**Verify.** Passes; a deliberately broken shim fails it, and the report names the shim. CI
+waits for the owner's push.
+
+### C67 — `book: one module for pdf-lib's internal requires`
+
+**A9-5 (R3).** The `createRequire` and `require('pdf-lib/cjs/...').default` block is repeated
+in nine production shims.
+
+**Change.** `book/lib/pdf-lib-internals.mjs`, which the nine import.
+
+**Verify.** `check_pdf_shims_equiv.mjs`; `book.bat` renders with the same page count and
+outline.
+
+### C68 — `book: the two onebuf shims share their range machinery`
+
+**A9-3 (R2).** `_registerContext` and `_appendArray` are identical apart from names in
+`fast-array-onebuf.mjs` and `fast-dict-onebuf.mjs`, while `pack`, `_cow`, `_makeFromRange`
+and `_makeFromAppend` differ by real bit-packing and subclass dispatch.
+
+**Change.** `book/lib/onebuf-range.mjs`, a factory parametrised over the subclass dispatch and
+the gap mask that the two genuinely need differently (V3's fourth note), not one body with
+renamed variables.
+
+**Verify.** `check_pdf_shims_equiv.mjs`; the book's page count and outline unchanged; render
+time within noise of before, since these shims exist for speed.
+
+### C69 — `book: each pdf-lib shim checks what it overwrites`
+
+**A9-1 (R2).** The thirteen production shims each guard against being installed twice and
+never check what they replace, and `parallel-deflate.mjs:50`'s `PDFStreamWriter` subclass has
+no guard at all. Only the exact pin protects them (recorded in `08-pdf-lib.md:1751-1757`),
+and it catches an accidental `npm update`, not a deliberate upgrade or a mistaken edit.
+Upstream is abandoned, and the `@cantoo` fork was evaluated and rejected
+(`08-pdf-lib.md:5048-5061`).
+
+**Change.** At load, each shim asserts the shape of what it overwrites (the member exists,
+its arity, and a fingerprint of its source where one is stable), modelled on `axe-scan.mjs`'s
+`SOURCE_PATCHES` counts, and throws naming itself otherwise. Each header states the exit: the
+shim goes when pdf-lib is replaced, or when a release changes what it patches.
+
+**Verify.** With a target altered in a scratch copy of pdf-lib, the named shim throws at load;
+`check_pdf_shims_equiv.mjs` passes; `book.bat` renders.
+
+*impexp (decision (b)): C70.*
+
+### C70 — `scripts: check_impexp_parity.mjs, the two impexp editions compared`
+
+**A10-6 (R2).** `impexp.mjs` and `impexp.py` each have 19 self-tests, with identical names in
+the same order, run by nothing, and Tools.md promises that the two print the same output and
+write byte-identical files.
+
+**Change.** A `test.bat` gate that runs both `--self-test` suites (the same names, all
+passing) and runs the same commands through both editions on the repository's fixtures
+(`indexer/sample.twinpack`, and a project under `test/example-projects/`), comparing printed
+output and written files byte for byte. Decision (b)'s open question is settled here: whether
+`test.bat` without Python fails, or reports the gate skipped, loudly. In CI a missing
+interpreter fails the gate and never skips it. **The owner settled it on 2026-09-25:**
+without Python, `test.bat` reports the gate skipped, loudly, and passes. The gate tells the
+two cases apart by the `CI` variable GitHub sets, not by an argument, because
+`check_ci_workflows` requires CI to pass each gate the wrapper's arguments unchanged. Registered in the composite action, Tools.md
+and WIP.md.
+
+**Verify.** Passes; a copy of one edition with one output line changed fails it. CI waits for
+the owner's push.
+
+## Phase 3: conventions users see
+
+Decision (e): converge on `impexp.mjs`'s discipline. `--help` prints usage to stdout and exits
+0; an unknown flag or a bad value prints to stderr and exits 2, or C18's value in the two link
+tools; each tool has one table of exit codes. Each commit changes `check_cli.mjs`'s
+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.
+
+### C72 — `scripts, book, eval, wisdom: an unknown flag or a bad value exits 2`
+
+**L1-7, L1-5 (R2), A5-1's typo half, A10-1.** Eleven tools ignore an unknown flag,
+`check_links.mjs` warns, eight refuse it with 2, some throw to 1 or 2, and `wisdom` refuses it
+with 1. `check_links.mjs:307-314` still tolerates flags "passed through via check.bat's %*",
+and the comment at `:406-412` still says `check.bat` passes it arguments; `check.bat` no
+longer calls it (`PLAN-checks.md:14`). `tbrun` and `addin_test`
+substitute their default for an explicit empty value.
+
+**Change.** Strict parsing everywhere: an unknown flag or a bad value is an error on stderr,
+with exit 2, or C18's value in the two link tools. `check_links.mjs`'s tolerance goes, and
+both comments are corrected. Any exception a tool keeps is stated in its usage text and in Tools.md.
+
+**Verify.** `check_cli.mjs` gains an unknown-flag case and an empty-value case for every tool.
+
+### C73 — `scripts: one meaning each for --json and --src`
+
+**L1-8 (R2), L1-9 (R3).** `--json` prints to stdout in `tbbuild`, `tbrun`, `check_examples`
+and `census_attributes`, and takes a file in `check_a11y_fingerprint.mjs`. `--src` is the
+documentation root in `tbdocs` and `check_publish_policy`, and the exported package tree in
+`census_attributes` and `build_package_api`.
+
+**Change.** The odd ones out are renamed: `check_a11y_fingerprint.mjs`'s file option and the
+two package-tree options get names of their own. WIP.A11y.md, WIP.Harness.md and Tools.md
+follow.
+
+**Verify.** `check_cli.mjs`; `git grep` finds no old spelling in the documents or scripts.
+
+### 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).
+
+### C75 — `serve.bat: return tbdocs's exit code`
+
+**A6-5 (R3).** `serve.bat` does not pass its child's exit code back, unlike the other
+wrappers, which capture it before `popd`.
+
+**Change.** The same idiom.
+
+**Verify.** A `serve.bat` that cannot start, because its port is taken, exits non-zero.
+
+## Phase 4: splits
+
+Decision 2: a split is taken only where the evidence says good practice calls for it, and
+size alone does not qualify. The test for each candidate is whether the split removes a hidden
+dependency, lets a part be tested on its own, or separates parts that Phases 1 to 3 had to
+change for unrelated reasons. Each is decided at the start of the phase against what the
+earlier phases found, and each is a pure move, checked by the tree comparison or the owning
+tool's oracle. `axe-scan.mjs` is not a candidate: the review found its single-source property
+worth keeping.
+
+### C76 — `render: split along its plugin seams`
+
+The strongest evidence. The image rule order matters and nothing says so (`svgInlinePlugin`
+at `:518` must run before `remoteImagePlugin` at `:520`); the ellipsis plugin assumes the
+dashes plugin has run (`:515-516`); the plugins anchor on a third-party rule name
+(`"curly_attributes"`); no plugin is tested alone; the two fence parsers sat 1,550 lines apart
+until C32 and C33. First each ordering becomes explicit and tested, with a probe that
+registers the plugins in the wrong order and fails; then the plugins move into modules along
+those seams.
+
+**Verify.** The tree comparison identical; each ordering probe fails with its order reversed.
+
+### C77 — `builder: tbdocs's Gantt and timing code moves beside gantt.mjs`
+
+`tbdocs.mjs:1268-1403` computes what `gantt.mjs` draws, and A1-1 is what the separation cost.
+Further candidates, taken on evidence: the `TASKS` literal, which mixes the graph's shape with
+the task bodies over 61 % of the file (`:260-1257`); `dispatch`'s `submit()`, which is SAB
+machinery (`:788-911`); the check glue (`:1085-1256`); the console report (`:1478-1525`).
+
+**Verify.** The tree comparison identical; the chart names the same tasks in the same rows.
+
+### C78 — `builder: template.mjs's date formatter moves to its own module`
+
+**A3-7 (R2).** After C53 and C54 take the URL and escape helpers, the strftime tables and
+`formatDate`/`parseDate` (`:940-989`) are the one job left in the file that is not
+templating. Its `navActivationCss` deferral (`PLAN-4.md` §3) is close to its trigger, and may
+remove more.
+
+**Verify.** The tree comparison identical, every formatted date included.
+
+### C79 — `builder: book.mjs as a resolver, an assembler and a coverage check`
+
+Its §A resolver, its §B–F assembly of `book.html` (with `rewriteBookHrefs`) and its §G
+coverage check are separate concerns, and `pdf.mjs` already imports them as though they were
+separate modules.
+
+**Verify.** The tree comparison identical, `book.html` above all; `check_book_coverage.mjs`
+passes.
+
+### C80 — `scripts: check_examples' bisection and probe suite in their own modules`
+
+Eight jobs share one file; bisection (`:657-927`, 270 lines in 14 functions) and the 425-line
+probe suite (`:1157-1581`) are the clearest to separate. `gen_attribute_probes.mjs` is not a
+candidate: half of it is its own data table.
+
+**Verify.** `check_examples.mjs --census` and the `examples.bat` summary unchanged (a harness
+run).
+
+### C81 — `scripts: tb-ide's console reading and add-in introspection move out`
+
+Both separate cleanly from the build-state core, which stays together.
+
+**Verify.** `addin-test.bat` green and the `examples.bat` summary unchanged (harness runs, one
+at a time).
+
+## Phase 5: documentation and measurement
+
+Written last, against the code as it then is, with every claim re-read against the file it
+describes: the last review found its own figures stale by the time its documentation commit
+ran.
+
+### C82 — `docs: the module map, Tools.md, WIP.Build.md and Extending.md, as they now are`
+
+Builder.md's module map (the new `builder/` modules, and `lib/`); Tools.md's entries for the
+new tools and gates, added in their commits and re-read here; WIP.Build.md, with the markdown
+module as the one answer to what is code and the rewrite rules restated against it;
+Extending.md's conventions (`lib/cli.mjs`, `gate-probes.mjs`, `lib/repo-paths.mjs`); WIP.md's
+gate table and wrapper bullets; and `PLAN-10.md:692`, which still cites
+`convert_em_dash_separators.py` (V2's second note).
+
+**Verify.** Every changed claim re-read against its file; `build.bat` and `check.bat` for the
+anchors.
+
+### C83 — `builder: the tooling survey re-run against its baseline`
+
+`node scripts/survey_tooling.mjs --summary`, recorded as an *after* column in this file's
+baseline survey table, with the reason for each measure's movement. Expected: private
+`flag`/`opt`/`die` copies from 6 to 0, `parseArgs` users from none to every migrated tool,
+undeclared packages from 2 to 0, and clone regions and repeated names well below 571 (266
+outside `perf/`) and 74 (54).
+
+**Verify.** The recorded column re-read against the survey's own output; each measure that did
+not move as expected has its reason written down.
+
+## Phase 6: formatting
+
+Decision 4's second half, once every fix is in, so that the review's citations stayed valid
+while the fixes were made and the formatter touches only code that survived them.
+
+### C84 — `repo: settle line endings for the formatted file types`
+
+The repository stores LF, 118 of 140 working-tree files are CRLF under `core.autocrlf=true`,
+and there is no `.gitattributes`. Either a `.gitattributes` for the formatted types or the
+formatter's own line-ending setting, shown to give the same verdict on a CRLF Windows checkout
+and an LF CI checkout; otherwise every local run flags 118 files.
+
+**Verify.** The formatter's check gives the same verdict in this working tree and in a
+worktree checked out with `core.autocrlf=false`.
+
+### C85 — `lint: the formatter and its style rules, set to the majority style`
+
+The formatter, the linter's own from C05, pinned exactly and configured to the majority style:
+double quotes, as `builder/`, `scripts/`, `test/` and `eval/` use, where `book/` and
+`wisdom/` use single. Any style lint rules go beside it. Nothing enforces it yet.
+
+**Verify.** The formatter's check runs clean on its own configuration and lists the files C86
+will change.
+
+### C86 — `format: apply the formatter`
+
+Mechanical, and nothing else.
+
+**Verify.** The tree comparison shows what it changed in the output: only
+`docs/assets/js/svg-inline.js` and `theme-toggle.js`, which the site ships as written, should
+differ. `test.bat`, `check.bat`, the `examples.bat` summary and `addin-test.bat` unchanged.
+
+### C87 — `lint: check formatting in the gate and the hook; blame ignores C86`
+
+`check_lint.mjs` and the pre-commit hook check formatting too; `.git-blame-ignore-revs` lists
+C86; WIP.md and Tools.md say so.
+
+**Verify.** A misformatted staged file is refused by the hook and fails the gate with 1;
+`git blame` on a file C86 touched skips it. CI waits for the owner's push.
+
+## Coverage
+
+Every finding in the review, mapped to the commit that addresses it. A finding closed by more
+than one commit lists each.
+
+### Findings
+
+| Finding | Tier | Commit |
+|---|---|---|
+| A1-1: the Gantt chart drops Check and `vendorAssets` | R1 | C21 |
+| A1-2: `picocolors` undeclared (and `pako`) | R1 | C09; policy in C01 |
+| A3-1 / L3-4 / L4-7: two fence-opener predicates disagree | R1 | C31–C34 |
+| A3-3 / L4-6: two URL helpers diverged | R1 | C53 |
+| A4-3: `crawl_check` misses seven link attributes | R1 | C22 |
+| A5-1: argument loops in eight scripts | R1 | C48; C71, C72 |
+| A6-1 / L1-11 / L4-13: the gates' scaffolding copied | R1 | C43; handlers first in C07, C28 |
+| A6-2: `test.bat`'s account of the gate's history | R1 | C29 |
+| A7-1: `tbrun` misses two failed-build shapes | R1 | C16 |
+| A8-1: census's keyword list lacks `Overridable` | R1 | C62 |
+| A10-2: three frontmatter readers disagree | R1 | C41; module in C31 |
+| L1-1: theme and viewport checked in one tool of three | R1 | C20 |
+| L1-2: `opt()` returns `undefined`, then `NaN` | R1 | C17; C49, C50 |
+| L1-3: `tbbuild --keep proj` fails | R1 | C17 |
+| L1-4: a `tbdocs` usage error exits as a link failure | R1 | C18 |
+| L2-1: two output-tree lists miss `_site-basepath*` | R1 | C13, after C12 |
+| L2-2: census's private install finder | R1 | C11 |
+| L3-1: `check_a11y` leaves Chromium running (in fact its profile folder; see C19) | R1 | C19 |
+| L3-3: `parseStaging` drops content after a fenced `---` | R1 | C26 (loud), C36 (fence-aware) |
+| L4-10: two `logicalLines` | R1 | C61 |
+| A1-3 / L4-1: a run, time and report block three times | R2 | C59 |
+| A1-4: `makeTimer`'s export unused | R2 | C14 |
+| A1-6: exit bits as bare literals | R2 | C60 |
+| A2-1 / A1-5 / L4-4: dead paths left by the diff tools | R2 | C14 |
+| A2-2 / A9-10: comments naming deleted tools; `extractImagePaths` | R2 | C14 |
+| A2-3 / L2-4 / L4-5: the baseline reader and drift guard | R2 | C57 |
+| A2-4 / A3-5's `escapeRegExp` part: `escapeRegExp` three times | R2 | C54 |
+| A2-7: the whitespace collapse twice | R2 | C58 |
+| A3-2 / L3-5: seven HTML escapers | R2 | C54 |
+| A3-6: three unguarded whole-page rewrites | R2 | C56, after C55 |
+| A3-7: `template.mjs` does four jobs | R2 | C53, C54, C78 |
+| A4-2: the findings translation twice | R2 | C42 |
+| A5-2: two tools crash to exit 1 | R2 | C28 |
+| A5-3 / L4-9: page discovery and stub ceiling twice | R2 | C45 |
+| A5-5: the diagram tools' launch and host page twice | R2 | C44; lifecycle from C19 |
+| A5-6 / L2-3: the `.dot` walker twice | R2 | C44 |
+| A6-3: the dash tool has no exit for a crash | R2 | C07; C43 |
+| A6-4: nothing reads the workflows | R2 | C03, C04 |
+| A7-2: `afterReveal`'s timeout discarded | R2 | C24 |
+| A7-3: two click primitives | R2 | C63 |
+| A7-4: `alive` and `norm` twice | R2 | C64 |
+| A7-5: the harness CLIs' defaults and `die()` | R2 | C17; C49 |
+| A7-8: the add-in scenario preamble ten times | R2 | C65 |
+| A8-2: census in `builder/` | R2 | C10 |
+| A8-4: two classifiers without probes | R2 | C62 |
+| A9-1: the shims are not guarded | R2 | C69 |
+| A9-2: no shim equivalence test | R2 | C66 |
+| A9-3: the onebuf helpers twice | R2 | C68 |
+| A9-6: shims only `perf/` loaded, in `book/lib/` | R2 | resolved in `90624841` |
+| A9-7: the code and pre guard four times | R2 | C55 |
+| A9-8 / A2-6: `normalizeBaseurl` twice | R2 | C53 |
+| A10-3: wisdom's own walker | R2 | C41 |
+| A10-4: `schemas.mjs` dead | R2 | C15 |
+| A10-5: wisdom's state files not written atomically | R2 | C27 |
+| A10-6: impexp parity unchecked | R2 | C70 |
+| L1-5: `check_links`' stale pass-through tolerance | R2 | C72 |
+| L1-6: `--help` handled four ways | R2 | C71 |
+| L1-7: unknown flags handled five ways | R2 | C72 |
+| L1-8: `--json` with two meanings | R2 | C73 |
+| L1-13: no tests of argument handling | R2 | C47 |
+| L2-5: the repository root derived in nineteen files | R2 | C46 |
+| L3-2: `check_examples`' spawn has no `'error'` listener | R2 | C23 |
+| L4-3: the vendoring fetch and write twice | R2 | C58 |
+| A1-7: FAILED written as a literal | R3 | C59 |
+| A1-8: `tbdocs`' parser untested | R3 | C52 |
+| A2-8: path separators flipped at ten sites, `posix()` twice | R3 | C58 |
+| A3-4 / A2-5 / A3-5 / A4-1, the L4-8 cluster: four leaf helpers written twice (its fifth, `makeTimer`, is A1-4) | R3 | C58 (`isNonEmpty`), C53 (`encodeSpaces`, `splitFragment`), C42 (`statSafe`) |
+| A3-8: three palette loops | R3 | C58 |
+| A3-9: `precomputeSeo` dead | R3 | C15 |
+| A3-10: `kramdownSlug` exported | R3 | C15 |
+| A5-4 / L4-9: `pad` and `median` twice | R3 | C45 |
+| A6-5: `serve.bat` drops the exit code | R3 | C75 |
+| A7-6: the `-EncodedCommand` comment | R3 | C25 |
+| A7-7: the 180-second literal at seven sites | R3 | C64 |
+| A7-9: `tbbuild`'s shutdown can skip its tidy step | R3 | C25 |
+| A8-3: the fence unit key twice | R3 | C64 |
+| A9-4: `_writeUint` and `_digitCount` twice | R3 | resolved in `90624841` |
+| A9-5: the `createRequire` block in nine shims | R3 | C67 |
+| A9-9: a comment citing a moved file | R3 | resolved in `90624841` |
+| A10-1: `eval/`'s four parsers | R3 | C51; C71, C72 |
+| L1-9: `--src` with two meanings | R3 | C73 |
+| L1-10: `--name=value` only in `tbdocs` | R3 | C47–C52, as a side effect of `parseArgs` |
+| L1-12: usage-then-exit six times | R3 | C51 |
+| L2-6: scratch folders not removed in a `finally` | R3 | C30 |
+| L2-7: three small tree walkers | R3 | none: the review proposes no fix |
+| L3-6: unguarded `spawnSync` | R3 | C30 |
+| L4-2: the two SCSS compiles | R3 | C58 |
+
+### Decisions
+
+| Decision | Commit |
+|---|---|
+| 1: `perf/` stays a lab notebook | done in `26eefeeb` and `90624841` |
+| 2: fix in place first, split later | Phase 4, C76–C81 |
+| 3: the tree comparison as a committed tool | C02 |
+| 4: the linter first, formatting last | C05, C06, C08; C84–C87 |
+| 5: both impexp editions, `build_fonts.py`, the link checker as a thin wrapper | C42; C70 |
+| 6: a gate on the workflows | C03 |
+| (a): the markdown module in `lib/`, and no `gray-matter` | C12, C31–C41 |
+| (b): the impexp parity gate | C70 |
+| (c): shim checks and an equivalence test | C66, C69; C67 and C68 under it |
+| (d): the composite action | C04 |
+| (e): command lines | C17 first, C47–C52, C71–C74 |
+| (f): the `staging.md` slip | done in `e0d127f0` |
+| (g): the pinning policy and Builder.md's list | C01, then the bar's rule |
+
+### The inventory's fifteen sites
+
+| Site | Commit |
+|---|---|
+| A1: `render.mjs`'s code mask | C32 |
+| A2: `render.mjs`'s admonition rewrite | C33 |
+| A3: `convert_em_dash_separators.mjs` | C34 |
+| A4: `check_examples.mjs`'s marker splice | C35 |
+| A5: wisdom's `parseStaging` and `serializeStaging` | C26, C36 |
+| B1: `census_attributes.mjs`'s `documentedAttributes` | C38 |
+| B2: `gen_attribute_probes.mjs`'s `parseAttributes` | C38 |
+| B3: `check_gate_lists.mjs`'s `splitSections` | C37 |
+| B4, B5: `counts.mjs`'s raw-source counts | C39 |
+| B6: `wisdom/extract/sitemap.mjs`'s frontmatter | C41 |
+| B7: `wisdom/extract/prep.mjs`'s frontmatter | C41 |
+| B8: `eval/run_case.mjs`'s protocol cut | C39 |
+| B9: `eval/nav_hops.mjs`'s links | C39; its frontmatter in C40 |
+| B10: `test/addin/symbols.test.mjs` | none: it reads the first line of a compiler hover reply, which no fence can come before |
+
+`discover.mjs`'s `gray-matter` call, a positive precedent in the inventory, moves in C40.
+
+### Verifier notes folded in
+
+| Note | Commit |
+|---|---|
+| V1: a fifth comment naming the diff tools, `offline.mjs:364-365` | C14 |
+| V1: `render.mjs`'s two escapers, one a copy of `highlight.mjs`'s | C54 |
+| V1: the bit-0 assignments safe only by source order | C60 |
+| V2: `Extending.md:654` overstates its own convention | C46 |
+| V2: `PLAN-10.md:692` cites the `.py` dash tool | C82 |
+| V2: `gen_attribute_probes.mjs --help` creates a folder | C71 |
+| V2: three frontmatter readers, not two | C41 |
+| V3: `tb-fences.mjs` has no `/* */` handling; its BOM safety comes from `trim()` | C61 |
+| V3: A8-1 and A8-4 are one gap | C62 |
+| V3: the onebuf factory must parametrise the dispatch and the gap mask | C68 |
+| V3: a `flag()` and `opt()` pair in each harness CLI | C49 |
+| V4: `render.mjs`'s and the dash tool's fence patterns are identical literals | C34 |
+| V4: `check_examples.mjs` has no process-level handler | C23 |
+| V4: `escapeHtml` names three unrelated functions | C54 |
+| V4: `check_a11y.mjs:229` names the path that leaks the browser | C19 |
+
+## The bar for each commit
+
+- `build.bat && check.bat && test.bat` clean; from C06, lint clean; from C87, format clean.
+- The commit's oracle from the table above, with every difference it shows explained. The
+  explanation goes in the commit's Landed note in this file, since commit messages stay one
+  line.
+- A commit that changes a gate shows the gate still failing on its reintroduced defect.
+- One concern per commit, with a concise one-line message under 80 characters and no
+  trailers.
+- **A new gate is registered in the commit that adds it**: in `test.bat` or `check.bat`, in
+  the composite action (in both workflows before C04), in Tools.md's numbered list, and in
+  WIP.md's gate table and wrapper bullet. `check_gate_lists.mjs` and `check_ci_workflows.mjs`
+  catch a miss in the first three; WIP.md is checked by hand.
+- **A commit that changes `package.json`** follows C01's policy and updates Builder.md's
+  Dependencies in the same commit. `npm install` and `npm uninstall` wait for the owner's
+  confirmation, and so does any change to git configuration.
+- **A move or a rename** updates every citation `git grep` finds, in published pages and the
+  WIP files included.
+- **Harness runs** (`examples.bat`, `addin-test.bat`, `tbbuild`, `tbrun`, `census_attributes`,
+  `build_package_api`, `gen_attribute_probes`, `check_tb_registry`) go one at a time, never two
+  at once, and each run's summary line goes in the Landed note.
+- **A commit that changes a workflow or the composite action** says what CI must show, and
+  waits for the owner's push to show it (see Oracles).
+
+## Where the plan was wrong
+
+When a commit lands and its code turned out different from its entry, the entry keeps its
+text, gains a Landed note, and the correction is listed here, as in the last review's plan.
+
+- **C19 (L3-1): a failed accessibility run leaves no Chromium running.** The review and C19's
+  entry said Chromium stays up for the rest of the CI job. Puppeteer kills its browser from
+  its own `exit` handler, on Windows and Linux alike. What a failed run leaves is the
+  browser's temporary profile folder, 4.3 MB. The change stands as planned and fixes that
+  instead; see C19's Landed note.
+- **C21 (A1-1): no `Other` band.** The entry adds `Check` and `Other` to the chart. At the
+  owner's request a task with no section fails the build instead, so nothing can reach
+  `Other`, and it was removed; see C21's Landed note. It landed as `builder: the Gantt chart
+  draws every task, or the build fails naming it`.
+- **C24 (A7-2): no retry.** The entry has each call site retry once before it throws.
+  Retrying the wait only makes it longer, which is what `afterReveal`'s `timeout` is for, and
+  opening the file again would start a new reveal, so each call throws at the first timeout,
+  the review's other option ("retry or fail loudly"); see C24's Landed note.
+
+## Found while implementing
+
+Defects the review did not have, found by building something this plan asks for.
+
+- **Four high-severity advisories in the installed packages**, which `npm` reported while C05
+  installed Biome. Fixed between C05 and C06 in `deps: update js-yaml, ws, linkify-it and
+  immutable past their advisories` (only `package-lock.json` changed). Two lessons bind later
+  work, and C40 needs both: `npm ls` can report a version that is not actually installed,
+  because `node_modules/.package-lock.json` can be rewritten (by e.g. `npm audit fix
+  --dry-run`) while the packages on disk stay old, and npm trusts that file when it is newer
+  than every package folder, so read each package's own `package.json` instead; and
+  `compare_trees.mjs` cannot see a dependency change at all, because both its worktrees
+  resolve packages from this checkout's one `node_modules`. So run it with `--keep` before
+  the change, copy `.compare-trees/before/.compare-out` aside, run it again after, and compare
+  the two `before` builds with the same three normalisers; they must agree.
+
+- **`serve.bat --dest` inside `docs/` could rebuild forever** for a destination name the
+  watcher does not recognise as an output tree, and `discover` reads a stale destination as
+  source content too, failing the publish allowlist on every rebuild. Fixed in C13a, which
+  added `assertDestinationClearOfSource`.
+
+- **Pipeline-Stages.md's `render.mjs` table listed exports the module does not have**, and
+  five more errors in the same material. Fixed in `docs, builder: correct render.mjs's export
+  table and plugin chain` (`57cdaa1d`).
+
+- **`tbrun` exits 0 with partial output when a procedure the probe calls fails code
+  generation**, found while verifying C16. Scheduled as C25a; see its entry for the fix.
+
+- **Builder.md's "Task DAG by section" disagrees with the chart and with the task graph**,
+  found while re-reading its Gantt paragraph for C21. The section lists put `discover` in
+  Seeds, where `GANTT_SECTION` charts it in Spine, and `warmInit` and `renderEnvInit` in Seeds
+  and Render, where the chart draws them as start-up bars in each worker's lane. The Seeds
+  discussion says a seed has no predecessors, then lists `scss`, `prepDest` and
+  `prepPageDirs`, which all have them. The Spine sketch draws `loadData` off `discover` and
+  before `highlighterInit`, where `loadData` waits for `highlighterInit`, which waits for
+  `config`. And the Gantt paragraph says boot timings form a row group of their own, where
+  they are drawn at the start of each worker's row. Fixed in `docs: Builder.md's task
+  sections match the chart and the task graph`.
+
+- **`crawl_check.mjs` can exit 127 on Windows where it should exit 1**, found while
+  verifying C22. It calls `process.exit()` straight after printing its report, and libuv
+  (Node 24.13.0) aborts on an assertion, `!(handle->flags & UV_HANDLE_CLOSING)` in
+  `src\win\async.c:76`. The report is complete; the exit code is not. It happened on three of
+  three runs against the C22 fixture and on none of four against the site; a clean run
+  reaches the same `process.exit(0)`, so nothing shows it is safe. Fixed in `scripts:
+  crawl_check sets its exit code instead of calling process.exit`.
+
+- **`serve.bat` serves a folder page at its URL without the trailing slash** rather than
+  redirecting, as GitHub Pages does, so the page's relative links resolve one level too high.
+  The built site links 74 folder pages that way, e.g. `../../tB/Modules/Collection` from
+  Permanent-Links. On the live site those links cost a 301; in a `serve.bat` preview reached
+  through one of them, the page's relative links are broken. `serve.mjs`'s resolver (about
+  `:84-100`) has the same three candidates the C22 scratch server started with, whose crawl
+  found 626 broken links for this reason. Fixed in `builder: serve.bat redirects a folder URL
+  to its trailing slash`.
+
+- **A crawl of `serve.bat` loses about 20 requests to connection resets**, found while
+  verifying C22b. Crawls of a test serve report about 20 broken links that are not HTTP
+  errors: a crawl of HEAD's serve had 20 `fetch failed` beside its 603 HTTP errors, and
+  after C22b's fix, three crawls reported 20, 21 and 20 broken, the last tallied by cause as
+  20 `fetch failed`, each caused by `read ECONNRESET`. The serve keeps Node's default 5 s
+  keep-alive timeout. With it raised to 300 s as a scratch experiment, two crawls of two had
+  no failure, and the kit's static server, which keeps connections 300 s, had none in C22a's
+  three crawls of `_site`. So the resets come from the server closing idle connections that
+  `fetch` then reuses, and `crawl_check` does not retry such a request. Whether the live site
+  does the same is unmeasured. Fixed in `scripts: crawl_check retries a request that fails
+  before any response`.
+
+- **Pipeline-Stages.md files eight tasks under a section other than the chart's**, found
+  while fixing Builder.md's copy of the same lists in C22c. Extending.md says each task's
+  `###` heading there sits "under the numbered section matching its Gantt section". Section 1
+  (Seed tasks) holds `scss`, `dot`, `prepDest` and `prepPageDirs`, Section 2 (Spine)
+  `loadData` and `dispatch`, and Section 3 (Render fan-out) `flush:i` and `flushJoin`, where
+  `GANTT_SECTION` charts them in Write, Spine, Render, Render, Seeds, Render, Write and Write.
+  Section 1 also holds `warmInit` and Section 3 `renderEnvInit`, which the chart gives no
+  section. And Section 1's introduction says its tasks have no predecessors, which `scss`,
+  `highlighterInit`, `prepDest` and `prepPageDirs` all have. Fixed in `docs: Pipeline-Stages.md's
+  task sections match the chart and the task graph`.
+
+- **Pipeline-Stages.md's module export tables omit two modules**, found while fixing its task
+  sections in C22e. The page says its second half covers every module, with the full export
+  table for each, and has no table for `counts.mjs` or `page-baseline.mjs`, both in
+  `builder/`. Its `markdownInit` section and `render.mjs`'s plugin table name `counts.mjs`'s
+  functions. Fixed in `docs: export tables for counts.mjs and page-baseline.mjs`.
+
+- **`tbdocs.mjs`'s task-graph comment (`:216-227`) contradicts `TASKS`**, found while checking
+  C22e's edges. It lists `scss` among the seeds, where `scss` waits for `scssLight`,
+  `scssDark` and `prepDest`; it gives `config → loadData`, where `loadData` waits for
+  `highlighterInit`; and it gives `flushJoin + prepPageDirs → writeAssets + searchData`, where
+  neither waits for `flushJoin`, and `searchData` waits for `renderJoin` and `prepDest`. It is
+  a fifth description of the graph, beside the four that Extending.md lists. Fixed in
+  `builder: tbdocs.mjs's task-graph comment points to TASKS and the docs`.
+
+- **`counts.mjs` exports `COUNT_NAMES`, which nothing reads**, found while checking C22f's
+  table. `git grep` finds no importer: `validateCountNames` checks a page against the keys of
+  the counts it is given (`counts.mjs:288`), and nothing else lists the names. Extending.md
+  (`:698`) names it as though it registered them. Fixed in `builder: delete counts.mjs's
+  unused COUNT_NAMES`.
+
+- **`crawl_check.mjs` says nothing about a page whose body cannot be read**, found while
+  implementing C22d. `crawlOne` records a same-site page as reachable (`:137`) before it
+  reads the body, and a failed read returns at once (`:149`): the page's links are never
+  extracted and its ids never indexed, so the fragment check skips every anchor into it, and
+  the report says nothing. The kit's `c22i-body.mjs` serves a page that answers 200 with a
+  `Content-Length` of 5000, sends a link to a missing page and closes the socket: the crawl
+  exits 0 with nothing broken, and the missing page is never requested. Fixed in `scripts:
+  crawl_check reports a page whose body cannot be read`.
+
+- **`crawl_check.mjs`'s `--timeout` stops at a page's headers**, found while implementing
+  C22i. `fetchWithRetry` cleared its timer in its `finally` (`:82`) once `fetch` resolved,
+  which is when the headers arrive, so nothing of the crawl's own bounded the read of a body.
+  The kit's `c22i-stall.mjs` serves a page that answers 200 with a `Content-Length` of 5000,
+  sends part of it and then nothing, and keeps the socket open: with `--timeout 1000` the
+  crawl waited 305.6 s, about undici's default body timeout of 300 s, before it reported the
+  page as C22i's `body: terminated`. The crawl starts its next batch of pages only when every
+  page of the current one is done, so the whole crawl waited. Fixed in `scripts:
+  crawl_check's --timeout covers a page's body`.
+
+- **`tbbuild` launches a named IDE that is not there**, found while implementing C25.
+  `findIde` returns a path from `--ide` or `TB_IDE` unchecked, and `tbbuild` alone of its six
+  callers went on to launch it: the hidden launch failed inside `tb-launch.ps1`, with a
+  message in PowerShell's CLIXML, and `--show` crashed. Fixed in `scripts: tbbuild refuses a
+  named IDE that is not there`.
+
+- **A failed hidden launch reports its cause in CLIXML, and the wrong cause**, found while
+  implementing C25. `launchIde` relays `tb-launch.ps1`'s stderr, which PowerShell writes in
+  CLIXML when its streams are redirected, as `tb-registry.mjs` had already found; and `Fail`
+  read the Win32 error after PowerShell's own calls had replaced it, so a missing executable
+  was reported as error 203, "The system could not find the environment option that was
+  entered". Fixed in `scripts: tb-launch.ps1 reports why a launch failed, in plain text`.
+
+- **`launchIde` under `--show` crashes when the IDE cannot be started**, found while
+  implementing C25. The branch spawned the IDE with no `'error'` listener, so a spawn that
+  failed ended the run on Node's report of an unhandled `'error'`, with exit 1, the code
+  `tbbuild` and `tbrun` give compile errors. Measured with a missing executable and with the
+  install folder. Fixed in `scripts: launchIde under --show reports a spawn that fails`.
+
+## 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;
+- 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;
+- whether the pre-commit hook should also run the dash check, which A6-3 assumed: the owner
+  approved a hook that runs Biome only (C08), so it stays out unless the owner asks for it.
+
+The two questions this plan started with are settled: the two link checkers (decision 5), and
+the survey script, which is committed.
diff --git a/builder/README.md b/builder/README.md
index 0934f9d9..b0d9e5c8 100644
--- a/builder/README.md
+++ b/builder/README.md
@@ -27,7 +27,7 @@ CLI flags:
 | Flag | Effect |
 |---|---|
 | `--src ` | Source root (default `docs`). |
-| `--dest ` | Online tree destination (default `/_site`). The offline tree lands at `-offline`, the PDF tree at `-pdf`. |
+| `--dest ` | Online tree destination (default `/_site`). The offline tree lands at `-offline`, the PDF tree at `-pdf`. It may not be or contain ``, and inside `` it must be, or be inside, a folder directly under it whose name starts with `_site`, `_serve` or `_pdf`; the build refuses any other. |
 | `--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 deployment origin). |
 | `--dry-run` | Skip every filesystem write. |
diff --git a/builder/REVIEW-TOOLING-fe9ce12b.md b/builder/REVIEW-TOOLING-fe9ce12b.md
new file mode 100644
index 00000000..0c14480c
--- /dev/null
+++ b/builder/REVIEW-TOOLING-fe9ce12b.md
@@ -0,0 +1,618 @@
+# Review of the tooling at `fe9ce12b`
+
+Scope: all first-party tooling — `builder/`, `scripts/`, `book/`, `eval/`, `wisdom/`, `test/`, the `.bat` wrappers and both CI workflows (`perf/` and vendored code excluded) · reviewed 2026-09-25 · line numbers refer to `fe9ce12b`
+
+This is a different kind of review from [REVIEW-c9f2dfe0-1b6922b.md](REVIEW-c9f2dfe0-1b6922b.md), which asked whether one range of commits was correct. This one asks about factoring and repetition, and about sound design against workarounds, across the whole of the repository's own tooling. Its charter, rubric and decisions are in [PLAN-TOOLING-REVIEW.md](PLAN-TOOLING-REVIEW.md).
+
+---
+
+## Verdict
+
+The tooling holds up well where it matters most. Fourteen review passes and four independent verifiers read `builder/`, `scripts/`, `book/`, `eval/`, `wisdom/`, `test/` and the wrappers directly against `fe9ce12b`, and confirmed nearly everything they found. The scheduler's barrier and SAB layout, the compiler harness's process discipline, the axe patch and the dot-metrics WASM patch, the gates' probe-and-report shape, and the paged.js fork's divergence record are all sound, for the reasons given under Verified sound below.
+
+Where the tooling is weak, it is weak in the same two ways, repeated across many files. First, small helper functions — URL builders, HTML escapers, baseline serializers, argv parsers, repo-root derivations, directory walkers — exist in two, three, or for HTML escaping seven copies, and several of the copies have already disagreed with each other, not merely risked it: a link checker with a narrower attribute table than its sibling, three CLI parsers that return `undefined` instead of a stated default on a trailing flag, two frontmatter readers with no BOM handling next to a third that has it, two install-finders that recognise an install by different files. Second, a smaller number of workarounds fail the review's own three-part test for an acceptable one — contained, guarded, with a stated exit. The clearest cases are the three post-render HTML rewrites that depend on an invariant (code content is always entity-escaped) that is true for three of the four ways text can reach a `` block and false for the fourth, and the pdf-lib shims, pinned against accidental version drift but asserting nothing about what they overwrite.
+
+The single most important theme, raised by the repository's owner partway through the review, threads through both weaknesses: markdown is handled as text, not as a parsed structure, and almost every tool that touches it re-derives, by hand, what counts as a fence, a heading, or a code span. The costs range from a reproducible bug — two fence-opener predicates in the same file disagree on one input — to a genuine defect in a file the repository commits: a missing blockquote marker in `wisdom/data/findings/staging.md` breaks a fence, and the tool that re-serializes that file's sections has no way to notice. Closing that gap with one shared, markdown-it-based module is the review's leading recommendation for the fix phases that follow; the remaining findings are smaller individually but numerous, and the plan schedules them across its phases, described in [PLAN-TOOLING-REVIEW.md](PLAN-TOOLING-REVIEW.md#execution).
+
+---
+
+## Provenance
+
+Fourteen passes, all on Sonnet, read the tree at `fe9ce12b` without changing it: reading, `git log`/`blame`, scratch scripts in the session scratchpad, and the read-only gates, but never a build, a browser, or the compiler harness:
+
+| Pass | Subject |
+|---|---|
+| A1 | Build orchestration and scheduling |
+| A2 | Output stages and auxiliary outputs |
+| A3 | The Markdown dialect and page templates |
+| A4 | Link and integrity checks, and the two-checker question |
+| A5 | Site-reading gates, accessibility, diagrams |
+| A6 | The gates as a system, and `test.bat` |
+| A7 | The compiler harness |
+| A8 | Sample compiling and the package API |
+| A9 | The book pipeline |
+| A10 | Smaller tools and the site's scripts |
+| L1 | Command-line and process conventions, every entry point |
+| L2 | Paths, configuration, file walking, dependencies |
+| L3 | Browsers, child processes, rewriting HTML and Markdown as text |
+| L4 | The survey's duplicate-code leads |
+
+Four verifiers, also on Sonnet, then re-read every R1 and R2 citation against the source as it is at `fe9ce12b` (`git show fe9ce12b:`, so commits made meanwhile could not move anything under them):
+
+| Verifier | Scope | Findings checked | Verdict |
+|---|---|---|---|
+| V1 | `builder/` (A1–A3, A9 subset, A2/L2/L4 overlaps) | 23 | All confirmed; several severity notes, five items noticed in passing |
+| V2 | Gates, links, a11y, CI, small tools (A4–A6, A10, L1–L2, L4 overlaps) | 29 | 25 confirmed, 4 corrected (A4-3, A5-1, L2-5, L2-6) |
+| V3 | Harness, samples, book (A7–A9, L2, L4 overlaps) | 15 | 10 confirmed, 5 corrected (A7-1, A7-3, A7-4, A8-2, A9-5) |
+| V4 | L3 (browsers, processes, text rewrites) | 6 | All confirmed; one nuance (L3-3), one conflict ruled (A3-6) |
+
+The orchestrator ran six checks of its own, beyond what the verifiers covered, and reports them because each one changed a finding's stated impact or resolved a conflict the passes left open:
+
+- **The census Overridable check against the BETA 983 package cache** — traced A8-1's claimed misclassification against the real exported package tree and found zero attributed `Overridable` members exist there today, so the divergence is real but dormant.
+- **The tbrun regex** — compared tbrun.mjs's failed-build pattern with tb-ide.mjs's `BUILD_FAILED` by reading (A7-1). The orchestrator first reported the narrower pattern as matching two of the five failure shapes; it is case-insensitive and matches three, as V3 found. The two it misses are the ones the finding names.
+- **The Gantt sections** — confirmed A1-1's claim that Check-phase tasks and `vendorAssets` are dropped from the Gantt chart on every build.
+- **The BOM scan of `docs/`** — confirmed A10-2's BOM gap is dormant: zero of 912 markdown files begin with a UTF-8 BOM today.
+- **The staging.md `parseStaging` run** — ran the real parser against the real, committed 15,553-line file and found the inventory's claim of live section-metadata mis-pairing not confirmed; see Where the passes were wrong.
+- **The Wisdom.md:304–305 fence** — confirmed `check_gate_lists.mjs`'s `splitSections` genuinely mis-splits on a heading-shaped line inside a fenced example at that location, dormant only because the phantom section states no gate count.
+
+The working records — the orchestrator's ledger, the four verifiers' files and the markdown inventory — are committed beside this document, in [`REVIEW-TOOLING-fe9ce12b/`](REVIEW-TOOLING-fe9ce12b/README.md).
+
+Four commits were made on `staging` during the review, all before this document:
+
+- **`d0a652d6`** — `scripts/survey_tooling.mjs`, the before-and-after clone-detection and import-graph tool the plan's baseline survey and this document's appendix both depend on (decision 3).
+- **`90624841`** — deleted the four superseded pdf-lib shims (`fast-refs`, `fast-dict-array`, `fast-dict-iter`, `fast-parse-dict`) that only `perf/` loaded, approved by the user during the review. Resolves **A9-4**, **A9-6**, **A9-9**.
+- **`26eefeeb`** — documented, in `perf/detach-pages.js` and `perf/README.md`, that `book.bat` and the deploy workflow load that file at run time — the amendment to decision 1 (nothing else moves out of `perf/`).
+- **`86a70107`** — `PLAN-TOOLING-REVIEW.md` itself, recording the review's strategy and the decisions made as the passes ran.
+
+---
+
+## Themes
+
+### Markdown is processed as text, each tool deciding privately what counts as code
+
+This is the theme the repository's owner raised while the passes were running, and it is the one worth fixing first. A dedicated inventory pass ([`REVIEW-TOOLING-fe9ce12b/markdown-inventory.md`](REVIEW-TOOLING-fe9ce12b/markdown-inventory.md)) found **15 sites** that scan or rewrite markdown by hand. Five rewrite text: `render.mjs`'s `maskCodeRegions`/`maskInlineCode` and its `stashCodeFences`/`rewriteAdmonitions` (behind **A3-1**), `convert_em_dash_separators.mjs` (behind **L3-4**/**L4-7**), `check_examples.mjs`'s marker splice, and wisdom's `parseStaging`/`serializeStaging` (behind **L3-3**). Ten only scan: `census_attributes.mjs` and `gen_attribute_probes.mjs` reading `Attributes.md` line by line, `check_gate_lists.mjs`'s section splitter, `counts.mjs`'s two raw-source counts, wisdom's two frontmatter readers (behind **A10-2**), two readers in `eval/`, and one test that reads markdown returned by the compiler's hover. Of the 15, **eight have no code awareness of any kind** — not even a hand-written fence check — and six more have a hand-written fence or frontmatter rule with a known, named gap. Exactly one site (`check_examples.mjs`'s marker splice) locates its edit through a markdown-it-derived line number and then re-verifies the line before touching it; it is the model the rest should follow, alongside the token-traversing plugins in `render.mjs` and `check_code_regions.mjs`'s own gate, which already consult markdown-it's token stream instead of re-deriving it (`scripts/lib/tb-fences.mjs`, `builder/discover.mjs`'s `gray-matter` use).
+
+Two live problems came out of tracing this by hand rather than by inference. `check_gate_lists.mjs`'s `splitSections` genuinely splits `docs/Documentation/Wisdom.md`'s `### staging.md format` section into two phantom sections at `Wisdom.md:305`, because that line is a fenced *example* of a `## ` heading, not a real one — dormant only because the phantom section happens to state no gate count. And the real, committed `wisdom/data/findings/staging.md` has a genuine content defect at lines 14245–14246: a fenced sample nested inside a `> [!NOTE]` admonition is missing its blockquote continuation marker on the fence's content line, so markdown-it does not close the fence where the source intends — it runs on for 52 lines. The inventory's stronger claim, that this already causes `parseStaging` to attach the wrong metadata to the wrong section, is **not confirmed** — see Where the passes were wrong.
+
+Markdown-it itself was tested directly, empirically, rather than assumed. Block-level tokens (`fence`, `code_block`, `html_block`) have a `.map` giving the exact source line range, including a fence nested inside a blockquote or a list item, prefix markers included in the raw slice and stripped in `.content` — both available from one parse. Inline tokens have no offsets at all (`.map` is always `null`), so a correct inline code-span splitter still has to be the same manual backtick/tilde-run scan `maskInlineCode` and `splitInlineCode` already implement independently — markdown-it can supply which lines an inline run occupies, never where within them. Frontmatter is not merely unhandled by markdown-it: it is **actively misparsed** unless split off first — a leading `---` becomes an `hr`, and the block that follows is then read as a setext H2 heading, so frontmatter must be split off before markdown-it sees a page, as `discover.mjs` already does. Timing over all 912 markdown files under `docs/` (3.98 MiB): a block-only parse (bypassing inline/linkify/replacements/smartquotes) takes 33.7 ms total, about 0.037 ms/file; a full parse takes 234–257 ms, about 0.26–0.28 ms/file — roughly seven times cheaper for block-only, verified to genuinely skip inline splitting (every inline token's `.children` stays the empty-array default).
+
+One more fact belongs to this theme, found independently while tracing the frontmatter gap: the build runs **two YAML parsers**. `gray-matter@4.0.3` bundles its own nested `js-yaml@3.14.2` (`node_modules/gray-matter/node_modules/js-yaml`), while `builder/data.mjs`, `builder/tbdocs.mjs` and `scripts/check_publish_policy.mjs` import the top-level `js-yaml@4.1.1` directly for `_config.yml`/`_book.yml`. Page frontmatter and site configuration are parsed by two major versions of the same library. See Decisions for you (a).
+
+### Command-line handling
+
+Thirty-six entry points (32 argv readers plus 4 argument-less gates) were inventoried by L1, and the pattern across them is consistent: every tool parses its own flags by hand, and several of the hand-written parsers have already shipped the same class of bug more than once. **L1-1** through **L1-13** catalogue it in full (see Findings); the sharper instances are a positional-option helper reimplemented seven times in four variants, one of which returns `undefined` instead of its stated default on a trailing value flag (**L1-2**); a theme/viewport validator that exists in one script specifically because an unvalidated value once mislabeled a report, and is missing from two siblings that do the same kind of work, one of them the axe-upgrade gate itself (**L1-1**); and a positional-argument detector that fails outright on a boolean flag placed before a path (**L1-3**). The area passes hit the same shape independently: **A5-1** found the identical manually written argv loop, diverged three ways, in eight accessibility and diagram scripts; **A7-5** found three incompatible `opt()`/`die()` shapes inside the compiler harness alone, with a concrete, traced misdiagnosis (a `NaN` timeout produces the wrong error message, not a timeout error); **A10-1** found the same split among the four parsers in `eval/`. The exception is `impexp.mjs`, whose verb table, named exit codes and single usage-error path L1 names as the model to follow. **L1-13** names the reason none of this was caught: nothing tests any tool's own argument handling, anywhere in the repository. See Decisions for you (e) for the proposed convergence.
+
+### Copies that have already diverged
+
+The review's severity rubric reserves R1 for a duplication that has already produced a disagreement, not merely risked one, and a sizeable share of the R1 tier is exactly that: **A4-3**'s link-attribute table is five pairs against the build checker's 21 tags and 26 pairs, and the post-release checker that uses the narrower one already misses seven kinds of link the build-time checker catches; **A3-3**/**L4-6**'s two URL helpers disagree on a forced leading slash, on protocol-relative `//host`, and on null input, in three separately confirmed ways; **A7-1**'s failed-build detector misses two of the five failure shapes its own sibling module lists, so a failed compile can report success; **A8-1**'s attribute-keyword lists already differ (dormant only because the current package cache never exercises the gap); **A10-2**'s three independent frontmatter readers disagree on BOM handling and on type coercion; **L2-1**'s two output-tree exclusion lists are missing the same prefix that a prior fix (`check_tree_fresh.mjs`) already closed once, in a different pair of files; **L2-2**'s two twinBASIC-install finders recognise an install by different files, so a partly unpacked install passes one and fails the other; **L4-10**'s two `logicalLines` implementations differ on BOM handling and on block comments. Every one of these copies has already diverged; two of the divergences (**A8-1**, **A10-2**) happen to change nothing on today's content. Each is presented in full under Findings, tier 1.
+
+### Dead code left by the retired `_diff`/`_triage` tools
+
+Commit `644d6bdb` deleted `_diff.mjs`, `_triage.mjs`, `_sitemap_diff.mjs` and their siblings, and the tree still shows two kinds of trace of them: functions that only they called, and comments that still name them. **A1-4**'s exported `makeTimer` has zero callers anywhere in the repository, while `offline.mjs` keeps a private, byte-identical copy whose own comment cites the deleted tools as the reason it can't import the exported one. **A2-1** (absorbing **A1-5** and **L4-4**) is five more dead paths from the same cause — `writeOfflinePages`, an unread `precomputed` parameter, an unreachable fallback branch, `writeSearchData`, `extractSitemapUrls` — plus a 24-name re-export block in `offline.mjs` with zero importers for any of the 24. **A2-2** (absorbing **A9-10**'s comment half) is five files whose comments still name the deleted tools, one of which (`pdf.mjs`) uses the stale comment to justify an export, `extractImagePaths`, that also has zero callers. **A9-4** and **A9-9** were two more instances of the same shape in `book/lib/`'s pdf-lib shims; both are now moot, resolved by the shim deletion in `90624841`.
+
+### Gate scaffolding and conventions
+
+The gates are individually well built — see Verified sound — but the scaffolding around them repeats itself and has already drifted in one place. **A6-1** (absorbing **L1-11** and **L4-13**) is a self-test accumulator, a report loop, and an `uncaughtException` handler, each copied across three or four gate scripts, with one of the three already re-indenting its output differently from its two siblings. **A6-2** is a genuine three-way disagreement, inside the repository itself, about the history of a past incident (`test.bat`'s own comment tells a different story than `check_gate_lists.mjs`'s header and `Tools.md`, neither of which corroborates `test.bat`'s specific counts) — in the comment that introduces the gate built to stop exactly that kind of drift. **A5-2** and **A6-3** are three tools that break the documented 0/1/2 exit convention (`build_dot_metrics.mjs`, `pick_a11y_sample.mjs`, `convert_em_dash_separators.mjs`); `pick_a11y_sample.mjs` runs in `check.bat` today, so a crash there reads as a coverage gap rather than a crash. **A6-4** is the structural gap decision 6 exists to close: nothing today reads either CI workflow to confirm it runs the same gates as the wrappers, though the two workflows' shared steps are in fact byte-identical, differing only in three already-recorded ways.
+
+### Dependencies and pinning
+
+**A1-2** is an undeclared direct dependency (`picocolors`, imported by `tbdocs.mjs` and `scheduler.mjs` and reachable today only through `puppeteer → cosmiconfig → parse-json → @babel/code-frame`); it works by accident of the lockfile, not by declaration. `book/lib/fast-inflate.mjs` imports `pako` the same way, through `pdf-lib`. Separately, `docs/Documentation/Builder.md`'s own "Dependencies" section is stale in three ways at once — it omits `recheck` entirely, states `wasm-graphviz` as `^1.21` when the installed version is `^1.29.1`, and says `axe-core` is the only exact pin when four packages are pinned exactly (`axe-core`, `pdf-lib`, `puppeteer`, `recheck`) — a repeat of a drift the previous review already fixed once, in `74b3395`. Each individual pin has its own recorded technical reason; nothing states the general policy behind the choice of exact versus caret, so a caret on a dependency whose output the build depends on reads as an oversight rather than a decision. See Decisions for you (g). The pdf-lib shims (**A9-1**, **A9-2**) round out the theme from the workaround side, immediately below.
+
+### Workarounds judged by the three tests
+
+The plan's bar for an acceptable workaround is that it is contained, guarded, and has a stated exit. Two workarounds pass emphatically and are the model for the rest: the axe patch (`scripts/lib/axe-scan.mjs`'s `SOURCE_PATCHES`, with `check_axe_patch_equiv.mjs` as its guard and "until axe ships a modern build" as its stated exit) and the dot-metrics WASM patch (signature match, a read-back check, a real layout check, and a `WeakSet` guard). Against that model, several workarounds found by the passes fall short. The pdf-lib shims (**A9-1**) are contained — one file per shim — but guarded only by an exact version pin, which catches accidental drift and nothing else: no shim asserts anything about the pdf-lib internals it depends on, and there is no equivalence test against stock pdf-lib output anywhere (**A9-2**). The three whole-page HTML rewrites in `render.mjs` and `template.mjs` (**A3-6**) depend on an invariant — code content is always entity-escaped before these run — that holds for three of the four ways text can end up inside ``/`
` and not the fourth (raw, hand-authored HTML, which markdown-it passes through unescaped by design), is not stated at any of the three call sites, and is outside what `check_code_regions.mjs` checks. WIP.Build.md already warns, about a neighbouring case, "Do not rely on that accident"; nothing connects that warning to these three. Wisdom's `parseStaging` (**L3-3**) promises in its header comment that it never silently drops reviewer content, and breaks the promise whenever a bare `---` line sits inside a code fence in a section: the rest of that section's body and its `finding_ids` line are dropped without a word.
+
+---
+
+## Findings
+
+Findings are grouped by severity after the verifiers' corrections, not as first reported. Where a finding was reported by more than one pass, every source ID is kept so it can be traced back to [`REVIEW-TOOLING-fe9ce12b/ledger.md`](REVIEW-TOOLING-fe9ce12b/ledger.md).
+
+### Tier 1 — already diverged, or one ordinary change from it
+
+#### A1-1 — R1, struct
+**The build's own Gantt chart silently drops the Check phase and `vendorAssets` from every build, and its "Other" bucket is dead.**
+
+- Where: `builder/gantt.mjs:39-49` (`mainSections`, only pushes `Seeds`/`Spine`/`Render`/`Write`), `:12` (`COLORS.Other`, unreferenced); `builder/tbdocs.mjs:1270-1282` (`GANTT_SECTION`/`GANTT_SECTION_ORDER` list `Check`); `vendorAssets` (`tbdocs.mjs:539-562`, `runOnMain:true`, no `GANTT_SECTION` entry).
+- Recorded reason: none found; `docs/Documentation/Builder.md:410` states the opposite — that a section-less task falls into a generic "Other" bucket.
+- Cost: reaches the published Build Info page. `checkBook`, `checkReport` and `vendorAssets` durations stretch the chart's time axis with no visible bar to show for it.
+- Fix in place: add `Check` and `Other` to `gantt.mjs`'s `mainSections`; give `vendorAssets` a `GANTT_SECTION` entry.
+- Verify by: the built `gantt.svg` names `checkBook`, `checkReport` and `vendorAssets` after the fix and not before (the byte comparison of the trees excludes this file, because it records timings); correct `Builder.md:410`.
+- Size: S.
+- Verified: confirmed (V1), and checked directly by the orchestrator (see Provenance).
+
+#### A1-2 — R1, hack
+**`picocolors` is imported directly in two files but never declared; it works only because it is an unlisted transitive dependency.**
+
+- Where: `builder/tbdocs.mjs:35`, `builder/scheduler.mjs:5`; reachable today via `puppeteer → cosmiconfig → parse-json → @babel/code-frame` (confirmed in the lockfile).
+- Recorded reason: none.
+- Cost: the day an update stops that chain installing it, every build — local, CI and deploy — stops at import with "Cannot find package 'picocolors'", for a reason unrelated to the colour output that uses it.
+- Fix in place: declare `picocolors` `^1.1.1` directly in `package.json`.
+- Verify by: `npm ls picocolors` before/after; build unaffected.
+- Size: S.
+- Verified: confirmed (V1).
+
+#### A3-1 / L3-4 / L4-7 — R1, dup
+**Two fence-opening predicates 1,550 lines apart in the same file disagree on whether a backtick-fenced block with a backtick in its info string is a valid opener, and a third file, and a fourth function, repeat the same gap.**
+
+- Where: `builder/render.mjs:141-175` `maskCodeRegions` (open test `:148`, no info-string check) vs. `:1704-1730` `stashCodeFences` (`:1713`, CommonMark-correct refusal); `scripts/convert_em_dash_separators.mjs:59-60` `FENCE_OPEN_RE` is byte-for-byte identical to `maskCodeRegions`'s pattern and shares the same gap; `render.mjs:180-204` `maskInlineCode` and `convert_em_dash_separators.mjs:74-99` `splitInlineCode` independently implement the same inline code-span algorithm (confirmed behaviourally equivalent today across 16 edge cases, not yet diverged).
+- Recorded reason: none; the three were written independently.
+- Cost: reproduced directly. A fence opened with `` ```abc`def `` is masked (protected) by `maskCodeRegions` but left exposed to `stashCodeFences`, so a `> [!NOTE]` inside it is rewritten into a live HTML admonition. `check_code_regions.mjs` is structurally blind to this class. Not observed in `docs/` today.
+- Fix in place: one shared, CommonMark-correct fence-opener predicate and one shared inline-code splitter, in the markdown module proposed under Decisions for you (a); both `render.mjs` functions and `convert_em_dash_separators.mjs` migrate onto it.
+- Verify by: the reproduction case above, turned into a probe in `check_code_regions.mjs`; tree comparison.
+- Size: M.
+- Verified: A3-1 confirmed by direct reproduction (V1); L3-4 confirmed, byte-identical regex (V4), which itself notes the merged finding should inherit A3-1's R1 rather than average down; L4-7 confirmed equivalent today (V1).
+
+#### A3-3 / L4-6 — R1, dup
+**Two URL helpers, ported into two files, have diverged in three separately confirmed ways.**
+
+- Where: `builder/seo.mjs:121-148` `absoluteUrl`/`relativeUrl` (config-arg, forces a leading slash at `:145-148`, no space encoding, `isAbsoluteUrl`'s scheme-only regex at `:160-162` misses `//host`) vs. `builder/template.mjs:917-936` (baseurl-arg, leaves a bare value unchanged at `:922`, encodes spaces at `:920`, its `:919` condition explicitly excludes `//host`); non-string/null input also handled differently (`null` vs. `""`).
+- Recorded reason: none.
+- Cost: no call site hits the gap today — this site's `baseurl` is always `""`, which masks the `//host` disagreement — but two independently maintained URL helpers is exactly the shape the rubric reserves for R1.
+- Fix in place: one `builder/url.mjs`.
+- Verify by: every URL in both trees, byte-identical before/after.
+- Size: M.
+- Verified: confirmed, all three disagreements traced directly (V1).
+
+#### A4-3 — R1, dup
+**The only post-release, real-HTTP link checker keeps its own five-pair attribute table instead of using the build checker's 21-tag, 26-pair table, and already misses seven kinds of link the build-time checker catches.**
+
+- Where: `scripts/crawl_check.mjs:91-108` (own if/else chain: `a`/`href`, `link`/`href`, `img`/`src`, `script`/`src`, `iframe`/`src`) vs. `builder/link-check.mjs:38-60` `LINK_ATTR_TABLE` (21 tags, 26 flattened pairs, 9 distinct attribute names — corrected from an initial count of 20).
+- Recorded reason: none found.
+- Cost: live, on the deployed site — `crawl_check.mjs` never follows `srcset`, `poster`, `cite`, `formaction`, `action`, `data` or `longdesc` links, which the build-time checker does cover.
+- Fix in place: export `LINK_ATTR_TABLE` (and `splitSrcset`) from `link-check.mjs`, and drive `crawl_check.mjs`'s tag handler from it, keeping its own HTTP concerns (concurrency, redirects, HEAD-then-GET). This is separate from the owner's decision on the two filesystem link checkers, which does not cover `crawl_check.mjs`.
+- Verify by: `crawl_check.mjs`'s findings before/after over a page using one of the missed attributes.
+- Size: S.
+- Verified: corrected (V2) — table size corrected to 21/26; severity raised from the pass's original R2 to R1 because the gap is live.
+
+#### A5-1 — R1, dup/conv
+**Argv-parsing is hand-written in eight scripts and has already diverged three ways.**
+
+- Where: `scripts/check_a11y.mjs:63-79` (no `--help` case, falls to "unknown arg", exit 2) vs. `scripts/pick_a11y_sample.mjs:144-147` and `scripts/sweep_a11y.mjs:106-110` (stderr, exit 0); `scripts/check_dot_fit.mjs:47` and `scripts/build_dot_metrics.mjs:55` (bare `.includes()`, silently ignores a typo'd flag); `scripts/check_a11y_fingerprint.mjs:115-121` and `scripts/check_axe_patch_equiv.mjs:43-45` (stdout, exit 0); `scripts/check_tree_fresh.mjs:76-90,82` (a third stdout example, in scope but left out of the pass's original count of seven).
+- Recorded reason: none.
+- Cost: a typo'd flag does nothing, silently, in two scripts; feeds the wider L1 convention gap.
+- Fix in place: the shared argv module proposed under command-line handling (Decisions for you (e)).
+- Verify by: each script's current flags and exit codes, preserved exactly.
+- Size: L (the fix is the shared module itself).
+- Verified: corrected (V2) — count raised from seven scripts to eight.
+
+#### A6-1 / L1-11 / L4-13 — R1, dup
+**A self-test accumulator, its report loop, an `uncaughtException` crash handler, and a `withBaseline` test fixture are each copied across three or four gate scripts, and have already diverged once.**
+
+- Where: `check_page_baseline.mjs:38-44,128-139`; `check_book_coverage.mjs:81-87,158-170`; `check_symbol_index.mjs:40-45,361-370`; the crash handler additionally byte-identical in `check_publish_policy.mjs:29`; missing entirely from `check_tb_registry.mjs`, whose only error path ends at exit 1 for both a crash and a real failure; `withBaseline` duplicated between `check_page_baseline.mjs:46-55` and `check_symbol_index.mjs:314-323`.
+- Recorded reason: none.
+- Cost: `check_book_coverage.mjs:162` is the only one of the three that re-indents a multi-line detail string — already diverged. A shared fix must keep probes unconditional and take the probe-failure exit code as a parameter, since two other gates (`check_gate_lists`, `check_regex_safety`) use probes to guard a separate sweep rather than as the whole gate.
+- Fix in place: one shared self-test/report/crash-handler helper, and one shared `withBaseline`.
+- Verify by: each gate's current probe count preserved (18, 6, 11, 12, 46, 22, 12, per the Sound tally).
+- Size: M.
+- Verified: confirmed (V2).
+
+#### A6-2 — R1, dup
+**Three places in the repository disagree about the history of the same past incident, and the odd one out is the comment that introduces the gate built to stop exactly that kind of drift.**
+
+- Where: `test.bat:34-43` (frames it as a fix that introduced three more wrong numbers) vs. `check_gate_lists.mjs`'s header (~30-44) and `Tools.md:435,437` (agree with each other: the numbers were already wrong in the commit that shipped the gate green). `test.bat`'s specific counts are corroborated by neither other account.
+- Recorded reason: three competing recorded reasons is itself the finding.
+- Cost: a maintainer reading `test.bat`'s comment gets an uncorroborated story.
+- Fix in place: trim `test.bat`'s comment to a citation into `check_gate_lists.mjs`'s header, matching the file's other seven comments.
+- Verify by: re-read after the edit; no behaviour change.
+- Size: S.
+- Verified: confirmed, the three-way disagreement traced exactly (V2).
+
+#### A7-1 — R1, dup
+**`tbrun`'s failed-build detector matches 3 of the 5 failure shapes its own sibling module lists, and still misses the two that matter.**
+
+- Where: `scripts/tbrun.mjs:327` (`/^\[(BUILD\]\s+failed|LINKER\]\s+FAILED)\b/i`) vs. `scripts/lib/tb-ide.mjs:730-734` `BUILD_FAILED` (5 shapes).
+- Recorded reason: the round-8 incident that motivated `tb-ide.mjs`'s own list is recorded; `tbrun.mjs`'s regex was never brought into line with it.
+- Cost: a build that fails with `"[BUILD] ERROR"` or `"[LINKER] compilation (codegen) error"` is reported by `tbrun` as a success.
+- Fix in place: export `tb-ide.mjs`'s `BUILD_FAILED` and use it from `tbrun.mjs`.
+- Verify by: a probe project that fails each of the 5 ways; `tbrun` exits non-zero for all 5.
+- Size: S.
+- Verified: corrected (V3) — the orchestrator first reported "2 of 5"; tbrun's `/i` flag catches both listed casings of the BUILD shape, so it is 3 of 5. The consequence — the two missed shapes still produce a false success — is unchanged.
+
+#### A8-1 — R1, dup
+**`census_attributes.mjs`'s modifier-keyword list already disagrees with the two other places that classify the same twinBASIC source, and is missing `Overridable`.**
+
+- Where: `builder/census_attributes.mjs` `MODS` (`:138-148`, also missing `Iterator`, `Dim`) vs. `scripts/lib/twin-api.mjs:123-125` and `scripts/lib/tb-fences.mjs:340-343` (which additionally has `Async`, `PtrSafe`, and others).
+- Recorded reason: none — three independently maintained keyword lists.
+- Cost: originally reported as a live misclassification (`"Public Overridable Sub"` falling through to the variable-declaration path). Traced against the real BETA 983 package cache: 31 `Overridable` `Sub`/`Function`/`Property` lines exist there, and zero have an attribute, so the divergence is real but dormant today, not live. Two further divergences (`blankStrings`'s missing `""` escape handling, and a per-line vs. cross-line block-comment state) are structurally confirmed, not shown to misfire on real source.
+- Fix in place: one shared keyword-classifier module, paired with the ride-along probes **A8-4** also calls for.
+- Verify by: re-run against the exported package tree; census output unchanged except newly recognised `Overridable` members.
+- Size: M.
+- Verified: confirmed as an already-diverged keyword list (V3); the impact claim corrected — see Where the passes were wrong.
+
+#### A10-2 — R1, dup
+**Three independent frontmatter readers — two in wisdom, one in the build — disagree on BOM handling and on type coercion.**
+
+- Where: `wisdom/extract/sitemap.mjs:69-87` `parseFrontmatter` (no BOM strip) vs. `builder/discover.mjs:94-117` (`stripBom`, added after the AppGlobalClassObject incident) and `wisdom/extract/prep.mjs:343-388` (adds array and boolean/number coercion sitemap.mjs's version lacks).
+- Recorded reason: none for the BOM gap.
+- Cost: dormant — a scan of all 912 `docs/` markdown files at `fe9ce12b` confirmed zero begin with a BOM today. `WIP.md` itself documents that Windows editors add one "without being asked."
+- Fix in place: route all three through the shared frontmatter parser proposed under Decisions for you (a).
+- Verify by: every page's parsed frontmatter, deep-equal before/after.
+- Size: M.
+- Verified: confirmed, dormancy confirmed by direct scan rather than inference (V2); independent of the "different dialects" point below (see Where the passes were wrong).
+
+#### L1-1 — R1, dup
+**A theme/viewport validator exists in one accessibility script, built specifically after an unvalidated value once mislabelled a report, and is missing from two siblings that do the identical kind of dispatch — one of which is the axe-upgrade gate.**
+
+- Where: `scripts/check_a11y.mjs:82-95` `pick()` vs. `scripts/sweep_a11y.mjs:120-121` and `scripts/check_a11y_fingerprint.mjs:134-135` (both bare `themeArg === "both" ? THEMES : [themeArg]`).
+- Recorded reason: the fix exists in one place; it was never propagated.
+- Cost: traced end to end — `axe-scan.mjs`'s `buildMatrix` builds the report label straight from the unvalidated string, and `gotoPage` applies it with no validation either; the dark CSS is scoped to `[data-theme=dark]` only, so an unrecognised value silently renders light while the report claims otherwise, reproducing the original bug in two tools that never received the fix.
+- Fix in place: hoist `pick()` into `axe-scan.mjs`, or validate inside `buildMatrix`.
+- Verify by: `--theme drak` fails loudly in all three tools.
+- Size: S.
+- Verified: confirmed, full consequence traced (V2).
+
+#### L1-2 — R1, dup
+**The flag-value helper `opt()` is reimplemented seven times in four variants, and the most common variant returns `undefined`, not its stated default, for a trailing value flag.**
+
+- Where: (A) returns `undefined` — `census_attributes.mjs:86`, `check_examples.mjs:93`, `tbbuild.mjs:61` (3 copies); (B) falls back to the default, also for an explicit empty value — `addin_test.mjs:68`, `tbrun.mjs:105-108` (2); (C) one-arg, no default — `build_package_api.mjs:56`; (D) inline/unnamed — `check_publish_policy.mjs:31-33`. `NaN` confirmed at `tbbuild.mjs:68,70` (port, timeout) and `check_examples.mjs:102-104` (jobs, port, batch).
+- Recorded reason: none.
+- Cost: a trailing `--port` or `--timeout` silently becomes `NaN` instead of the documented default.
+- Fix in place: the shared `numberOption()` in the cli.mjs module (Decisions for you (e)).
+- Verify by: a trailing value flag now errors instead of becoming `NaN`, for every affected tool.
+- Size: M.
+- Verified: confirmed, all 7 copies found and classified (V2).
+
+#### L1-3 — R1, dup/hack
+**Positional-argument detection disagrees between two harness tools, and one fails outright on a boolean flag placed before the positional argument.**
+
+- Where: `scripts/tbrun.mjs:112-116` (explicit `VALUE_FLAGS` list) vs. `scripts/tbbuild.mjs:62` (order-dependent: rejects a token whose *predecessor* starts with `--`, with no distinction between a boolean switch and a value-taking flag).
+- Recorded reason: none.
+- Cost: `tbbuild --keep proj` reports a usage error, because `--keep`'s own `--` prefix rejects the token after it.
+- Fix in place: the shared flag registry in the cli.mjs module.
+- Verify by: `tbbuild --keep proj` succeeds after the fix.
+- Size: S.
+- Verified: confirmed, traced exactly (V2, by trace rather than execution).
+
+#### L1-4 — R1, hack
+**`tbdocs.mjs` throws on an unknown argument, and the resulting exit code is the same bit already documented elsewhere in the same function as meaning "link check failed."**
+
+- Where: `builder/tbdocs.mjs:189-191` (throw), `:1616-1635` (`main()`/`.catch`, uniform exit 1), `:1568-1570` (the link-check bit-0 meaning).
+- Recorded reason: none.
+- Cost: a parse error and a real link-check failure are indistinguishable by exit code, in the flagship, CI-invoked tool.
+- Fix in place: exit 2 for parse errors, distinct from the build-failure bitmask.
+- Verify by: a malformed flag exits 2; a real link-check failure still sets bit 0.
+- Size: S.
+- Verified: confirmed, full trace (V2).
+
+#### L2-1 — R1, dup
+**Two more hand-kept lists of "which folders are output trees" repeat a defect class `check_tree_fresh.mjs` was already fixed for once, and one of the two has a traced live consequence.**
+
+- Where: `builder/serve.mjs:138` `IGNORED_PREFIXES` (no `_site-basepath*` entry — a `--dest docs/_site-basepath` run mid-`serve.bat` is not filtered, causing a spurious rebuild); `eval/build_corpus.mjs:59-71` `EXCLUDED_PATHS` (lists `docs/_site-basepath` explicitly, but its prefix test does not match `-offline`/`-pdf` variants, since the next character is `-` not `/`). `60bb6f5` edited `serve.mjs`'s list the same day `scripts/lib/markdown-files.mjs`'s general `isOutputTree` was added, without switching to it.
+- Recorded reason: none.
+- Cost: a spurious rebuild while `serve.bat` is running; incomplete exclusion in the evaluator's corpus builder.
+- Fix in place: both use `isOutputTree`.
+- Verify by: a `--dest docs/_site-basepath` build during `serve.bat` produces no spurious rebuild; `build_corpus.mjs` excludes all three basepath variants.
+- Size: S.
+- Verified: confirmed, both halves (V1).
+
+#### L2-2 — R1, dup
+**`census_attributes.mjs` reimplements the twinBASIC-install finder that a shared module's own header exists specifically to prevent a private copy of, and the two have already diverged.**
+
+- Where: `census_attributes.mjs:99-119` `findInstall` (validates via a `packages/` directory) vs. `scripts/lib/tb-install.mjs:19-35` `findIde` (validates via `twinBASIC.exe`); the home-directory fallback (`os.homedir()` when `USERPROFILE` is unset) exists only in the private copy.
+- Recorded reason: `tb-install.mjs`'s own header states the shared-module rationale this private copy undercuts.
+- Cost: the two recognise an install by different files, so a partly unpacked install passes one check and fails the other; they also disagree when `USERPROFILE` is unset.
+- Fix in place: `census_attributes.mjs` uses `findIde`, keeping its `packages/` validation; fold the home-directory fallback into `tb-install.mjs` itself.
+- Verify by: both functions agree on the same Desktop layout, including an unset `USERPROFILE`.
+- Size: S.
+- Verified: confirmed (V3).
+
+#### L3-1 — R1, dup
+**`check_a11y.mjs` launches its browser with no try/finally, and the exact exception path it leaks on is already named in a comment one line above the unguarded code.**
+
+- Where: `scripts/check_a11y.mjs:167` (launch), `:218` (`browser.close()`, success path only), `:244-247` (`main().catch`, exits 2 without closing); `:229`'s own comment ("though PAGE_STATES throws before it gets that far"); every sibling using the same library guards it (`check_a11y_fingerprint.mjs:234,238-248`; `sweep_a11y.mjs:209,214-274`; `check_axe_patch_equiv.mjs:99,104-116`).
+- Recorded reason: none for the missing guard; the throw path is independently confirmed real — `axe-scan.mjs`'s `PAGE_STATES` appliers throw by design on a failed assertion.
+- Cost: any `PAGE_STATES` assertion failure leaves Chromium running for the rest of the job on Linux, which is where CI runs this gate. Whether Windows reaps it with its parent was not tested.
+- Fix in place: a shared `withBrowser(fn)` helper in `axe-scan.mjs`, adopted by all four scripts.
+- Verify by: a deliberately failing assertion leaves no child process running, before/after.
+- Size: S.
+- Verified: confirmed by code reading; no browser was actually started to reproduce it (V4).
+
+#### L3-3 — R1, hack
+**Wisdom's section splitter contradicts its own header comment — "we never silently drop reviewer content" — by dropping exactly that, with no fence awareness of any kind.**
+
+- Where: `wisdom/extract/merger.mjs:114-129` `parseStaging` (splits on any line exactly equal to `---`); `:162-168` `parseSection` (returns `null` for a chunk not starting `"## "`); `:147-148`/`152-153` (the caller drops a `null` result silently); `:111-112` (the contradicted comment).
+- Recorded reason: the comment states the opposite of what the code does.
+- Cost: reproduced directly on a synthetic file — a bare `---` inside a fenced block does not make the whole section disappear, but it does silently cut off the rest of that section's own body and its entire metadata line (`finding_ids`/`confidence`): the tail chunk does not start with `## `, so it is dropped. Ground truth on the real, 15,553-line committed `staging.md`: 1,160 bare `---` lines produce 1,160 chunks, and all 1,160 start with `"## "` — zero dropped today, so the defect is dormant on the current file (a separate, real content slip at the same file's lines 14245–14246 is discussed under Where the passes were wrong and Decisions for you (f)).
+- Fix in place: parse `staging.md` through the shared markdown module's fence-aware section splitter (Decisions for you (a)) instead of a bare line-equality test.
+- Verify by: replay of the real `staging.md`, zero dropped or mis-paired sections, before and after.
+- Size: M.
+- Verified: confirmed, with the nuance above (V4).
+
+#### L4-10 — R1, dup
+**Twin-BASIC source-line splitting is implemented twice, and the two have already diverged on BOM handling and on block comments.**
+
+- Where: `scripts/lib/tb-fences.mjs:423-445` `logicalLines` (private) vs. `scripts/lib/twin-api.mjs:51-86` `logicalLines` (exported). Twin-api strips a leading BOM and threads a multi-line `/* */` comment across physical lines; tb-fences does neither — it has **no `/* */` handling at all**.
+- Recorded reason: tb-fences's quote-aware comment stripping (its own stated reason, `:423`) is preserved in twin-api's version too — confirmed directly, both are quote-aware for the same structural reason. Not a drop-in swap, though: twin-api keeps blank lines (tb-fences drops them), uses 1-based `line` vs. tb-fences's 0-based `at`, never trims (tb-fences does), and recognises `Rem` (tb-fences does not) — four behaviour changes a real replacement must absorb deliberately.
+- Cost: a `/* */` comment spanning two physical lines is not stripped by tb-fences at all; its trailing declaration is left unreachable by tb-fences' own `^`-anchored classifier regexes.
+- Fix in place: consolidate on one `logicalLines`, absorbing the four behaviour differences deliberately, with a probe for a `/* */` span.
+- Verify by: `check_examples.mjs`'s 74-probe `runProbes` suite unchanged; a new probe for a block comment.
+- Size: M.
+- Verified: confirmed, including the full replacement-question analysis run for exactly this purpose (V3).
+
+### Tier 2 — makes every change in the area slower or riskier
+
+#### A1-3 / L4-1 — R2, dup
+**A "run handler, time it, report" block is repeated three times in the same file.** Where: `builder/cpu-worker.mjs:360-377, 423-440, 469-486` (a fourth block at `:497-540` is genuinely different — a distinct message shape and a deliberate SAB-then-postMessage ordering that closes a race, related to **A1-7**). Recorded reason: none. Cost: a fix must be applied by hand three times. Fix in place: one shared `runTimed()` helper for the three identical paths. Verify by: tree comparison. Size: M. Verified: confirmed, including the fourth path's genuine difference (V1).
+
+#### A1-4 — R2, dead
+**An exported timer helper has zero callers anywhere, and the private copy that is actually used justifies itself by citing tools that no longer exist.** Where: `builder/tbdocs.mjs:196-209` `makeTimer` (exported, unused) vs. `builder/offline.mjs:98-111` (private, identical, called at `:151`); the comment at `:95-97` cites the tools `644d6bdb` deleted. Recorded reason: expired — the cited consumers are gone. Cost: a future editor cannot tell which copy is live. Fix in place: delete the unused export, or have `offline.mjs` import the one copy; correct the comment. Verify by: grep for callers; tree comparison. Size: S. Verified: confirmed (V1). One of the five members of the **L4-8** low-level-util cluster (see Tier 3).
+
+#### A1-6 — R2, conv
+**Exit-code bits are set at seven sites with bare magic-number literals, and bit 0 is set independently by five of them.** Where: `builder/tbdocs.mjs:560, 1469, 1470, 1568-1573, 1598, 1610`. Recorded reason: none. Cost: today's five bit-0 sites are safe only because they execute in a fixed source order before the OR'd sites; nothing enforces that order, so a reordering silently clobbers a bit. Fix in place: a `failBuild(bit)` helper with named constants. Verify by: tree comparison; a deliberately reordered defect still fails loudly. Size: M. Verified: confirmed (V1).
+
+#### A2-1 / A1-5 / L4-4 — R2, dead
+**Five dead code paths from the retired `_diff`/`_triage` tools remain live in `offline.mjs`, plus a 24-name re-export block with zero importers.** Where: `builder/offline.mjs:244-289` `writeOfflinePages` (zero callers, a clone of the live pre-pass at `cpu-worker.mjs:183-201`), `:117` (unread `precomputed` param), `:207,213,359-394` (`buildSitePaths`'s unreachable fallback — `tbdocs.mjs:705-706` always sets `sitePaths`), `builder/search.mjs:18-24` `writeSearchData`, `builder/sitemap.mjs:70-74` `extractSitemapUrls`, `offline.mjs:55-88` (re-export block, confirmed zero importers for every one of the 24 names). Recorded reason: none live. Cost: dead code that looks essential to an editor who doesn't check. Fix in place: delete all five paths and the re-export block. Verify by: repo-wide grep for callers = 0; tree comparison. Size: M. Verified: confirmed, severity corrected from the pass's own hedged R1 to R2, since nothing here has actually diverged, only gone unreachable (V1).
+
+#### A2-2 / A9-10 — R2, dead
+**Comments in five files still name tools deleted in `644d6bdb`, and one uses the stale reference to justify a dead export.** Where: `offline-rewrite.mjs:411`, `search.mjs:65-66`, `sitemap.mjs:67-68,97`, `redirects.mjs:35-36`, `pdf.mjs:6-9,99-107` (justifies `extractImagePaths`, `pdf.mjs:146-158`, zero callers; `deriveBookOutputs`, by contrast, is live, called from `writePdf`). Recorded reason: describes tools that no longer exist. Cost: a stale comment claiming a real consumer is what lets dead code survive review. Fix in place: delete the comments and the dead export. Verify by: grep for the deleted tool names outside git history. Size: S. Verified: confirmed (V1); zero callers for `extractImagePaths` confirmed repo-wide.
+
+#### A2-3 / L2-4 / L4-5 — R2, dup
+**A baseline reader is byte-identical in two files, and the six-branch drift-guard state machine around it is retyped rather than shared; the accompanying hand-written writer has its own small divergence.** Where: `page-baseline.mjs:83-90` vs. `symbol-baseline.mjs:45-52` (`readBaseline`); `checkPageBaseline:117-176` vs. `checkSymbolBaseline:75-125` (the six-branch structure); `symbol-baseline.mjs:55-58` `writeBaseline`. Recorded reason: `WIP.Build.md` ("the page-count guard's shape applied to URLs") explains why the two look alike, not why the branch bodies weren't factored together. Cost: any fix to the drift-guard logic must be made twice by hand. Fix in place: one shared baseline-drift-guard module; `writeBaseline` delegates to `JSON.stringify(x, null, 2) + "\n"`. Verify by: byte-identical baseline files before/after; the gate still fails on reintroduced drift. Size: M. Verified: confirmed (V1). Conflict resolved: `writeBaseline` is byte-identical to `JSON.stringify(...,null,2)+"\n"` for every non-empty list, confirmed by execution — it differs only on an empty list (`[\n\n  ]` vs. `[]`), an accidental artifact rather than the "one URL per line" design L4 first read into it.
+
+#### A2-4 / A3-5 (escapeRegExp) — R2, dup
+**A regex-escaping helper is defined identically in three files, and a fourth tool had to be specially built to tolerate the duplication.** Where: `render.mjs:2235-2237`, `offline-rewrite.mjs:239-241` (exported), `book.mjs:212-214` `escapeRegExpBook`. Recorded reason: none for the duplication; `scripts/lib/regex-fold.mjs:58-63` explicitly recognises the escaper "by shape rather than by name" because of these exact copies — evidence for the finding, not a defence of it. Cost: a fourth copy is one keystroke away. Fix in place: one exported `escapeRegExp`. Verify by: tree comparison. Size: S. Verified: confirmed (V1).
+
+#### A2-7 — R2, dup
+**An ASCII-only, NBSP-preserving whitespace trim is implemented twice, with two different techniques, repeating the shape of a whitespace-in-code defect that has already shipped once.** Where: `compress.mjs:73-84` `collapseWhitespace` (regex-based) vs. `search.mjs:251-261` `stripAsciiWhitespace`/`isAsciiWs` (charCode-scan-based); both independently preserve NBSP. Recorded reason: none for the duplication. Cost: `WIP.Build.md` documents a real, named prior defect in exactly this area of `compress.mjs` ("Whitespace inside inline code is content"); two implementations double the chance of a second one. Fix in place: one shared whitespace-collapse helper. Verify by: tree comparison (search index and compressed HTML unchanged). Size: S. Verified: confirmed (V1).
+
+#### A3-2 / L3-5 — R2, dup
+**Seven HTML-escaper copies exist across two classes, "escapeHtml" collides three ways as a name across the build, and one function mixes both classes for sibling token types.** Where: minimal (`&<>`) — `render.mjs:2230-2233` `escapeHtmlMinimal`, `highlight.mjs:251-254` `escapeHtml` (recorded reason: matches Rouge, which escapes only `&<>`), `gantt.mjs:213` `esc`; full (`&<>"'`) — `render.mjs:2225-2228` `escapeHtml`, `template.mjs:993-998` `escText` and `:999-1001` `escAttr` (byte-identical to `escText` under a different exported name), `sitemap.mjs:106-113` `xmlEscape`; `render.mjs:1437-1450` `headingTocHtml` uses the full escaper for `text` tokens and the minimal one for `code_inline` tokens, within the same function. Recorded reason: `highlight.mjs`'s own 3-character choice is explained (Rouge parity); `render.mjs`'s internal mixing is not. Cost: dormant (no TOC'd heading has an apostrophe today), but a maintainer grepping for "escapeHtml" can pick the wrong module's copy and silently under-escape. Fix in place: one minimal and one full escaper in a shared module; `headingTocHtml` escapes all inline-token text consistently. Verify by: TOC rendering unchanged for the existing corpus; a probe heading with an apostrophe. Size: S. Verified: confirmed — A3-2 narrowed to `render.mjs`'s internal mixing, since `highlight.mjs`'s own choice is separately justified (V1); L3-5's broader catalogue, including the `escText`/`escAttr` duplicate, confirmed independently (V4).
+
+#### A3-6 — R2, hack
+**Three whole-page HTML rewrites have no code guard and depend on an invariant that is true for three of four ways text can reach ``/`
`, and false for the fourth.** Where: `render.mjs:74-80` `padEmptyCells`, `:351-354` `normaliseVoidTags`, `template.mjs:714-727` `injectAnchorHeadings` (its `HEADING_REGEX` at `:694` has no ``/`
` leading alternative). Recorded reason: none at the three sites; `WIP.Build.md`'s "Never rewrite markdown source without knowing what is code" section never mentions these three, as compliant examples or as a stated exception. Cost: the fourth path — a raw, hand-authored `
` or heading tag in markdown source, structurally open via `html: true` and never overridden by any `md.renderer.rules` assignment — lets text end up in ``/`
` unescaped, though `git grep` across all 912 pages finds none today. This codebase has already paid for exactly this class of mistake once, in `book.mjs`'s chapter-transform rewrites, whose incident report concludes "do not rely on that accident." Fix in place: state the invariant, and extend it with the code-guarded pattern already used elsewhere (`replaceOutsideCode`, or the ``/`
` leading-alternation shape), or a probe. Verify by: `check_code_regions.mjs` extended to the post-render chain. Size: M. Verified: confirmed, with a conflict ruled — L3 had called these three "sound" on the reasoning that every code path escapes first; true for three of four paths, not the fourth. Both readings are partly right; A3-6 stands as written (V4).
+
+#### A3-7 — R2, struct
+**One template module holds a date formatter, URL helpers, and a fourth, independent escape-helper implementation, beyond its own templating job.** Where: `template.mjs:940-989` (strftime tables and `formatDate`/`parseDate`), `:917-936` (URL helpers), `:991-998` (a fourth escape-helper block, under its own "§5.15 escape helpers" header). Recorded reason: none. Cost: several unrelated jobs in one module, the rubric's own definition of a struct finding. Fix in place: see Split candidates. Size: L. Verified: confirmed, reinforced by a fourth job V1 found beyond the pass's own citation.
+
+#### A4-2 — R2, dup
+**The structured-findings translation is written twice, though the human-readable half is already shared.** Where: `builder/check.mjs:422-449` `findingsFor` vs. `scripts/check_links.mjs:602-634` `buildFindings` (same broken/forbidden/html/a11y/dupIds/remoteAssets logic, differing only in the path-relativiser and a null-guard); both already import `formatLinkReport`/`formatIntegrityReport` from `link-check.mjs`. Recorded reason: both comments (`check.mjs:410-414`, `check_links.mjs:599-601`) tie the parallel shape to `check_links_diff.mjs` cross-checking two independently written implementations — legitimate when written. Cost: superseded by the user's 2026-09-25 decision to make `check_links.mjs` a thin wrapper over `builder/check.mjs` (decision 5); after that refactor there is one implementation behind two front ends, and both comments need rewriting. Fix in place: as part of the Phase 2 thin-wrapper work already decided, delete the duplicate translation and rewrite both comments. Verify by: `check_links_diff.mjs` continues to cross-check disk-read vs. memory-read over the one implementation. Size: M. Verified: confirmed (V2); the conflict this raises with L4's "deliberately mirrored" reading is resolved under Where the passes were wrong.
+
+#### A5-2 — R2, conv
+**The documented 0/1/2 exit convention is broken in two scripts that crash instead of exiting 1 for a genuine finding.** Where: `docs/Documentation/Extending.md:640-648` (the convention); `build_dot_metrics.mjs` (no catch around its puppeteer work; its only intentional exit path is `exitCode=1` for STALE); `pick_a11y_sample.mjs`'s `discover()` (`:159-169`, an absent tree throws uncaught, ending at the same exit 1 as a real coverage gap at `:310`); contrast `check_dot_fit.mjs:31-34`, which has the guard and cites the convention by name. Recorded reason: the convention exists; these two don't follow it. Cost: `pick_a11y_sample.mjs` runs live in `check.bat:23` — a crash there reads as a coverage gap to anyone triaging a red `check.bat`. Fix in place: wrap both in the convention's try/catch, exit 2 for a genuine crash. Verify by: a deliberately broken tree exits 1, not crashes, for a real gap; exits 2 for an actual crash. Size: S. Verified: confirmed (V2).
+
+#### A5-3 / L4-9 (part 1) — R2, dup
+**Page discovery, tag counting, and a stub ceiling are each implemented independently in two scripts whose outputs must agree with each other.** Where: `pick_a11y_sample.mjs:122,159-169,184` `STUB_CEILING` vs. `sweep_a11y.mjs:78,129-153` `STUB_TAG_CEILING` (both 100 today); `pick_a11y_sample.mjs`'s `--propose` reads the exact JSONL file `sweep_a11y.mjs`'s `--out` default writes. Recorded reason: none. Cost: the two ceilings must stay in step for the join between them to mean anything, and nothing enforces that today. Fix in place: one shared discovery/ceiling module. Verify by: `--propose` output unchanged. Size: S. Verified: confirmed (V2).
+
+#### A5-5 — R2, dup
+**Two scripts hand-build an identical Inter-webfont host page and an identical puppeteer launch, including a flag neither explains, while a third tool's equivalent launch explains its flags but omits this one.** Where: `check_dot_fit.mjs:76-87` and `build_dot_metrics.mjs:57-68` (both `["--no-sandbox", "--disable-dev-shm-usage", "--allow-file-access-from-files"]`, no comment on the third flag); `axe-scan.mjs:258-264` `LAUNCH_ARGS` (explains the first two, silent on the third for the same class of `file://` load); three different repo-root idioms coexist in scope at the same time. Recorded reason: none. Cost: a maintainer changing one launch has to find and update the other by hand. Fix in place: one shared puppeteer-launch helper with the flag documented once. Verify by: tree comparison (diagram fit, metrics unchanged). Size: S. Verified: confirmed (V2).
+
+#### A5-6 / L2-3 — R2, dup
+**A gate re-scans `.dot` sources by hand instead of importing the shared, already-exported-elsewhere walker.** Where: `check_dot_fit.mjs:49-67` `findDotSvgs` vs. `builder/dot.mjs:135-155` `listDotSources` (never exported; only `regenerateDot` calls it internally); five other gates already import the shared `markdown-files.mjs` chain instead of re-implementing the same traversal. Neither function uses `isOutputTree`; their shared, broader `"_"/"."` skip is safe today, since no `.dot` file sits under `_data`/`_sass`/`_App`/`_Images`. Recorded reason: none. Cost: the mirror-fault shape this review is watching for — a second copy that silently stops matching the first if either changes. Fix in place: export `listDotSources`, or a shared `filesUnder(root, {ext, skip})` helper. Verify by: identical file lists before/after. Size: S. Verified: confirmed (V2).
+
+#### A6-3 — R2, conv
+**A tool about to become a pre-commit check has no path from a crash to a distinct exit code, which will matter once it runs inside the pre-commit hook already planned for it.** Where: `convert_em_dash_separators.mjs` has exactly one `process.exit(...)` (`:214`), fed only 0 or 1 from `main()` (`:210`); no `catch`/`uncaughtException` anywhere. `PLAN-10.md:690-693,795-797` both defer exactly that hook to later. Recorded reason: the hook doesn't exist yet, so the gap is dormant by design, not by oversight. Cost: none today; matters the day the hook ships. Fix in place: the shared crash-handler pattern from **A6-1**. Verify by: a crash now exits 2, a real finding still exits 1. Size: S. Verified: confirmed (V2).
+
+#### A6-4 — R2, struct
+**Nothing reads either CI workflow to confirm it runs the same gates as the wrappers, though today the two workflows' shared steps are byte-identical.** Where: `check_gate_lists.mjs` never touches a workflow file (confirmed: zero matches for `.github`/`workflow`/`.yml`); the two workflows' 12 shared gate steps are identical, in the same order, differing only in three already-recorded ways (`checks.yml`'s extra fixture-built link-checker step, the deploy build's extra `--url`/`--baseurl`, and deploy-only steps). Recorded reason: decision 6 already commits to building this gate; the design (a new `scripts/check_ci_workflows.mjs` plus a shared `scripts/lib/gate-roster.mjs`, generalising `check_gate_lists.mjs`'s `gatesFromBat`) is in the ledger but not yet evaluated by a verifier. Cost: nothing today would catch a workflow silently dropping a gate, reordering one around a critical flag, or losing `--check-audit-index`. Fix in place: as designed — compare wrapper roster vs. each workflow, the two workflows against each other, and critical build flags, against an explicit allowlist of the three recorded deltas. Verify by: probes for a missing gate, an extra step, reordering, a missing flag, and confirmation the three allowlisted deltas do not fire. Size: L. Verified: facts confirmed (V2, which did not evaluate the proposed design, per its own scope).
+
+#### A7-2 — R2, struct (kind arguable — reads closer to an unguarded workaround; left as filed)
+**A timeout return value is silently discarded at every call site, reopening the cursor-reset race the function exists to prevent.** Where: `tb-operate.mjs:421-429` `afterReveal` (returns `false` on timeout) vs. its three call sites, `openFile:453`, `setCursor:461`, `select:475` (each a bare `await afterReveal(c);`). Recorded reason: the race is documented (`:401-409`, the measured `"xyz"→"zy"` cursor corruption); the discard that reopens it is not. Cost: silently proceeding after a timeout risks exactly that corruption again. Fix in place: check the return value at all three call sites; retry or fail loudly on timeout. Verify by: `addin-test.bat` green, same lanes. Size: S. Verified: confirmed (V3).
+
+#### A7-3 — R2, dup
+**Two CDP click primitives exist, one minimal and one with scroll, hit-test and retry, and the minimal one is used for the build icon alone.** Where: `tb-ide.mjs:848-861` `clickCenter` (no scrollIntoView, hit-test or retry; exactly 2 call sites, both the build icon: `tb-ide.mjs:757`, `tbrun.mjs:295`) vs. `tb-operate.mjs:79-134` `click` (scrollIntoView, shadow-root-aware hit test, retry loop; used directly in 4 of the 10 `test/addin/*.test.mjs` files — appdata, panes, sample10, sample15 — plus twice inside `tb-operate.mjs` itself). Recorded reason: none. Cost: the build-icon click path skips the retry and hit-test logic every other click path relies on. Fix in place: route `clickCenter`'s two call sites through `click`, or justify why the build icon is exempt. Verify by: `addin-test.bat` green, same lanes. Size: S. Verified: corrected (V3) — usage overstated as "every scenario"; corrected to the exact 4-of-10 file list.
+
+#### A7-4 — R2, dup
+**Two small helpers are byte-identical between a shared module and its one caller, which separately imports ten names from that module.** Where: `tb-registry.mjs:588-590` `alive` and `:462` `norm` (both private/unexported) vs. `addin_test.mjs:105` and `:230` (identical bodies); `addin_test.mjs:60-61` imports 10 names from `tb-registry.mjs` (corrected from an initial count of 9). Recorded reason: none. Cost: none yet — not diverged. Fix in place: export `alive`/`norm` from `tb-registry.mjs`, remove the private copies. Verify by: `addin-test.bat` green. Size: S. Verified: corrected (V3) — import count corrected to 10. A conflict with L4's "no clone region, below the detector's window" reading is resolved in A7-4's favour: both bodies were read directly and found identical.
+
+#### A7-5 — R2, conv
+**The harness's three CLI tools disagree on defaulting behaviour in independently reproducible ways, one of which produces a misleading diagnostic.** Where: `tbbuild.mjs:61-62,68,70,118-122` (`opt()` returns `undefined` on a trailing flag, so `Number(undefined)` is `NaN` for `--port`/`--timeout`; a `NaN` timeout makes `tb-ide.mjs:436`'s polling loop run zero times, producing "the IDE never reported `` as open" instead of a timeout message); `tbrun.mjs:105-108`/`addin_test.mjs:68-69` (substitute the default for a trailing flag *and* for an explicit `""`); `tbbuild.mjs`'s positional finder skips a token after any `--flag` regardless of whether it takes a value, so `tbbuild --json proj` fails (dormant — `check_examples.mjs:602` always puts the path first); `die()` exists in three different shapes across the three tools. Recorded reason: none. Cost: a misconfigured timeout produces the wrong diagnosis. Fix in place: the shared cli.mjs module — `numberOption()` closes the `NaN` hole, a flag registry replaces the order-dependent positional finder. Verify by: the reproduced cases above become fixture cases. Size: M. Verified: confirmed, every sub-claim independently reproduced by script or trace (V3).
+
+#### A7-8 — R2, dup
+**Every one of the ten add-in test scenarios hand-writes its own lane preamble, and six of the ten hand-write a "console lines since a mark" reader four different ways.** Where: all ten `test/addin/*.test.mjs` files (the `HERE`/`HOST`/lane preamble and the skip object); `appdata.test.mjs:33`/`panes.test.mjs:86` `probeLines` (two hard-coded slice offsets), `arch.test.mjs:31`/`reload.test.mjs:37` (two different regex-capture readers), `keys.test.mjs:28` (plain split+trim), `sample10.test.mjs:81` (bare trim, no split). Recorded reason: none. Cost: a change to how the console mark works must be found and fixed in up to four different shapes. Fix in place: a scenario helper, plus one shared `linesSince` beside `readConsole`. Verify by: `addin-test.bat` green, all ten lanes. Size: M. Verified: confirmed (V3).
+
+#### A8-2 — R2, struct
+**A `builder/` module depends on `scripts/`, against the codebase's own stated rule, and a gate has a carve-out that exists only for this one file.** Where: `census_attributes.mjs:79` imports `../scripts/lib/tb-packages.mjs`; `render.mjs:383`'s comment states, verbatim, "builder/ must not depend on scripts/"; `check_tree_fresh.mjs:57-63` `IGNORED_FILES` exists only for `census_attributes.mjs`. Recorded reason: none for the violation itself. Cost: 11 files cite the `builder/census_attributes.mjs` path directly, and 13 cite the bare filename (corrected — the pass's original ten-item list wrongly included `WIP.Build.md`, which only cites the bare filename, and omitted `scripts/build_package_api.mjs:12` and `scripts/lib/tb-fences.mjs:334,349`, which cite the literal path); every one needs updating on a move. Fix in place: move `census_attributes.mjs` to `scripts/`, beside `build_package_api.mjs` (already scheduled in Phase 1 of the plan). Verify by: tree comparison; all citations updated; `check_tree_fresh.mjs`'s carve-out removed. Size: M. Verified: corrected (V3) — citation count and list corrected.
+
+#### A8-4 — R2, struct
+**Two independent keyword/phrase classifiers, both with a documented history of silent misparses, have no fixture-based regression test.** Where: `census_attributes.mjs` (no `selftest`/`node:test`/assertion anywhere; its own header at `:42-67` names a 14-site Interface-member loss and, at `:150-152`, a 368-Declare misparse) and `gen_attribute_probes.mjs`'s `parseTargets` (`:961-978`, its own comment at `:946-947` names a plural-matching bug it already shipped). Recorded reason: both histories are recorded; neither has a test that would have caught them by construction. Cost: the next silent misparse ships the same way the last ones did. Fix in place: a shared "labelled-keyword-list classifier plus ride-along probes" pattern, covering both (paired with **A8-1**'s fix). Verify by: the probes themselves, run in `test.bat`. Size: M. Verified: confirmed (V3).
+
+#### A9-1 — R2, hack
+**The pdf-lib shims are contained but not guarded: only an exact version pin protects them, which catches accidental drift and nothing else.** Where: all 13 production shim files under `book/lib/` (e.g. `fast-array-onebuf.mjs`, `fast-sync-load.mjs`); each has only an idempotency guard (`__xInstalled`-style), never a check of what it overwrites; `parallel-deflate.mjs:50`'s `PDFStreamWriter` subclass has no guard of any kind; `package.json:20` pins `pdf-lib` at exactly `1.17.1`. Recorded reason: `perf/notes/08-pdf-lib.md:1751-1757` states the pin exists "so a stray `npm update` can't silently swap upstream" — covers accidental drift only, exactly as claimed; pdf-lib's upstream is genuinely abandoned (the `@cantoo` fork was evaluated and rejected, `08-pdf-lib.md:5048-5061`), which is why this is R2 rather than R1. Cost: a deliberate future version bump, or a mistaken hand-edit to a shim, fails silently. Fix in place: load-time shape assertions per shim, modelled on `axe-scan.mjs`'s `SOURCE_PATCHES` counts. Verify by: a deliberately wrong shape throws by name. Size: M. Verified: confirmed — matches the rubric's un-met "guarded" test exactly (V3).
+
+#### A9-2 — R2, struct
+**No test anywhere compares the shimmed output against stock pdf-lib.** Where: no test file exists under `book/` at all; the only comparisons are one-off, manual notes in `perf/notes/08-pdf-lib.md`. Recorded reason: none. Cost: a shim regression would only be caught by visual inspection of the rendered PDF. Fix in place: `check_pdf_shims_equiv.mjs`, modelled on `check_axe_patch_equiv.mjs` (stock pdf-lib run in a child process); would become a new `test.bat` gate. Verify by: the new gate, run against a deliberately broken shim. Size: M. Verified: confirmed, no test file exists anywhere under `book/` (V3). See Decisions for you (c).
+
+#### A9-3 — R2, dup
+**Two of six helpers shared between the array and dict "onebuf" implementations are identical apart from naming; the other four are similarly shaped but not copies.** Where: `fast-array-onebuf.mjs` vs. `fast-dict-onebuf.mjs` — `_registerContext` and `_appendArray` are identical bar internal names; `pack`, `_cow`, `_makeFromRange`, `_makeFromAppend` differ by real bit-packing and subclass-dispatch logic dict needs and array does not. Recorded reason: `fast-array-onebuf.mjs:36-39` names only the singleton-context mechanism, leaving `_appendArray`'s exact duplication unaddressed. Cost: a fix to the two identical helpers must be applied twice. Fix in place: `book/lib/onebuf-range.mjs`, a factory that parametrises over the subclass-dispatch and gap-mask logic the two genuinely need differently — not a naive single-body extraction. Verify by: the book pipeline's oracle (page count, outline, extracted text unchanged). Size: M. Verified: confirmed, with the "identical" claim narrowed to exactly two of the six helpers (V3).
+
+#### A9-6 — R2, struct — Resolved in `90624841`
+**Four A/B-baseline shims lived in `book/lib/` but were loaded only by `perf/`.** Where: `fast-refs`, `fast-dict-array`, `fast-dict-iter`, `fast-parse-dict`; `render-book.mjs:56-58` records them as baselines, not their location. Recorded reason: needed the user's decision, since it is the converse of decision 1 (moving code the other direction, from `book/` toward `perf/`). Cost: production code (`book/lib/`) held code nothing in production loaded. Resolution: the user approved deleting the four rather than moving them, since `perf/`'s own measurements against them are already recorded in `perf/notes/08-pdf-lib.md` and git keeps the code. Verified: confirmed at pass time (V1, via the deletion decision's own cross-check of importers).
+
+#### A9-7 — R2, dup
+**A safety-critical code/pre guard alternation is re-typed by hand four times, including twice in the same file, with no gate tying the copies together.** Where: `book.mjs:210` `CODE_OR_PRE_BOOK` and `:228-229` `IMG_SRC_RE_BOOK` (same file); `pdf.mjs:143-144` `IMG_SRC_RE` (byte-identical to `book.mjs`'s); `offline-rewrite.mjs:299` `HTML_COMBINED_RE` (opens with the identical alternation before diverging). Recorded reason: none. Cost: this is the exact guard shape `WIP.Build.md` prescribes for a whole-page HTML rewrite; a fix to it — say, a missing HTML5 void-element case — must be found and applied in four places. Fix in place: one exported fragment, composed into each regex. Verify by: `book.html`, the PDF and the offline HTML, byte-identical before/after. Size: S. Verified: confirmed (V3).
+
+#### A9-8 / A2-6 — R2, dup
+**A URL-normalising helper is byte-identical in two files, and the comment justifying the private copy describes a plugin architecture this codebase no longer has.** Where: `book.mjs:303-307` `normalizeBaseurl` vs. `offline-rewrite.mjs:232-236` (exported); `book.mjs:300-302`'s comment cites "book-href-rewrite.rb keeps its own copy... so plugins are independent" — the old Ruby/Jekyll architecture. Recorded reason: expired. `book.mjs` already imports two sibling `builder/` modules (`compressHtml` from `compress.mjs`, `loadData` from `data.mjs`); nothing prevents it from importing `normalizeBaseurl` the same way. The comment is also wrong about the function's current location (`offline-rewrite.mjs`, not `offline.mjs`). Cost: a fix to URL normalisation must be applied twice. Fix in place: `book.mjs` imports `normalizeBaseurl` from `offline-rewrite.mjs`. Verify by: byte-identical output before/after. Size: S. Verified: confirmed byte-identical (V1). Conflict resolved in favour of this finding — see Where the passes were wrong.
+
+#### A10-3 — R2, dup
+**Wisdom re-implements the shared markdown file-walker from scratch, and its recorded reason does not actually cover what the shared version requires.** Where: `wisdom/extract/sitemap.mjs:61-67` `walk()` (bare recursive `readdirSync`, no `.md` filter, no output-tree skip). Recorded reason: `PLAN-3.md:404` — "a minimal built-in parser, no dependency on `builder/`" — but `scripts/lib/markdown-files.mjs` imports only `node:fs`/`node:path`, zero dependency on `builder/`, so the stated reason does not justify skipping it. Cost: currently harmless — the call is scoped to `docs/Reference/`, which never contains an output tree. Fix in place: use `markdownFiles` from `scripts/lib/markdown-files.mjs`. Verify by: identical file lists. Size: S. Verified: confirmed (V2).
+
+#### A10-4 — R2, dead
+**A schema module is imported by nothing and has drifted from the inline schemas actually in use.** Where: `wisdom/extract/schemas.mjs` (zero importers repo-wide) vs. `workflow.mjs:49` (`thread_path`, not `schemas.mjs`'s `source_thread`) and `workflow.mjs:77` (`section` as an enum, not `schemas.mjs`'s free-text string). Recorded reason: none. Cost: none live; would mislead if ever revived. Fix in place: delete `schemas.mjs`, or bring it into line with `workflow.mjs` and connect it. Verify by: grep for importers = 0, before deleting. Size: S. Verified: confirmed, both divergences confirmed by direct comparison (V2).
+
+#### A10-5 — R2, conv
+**Two wisdom state files are written non-atomically, with no parse guard on load, next to a sibling that is explicitly commented as doing both correctly.** Where: `wisdom/discord/messages.mjs:10-12` `saveManifest` (plain `writeFileSync`), `wisdom/wisdom.mjs:146` (same); `loadManifest` has no try/catch around its `JSON.parse`. Contrast `wisdom/extract/state.mjs:62-75`, explicitly commented "write state atomically (temp file + rename)" and doing exactly that. Recorded reason: none for the inconsistency. Cost: an interrupted write can corrupt `manifest.json`/`denied.json`, and a corrupt file crashes the next load with no diagnostic. Fix in place: the same temp-and-rename pattern, applied to both. Verify by: an interrupted-write simulation leaves the previous file intact. Size: S. Verified: confirmed (V2).
+
+#### A10-6 — R2, struct
+**Two parallel implementations have 19 byte-identical self-test names in the same order, and nothing runs either self-test suite.** Where: `impexp.mjs` and `impexp.py`, 19 `test(...)` calls each, confirmed byte-identical in name and order. Recorded reason: `Tools.md:958` states, unhedged, "the two editions print the same output and write byte-identical project files," with nothing mechanical behind the claim. Cost: real engineering discipline (keeping the two ports in lock-step) with no automated backstop at all. Fix in place: `check_impexp_parity.mjs`, run from `test.bat`. Verify by: the parity check itself, against a deliberately diverged copy. Size: M. Verified: confirmed (V2). See Decisions for you (b) — this fix makes `test.bat` and CI depend on Python.
+
+#### L1-5 — R2, dead
+**A script still tolerates unknown flags "passed through via `check.bat`'s `%*`," and `check.bat` no longer calls it at all.** Where: `check_links.mjs:307-314,406-412`; `check.bat` (read in full) never calls `check_links.mjs` — link and integrity checking moved into `build.bat`. Recorded reason: stale — corroborated independently by `PLAN-checks.md:14`. Cost: dead tolerance code, easy to mistake for a live contract. Fix in place: delete the stale tolerance and its comment. Verify by: `check_links.mjs`'s own tests unaffected. Size: S. Verified: confirmed (V2).
+
+#### L1-6 — R2, conv
+**`--help` is handled four different ways across 36 entry points, and one of the four gaps has a live filesystem side effect.** Where: stdout/exit 0; stderr/exit 0 (`pick_a11y_sample`, `sweep_a11y`); stderr/exit 2 via the general usage-error path (`tbbuild`, `tbrun`, `addin_test`); unhandled in 12 tools. `gen_attribute_probes.mjs:1147-1158` takes a bare `--help` as its `out_dir` argument and actually creates a `--help/Sources` directory on disk — traced, not just "fails to print help." `render-book.mjs:210-220` rejects `--help` as an unknown argument (exit 2) and never reaches its own usage text. Recorded reason: none. Cost: the first command a confused user types against `gen_attribute_probes.mjs` writes to disk. Fix in place: the shared `printHelpAndExit()` in the cli.mjs module. Verify by: `--help` on every tool now prints usage and exits 0, with no side effect. Size: M. Verified: confirmed, the `gen_attribute_probes.mjs` side effect independently confirmed as a live write (V2).
+
+#### L1-7 — R2, conv
+**Unknown-flag handling is five-way inconsistent.** Where: ignore (11 tools), warn (`check_links`), exit 2 (8 tools), throw → exit 1 or 2, exit 1 (wisdom). Recorded reason: none. Cost: the same mistake produces a different outcome depending on which tool it's made against. Fix in place: the shared cli.mjs module's strict-mode handling, one behaviour for all. Verify by: each tool's current, intentional exceptions preserved explicitly. Size: M. Verified: not re-verified — outside the set of citations the verifiers covered.
+
+#### L1-8 — R2, conv
+**`--json` means a boolean-to-stdout flag in four tools and a value-taking output file in a fifth.** Where: `tbbuild`, `tbrun`, `check_examples`, `census_attributes` (boolean) vs. `check_a11y_fingerprint` (takes a path). Recorded reason: none. Cost: the same flag name means something different depending on the tool. Fix in place: document the two shapes explicitly in the cli.mjs spec, or rename one. Verify by: `Tools.md`'s CLI tables, checked against real behaviour. Size: S. Verified: not re-verified — outside the set of citations the verifiers covered.
+
+#### L1-13 — R2, struct
+**Nothing tests any tool's own argument handling, anywhere in the repository — the missing seam that let L1-1, L1-2, L1-3 and L1-6 ship.** Where: every `--self-test`/`selfTest` in the repo (`check_code_regions.mjs`, `check_gate_lists.mjs`, `check_links.mjs`/`check_links_diff.mjs`, `check_regex_safety.mjs`, `impexp.mjs`) asserts domain logic, never argv parsing; no dedicated CLI test file exists anywhere. Recorded reason: none. Cost: demonstrated positively — four real, currently shipping argv defects a minimal parser test would have caught. Fix in place: the shared cli.mjs module has its own ride-along self-test in `test.bat`. Verify by: the self-test itself, plus regression coverage for L1-1/L1-2/L1-3/L1-6. Size: M. Verified: confirmed (V2).
+
+#### L2-5 — R2, dup
+**A repository-root path is derived inline in 19 files, by roughly five distinct expressions, and the one place that names a convention for it overstates how widely that convention is actually followed.** Where: 19 files confirmed exactly (`census_attributes`, `tbdocs`, `build_corpus`, `nav_hops`, `run_case`, `site_search`, `addin_test`, `build_dot_metrics`, `build_package_api`, `check_code_regions`, `check_dot_fit`, `check_examples`, `check_gate_lists`, `check_links_diff`, `check_tree_fresh`, `convert_em_dash_separators`, `gen_attribute_probes`, `wisdom/extract/prep.mjs`, `builder/write.mjs`); `axe-scan.mjs:29` exports `REPO_ROOT`, imported by only 2 of the 19. Recorded reason: `Extending.md:654` states a convention ("nearly all of them do" a specific expression), but only 3 of the 19 actually use that exact expression — the nested-`dirname` shape is the majority, 10 of 19. Cost: a change to the repository layout must be traced through five different idioms. Fix in place: `scripts/lib/repo-paths.mjs`, re-exported by `axe-scan.mjs`; correct `Extending.md:654`. Verify by: every derived root byte-identical before/after. Size: M. Verified: corrected (V2) — count of distinct expressions raised from four to roughly five.
+
+#### L3-2 — R2, dup
+**One spawn call has no `'error'` handler and awaits `'exit'` rather than `'close'`, and a failure there skips the registry-restore step that exists to guard against exactly this.** Where: `check_examples.mjs:601-612` `buildStaged` (spawn at `:608`, `'exit'` at `:612`); a spawn `'error'` with no listener is an uncaught exception at the emitter's own emit point, not a promise rejection, so it bypasses both `main()`'s local `catch` (`:1678-1680`) and `main().catch` (`:1810`) — neither is in its call stack, and no `uncaughtException` handler exists anywhere in the file. Two siblings (`addin_test.mjs:180,185`, `check_regex_safety.mjs:347-348`) register both events. Recorded reason: none. Cost: a spawn failure (a bad `execPath`, for instance) leaves the tbIDE registry unrestored — the exact hazard `tb-registry.mjs`'s tidy machinery exists to prevent. Fix in place: register both `'error'` and `'close'`, matching the two siblings. Verify by: a deliberately unspawnable command still restores the registry. Size: S. Verified: confirmed (V4).
+
+#### L4-3 — R2, dup
+**Two vendoring functions each independently re-implement the same fetch-with-fallback-and-atomic-write sequence.** Where: `vendor-assets.mjs:222-252` `fetchToFile` and `:279-319` `fetchAttachment`; both wrap `fetch()` in try/catch, check `res.ok`, wrap `arrayBuffer()` in a second try/catch, then use the identical `` `${destPath}.tmp-${pid}-${Date.now()}` `` temp-and-rename idiom (confirmed byte-identical). Recorded reason: none for the shared half; the two functions' validation logic legitimately differs (content-type checking differs between them, tracked separately in the previous review). Cost: a fix to the atomic-write idiom must be applied twice. Fix in place: one shared `fetchWithFallback`/`atomicWrite` pair, called from both. Verify by: tree comparison (vendored assets unchanged). Size: S. Verified: confirmed (V1).
+
+### Tier 3 — local untidiness
+
+| ID(s) | Kind | Statement | Where | Verified |
+|---|---|---|---|---|
+| A1-7 | dead | `cpu-worker.mjs:510` writes the literal `4` (FAILED status); nothing anywhere reads it. | `builder/cpu-worker.mjs:510` | Not re-verified. |
+| A1-8 | struct | `tbdocs.mjs`'s own argument parser is not exported and has no test. | `builder/tbdocs.mjs:91-194` | Not re-verified. |
+| A2-8 | dup | `replaceAll("\\","/")` inline at 10 sites; two more files each keep a private `posix()`. | `offline-rewrite.mjs:34,39,44,219,226`; `offline.mjs:133,311,370,375,380`; `publish-policy.mjs:189`; `check-tree.mjs:46` (recorded) | Not re-verified. |
+| A3-4 / A2-5 / A3-5 / A4-1 (L4-8 cluster) | dup | Four small leaf helpers — `isNonEmpty`, `encodeSpaces`, `splitFragment`, `statSafe` — are each duplicated once, three of them byte-identical, `encodeSpaces` a different idiom with the same behaviour. `makeTimer`, the fifth member of this cluster, is the substantial one and is covered as **A1-4** in Tier 2. | `nav.mjs:338` / `seo.mjs:164`; `search.mjs:204` / `template.mjs:925`; `render.mjs:1593` / `crawl_check.mjs:51`; `link-check.mjs:455-457` / `check_links.mjs:151-153` | Confirmed, all four (V1, V2). |
+| A3-8 | dup | Three near-identical palette-building loops in one file. | `highlight-theme.mjs:336-344,351-359,364-372` | Not re-verified. |
+| A3-9 | dead | `precomputeSeo` has zero callers. | `seo.mjs:90-94` | Not re-verified. |
+| A3-10 | dead | `kramdownSlug` is exported but used only inside its own module. | `render.mjs:1352` | Not re-verified. |
+| A5-4 (= L4-9 part 2) | dup | `pad`/`median` are byte-identical in two scripts. | `pick_a11y_sample.mjs:229-232,259`; `sweep_a11y.mjs:279-283` | Confirmed, byte-identical (V2). |
+| A6-5 | conv | `serve.bat` does not propagate its child process's exit code. | `serve.bat` | Not re-verified. |
+| A7-6 | conv | A comment says `tbrun`'s snapshot uses `-EncodedCommand`; the code uses `-Command` (safe today — a fixed literal). | `tb-registry.mjs:49-50` vs. `tbrun.mjs:376` | Not re-verified. |
+| A7-7 | dup | A 180-second compile timeout is a literal, repeated at 7 sites in 4 files. | across `scripts/lib/tb-*` and the three harness CLIs | Not re-verified. |
+| A7-9 | struct | `tbbuild`'s shutdown skips its tidy step when the IDE handle was never set; safe only by an invariant in `tb-launch.ps1`. | `tbbuild.mjs` shutdown path | Not re-verified. |
+| A8-3 | dup | The fence unit key is computed twice in the same file. | `check_examples.mjs:424` (`makeBatches`), `:675` (`unitsOf`) | Not re-verified. |
+| A9-4 | dup — **Resolved in `90624841`** | `_writeUint`/`_digitCount` duplicated between two shims, both since deleted. | (deleted) `fast-refs.mjs`, `fast-refs-class.mjs` | Moot; not separately re-verified. |
+| A9-5 | dup | The `createRequire` + `require('pdf-lib/cjs/...').default` block is repeated in 12 files (corrected from an estimated ~15); 3 of the 12 were among the shims deleted in `90624841`, leaving 9 in production. | `book/lib/fast-*.mjs` (list in [`REVIEW-TOOLING-fe9ce12b/V3.md`](REVIEW-TOOLING-fe9ce12b/V3.md)) | Corrected (V3). |
+| A9-9 | dead — **Resolved in `90624841`** | A comment cited a file moved in `4c36c8d6`; the citing file has since been deleted entirely. | (deleted) `fast-dict-array.mjs:9` | Moot; not separately re-verified. |
+| A10-1 | conv | Four `eval/` scripts each parse arguments differently; one exits 1 on a bare `--help`. | `eval/*.mjs`, incl. `transcript.mjs:190-198` | Not re-verified. |
+| L1-9 | conv | `--src` means the docs root in some tools and the exported package tree in others. | `tbdocs`, `check_publish_policy` vs. `census_attributes`, `build_package_api` | Not re-verified. |
+| L1-10 | conv | `--name=value` form works only in `tbdocs`, and not for two of its own flags. | `builder/tbdocs.mjs` | Not re-verified. |
+| L1-12 | dup | "Usage text; exit code follows whether help was asked" is repeated six times across `eval/` and wisdom. | `eval/`, `wisdom/` | Not re-verified. |
+| L2-6 | conv | A scratch directory is not removed in a `finally` in two scripts; four others do this correctly (corrected from "three"). | `check_publish_policy.mjs:152-189`, `check_links_diff.mjs:651-750` (wrong); `check_links.mjs`, `impexp.mjs`, `check_page_baseline.mjs`/`check_symbol_index.mjs`'s `withBaseline` (right) | Corrected (V2). |
+| L2-7 | dup | Three small tree walkers, not proposed as a fix. | `offline.mjs:401-416,608-626`; `write.mjs:281-299` | Not re-verified. |
+| L3-6 | conv | Two `spawnSync` calls throw unguarded; the resulting stack trace reaches the terminal at the wrong exit code and without the tool's own `error:` convention. | `check_links_diff.mjs`: `fusedBuild` (`:426,432`), `ensureBasePathTree` (`:518,525`) | Confirmed — fails loudly, just inconsistently (V4). |
+| L4-2 | dup | Two SCSS-compiling functions share the same body. | `scss.mjs:65-76,78-89` (`compileLightScss`/`compileDarkScss`) | Not re-verified. |
+
+---
+
+## Split candidates
+
+Per decision 2, these are candidates for a later, separate pass — not proposed as fixes here.
+
+- **`builder/render.mjs`** — strongest evidence. The image-renderer rule order matters and is silent about it (`svgInlinePlugin` at `:518` must run before `remoteImagePlugin` at `:520`; swapping them reverses the behaviour with no error); the ellipsis plugin assumes the dashes plugin already ran (`:515`/`:516`); shared helpers thread through every plugin registered on the instance; no plugin is tested in isolation; anchors reference third-party rule names directly (`"curly_attributes"`). Separately, its two fence/code-span parsers (**A3-1**) sit 1,550 lines apart with no cross-reference to each other.
+- **`builder/tbdocs.mjs`** — the `TASKS` literal fuses DAG topology with task bodies across 61% of the file (`:260-1257`); `dispatch`'s `submit()` is SAB machinery (`:788-911`); check glue (`:1085-1256`); Gantt/timing logic (`:1268-1403`) belongs beside `gantt.mjs` — **A1-1** is the visible cost of it not being there; the console report (`:1478-1525`); exit policy scattered across the file (**A1-6**).
+- **`scripts/check_examples.mjs`** — eight distinguishable jobs in one file: templates (`:125-193`), selection (`:201-378`), batching (`:380-506`), staging (`:508-568`), orchestration (`:570-655,928-949`), bisection (`:657-927`, 270 lines across 14 functions), diagnostic mapping inside `buildStaged` (`:643-653`), and three report modes threaded through as booleans — plus a 425-line probe suite (`:1157-1581`) that is itself a candidate on its own. `gen_attribute_probes.mjs` is explicitly **not** a candidate: 47% of that file is its own exploratory data table, not logic.
+- **`builder/book.mjs`** — a resolver (§A), `book.html` assembly (§B–F, including `rewriteBookHrefs`), and a coverage checker (§G) are already three separable concerns; `pdf.mjs` already imports all three as if they were separate modules.
+- **`builder/template.mjs`** — weaker evidence. Its `navActivationCss` deferral trigger, named in `PLAN-4.md` §3, is close to being met, which would remove part of the case for splitting it; **A3-7**'s finding (a date formatter, URL helpers, and an escape-helper block, all beyond templating) stands independent of whether the file is ever split.
+- **`scripts/lib/tb-ide.mjs`** — console reading and add-in introspection are separable from the coupled build-state core.
+- **`scripts/lib/axe-scan.mjs`** — **not proposed.** It has a single-source property the review found worth protecting rather than splitting: any split would have to re-export everything through one module anyway, so a split would add a layer without removing one.
+
+---
+
+## Verified sound
+
+**Build orchestration.** Small modules elsewhere in `builder/`; the SAB layout (174,100 bytes) matches `Builder.md`; `HANDLERS` key sets match across the scheduler and the workers; SAB constants are imported by name everywhere except **A1-7**; the barrier invariant holds; `--dry-run` and `--profile-offline` are both live and `Tools.md`'s flag table matches 20 of 20; the stall watchdog matches `WIP.Build.md`.
+
+**Output stages.** The `offline.mjs`/`offline-rewrite.mjs` split is a recorded, deliberate one; `HTML_COMBINED_RE` follows the code-guard shape; `deriveOfflineJtdJs` uses a real parser (`acorn`), not a regex; `vendor-assets.mjs` is contained and guarded; `symbols.mjs` is not a `.twin` scanner (a common misreading); `substitute()`/`decode()` name collisions across files are coincidental, not duplicated logic; the publish-allowlist's own disjointness self-test passes; the gates run clean today (publish 6/6, page-baseline 11/11, symbol-index 46/46).
+
+**The Markdown dialect.** `nav.mjs`; the table and fence fix-ups operate on single, already-known tokens; token-traversing `md.core` plugins are immune to the class of bug the rewrite-site theme describes, because they consult markdown-it's own parsed structure rather than re-deriving it; `html_block`-scoped rewrites are correctly scoped; `template.mjs` reuses `seo.mjs`'s `stripHtml` rather than a third copy; `clampContrast` fails loudly rather than silently; `VOID_TAGS_RE` is properly gated.
+
+**Link checker.** `check.mjs`, `check_links.mjs` and `check_links_diff.mjs` are coherent as three files, with no split candidate among them.
+
+**Accessibility and diagram gates.** The axe patch (`SOURCE_PATCHES`) earns the review's model status for a workaround — see Themes; the dot-metrics WASM patch passes emphatically (signature match, a read-back check, a real layout check, a `WeakSet` guard); `check_tree_fresh.mjs` correctly uses `isOutputTree`; the `FAMILIES` table is data, not logic that needs factoring; `check_a11y.mjs`'s mutual-exclusion handling is correct.
+
+**Gates as a system.** `check_gate_lists.mjs` is exemplary (18 probes); `check_publish_policy.mjs`'s check is bidirectional; `check_regex_safety.mjs` and `scripts/lib/regex-fold.mjs` are the strongest pair in the gate roster; `check_code_regions.mjs` and `scripts/lib/markdown-files.mjs` are strong; `convert_em_dash_separators.mjs` correctly uses the shared walker and preserves line endings; the exit convention is followed everywhere except **A5-2**/**A6-3**; the `.bat` idiom is deliberate and cross-referenced; the workflow files' own comments point at their source rather than restate it.
+
+**The compiler harness.** A strict DAG with no cycles; `tb-registry.mjs` is imported only by the three CLIs that need it; `stageProject` is genuinely shared; the `laneProjectId` allocator is correct; `check_tb_registry.mjs` has its own independent fixtures; every PowerShell payload is a fixed literal, with real data passed through the environment or stdin, never interpolated into the script text; the job-object-plus-suspended-kill path is sound; retry loops are recorded and fail loudly rather than silently.
+
+**Samples and the package API.** The `serialize`/`strip`/`kindOf` name collisions across files are coincidental, not duplicated logic; `symbols.mjs` is not a `.twin` scanner; `tb-fences.mjs` is tested, indirectly, through `check_examples.mjs`'s 74-probe `runProbes` suite.
+
+**The book pipeline.** `book.bat` is correct; `render-book.mjs`'s 13-shim import list is internally consistent; every shim's `__xInstalled` guard works as intended; the paged.js fork's divergence from upstream is fully recorded (20 `[PATCH]` tag families across 52 sites, in `Fixes-PagedJS.md`); `pdf.mjs`'s `IMG_SRC_RE` follows the code-guard rule; `outline.mjs` and `postprocesser.mjs` are attributed, unmodified ports of `pagedjs-cli` and should be treated as vendored code, not first-party tooling.
+
+**Smaller tools.** The site's own client-side JavaScript is a set of self-contained IIFEs, with no shared state between them; `svg-inline.js` does not duplicate `svgInlinePlugin` — one is markup-time, the other runtime; `build_fonts.py` matches what `WIP.Fonts.md` describes; the evaluator's process-isolation design is deliberate and sound; `wisdom/extract/merger.mjs` and the wisdom API's pagination are both fine apart from **L3-3**.
+
+**Paths, configuration, dependencies.** Exactly three real YAML parse sites exist (`tbdocs.mjs:271`, `check_publish_policy.mjs:69`, `data.mjs:12`), each loaded once per build; `check_publish_policy.mjs`'s cwd-relative `SRC` is a recorded, deliberate choice (`Extending.md:654`); atomic temp-and-rename writes are used correctly wherever they are used; `tbrun`'s per-port scratch-directory cleanup at the next start is deliberate; only `picocolors` and `pako` are undeclared dependencies; the lockfile and `npm ci` are used correctly throughout.
+
+**Browsers, processes, text rewrites.** The four puppeteer launches are consistent wherever consistency matters; `--allow-file-access-from-files` is needed wherever a page `fetch()`es a sibling `file://` resource; child processes never use `shell:true`, always pass array arguments, are killed by pid (never by image name), and the one stdin hazard the harness had was fixed once, centrally, in `runCompiler`; PowerShell payloads pass data through the environment or stdin, never interpolated; cleanup is disciplined across `tb-lane`, `tb-addin`, `addin_test.mjs` (on `SIGINT`), the add-in tests' own `after()` hooks, and `check_tb_registry.mjs`; `runShard` is the model worth following elsewhere; `check_links.mjs`'s worker dispatch and `worker-pool.mjs` are both sound. On text: `applyPreRenderRewrites` correctly brackets its four rewrites between mask and restore; token-scoped `md.core` rules are a third sound mechanism that `WIP.Build.md` does not currently name (a documentation gap, not a code one); `HTML_COMBINED_RE` and `IMG_SRC_RE` both follow the documented guard shape; every other unguarded rewrite in the tree targets build-generated markers, not author content.
+
+---
+
+## Where the passes were wrong
+
+The verification step exists because a pass's first read is not always the last word. These are the specific corrections that changed a finding's severity, its impact, or its standing:
+
+- **A4-3's severity.** First filed as R2 with a count of "20" attribute-tag pairs. The verifier found the build checker's table actually has 21 tags and 26 pairs, and — more importantly — that the gap between it and `crawl_check.mjs`'s own five-pair table is not merely a risk: it already causes the only post-release checker to miss seven kinds of link on the live site. Raised to R1.
+- **A8-1's impact.** The pass reported a live misclassification of `Overridable`-attributed members. The orchestrator traced it against the real BETA 983 package cache and found zero attributed `Overridable` members exist there today — the keyword-list divergence is real and already diverged (satisfying R1 on its own terms), but its specific claimed consequence is dormant, not live.
+- **The inventory's staging.md claim.** The markdown inventory reported "live, on-disk section corruption" in the committed `staging.md`, with a specific claimed mis-pairing of metadata between two named sections. Running the real `parseStaging` against the real file, the orchestrator found the named section (`Len.md · after-remarks`) has exactly the metadata beneath it on disk, correctly; the heading's own "see also thread" text, which lists other duplicate sections, is what misled the inventory's read. **Not confirmed.** What is real, and confirmed, is a narrower defect: a two-line content slip at `staging.md:14245-14246`, missing the blockquote continuation marker, which would render as a broken admonition and a runaway code fence if that section is ever grafted into `Len.md`. See Decisions for you (f).
+- **A7-1's count.** The orchestrator, reading the two patterns, first reported that tbrun's regex matches 2 of the 5 documented failure shapes. The verifier ran both regexes against all five shapes and found it actually matches 3 of 5 — `tbrun`'s case-insensitive flag catches both listed casings of the BUILD shape. The two shapes it misses, and the resulting false-success consequence, are unchanged.
+- **Corrected counts**, none changing a verdict's direction but each changing a specific number the finding cites: **A5-1** (7 scripts → 8, `check_tree_fresh.mjs` added); **A7-3** ("used by every scenario" → used directly in 4 of 10 test files); **A7-4** (9 imports → 10); **A8-2** (the ten-item citation list had one wrong entry and two missing; corrected to 11 strict-path / 13 bare-filename citations); **A9-5** (~15 files → 12, 3 of which were later deleted in `90624841`); **L2-5** ("four expressions" → confirmed 19 files, closer to five distinct AST shapes); **L2-6** ("three siblings do it right" → four).
+- **L4's classification of `findingsFor`/`buildFindings`.** L4 read the parallel-shape comments in `check.mjs` and `check_links.mjs` as a deliberate, still-valid design (mirrored on purpose so `check_links_diff.mjs` can cross-check two independent implementations) and declined to flag it. That reading was correct when the comments were written. It is superseded by the user's own 2026-09-25 decision to make `check_links.mjs` a thin wrapper over `builder/check.mjs` (decision 5): after that refactor, one implementation sits behind two front ends, not two independent ones, and both comments need to be rewritten to say so.
+- **L4's acceptance of `book.mjs`'s "plugins are independent."** L4 accepted the recorded reason for `book.mjs`'s private copy of `normalizeBaseurl`/`escapeRegExpBook` at face value. The reason describes the old Ruby/Jekyll plugin architecture, where `book-href-rewrite.rb` and `offlinify.rb` had no shared-code mechanism between them. That premise expired at the Node cutover — `book.mjs` already imports two sibling `builder/` modules for other things — and A9-8's reading is the one this document adopts.
+- **L3's "sound" verdict on the three whole-page HTML rewrites.** L3 called `padEmptyCells`, `normaliseVoidTags` and `injectAnchorHeadings` sound, reasoning that every code path escapes `&<>` before they run. Traced against all four ways text can end up inside ``/`
`, that is true for three of the four and false for the fourth (raw, hand-authored HTML). Both readings are partly right; **A3-6** stands as filed, at R2, because the fourth path is real even though nothing in the current corpus triggers it.
+
+---
+
+## Decisions for you
+
+**Decided on 2026-09-25: all seven as recommended.** The one question left open inside them is (b)'s local behaviour, settled when that gate is written.
+
+**(a) The shared markdown module — home, scope, and migration order.**
+Home: a new top-level `lib/` directory, sibling to `builder/`, `scripts/`, `wisdom/` and `eval/`. `builder/render.mjs:383`'s own comment rules out `scripts/lib/`, since `builder/` is a required consumer and must not depend on `scripts/`; `wisdom/`'s stated reason for its own private parser (`PLAN-3.md:404`, "no dependency on `builder/`") also rules out putting the module inside `builder/`. Scope: markdown-it block regions (fence/`code_block`/`html_block`, with `.map` line ranges) plus one tested inline-code-span splitter plus a frontmatter splitter built on `js-yaml` 4 directly, dropping `gray-matter` (and its bundled `js-yaml` 3) entirely. Migration order: the five rewrite sites first (`render.mjs`'s two, `convert_em_dash_separators.mjs`, `check_examples.mjs`'s marker splice, which is already close, and wisdom's `parseStaging`/`serializeStaging`), since a wrong region there can corrupt committed content; the ten read-only scan sites after, since their failure mode is under- or over-counting, not corruption.
+*Recommendation:* a new top-level `lib/`, with the inventory's API sketch (`blockRegions`, `maskCode`, `splitCodeSpans`, `splitOnMarker`, `parseFrontmatter`) as the starting point. The sketch was built by testing the real markdown-it instance against the real corpus, not from the sites' existing behaviour. The oracle for moving each rewriter onto it is the byte comparison of the built trees; for dropping `gray-matter`, every page's parsed frontmatter compared both ways.
+
+**(b) A parity gate for `impexp.mjs`/`impexp.py`.**
+**A10-6** found the two ports keep 19 byte-identical self-test names with nothing running either suite. Adding `check_impexp_parity.mjs` to `test.bat` closes that, but it is the first gate that would make `test.bat`, and both CI workflows, depend on a Python interpreter being present.
+*Recommendation:* add it, and run it unconditionally in CI, whose runners have Python. The discipline behind the parity is already real (the two test suites are kept in step by hand, and `Tools.md` promises byte-identical output); a gate only formalises a check the project already believes it needs. The part to decide is local: whether `test.bat` should require Python, or report this one gate as skipped, loudly, when no interpreter is found. `build_fonts.py` already requires Python, but only for someone regenerating fonts; nothing in the routine local loop does today.
+
+**(c) The pdf-lib shims — guard, or record the pin as the only guard.**
+**A9-1**/**A9-2**: either add load-time shape assertions per shim plus an equivalence test against stock pdf-lib (modelled on `check_axe_patch_equiv.mjs`), or explicitly record that the exact version pin is the only guard this workaround will ever have, given upstream pdf-lib is abandoned and the `@cantoo` fork was evaluated and rejected.
+*Recommendation:* add the assertions. The pin only protects against an accidental `npm update`; it does nothing for a deliberate, intentional bump to a newer pdf-lib release, or for a future contributor's mistaken hand-edit to a shim file. Both are realistic over the tool's lifetime, and the axe patch shows the cost of the guard is small once one example exists to copy.
+
+**(d) A composite CI action as a follow-on to the roster gate.**
+**A6-4**'s design proposes `.github/actions/run-gates` after the CI-roster gate is in place (decision 6), not as a reusable workflow — a separate job would lose the built trees the deploy job needs.
+*Recommendation:* proceed in that order. Building the roster gate first gives the composite action something to check itself against; building the action first would let it and the gate drift from each other before either is proven.
+
+**(e) The command-line convention Phase 3 should converge on.**
+`impexp.mjs`'s own discipline (a verb `Map`, an `EXIT` object, `usageError()`) is the best-disciplined CLI in the repository today and is the model **L1** names.
+*Recommendation:* for Phase 3, converge on that discipline: `--help` prints usage to stdout and exits 0; an unknown flag or a bad value prints to stderr and exits 2; each tool names its exit codes in one table. For Phase 2, which must change no behaviour, build the shared `scripts/lib/cli.mjs` module L1 specified — `parseCli()` over `node:util` `parseArgs` (strict, `allowPositionals`, tokens) with camelCase aliases and positional-count checks; `withUsageError()` preserving each tool's current message, stream and exit code; `numberOption()` closing the `NaN` hole; `printHelpAndExit()` preserving each tool's current `--help` text until Phase 3 actually changes it. Migrate `tbdocs.mjs` last — its cross-flag ordering (`--no-check` resets other flags) needs a scan over the parsed tokens, not a simple options object. Migrate wisdom last, or not at all — its subcommand shape needs custom code the shared module does not cover.
+
+**(f) The `staging.md` content slip.**
+The one confirmed, real defect in the committed data itself: `wisdom/data/findings/staging.md:14245-14246` is missing a blockquote continuation marker on a fenced sample's content line.
+*Recommendation:* fix the data now, independent of the tooling fix in (a): add the missing `> ` to lines 14245 and 14246. It is a two-line editorial correction to a file reviewers already edit by hand, and there is no reason to wait for the shared parser before correcting it.
+
+**(g) State the dependency-pinning policy, and fix `Builder.md`'s stale Dependencies section.**
+`Builder.md`'s "Dependencies" section omits `recheck` entirely, states `wasm-graphviz` as `^1.21` when it is `^1.29.1`, and says `axe-core` is the only exact pin when four packages are pinned exactly. This is a repeat of a drift the previous review already fixed once, in `74b3395`.
+*Recommendation:* fix the section, and add one sentence stating the policy itself — exact pin when the code patches the dependency's internals or depends on undocumented behaviour, caret otherwise — so a future caret on an output-changing dependency reads as a decision rather than an oversight.
+
+Not re-opened here, because each was already decided by the owner before or during the review: the link-checker architecture (`check_links.mjs` stays a thin wrapper over `builder/check.mjs`, decision 5); `perf/`'s scope (nothing moves out, `detach-pages.js` documented in place, decision 1 as amended); the four superseded pdf-lib shims (deleted in `90624841`); formatting as the last phase (decision 4, Phase 6); and working directly on `staging` with no PR branch.
+
+---
+
+## Appendix
+
+### Baseline survey
+
+Taken at `fe9ce12b` with `scripts/survey_tooling.mjs` (a token-level clone detector — identifiers and literals normalised, 60-token windows — plus an import graph over 185 first-party JavaScript files, `perf/` included, vendored code excluded):
+
+| Measure | At `fe9ce12b` | After the shim deletion (`90624841`) |
+|---|---|---|
+| Clone regions of 60+ tokens | 680, of which 318 outside `perf/` | 571, of which 266 outside `perf/` |
+| Top-level function names defined in 2+ files | 77, of which 57 outside `perf/` | 74, of which 54 outside `perf/` |
+| Command-line tools outside `perf/` reading `process.argv` | 32, none using `node:util` `parseArgs` | (unchanged by the deletion) |
+| Tools with a private copy of `flag`/`opt`/`die` | 6, with `opt` in three different versions | (unchanged by the deletion) |
+| Packages imported but not declared | 2: `picocolors`, `pako` | (unchanged by the deletion) |
+| Clone regions by pair of areas (`builder`/`scripts`, `scripts`/`scripts`, `builder`/`builder`) | 55, 95, 39 | (not re-measured) |
+
+Observed by hand at `fe9ce12b`: 5 places state which gates run (three `.bat` files, two workflows) plus `Tools.md`; no lint or format tooling exists; line endings are LF in the repository but CRLF in 118 of 140 working-tree files under `core.autocrlf=true`, with no `.gitattributes`; quote style is double in `builder/`, `scripts/`, `test/`, `eval/` and single in `book/`, `wisdom/`, `perf/`.
+
+The drop in clone regions and repeated function names between the two columns reflects the four pdf-lib shims deleted during the review. Apart from the three findings that deletion resolved, none of this document's fixes has been made yet.
+
+### Finding counts, merged, by tier and kind
+
+Counts are of merged findings (one row per finding as presented above), not of raw pass-level citations; a finding with a compound kind label (for example "dup/hack") is counted under its first-listed kind.
+
+| Tier | dup | hack | struct | conv | dead | Total |
+|---|---|---|---|---|---|---|
+| Tier 1 (R1) | 16 | 3 | 1 | 0 | 0 | 20 |
+| Tier 2 (R2) | 19 | 2 | 9 | 8 | 5 | 43 |
+| Tier 3 (R3) | 11 | 0 | 2 | 7 | 4 | 24 |
+| **Total** | **46** | **5** | **12** | **15** | **9** | **87** |
+
+Eighty-seven merged findings account for 109 raw finding IDs across the fourteen passes; the difference is the roughly twenty IDs the ledger's explicit MERGE and `=` directives fold into another finding. Three findings (**A9-4**, **A9-6**, **A9-9**) are resolved, in `90624841`, ahead of the fix phases the rest of this document's findings still await.
diff --git a/builder/REVIEW-TOOLING-fe9ce12b/README.md b/builder/REVIEW-TOOLING-fe9ce12b/README.md
new file mode 100644
index 00000000..8f64c28d
--- /dev/null
+++ b/builder/REVIEW-TOOLING-fe9ce12b/README.md
@@ -0,0 +1,18 @@
+# Evidence for REVIEW-TOOLING-fe9ce12b.md
+
+The working records behind [the review](../REVIEW-TOOLING-fe9ce12b.md), kept as they were
+when it was written. Line numbers in them refer to `fe9ce12b`.
+
+| File | What it is |
+|---|---|
+| [ledger.md](ledger.md) | The orchestrator's condensed record of every pass's findings (A1–A10, L1–L4), the owner's decisions as they were taken, and the verification status. Where its top sections correct a pass, the correction wins. |
+| [V1.md](V1.md) | Verifier V1: the `builder/` findings. |
+| [V2.md](V2.md) | Verifier V2: the gates, the link checkers, the accessibility tools, CI and the smaller tools. |
+| [V3.md](V3.md) | Verifier V3: the compiler harness, sample compiling, the package API and the book pipeline. |
+| [V4.md](V4.md) | Verifier V4: pass L3's findings, and the ruling on the A3-6 conflict. |
+| [markdown-inventory.md](markdown-inventory.md) | Every place the tooling processes markdown as text, and what markdown-it can supply. The ledger calls it `INVENTORY.md`. Its claim of section mis-pairing in `staging.md` was **not confirmed**; see the review's "Where the passes were wrong". |
+
+These files cite `scratchpad/...` paths: the session folder where the passes and verifiers
+ran small scripts. The scripts were not kept. Some copied repository functions verbatim for
+comparison, and would distort `scripts/survey_tooling.mjs`'s clone counts if committed; each
+result is described in the file that cites it.
diff --git a/builder/REVIEW-TOOLING-fe9ce12b/V1.md b/builder/REVIEW-TOOLING-fe9ce12b/V1.md
new file mode 100644
index 00000000..9a131a03
--- /dev/null
+++ b/builder/REVIEW-TOOLING-fe9ce12b/V1.md
@@ -0,0 +1,80 @@
+# V1 verification report
+
+Verified against commit `fe9ce12b` (confirmed `HEAD == fe9ce12b` at start; working tree later
+gathered the expected drift described in the task — four `book/lib/fast-*.mjs` deletions and
+comment edits in `book/render-book.mjs`, `perf/measure.mjs`, `perf/phase0-measure.mjs` — none of
+which touches any file cited below). All line numbers are as read via `git show fe9ce12b:`,
+copied into `scratchpad/verify/V1/`.
+
+## Table
+
+| ID | Verdict | Severity note | Correction / evidence (path:line at fe9ce12b) |
+|---|---|---|---|
+| A1-1 | CONFIRMED | R1 struct fits — already silently drops tasks on every `--check` build. | `builder/gantt.mjs:39-49` (`mainSections = [["Seeds",...],["Spine",...],["Write",...]]`, loop at 41-48 only pushes `t.lane!=null` (Workers), `"Seeds"`, `"Spine"/"Render"`, `"Write"` — anything else, incl. `"Check"` and `"Other"`, is silently dropped). `builder/tbdocs.mjs:1270-1282` `GANTT_SECTION`/`GANTT_SECTION_ORDER` include `"Check"` (`linkJoin`/`checkBook`/`checkReport`); `checkBook`(1178-1196)/`checkReport`(1200-…) both `runOnMain:true` so never get a `.lane`. `vendorAssets` (539-562) is `runOnMain:true` and has no `GANTT_SECTION` entry, so falls to `"Other"` (line 1294) — also dropped. `COLORS.Other` (gantt.mjs:12) confirmed unreferenced by any code path that actually runs. `docs/Documentation/Builder.md:410` promises "Tasks without a section fall into a generic 'Other' bucket" — false, they vanish. |
+| A1-2 | CONFIRMED | R1 fits (breaks on the next `puppeteer`/`cosmiconfig` bump). "hack" label is a loose fit for an undeclared transitive dep, but no better bucket exists; not worth reclassifying. | `builder/tbdocs.mjs:35` `import pc from "picocolors";`, `builder/scheduler.mjs:5` same. `package.json` `devDependencies` (fe9ce12b) has no `picocolors` entry. Lockfile chain confirmed exactly as claimed: `puppeteer` (line 1902-1905) → `cosmiconfig` (912-922) → `parse-json` (1801-1811) → `@babel/code-frame` (1795, dep `"picocolors": "^1.1.1"` at line 40). |
+| A1-3 (=L4-1) | CONFIRMED, incl. 4th-path claim | R2 fits — not yet diverged, but a bug fix (e.g. adding the FAILED-status write) must be applied 3x by hand. | Three near-identical "run handler, time it, report `perWorkerTiming`" blocks in `builder/cpu-worker.mjs:359-377` (idle task), `423-440` (nested dep), `469-486` (dep) — same shape (`t0=Date.now()`/try-catch calling `handlerById[...handlerIdx]()`/`t1=Date.now()`/`Atomics.store(perWorkerDone,...)`/`postMessage({perWorkerTiming:true,...})`), differing only in which index/meta/result variable is used. 4th path (`497-540`, "Execute task") is genuinely different: calls `handler(taskIdx)` (with an arg, not `handlerById[meta.handlerIdx]()`), posts a **different message shape** (`{done,output,timing,lane}` not `{perWorkerTiming,...}`), writes `Atomics.store(views.status,taskIdx,4)` on failure (ties to A1-7), and — per its own comment at 515-525 — deliberately posts the message **before** the SAB `onTaskDone` update (opposite order from the other three, which `Atomics.store` before `postMessage`), to close the exact race that "lost pages from the search index." That ordering concern has no counterpart in the other three blocks. |
+| A1-4 | CONFIRMED | R2 fits — two copies, and the stated reason for the private one is gone, so a future editor has no way to know which copy offline.mjs actually uses. | `builder/tbdocs.mjs:196-209` `export function makeTimer()`; repo-wide grep for `makeTimer` outside its own file and `builder/offline.mjs` finds only `builder/PLAN-9.md:68`, a planning doc, never an actual import. `builder/offline.mjs:98-111` private, textually identical copy, called at `offline.mjs:151`. Its comment (95-97) says the private copy avoids a cyclic import because "the verify harnesses and diff tools import offline.mjs without going through tbdocs.mjs's main() side effects" — `git show 644d6bdb --stat` confirms that commit deleted exactly those tools (`_diff.mjs`, `_diff_all.mjs`, `_triage.mjs`, `_sitemap_diff.mjs`, `_audit_accepted.mjs`, `accepted-divergences.mjs`, `verify-phase1..8.mjs`). |
+| A1-6 | CONFIRMED | R2 conv fits (not yet a live defect — see "noticed in passing" #3 for why the ordering is currently accidentally safe). | Exactly 7 sites, all bare `1`/`2` literals, no named constants: `tbdocs.mjs:560` (`process.exitCode = 1`, vendorAssets), `1469` (dot), `1470` (scss), `1570` (`(process.exitCode??0)\|code` inside the 1568-1573 check block), `1573` (recheck-only branch), `1598` (page-baseline drift), `1610` (symbol-baseline drift). Bit 0 (`=1`/`\|1`) is set independently by 5 of those 7: vendorAssets(560), dot(1469), scss(1470), page-baseline(1598), symbol-baseline(1610) — "conflates 5 causes" confirmed exactly. |
+| A2-1 (+L4-4, A1 lead) | CONFIRMED | Lean **R2**, not the pass's own hedged R1 — nothing here has actually diverged (it's unreachable, not two live copies disagreeing); the R1 rubric example is "copies that disagree." Matches the source pass's own "[likely R2]" hedge. | `writeOfflinePages` defined `offline.mjs:244-289`; repo-wide grep for `writeOfflinePages\(` (a call, not the definition) finds **zero** hits anywhere, including inside `offline.mjs` itself — `writeOffline`'s own `Promise.all` (179-195) no longer calls it (comment at 174: "Offline page HTML is already on disk from per-worker flush"). Its body (258-280, the `byDir`/`navCache` pre-pass) is line-for-line the same algorithm as the **live** copy in `cpu-worker.mjs:183-201` (same variable roles: `writable`/`byDir`/`navCache`, same loop shapes), confirming the "clone" half. `writeOffline`'s `precomputed=false` destructured param (`offline.mjs:117`) is never referenced anywhere in `writeOffline`'s body (117-198) — confirmed dead by direct read. `buildOfflineState`'s `sitePaths ?? await buildSitePaths(...)` fallback (207, 213) is unreachable: `tbdocs.mjs:705-706` always computes and sets `state.sitePaths` unconditionally, and the `writeOffline` task (1024-1027) always forwards `sitePaths: state.sitePaths`; `buildSitePaths` itself spans exactly `359-394` as cited. `writeSearchData` (`search.mjs:18-24`) and `extractSitemapUrls` (`sitemap.mjs:70-74`, off by one from the cited 69-74 — the function itself starts at 70, a one-line `LOC_RE` const is at 69) both have zero call sites repo-wide (only their own definitions and doc-table mentions). Re-export block `offline.mjs:55-88` (24 names): confirmed via `import { writeOffline, enumerateVendoredThemeAssets } from "./offline.mjs"` (`tbdocs.mjs:58`, neither name is in the 24) and `import { buildSitePathsSync, deriveOfflineCss, normalizeBaseurl } from "./offline-rewrite.mjs"` (`tbdocs.mjs:59-60`, bypassing offline.mjs's re-export entirely) that **every one of the 24 re-exported names has zero importers anywhere in the repository** — confirmed repo-wide, not just for the names A1's lead singled out. |
+| A2-2 (+A9-10 comment half) | CONFIRMED | R2 is defensible (a stale comment claiming a real external consumer is what lets dead code like A9-10's `extractImagePaths` survive review); could be argued R3, not clearly wrong either way. | All citations verified to name the tools `644d6bdb` deleted: `offline-rewrite.mjs:411` ("`_diff.mjs`'s per-call buildOfflineState"), `search.mjs:65-66` ("`_triage.mjs`, `_diff.mjs`... `accepted-divergences.mjs`"), `sitemap.mjs:67-68` ("`_triage.mjs` and `_sitemap_diff.mjs`") and `:97` ("`_triage.mjs` / `_diff.mjs`"), `redirects.mjs:35-36` ("`_triage.mjs` / `_diff.mjs`"). `pdf.mjs:6-9` ("`_diff.mjs --book` / `_triage.mjs auditBook*`") and `:99-107` ("used by... the diff tools"; "retained below as a fallback/diagnostic export for the bulk-triage tools") — and `extractImagePaths` (`pdf.mjs:146-158`) confirmed zero callers repo-wide (only its own definition and a `docs/Documentation/Pipeline-Stages.md:847` doc-table row referencing it as documentation, not a call). |
+| A2-3 (+L2-4, L4-5) | CONFIRMED | R2 dup fits well. | `readBaseline` byte-identical: `page-baseline.mjs:83-90` vs `symbol-baseline.mjs:45-52`, both exactly `try { return JSON.parse(await readFile(file,"utf8")); } catch(err) { if (err.code==="ENOENT") return null; throw err; }`. Six-branch structure re-typed: `checkPageBaseline` spans exactly `117-176`, `checkSymbolBaseline` spans exactly `75-125` — both: skip-if-wrong-src, force-write, missing-baseline (fail-if-!write / create-if-write), regression (fail), growth (write-if-write), fallthrough no-op. `WIP.Build.md:589` ("The drift guard is the page-count guard's shape applied to URLs") is a real recorded reason but — as the pass argues — explains only why the two look alike by design, not why the actual branch bodies weren't factored into one shared state machine. |
+| A2-4 (+A3-5 escapeRegExp) | CONFIRMED | R2 dup fits; not yet diverged so not R1. | Three byte-identical bodies, `s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")`: `render.mjs:2235-2237` (`escapeRegExp`), `offline-rewrite.mjs:239-241` (`export function escapeRegExp`), `book.mjs:212-214` (`escapeRegExpBook`). `scripts/lib/regex-fold.mjs:58-63` confirms the cost cited: it documents recognising the escaper "by shape rather than by name, so the three copies in this tree (`escapeRegExp` twice, `escapeRegExpBook`) and any fourth are all covered without a list to maintain" — i.e. the gate had to be specially built to tolerate this exact duplication, which is evidence *for* the finding, not a defeating "recorded reason" (it justifies the gate's design, not the duplication itself). |
+| A2-6 (=A9-8) | CONFIRMED | R2 dup fits (not yet diverged). | `book.mjs:303-307` vs `offline-rewrite.mjs:232-236` (`export`ed): byte-identical bodies. See Conflicts §2 for the "by design" comment ruling. |
+| A2-7 | CONFIRMED | R2 dup fits; connects to a named prior incident, so the "cost" argument is well grounded. | `compress.mjs:73-84` `collapseWhitespace` (regex-based, `/[ \t\n\r\f\v]+/g` + `.trim()`/one-sided regex trims) vs `search.mjs:251-261` `stripAsciiWhitespace`+`isAsciiWs` (charCode-scan based). Both explicitly preserve NBSP (U+00A0) with near-identical comments explaining why. `WIP.Build.md:291-296` "### Whitespace inside inline code is content" confirms a real, named prior shipped defect in exactly this area of `compress.mjs`. |
+| A3-1 | CONFIRMED (directly reproduced) | R1 fits — the divergence is demonstrated, not hypothetical. | **Reproduced** in `scratchpad/verify/V1/test_A3-1_repro.mjs` by importing `builder/render.mjs` (copied at fe9ce12b) directly. Source `` "```abc`def\n> [!NOTE]\n...\n```" ``: `maskCodeRegions` (render.mjs:141, open-fence test at 148 `/^[ \t]{0,3}(`{3,}\|~{3,})/`, no info-string check) treats the whole block as ONE masked region and restores it byte-for-byte unchanged. `rewriteAdmonitions` → `stashCodeFences` (1706, guard at 1713 `if (ticks[0]==="`" && info.includes("`")) { out.push(lines[i]); continue; }`) refuses to recognise the same line as a fence (CommonMark-correct: a backtick fence's info string may not contain a backtick) — so it does **not** protect the "> [!NOTE]" line, and `ADMONITION_RE` converts it into a real `
`. Confirmed both via `rewriteAdmonitions` alone and via the full `applyPreRenderRewrites` pipeline. Control case (fence with no backtick in the info string) confirmed the admonition is correctly left alone, isolating the bug to exactly this edge case. Did not independently re-trace the "check_code_regions structurally blind to it" sub-claim beyond what WIP.Build.md documents about the gate's known "mirror fault" blind spot — plausible, not separately re-verified. | +| A3-2 | CONFIRMED | R2 dup fits — dormant today (as the finding itself says), so not R1. | `highlight.mjs:249-254`: 3-char `escapeHtml` (`&<>` only), with an explicit, specific recorded reason ("Rouge's HTML formatter escapes only `& < >`... Match that"). `render.mjs:2225-2227`: 5-char `escapeHtml` (`&<>"'`). `headingTocHtml` (`render.mjs:1437-1450`) confirmed mixing both **within the same function**: line 1440 uses the 5-char `escapeHtml` for `text` tokens, line 1442 uses the 3-char `escapeHtmlMinimal` (2230-2233, itself a near-duplicate of highlight.mjs's escaper) for `code_inline` tokens. See Conflicts §3. | +| A3-3 (+L4-6) | CONFIRMED | R1 fits — rubric's own R1 example is "copies that disagree," and these genuinely do, in 3 separate documented ways. | `seo.mjs:121-148` (config-arg `absoluteUrl`/`relativeUrl`, `ensureLeadingSlash` **forces** a leading slash via 145-148, no space-encoding) vs `template.mjs:917-936` (baseurl-arg, url left **unchanged** if it doesn't already start with `/` — line 922 `return url;` — and `encodeSpaces` called at 920). L4-6's two extra disagreements both verified: (1) protocol-relative `//host` — `template.mjs:919`'s condition `url.startsWith("/") && !url.startsWith("//")` explicitly excludes `//host` from baseurl-prepending (leaves it alone), while `seo.mjs`'s `isAbsoluteUrl` (160-162, scheme-only regex) returns false for `//host`, so `seo.mjs`'s `relativeUrl` prepends baseurl to it regardless — traced by hand that this is currently masked because this site's `baseurl` is always `""`. (2) non-string/null input — `seo.mjs`'s `absoluteUrl` returns `null` for `input==null` (line 122) vs `template.mjs`'s `absoluteUrl`/`relativeUrl` both return `""` for `typeof x !== "string"` (929-930, 917-918). | +| A3-4 | CONFIRMED | Lean **R3**, not R2 — two 4-line, leaf, no-divergence-risk predicate functions; smaller and lower-cost than the other escape/URL dups grouped nearby. Not a clear-cut call either way, so not insisting on a change. | `nav.mjs:338-342` byte-identical to `seo.mjs:164-168`: `function isNonEmpty(value) { if (value==null) return false; if (typeof value==="string") return value.length>0; return true; }`. | +| A3-6 | CONFIRMED | R2 hack fits — fails the rubric's "guarded" test (nothing fails loudly if the entity-escaping invariant ever breaks). | `render.mjs:74-80` `padEmptyCells` (`html.replace(/<(t[dh])([^>]*)><\/\1>/g,...)`), `render.mjs:351-354` `normaliseVoidTags` (`html.replace(VOID_TAGS_RE,...)`), `template.mjs:714-727` `injectAnchorHeadings` (`html.replace(HEADING_REGEX,...)`, `HEADING_REGEX` itself at 694 has no ``/`
` leading alternative) — all three are bare whole-string `.replace()` calls over **rendered** HTML with no `replaceOutsideCode`-style guard, contrary to WIP.md's own Don't rule ("a rendered-HTML rewrite uses `replaceOutsideCode` or the ``/`
` leading-alternation shape"). Safety today rests on code content already being entity-escaped by the renderer (stated only locally, e.g. template.mjs:711-713, not as a general contract), which is outside anything `check_code_regions.mjs` verifies for the post-render chain. |
+| A3-7 | CONFIRMED | R2 struct fits (textbook "several unrelated jobs"). | `template.mjs` confirmed to hold, beyond its core templating job: a strftime formatter spanning **exactly** `940-989` (`STRFTIME_*` tables 940-945, `formatDate` 947-980, `parseDate` 982-989); URL helpers `relativeUrl`/`absoluteUrl`/`encodeSpaces` (917-936); and — beyond what A3-7's own citation mentions — a fourth escape-helper implementation under an explicit "`---------- §5.15 escape helpers ----------`" section header (991-998, `HTML_ESCAPE`/`escText`), reinforcing the "several unrelated jobs" struct claim. |
+| A9-7 | CONFIRMED | R2 dup fits (not yet diverged; "safety-critical" framing justifies keeping it above A3-4/A2-4's tier despite being nominally the same severity). | `book.mjs:210` `CODE_OR_PRE_BOOK = /]*>[\s\S]*?<\/code>\|]*>[\s\S]*?<\/pre>/` re-typed, **in the same file**, inside `book.mjs:228-229` `IMG_SRC_RE_BOOK` (whose first two alternatives are that exact text). `pdf.mjs:143-144` `IMG_SRC_RE` confirmed byte-identical in full to `book.mjs`'s `IMG_SRC_RE_BOOK`. `offline-rewrite.mjs:299` `HTML_COMBINED_RE` opens with the identical code/pre alternation text before diverging into its own href/src third alternative. Did not independently confirm "no gate ties them" beyond consistency with everything else read (no fragment-identity check found anywhere in the gates I reviewed). |
+| A9-10 | CONFIRMED | Same R2-vs-R3 judgment call as A2-2 — defensible either way. | `extractImagePaths` (`pdf.mjs:146-158`) has zero callers repo-wide: only its own definition and a documentation-table row (`docs/Documentation/Pipeline-Stages.md:847`) reference the name; no `.mjs` file calls it. `deriveBookOutputs` (108-110) confirmed live, called from `writePdf` at line 61. |
+| L2-1 | CONFIRMED, both halves | R1 fits strongly — this is a literal recurrence of a bug class already fixed once elsewhere (`check_tree_fresh.mjs`), with a concrete failure scenario demonstrated for each half. | **serve.mjs half:** `builder/serve.mjs:138` `IGNORED_PREFIXES = ["_site","_site-offline","_site-pdf","_serve","_pdf","node_modules",".git"]`, checked via exact `.includes(segs[0])` (line 144) — no `_site-basepath` entry, so a `--dest docs/_site-basepath` run mid-`serve.bat` is not filtered, causing the claimed spurious rebuild. Git history confirmed exactly as claimed: `scripts/lib/markdown-files.mjs` (with its general `isOutputTree` prefix-matcher, `OUTPUT_TREES=["_site","_serve","_pdf"]`, `.startsWith(prefix)`) was created in `3834815d` (2026-09-23 21:33:29); `60bb6f5` (2026-09-23 21:45:22, 12 minutes later, same day) then edited `IGNORED_PREFIXES` directly (`git show 60bb6f5 -- builder/serve.mjs` shows the literal diff, removing `"_serve-offline","_serve-pdf"`) **without** switching it to the newly-created shared helper. **build_corpus.mjs half:** `eval/build_corpus.mjs:59-71` `EXCLUDED_PATHS` does list `"docs/_site-basepath"` explicitly, but `isExcluded` (109-111) tests `rel===p \|\| rel.startsWith(p+"/")`, which does not match `"docs/_site-basepath-offline"` or `"...-pdf"` (next char after the prefix is `-`, not `/`) — confirmed by hand-tracing the string test. `markdown-files.mjs`'s `isOutputTree` correctly covers all of these via a plain prefix match. |
+| L4-3 | CONFIRMED | R2 dup fits (single-file scope, moderate cost). | `vendor-assets.mjs:222-252` `fetchToFile` and `:279-319` `fetchAttachment` both independently implement: `fetch()` wrapped in try/catch → `{ok:false,status:"network error (...)"}"`, then `if(!res.ok)` check, then a second try/catch around `Buffer.from(await res.arrayBuffer())`, then — after their (legitimately different) validation logic — the **identical** atomic-write idiom: `` const tmpPath = `${destPath}.tmp-${process.pid}-${Date.now()}`; await fs.mkdir(...,{recursive:true}); await fs.writeFile(tmpPath,buf); await fs.rename(tmpPath,destPath); ``. |
+| L4-7 | CONFIRMED equivalent today | R2 dup fits, matching A2-7's reasoning (a real prior bug lends weight even though not yet diverged). | Structural comparison: `render.mjs:180-204` `maskInlineCode` and `convert_em_dash_separators.mjs:74-99` `splitInlineCode` share the identical backtick-run-counting algorithm (same `k`/`n`/`p`/`m`/`found` shape), differing only in output format (masked string+stash vs `{code,text}` segment array). **Empirically verified equivalent** via `scratchpad/verify/V1/test_L4-7_equiv.mjs` (scratch-only `export` added to each private function, not the repo): 16 hand-picked edge cases (empty string, no backticks, single/double/quadruple-backtick runs, unmatched backticks, adjacent spans, line-boundary spans, mismatched-length runs, a doubled-backtick span containing one literal backtick, multiple equal-length runs) — all 16 produced identical masked output and identical extracted code-span lists. `convert_em_dash_separators.mjs:70-73`'s own comment confirms a real prior shipped bug in this exact logic ("The previous regex matched single-backtick spans only, so an em-dash inside a doubled-backtick span was rewritten as prose"). |
+| L4-8 | CONFIRMED, with one exception | Per-component severities as separately recorded (R2 for makeTimer/isNonEmpty, R3 for encodeSpaces/splitFragment/statSafe) all fit; no change suggested. | Of the five: **isNonEmpty** (`nav.mjs:338-342`/`seo.mjs:164-168`) — byte-identical. **makeTimer** (`tbdocs.mjs:196-209`/`offline.mjs:98-111`) — byte-identical (=A1-4). **splitFragment** (`render.mjs:1593-1597`/`crawl_check.mjs:51-55`) — byte-identical modulo the parameter name (`href` vs `url`). **statSafe** (`link-check.mjs:455-457`/`check_links.mjs:151-153`) — byte-identical. **encodeSpaces is the one exception**: `search.mjs:204-206` uses `s.replaceAll(" ","%20")`, `template.mjs:925-927` uses `s.replace(/ /g,"%20")` — same behaviour, genuinely different idiom, exactly as A2-5's own "(different idioms)" note already says. So 4 of 5 are byte-identical; the fifth is functionally-but-not-textually equivalent. |
+
+## Conflicts
+
+### 1. `writeBaseline` in `builder/symbol-baseline.mjs`
+
+**Ruling: sides with L2-4.** It equals `JSON.stringify(x, null, 2) + "\n"` for every list except the empty one.
+
+Code (`symbol-baseline.mjs:55-58`):
+```js
+async function writeBaseline(file, src, urls) {
+  const body = urls.map((u) => `    ${JSON.stringify(u)}`).join(",\n");
+  await writeFile(file, `{\n  "src": ${JSON.stringify(src)},\n  "urls": [\n${body}\n  ]\n}\n`, "utf8");
+}
+```
+
+Empirically verified by calling the real (copied, unmodified) `checkSymbolBaseline` with `force:true` against scratch output files (`scratchpad/verify/V1/test_writeBaseline.mjs`):
+
+- Non-empty (`urls:["a","b"]`): actual output `{\n  "src": "docs",\n  "urls": [\n    "a",\n    "b"\n  ]\n}\n` — **byte-identical** to `JSON.stringify({src:"docs",urls:["a","b"]}, null, 2) + "\n"`.
+- Empty (`urls:[]`): actual output `{\n  "src": "docs",\n  "urls": [\n\n  ]\n}\n` (blank line between `[` and `  ]`) vs `JSON.stringify({src:"docs",urls:[]}, null, 2) + "\n"` = `{\n  "src": "docs",\n  "urls": []\n}\n` — **differs**.
+
+L4's "deliberately different (one URL per line)" framing doesn't hold up: `JSON.stringify(...,null,2)` *also* puts one URL per line for any non-empty array (proven above), so "one URL per line" doesn't actually distinguish the hand-rolled version from `JSON.stringify` in any case that matters — they diverge only in the single degenerate empty-list case, and no comment anywhere claims that specific rendering (`[\n\n  ]` instead of `[]`) is wanted. Reads as an accidental artifact of building the array body by `.map().join(",\n")` instead of delegating to `JSON.stringify`, not a deliberate choice. Harmless in practice — `[\n\n  ]` is still valid JSON and round-trips through `readBaseline`'s `JSON.parse` unchanged — so this is a cosmetic R3, not a correctness bug.
+
+### 2. `normalizeBaseurl` / `escapeRegExpBook` in `builder/book.mjs`
+
+**Ruling: sides with A9 (=A9-8/A2-6).** The recorded reason has expired.
+
+`book.mjs:300-302`'s comment: "PLAN-8 §6.12 / §6.13 (duplicated from offline.mjs by design -- book-href-rewrite.rb keeps its own copy of normalize_baseurl so plugins are independent)."
+
+That reason describes the *old Ruby/Jekyll* architecture, where `docs/_plugins/book-href-rewrite.rb` and `docs/_plugins/offlinify.rb` were separate Ruby plugin files with no shared-code mechanism between them. **What `book.mjs` imports today** (lines 27-28): `import { compressHtml } from "./compress.mjs"; import { loadData } from "./data.mjs";` — `book.mjs` is an ordinary Node ES module that already freely imports from two sibling `builder/` modules. The "plugins are independent" premise that justified a private copy no longer describes the code: nothing about the current module graph prevents `book.mjs` from doing `import { normalizeBaseurl } from "./offline-rewrite.mjs"` the same way it already imports `compressHtml` and `loadData`. The comment is also now factually wrong about the location — the current home of `normalizeBaseurl` is `offline-rewrite.mjs` (`offline-rewrite.mjs:232-236`, exported), not `offline.mjs` (confirmed via A2's "sound" note that the offline/offline-rewrite split happened after this comment was written, and via `offline.mjs`'s own import of `normalizeBaseurl` *from* `offline-rewrite.mjs` at line 42).
+
+### 3. `escapeHtml` 3-char vs 5-char
+
+**Ruling: keep A3-2, narrowed, per the ledger's own proposed resolution — confirmed correct.**
+
+`highlight.mjs:249-251` records a specific, narrow, still-valid reason for *its own* 3-char escaper: "Rouge's HTML formatter escapes only `& < >` -- not quotes. Match that so string literals inside code blocks keep their literal \" character." That fully explains why `highlight.mjs` (syntax-highlighted code blocks) differs from a generic 5-char escaper — it is not an unexplained inconsistency by itself.
+
+It does **not**, however, say anything about `render.mjs`'s own internal consistency. `render.mjs` has both a 5-char `escapeHtml` (2225-2227) and a 3-char `escapeHtmlMinimal` (2230-2233, itself unrelated to Rouge/highlighting), and `headingTocHtml` (1437-1450, unrelated to code highlighting — it renders TOC entries) mixes them for sibling inline-token types within one function: `escapeHtml` for `text` nodes (1440), `escapeHtmlMinimal` for `code_inline` nodes (1442). That specific mixing has no recorded justification anywhere and is a separate, narrower fact from highlight.mjs's Rouge-parity choice. So: don't treat highlight.mjs's 3-char choice as an unexplained global inconsistency (it's explained), but do keep the narrower claim that `render.mjs`'s own TOC rendering inconsistently escapes apostrophes between adjacent token types (dormant today — confirmed no counter-evidence found that any current TOC'd heading contains an apostrophe, but not exhaustively re-swept).
+
+## Noticed in passing
+
+- `offline.mjs:364-365`'s `buildSitePaths` comment also says "the diff tools call this without running templatePhase first" — a fifth live reference to the deleted `_diff`/`_triage` tools beyond A2-2's cited list.
+- `render.mjs` alone now has two near-duplicate HTML escapers (5-char `escapeHtml` 2225-2227, 3-char `escapeHtmlMinimal` 2230-2233), and the latter is itself a third copy of `highlight.mjs`'s escaper (249-254) — a wider "escape helper" cluster than A3-2 alone captures (A2's own "Leads" section already flagged "HTML escape maps x4").
+- A1-6's three plain-`=` bit-0 sites (`tbdocs.mjs:560,1469,1470`) are only safe today because they execute strictly before the three `\|=`'d sites (1570/1573/1598/1610) in source order; nothing enforces that order, so reordering the task graph or the summary code is a silent bit-clobbering hazard beyond what A1-6's "magic numbers" framing alone conveys.
+- `pdf.mjs:115-131` `resolveBookPage` is called at line 59 purely for its throw-on-missing/duplicate side effect; its return value is discarded (the real lookup goes through `site.bookData` elsewhere per its own comment, 112-114) — not a finding, just an easy-to-miss-on-first-read pattern.
+- `render.mjs:19-23` and `counts.mjs:68` form a deliberate, documented circular import (`maskCodeRegions` re-imported back into the module that partly motivates it); confirmed safe as described (both sides are hoisted function declarations), included here only because it's the kind of thing that looks like a bug until you check.
diff --git a/builder/REVIEW-TOOLING-fe9ce12b/V2.md b/builder/REVIEW-TOOLING-fe9ce12b/V2.md
new file mode 100644
index 00000000..cc337dd1
--- /dev/null
+++ b/builder/REVIEW-TOOLING-fe9ce12b/V2.md
@@ -0,0 +1,69 @@
+# Verifier V2 results
+
+Verified against commit `fe9ce12b` (read via `git show fe9ce12b:` and `git archive fe9ce12b`
+throughout; the working branch moved to `86a70107` partway through this session, confirmed not to
+affect any citation below since every read was pinned to the `fe9ce12b` ref).
+
+## Findings table
+
+| ID | Verdict | Severity note | Correction or evidence (path:line at fe9ce12b) |
+|---|---|---|---|
+| A4-1 | CONFIRMED | R3 fits (trivial one-liner, self-contained) | `statSafe` byte-identical: `builder/link-check.mjs:455-457` and `scripts/check_links.mjs:151-153` both read `function statSafe(p) { try { return fs.statSync(p); } catch { return null; } }`. |
+| A4-2 | CONFIRMED | R2 fits | `builder/check.mjs:422-449` `findingsFor` and `scripts/check_links.mjs:602-634` `buildFindings` run the same broken/forbidden/html/a11y/dupIds/remoteAssets translation logic (same message templates, e.g. `` `${s}: a11y-img-missing-alt: src=${e.src}` ``), differing only in the path-relativizer (`posix(src)` vs `rel(p)`) and a null-guard. "Human-readable twin already shared" confirmed: both files import `formatLinkReport, formatIntegrityReport` from `builder/link-check.mjs` (check.mjs:35-40, check_links.mjs:69-75) — only the *structured*-findings half is unshared. |
+| A4-3 | CORRECTED | Substance holds; suggest R1 over R2 (see below) | crawl_check's 5 is exact: `scripts/crawl_check.mjs:98-102`'s if/else chain covers exactly a/href, link/href, img/src, script/src, iframe/src. But `LINK_ATTR_TABLE` (`builder/link-check.mjs:38-60`) is **21 tags / 26 flattened tag-attr pairs / 9 distinct attribute names**, not 20 (counted programmatically in scratch `count_table.mjs`). Likely an off-by-one against the map's 21 entries. No record of the narrower table found in crawl_check.mjs (only import is `htmlparser2`'s `Parser`). Severity: the rubric calls "already diverged" copies the **worst** case for `dup`, and this one already has — crawl_check silently never checks `srcset`, `poster`, `cite`, `formaction`, `action`, `data`, `longdesc` links on the live, post-release site. That reads as R1 ("has already produced a divergence") rather than R2; flagging for the orchestrator's judgment, not insisting. |
+| A5-1 | CORRECTED | R1 dup/conv fits | All three claims confirmed by reading: `check_a11y.mjs` (63-79) has no `--help` case, so `--help` hits `else { console.error("unknown arg..."); process.exit(2); }`; `pick_a11y_sample.mjs:144-147` and `sweep_a11y.mjs:106-110` both print usage via `console.error` (stderr) and `process.exit(0)`. `check_dot_fit.mjs:47` and `build_dot_metrics.mjs:55` each have exactly one argv read, `process.argv.includes(...)`, no validation, so a typo is silently ignored. stdout-vs-stderr also confirmed: `check_a11y_fingerprint.mjs:115-121` and `check_axe_patch_equiv.mjs:43-45` print `--help` via `console.log` (stdout) at exit 0, vs `pick_a11y_sample`/`sweep_a11y`'s stderr. Correction: "7 scripts" underc­ounts by one. `check_tree_fresh.mjs` is explicitly in A5's own named scope (PLAN-TOOLING-REVIEW.md's A5 row) and has the identical hand-rolled `for` loop (check_tree_fresh.mjs:76-90) *and* is itself a third stdout example (`console.log` at line 82) — it directly supplies the "stdout in some" half of this very finding's third claim, so excluding it from the headcount looks like an oversight, not a deliberate scoping choice. Correct count: 8 CLI scripts in A5's scope hand-roll argv (5 loop-based + 2 `.includes()`-based + check_tree_fresh), none via a shared helper. |
+| A5-2 | CONFIRMED | R2 fits | Convention at `docs/Documentation/Extending.md:640-648` (exact: 640 heading, 644-646 the 0/1/2 table, 648 "Separating 1 from 2..."). `build_dot_metrics.mjs` has no `uncaughtException`/try-catch around its puppeteer work; its only exit path is `process.exitCode = 1` for STALE (line 148) — a crash and a real finding both read 1. `pick_a11y_sample.mjs`'s `discover()` (159-169) calls `readdirSync` with no try/catch; an absent tree throws uncaught → Node's default exit 1, same as `if (missingPages.length) process.exit(1)` at line 310. Confirmed this script runs live in `check.bat:23` (`node scripts/pick_a11y_sample.mjs --check`). `check_dot_fit.mjs:31-34` has the guard, with a comment citing "Extending.md's gate conventions" verbatim. |
+| A5-3 | CONFIRMED | R2 fits | `pick_a11y_sample.mjs:122` `STUB_CEILING = 100`; discover fn at 159-169; use at 184. `sweep_a11y.mjs:78` `STUB_TAG_CEILING = 100`; `staticTagCount`+`discoverPages` at 129-153. Both recursive walkers are near-identical, independently written. `pick_a11y_sample.mjs:131,142,217-218` confirm `--propose` reads `perf/results/a11y-sweep.jsonl`, the exact file `sweep_a11y.mjs`'s `--out` default writes — so the two ceilings do have to stay in step for the join to mean anything. |
+| A5-5 | CONFIRMED | R2 fits | `check_dot_fit.mjs:76-87` and `build_dot_metrics.mjs:57-68` each hand-build an Inter-webfont host-page string and call `puppeteer.launch({ headless: true, args: ["--no-sandbox", "--disable-dev-shm-usage", "--allow-file-access-from-files"] })` verbatim, no comment near either call explaining the third flag. `axe-scan.mjs:258-264`'s `LAUNCH_ARGS` (`["--no-sandbox", "--disable-dev-shm-usage"]`, no third flag) has a detailed comment explaining the first two and noting `book/render-book.mjs` uses "the same pair for the same reason" — silent on the third flag because it doesn't carry it, for the same class of file:// load. "Three repo-root idioms in scope" confirmed: check_dot_fit/build_dot_metrics both use `path.dirname(path.dirname(fileURLToPath(import.meta.url)))` (identical to each other), axe-scan.mjs:29 uses `resolve(fileURLToPath(import.meta.url), "../../..")` — a third, different expression. |
+| A5-6 (+L2-3) | CONFIRMED | R2 fits | `builder/dot.mjs:135-155` `listDotSources` is never exported (only `regenerateDot`, line 45, calls it internally) — confirmed via `export` grep. `check_dot_fit.mjs:49-67` `findDotSvgs` independently re-implements the same readdir/ENOENT-catch/underscore-skip/recurse walk, then extra-maps each `.dot` to its `.svg`. "Five gates already import the chain" confirmed as the precedent: `scripts/lib/markdown-files.mjs` has exactly 5 importers (`eval/nav_hops.mjs`, `check_code_regions.mjs`, `check_tree_fresh.mjs`, `convert_em_dash_separators.mjs`, `lib/tb-fences.mjs`) — the established "import the shared walker" convention this instance doesn't follow. L2-3 merge confirmed: neither `listDotSources` nor `findDotSvgs` uses `isOutputTree`; both use the same broad `"_"/"."`-prefix skip (dot.mjs:146, check_dot_fit.mjs:58). |
+| A6-1 | CONFIRMED | R1 fits (concrete, already-diverged copies) | Accumulator+report loop byte-close across all three: `check_page_baseline.mjs:38-44,128-139`, `check_book_coverage.mjs:81-87,158-170`, `check_symbol_index.mjs:40-45,361-370` — line numbers match exactly. The cited divergence is exact: `check_book_coverage.mjs:162` is the **only** one with `` detail.replaceAll("\n", "\n       ") ``; the other two print `` `       ${detail}` `` unindented. The 4-gate crash handler (`process.on("uncaughtException", ...); process.exit(2);`) is byte-identical at `check_page_baseline.mjs:34`, `check_book_coverage.mjs:27`, `check_symbol_index.mjs:38`, `check_publish_policy.mjs:29`. |
+| A6-2 | CONFIRMED | R1 fits (three-way live disagreement, on the page about avoiding exactly this) | Compared all three accounts side by side. `test.bat:34-43` frames round 4 as: round 3's *fix* "had put three more wrong numbers into Building.md and one into README.md, under a gate that read neither file" (a fix-introduces-errors narrative). `check_gate_lists.mjs`'s header (~lines 30-44) and `Tools.md:435,437` instead agree with each other: Building.md and README.md "were already wrong, in the same commit that shipped the gate green" (Tools.md:437: "both wrong on the day the gate shipped green") — a pre-existing-staleness narrative, discovered (not caused) at round 4. test.bat is the odd one out, with specific counts ("three... one") not corroborated by either other account. |
+| A6-3 | CONFIRMED | R2 fits (dormant until the hook exists) | `convert_em_dash_separators.mjs` has exactly one `process.exit(...)` in the whole file (line 214: `process.exit(await main(...))`); `main()` only ever `return`s 0 or 1 (line 210); no `catch`/`uncaughtException` anywhere — confirmed by grep. `PLAN-10.md:690-693` ("pre-commit hook would be the right home, not the integrity checker") and `:797` ("Pre-commit hook for em-dash normalisation" listed as deferred/out-of-scope) both confirmed at those exact lines. |
+| A6-4 | CONFIRMED (facts only, per instructions) | R2 struct fits for the missing seam; did not evaluate the proposed design | Fact 1: `check_gate_lists.mjs` never reads a workflow — confirmed, zero matches for `.github`/`workflow`/`.yml` anywhere in the file. Fact 2: the two workflows' gate steps match except the recorded deltas — confirmed by extracting every `name:`+`run:` pair from both files: the 12 shared "Verify.../Accessibility check" steps are byte-identical between `checks.yml` and `tbdocs-gh-pages.yml`, in the same order. The only differences are exactly the three recorded: `checks.yml:126-127`'s extra "Verify the build's own link checker" (`--case fixture-built --case fixture-built-offline`) step, absent from the deploy workflow; the deploy build step's extra `--url`/`--baseurl` (`tbdocs-gh-pages.yml:96`); and deploy-only steps (Setup Pages, Render book PDF, artifact uploads, deploy/release jobs). |
+| L1-1 | CONFIRMED | R1 fits | `sweep_a11y.mjs:120-121` and `check_a11y_fingerprint.mjs:134-135` are both exactly `themeArg === "both" ? THEMES : [themeArg]`, no validation, vs `check_a11y.mjs`'s `pick()` (82-95) which exists specifically because `--theme drak` once did this. Traced the full consequence: `axe-scan.mjs`'s `buildMatrix` (699-739) builds the report label straight from the string (`` `${filePath} [${theme}, ${viewport}]` ``, line 737) and `gotoPage` (629-634) applies it via `setAttribute("data-theme", t)` with no validation either; the dark CSS is scoped to `[data-theme=dark]` only, so an unrecognized value silently renders light while every report line is mislabelled — exactly reproducing the original bug in two tools that never got the fix. |
+| L1-2 | CONFIRMED | R1 fits | All 7 `opt()`-shaped copies found and classified into 4 variants: (A) unsafe `(n,d)=>{const i=argv.indexOf(...); return i<0?d:argv[i+1];}` — `census_attributes.mjs:86`, `check_examples.mjs:93`, `tbbuild.mjs:61` (3 copies); (B) safe, checks `argv[i+1]` truthiness — `addin_test.mjs:68`, `tbrun.mjs:105-108` (2 copies); (C) 1-arg, no default — `build_package_api.mjs:56` (1); (D) inline/unnamed — `check_publish_policy.mjs:31-33` (1). 3+2+1+1 = 7 in 4 variants, exact. NaN trace confirmed: `tbbuild.mjs:68` (`Number(opt("port", 9333))`) and `:70` (`Number(opt("timeout", 180))`) use variant A, so a trailing `--port`/`--timeout` returns `undefined` not the default, giving `NaN`; `check_examples.mjs:102-104` (jobs/port/batch) same. |
+| L1-3 | CONFIRMED | R1 dup/hack fits | `tbrun.mjs:112-116` `VALUE_FLAGS` names every value-taking flag explicitly. `tbbuild.mjs:62`: `` const proj = args.find((a, i) => !a.startsWith("--") && !args[i - 1]?.startsWith("--")); `` — traced `tbbuild --keep proj`: args=["--keep","proj"]; index 0 fails (starts with "--"); index 1 fails too (`args[0]` = "--keep" starts with "--", so the *previous-token* guard rejects it even though `--keep` is a boolean switch with no value). `proj` ends up `undefined` → line 75's `if (!proj || ...)` fires the usage error. Reproduced by trace, not by running. |
+| L1-4 | CONFIRMED | R1 fits (tbdocs is the flagship, CI-invoked tool) | Full trace confirmed: `tbdocs.mjs:189-191` `else { throw new Error(\`Unknown argument: ${a}\`); }`; `main()` (1616-1624) calls `parseArgs` synchronously as its first statement; `main().catch((err) => { ...; process.exit(1); })` at 1628-1635 catches everything uniformly. Bit 0 (exit 1) is documented elsewhere in the same function (line 1610, `process.exitCode \|= 1` for a link-check failure) as meaning "link check failed" — a parse error reuses that exact code with nothing to distinguish it. |
+| L1-5 | CONFIRMED | R2 fits | `check.bat` (34 lines, read in full) does not call `check_links.mjs` anywhere; its own header (lines 1-5) says link/integrity checking "moved into the build itself." `check_links.mjs:307-308` ("Tolerate unknown flags passed through via check.bat's %*") and `:411` ("check.bat / CI both pass the same string for --root-dir...") are both now stale. Independently corroborated at `builder/PLAN-checks.md:14`: "`check.bat` no longer invokes `check_links.mjs`." |
+| L1-6 | CONFIRMED | R2 conv fits generally; the gen_attribute_probes case is arguably a live R1-class defect in its own right | Traced (not run) both: `gen_attribute_probes.mjs:1147-1158` `main(argv)` only short-circuits when `argv.length < 1`; a lone `--help` has length 1, so `out = argv[0] = "--help"`, and `await fs.mkdir(path.join("--help", "Sources"), { recursive: true })` runs — it really does create a `--help/Sources` directory instead of printing help. `render-book.mjs:210-220`: no `-h`/`--help` case in the loop; `--help` fails every explicit check (starts with `-`, so the bare-positional branch also refuses it) and falls to `else { console.error("unknown arg: --help"); process.exit(2); }` — it never reaches the actual usage text at line 223. |
+| L1-11 | CONFIRMED | R2 fits | The 4-gate handler cross-checked again (see A6-1). `check_tb_registry.mjs`: no `uncaughtException` handler anywhere (grep confirmed); its only error path is a local `try{...}catch(e){...; process.exitCode = 1;}` (266-268) wrapping the whole self-test body, so both a genuine assertion failure and an environment crash land on exit 1. `Tools.md:368` for check_publish_policy.mjs ends "Exits 1 naming each failed assertion." with no mention of exit 2, unlike the neighboring entries for `check_tree_fresh.mjs` (line 375: states all of 0/1/2) and `check_gate_lists.mjs` ("Exits 1 ... 2 if it cannot run.") — confirmed the omission is real and is specific to this one entry. |
+| L1-13 | CONFIRMED | R2 struct fits (matches the rubric's own "missing seam" example) | Read every `--self-test`/`selfTest` implementation in the repo (`check_code_regions.mjs`, `check_gate_lists.mjs`, `check_links.mjs`+`check_links_diff.mjs`, `check_regex_safety.mjs`, `impexp.mjs`): all assert the tool's *domain* logic (gate comparison, mutation detection, regex classification, round-trip fidelity), none assert anything about the tool's own argv/flag handling. No dedicated CLI/argv test file exists anywhere in the tree (checked `test/`, and every `node:test` user, all of which are the add-in harness, unrelated). This absence is also positively demonstrated: L1-1, L1-2, L1-3 and L1-6 are all real, currently-shipping argv-handling defects that a minimal parser test would have caught. |
+| L2-5 | CORRECTED | R2 fits; "four expressions" is a defensible bucketing, not a precise count | 19 inline-deriving files confirmed exactly (census_attributes, tbdocs, build_corpus, nav_hops, run_case, site_search, addin_test, build_dot_metrics, build_package_api, check_code_regions, check_dot_fit, check_examples, check_gate_lists, check_links_diff, check_tree_fresh, convert_em_dash_separators, gen_attribute_probes, wisdom/extract/prep.mjs, builder/write.mjs). At strict AST-shape granularity there are closer to 5 distinct expressions (nested-dirname ×10; URL-relative ×3; `resolve(dirname,"..")` ×4 in eval/, structurally the same as `write.mjs`'s `resolve(__dirname,"..")` once its local `__dirname` shim is inlined [+1]; `join(__dirname,'..','..')` in wisdom/prep.mjs, same strategy but one level deeper [+1]) — "four" holds only if the eval/write.mjs/wisdom trio is counted as one "dirname + relative join" idiom family rather than split by exact depth. `axe-scan.mjs:29`'s exported `REPO_ROOT` confirmed imported by exactly 2 (`pick_a11y_sample.mjs:47`, `sweep_a11y.mjs:51`). `Extending.md:654` confirmed at that exact line — and, notably, its own text ("`resolve(fileURLToPath(new URL("..", import.meta.url)))` is what nearly all of them do") is itself inaccurate: that exact expression is used by only 3 of the 19, not "nearly all" (the nested-dirname shape is the majority, 10 of 19). |
+| L2-6 | CORRECTED | R3 fits (local untidiness, not a correctness defect) | `check_publish_policy.mjs:152-189` confirmed: `mkdtemp` at 152, `fs.rm(tmp, ...)` at 189 is a plain unprotected statement (any throw from the `probe()` helper's uncaught `fs.mkdir`/`fs.writeFile` calls, lines 153-156, skips it). `check_links_diff.mjs:651-750` confirmed: `fixtureDir` declared at 651, `mkdtempSync` at 682, cleanup `if (fixtureDir) fs.rmSync(...)` at 750 sits after two loops (656-728, 734-748) that run the actual checks and can throw, all unprotected by try/finally. "Three siblings do it right" is close but not exact: **four files** use a proper `try{...}finally{...rm...}` — `check_links.mjs` (selfTest, finally at line 755), `impexp.mjs` (selfTest, finally at 762-763), and **both** `check_page_baseline.mjs:46-55` and `check_symbol_index.mjs:314-323`'s `withBaseline`. The "three" reading only works if the latter two (which L4-13 already notes are near-clones of each other) are counted as one pattern rather than two files. |
+| A10-2 | CONFIRMED | R1 as assigned is defensible given how easily a BOM reappears (WIP.md: "editors on Windows add one without being asked"); genuinely dormant today | `wisdom/extract/sitemap.mjs:69-87` `parseFrontmatter`: strips `\r\n`→`\n` (line 70) but never strips a BOM; `content.startsWith('---')` (71) would be false behind a leading ``. `builder/discover.mjs:94-117` has the dedicated `stripBom()` (105-107) called before the frontmatter test (110), with a comment (94-104) narrating the AppGlobalClassObject incident by name. Independently ran a scratch scan (`check_bom.mjs`) over all 912 `docs/**/*.md` files archived at `fe9ce12b` via `git archive`: **0 begin with a UTF-8 BOM** — confirms "dormant" directly rather than by inference. Coercion disagreement with `prep.mjs:343-388` also confirmed: `prep.mjs`'s `parseThreadFrontmatter` additionally handles inline arrays and coerces `'true'`/`'false'`/digit-strings to real booleans/numbers (378-382), while `sitemap.mjs`'s version leaves everything as a string — same input frontmatter parses to different JS types. (This also makes it a *third* independent frontmatter parser alongside discover.mjs and sitemap.mjs, not just a second disagreement.) |
+| A10-3 | CONFIRMED | R2 fits | `sitemap.mjs:61-67` `walk(dir, callback)` is a bare recursive `readdirSync` with no `.md` filter and no output-tree skip (filtering happens in the caller instead, `buildSitemap` line 8). `wisdom/PLAN-3.md:404` confirmed verbatim: "a minimal built-in parser — no dependency on builder/". But `scripts/lib/markdown-files.mjs` (read in full) imports only `node:fs`/`node:path` — zero dependency on `builder/` — so the stated reason doesn't actually justify skipping it. In this specific call (scoped to `docs/Reference/`, which never contains an output tree) the missing `isOutputTree` skip is currently harmless, consistent with R2 rather than a live bug. |
+| A10-4 | CONFIRMED | R2 fits (dead but drifted, so wrong-if-ever-revived) | `wisdom/extract/schemas.mjs` confirmed imported by nothing anywhere in the repo (repo-wide grep for both quote styles, zero hits). Both cited divergences confirmed by direct comparison: `schemas.mjs:12` `source_thread: { type: 'string' }` vs the live `workflow.mjs:49` `thread_path: { type: 'string' }`; `schemas.mjs:24` `section: { type: 'string' }` (free text) vs live `workflow.mjs:77` `section: { enum: ['after-remarks','example','see-also','new-section'] }`. |
+| A10-5 | CONFIRMED | R2 fits | `wisdom/discord/messages.mjs:10-12` `saveManifest` is a plain `writeFileSync`, no temp file. `wisdom/wisdom.mjs:146` (`writeFileSync(deniedPath, ...)`) same. `loadManifest` (4-8) does `JSON.parse(readFileSync(...))` with no try/catch anywhere in the function. Contrast confirmed: `wisdom/extract/state.mjs:62-75` is explicitly commented "Write state atomically (temp file + rename)" and does exactly that (`tmpPath = path + '.tmp'`; `writeFileSync(tmpPath,...)`; `renameSync(tmpPath, path)`). |
+| A10-6 | CONFIRMED | R2 struct fits | Both self-tests counted directly: `impexp.mjs` has 19 `test(...)` calls, `impexp.py` has 19 `test(...)` calls, **and the two lists of test names are byte-identical in the same order**, all 19 (e.g. both open with `'Parse and serialize round-trip in memory'` and close with `'sample.twinpack survives export, import and export again'`) — answers "are the names the same?": yes. Answers "is `--self-test` run by any .bat/workflow/gate?": no — exhaustive repo-wide search of every `.bat` and `.yml` for "impexp" found only `docs/_config.yml:101-104`'s `bundle_extra` download-copy declarations; the two gate scripts that mention "impexp" (`check_gate_lists.mjs:159`, `check_publish_policy.mjs:103`) do so only in unrelated comments, neither invokes it. `Tools.md:958` confirmed verbatim: "the two editions print the same output and write byte-identical project files" — an unhedged claim with nothing mechanical behind it. |
+| L4-9 | CONFIRMED | = A5-3 (R2) + A5-4 (R3); both independently confirmed | A5-3 evidence above. A5-4 (`pad`/`median` identical): confirmed byte-identical — `pick_a11y_sample.mjs:229-232,259` and `sweep_a11y.mjs:279-283` both have `` const pad = (s, w) => String(s).padStart(w); `` and the same 3-line sort-and-midpoint `median`. |
+| L4-13 | CONFIRMED | = A6-1 (R1) + L1-11 (R2) + withBaseline dup; both independently confirmed above | withBaseline pair confirmed byte-close: `check_page_baseline.mjs:46-55` and `check_symbol_index.mjs:314-323` are the same `mkdtemp` → `try{ write initial; return fn(file) }finally{ rm dir }` shape, differing only in the tmp-prefix string, filename, and the exact JSON written. |
+
+## Conflicts
+
+### 1. `findingsFor` (builder/check.mjs) vs `buildFindings` (scripts/check_links.mjs)
+
+**Ruling:** the comments do record the mirroring as deliberate. Quoted exactly:
+
+- `builder/check.mjs:410-414`:
+  > The same conclusions, in the shape scripts/check_links.mjs's `structured` mode emits, so scripts/check_links_diff.mjs can diff the two implementations category by category. This is the whole gate: check.bat going green proves nothing about whether the fused pass still looks at everything the standalone script does.
+
+- `scripts/check_links.mjs:599-601`:
+  > Reduce a pass's conclusions to sorted arrays of tree-relative strings. Category names match the flags that produce them, so a diff says which check drifted rather than only that something did.
+
+Both comments tie the parallel-shape design to `check_links_diff.mjs` cross-checking two *independent* implementations against each other — a legitimate, stated reason under the review's own "recorded decision is not a finding" rule, at the time it was written. The user's option-2 decision (A4, 2026-09-25: `check_links.mjs` becomes a thin wrapper over `builder/check.mjs`) supersedes the premise these comments describe — after that refactor there is one implementation called from two front ends, not two independently-written ones a diff tool cross-checks. Per the task framing, I have not judged whether that decision is correct, only confirmed the comments say what L4 and A4-2 both claim, so the fix that lands with the thin-wrapper refactor needs to rewrite both.
+
+### 2. `extractFromHtml` (link-check.mjs) vs crawl_check.mjs
+
+**Ruling:** A4-3's point holds independently of L4's "different sizes, not a clone" — they answer different questions, not contradictory ones. L4's clone detector correctly reports these as non-clones: `link-check.mjs`'s SAX walker is table-driven off a 21-entry `Map` (`extractFromHtml` proper, plus `LINK_ATTR_TABLE`), while `crawl_check.mjs:91-108`'s `extractFromHtml` is a 5-line hand-written if/else chain — genuinely different code shape and size, so a token-window clone detector is right to stay silent. But A4-3 isn't claiming they're textual clones; it's claiming the two independently-maintained *lists of which HTML attributes carry links* have diverged, with a concrete, live consequence: `crawl_check.mjs` (the one post-release, real-HTTP checker per Building.md:653-655) silently never follows `srcset`, `poster`, `cite`, `formaction`, `action`, `data`, or `longdesc` links, all of which the offline/build-time checker does cover. Both things are true at once: not a clone, and a real coverage gap. Keep A4-3, independent of L4's classification.
+
+### 3. `parseFrontmatter` (wisdom/extract/sitemap.mjs) vs builder/discover.mjs
+
+**Ruling:** same shape of non-conflict as #2. L4 is right that these are "different dialects" — `discover.mjs`'s version wraps the full `gray-matter` library (arbitrary YAML), while `sitemap.mjs`'s is a minimal line-based `key: value` reader with no nesting, no multi-line values, no arrays. A token-clone detector correctly declines to call these clones. A10-2's point is orthogonal: regardless of how sophisticated the rest of the parsing is, *both* implementations start by testing whether the raw text begins with `---`, and only one of the two guards that test against a leading BOM. That gap doesn't depend on dialect sophistication at all — a "richer" or "thinner" YAML dialect is equally blind to a BOM sitting in front of its own opening fence check. Confirmed independently (see A10-2 row: 0/912 files carry a BOM today, so dormant, but the gap is real and unrelated to which dialect is being parsed). Keep A10-2, independent of L4's "different dialects."
+
+## Noticed in passing
+
+- `docs/Documentation/Extending.md:654` itself overstates its own convention: it says `resolve(fileURLToPath(new URL("..", import.meta.url)))` is "what nearly all of them do," but only 3 of the 19 inline repo-root derivations actually use that expression — the nested-`dirname` shape is the majority (10 of 19).
+- `builder/PLAN-10.md:692` cites `scripts/convert_em_dash_separators.py` — stale; the tool has been `.mjs` since the JavaScript port (WIP.md lists only `impexp.py` and `build_fonts.py` as the two remaining Python files).
+- `gen_attribute_probes.mjs --help` doesn't just fail to print help (L1-6's "conv" framing) — traced, it actually creates a `--help/Sources` directory on disk via `fs.mkdir`, a live side effect from the exact command a confused user would type first.
+- wisdom/ has *three* independent hand-rolled-or-library frontmatter readers, not two: `discover.mjs` (gray-matter + BOM strip), `sitemap.mjs` (minimal, no BOM/coercion), and `prep.mjs` (minimal + arrays + boolean/number coercion) — A10-2 covers two of the three pairwise gaps but the triplication itself is broader than either single comparison.
+- impexp.mjs/impexp.py's 19 self-test names are byte-identical in the same order in both editions (real, by-hand engineering discipline to keep the ports in lockstep) — which makes A10-6's "and nothing mechanically checks they still agree" more notable, not less: all that manual care has no automated backstop at all.
diff --git a/builder/REVIEW-TOOLING-fe9ce12b/V3.md b/builder/REVIEW-TOOLING-fe9ce12b/V3.md
new file mode 100644
index 00000000..24f33d23
--- /dev/null
+++ b/builder/REVIEW-TOOLING-fe9ce12b/V3.md
@@ -0,0 +1,100 @@
+# V3 verification -- compiler harness, sample compiling, package API, book pipeline
+
+Verified against commit `fe9ce12b` (all source read via `git show fe9ce12b:`, so later
+commits landing on `staging` during this session did not affect any citation below). Scratch
+scripts under `scratchpad/verify/V3/`: `a7-1-regex.mjs`, `a7-5-opt.mjs`, `l4-10-logicallines.mjs`
+(plus extracted copies of the source files used for line-numbered reading).
+
+## Table
+
+| ID | verdict | severity note | correction or evidence (path:line at fe9ce12b) |
+|---|---|---|---|
+| A7-1 | CORRECTED | R1 dup fits (round-8 bug class cited in-file is a real prior defect) | tbrun.mjs:327 `/^\[(BUILD\]\s+failed\|LINKER\]\s+FAILED)\b/i` vs tb-ide.mjs:734 `BUILD_FAILED`. Ran both against all 5 shapes named in tb-ide.mjs:727-731 (`a7-1-regex.mjs`): tbrun's regex matches **3 of 5** (LINKER FAILED, BUILD FAILED, BUILD failed -- the `/i` flag makes the BUILD-alternative case-insensitive so it catches both listed casings), not "2 of 5". It still misses exactly the two shapes claimed -- "[BUILD] ERROR" and "[LINKER] compilation (codegen) error" -- so the "exit 0 on a failed build" consequence is correct; only the count is off. |
+| A7-2 | CONFIRMED | R2 fits; kind label arguable (reads more like the rubric's "hack" -- an unguarded workaround -- than "struct"), not clearly wrong either way | tb-operate.mjs:421-429 `afterReveal` returns `false` on timeout. All three call sites discard it: openFile tb-operate.mjs:453, setCursor:461, select:475 -- each is a bare `await afterReveal(c);`. Grep confirms no other call site checks the return. The race afterReveal exists to prevent is documented at tb-operate.mjs:401-409 (the measured "xyz"->"zy" cursor-reset corruption); silently proceeding after a timeout reopens exactly that class of corruption. |
+| A7-3 | CORRECTED | R2 dup fits | tb-ide.mjs:848-861 `clickCenter` (no scrollIntoView, no hit-test, no retry) has exactly two call sites, both for `"buildIcon"`: tb-ide.mjs:757 (buildProject) and tbrun.mjs:295 -- confirmed exact. tb-operate.mjs:109-134 `click` has scrollIntoView (115), a shadow-root-aware hit test (119-124) and a retry loop (110-133). "used ... by every scenario" overstated: `click` is called directly in only 4 of the 10 `test/addin/*.test.mjs` files (appdata.test.mjs:83, panes.test.mjs x7, sample10.test.mjs x5, sample15.test.mjs x4), plus twice inside tb-operate.mjs itself (309 msgbox answer, 361 restartIcon). arch/entry/ideserver/keys/reload/symbols never call it. |
+| A7-4 | CORRECTED | R2 dup fits (no divergence yet, so R1 would be wrong) | tb-registry.mjs:588-590 `alive` and :462 `norm`, both unexported. addin_test.mjs:105 `alive` and :230 `norm` are byte-identical in body to their tb-registry.mjs counterparts (only `function`/`const =>` syntax differs). Import count wrong: addin_test.mjs:60-61 imports **10** names from tb-registry.mjs (deleteSettings, finishTidy, ideLists, restoreKeys, SETTINGS_ROOT, settingsKey, snapshotKeys, startTidy, subkeyNames, sweepArchitectureMemory), not 9 -- confirmed by direct count and by `sed -n '60,61p'`. |
+| A7-5 | CONFIRMED | R2 conv fits | All sub-claims reproduced in `a7-5-opt.mjs` plus direct reads. tbbuild.mjs:61 `opt` on a trailing flag returns `undefined` -> `Number()` = `NaN` (script output confirms). tbrun.mjs:105-108 / addin_test.mjs:68 `opt` substitutes the default both for a trailing flag AND for an explicit `""` (script: `optTbrun(["--port",""],"port",9346)` -> `9346`). tbbuild.mjs:62 positional finder: `tbbuild --json proj` finds no positional (script confirms `undefined`) because it only excludes a token whose *predecessor* starts with `--`, with no value/boolean distinction; check_examples.mjs:602 puts `staged.proj` first (confirmed exact line), keeping the bug dormant. tb-ide.mjs:436 `while (Date.now() - t0 < timeout)` with `timeout=NaN` never executes (script: 0 iterations) -> `waitForCompile` returns `{loaded:false,...}` at once -> tb-ide.mjs:484-491 `compileOutcome` reports exit 3 "the IDE never reported `` as open" -- the misleading message, confirmed by tracing tbbuild.mjs:145-146. `die()` "three structures" confirmed: tbbuild.mjs:118-122 (function, calls `shutdown()`), tbrun.mjs:110 / addin_test.mjs:69 (identical one-line arrow, no shutdown call built in), and check_tb_registry.mjs:267-268 (no `die` helper at all -- bare try/catch setting `process.exitCode = 1`). |
+| A7-8 | CONFIRMED | R2 dup fits | All ten `test/addin/*.test.mjs` files hand-roll the HERE/HOST/lane preamble (`fileURLToPath` + `addinLane()` + `path.join(...,"host")`) and the byte-identical `describe(..., { skip: lane ? false : "run it with addin-test.bat" }, ...)` object -- confirmed by grep across all ten. Six touch a "console lines since a mark" reader: appdata.test.mjs:33 `probeLines` and panes.test.mjs:86 `probeLines` (same name, different bodies: `.slice(15)` for `"[AppDataProbe] "` vs `.slice(13)` for `"[PanesProbe] "`); arch.test.mjs:31 `loadsSince` and reload.test.mjs:37 `probeSince` (both regex-capture, different regexes); keys.test.mjs:28 `linesSince` (plain split+trim, no filter); sample10.test.mjs:81 inline (no split at all, bare `.trim()`). That is 4 distinguishable shapes (prefix-slice x2, regex-capture x2, plain-trim, bare-trim) reasonably compressed to "three different ways" plus a degenerate case; not a stretch. |
+| A8-1 | CONFIRMED | R1 dup fits (already-diverged keyword lists; the orchestrator's own "dormant" finding for the main Overridable claim already satisfies R1's "will [misfire] on the next ordinary change") | census_attributes.mjs:170-179 `blankStrings` toggles `inStr` on every literal `"` with no dedicated `""`-pair branch. twin-api.mjs:67-71 explicitly special-cases `c==='"' && s[k+1]==='"'` (blanks both to two spaces, stays in-string, skips the second char). Confirmed divergence exists. Caveat worth recording: because a valid `""` escape is always *two* toggles, census's naive parity-toggle still tracks the true string boundary correctly on syntactically valid source (verified by hand-tracing `"it's ""fine"" isn't it"` and by the L4-10 script's equivalent case) -- so this is a real code-shape/fragility divergence, not a demonstrated misclassification today. BLOCK_COMMENT_RE claim fully confirmed: census_attributes.mjs:164/166 `decomment` is called fresh per individual line (lines array built at :286 via plain `.split(/\r?\n/)`, `decomment` invoked per-`lines[i]`/`lines[j]` throughout, e.g. :308,:316,:349) with no shared state between calls; twin-api.mjs:56 declares `inBlock` outside the per-line loop and threads it across lines (:63-66). A `/* */` spanning a line boundary is not stripped by census_attributes.mjs at all. |
+| A8-2 | CORRECTED | R2 struct fits | Core claim confirmed: census_attributes.mjs:79 imports `../scripts/lib/tb-packages.mjs`; render.mjs's comment (ending line 383) states verbatim "builder/ must not depend on scripts/". check_tree_fresh.mjs:57-63 IGNORED_FILES / its dedicated comment for census_attributes.mjs confirmed. But the "ten path citations" list is wrong: `git grep -n "builder/census_attributes.mjs" fe9ce12b` (excluding the file's own self-reference at builder/census_attributes.mjs:4) finds **11** files, and the broader `git grep -n "census_attributes" fe9ce12b` the task specifies finds **13**. The ledger's ten-item list wrongly includes WIP.Build.md (only cites the bare filename at :581, "shared with `census_attributes.mjs`" -- never the `builder/` path) and omits two files that DO cite the literal path: scripts/build_package_api.mjs:12 and scripts/lib/tb-fences.mjs:334 and :349. |
+| A8-4 | CONFIRMED | R2 struct fits | Neither has self-test scaffolding: `grep -i "selftest\|node:test\|assert"` is empty for census_attributes.mjs; gen_attribute_probes.mjs's only "assert" hits (:261,:591,:1213) are unrelated prose, not a JS assertion. gen_attribute_probes.mjs:961-978 `parseTargets` carries its own historical-misparse comment at :946-947 (the `\b`-after-"const" plural bug). census_attributes.mjs documents its own past silent misparses in its header (:42-67, e.g. point 5's 14 lost Interface-member sites) and at :150-152 (368 Declares swallowed into a phantom Type) -- satisfying "both have shipped silent misparses before" even though census_attributes.mjs has no function literally named `parseTargets` (confirmed: zero matches for that name in the file; the ledger's possessive grammar ties "parseTargets" only to gen_attribute_probes, which matches). |
+| L2-2 | CONFIRMED | R1 dup fits (the two functions can pick different installs off the same Desktop today, not just on a future change) | census_attributes.mjs:99-119 `findInstall`'s Desktop scan validates each BETA folder via `existsSync(path.join(b.dir, "packages"))` (:115). tb-install.mjs:19-35 `findIde`'s scan validates via `existsSync(path.join(desktop, name, "twinBASIC.exe"))` (:28-29) instead -- a genuinely different criterion, confirmed by direct comparison. findInstall's home-dir fallback `process.env.USERPROFILE \|\| os.homedir()` (:108) has no counterpart in findIde, which uses `process.env.USERPROFILE ?? ""` alone (:22) -- an unset USERPROFILE makes findIde build a relative `"Desktop"` path instead of falling back to the real home directory. tb-install.mjs's own header (:1-3) states the shared-module rationale this private copy undercuts. |
+| L4-10 | CONFIRMED | R1 dup fits | tb-fences.mjs:423-445 `logicalLines` (confirmed private -- absent from the file's `^export` list) vs twin-api.mjs:51-86 `logicalLines` (exported). Both claimed differences directly confirmed by copying both functions verbatim into `l4-10-logicallines.mjs` and running them: twin-api strips a leading BOM (its own dedicated test: tb-fences leaves it, twin-api removes it) and threads `/* */` across physical lines (tb-fences does not: a 2-line block comment leaves both comment delimiters embedded as literal text in tb-fences' output, confirmed by the script). See the dedicated section below for the full replacement-question answer the task asked for. |
+| A9-1 | CONFIRMED | R2 hack fits (contained per-file, but not guarded -- matches the rubric's un-met workaround test #2 exactly) | Grepped all 13 production shim files (fast-array-onebuf, fast-decode-name, fast-dict-onebuf, fast-indirect-objects, fast-inflate, fast-number-to-string, fast-parse-name, fast-parse-number, fast-parse-object, fast-pdfnumber-pool, fast-refs-class, fast-size-in-bytes, fast-sync-load) for shape/version assertions: every one has only a `__xInstalled`-style idempotency guard (e.g. fast-array-onebuf.mjs:166, fast-sync-load.mjs:88) that blocks double-installation, never a check of what is being overwritten. parallel-deflate.mjs:50 `ParallelStreamWriter extends PDFStreamWriter` has no guard of any kind. package.json:20 pins `"pdf-lib": "1.17.1"` exactly, confirmed. perf/notes/08-pdf-lib.md:1751-1757 confirms verbatim: "The shim's correctness depends on the upstream pdf-lib source being structurally what the line-by-line port assumed," and the pin "so a stray `npm update` can't silently swap upstream" -- covers accidental drift only, exactly as claimed. perf/notes/08-pdf-lib.md:5048-5061 confirms the @cantoo fork evaluation and rejection verbatim. |
+| A9-2 | CONFIRMED | R2 struct fits | `git grep -l "pdf-lib" fe9ce12b -- test/ scripts/` is empty; no test file exists anywhere under book/ (`git ls-tree` for `book/.*test` is empty). No automated shimmed-vs-stock comparison exists anywhere in the tree; only prose (e.g. the @cantoo A/B numbers in perf/notes/08-pdf-lib.md). scripts/check_axe_patch_equiv.mjs (confirmed real, SOURCE_PATCHES-based, see its header :1-27) is a structurally apt model for the proposed fix. |
+| A9-3 | CONFIRMED | R2 dup fits | Compared all six named helpers between fast-array-onebuf.mjs and fast-dict-onebuf.mjs directly. Exactly two are identical apart from naming: `_registerContext` (array :104-110 vs dict :144-150, differ only in the file-label string inside the thrown Error) and `_appendArray` (array :121-125 vs dict :161-165, differ only in the shared buffer/counter names `arrayMain`/`arrayMainLen` vs `main`/`mainLen`). The other four are NOT identical, only similarly shaped: `pack` uses different bit-packing constants (array :91-95 `POW_24`/24-bit vs dict :127-131 `POW_25`/23-bit -- dict reserves 2 extra gap bits PDFArray does not need); `_cow` (array :129-138 vs dict :175-184) differs by dict's extra `+ (d & GAP_MASK)` term; `_makeFromRange`/`_makeFromAppend` (dict :226,:239) take an extra leading `ProtoClass` parameter array's versions (:155,:160) do not, for PDFCatalog/PDFPageTree/PDFPageLeaf dispatch. The recorded reason at fast-array-onebuf.mjs:36-39 names only the singleton-context mechanism, leaving `_appendArray`'s exact duplication unaddressed by any recorded rationale. |
+| A9-5 | CORRECTED | R3 dup fits regardless of the exact count | The pattern is real but the count is off: exactly **12** files in book/lib/ (not "~15") contain `createRequire` + `require('pdf-lib/cjs/...').default`: fast-array-onebuf, fast-dict-array, fast-dict-iter, fast-dict-onebuf, fast-indirect-objects, fast-number-to-string, fast-parse-dict, fast-parse-name, fast-parse-number, fast-parse-object, fast-size-in-bytes, fast-sync-load -- confirmed exhaustively (`git grep -l "require('pdf-lib" fe9ce12b -- book/lib/`, cross-checked against `createRequire` count, both give 12). Zero matches in book/render-book.mjs, builder/book.mjs, builder/pdf.mjs. Of the 12, three (fast-dict-array, fast-dict-iter, fast-parse-dict) are among the four A/B-baseline shims already approved for deletion elsewhere in the ledger, so the production-only count is 9. The five ESM-style shims (fast-decode-name, fast-pdfnumber-pool, fast-refs-class, fast-refs, outline.mjs) use plain `import {X} from "pdf-lib"` instead and do not participate in this particular duplication. |
+
+## L4-10: could twin-api's logicalLines replace tb-fences' without losing its quote-aware comment stripping?
+
+**Yes on the specific question asked.** Both functions were copied verbatim (fe9ce12b) into
+`scratchpad/verify/V3/l4-10-logicallines.mjs` and run on the requested cases plus a few more.
+Full output is in that file's run log; the relevant lines:
+
+```
+=== apostrophe inside a string ===          Dim s As String = "it's a test"
+tb-fences : "Dim s As String = \"it's a test\""
+twin-api  : "Dim s As String = \"           \""      (content blanked, but NOT truncated)
+
+=== "" escape then an in-string apostrophe ===   "it's ""fine"" isn't it"
+tb-fences : "Dim s As String = \"it's \"\"fine\"\" isn't it\""   (unchanged, no early break)
+twin-api  : "Dim s As String = \"                      \""       (blanked, no early break)
+```
+
+Neither implementation mistakes the apostrophe for a comment start, including in the combined
+case (a `""` escape immediately followed by a real apostrophe, still inside the string) -- so
+swapping in twin-api's version would **not** lose tb-fences' quote-aware comment stripping.
+twin-api's version is quote-aware for the same structural reason tb-fences' is: the `'`-breaks-
+the-line check sits behind an `if (inStr) { ...; continue; }` branch in both, so it is never
+reached while a string is open.
+
+**It is not a drop-in swap, though** -- four other differences surfaced by the same test run,
+none of which the ledger's citation mentions, that a real replacement would have to absorb:
+
+1. **Return shape.** twin-api keeps a blank source line as an empty-text entry; tb-fences drops
+   it. Confirmed on a 3-line source with a blank middle line: tb-fences returns 2 entries
+   (`at:0`, `at:2`), twin-api returns 3 (`line:1`, `line:2` with `text:""`, `line:3`).
+2. **Field naming/numbering.** `line` (1-based) vs `at` (0-based) -- a caller reading `.at`
+   off twin-api's result gets `undefined`.
+3. **No trim.** twin-api never calls `.trim()`; tb-fences trims every returned line. Confirmed
+   on `"Dim x As Long  ' this is a comment"`: tb-fences returns `"Dim x As Long"`, twin-api
+   returns `"Dim x As Long  "` (two trailing spaces kept). Left untrimmed, a line with leading
+   whitespace could fail tb-fences' own `^`-anchored classifier regexes (none of which allow
+   for arbitrary leading space before the first token).
+4. **`Rem` recognition.** twin-api blanks a whole `Rem ...` line to `""`; tb-fences leaves it
+   as ordinary text (confirmed: `"Rem this whole line is a comment"` survives unchanged through
+   tb-fences). Likely dormant either way -- `Rem` is legacy VB6, unlikely to appear in the
+   corpus -- but it is a behaviour change a swap would introduce, not just a bug fix.
+
+One thing the swap would **fix**, not just preserve: tb-fences has **no `/* */` handling at
+all**, not even the single-line case. Run on census_attributes.mjs's own DAO.twin example
+(its header, :58-60), `"/* voffset &H00A8*/ Property Get X()"`, tb-fences returns the comment
+delimiters embedded verbatim in the logical line; twin-api correctly returns `"  Property Get
+X()"`. On a comment spanning two physical lines, tb-fences returns the *second* physical line as
+`"spans two lines */ Dim z As Long"` -- the trailing declaration is buried mid-line, unreachable
+by any of tb-fences' `^`-anchored regexes, which is exactly the "silent misclassification"
+shape census_attributes.mjs's own header (:58-60) says cost it 14 Interface-member sites. twin-api
+correctly recovers `"  Dim z As Long"` as its own line. This is a real latent gap in tb-fences
+today, independent of whatever the corpus currently exercises (see noticed-in-passing #1 below).
+
+## Noticed in passing
+
+1. tb-fences.mjs's `logicalLines` has zero `/* */` handling (not just missing the multi-line
+   case) -- exposed to the exact 14-site failure class census_attributes.mjs's own header
+   documents paying for; worth its own finding if docs/ code samples ever use block comments.
+2. tb-fences.mjs's apparent BOM-safety is accidental, not designed: `"Dim x".trim()`
+   strips the BOM because ECMAScript's `trim()` treats U+FEFF as whitespace (verified directly),
+   not because tb-fences.mjs does anything BOM-aware -- fragile if ever accessed without the
+   trailing `.trim()` call.
+3. A8-1 and A8-4 point at the same underlying gap from two angles: census_attributes.mjs's
+   MODS list and gen_attribute_probes.mjs's parseTargets are both hand-kept keyword/phrase
+   classifiers with a documented history of silent misparses and no fixture-based regression
+   test; a shared "labelled-keyword-list classifier + ride-along probes" fix could cover both,
+   not just census_attributes.mjs alone.
+4. A9-3's proposed `book/lib/onebuf-range.mjs` factory needs to parametrize over the
+   subclass-dispatch (`ProtoClass`) and gap-mask logic dict needs and array does not, not just
+   rename the buffer variable -- `_cow`/`_makeFromRange`/`_makeFromAppend` are genuinely
+   different, not copy-paste twins, so a naive single-body factory would under-serve one side.
+5. tbbuild.mjs, tbrun.mjs and addin_test.mjs each have their own `flag()`/`opt()` pair in
+   addition to the three `die()` shapes A7-5 catalogues -- the same three-way split repeats
+   one level up (not re-verified in detail here; flagged for whoever picks up L1-2/L1-3).
diff --git a/builder/REVIEW-TOOLING-fe9ce12b/V4.md b/builder/REVIEW-TOOLING-fe9ce12b/V4.md
new file mode 100644
index 00000000..01d95bd1
--- /dev/null
+++ b/builder/REVIEW-TOOLING-fe9ce12b/V4.md
@@ -0,0 +1,134 @@
+# V4 verification report -- L3 (browsers, child processes, text rewrites)
+
+Verified against commit `fe9ce12b` (`git show fe9ce12b:`). Source copies read into
+`scratchpad/verify/V4/src/`; reproduction scripts in `scratchpad/verify/V4/*.mjs`.
+
+## Table
+
+| ID | verdict | severity note | correction or evidence (path:line at fe9ce12b) |
+|---|---|---|---|
+| L3-1 | CONFIRMED | R1 dup fits. `main()`'s only exit from `runMatrix` to `browser.close()` is the success path; any exception (the very `PAGE_STATES` throw the file's own comment names) leaks a Chromium child on every platform, not just CI/Linux -- a real, already-wired trigger, not a hypothetical one. | `scripts/check_a11y.mjs:167` `launchBrowser()`, `:218` `browser.close()` (success path only), `:244-247` `main().catch` (exits 2, never closes); check_a11y.mjs:229 comment ("though PAGE_STATES throws before it gets that far") shows the throw path was already known. Siblings guard it: `check_a11y_fingerprint.mjs:234` launch, `:238-248` try/finally; `sweep_a11y.mjs:209` launch, `:214-274` try/finally; `check_axe_patch_equiv.mjs:99` launch, `:104-116` try/finally. `lib/axe-scan.mjs` PAGE_STATES appliers throw on a failed assertion: `section-links-open` (`:122-137`, throws `:125`,`:132`), `content-details-open` (`:147-172`, throws `:151`,`:166`); comment at `:117-120` states the "MUST assert" design rule. No browser was started to verify this. |
+| L3-2 | CONFIRMED | R2 dup is defensible: `spawn(process.execPath, ...)` essentially never fails at the OS level in ordinary operation (it is re-spawning the running Node binary), so "will fail on the next ordinary change" is a stretch -- but the consequence (an un-restored tbIDE registry, the exact hazard `lib/tb-registry.mjs`'s tidy exists to prevent) is severe enough that R2 rather than R3 is right. | `scripts/check_examples.mjs:601-612` `buildStaged`: spawn at `:608`, no `.on("error", ...)`, awaits `"exit"` at `:612` (`child.on("exit", r)`), not `"close"`. Call path: `buildStaged` (601) <- `laneOf(...).build` (`:770-775`, calls it `:772`) <- `runBatch` (`:898-899`) <- `runAll`'s lane loop (`:931-947`, inside `Promise.all` `:947`) <- `main()`'s `try { results = await runAll(...) } catch (e) { ...; finishTidy(tidy); process.exit(2); }` (`:1678-1680`). A spawn `'error'` event with no listener is a Node uncaught exception at the EventEmitter's own emit point (not a promise rejection), so it bypasses that `catch` and bypasses `main().catch(...)` at `:1810` (which also calls `finishTidy`) -- neither is in its call stack. No `process.on("uncaughtException", ...)` exists anywhere in the file (grep confirmed zero matches), so `finishTidy(tidy)` -- the registry restore -- is skipped and the process dies via Node's default uncaught-exception handling. Siblings register both: `test/scripts/addin_test.mjs:180` `child.on("error", ...)` + `:185` `child.on("close", ...)`; `scripts/check_regex_safety.mjs:347` `child.on("error", reject)` + `:348` `child.on("close", ...)`. (Note: `addin_test.mjs` lives at `scripts/addin_test.mjs`, not `test/addin/addin_test.mjs` as the ledger's path implies -- same file, path only.) |
+| L3-3 | CONFIRMED, with one nuance | R1 hack is defensible for an actively-written pipeline even though currently dormant (see evidence) -- the ledger's own "Not live today" qualifier is accurate, so the severity already reflects the dormancy correctly. | `wisdom/extract/merger.mjs:118-129` `parseStaging` splits on lines exactly equal to `'---'`, zero fence-awareness. `parseSection:162,168` returns `null` when `buf[0]` does not start with `"## "`; the caller drops a falsy result silently at `:147-148` and `:152-153`. Comment at `:111-112` reads verbatim: "The parser is forgiving: anything it can't categorise gets preserved as part of a section body so we never silently drop reviewer content" -- contradicted. **Reproduced** in `scratchpad/verify/V4/repro_l3_3.mjs` (imports the real `merger.mjs` by relative path, confirmed side-effect-free at import together with its one local import `state.mjs`): a synthetic staging.md whose 2nd section's fenced ` ```tb ` block contains a bare `---` line does **not** make the section itself disappear -- `parsed.sections.length` is unaffected (3 sections still returned, matching the 3 "## " headings). What silently disappears is the **tail** of that section's own body (everything after the embedded `---`: the rest of the code, the closing fence, trailing prose) **and its entire meta line** (`_Source threads: ... confidence: ..._`, i.e. its `finding_ids`/`confidence`) -- the orphaned tail chunk starts with `"    x = 1"`, not `"## "`, so `parseSection` returns `null` for it and it is dropped exactly as the finding describes. So: not "a section disappears," but "a section survives with its tail and metadata silently amputated," which is arguably worse (a corrupted, mis-attributed section is harder to notice than a missing one). `wisdom/data/findings/staging.md` **is tracked** at `fe9ce12b` (`git ls-files wisdom/data/findings/staging.md` returns it; `git show fe9ce12b:wisdom/data/findings/staging.md` succeeds, 15,553 lines). **Ground truth on the real file** (`scratchpad/verify/V4/check_staging_chunks.mjs`, which mirrors `parseStaging`'s own chunk-split logic exactly rather than guessing at fence boundaries): 1,160 bare `"---"` lines produce 1,160 post-preamble chunks, **all 1,160 start with `"## "`** -- 0 dropped chunks, confirming "not live today" precisely. The 61-line gap between a naive raw `"## "` count (1,221) and the parsed section count (1,160) is fully explained by 61 `"## "`-prefixed lines occurring later *inside* an already-open section's body (folded into prose, not lost) -- a milder, separate, lossless side effect, not the defect under test. |
+| L3-4 | CONFIRMED | R2 dup is reasonable standalone; note the ledger's own A3-1 (not mine to verify) rates the render.mjs half of this identical gap R1 with a live repro, so the eventual merge should likely inherit R1 rather than average down to R2. | `scripts/convert_em_dash_separators.mjs:59`: `const FENCE_OPEN_RE = /^[ \t]{0,3}(\`{3,}|~{3,})/;` -- used at `:144` (`line.match(FENCE_OPEN_RE)`) inside `convertText` (`:135-166`) with no examination of anything after the marker anywhere in that function. This is **byte-for-byte identical** to `builder/render.mjs:148`'s `maskCodeRegions` open pattern (`/^[ \t]{0,3}(\`{3,}|~{3,})/`, confirmed by direct comparison) -- not merely similar. Contrast `render.mjs:1704`'s `stashCodeFences` pattern, `FENCE_OPEN_RE = /^([ \t]*)(\`{3,}|~{3,})(.*)$/`, which captures the info string and explicitly refuses a match at `:1712-1713` when `ticks[0] === "\`" && info.includes("\`")`, per the CommonMark rule stated in the comment at `:1700-1703`. convert_em_dash_separators.mjs has no equivalent guard anywhere. |
+| L3-5 | CONFIRMED | R2 dup fits -- classic same-operation duplication, sharpened by a real same-name/different-behavior collision (a maintainer grepping for "escapeHtml" and picking the wrong module's copy would silently under-escape). | Minimal (`&<>`), 3 copies: `render.mjs:2230-2233` `escapeHtmlMinimal`; `highlight.mjs:251-254` `escapeHtml` (comment `:249-250`: "Rouge's HTML formatter escapes only \`& < >\` -- not quotes"); `gantt.mjs:213` `esc`. Full (`&<>"'`), 4 copies: `render.mjs:2225-2228` `escapeHtml`; `template.mjs:993-998` `escText`; `template.mjs:999-1001` `escAttr`; `sitemap.mjs:106-113` `xmlEscape`. **`template.mjs`'s `escAttr` (999-1001) is byte-identical to `escText` (996-998)**: both bodies are exactly `return String(s).replace(HTML_ESCAPE_RE, (c) => HTML_ESCAPE[c]);` against the same shared `HTML_ESCAPE`/`HTML_ESCAPE_RE` constants at `:993-994` -- two exported names for one implementation, confirmed equal. "escapeHtml names both classes" also confirmed: `render.mjs`'s `escapeHtml` (full, 5-char) and `highlight.mjs`'s `escapeHtml` (minimal, 3-char) are different functions in different files sharing the identical name and doing different things. |
+| L3-6 | CONFIRMED | R3 conv fits -- it fails loudly (a full stack trace reaches the terminal), just inconsistently with the tool's own `error: ...` convention and with a nonstandard exit code (Node's default 1, not this repo's documented 2), rather than silently or by producing a wrong answer. | `scripts/check_links_diff.mjs`, `main()` spans `:598-761`. Its only `try`/`catch` is `:600-601`, scoped to exactly `opts = parseArgs(process.argv.slice(2));`. `fusedBuild` (`:410-438`) calls `spawnSync` at `:426` and `throw new Error(...)` at `:432` when the build produced no findings file, with no local try/catch. `ensureBasePathTree` (`:510-528`) calls `spawnSync` at `:518` and `throw new Error(...)` at `:525` on nonzero exit, likewise unguarded. Both are invoked from `main()`'s case loop, **outside** the `:600-601` try/catch: `fusedBuild(c.fused)` at `:670` and `:676`, `ensureBasePathTree(...)` at `:677`. The module-level call at `:763`, `process.exit(main());`, has no try/catch either, so either throw becomes a raw, unformatted Node stack trace instead of the tool's `error: ${e.message}` pattern used at `:601`. |
+
+## The A3-6 conflict
+
+**Ruling: both sides are partly right, and neither statement stands alone.** A3-6 is right
+that none of the three rewrites has a code guard and that no invariant is stated anywhere
+near them. L3 is right that nothing in the corpus is corrupted by this today. Where L3
+overstates is the *reason* it gives -- "every code-rendering path escapes `& < >` first" is
+true of three paths out of four, and the fourth is a path this site's own configuration
+deliberately keeps open to authors.
+
+### Traced paths
+
+All four token types that can put text inside ``/`
` in the final page, checked
+against `builder/render.mjs`'s `createMarkdownIt` (`:360-379`) and `builder/highlight.mjs`:
+
+1. **Fenced, highlighted** (a recognised Shiki grammar, including the `tb`/`vb`/`vba`/
+   `twinbasic` aliases). `render.mjs`'s `fence` rule (`:399-414`) calls
+   `options.highlight(tok.content, lang)` (`:409`), wired at `:378` to
+   `ctx.highlighter.render`, which is `renderCodeBlock` (`highlight.mjs:96,102-142`). The
+   Shiki branch (`:131-136`) renders through `renderThemedSpans` (`:158-247`), which calls
+   `escapeHtml(ex.content)` / `escapeHtml(tok.content)` on every token segment (`:217`,
+   `:220`) before wrapping it in a ``. **Always escaped.**
+2. **Fenced, plain** (unrecognised language, or explicit `plaintext`/`text`/`txt`/empty).
+   Same `renderCodeBlock`, `else` branch (`:137-138`): `tokenizedHtml = escapeHtml(codeBody)`
+   over the whole block in one call. **Always escaped.**
+3. **Indented (4-space) code blocks.** `render.mjs`'s `code_block` rule (`:420-424`):
+   `const body = escapeHtmlMinimal(tok.content);`. No Shiki involvement at all (no
+   language info exists for this token type). **Always escaped.**
+4. **Inline code spans.** `render.mjs`'s `code_inline` rule (`:431-434`):
+   `escapeHtmlMinimal(tok.content)`. A second copy for TOC-heading generation at
+   `:1441-1442` does the same. **Always escaped** (for `&<>`; `"`/`'` survive by design,
+   irrelevant to this trace since none of the three rewrites key on quotes).
+5. **Code inside a raw, hand-authored HTML block** (e.g. an author types `
...
` + directly in the markdown source instead of using a fence). `render.mjs:364` sets + `html: true` on the `MarkdownIt` instance. Grepping every `md.renderer.rules.*` + assignment in `render.mjs` (fence, code_block, code_inline, table_open/close, th_open, + td_open, ordered_list_open, footnote_*, image x2) finds **no override for `html_block` + or `html_inline`**. Confirmed directly against the vendored package, + `node_modules/markdown-it/lib/renderer.mjs:102-106` (markdown-it 14.2.0, per its + `package.json`): `default_rules.html_block = (tokens, idx) => tokens[idx].content` and + the same for `html_inline` -- verbatim passthrough, **never escaped**, by markdown-it's + own design (contrast `default_rules.text` at `:98-100`, which does call `escapeHtml`). + `template.mjs:81` feeds `injectAnchorHeadings` the page's full `renderedContent`, not a + pre-filtered subset, so this passthrough content is exactly as exposed to + `HEADING_REGEX`/`VOID_TAGS_RE`/the empty-cell pattern as everything else on the page. + +So: **yes, a path exists** (path 5) where a raw, unescaped `<` or `>` can land inside a +`
` or `` element before `padEmptyCells` (`render.mjs:74-80`), `normaliseVoidTags`
+(`render.mjs:341,351-354`) or `injectAnchorHeadings` (`template.mjs:714-727`) run over the
+assembled page HTML (call order confirmed at `render.mjs:60-69`: `applyPreRenderRewrites` →
+`md.render` → `normaliseVoidTags` → `padEmptyCells`; `injectAnchorHeadings` runs later over
+`page.renderedContent`, `template.mjs:81`). `HEADING_REGEX` (`/<(h[1-6])(\s[^>]*?)?>([\s\S]*?)<\/\1>/g`,
+`template.mjs:694`) in particular mutates *any* `

`-`

` pair it finds -- even one with +no `id` attribute, it still pads the body with spaces (`:725`) -- so a hand-written `
`
+block illustrating literal heading or void-tag HTML (very plausible on a page describing this
+site's own rendering pipeline) would have its example text silently altered.
+
+**Is it live?** No. `git grep` across all 912 `docs/*.md` files at `fe9ce12b` finds zero
+block-starting ``-`
` tags anywhere. It does find 42 +lines that are bare void tags (`
` etc.) at column start, but every sampled hit (e.g. +`docs/Features/Packages/Creating a TWINPACK package.md:16,17,22,23,...`) is an ordinary manual +line break used for layout, with no enclosing code context -- exactly the kind of real markup +`normaliseVoidTags` exists to normalise, not example text it corrupts. So L3's practical +conclusion holds: **nothing in the current corpus is being corrupted.** + +### Is the invariant written down? + +**No, not at any of the three call sites.** The surrounding comments explain *what* each +rewrite does and *why* (kramdown byte-parity for `padEmptyCells`/`normaliseVoidTags`, +a11y/anchor rationale for `injectAnchorHeadings`, `template.mjs:709-713`) -- none of them +states or relies on an escaping guarantee. + +**Also not written down in `WIP.Build.md`**, which is the one place in the tree that states +this exact policy. Its section "Never rewrite markdown source without knowing what is code" +(`WIP.Build.md:236-289`) says a rendered-HTML rewrite must use `replaceOutsideCode` +(`builder/book.mjs`) or "the same leading-alternation shape found in `offline-rewrite.mjs:299`, +`pdf.mjs:138` and `book.mjs`'s `IMG_SRC_RE_BOOK`, which consume `` and `
`
+atomically" (`:275-278`) -- and never mentions `padEmptyCells`, `normaliseVoidTags` or
+`injectAnchorHeadings`, either as compliant examples or as a stated exception to the rule.
+Worth noting: the same section carries a directly analogous, already-learned lesson two
+paragraphs above (`:256-267`) -- `book.mjs`'s chapter-transform rewrites (`id="`, `href="#`,
+`src="/`) were assumed safe because "highlighted blocks escape this only by accident: the
+highlighter splits attributes across `` boundaries", until inline code spans (no such
+splitting) let all three patterns match inside a sample and corrupted six code spans in the
+published PDF. The document's own conclusion: "**Do not rely on that accident.**" That is a
+different mechanism (quote-survival in already-escaped inline code, not raw-HTML-block
+passthrough) but the identical shape of mistake -- trusting an unstated, implementation-
+dependent escaping guarantee for a whole-page rewrite -- and it is on record one file away
+from the three functions in question, without being connected to them.
+
+**Conclusion:** A3-6's "no code guard, unstated invariant" holds exactly as written. L3's
+"sound because every code path escapes" is true for three of the four paths that can put text
+in ``/`
`, false for the fourth (raw hand-authored HTML, structurally open via
+`html: true` and never overridden), and not currently triggered by anything in `docs/`. A3-6's
+R2 hack rating is fair: this codebase has already paid for exactly this class of mistake once
+(the book.mjs incident above), the fix is cheap (state the invariant and add a probe, or move
+to the code-guarded pattern already used elsewhere), and the trigger (an author illustrating
+raw HTML output by hand) is an ordinary kind of edit for pages that describe this pipeline's
+own rendering -- not R1 only because nothing is broken yet.
+
+## Noticed in passing
+
+1. `convert_em_dash_separators.mjs:59` and `render.mjs:148`'s fence-open regexes are not just
+   similar, they are byte-for-byte identical literals -- a stronger duplication than "shares
+   the gap" suggests.
+2. `check_examples.mjs` (1,810 lines) registers no `uncaughtException`/`unhandledRejection`
+   handler anywhere; the `buildStaged` spawn (L3-2) is one instance of a wider exposure that
+   would affect any stray async error in the file, not only this one.
+3. The real, committed `staging.md` already has a near-miss of the L3-3 shape at source lines
+   14244-14246: a blockquoted ` ```tb ` fence whose interior lines drop their `"> "`
+   continuation prefix. It doesn't trip the defect (confirmed: 0 dropped chunks file-wide),
+   but shows how easily a slightly different malformed blockquote-fence would.
+4. `escapeHtml` is a three-way name collision, not two: markdown-it's own internal
+   `escapeHtml` (used by its default `text` rule), `render.mjs`'s `escapeHtml` (full 5-char),
+   and `highlight.mjs`'s `escapeHtml` (minimal 3-char) are three unrelated functions sharing
+   one name across the same build.
+5. `check_a11y.mjs:229`'s own comment ("though PAGE_STATES throws before it gets that far")
+   shows the author already knew the exact exception path that leaks the browser in L3-1, one
+   line above code with no try/finally to catch it.
diff --git a/builder/REVIEW-TOOLING-fe9ce12b/ledger.md b/builder/REVIEW-TOOLING-fe9ce12b/ledger.md
new file mode 100644
index 00000000..a37ffd58
--- /dev/null
+++ b/builder/REVIEW-TOOLING-fe9ce12b/ledger.md
@@ -0,0 +1,577 @@
+# Review ledger (condensed pass reports, for synthesis)
+
+## VERIFICATION STATUS (details in scratchpad/verify/V*.md)
+
+- V1 (builder/, 23 findings): all CONFIRMED. Severity notes: A2-1 R2 not R1 (unreachable code, no
+  live divergence); A3-4 R3 not R2 (two 4-line predicates). L4-8: 4 of 5 helpers byte-identical,
+  encodeSpaces same behaviour, different idiom. Conflicts: writeBaseline -> L2-4 right (equals
+  JSON.stringify(x,null,2)+"\n" except an empty list, run); normalizeBaseurl -> A9 right (premise
+  expired: book.mjs imports compress.mjs and data.mjs); escapeHtml -> keep A3-2, narrowed. Five
+  noticed-in-passing items in V1.md (another stale diff-tool reference, a third HTML-escaper copy,
+  an exit-code ordering hazard, ...).
+- V4 (L3, 6 findings): all CONFIRMED. L3-3 nuance: a bare --- inside a fence does not make the
+  section vanish; it cuts off the rest of that section's body AND its meta/finding_ids line (the
+  caller drops the null); replay of the real 15,553-line staging.md drops 0 chunks today. L3-2: an
+  unhandled spawn 'error' is an uncaught exception, bypassing both catches and finishTidy -- no
+  uncaughtException handler in the file. A3-6 CONFLICT RULED: both partly right -- fenced, indented
+  and inline code are always escaped, but hand-authored raw HTML (
, headings; html:true,
+  markdown-it's own html_block/html_inline) passes through unescaped and IS exposed to the three
+  whole-page rewrites. Not live: zero hand-written 
 or heading tags in 912 pages. The invariant
+  is written nowhere near the sites nor in WIP.Build.md, which has an adjacent precedent ("Do not
+  rely on that accident"). -> keep A3-6 as R2 hack: a real, unguarded, undocumented path.
+- V3 (harness, samples, book; 15 findings): 10 CONFIRMED, 5 CORRECTED (details only):
+  A7-1 tbrun matches 3 of the 5 shapes (case-insensitive), still misses "[BUILD] ERROR" and
+  "[LINKER] compilation (codegen) error" (I had told the user "2 of 5" -- corrected to them);
+  A7-3 click() used directly in 4 of 10 scenario files (appdata, panes, sample10, sample15), not all;
+  A7-4 addin_test imports 10 names from tb-registry, not 9; A8-2 path citations are 11 (strict) or 13
+  (bare filename) -- the list wrongly had WIP.Build.md and missed build_package_api.mjs and
+  tb-fences.mjs (V3.md has the exact list); A9-5 12 files repeat the createRequire block, not ~15.
+  L4-10: YES, twin-api's logicalLines keeps tb-fences' quote-aware comment stripping (8+ cases incl.
+  "" then a real apostrophe), but the swap is not mechanical: twin-api keeps blank lines, `line`
+  1-based vs `at` 0-based, no trim, recognises Rem. tb-fences has NO /* */ handling at all.
+  A7-2's kind (struct vs hack) arguable, left.
+- V2 (gates, links, a11y, CI, small tools; 29 findings): 25 CONFIRMED, 4 CORRECTED: A4-3
+  LINK_ATTR_TABLE is 21 tags / 26 flattened pairs (not 20); crawl_check's 5 exact; RAISE TO R1 -- the
+  gap is live (crawl_check misses srcset, poster, cite, formaction, action, data, longdesc today).
+  A5-1: 8 scripts, not 7 (check_tree_fresh.mjs too). L2-5: 19 files exact; ~5 AST shapes rather
+  than 4. L2-6: four files use try/finally cleanup, not three. All three conflicts: keep the
+  original findings alongside L4's classification (different questions).
+- ALL FOUR VERIFIERS DONE.
+- ORCHESTRATOR CHECK of the inventory's "live staging.md corruption" claim: NOT CONFIRMED. Running
+  the real parseStaging on the real file: section 1060 (Len.md, holds the ReDim sample) carries
+  finding_ids ["1314412625714479125"], dates 2024-12-06 -- exactly the meta beneath it on disk;
+  sections 1059 and 1061 keep their own. The heading's "see also thread ..." lists the OTHER
+  duplicates, which misled the agent. What IS real: a content slip -- staging.md:14245-14246 lack
+  the "> " continuation, so if that section is ever grafted into Len.md it renders as a broken
+  admonition and a fence that runs on (markdown-it reads 14246-14297 as one fence). A data defect a
+  markdown-aware check of section bodies would catch; not mis-pairing.
+- ORCHESTRATOR CHECK: docs/Documentation/Wisdom.md:304 opens a fence, :305 is "## docs/..." inside
+  it -> check_gate_lists.mjs's splitSections splits there (dormant: that phantom section states no
+  gate count). CONFIRMED.
+
+## THEME raised by the user (2026-09-25): markdown handled as text without parsing first
+
+The user's observation: markdown is repeatedly treated with regexes directly, without first parsing
+the file to separate code. Instances: render.mjs maskCodeRegions/maskInlineCode and stashCodeFences
+(A3-1), convert_em_dash_separators.mjs (L3-4, L4-7), wisdom merger.mjs '---' split (L3-3), wisdom's two
+frontmatter parsers (A10-2), census_attributes.mjs's Attributes.md line regexes; precedent for doing it
+right: tb-fences.mjs and check_code_regions.mjs (markdown-it tokens), discover.mjs (gray-matter).
+Direction: ONE shared module deriving prose/code/HTML/frontmatter regions from markdown-it's block
+parse (the renderer's own view) + one tested inline-code splitter; every markdown scan/rewrite goes
+through it. This is a headline theme for the review, with a design decision for the user.
+Inventory agent DONE -> scratchpad/md-inventory/INVENTORY.md (15 sites: 5 rewrite/A,
+10 read-only-scan/B, cross-referenced against A3/A10/L3/L4's findings above and confirms
+them; sharpest new result: live, on-disk section corruption in the COMMITTED
+wisdom/data/findings/staging.md, traced with markdown-it against the real 15,553-line file
+-- a blockquote-fence missing its "> " continuation at line 14245 makes markdown-it read
+lines 14246-14297 as one unclosed fence, and parseStaging's bare-"---" splitter attaches
+a DIFFERENT section's _Source threads:_/_Date range:_ meta to the "Len.md · after-remarks"
+heading; re-running extract --merge would serialize the wrong pairing back to the repo.
+Revises L3-3's "not live today" -- it IS live, just not via the bare-dash-in-fence shape
+L3-3 checked; a naive fence-aware toggle also mis-verifies this, worth noting for
+whoever writes the fix. Also confirmed A3-1's repro independently (own read, before
+seeing A3's note). New: B3 check_gate_lists.mjs's splitSections also mis-splits on a
+"## ..." line inside a fenced staging.md-format example at Documentation/Wisdom.md:305
+(dormant, same root cause class as L3-3, different file). Task 2: markdown-it answers
+all confirmed empirically (fixtures + real corpus); frontmatter is ACTIVELY misparsed
+(hr + setext H2), not just ignored, if not stripped first. Timing: 912 files/3.98MiB,
+full parse 234-257ms (0.26-0.28ms/file), block-only 34ms (0.037ms/file, ~7x cheaper,
+verified to skip inline splitting for real). Recommended home: new top-level lib/
+(sibling to builder/scripts/wisdom/eval), since builder/ can be imported FROM scripts/
+already (check_code_regions.mjs does) but not the reverse -- scripts/lib/ is out because
+render.mjs:383 says so verbatim, and putting it inside builder/ would make wisdom/eval/
+depend on the site generator for fence-detection, which is backwards. Also verified the
+js-yaml skew independently: gray-matter@4.0.3 nests js-yaml@3.14.2
+(node_modules/gray-matter/node_modules/js-yaml), while builder/data.mjs, tbdocs.mjs and
+check_publish_policy.mjs import the top-level js-yaml@4.1.1 directly -- confirms the
+follow-up note above from package.json reads, not just cited.
+Follow-up (user asked why two parsers): gray-matter is a frontmatter splitter + YAML, not a markdown
+parser -- but it bundles js-yaml 3.14.2 while the build parses _config.yml/_book.yml with the direct
+js-yaml 4.1.1: page frontmatter and site config go through two YAML major versions. Users:
+builder/discover.mjs:8, eval/nav_hops.mjs:35. discover.mjs strips a BOM itself because matter.test()
+does not. Recommendation (told the user): drop gray-matter; a small frontmatter splitter in the
+shared markdown module + js-yaml 4. Oracle: every page's parsed frontmatter deep-equal both ways
+(906 pages) + identical built trees.
+
+Verifier column: what must be re-read before a finding goes into the review.
+
+## A4 -- link checkers (done)
+
+Two-checker question, evidence:
+- "Tree the build did not produce" (release zip, bisect, foreign artifact) is stated in six places, exercised nowhere: CI runs check_links.mjs only in-process via check_links_diff.mjs on fixtures; the release zips the tree the same job already checked; git history has no such use. crawl_check.mjs (live HTTP, person-run after release, Building.md:653-655) is the only post-release check and shares no code.
+- Shared (link-check.mjs): extraction, resolution, oracle, cross-file checks, human-readable reports. Parallel: tree loading (deliberate, PLAN-checks "What is being moved"); findings translation (clone); statSafe; 1/2/3 exit assembly (deliberate: tbdocs ORs build-failure bits too, tbdocs.mjs:1566-1584).
+- Harness cost: 763 lines, 3 CI steps (~0.3 s, ~1 s, ~0.3 s), not in check/test.bat; fixture counts break on unrelated changes (fonts preload: broken 3 -> 9, test/README.md).
+- Harness catches (all in the shared core or fixtures, none algorithm-vs-algorithm): 39e03ab2 brokenUnique double count; d2c3a743 skipped check printed as clean; Phase 2 coordinate bugs (95,839 and 11,815 phantom links); Phase 3 writeOfflineRedirects dependency gap and resolution-cache bug; the dead --check-html gate; revived scriptSelfTest.
+- Recommendation: option 2 -- check_links.mjs keeps its disk walk and CLI but calls check.mjs's exported checkChunk/joinChunks/findingsFor/formatReport; harness then compares disk-read vs memory-read over one implementation (still catches index/coordinate bugs); fixture counts stay as a regression test. Not option 3: capability cheap to keep; tbdocs would gain an unrelated job; post-release checking wants HTTP anyway.
+
+Findings:
+- A4-1 R3 dup statSafe twice: link-check.mjs:455-457 (FsOracle), check_links.mjs:151-153. VERIFY identical.
+- A4-2 R2 dup findings translation: check.mjs:438-449 findingsFor vs check_links.mjs:621-634 buildFindings; human-readable twin already shared. VERIFY clone.
+- A4-3 R2 dup crawl_check.mjs:91-108 own attribute table, 5 tag/attr pairs vs link-check.mjs:38-60 LINK_ATTR_TABLE's 20; no record. VERIFY counts.
+- Split candidates: none (check.mjs, check_links.mjs, check_links_diff.mjs coherent).
+- Lead for L2: check_links.mjs:276 oracle default depends on platform (index on win32) -- reasoned.
+- DECIDED by the user (2026-09-25): option 2. check_links.mjs stays as a thin wrapper over
+  builder/check.mjs. Record under decision 5 in PLAN-TOOLING-REVIEW.md when the passes are in;
+  the fix goes in Phase 2 (shared code). A4-3 (crawl_check's table) is separate, not decided.
+
+## A9 -- book pipeline (done)
+
+Decision 1 scope: the book build loads exactly ONE file from perf/ at run time: perf/detach-pages.js
+(via render-book's --additional-script). perf/timing-handler.js is only mentioned in a comment
+(progress-handler.js:8). Places naming detach-pages.js: book.bat:13,39; tbdocs-gh-pages.yml:196;
+render-book.mjs:28 (comment); Tools.md:108,975; PDF-Generation.md:44,197,395,396,430; Building.md:86;
+docs/assets/css/print.css:136 (comment); paged.browser.js:30542 (comment, fork). AND five perf scripts
+resolve it next to themselves -- perf/measure.mjs:366, probe-tabs-vs-procs.mjs:48,
+probe-renderer-mem.mjs:71, probe-parallel.mjs:61, probe-memory.mjs:66 -- which break on the move
+unless their paths are updated (mechanical; perf otherwise untouched).
+Original of the measure-pass copy: perf/phase0-measure.mjs (f76701a4); book/lib/measure-pass.mjs was
+extracted from it 40 min later (d963c183). Note only.
+
+Findings:
+- A9-1 R2 hack: pdf-lib shims (fast-*.mjs, parallel-deflate's PDFStreamWriter subclass) unguarded;
+  only the exact pin 1.17.1 (recorded perf/notes/08-pdf-lib.md:1751-1757) -- covers accidental drift,
+  not a deliberate bump; a behavioural upstream change fails silently. Fix: load-time shape
+  assertions per shim, modelled on axe-scan's SOURCE_PATCHES counts. pdf-lib upstream abandoned
+  (@cantoo fork evaluated and rejected, 08-pdf-lib.md:5048-5061) -> R2 not R1.
+- A9-2 R2 struct: no equivalence test of shimmed vs stock pdf-lib output anywhere (one-off manual
+  checks in perf notes). Fix: check_pdf_shims_equiv.mjs like check_axe_patch_equiv (stock pdf-lib in
+  a child process). Would be a new test.bat gate -> gate lists/CI/decision-6 gate.
+- A9-3 R2 dup: onebuf machinery (pack, _cow, _registerContext, _appendArray, _makeFromRange,
+  _makeFromAppend) in both fast-array-onebuf and fast-dict-onebuf; recorded reason
+  (fast-array-onebuf.mjs:36-39) covers only the singleton; _registerContext/_appendArray identical
+  bar names. Fix: book/lib/onebuf-range.mjs factory.
+- A9-4 R3 dup: _writeUint/_digitCount in fast-refs and fast-refs-class. MOOT if A9-6 moves
+  fast-refs.mjs to perf/ (orchestrator confirmed fast-refs.mjs is imported only by perf/measure.mjs:428
+  and perf/phase0-measure.mjs:28; fast-refs-class does not import it).
+- A9-5 R3 dup: ~15 shims repeat createRequire + require('pdf-lib/cjs/...').default blocks.
+  Fix: book/lib/pdf-lib-internals.mjs.
+- A9-6 R2 struct: four A/B-baseline shims live in book/lib but only perf/ loads them: fast-refs,
+  fast-dict-array, fast-dict-iter, fast-parse-dict (render-book.mjs:56-58 records them as baselines,
+  not their location). Fix: move into perf/ and update perf's imports. NEEDS USER OK: the converse of
+  decision 1 (book -> perf), which the user did not decide.
+- A9-7 R2 dup: the code-guard alternation (/
 leading alternative) re-typed by hand:
+  book.mjs:210 CODE_OR_PRE_BOOK vs IMG_SRC_RE_BOOK book.mjs:228-229 (same file!), pdf.mjs:143-144
+  IMG_SRC_RE (identical), offline-rewrite.mjs:299 HTML_COMBINED_RE. Safety-critical, no gate ties
+  them. Fix: one exported fragment. (Expect overlap with L3.)
+- A9-8 R2 dup: normalizeBaseurl book.mjs:303-307 == offline-rewrite.mjs:232-236 (exported); recorded
+  reason (Jekyll plugins independent, book.mjs:300-302) expired at the Node cutover; comment also
+  misnames offline.mjs. Fix: import it. VERIFY byte-identical.
+- A9-9 R3 dead: fast-dict-array.mjs:9 cites docs/lib/fast-parse-dict.mjs (moved in 4c36c8d6).
+- A9-10 R2 dead: pdf.mjs:6-9, 99-107 justify exports by the deleted _diff.mjs/_triage.mjs (644d6bdb);
+  extractImagePaths (pdf.mjs:146-158) has zero callers. deriveBookOutputs is live (writePdf).
+  VERIFY zero callers.
+- Split candidate: builder/book.mjs -- resolver (§A), book.html assembly (§B-F, incl. rewriteBookHrefs),
+  coverage checker (§G); pdf.mjs already imports the three as if separate.
+- Verified sound: book.bat; render-book's 13-shim import list consistent; __xInstalled guards;
+  paged.js fork divergence fully recorded (20 [PATCH] tag families, 52 sites, Fixes-PagedJS.md);
+  pdf.mjs IMG_SRC_RE follows the guard rule; postprocesser/outline are attributed ports.
+- Leads: render-book.mjs:204-225 hand-rolled argv (L1); offline.mjs imports from offline-rewrite
+  (the model A9-8 should follow).
+
+## A8 -- samples and package API (done)
+
+- A8-1 R1 dup: census_attributes.mjs MODS (138-148) lacks Overridable (+Iterator, Dim; tb-fences has
+  Async/PtrSafe/... too) vs twin-api.mjs:123-125 and tb-fences.mjs:340-343. "Public Overridable Sub"
+  -> DECL_RE fails -> VAR_RE matches -> "Variable". ORCHESTRATOR VERIFIED the trace. IMPACT CLAIM FALSE:
+  BETA 983 cache has 31 Overridable Sub/Function/Property lines, 0 carry an attribute (inline or above,
+  past comments/#If), and block tracking uses only type keywords (OPEN_RE/CLOSE_RE, 157-159), so no
+  stack corruption. Dormant divergence. Two more claimed divergences to VERIFY: blankStrings (170-179)
+  no "" escape; BLOCK_COMMENT_RE per line, stateless (twin-api stateful, 56-66).
+- A8-2 R2 struct: census_attributes.mjs in builder/ breaks render.mjs:383's rule "builder/ must not
+  depend on scripts/"; check_tree_fresh.mjs:57-63 IGNORED_FILES exists only for it. Move to scripts/
+  beside build_package_api; ten path citations: WIP.md, WIP.Harness.md, WIP.HelpAddin.md,
+  WIP.ExamplesBuild.md, WIP.Build.md, BUGS-TO-REPORT.md:430, Tools.md #census-attributes,
+  docs/Reference/Attributes.md:588 (HTML comment, published page), tb-packages.mjs:2, twin-api.mjs:17.
+- A8-3 R3 dup: fence unit key twice in check_examples.mjs (:424 makeBatches, :675 unitsOf).
+- A8-4 R2 struct: census_attributes and gen_attribute_probes' parseTargets have no ride-along probes;
+  both have shipped silent misparses before (parseTargets' own comment).
+- Split: check_examples.mjs -- 8 jobs (templates 125-193, selection 201-378, batching 380-506,
+  staging 508-568, orchestration 570-655/928-949, bisection 657-927 [270 lines, 14 fns], diag mapping
+  inside buildStaged 643-653, 3 report modes threaded as booleans) + 425-line probe suite 1157-1581.
+  NOT gen_attribute_probes (47% is the EXPLORATORY data table).
+- Sound: serialize/strip/kindOf name collisions coincidental; symbols.mjs is not a .twin scanner;
+  tb-fences tested only via check_examples' runProbes (74 probes).
+- Leads: L2 -- no gate enforces "builder/ must not depend on scripts/" (import-direction check).
+
+## A1 -- build orchestration (done)
+
+- A1-1 R1 struct: Gantt drops checkBook, checkReport (section "Check") and vendorAssets (no
+  GANTT_SECTION entry) on every build -- gantt.mjs:39-49 renders Seeds/Spine(+Render)/Write only; the
+  "Other" colour (gantt.mjs:12) is unused; Builder.md:410 promises an "Other" bucket. Reaches the
+  published Build Info page; their durations still stretch the axis. ORCHESTRATOR VERIFIED.
+- A1-2 R1 hack: picocolors undeclared (tbdocs.mjs:35, scheduler.mjs:5); via puppeteer -> cosmiconfig
+  -> parse-json -> @babel/code-frame. Fix: declare 1.1.1.
+- A1-3 R2 dup: on-demand task run/time/report block x3 in cpu-worker.mjs (360-377, 423-440, 469-486).
+- A1-4 R2 dead: tbdocs.mjs:196-209 exports makeTimer, zero callers; offline.mjs:98-111 private copy;
+  its reason (offline.mjs:95-97, "verify harnesses and diff tools") expired with 644d6bdb.
+- A1-5 = A2-1 (dead writeOfflinePages branch cloning cpu-worker.mjs:183-201). MERGE.
+- A1-6 R2 conv: exit-code bits set at 7 sites with magic numbers (tbdocs.mjs:560, 1469-1470,
+  1568-1573, 1598, 1610); bit 0 conflates 5 causes. Fix: failBuild(bit) + named constants.
+- A1-7 R3 dead: cpu-worker.mjs:510 writes literal 4 (FAILED) -- and nothing ever reads FAILED.
+- A1-8 R3 struct: tbdocs parseArgs (91-194) not exported, untested.
+- Split: tbdocs.mjs -- TASKS literal fuses DAG topology with task bodies (260-1257, 61%); dispatch's
+  submit() is SAB machinery (788-911); check glue (1085-1256); Gantt/timing (1268-1403) belongs by
+  gantt.mjs (A1-1 is the cost); console report (1478-1525); exit policy scattered.
+- Sound: small modules; SAB layout 174,100 bytes matches Builder.md; HANDLERS key sets match; SAB
+  constants imported by name (except A1-7); barrier invariant; --dry-run and --profile-offline LIVE,
+  Tools.md flag table matches 20/20; stall watchdog matches WIP.Build.md.
+- Leads: offline.mjs's "Re-export surface for diff tools" (55-88, ~25 names) has zero importers (A2).
+
+## A2 -- output stages (done)
+
+- A2-1 [agent R1, likely R2] dead: writeOfflinePages (offline.mjs:244-289, zero callers; 258-280 clone
+  of cpu-worker.mjs:183-201), writeOffline's unread `precomputed` param (117), buildSitePaths +
+  fallback (207, 213, 359-394; tbdocs.mjs:705-706 always sets sitePaths), writeSearchData
+  (search.mjs:18-24), extractSitemapUrls (sitemap.mjs:69-74). Plus A1's lead: offline.mjs 55-88
+  re-export block unused. All from the retired _diff/_triage tools and the SAB split.
+- A2-2 R2 dead: comments naming deleted _diff.mjs/_triage.mjs/_sitemap_diff.mjs/accepted-divergences:
+  offline-rewrite.mjs:411, search.mjs:65-66, sitemap.mjs:67-68,97, redirects.mjs:35-36 -- and
+  pdf.mjs:6-9,99-107 (A9-10). MERGE with A9-10's comment half.
+- A2-3 R2 dup: readBaseline byte-identical (page-baseline.mjs:83-90, symbol-baseline.mjs:45-52); the
+  six-branch drift-guard state machine re-typed (117-176 vs 75-125). Recorded: "the page-count
+  guard's shape applied to URLs" (WIP.Build.md) -- covers look-alike, not the copy.
+- A2-4 R2 dup: escapeRegExp x3 -- render.mjs:2235, offline-rewrite.mjs:239 (exported), book.mjs:212
+  (escapeRegExpBook). regex-fold recognises escape helpers "by body" because of the copies. MERGE A3-5.
+- A2-5 R3 dup: encodeSpaces search.mjs:204 vs template.mjs:925 (different idioms). MERGE A3-3/A3-5.
+- A2-6 R2 dup: normalizeBaseurl = A9-8. MERGE.
+- A2-7 R2 dup: ASCII-only NBSP-preserving trim twice: compress.mjs:73-84 (regex) vs search.mjs:251-261
+  (charCode scan) -- the shipped-defect class of "Whitespace inside inline code is content".
+- A2-8 R3 dup: replaceAll("\\","/") inline x10 (offline-rewrite 34,39,44,219,226; offline 133,311,
+  370,375,380); publish-policy.mjs:189 has a private posix(); check-tree.mjs:46 another (recorded).
+- Split: none (offline.mjs sections; loses ~100 lines with A2-1).
+- Sound: offline/offline-rewrite split recorded; HTML_COMBINED_RE follows the guard rule;
+  deriveOfflineJtdJs uses acorn; vendor-assets contained+guarded; symbols.mjs not a .twin scanner;
+  substitute() and decode() collisions coincidental; publish-policy disjointness self-test; gates run
+  clean (publish 6/6, page-baseline 11/11, symbol-index 46/46).
+- Leads: HTML escape maps x4 (highlight.mjs:251, render.mjs:2225/2230, template.mjs:993, gantt esc
+  213); atomic-write idiom; write.mjs:173-178 vs offline-rewrite 442-465 url() regexes (different jobs).
+
+## A3 -- Markdown dialect (done)
+
+- A3-1 R1 dup: fence-opener predicates differ -- maskCodeRegions (render.mjs:148,
+  /^[ \t]{0,3}(`{3,}|~{3,})/) accepts a backtick fence whose info string holds a backtick;
+  stashCodeFences (1713) refuses it (CommonMark-correct). Agent REPRODUCED: "```abc`def" masked by one,
+  unprotected by the other (a "> [!NOTE]" inside became an admonition). No live trigger in docs/;
+  check_code_regions structurally blind to it. Fix: one predicate + a probe. VERIFY repro.
+- A3-2 R2 dup: escapeHtml = 3 chars in highlight.mjs:252, 5 chars in render.mjs:2226; headingTocHtml
+  (1437-1450) uses 5-char for text, 3-char for code_inline; markdown-it escapes &<>" -> a TOC'd heading
+  with an apostrophe renders ' in the TOC, ' in the heading. Not live (no TOC'd page has one).
+- A3-3 R1 dup: absoluteUrl/relativeUrl ported twice and diverged: seo.mjs:121-148 (config arg, forces
+  leading slash, no space encoding) vs template.mjs:917-936 (baseurl arg, leaves bare values
+  unchanged, encodes spaces). No call site hits the gap today. Fix: builder/url.mjs.
+- A3-4 R2 dup: isNonEmpty nav.mjs:338 == seo.mjs:164.
+- A3-5 R3 dup: escapeRegExp (MERGE A2-4), splitFragment render.mjs:1593 vs crawl_check.mjs:51,
+  encodeSpaces (MERGE A2-5).
+- A3-6 R2 hack: three post-render whole-page regex rewrites with no code guard -- padEmptyCells
+  (render.mjs:74-80), normaliseVoidTags (351-354), injectAnchorHeadings (template.mjs:714-727). Safe
+  only by an unstated invariant (code content is entity-escaped); outside check_code_regions (which
+  covers only the pre-render chain). Fix: state the invariant; extend the gate. (Expect L3 overlap.)
+- A3-7 R2 struct: template.mjs holds a strftime formatter (940-989), URL helpers, escape helpers.
+- A3-8 R3 dup: highlight-theme.mjs three palette loops (336-344, 351-359, 364-372).
+- A3-9 R3 dead: precomputeSeo (seo.mjs:90-94) zero callers.
+- A3-10 R3 dead: kramdownSlug exported (render.mjs:1352), used only inside.
+- Split: render.mjs -- STRONG evidence: image renderer rule chained by md.use order (svgInline 518
+  before remoteImage 520; swapping reverses silently); anchors on third-party rule names
+  ("curly_attributes"); ellipsis plugin assumes dashes plugin ran first (515/516); shared helpers
+  thread through all plugins; no plugin tested in isolation. Seams listed. template.mjs weaker:
+  navActivationCss (446-567) deferral trigger in PLAN-4 §3 close to met.
+- Sound: nav.mjs; table/fence fixups on single known tokens; token-walking plugins immune to code;
+  html_block-scoped rewrites; template reuses seo's stripHtml; clampContrast loud; VOID_TAGS_RE gated.
+
+## A5 -- a11y and diagram gates (done)
+
+- A5-1 R1 dup/conv: argv loops copied in 7 scripts, diverged: check_a11y.mjs --help -> "unknown arg"
+  exit 2 while siblings print usage exit 0; check_dot_fit/build_dot_metrics use argv.includes and
+  ignore typos; usage printed to stdout in some, stderr in others. (Feeds L1.)
+- A5-2 R2 conv: 0/1/2 convention (Extending.md:640-648) broken in build_dot_metrics.mjs (no catch;
+  crash exits 1 = STALE) and pick_a11y_sample.mjs discover() (ENOENT exits 1 = coverage gap; live in
+  check.bat). check_dot_fit.mjs:31-34 has the guard with a comment citing the convention.
+- A5-3 R2 dup: page discovery + tag count + stub ceiling in pick_a11y_sample (122, 159-171, 184) and
+  sweep_a11y (78, 129-153); ceiling named STUB_CEILING vs STUB_TAG_CEILING (both 100); the two must
+  agree (--propose reads sweep's JSONL).
+- A5-4 R3 dup: pad/median identical in the same two files.
+- A5-5 R2 dup: check_dot_fit.mjs and build_dot_metrics.mjs duplicate the Inter host page + puppeteer
+  scaffold (font filenames, launch args incl. --allow-file-access-from-files unexplained there;
+  axe-scan's LAUNCH_ARGS omits it for the same kind of load). Three repo-root idioms in scope.
+- A5-6 R2 dup: check_dot_fit.mjs:49-67 re-walks .dot sources instead of importing dot.mjs's
+  listDotSources (135-155, private) -- the mirror-fault shape; five gates already import the chain.
+- Split: axe-scan.mjs NOT proposed (single-source property; any split must re-export).
+- Sound: axe SOURCE_PATCHES earns model status (exit implicit: "until axe ships a modern build");
+  dot-metrics WASM patch passes emphatically (signature match, read-back, real layout check, WeakSet);
+  check_tree_fresh uses isOutputTree; FAMILIES table is data; check_a11y mutual exclusion correct.
+
+## A6 -- gates as a system (done)
+
+- A6-1 R1 dup: self-test accumulator + report loop in check_page_baseline (38-44, 128-139),
+  check_book_coverage (81-87, 158-170), check_symbol_index (40-45, 361-370), + the crash handler in 4
+  gates. Diverged: only check_book_coverage.mjs:162 re-indents multi-line detail. Shared helper must
+  (a) keep probes unconditional, (b) take the probe-failure exit code as a parameter (1 for these
+  three; 2 for gates whose probes guard a separate sweep: check_gate_lists, check_regex_safety).
+- A6-2 R1 dup: test.bat:34-43's history of check_gate_lists contradicts the script's header (9-44)
+  and Tools.md:435-437. Fix: trim to a citation, like the other seven comments.
+- A6-3 R2 conv: convert_em_dash_separators.mjs:213-215 has no exit-2 path; matters once it runs in
+  the pre-commit hook (PLAN-10.md:690-693,795-797 deferred exactly that hook).
+- A6-4 R2 struct: nothing reads the workflows (decision 6). DESIGN: new standalone
+  scripts/check_ci_workflows.mjs + shared scripts/lib/gate-roster.mjs (gatesFromBat generalised,
+  check_gate_lists.mjs:111-118). Compares (1) wrapper roster vs each workflow (canonical: test.bat
+  U check.bat - check_tree_fresh U recorded CI-only check_links_diff), (2) the two workflows with each
+  other, (3) load-bearing build flags (--check-audit-index, --no-fetch-assets). Allowlist of recorded
+  deltas: checks.yml's fixture-built link-diff step (checks.yml:114-126); deploy's --url/--baseurl;
+  deploy-only steps. Probes: missing gate, extra step, reordered, missing --check-audit-index, and the
+  allowlisted deltas must NOT fire. Registration -> test.bat + both workflows + Tools.md list grows.
+- A6-5 R3 conv: serve.bat does not propagate the exit code.
+- Q4: composite action (.github/actions/run-gates) as a follow-on AFTER the gate -- not a reusable
+  workflow (separate job loses the built trees the deploy job needs). Would also fold checks.yml's
+  three install steps vs deploy's one.
+- Sound: check_gate_lists exemplary (18 probes); check_publish_policy bidirectional; regex_safety +
+  regex-fold strongest; code_regions + markdown-files strong; convert_em_dash uses markdownFiles and
+  preserves line endings; exit convention followed except A6-3/A5-2; .bat idiom deliberate and
+  cross-referenced; workflow comments point rather than restate. Probe counts: 18, 6, 11, 12, 46, 22, 12.
+- Leads: convert_em_dash --check as a pre-commit entry (closes the PLAN-10 deferral); A4's two
+  selfTest shapes and impexp's test trio are further probe-harness shapes.
+
+## A7 -- compiler harness (done)
+
+- A7-1 R1 dup: tbrun.mjs:327's failed-build test covers 2 of the 5 shapes tb-ide.mjs:730-734
+  BUILD_FAILED lists; misses "[BUILD] ERROR" and "[LINKER] compilation (codegen) error" -> exit 0 on a
+  failed build (the round-8 bug class). ORCHESTRATOR VERIFIED the regexes. Fix: export BUILD_FAILED.
+- A7-2 R2 struct: afterReveal's false (timeout) discarded by openFile/setCursor/select
+  (tb-operate.mjs:421-429, 453, 461, 475) -- reopens the "MsgBox( -> gBox(s" race silently.
+- A7-3 R2 dup: two CDP click primitives: tb-ide.mjs:848-861 clickCenter (no scroll/hit-test/retry,
+  used for buildIcon by buildProject and tbrun.mjs:295) vs tb-operate.mjs:79-134 click (used for
+  restartIcon and by every scenario).
+- A7-4 R2 dup: alive() and norm() in tb-registry.mjs (588-590, 462, private) and addin_test.mjs
+  (105, 230); addin_test already imports 9 names from tb-registry.
+- A7-5 R2 conv: CLI divergences REPRODUCED: tbbuild opt trailing -> undefined -> port NaN, timeout NaN
+  -> waitForCompile exits at once with a misleading message; tbrun/addin_test substitute defaults
+  (also for an explicit ""); tbbuild's positional finder skips a token after ANY --flag, so
+  `tbbuild --json proj` fails (dormant: check_examples.mjs:602 puts the path first); die() three
+  structures. (Feeds L1.)
+- A7-6 R3 conv: tb-registry.mjs:49-50 says tbrun's snapshot uses -EncodedCommand; tbrun.mjs:376 uses
+  -Command (safe today: fixed literal).
+- A7-7 R3 dup: 180 s compile timeout literal at 7 sites in 4 files.
+- A7-8 R2 dup: all ten test/addin scenarios hand-roll the HERE/HOST/lane preamble and skip object;
+  six hand-roll a "console lines since a mark" reader three different ways. Fix: a scenario helper +
+  linesSince beside readConsole.
+- A7-9 R3 struct: tbbuild's shutdown skips finishTidy when ide was never set; safe only by an
+  invariant in tb-launch.ps1 (suspended start); tbrun always tidies.
+- Split: tb-ide.mjs (console reading and add-in introspection separable; build-state core coupled);
+  tb-registry.mjs (file-boundary only); tbrun's reap tail -> tb-reap.mjs (would host A7-6's fix).
+- Sound: strict DAG, no cycles; tb-registry imported only by the three CLIs; stageProject shared;
+  laneProjectId allocator; check_tb_registry independent fixtures; PowerShell payloads are fixed
+  literals with data via env/stdin; job object + suspended-kill path; retry loops recorded + loud.
+
+## A10 -- smaller tools (done)
+
+- A10-1 R3 conv: eval's four parseArgs differ on unknown args and exit codes; transcript.mjs:190-198
+  exits 1 on bare --help.
+- A10-2 [agent R1] dup: wisdom sitemap.mjs:69-87 parseFrontmatter has no BOM strip (discover.mjs
+  94-117 does, after the AppGlobalClassObject incident) and disagrees with prep.mjs:343-388 on
+  coercion. ORCHESTRATOR: no markdown under docs/ has a BOM today -> dormant.
+- A10-3 R2 dup: wisdom sitemap.mjs:61-67 walk() re-implements markdownFiles; its reason ("no
+  dependency on builder/", PLAN-3.md:404) does not reach scripts/lib.
+- A10-4 R2 dead: wisdom/extract/schemas.mjs imported by nothing and drifted from workflow.mjs's inline
+  schemas (source_thread vs thread_path; section string vs enum).
+- A10-5 R2 conv: wisdom manifest.json/denied.json written non-atomically (messages.mjs:10-12,
+  wisdom.mjs:146); loadManifest has no parse guard; Phase 3 uses temp+rename.
+- A10-6 R2 struct: impexp.mjs/impexp.py self-tests (19 each) run by nothing; parity claimed in
+  Tools.md:958, unchecked. Fix proposed: check_impexp_parity.mjs in test.bat -- NEEDS DECISION: it
+  makes test.bat (and CI) depend on Python.
+- Split: none (impexp single-file downloads).
+- Sound: site JS clean IIFEs, svg-inline.js does not duplicate svgInlinePlugin (markup vs runtime);
+  build_fonts.py matches WIP.Fonts.md; eval isolation stack deliberate; merger.mjs; wisdom api paging.
+- Leads: atomic write x4 with inconsistent cleanup (impexp x2, wisdom state/merger); wisdom.mjs
+  switch-shaped parseArgs (another CLI variant).
+
+## DECISIONS since the plan was written
+
+- Decision 1 AMENDED by the user: detach-pages.js STAYS in perf/ (moving it breaks five perf scripts
+  and touches many docs). Instead: two header lines in perf/detach-pages.js (+ one in perf/README.md)
+  saying book.bat and the deploy workflow load it. Nothing moves out of perf/.
+- APPROVED by the user: DELETE the four superseded shims (fast-refs, fast-dict-array, fast-dict-iter,
+  fast-parse-dict). Do it AFTER L3/L4 finish (L4 reads their clone pairs), as its own commit.
+- USER: NO BRANCH. staging is the working branch ("whatever I'm working on right now"), merged to
+  upstream/main by the user when a chunk of work is done. Commit on staging. Revise the plan's
+  "Each phase lands as one pull request" to match: commits on staging, merging upstream is the user's. Touches: the four files; perf/measure.mjs's four
+  flags (+ exclusivity checks, imports); perf/phase0-measure.mjs:28 import -> fast-refs-class (its
+  "production-equivalent" claim is currently false); comments pointing at the files:
+  render-book.mjs:46,57, fast-indirect-objects.mjs:21, fast-parse-object.mjs:39,
+  fast-refs-class.mjs:3,37, fast-sync-load.mjs:80. No published page names them. Closes A9-4, A9-6.
+  Fallback if the user prefers: move into perf/ + path updates in the same two perf scripts.
+
+## L1 -- CLI conventions (done)
+
+36 entry points inventoried (32 argv readers + 4 argument-less gates). Appendix has the table and the
+cli.mjs spec.
+- L1-1 R1 dup: --theme/--viewport validation (check_a11y.mjs:82-95 pick(), fixed after "--theme drak"
+  labelled a light run "drak") missing from sweep_a11y.mjs:120-121 and check_a11y_fingerprint.mjs:
+  134-135 (the axe-upgrade gate). Fix: hoist pick() into axe-scan.mjs or validate in buildMatrix.
+  VERIFY.
+- L1-2 R1 dup: opt() x7 in 4 variants -- adds check_publish_policy.mjs:31-33 (inline, unnamed). NaN
+  from a trailing value flag: tbbuild.mjs:68,70 (port, timeout), check_examples.mjs:102-104 (jobs,
+  port, batch).
+- L1-3 R1 dup/hack: positional detection -- tbrun VALUE_FLAGS (112-116); tbbuild order-dependent
+  find (62): `tbbuild --keep proj` -> usage error.
+- L1-4 R1 hack: tbdocs.mjs:189-191 throws on an unknown argument -> main().catch -> exit 1, the "link
+  check failed" code. Fix: exit 2 for parse errors. VERIFY.
+- L1-5 R2 dead: check_links.mjs:307-314,406-412 tolerate unknown flags "passed through via
+  check.bat's %*" -- check.bat no longer calls it (PLAN-checks.md:14).
+- L1-6 R2 conv: --help in four shapes (stdout/0; stderr/0 in pick_a11y_sample, sweep_a11y; stderr/2
+  via the usage error in tbbuild, tbrun, addin_test; none in 12 tools). gen_attribute_probes takes
+  --help as out_dir and mkdirs "--help/Sources"; render-book rejects --help as unknown (exit 2).
+- L1-7 R2 conv: unknown flag -> ignore (11 tools) / warn (check_links) / err 2 (8) / throw -> 1 or 2
+  / err 1 (wisdom).
+- L1-8 R2 conv: --json boolean-to-stdout (tbbuild, tbrun, check_examples, census) vs value-taking file
+  (check_a11y_fingerprint).
+- L1-9 R3 conv: --src = docs root (tbdocs, check_publish_policy) vs exported package tree (census,
+  build_package_api).
+- L1-10 R3 conv: --name=value only in tbdocs, and not for --check-findings/--symbol-gaps.
+- L1-11 R2 dup: uncaughtException->exit 2 one-liner in check_page_baseline:34, check_book_coverage:27,
+  check_symbol_index:38, check_publish_policy:29; missing in check_tb_registry.mjs (crash exits 1).
+  Tools.md's check_publish_policy entry omits its exit 2. (Overlaps A6-1.)
+- L1-12 R3 dup: "usage; exit by whether help was asked" x6 in eval/ and wisdom.
+- L1-13 R2 struct: nothing tests any tool's argv handling -- the seam that let L1-1/L1-2 ship. The
+  shared module should carry its own ride-along self-test in test.bat.
+- Spec (scripts/lib/cli.mjs): parseCli(argv, {options, positionals}) over node:util parseArgs
+  (strict, allowPositionals, tokens) + camelCase aliases + positional-count checks + tokens pass-
+  through; withUsageError(fn, {onError}) keeps each tool's own message/stream/code; numberOption()
+  closes the NaN hole; printHelpAndExit(text, {stream, exitCode}) keeps each tool's current --help
+  shape until Phase 3. Care: multiple:true for --forbid (check_links), --source (check_tree_fresh),
+  --case (check_links_diff), --additional-script (render-book), --channel (wisdom); tbdocs's
+  cross-flag order (--no-check resets others) needs a walk over tokens -> MIGRATE TBDOCS LAST;
+  strict:false still parses undeclared options; check_links's warn-collect and wisdom's subcommands
+  need custom code (wisdom last or never).
+- Exit schemes: link bitmask (tbdocs, check_links: deliberate, documented); tbbuild 0-4, tbrun 0-3,
+  addin_test/check_examples 0-2; gates 0/1/2; impexp 0-6. Misleading: L1-4; addin_test's 2 means
+  either "harness failed" or "registry not restored".
+- Sound: .bat capture-before-popd x7; check_examples --json keeps stdout clean; impexp.mjs is the
+  best-disciplined CLI (verb Map, EXIT object, usageError) -- the model for Phase 3; census --help
+  prints its own header.
+- Leads: check_regex_safety has an internal --shard re-exec flag (A6); buildMatrix validation (A5);
+  serve.mjs exit codes vs tbdocs (A1).
+
+## L2 -- paths, config, dependencies (done)
+
+- L2-1 R1 dup: two more hand-kept lists of output trees, the exact defect check_tree_fresh was fixed
+  for (WIP.Build.md:376-388): builder/serve.mjs:138 IGNORED_PREFIXES has no _site-basepath* (a
+  check_links_diff --b fused run while serve.bat runs -> spurious rebuild); eval/build_corpus.mjs:59-71
+  EXCLUDED_PATHS has docs/_site-basepath but its prefix test misses _site-basepath-offline/-pdf.
+  60bb6f5 edited serve.mjs's list the same day markdown-files.mjs landed. Fix: isOutputTree. VERIFY.
+- L2-2 R1 dup: census_attributes.mjs:99-119 findInstall re-implements tb-install.mjs:19-35 findIde
+  (whose header exists to prevent a private copy) and diverged: validates packages/ vs twinBASIC.exe;
+  os.homedir() fallback only in the private copy. Fix: use findIde, keep its packages/ check, move the
+  homedir fallback into tb-install.
+- L2-3 R2 dup: = A5-6 (dot.mjs listDotSources vs check_dot_fit findDotSvgs); neither uses
+  isOutputTree -- their broader "_"/"." skip is safe today (no .dot under _data/_sass/_App/_Images).
+  Fix: one filesUnder(root, {ext, skip}) helper; keep the broad skip. MERGE with A5-6.
+- L2-4 R2 dup: = A2-3 readBaseline; plus symbol-baseline.mjs:55-58's hand-rolled writeBaseline emits
+  `"urls": [\n\n  ]` for an empty list where JSON.stringify gives `[]` (agent verified). Four
+  generated JSON files, four serializers (inter-metrics 1-space and package-api hand-built have
+  reasons). MERGE with A2-3.
+- L2-5 R2 dup: repo root derived inline in ~19 files, four expressions; axe-scan.mjs:29 exports
+  REPO_ROOT, imported by 2. Extending.md:654 names the convention. Fix: scripts/lib/repo-paths.mjs
+  (re-exported by axe-scan).
+- L2-6 R3 conv: mkdtemp scratch not removed in a finally: check_publish_policy.mjs:152-189,
+  check_links_diff.mjs:651-750 (three siblings do it right).
+- L2-7 R3: three small tree walkers in offline.mjs (401-416, 608-626) and write.mjs (281-299) -- not
+  proposed.
+- Sound: exactly three real YAML parse sites (tbdocs.mjs:271, check_publish_policy.mjs:69,
+  data.mjs:12), bare yaml.load, each loaded once per build; check_publish_policy's cwd-relative SRC
+  is recorded (Extending.md:654); atomic temp+rename writes where used; tbrun cleans its per-port work
+  dir at the next start on purpose; only picocolors and pako are undeclared; lockfile + npm ci.
+- Leads: Builder.md:412-440 "Dependencies" is stale -- misses recheck, shows wasm-graphviz ^1.21
+  (really ^1.29.1), and says axe-core is the only exact pin (four are: axe-core, pdf-lib, puppeteer,
+  recheck). A REPEAT of a drift the last review fixed (74b3395). Each exact pin has a recorded
+  technical reason; nothing states the policy (exact = we patch or depend on internals; caret =
+  the rest), so carets on output-changing deps look like an accident when they are not.
+
+## L4 -- clone leads classified (done)
+
+37 regions >= 100 tokens + 57 same-named functions, both sides opened.
+NEW:
+- L4-2 R3 dup: scss.mjs compileLightScss/compileDarkScss (65-76, 78-89) same body.
+- L4-3 R2 dup: vendor-assets.mjs fetchToFile (222-252) / fetchAttachment (279-319) each re-implement
+  fetch-with-two-tiers + atomic temp+rename write.
+- L4-7 R2 dup: CommonMark code-span splitting twice -- render.mjs:180-204 maskInlineCode vs
+  convert_em_dash_separators.mjs:74-~100 splitInlineCode; the em-dash tool already shipped a bug in
+  this exact logic (its comment 70-73). Equivalent today. Fix: shared splitOnCodeSpans.
+- L4-10 R1 dup: logicalLines -- tb-fences.mjs:423-445 (private) vs twin-api.mjs:51-~90 (exported);
+  twin-api handles a BOM and multi-line /* */ block comments, tb-fences neither. VERIFY that swapping
+  in twin-api's version keeps tb-fences' quote-aware comment stripping (its reason, :423).
+MERGES: L4-1 = A1-3 (and the 4th, "claimed task" path, 497-540, is legitimately different: its own
+ordering trap 515-525). L4-4 -> A2-1 (buildSitePaths is dead: delete, don't delegate). L4-5 = A2-3.
+L4-6 = A3-3, with two more disagreements: protocol-relative //host (template.mjs skips it; seo.mjs's
+isAbsoluteUrl misses it and prepends baseurl -- masked while baseurl is ""), non-string input (null
+vs ""). L4-8 = isNonEmpty/encodeSpaces/splitFragment/statSafe/makeTimer cluster (A3-4, A2-5, A3-5,
+A4-1, A1-4) -> a low-level util module; route makeTimer so offline.mjs never imports tbdocs.mjs.
+L4-9 = A5-3/A5-4. L4-11/12 = L1-2/L1 (CLI). L4-13 = A6-1/L1-11 (+ withBaseline tmpdir fixture twice:
+check_page_baseline.mjs:46-55, check_symbol_index.mjs:314-323). L4-14 -> A7-8 (probeLines with
+hard-coded .slice(15)/.slice(13)).
+CONFLICTS to settle in verification:
+- findingsFor/buildFindings: L4 says (c) recorded -- mirrored so check_links_diff can cross-check
+  (check.mjs:410-414, check_links.mjs:599-601). SUPERSEDED by the user's option-2 decision (A4); the
+  fix must rewrite those two comments.
+- normalizeBaseurl/escapeRegExpBook: L4 accepts book.mjs:298-300's "by design ... plugins are
+  independent"; A9-8 says that premise (Ruby plugins) expired at the Node cutover -- book.mjs already
+  imports siblings. Side with A9, cite both.
+- escapeHtml 3 vs 5 chars: L4 (c) via highlight.mjs:249-250 (Rouge). Covers highlight's choice, not
+  render.mjs's TOC mixing both -> keep A3-2, narrowed.
+- writeBaseline: L4 says deliberately different (one URL per line); L2-4 says it equals
+  JSON.stringify(...,2) except an empty list. READ the code.
+- alive/norm: L4 (b) "no clone region" (below the window); A7-4 read both: identical. Keep A7-4 (R3).
+- parseFrontmatter: L4 (b) different dialects; A10-2's BOM gap is independent of dialect. Keep,
+  dormant.
+- extractFromHtml: L4 (b) different sizes; A4-3 is a coverage gap (5 vs 20 tag/attr pairs), not a
+  clone. Keep A4-3.
+Sound (b): data tables (GANTT_SECTION~HUMAN, SCOPE_TO_SYMBOL~MUST_REFUSE, FAMILIES, fixtures), main,
+the five selfTest shapes, classify, page, kindOf, zero-pad pad, decode, resolve, strip, serialize,
+select, runAll, printHelp, discover, walk, fmtMs, log, count, substitute, node:test boilerplate.
+(c): check-tree.mjs's inlined fnmatch/excluded (import cost on the dispatch path).
+
+## L3 -- browsers, processes, text rewrites (done)
+
+- L3-1 R1 dup: check_a11y.mjs:167-218 launches the browser with no try/finally (browser.close at 218
+  only on success; main().catch at 244-247 exits 2 without closing); siblings on the same library
+  guard it: check_a11y_fingerprint.mjs:234-248, sweep_a11y.mjs:209-273, check_axe_patch_equiv.mjs:
+  99-115. PAGE_STATES appliers (axe-scan.mjs:117-172) throw by design. Leaks Chromium in CI (Linux);
+  Windows unverified. Fix: withBrowser(fn) in axe-scan.mjs for all four.
+- L3-2 R2 dup: check_examples.mjs:601-612 (buildStaged) spawn has no 'error' handler and awaits
+  'exit' not 'close'; addin_test.mjs:180 and check_regex_safety.mjs:347-348 do both. A spawn failure
+  crashes past finishTidy (registry restore).
+- L3-3 R1 hack: wisdom/extract/merger.mjs:114-129 parseStaging splits on bare '---' with no fence
+  awareness; parseSection (162-168) returns null for a chunk not starting "## " and the caller drops
+  it -- contradicting 111-112 ("never silently drop reviewer content"). staging.md is committed and
+  PLAN-3.md:181-183 puts fenced tb code in every section. Not live today (section count = '---' count).
+- L3-4 R2 dup: three fence/code-span parsers -- render.mjs maskCodeRegions (141-175) + maskInlineCode
+  (180-204), stashCodeFences (1704-1730), convert_em_dash_separators.mjs FENCE_OPEN_RE/CLOSE_RE
+  (59-60) + splitInlineCode (74-99) + convertText (141-157). The info-string guard is only in
+  stashCodeFences; the em-dash tool shares maskCodeRegions' gap. MERGE with A3-1 and L4-7.
+- L3-5 R2 dup: HTML escapers -- minimal (&<>) x3: render escapeHtmlMinimal 2230, highlight escapeHtml
+  251, gantt esc 213; full (&<>"') x4: render escapeHtml 2225, template escText 993-998, template
+  escAttr 999-1001 (== escText), sitemap xmlEscape 106-113. escapeHtml names both classes.
+  html-entities used only to decode (outline.mjs:22). MERGE with A3-2 and A2's lead.
+- L3-6 R3 conv: check_links_diff.mjs:598-601 main()'s try/catch covers only argument parsing;
+  fusedBuild (426-432) and ensureBasePathTree (518-525) spawnSync unguarded -> raw stack trace.
+- Split: render.mjs -- the two fence parsers sit 1,550 lines apart with no cross-reference (evidence
+  for A3's candidate).
+- CONFLICT with A3-6: L3 calls the unguarded whole-page HTML rewrites SOUND -- padEmptyCells,
+  normaliseVoidTags, injectAnchorHeadings, search sanitiseContent/extractSections, seo stripHtml,
+  book.mjs bookChapterTransform steps 2/2b/4 -- because every code path escapes & < > before they
+  run (highlight.mjs:252-253, render.mjs:2231-2233); book.mjs steps 1 and 5 (quote-anchored) use
+  replaceOutsideCode. A3-6 says the same invariant is unstated at the sites and outside the gate.
+  Likely resolution: the invariant holds (so not a live hazard); the finding becomes "state it and
+  probe it", R3 or low R2.
+- Sound: four puppeteer launches consistent where it matters; --allow-file-access-from-files needed
+  where the page fetch()es a sibling file:// resource (A5-5 still right that the dot tools don't say
+  so); share the lifecycle, not the args. Child processes: no shell:true, array args everywhere,
+  killTree by pid, stdin hazard fixed once in runCompiler, PowerShell -EncodedCommand with data via
+  env/stdin, tbrun's -Command a fixed literal; cleanup disciplined across tb-lane, tb-addin,
+  addin_test (SIGINT), panes.test after(), check_tb_registry; runShard is the model; check_links'
+  Worker dispatch; worker-pool. Text: applyPreRenderRewrites brackets its four rewrites; token-scoped
+  md.core rules are a third sound mechanism WIP.Build.md does not name; HTML_COMBINED_RE and
+  IMG_SRC_RE follow the documented shape; other unguarded rewrites target build-generated markers.
+- Leads: document the token-scoped mechanism in WIP.Build.md; book/lib/outline.mjs and
+  postprocesser.mjs are pagedjs-cli ports -- treat as vendored (A9 found them attributed, unmodified);
+  L3-1 needs an empirical browser test.
+
+
diff --git a/builder/REVIEW-TOOLING-fe9ce12b/markdown-inventory.md b/builder/REVIEW-TOOLING-fe9ce12b/markdown-inventory.md
new file mode 100644
index 00000000..9e1fe95b
--- /dev/null
+++ b/builder/REVIEW-TOOLING-fe9ce12b/markdown-inventory.md
@@ -0,0 +1,273 @@
+# Markdown-as-text inventory
+
+Read-only investigation. Nothing in the repository was changed; verification used
+small Node scripts in this folder (`task2_fixtures.mjs`, `task2_timing.mjs`,
+`probe_staging.mjs`, `probe_staging2.mjs`, `verify_navhops.mjs`,
+`verify_navhops2.mjs`, `verify_staging_rigorous.mjs`) run against the repo's own
+`node_modules` (resolved through the scratchpad's junction) and, for two probes,
+against the real committed `wisdom/data/findings/staging.md`.
+
+---
+
+## Task 1 — Inventory
+
+### Legend for "code awareness"
+
+CommonMark rules a fence/inline parser can know about, abbreviated in the table:
+
+- **F** — backtick *and* tilde fences recognised
+- **I3** — up to 3 leading spaces on the opener allowed (a 4-space opener is an indented block, not a fence)
+- **CL** — closing-fence rule: same character as the opener, run length ≥ opener's, line otherwise blank
+- **IS** — info-string rule: a *backtick* fence's info string may not itself contain a backtick
+- **IC** — indented (4-space) code blocks recognised as code
+- **NEST** — a fence nested inside a blockquote (`> `) or list item is still recognised, with the prefix stripped
+- **CS** — inline code spans, multi-backtick runs (`` ``a`b`` ``) matched correctly
+- **HTML** — raw HTML blocks recognised as opaque (not prose)
+- **FM** — YAML frontmatter recognised/stripped before anything else runs
+
+"none" = no rule from this list; "hand-rolled (X, Y)" = only the listed rules, by hand, no markdown-it.
+
+### A — Rewrite sites (mutate source or a file)
+
+| # | Site | Matches | Awareness | Misfires on |
+|---|---|---|---|---|
+| A1 | `builder/render.mjs:141-169` `maskCodeRegions` + `:180-204` `maskInlineCode` | fenced blocks (mask), then per-line backtick/tilde runs (mask) | hand-rolled (F, I3, CL, CS). **No IS** — open regex `/^[ \t]{0,3}(\`{3,}\|~{3,})/` never checks the info string for a backtick. No IC (deliberate, documented gap). No NEST test (works structurally by line-scan, so blockquote/list prefixes pass through as ordinary text — it happens to work for masking purposes but the closing-fence scan doesn't know it's "inside" anything). | A backtick-fenced opener whose info string itself holds a backtick (e.g. `` ```abc`def ``) is accepted as an opener here but refused by A2's `stashCodeFences` (CommonMark-correct) 1550 lines below in the *same file* — the two disagree on the same input. Reproduced directly: `` ```abc`def `` masked by A1, left unprotected by A2, and a `> [!NOTE]` inside it was then rewritten into a live admonition. **Not observed in `docs/` today** — `check_code_regions.mjs` is structurally blind to this class (see its "KNOWN GAP" comments), so a future instance would ship silently. |
+| A2 | `builder/render.mjs:1670` `ADMONITION_RE`, `:1704-1730` `stashCodeFences`, `:1732-1772` `rewriteAdmonitions` | admonition blockquotes (`> [!NOTE]` etc.) outside the A1 mask (deliberately — a fence inside an admonition still carries `> ` markers when A1 has already run and been restored) | hand-rolled (F, CL, IS). No I3 (opener anchored with `[ \t]*`, same as A1). No IC, no NEST tracking beyond the line-scan `stashCodeFences` already does for the reason above. | **This is the site the repo's own comments cite as having already shipped broken**: `docs/Reference/Attributes.md`'s `Description` attribute example (a `[Description("...")]` string built from twinBASIC literals `"\`\`\`basic"` and `"\`\`\`"` used as *sample text*, not real fences — see lines 379-393 of that file) used to close the surrounding fence on the embedded literal instead of the real closer, corrupting every admonition on the rest of the page. **Fixed** by the current line-based `stashCodeFences` (it requires a *standalone* fence-only closing line, which the embedded literals never are, since they sit inside a longer source line). Read today: confirmed the current code handles this specific page correctly. The class of bug is real and documented (`check_code_regions.mjs`'s own `ADMONITION_PROBES` reproduce five variants), just not currently tripped by this exact page. |
+| A3 | `scripts/convert_em_dash_separators.mjs:59-60` `FENCE_OPEN_RE`/`FENCE_CLOSE_RE`, `:74-99` `splitInlineCode`, `:135-166` `convertText` | same fence-boundary shape as A1 | hand-rolled (F, I3, CL, CS). **Same missing-IS gap as A1** — literally the same regex shape, independently written. No IC (the file's own comment states this as a known, accepted gap, citing `Reference/Core/Get.md`/`Option.md`'s 2-space list-item fences by name). | Verified against the real corpus: `Get.md` and `Option.md` do carry exactly the cited 2-space fences (confirmed at `docs/Reference/Core/Get.md:49-51,69-73`), each nested under a bullet item. This tool's own header comment records that its *inline* splitter (`splitInlineCode`) already shipped one dash-inside-a-doubled-backtick-span bug and was fixed — the defect class has a track record in this exact file, not just a theoretical one. |
+| A4 | `scripts/check_examples.mjs:1123-1154` `applyMarkers` | one exact fence-opener *line*, located by an already-markdown-it-derived line number (`fence.line`, from `tb-fences.mjs`'s `collectFences`, which itself uses `md.parse`) | **hybrid**: the *location* is markdown-it-correct; the *edit* is a guarded plain-text splice — reads the line, requires it to match `/^[ \t]*(?:>[ \t]*)*(\`{3,}\|~{3,})tb[ \t]*$/` (NEST via blockquote-prefix tolerance built in) before touching it, else refuses with a named error. CRLF-preserving by construction (captures/reattaches a trailing `\r` per modified line). | Low risk by design — it only ever touches a line it can re-verify, and refuses rather than guessing. Included because it duplicates "split file into lines, edit one, rejoin, preserving CRLF" logic that a shared module should provide once rather than leave as a one-off. |
+| A5 | `wisdom/extract/merger.mjs:114-160` `parseStaging`, `:412-435` `serializeStaging` | splits `staging.md` into chunks on any line that is *exactly* `---`, then requires each chunk to start with `## ` | **none** — no fence rule at all, of any kind. `parseSection` (`:162-168`) `return`s `null` for a chunk not starting `## `, and the caller (`:150-154`) silently drops it — which directly contradicts the file's own header comment ("the parser is forgiving... we never silently drop reviewer content", `:106-113`). | **Confirmed live in the real, committed `wisdom/data/findings/staging.md`** — see the dedicated section below. This is the most severe finding in the inventory: real, on-disk section corruption today, not a hypothetical. |
+
+### B — Read-only scan sites
+
+| # | Site | Matches | Awareness | Misfires on |
+|---|---|---|---|---|
+| B1 | `builder/census_attributes.mjs:377-392` `documentedAttributes` | `docs/Reference/Attributes.md`, per-line `/^Syntax:\s*\*\*\[(\w+)/` and `/^Applicable to:\s*(.*)$/` | none | No fence exclusion at all. Dormant today: the page's own `Description`-attribute example (lines 366-406) demonstrates the *convention* using `"### Syntax"` (a heading, inside a string literal) rather than the literal string `Syntax:`, so it does not trip this scanner as written. A future example demonstrating the `Attributes.md` page format itself (plausible — it is meta-documentation about attributes) would. |
+| B2 | `scripts/gen_attribute_probes.mjs:66-90` `parseAttributes` | same file, near-identical `/^Syntax:\s*(.*)$/` + `/^Syntax:\s*\*\*\[(\w+)/` + `/^Applicable to:\s*(.*)$/`, line-by-line | none | Same exposure as B1 — this is an independent, near-duplicate implementation of the same scan (**a third hand-written `Attributes.md` parser**, after B1 and A2's shared subject matter), with its own CRLF-normalisation comment ("Python reads in text mode... Match that, or every line carries a trailing CR") showing it was ported from a different predecessor than B1. |
+| B3 | `scripts/check_gate_lists.mjs` (whole file: `gatesFromBat` `:111-118`, `sectionBody` `:121-128`, `gatesFromDoc` `:138-145`, `commandRuns` `:164-180`, `splitSections` `:251-264`, `subjectWrapper` `:273-289`, `proseClaims` `:297-351`) | `README.md` + every `docs/Documentation/*.md` page: batch-file step lists, and five regex shapes over prose (possessive/verbal/line-initial/section-total/back-reference gate-count claims) | **none** — `splitSections` treats *any* line matching `/^#{1,6}\s/` as a new section, full stop; nothing excludes a fenced code block first. | **Confirmed live**: `docs/Documentation/Wisdom.md:305` is a fenced example of the `staging.md` format (see A5) whose first line, `## docs/Reference/VB/Form/index.md · after-remarks`, matches the heading regex. `splitSections` genuinely splits `### staging.md format` into two phantom sections at that point — verified with a direct `awk` sweep of every fenced region in `README.md` and `docs/Documentation/*.md` for a line matching `^#{1,6} `; this is the only hit. It does not currently flip a verdict (the phantom section's body happens to contain no `check.bat`/`test.bat` count phrase), but the tool's internal model of "what section am I in" is provably wrong at that point today, for a reason that has nothing to do with gate counts. |
+| B4 | `builder/counts.mjs:112-116` `countAttributeAnchors` | `Reference/Attributes.md`'s **raw** `page.rawContent`, `/^\{: #[a-z0-9]+ \}/gm` | none (regex over raw source, not the rendered/masked form used elsewhere in the same file) | Narrow pattern, low realistic collision surface; no fence in `Attributes.md` currently contains a line of that exact shape. |
+| B5 | `builder/counts.mjs:130-140` `countEnumerations` | `Reference/Enumerations.md`'s raw content: split on `/^## Alphabetical index\s*$/m`, then on `/^#{1,6} /m`, then counts `/^- \[/gm` | none — the function's own comment admits it: "it scans one page's raw markdown, so a change to that page's list formatting moves the number." | No fence currently sits inside that page's "Alphabetical index" section, so dormant. |
+| B6 | `wisdom/extract/sitemap.mjs:69-87` `parseFrontmatter` | any `docs/Reference/**/*.md`: `content.startsWith('---')`, `indexOf('\n---', 3)`, then per-line `/^(\w[\w_]*)\s*:\s*(.+)$/` | hand-rolled (**FM**, partial — no BOM strip, unlike `discover.mjs`; no YAML lists/multi-line values, only quoted scalars) | Dormant today — "no markdown under `docs/` has a BOM" was checked (matches the AppGlobalClassObject incident's fix). Live the moment an editor reintroduces one, which `WIP.md` documents as something Windows editors do "without being asked." Also, `sitemap.mjs:61-67`'s `walk()` re-implements `scripts/lib/markdown-files.mjs`'s directory walk from scratch (does not skip `docs/_site*` etc. by the shared rule) rather than importing it — a second, independent copy of "which folders are output trees." |
+| B7 | `wisdom/extract/prep.mjs:343-388` `parseThreadFrontmatter` | Discord-thread `.md` frontmatter under `wisdom/data/threads/`: same `---`-delimited-block approach as B6 plus inline-array and boolean/number coercion | hand-rolled (**FM**, partial — no BOM strip; a *different* dialect from B6: arrays and type coercion B6 doesn't do) | Two independently hand-written, behaviourally-diverging frontmatter parsers (B6, B7) for what is conceptually one problem. A thread title or tag value containing an unquoted `:` (plausible in harvested Discord content) would misparse under the same `/^(\w[\w_]*)\s*:\s*(.+)$/` per-line rule both share. |
+| B8 | `eval/run_case.mjs:99-110` `evaluatorProtocol` | `eval/protocol.md`: `lines.indexOf("---")` for the start marker, `lines.findIndex(l => l.startsWith("## For the orchestrator"))` for the end, slices between | none | Checked the real file: exactly one `---` line (line 10) and zero fences in `eval/protocol.md` today, so dormant — but the mechanism is the same "first bare marker line, no code awareness" shape as A5/B3, applied to the document that becomes the evaluator's own system prompt. A fenced example added before the `---` or containing one would silently move the boundary and could leak orchestrator-only instructions into what the evaluator reads (or vice versa). |
+| B9 | `eval/nav_hops.mjs:86-94` `hrefs` | every page's link targets, after stripping fenced blocks with `/^(\`\`\`\|~~~)[^\n]*\n[\s\S]*?^\1[ \t]*$/gm` | **hand-rolled but deliberately fence-aware** (F, CL) — the *only* site in the inventory outside the markdown-it-based ones that tries. Verified it correctly handles CRLF (JS regex `$` in multiline mode treats a bare `\r` as its own line terminator, so `[ \t]*$` matches before it — tested directly). **No I3** (opener anchored at column 0, no indent tolerance) and **fixed-length-3 only** (`` ``` `` / `~~~` exactly, not "3 or more" — CommonMark's actual rule), so it cannot recognise a 4+-backtick fence or one indented for a list/blockquote. | Measured precisely against every page under `docs/`: markdown-it finds 1,371 real fences; this regex recognises 1,353 of them (≈98.8%), missing 11 indented and 3 four-backtick fences. Of the 3 real fences anywhere in `docs/` whose content contains `](` (link-shaped text), **zero** are among the missed ones — so no live misfire today, but the gap is real and of the same shape already known to bite this corpus (the same 2-space list-item fences A3 documents by name). |
+| B10 | `test/addin/symbols.test.mjs:57-58` `declaredIn`/`firstLine` | markdown returned live by the twinBASIC compiler's LSP hover (rendered from a `[Description(...)]` attribute string, see A2/`Attributes.md`), `/^## \*\*\w+\*\*[^\`\r\n]*\`in ([\w.]+)\`/m` | none | Low risk in practice — the regex only needs the *first* heading line, and every convention-following `Description` puts the heading first (`## **Name** ... \`in Module\``), before any fenced example could appear. Included for completeness since it is literally "markdown held as a string, regexed with no fence awareness," per the task's own phrasing, sourced from a live compiler response rather than a file. |
+
+### C — Checked, out of scope (not markdown-as-text, or not markdown at all)
+
+For completeness, sites the same searches surfaced and were read, then excluded:
+
+- `eval/build_corpus.mjs` — copies `.md` files byte-for-byte (CRLF→LF only) into the evaluator's corpus; no structural parsing.
+- `eval/site_search.mjs`, `eval/transcript.mjs` — read the built `search-data.json` / stream-JSON transcripts; no markdown involved.
+- `scripts/check_links.mjs`, `check_links_diff.mjs` — operate on the *built* `docs/_site` HTML tree via `builder/link-check.mjs`, never on markdown source.
+- `scripts/pick_a11y_sample.mjs`, `sweep_a11y.mjs` — `split("\n")` over their own JSONL result logs, not markdown.
+- `scripts/check_regex_safety.mjs` — scans regex *literals inside `.mjs` source files* for ReDoS safety; mentions `Attributes.md`/`Tools.md` only in a comment.
+- `builder/highlight-theme.mjs:196-208` `parseTheme` — parses a TextMate-style `Name: value;` theme file, a different text format entirely, not markdown.
+- `builder/symbols.mjs:110-124` `headingsOf` — scans **rendered HTML** (`` tags) with a hand-written linear scan rather than regex (explicitly to avoid `O(n^2)`); real, but not markdown source, so out of the task's stated scope. Same for `render.mjs`'s post-render whole-page HTML rewrites (`padEmptyCells`, `normaliseVoidTags`) and `template.mjs`'s `injectAnchorHeadings` — HTML text, not markdown.
+- `wisdom/process/render.mjs:67-70` `truncate` — takes the first line of a Discord message for a reply-quote preview (`.split('\n')[0]`); not structural, no heading/fence interpretation attempted.
+- `wisdom/process/thread.mjs`, `wisdom/discord/api.mjs` — read/write JSON and generate `.md`, never scan existing markdown for structure.
+- `wisdom/extract/state.mjs` — pure JSON state, no markdown.
+
+### D — Positive precedent (already token/parser-based)
+
+- `scripts/lib/tb-fences.mjs:295-328` `collectFences` — `const md = new MarkdownIt({ html: true })`; walks `md.parse(src, {})`'s tokens for `type === "fence"`, using `t.map` for page line numbers.
+- `scripts/check_code_regions.mjs:75-90` `codeRegions` — same bare-instance pattern; walks `fence`/`code_block`/`code_inline` tokens for its before/after diff gate.
+- `builder/discover.mjs:94-117` `parseFrontmatter`/`stripBom` — `gray-matter`, with an explicit BOM-strip in front of it because `matter.test()` doesn't do that itself.
+- `builder/counts.mjs:242-249` `findCountRefs` (+ `:287-303` `validateCountNames`) — reuses `render.mjs`'s `maskCodeRegions` rather than re-deriving "what is code."
+- `builder/counts.mjs:322-327` `findSurvivingPlaceholder` — scans **rendered HTML** with the ``/`
` leading-alternation guard (`WIP.Build.md`'s documented safe shape for an HTML-level rewrite).
+- `builder/render.mjs:860-1173` (`standaloneIalForwardPlugin`, `tightLooseListPlugin`, `looseDeflistPlugin`) — these register as `md.core.ruler` passes that run *after* block parsing and consult `token.map` to index back into `state.src.split("\n")` only for already-parsed, already-located tokens. Not a "site" in the same sense (no independent fence-detection logic — they consume markdown-it's own structural output), included as the architecture the rest of the file's two fence parsers (A1/A2) do not follow.
+
+### The `staging.md` finding, in detail
+
+This is the strongest concrete result in the inventory: real, on-disk corruption in the
+**already-committed** `wisdom/data/findings/staging.md` (15,553 lines), caused exactly
+by A5's lack of fence/blockquote awareness. Traced with markdown-it against the real file
+(`verify_staging_rigorous.mjs`):
+
+Real source, `wisdom/data/findings/staging.md:14238-14251` (unedited excerpt):
+
+```
+14238: ## docs/Reference/VBA/Strings/Len.md · after-remarks [DUPLICATE? -- see also thread ...]
+14239:
+14240: > [!NOTE]
+14241: >
+14242: > In twinBASIC x64 builds, `Len()` and `LenB()` return different values...
+14243: >
+14244: > ```tb
+14245: ReDim arr(LenB(udt) - 1)
+14246: ```
+14247:
+14248: _Source threads: 1314412625714479125 · confidence: high_
+14249: _Date range: 2024-12-06_
+14250:
+14251: ---
+```
+
+Line 14245 is missing its blockquote continuation marker (`> `) — a real authoring slip in
+a fenced sample nested inside a `> [!NOTE]` admonition. Per CommonMark, an unprefixed line
+cannot lazily continue a blockquote-wrapped fence, so markdown-it does **not** close the
+fence at line 14246 (which itself has an empty info string and is not part of the same
+fence at all): it opens a *new*, unclosed fence there that runs for 52 lines, to line 14297,
+swallowing the real meta lines (14248-14249), the real section-separator `---` (14251), and
+the entire next heading and its body as literal fence content.
+
+`parseStaging` knows nothing of this. Its splitter still cuts a chunk at line 14251 (a
+literal `---`, which is all it checks), producing a section headed `## docs/.../Len.md ·
+after-remarks [DUPLICATE? ...]` whose parsed body is truncated to 7 lines ending mid-fence
+(`"\`\`\`"`, confirmed) — and whose `_Source threads:_`/`_Date range:_` metadata
+(`1314412625714479125` / `2024-12-06`) belongs to the **earlier, unrelated** "ReDim arr"
+section, not to any of the six thread IDs the heading itself names. Re-running
+`extract --merge` today would serialize this wrong pairing back to the committed file.
+`raw bare --- lines: 1160` and `parsed.sections.length: 1160` agree only because the
+splitter always produces exactly one section per `---` it sees, whether or not that `---`
+was a real boundary — count-matching is not evidence of correctness here, and a naive
+same-shape check (a manual fence-open/close toggle with no CommonMark closing-length rule)
+independently produced a *misleading* "100 bare dashes inside a fence" figure before this
+markdown-it-based re-check narrowed it to the one genuine, confirmed case above — which is
+itself a small demonstration of the task's point: even a "fence-aware" hand check can get
+the wrong answer.
+
+---
+
+## Task 2 — Can markdown-it supply the regions?
+
+Tested against the repo's own `markdown-it` (from `node_modules`, both a bare
+`new MarkdownIt({ html: true })` instance — matching `tb-fences.mjs`/`check_code_regions.mjs`
+— and the site's configured `createMarkdownIt` from `builder/render.mjs`, imported by file
+URL; importing it has no observable side effect, its top-level code is declarations only).
+
+**(a) Fence/`code_block`/`html_block` tokens with `.map`, top level.** Yes. A top-level fence
+`` ```tb\nDim x\n``` `` parses to a `fence` token with `map=[2,5]` (half-open line range,
+0-indexed) — confirmed the range covers exactly the opener, content and closer lines.
+
+**(b) Same, inside a blockquote and inside a list item — does `.map` cover the *prefixed*
+source lines?** Yes to both, tested separately and combined. A fence inside a plain list
+item (`- item\n\n  \`\`\`tb\n  Dim x\n  \`\`\`\n`) gets its own correctly-nested `fence`
+token (`map=[2,5]`) under `bullet_list_open` / `list_item_open`, same shape as top level.
+For the prefix question specifically: `` > ```tb\n> Dim x\n> y = 1\n> ``` `` gives
+`map=[2,6]`, and indexing the *original, prefixed* source at that range yields
+`["> \`\`\`tb", "> Dim x", "> y = 1", "> \`\`\`"]` verbatim — the map is in terms of raw
+source lines, prefix included, while `token.content` is separately given already unwrapped
+(`"Dim x\ny = 1\n"`, `>` markers stripped). Both are available from one parse. Same result
+one level deeper still (fence inside a blockquote inside a list item): `map` correctly
+spans just the fenced lines with their combined `"   > "` prefix intact in the raw slice.
+
+**(c) Inline code span positions.** Confirmed absent, exactly as the task assumed:
+`code_inline` tokens carry `map=null` always (only block-level tokens get a `.map`), plus
+`.content` (the text between the backtick delimiters) and `.markup` (the delimiter run
+itself, e.g. `` ` `` vs `` `` ``) — no character offset into the source line. A correct
+inline splitter therefore cannot be "ask markdown-it"; it has to be the same manual
+backtick/tilde-run scan `maskInlineCode` (A1) and `splitInlineCode` (A3) already implement
+independently — markdown-it can supply *which source lines* an inline run occupies (via the
+parent `inline` token's own `.map`), but not offsets within them.
+
+**(d) Frontmatter.** Nothing in either instance (bare or site-configured — identical
+output) handles it, and it is actively **misparsed**, not just ignored: given
+`"---\ntitle: X\npermalink: /y\n---\n\n# Heading\n"`, both instances produce `hr` for the
+first `---`, then treat `"title: X\npermalink: /y"` followed by the second `---` as a
+**setext H2 heading** (`heading_open map=[1,4]`, inline text `"title: X"` + softbreak +
+`"permalink: /y"`), then the real `# Heading` as a second, separate H1. This confirms
+`discover.mjs`'s approach is necessary, not optional: strip a BOM, then hand the *whole*
+source to `gray-matter` (or an equivalent frontmatter splitter) **before** any markdown-it
+call, exactly as it does — never lean on markdown-it to recognise frontmatter itself.
+
+**Timing** (`task2_timing.mjs`, single-threaded, this machine): 912 markdown files under
+`docs/` (via `markdownFiles`, output trees already excluded), 3.98 MiB total source.
+
+| parse | total | per-file mean |
+|---|---|---|
+| full `md.parse` (block + inline + core rules) | 234-257 ms | 0.26-0.28 ms |
+| block-only (`md.block.parse` called directly, bypassing the `inline`/`linkify`/`replacements`/`smartquotes` core rules) | 33.7 ms | 0.037 ms |
+
+Block-only is genuinely block-only, not merely faster for some other reason — verified
+directly: after calling `md.block.parse()` alone, every `inline`-type token's `.children`
+is still the empty array `[]` (markdown-it's `Token` constructor default) and its
+`.content` is the raw, unsplit source text; the "inline" *core rule* — the thing that
+actually walks a line for `` `code` ``/`**bold**`/etc. and would double as A1/A3's job if it
+exposed offsets, which per (c) it does not — never ran. Full-parse per-file distribution:
+min 0.012 ms, median 0.092 ms, p95 1.10 ms, max 5.50 ms. Block parsing alone is ~7x cheaper
+and is sufficient for every region-finding need in this inventory (fences, code blocks,
+html blocks, headings for section-splitting) — only inline code-span detection needs
+anything more, and that "more" is the hand-written splitter from (c), not a deeper
+markdown-it call.
+
+---
+
+## Task 3 — The union of needs, and where the module should live
+
+### What every site in Task 1 is actually asking for
+
+| Need | Sites that need it |
+|---|---|
+| Block regions (fence / code_block / html_block / prose) with line ranges, from one real block parse | A1, A2, A3, B3, B4, B5, B9, D (already has it, twice, independently) |
+| A "mask code, rewrite prose, restore" helper built on the above, correctly covering the info-string rule the current one misses | A1, A2, A3 |
+| One tested inline code-span splitter (backtick/tilde-run aware, offsets into the raw line) | A1, A3 |
+| "Split into sections on a marker line, but only outside a code region" | A5 (bare `---`), B3 (`#{1,6}` headings), B8 (`---` twice) |
+| Frontmatter: BOM-strip + `---`-delimited block + YAML parse, one implementation | B6, B7 (currently two, diverging), plus `discover.mjs`/`nav_hops.mjs`'s `gray-matter` calls, which could use it instead |
+| CRLF / byte-exact round-trip (no forced LF normalisation of the caller's *output*) | A3 (has this today, carefully) — the module must not regress it |
+| The existing directory walker, reused rather than re-implemented | B6 (`sitemap.mjs`'s private `walk()` duplicates `scripts/lib/markdown-files.mjs`) |
+
+A fact worth folding into the frontmatter design specifically: `gray-matter@4.0.3` bundles
+its **own** nested `js-yaml@3.14.2` (`node_modules/gray-matter/node_modules/js-yaml`,
+confirmed by reading its `package.json`), while `builder/data.mjs`, `builder/tbdocs.mjs` and
+`scripts/check_publish_policy.mjs` import the top-level `js-yaml@4.1.1` directly for
+`_config.yml`/`_book.yml`. Page frontmatter and site configuration are parsed by two major
+versions of the same YAML library today. A shared frontmatter helper should parse the
+`---`-delimited block with the direct `js-yaml@4` dependency instead of pulling in
+`gray-matter` (and its private v3) at all — one YAML implementation for the whole build.
+
+### API sketch
+
+```js
+// a new shared module, e.g. lib/markdown-regions.mjs + lib/frontmatter.mjs
+
+/** Every fence / code_block / html_block region, in document order, from one
+ *  bare (site-plugin-free) block parse. map is [startLine, endLine) 0-indexed,
+ *  against the raw source exactly as split("\n") would give it — blockquote/
+ *  list prefixes included, per Task 2(b). */
+export function blockRegions(src) // -> {type, map:[a,b], content, info?}[]
+
+/** Generalizes maskCodeRegions (A1) and stashCodeFences (A2) into one,
+ *  CommonMark-correct (fixes the missing info-string rule both A1 and A3
+ *  share) implementation. indented:false matches today's pre-render-rewrite
+ *  choice; true is what check_code_regions.mjs's KNOWN GAP 1 wants instead. */
+export function maskCode(src, { indented = false } = {}) // -> {masked, restore(masked) => src}
+
+/** The one tested backtick/tilde-run scanner inline positions actually need
+ *  (Task 2(c)): splits ONE line into alternating segments. */
+export function splitCodeSpans(line) // -> {code: boolean, text: string}[]
+
+/** Chunks src on lines matching isMarker, using blockRegions to skip any
+ *  marker-shaped line that sits inside a fence/code_block/html_block. Both
+ *  A5's bare "---" and B3's "^#{1,6} " are isMarker predicates over this. */
+export function splitOnMarker(src, isMarker) // -> {heading: string|null, lines: string[]}[]
+
+/** BOM-strip + "---"-delimited block + direct js-yaml v4 (no gray-matter).
+ *  Byte-preserving on `content` (CRLF untouched) per the A3 constraint. */
+export function parseFrontmatter(raw) // -> {data: object, content: string} | null
+```
+
+### Where it should live
+
+The constraint stated in the task is real and current: `builder/render.mjs:383`'s own
+comment — *"Matched against the raw info string rather than parsed with
+scripts/lib/tb-fences.mjs's parseInfo: builder/ must not depend on scripts/"* — rules out
+`scripts/lib/` as the home, because `builder/` is one of the four required consumers. The
+reverse direction is already precedented and fine (`scripts/check_code_regions.mjs` imports
+`../builder/render.mjs` directly), so the module *could* physically live under `builder/`
+— but `wisdom/` and `eval/` importing from the site generator for something as basic as
+"find the fences in a markdown string" is a layering smell in the other direction: neither
+tool has anything else to do with `builder/`, and `builder/` is the actively-churning,
+large module in this repo (per `PLAN-scheduler*.md`) — not somewhere a Discord-harvesting
+tool's frontmatter parsing should have to track.
+
+**Recommended: a new top-level directory, sibling to all four** — e.g. `lib/` at the repo
+root (`lib/markdown-regions.mjs`, `lib/frontmatter.mjs`), imported by relative path from
+`builder/`, `scripts/` (and `scripts/lib/`), `wisdom/` (and `wisdom/extract/`), and `eval/`
+alike. This is the only placement where none of the four is made to depend on another
+subsystem's tree, it matches the existing `scripts/lib/` precedent of "a lib/ folder holds
+cross-cutting non-CLI modules" one level up, and `eval/nav_hops.mjs` already reaches across
+tree boundaries for exactly this kind of shared helper (it imports
+`scripts/lib/markdown-files.mjs` today, by absolute file URL, with a comment explaining why
+— the same import shape this new module would use from `wisdom/`, `eval/`, and `builder/`).
+The one naming risk is visual adjacency to `scripts/lib/`; `mdlib/` is a fine alternative if
+that confusion matters more than the short name.
diff --git a/builder/book.mjs b/builder/book.mjs
index 46479edc..12d77ee2 100644
--- a/builder/book.mjs
+++ b/builder/book.mjs
@@ -1,8 +1,8 @@
 // Phase 2 book chapter resolution + Phase 8 book.html assembly.
 //
-// Phase 2 surface (§A below): loadBookData, resolveBookChapters,
-// sortByNavOrder. Loads _book.yml and walks every entry / part /
-// chaptered-part-chapter, resolving the selector schema (page / pages /
+// Phase 2 surface (§A below): resolveBookChapters, sortByNavOrder.
+// Walks every entry / part / chaptered-part-chapter of _book.yml, which
+// the build reads through data.mjs into site.data.book, resolving the selector schema (page / pages /
 // nav_page / nav_pages + no_descent) to a concrete Array stored
 // as `_chapters` on the entry. Pre-resolves landing_page / foreword_page
 // URL lookups in the same pass so Phase 8 has no pages-walk left to do.
@@ -25,22 +25,11 @@
 //   docs/_plugins/book-href-rewrite.rb   (cross-ref rewrite + landing strip)
 
 import { compressHtml } from "./compress.mjs";
-import { loadData } from "./data.mjs";
 
 // ---------------------------------------------------------------------------
-// §A  Phase 2: _book.yml loader + chapter resolver + sort_by_nav_order
+// §A  Phase 2: chapter resolver + sort_by_nav_order
 // ---------------------------------------------------------------------------
 
-// Back-compat wrapper around the generic `loadData` loader. The
-// orchestrator (PLAN-9 §5.2) calls `loadData(srcRoot)` once and stashes
-// the result on `site.data`; downstream consumers read
-// `site.data.book` directly. `loadBookData` is retained for the verify
-// harnesses and diff tools that haven't migrated to `site.data` yet.
-export async function loadBookData(srcRoot) {
-  const data = await loadData(srcRoot);
-  return data.book ?? null;
-}
-
 export function resolveBookChapters(bookData, pages) {
   if (!bookData) return;
 
@@ -220,11 +209,11 @@ function replaceOutsideCode(html, pattern, replacer) {
     (m.startsWith("/
-// (consumed atomically so src= inside code samples doesn't count),
-// then a real page-relative `src="..."` attribute. The code/pre
-// branches leave m[1] (the quote char) undefined; we skip those.
+// PLAN-9 §5.9: per-chapter image-path collector. Three top-level
+// alternatives: /
 (consumed atomically so src= inside code
+// samples doesn't count), then a real page-relative `src="..."`
+// attribute. The code/pre branches leave m[1] (the quote char)
+// undefined; we skip those.
 const IMG_SRC_RE_BOOK =
   /]*>[\s\S]*?<\/code>|]*>[\s\S]*?<\/pre>|\bsrc=(["'])((?![#/]|[a-zA-Z][a-zA-Z0-9+.\-]*:)[^"']+)\1/g;
 
@@ -610,7 +599,7 @@ export function assembleBook(site, pages) {
   out.push("\n\n");
   out.push(renderTitlePage(site));
   emitFrontMatter(out, bookData, baseurl, imagePaths);
-  (bookData.parts ?? []).forEach((part, i) => emitPart(out, part, i, site, baseurl, imagePaths));
+  (bookData.parts ?? []).forEach((part, i) => { emitPart(out, part, i, site, baseurl, imagePaths); });
   out.push("\n\n\n");
 
   let bookHtml = out.join("");
diff --git a/builder/counts.mjs b/builder/counts.mjs
index dc4e01d8..0488e407 100644
--- a/builder/counts.mjs
+++ b/builder/counts.mjs
@@ -143,11 +143,11 @@ function countEnumerations(pages) {
  * Derive every named count from build state.
  *
  * @param {object} state    scheduler state: needs `pages` and `staticFiles`
- * @param {object} [extra]  values from tasks other than discover
- * @param {number} [extra.redirectStubs]  deriveRedirects' stub count
+ * @param {object} extra    values from tasks other than discover
+ * @param {number} extra.redirectStubs  deriveRedirects' stub count
  * @returns {Record}
  */
-export function deriveCounts(state, extra = {}) {
+export function deriveCounts(state, extra) {
   const pages = state.pages ?? [];
   const refPages = pages.filter((p) => p.srcRel.startsWith(REF_PREFIX));
   const folderStyle = refPages.filter((p) => p.srcRel.endsWith("/index.md"));
@@ -179,12 +179,10 @@ export function deriveCounts(state, extra = {}) {
     enumerations: countEnumerations(pages),
     // Whole-page stubs emitted for every `redirect_from:` entry. Passed in
     // because it comes from deriveRedirects rather than from discover.
-    redirectStubs: extra.redirectStubs ?? 0,
+    redirectStubs: extra.redirectStubs,
   };
 }
 
-export const COUNT_NAMES = Object.keys(deriveCounts({ pages: [], staticFiles: [] })).sort();
-
 // ------------------------------------------------------------------ plugin
 
 /**
@@ -192,8 +190,8 @@ export const COUNT_NAMES = Object.keys(deriveCounts({ pages: [], staticFiles: []
  *
  * Registered after `replacements` so it sees the same text the reader will.
  * `ctx.counts` absent is not an error -- it means a caller that does not need
- * substitution (the SEO pass builds a markdown-it of its own), and the rule
- * then does nothing.
+ * substitution (check_examples.mjs's markup probe builds one without counts),
+ * and the rule then does nothing.
  */
 export function countPlugin(md, ctx) {
   md.core.ruler.push("tbdocs-counts", (state) => {
diff --git a/builder/cpu-worker.mjs b/builder/cpu-worker.mjs
index f22ad314..1b246bee 100644
--- a/builder/cpu-worker.mjs
+++ b/builder/cpu-worker.mjs
@@ -35,7 +35,6 @@ const myLane = workerData?.lane ?? 0;
 
 let views     = null;   // Int32Array views into the scheduling SAB
 let ctx       = null;   // { srcRoot, destRoot, opts, workerCount }
-let idMapping = null;   // { nameToIdx, idxToName, DYNAMIC_BASE, … }
 
 let _payloadSAB = null;   // SharedArrayBuffer with packed per-task payloads
 let _sharedSAB  = null;   // SharedArrayBuffer with packed shared payload
@@ -180,6 +179,23 @@ const handlers = {
         caches: { rawResolution: new Map(), seg: new Map(), result: new Map() },
       };
 
+      // PLAN-9 §5.3 (B7) nav-block cache: the just-the-docs sidebar in
+      // `` is byte-identical across every page
+      // site-wide before rewrite (template.mjs's renderSidebar takes only
+      // `site`, not `page`; the per-page active highlight lives in a
+      // separate `