Every gate's failure history: what it caught, what shipped green past it, and the rule that came out of that. Split out of WIP.md, which keeps the roster, the wrapper commands and the rules themselves under The gates, and where their internals are.
Read this before adding a gate, changing one, or adding any rewrite over markdown source or rendered HTML. Most of what is here was written after something shipped broken with every gate green over it, and the recurring shape is a check that quietly stopped checking --- which reports exactly what a healthy tree reports.
Before adding a fan-out to the task graph, read why a dep count of zero does not mean the submits have run. A worker posts its result and then decrements its successors' dependency counts in shared memory, so a barrier's count can reach zero while results are still queued and the submit() calls that merge them into build state have not run --- the shared counter orders the work, not the state. A dynamic barrier must therefore list every chunk task in its expected, even when its own execute() ignores the inputs; that list is the only thing the scheduler checks before it lets the barrier proceed. renderJoin went without it and silently dropped ~6 pages from search-data.json on about one build in three, because the index is built by flattening a new Array(N) and Array.prototype.flat() skips holes without reporting anything. Two silent failures combining into one invisible one. Both halves are fixed, and every skip on the chunk-merge path that used to tolerate a missing piece now refuses to continue --- see where the completeness checks are. Keep it that way: on this path, "the piece is missing" is a bug, not a case to handle.
A sort key on this path must be total. discover() fills pages from inside
a Promise.all, so a page is pushed when its readFile resolves, not in
allFiles order; pages.sort(byName) is stable and Jekyll's key is the
basename, so every tie kept that I/O completion order --- and ~111 folder-style
classes are all named index.md. That reordered the chunking and made
search-data.json differ between two builds of one commit, 545 of 3,724 entries
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.
Readers get this at When a build stops instead of
failing, with a --stall-timeout row in
Tools.md's flag table and an entry in Builder.md's failure-mode list. Keep
those in step with any change here: a feature nobody can find is worth what an
absent one is worth.
A task that a worker claims and never finishes wedges the whole graph in
silence. Its successors' dep counts never drop, _remaining never reaches
zero, the scheduler's promise never settles, and the process sits there with its
last log line on screen --- no error, no exit code, nothing to grep. Nothing in
the SAB protocol can notice, because the scheduler is waiting on a message that
is not coming.
Scheduler now watches for that: if no task completes for --stall-timeout
seconds (default 120, 0 disables), it prints what was outstanding and
fails the build. The default is deliberately generous --- the longest single task
here is worker cold boot at ~1.6 s, so a loaded CI box may be an order of
magnitude slower than the dev box without being called stalled.
The report splits the outstanding tasks three ways, because listing them together buries the two names that matter under a dozen that do not:
- Claimed by a worker that never returned --- the cause. For a
render:iorflush:ichunk it also prints the chunk's source pages, via an optionaldescribe()on the task def that nothing but this report reads. "render:33 never returned" is not actionable; the six paths under it are, because the fault is nearly always one page's content. - Runnable, but nothing picked it up --- including the
F_PIN_TO_PREDcase, which is worth spelling out: a pinnedflush:ican only run on the lane itsrender:iran on, so when that lane is the wedged one the task is runnable and permanently unrunnable at once. Unlabelled it reads as a second, unrelated fault. - Blocked on a predecessor --- the consequence, with the missing input names.
Two details worth knowing. Worker.terminate() does kill a thread spinning
inside a regex, so the abort really does end the process rather than adding a
second hang --- verified against the real fault. And under --serve the pool
outlives a rebuild, so a wedged worker would poison every later build (the
per-worker tasks wait on every lane); the stall error carries a stalled flag
and serve.mjs replaces the whole pool when it sees one. Replacing just the
wedged lane would mean identifying it, and the SAB records the lane a task
completed on, not the one that claimed it.
Folding check.bat's gates into that same graph is designed in builder/PLAN-checks.md. Phase A, the link checker, is implemented: extraction runs inside flush, where both trees' final HTML is already in worker memory, so the build no longer writes ~270 MB out only to read it back and re-parse it. The pick_a11y_sample.mjs --check census and the axe scan's orchestration are follow-ons, seeded with measurements and open questions but not yet designed.
Historical engineering notes from the Jekyll era --- the original build pipeline, the HTML-compress plugin, the per-phase optimisation passes that preceded the JS port, the migration notes, and the Phase 11 parity-update retrospective --- live in WIP.OldJekyll.md.
Everything under scripts/, builder/, 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
CRLF on Windows --- a whole-file diff for a one-character fix. 24 of the tree's 906
markdown files are LF.
Two .py files stay, and neither is an oversight:
scripts/impexp.pyis not tooling. It is a published download, declared in_config.yml'sbundle_extrabesideimpexp.mjsand offered to readers on Import/Export Tool as the Python edition of the same standalone tool. Porting it would delete a deliberate offering.scripts/build_fonts.pystays because the JavaScript build of HarfBuzz it would use produces wrong CFF2 metrics --- a one-line build-configuration defect in harfbuzzjs, documented with the evidence in WIP.Fonts.md.
One .ps1 exists for a third kind of reason. scripts/lib/tb-launch.ps1 is Win32
calls --- CreateDesktop, CreateProcess with STARTUPINFO.lpDesktop, and the job object
the IDE runs in (CreateJobObject, AssignProcessToJobObject) --- which Node cannot make
without a native FFI addon, and adding one for a handful of calls would mean
npm install no longer suffices to run the tooling. It is also not a script anyone runs:
scripts/lib/tb-ide.mjs reads the text and passes it through -EncodedCommand, so it never meets the
execution policy. See Compiling a twinBASIC project without the IDE in front of
you.
Two .mjs files also run a little PowerShell inline, for Windows state Node has no API
for, and neither adds a file: scripts/tbrun.mjs takes a process snapshot with
Get-Process, and scripts/lib/tb-registry.mjs reads and restores the IDE's registry
keys through .NET, because reg.exe mangles names outside the console code page. See
What a run leaves in the registry.
The full account of the JavaScript port of build_fonts.py --- what works, the harfbuzzjs
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.
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.
eval/ — use-case evaluation of the developer documentation. build_corpus.mjs mirrors the
repository with every non-prose file stubbed unreadable, so "documentation only" is a property
of the tree rather than an instruction; site_search.mjs replays the site's real lunr index
and query logic, because search and navigation fail on different pages. usecases.md is the
catalogue, protocol.md is what an evaluator is given. This asks whether the docs work,
which is orthogonal to whether they are accurate — most findings so far involve sentences
that are individually true. Mine this file for cases: it is substantially a catalogue of
"this shipped broken and nobody noticed", and each entry is a use case waiting to be written.
The split is by what a gate interrogates, not by what it happens to open. check_axe_patch_equiv.mjs loads a built page, but only because its probe needs some document to run inside --- what it tests is the axe source patch, and it would be worth running against an empty docs/. That is the test: a new gate belongs in test.bat if it would still mean something with no documentation in the tree.
Older notes under builder/PLAN-*.md still place check_publish_policy.mjs and check_axe_patch_equiv.mjs in check.bat; both are in test.bat, and those notes are historical.
Both CI workflows run every one of these scripts as its own step, unconditionally, and always did --- CI never invoked the .bat files. So the split changes what a local content edit has to pay for and nothing about what reaches staging; a tooling regression cannot get in by someone skipping test.bat.
The link and integrity check runs inside the build. build.bat passes --check-audit-index, which implies --check, and the check walks the HTML on the worker lanes that produced it -- both trees' final strings are already decoded and in memory at flush(), so the ~270 MB the two trees weigh is never written out only to be read back. It also audits the tree index the build derives from its own records against what landed on disk -- the one direction the two-checker comparison structurally cannot see, since a spurious entry makes the oracle answer "exists" for a path that 404s in production. It catches broken intra-site links, missing pages, malformed redirect_from entries (the most common breakage when adding new pages or moving content between sections), duplicate ids, remote <img src>, badly nested tags, sitemap and search-index gaps, canonical mismatches, and (via a forbidden-prefix rule on the offline tree) any extracted link that still points at the live docs site after the offlinify rewrite. A clean build.bat && check.bat is the bar for "ready to commit".
A failing check never aborts the build: a broken link still produces a site you want on disk to inspect. It sets the exit code instead, using the same scheme check_links.mjs has always used -- 1 for link failures, 2 for integrity failures, 3 for both -- so CI can tell them apart.
The remote-asset rule fails the run on any <img src> resolving off-box (http://, https://, or protocol-relative //host). In the build it is unconditional -- checkRemoteAssets: true on both trees in builder/check.mjs's TREES -- and is not reachable by a flag: tbdocs rejects --check-remote-assets as an unknown argument. That name belongs to the standalone scripts/check_links.mjs, where it is opt-in. The PDF pass over book.html is informational, so enforcement comes from the _site/ pass -- every page in the book is also in _site/, making it a superset. The check is deliberately scoped to <img> only; <iframe> is untouched.
scripts/check_links.mjs is still the tool for a tree the build did not produce -- a release zip, a bisect, someone else's artifact -- and both CI workflows still run it, though not directly: they invoke check_links_diff.mjs, which calls the script in-process as its script side (only the fused side spawns, and it spawns tbdocs). It is exercised only against the fixtures, never against the real trees. The pure core both front ends share lives in builder/link-check.mjs; the build-side plumbing is builder/check.mjs and builder/check-tree.mjs.
Two implementations of one check is exactly the shape that rots quietly: a checker that silently checks less reports a clean pass. scripts/check_links_diff.mjs is the gate against that, and it plays the same role on this side that check_a11y_fingerprint.mjs plays on the axe side. Run it whenever link-check.mjs, check.mjs or check_links.mjs changes:
node scripts/check_links_diff.mjs --a script --b fusedIt diffs the two implementations' findings category by category across the real invocations -- _site/ with sitemap + search + canonical, _site-offline/ with the forbidden-prefix rule, book.html with the same rule (there it collects the links that leave the book for the website, reported as OUT OF BOOK), and a --baseurl tree checked with the matching base path. It is deliberately not in check.bat: the script side costs ~3 s, which is the whole saving.
Two further modes matter:
--self-testdiffs the script against a deliberately corrupted side and fails unless the difference is reported. Everything else the harness prints reduces to "the two sides agreed", which is also what a harness comparing nothing says.tbdocs --src docs --check-audit-indexdiffs the tree index the build derives from its own records against what actually landed on disk. This is the one failure mode the findings comparison structurally cannot see: a missing index entry turns a working link into a reported break, which is loud, but a spurious one masks a real break, and on a clean site nothing links to a path that does not exist, so nothing would ever notice.
The harness carries a synthetic fixture case for the same reason -- the real site is clean, so every other case compares empty against empty in eight of the nine categories. The fixture provokes one fault of each kind and asserts the count, so a fixture that stops provoking one fails loudly instead of quietly going back to empty-vs-empty.
discover() files every non-page it finds under docs/ as a static file, and
write.mjs copies it verbatim, so the source tree's shape is the site's shape.
The only filter used to be _config.yml's exclude:, and a denylist can only
refuse what someone thought to name in advance. Measured against the real config:
a scratch .md with no frontmatter, a .bak, a .twin, a secrets.json, a
.docx, a deploy.pem, Thumbs.db and a build.log, all planted in docs/,
every one published at a public URL on a green build.
Two publish surfaces reach the world from those trees: the deploy workflow
uploads docs/_site/ wholesale to Pages, and the manual-dispatch path zips
docs/_site-offline/ onto a GitHub release. Neither looks at what it is
carrying.
builder/publish-policy.mjs inverts the rule ---
name what may ship, refuse the rest --- and is enforced at two points, both
unconditional, because a build run with --no-check is exactly when nothing
else is watching:
- Source, in the
discovertask, over the static-file inventory. Names the file on disk and aborts before anything is written. - Tree, in the
dispatchtask, over each tree's derived inventory (deriveTreeRels). Covers what the source sweep structurally cannot see: redirect stubs, vendored theme assets, and the generated auxiliaries (sitemap.xml,search-data.json) are all minted by the build, not found indocs/.
Unlike the link check, a finding here aborts the build. A broken link still
leaves a tree worth inspecting; a tree with a private key in it is a tree nobody
should be one upload-pages-artifact away from publishing.
Three details of the policy are load-bearing:
SOURCE_EXTENSIONSandBUILD_EXTENSIONSare separate sets, and must stay separate. The build emits.xmland.json; a contributor has no business dropping either intodocs/, and.jsonis among the extensions most worth refusing at source. Folding the two together would pass every other assertion in the self-test, so the self-test asserts the disjointness directly..mdis deliberately absent from both. A markdown file that reaches the check is onegray-matterfound no frontmatter block in --- the AppGlobalClassObject shape, where the raw markdown was served verbatim for months. The two causes that come to mind first are both handled upstream: a UTF-8 BOM is stripped before parsing, and malformed YAML inside the block throwsFailed to parse frontmatter in <file>fromdiscover.mjs. What reaches here is a file with no block at all, or one where something precedes the opening------ a blank line is enough --- so keep the message naming that and not the BOM.bundle_extrais exempt by path, not by extension._config.ymldeclaresFeatures/Packages/downloads/impexp.pyandimpexp.mjswith both ends spelled out, which is what makes them shippable. The same extension anywhere else still fails --- otherwise declaring one entry would quietly bless a whole type.
A clean build says only that nothing in docs/ is currently refused, which is
also what an allowlist widened until it refuses nothing says. The interesting
assertion is the other one, and no build over a clean tree can make it, so
scripts/check_publish_policy.mjs makes it
against named probes --- a .bak, a .pem, a .docx, a frontmatter-less .md,
a Thumbs.db --- plus the reverse (a .png, a .PNG, a .woff2, CNAME must
still publish, or a policy that refuses everything would also report a clean
sweep). No browser, no built tree, ~40 ms. It runs first in test.bat and in
both CI workflows.
node scripts/check_publish_policy.mjsAdding a new asset type is a one-line edit to publish-policy.mjs, and that is
the point --- the cost is paid once, by the person who knows they are adding it,
instead of being paid silently by whoever drops a key file into docs/ three
years from now.
render.mjs applies several kramdown-parity rewrites to raw markdown, before
markdown-it has parsed anything. A rewrite at that layer cannot tell prose from
code, and this site's subject matter is code. Four defects of exactly that
shape shipped, none of them caught by anything:
| rewrite | what it did |
|---|---|
stripLiquidRawTags |
removed {% raw %} inside fences, so no page could show the tag it existed to handle |
rewriteAdmonitions body strip |
ate the indentation of code inside an admonition |
encodeSpacesInMediaUrls |
turned Items[1](a, b) into Items[1](a,%20b) |
rewriteListItemSetextHeadings |
deleted a YAML sample's closing --- and promoted the line above it to a heading |
Reference/Default/VBA/Interaction/InputBox shipped its If/ElseIf/Else
bodies flush left --- wrong control flow, in a language reference. Fixing the
admonition strip corrected 11 pages, not three: the greedy \s* had also
been merging paragraphs inside admonition prose, which a code-focused audit
never thinks to look for.
The same class exists on rendered HTML. book.mjs's chapter transforms
rewrite id=", href="# and src="/ across a whole body. An inline code span
is emitted through escapeHtmlMinimal, which escapes only &, < and >, so
quotes survive as literal bytes and all three patterns match inside a sample.
Every one of the six exposed code spans in the corpus was corrupted in the
published PDF --- <style id="jtd-nav-activation"> read
<style id="ch-Documentation-Development-Pipeline-Stages-jtd-nav-activation">.
Highlighted blocks escape this only by accident: the highlighter splits
attributes across <span> boundaries, so src="/vs/loader.js" never appears as
a contiguous byte sequence. Inline spans get no such treatment. Do not rely on
that accident.
Two mechanisms now exist, and a new rewrite must use one of them:
- Source rewrites go inside
applyPreRenderRewritesin builder/render.mjs, betweenmaskCodeRegionsand itsrestore. The mask hides fenced blocks (backtick or tilde, any length) and inline code spans (any backtick-run length). - Rendered-HTML rewrites use
replaceOutsideCodein builder/book.mjs, or the same leading-alternation shape found inoffline-rewrite.mjs:299,pdf.mjs:138andbook.mjs'sIMG_SRC_RE_BOOK, which consume<code>and<pre>atomically.
One gap is deliberate and stated rather than hidden: maskCodeRegions does
not protect indented (4-space) code blocks, because telling one from a
list-item continuation needs block context a pre-render pass does not have, and
guessing would change how real list content renders. check_code_regions.mjs
does compare them, so a rewrite that damages one is reported --- and must be
fixed at the rewrite, not by widening the mask.
rewriteAdmonitions deliberately runs outside the mask. A fence inside an
admonition still carries its > markers at that point, so the mask does not
see it as a fence, and the admonition rewrite is what strips those markers.
compress.mjs split the page on <pre> only, and collapsed every whitespace
run outside it --- including inside inline <code>. The comment said this
matched "the upstream behaviour", meaning Jekyll's. That parity is not a
reason for anything any more, and it was destroying documented values.
Partition returns
fixed-width, space-padded range strings. Its page says so in prose --- "pads
each end of the range with leading spaces" --- and the table demonstrating it
rendered " 0: 4" where the function returns " 0: 4". Thirteen spans on
that one page stated wrong return values, and the page contradicted itself.
Fixing it turned up three more of the same defect: Features/Language/Pointers
and Features/Standard-Library/New-Functions document what Debug.Print emits
with comma separators, where the print-zone padding is the behaviour being
shown, and both rendered it as single spaces.
Those two pages were still wrong after that fix, because a pipeline can only preserve padding that reaches it and the padding was never in their source. Measured through
tbrunagainst the pages' own samples, five claims were wrong: each missing the leading space a positive number carries where its sign would be, and the trailing space, and two showing a thirteen-space print zone as two spaces.An inline code span cannot carry a leading or trailing space naively, which is the trap that keeps this defect coming back. CommonMark strips one space from each end of a code span whose content is not all spaces, so writing
` 1 … 3 `renders as1 … 3--- the exact value the page is trying to state, silently de-padded by the parser rather than by anything inbuilder/. Double the outer spaces to defeat it, and verify in the built HTML rather than by eye. Four sites were fixed this way and the rendered<code>now matches the measured output byte for byte.
Two changes, and neither works alone:
compress.mjsnow treats inline<code>as a preserved region as well as<pre>, so the bytes survive compression.custom/custom.scssandprint.cssgive inline codewhite-space: pre-wrap, because a browser collapses runs inside inline code by default.pre-wraprather thanpreso a long snippet still wraps instead of forcing a horizontal scroll --- measured at the mobile viewport: no page overflow, and the table's own wrapper scrolls as it already did.
One trap in making <code> a split boundary, worth knowing if this is ever
touched again. The collapse function trimmed each segment's ends, which was
harmless when the only boundaries were block-level <pre>. Adding inline
<code> created boundaries inside sentences, and trimming there welds the
code to the word beside it --- a <code>x</code> b came out as ax b. Trimming
is now conditional on which element bounds the segment, so <pre> boundaries
stay byte-identical to what they produced before.
Blast radius across the whole site was 6 pages plus the two stylesheets; every change was a padded value being restored.
Testing only that docs\_site-pdf\book.html exists is not enough: edit a
page, run book.bat without build.bat, and it spends two minutes rendering the
previous book and reports success. Nothing downstream can notice --- the PDF is
internally consistent, correctly paginated and correctly bookmarked, simply the
wrong book. So the freshness gate runs first:
node scripts/check_tree_fresh.mjs --tree docs/_site-pdf --marker book.html--marker is what makes that work on this tree. The script identifies a tree
by its index.html, which every output tree has except _site-pdf/ --- that one
holds a single book.html. Exit codes are the script's: 2 when the tree is
absent, 1 when it is older than docs/ or builder/.
One batch detail that is easy to get wrong:
%ERRORLEVEL%inside a parenthesisedif errorlevel 1 (...)block expands when the block is parsed, not when it runs, so the value captured there is the one from before the check. The guard usesgoto :failand captures outside the block, which is the same shapetest.batalready uses, and for the same reason.
Known false positive, inherited rather than introduced. DEFAULT_SOURCES is
["docs", "builder"] and does not distinguish code from notes, so editing a
builder/PLAN-*.md or REVIEW-*.md marks every tree stale even though nothing
in the build reads those files. It errs toward refusing, which is the safe
direction, and a rebuild is ~4 s --- but wiring the check into book.bat means
a pure note edit now also blocks a render until you rebuild.
Which folders under docs/ are outputs comes from one list. The script used to
name them one at a time, and missed four that sat beside the ones it named, all
read as sources. Two were real outputs: a build given --dest docs/_site-basepath
writes _site-basepath-offline and _site-basepath-pdf as well. The other two,
_serve-offline and _serve-pdf, should never have existed. prepDest in
builder/tbdocs.mjs prepared <dest>-offline and <dest>-pdf for every run, so
serve mode --- which runs neither pass --- recreated both, empty, on every rebuild. It
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 names --- the
prefix list the markdown walk uses --- and keeps only .git and node_modules as
names of its own.
scripts/check_code_regions.mjs tokenises every
markdown file, applies the real applyPreRenderRewrites chain, re-tokenises,
and compares the fence / code_block / code_inline contents in order. Any
difference fails. In test.bat and both CI workflows; ~2 s, no browser, no
built tree.
node scripts/check_code_regions.mjs
node scripts/check_code_regions.mjs --verbose
node scripts/check_code_regions.mjs --self-testTwo details are load-bearing. It imports the chain rather than reconstructing
it, so removing the mask from one rewrite changes what the gate runs and is
caught --- a gate that exercised maskCodeRegions alone would have passed. And
its seven probes ride along in the normal run, each a defect this repository
actually shipped, because the corpus is clean: a sweep that finds nothing is
otherwise indistinguishable from a gate that has stopped detecting. Verified by
reverting a rewrite to run outside the mask, which the probes catch while the
906-file sweep still reports zero.
Nothing else can see this class. The link check, integrity check, publish
allowlist, regex-safety gate and axe scan all passed green on a tree with six
corrupted code samples in the published book, because the corruption is inside
<code> and none of them looks there.
Its sweep used to crash while serve.bat was running, over nothing in any page.
The walk was a recursive readdir of docs/ that dropped the output trees from
its results afterwards, so it had already descended into _serve --- which a
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, 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
stayed clean through 642 walks and five full gate runs timed into rebuilds.
tbdocs.mjs ended with if (pages.length < 836), described in
Builder.md as catching "a discover-rule
regression that silently drops content". A floor is not a drift check, and
this one had stopped being even a loose one: the constant was written when the
site had 836 pages, the site has 908, and the loss it exists to catch was
37. Repeat the _App disaster today --- the blanket **/_*/** exclude that
swallowed AppGlobalClassObject's 37 pages --- and the count lands at 871, well
clear of 836, and the build says nothing at all.
The baseline is now builder/page-baseline.json, a committed artifact of the
same kind as inter-metrics.json: a rise rewrites it and says so, a fall
fails the build. Raising the constant to a tight floor was the obvious
alternative and is wrong --- it would fire on every legitimate page removal, and
a gate that fires on ordinary work gets switched off. A rise costs nothing, so
the number stays current by itself; only a fall wants a decision, and
--update-page-baseline is how it is recorded, in the same commit as the
deletion.
Three things about it were learned by getting them wrong, and each is now a comment in builder/page-baseline.mjs:
- The baseline has to be keyed to a source tree.
tbdocsis not only run overdocs/:check_links_diff.mjsspawns it overtest/fixtures/check-src, three pages, to compare the two link checkers. Against an unkeyed baseline that build reports 905 pages missing --- a loud, confident, entirely wrong finding, on the one harness whose whole job is noticing when two implementations disagree.GUARDED_SRCnames the tree the numbers are of and every other root is skipped in silence. - The build now writes a tracked file, and
check_tree_fresh.mjswatchesbuilder/. The write happens after the tree, so without an exclusion the very nextcheck.batwould call the tree it had just built stale --- on exactly the builds that added a page.IGNORED_FILEScloses it, and the reasoning is not a special case: the script's own comment says its sources are "the inputs that decide the built bytes", and a baseline decides none of them.dotandvendorAssetswrite intodocs/and escape this only because they run early. - Neither CI nor
--servemay write. A CI run that rewrote the file would record the drop it was asked to catch, so there a missing baseline is an error rather than a first run.--serverebuilds on every save underdocs/, so a page half-deleted in an editor would lower the baseline and a half-added one would raise it.
OR the exit code on this path, never assign it. Plain process.exitCode = 1
after the link check has already set bits 1 and 2 reports only the later failure.
scripts/check_page_baseline.mjs is the gate on the gate, in test.bat and
both CI workflows: eleven probes against a scratch baseline, no browser, no
built tree. It is not optional bookkeeping --- the guard is silent on a
healthy tree, so a green build is exactly what a guard that has stopped working
produces. Reverting the comparison to the old floor fails three of the eleven,
including the _App replay; the two probes that look redundant (foreign source
root, missing baseline under CI) are the two that caught the real bugs above.
The gate above compares code regions, and there is a second way the same confusion shows up that it structurally cannot see. A rewrite that mistakes prose for code does not corrupt anything --- the text is stashed and restored unchanged, so every region matches --- it simply never runs.
rewriteAdmonitions stashed fences with one regex that paired an opening fence
with the next fence marker anywhere, including one in the middle of a line.
Reference/Attributes.md has exactly that: the
[Description(...)] entry's sample builds a Markdown string out of twinBASIC
string literals, two of which are ``` markers. The tb fence around it closed on
the literal, and every pairing for the rest of the file was off by one --- so
from there on the stasher had prose and code the wrong way round.
All six of that page's admonitions shipped as the literal text [!NOTE],
inside a plain blockquote, on one page of 869. Every gate was green: the region
comparison matched, the link check passed, and axe has no opinion about a
blockquote. It was found only because a new entry added to that page rendered
the same way and looked wrong.
The stasher is a line scan now --- CommonMark closes a fence on a line that is
only the fence character, repeated at least as often as in the opener, which is
a rule about lines rather than something to express as one regex over a whole
document. Measured across the site, the fix changes four files: Attributes.html,
search-data.json (which indexes it), and the two that record build timings.
The same stasher had a second way to fail: it recognised backtick fences only,
reasoning that maskCodeRegions knows about tildes --- but rewriteAdmonitions runs
outside the mask by design, so nothing protected a tilde fence. FENCE_OPEN_RE
accepts either character now and closes on the one that opened. docs/ contains no
tilde fence, which is why the corpus sweep could never have found it --- the same
blind spot that makes the ADMONITION_PROBES necessary.
Five probes in check_code_regions.mjs assert this direction, and writing one
correctly is not obvious: a mis-paired opener swallows text only as far as the next
fence marker, so a probe with no fence after the admonition passes against the very
stasher it was written to catch. The damage is always to the prose between two
fences.
A page no _book.yml entry selected was left out of the PDF without a word, and
by September 2026 that had taken 52 pages out of the book. Some were deliberate ---
the 404 page, Videos, Challenges --- and some were not: Data Types, Enumerations and
twinBASIC Additions are as plainly reference material as anything the book carries,
and nothing recorded why they were missing. The IDE section's pages with real prose
went the same way as its placeholders. The only trace was the book pass of the link
check, which listed the 32 links from the book to pages it did not carry as BROKEN,
on a pass marked informational --- so the list read as noise.
Two halves fixed it, and the second is what makes the first worth having. left_out:
in _book.yml names every page that is out on purpose, with a reason:, and
bookCoverage() in builder/book.mjs warns about a page that is in neither. Every
page has an entry one way or the other, so a warning is a decision nobody has made.
Without the list the warning fired for 37 pages on every build, which is a warning
nobody reads after the first week.
It reports five things, all empty on a consistent manifest: a page in no entry, a page
in the book and in left_out:, a book entry that selects no page, a left_out: entry
that matches none (a page renamed or deleted), and a landing or foreword URL no page
publishes at. They are warnings, not failures: the book is complete for the
manifest it was given, and a new page should not stop a build. They print under the
pdf: summary, and only when the book is built, so --serve does not repeat them on
every save. A link from the book to a page left out opens the website instead, and the
link check lists it as OUT OF BOOK --- which left-out pages the book still links to.
"In the book" has to mirror emitPart, not the selectors. A chaptered part's
landing_page and a foreword_page are emitted by URL rather than selected, so a
check that walked only _chapters would report the Features landing and the Packages
foreword on every build. The emission sites are listed once, in bookCoverage(), and
two probes pin them.
scripts/check_book_coverage.mjs is the gate on it, in test.bat and both CI
workflows: twelve probes over pages and a manifest built in memory, so it reads nothing
under docs/. Dropping the chaptered-landing site fails ten of the twelve; ignoring
left_out: fails nine.
tB/symbols.json is written by the symbolIndex task
(builder/symbols.mjs) for the IDE help add-in; why it exists and
what it has to say is WIP.HelpAddin.md, Stage 3.
Three decisions about the build side, each with the alternative it rules out:
- The entries come from the rendered pages, and the packages only annotate them. A
URL is a
permalink:as written or that plus the id the render gave a heading, read out ofrenderedContent--- never recomputed withkramdownSlug, which would disagree with the page on every pinned{: #id }and every-1duplicate. What the pages cannot say comes frombuilder/package-api.json, a committed snapshot. An index built the other way round, from the packages, would list thousands of symbols with no page and put the docs' own layout (Array filed under Information, the Styles classes documented though declaredPrivate) in the wrong place. - The snapshot is committed, like
inter-metrics.json, because making it needs a twinBASIC install.scripts/build_package_api.mjsexports the packages throughscripts/lib/tb-packages.mjs(shared withcensus_attributes.mjs, whose output was verified identical across the move), scans them withscripts/lib/twin-api.mjs, and writes 255 KB.package-api.jsonis a build input, socheck_tree_fresh.mjswatches it;symbol-baseline.jsonis an output and is in itsIGNORED_FILES. - The heading scan is
indexOf, not a regex./<h([1-6])\b([^>]*)>([\s\S]*?)<\/h\1>/is cubic in the regex gate's census, and it runs over every reference page; the scan gives a byte-identical index.
The drift guard is the page-count guard's shape applied to URLs
(builder/symbol-baseline.mjs): builder/symbol-baseline.json
lists every URL the index has published, one to a line; a build that loses one fails and
names it; a build that adds one rewrites the list; CI, --serve and --dry-run never
write; a source root other than docs is skipped; --update-symbol-baseline records a
removal. It exists because an anchor has no redirect_from: --- a reworded member
heading moves its id, an installed add-in keeps the old URL, and the link check only
follows links made inside the site, so before this nothing noticed. The failure message
leads with the usual fix, pinning the old id on the reworded heading.
scripts/check_symbol_index.mjs is the gate on all three pieces, in test.bat and both
CI workflows: forty-six probes on fixtures, no tree, no install. The scanner's probes are
the traps the BETA 983 packages actually contain; the derivation's are each a rule that the
real site exercised only once or twice (symbols:, the section heading named like a
member, the ellipsis the typographer puts in a Core H1 --- whose absence from the rules
was found only because Do…Loop's first heading never split).
The guard caught the first two defects it met, before either was committed. Six
### Example and #### Example headings, placed among a class's members, went into the
index as members named Example by the rule that a heading under Properties or Methods is
a member. With that rule narrowed to headings one level under the section and not named
like prose, the six URLs left the index and the build failed naming them. Then the
snapshot learned the members of VB's Screen, declared on a private interface, and
Screen.Fonts moved from #fonts-1, its ### Fonts under Properties, to #fonts, a
prose section above it --- because the first heading of a member's name on the page won.
The build named that URL too, and the fix, placing headings under a section of members
first, moved three more entries back from prose sections to their properties (Style on
ComboBox and ListBox, CheckBoxes on TreeView). A probe now holds each shape.
{{tbdocs:pages}} in a page renders as the number of pages the build
discovered. Designed in builder/PLAN-counts.md,
implemented in builder/counts.mjs, documented for
contributors at Authoring
Pages. Twelve names
are live, and most of the prose is still hand-written.
enumerations counts the bullets in Reference/Enumerations.md's alphabetical index
--- the attributeAnchors shape, a scan of one page's rawContent, legitimate because
the page is the list. It reads the index alone, so an entry added only to the
by-package section above it is still a half-edit that nothing reports.
defaultPackages and builtInPackages exist because packages alone could not
express either sentence the site writes: Reference/index.md called all thirteen
"built-in" while Reference/Packages.md reserved the word for the ten, both
arithmetically right, and no reader could tell that from either page. A count name
is also a way of naming the set, which is a second thing it buys beyond not going
stale.
A name is a derivation over build state, never a constant. A registry
holding pages: 908 would not have removed the stale figure, only moved it
from a page a contributor reads into a module nobody opens. If a number cannot
be derived it does not get a name.
The substitution is a core rule over the inline token stream, and that layer is the whole design. Code is immune without a rule for it, because a fence and an indented block are block tokens with no children and an inline code span is a token type of its own --- so an inline walk cannot reach any of them. On a corpus whose subject matter is programming languages that matters more than it sounds: it is the same hazard as Never rewrite markdown source without knowing what is code, avoided by construction rather than by a mask.
Three things fell out of building it that the design had not predicted:
- The walk has to recurse, for image alt. An
imagetoken carries its alt as its own children, so a flat walk stops at the image. markdown-it's ownreplacementsrule does not descend, which is whykramdownDashesPluginrecurses as well --- see Source dashes. - A raw HTML block is unreachable, and source validation cannot see it.
html_blockis one opaque token with no children, and a placeholder inside one has a perfectly good name --- so the validator passes it and the page publishes{{tbdocs:pages}}to readers, which is the exact failure the feature exists to prevent, arriving by a new route. It takes a second check on the other side of the render:findSurvivingPlaceholderscans the rendered HTML for a placeholder outside<code>and<pre>. One string scan per page, and it catches every cause rather than the anticipated ones. markdownInitgained a dependency onderiveRedirects, forredirectStubsalone. No cycle, but it is a task-graph edge added for a count.
Validation is on main, before any worker renders, because an unknown name cannot be an error inside the rule: markdown-it emits an unrecognised inline verbatim, so the rule would publish the typo rather than fail. The message names the file, the line and the nearest match.
scripts/check_gate_lists.mjs compares
check.bat and test.bat against the two numbered lists on
Tools and Scripts --- membership, order, and the
step count each section states --- and then sweeps README.md and every page
under docs/Documentation/ for a gate count asserted anywhere in prose. In
test.bat and both CI workflows; ~50 ms, no browser, no built tree.
A gate scoped to one page guards one file, not a class. The first version read
Tools.md alone, on the convention that one page owns the lists and the others cite
it --- and Building.md and README.md were already restating them, wrong, in the
commit that shipped the gate green. Hence the sweep.
Four shapes are recognised, each taken from a site that really published:
| shape | example |
|---|---|
| possessive | two of `check.bat`'s four steps |
| verb | `test.bat` is six more, `check.bat` runs six further gates |
| line-initial | check.bat # six more gates, a table cell restating a wrapper |
| section total | a wrapper's own section opening "Five gates that ..." |
The fourth is why the sweep is per section, and it is the one a first attempt
misses. Building.md:248 states the count in a section whose only mention of
the wrapper is the indented command under its heading, so nothing on that line
names a wrapper and a line-by-line scan reports nothing. A section's subject is
the wrapper in its heading, else the wrapper on the first command line beneath
it --- and the kramdown attribute block has to be skipped to get there
({: #tests-of-the-toolchain } sits between the two), which is a one-line
detail that silently cost the rule the only section it was written for.
Two judgement calls worth keeping:
- Only the first bare
N gatesin a wrapper's section counts as its total. Later ones are legitimate subset claims. The cost runs the other way: a section that opens with a subset claim is reported, and the fix is to delete the number rather than correct it --- which is what the failure message says, because that is the editorial remedy the round asked for. - Verbs, not proximity.
Tools.mdnarrates this gate's own history, including the numbers that were wrong at the time. A proximity rule read "foundtest.batdocumented as three gates when it had four" as a false claim, so the verb list is explicit.
Twelve of its eighteen probes cover the sweep, seven positive and five negative, each taken from the real corpus. The verification that means anything is reverting the offending pages to the commit that shipped them and confirming the gate names every site.
Its six patterns are built with new RegExp(...) from shared constants, and that
is what made check_regex_safety.mjs read constructed
regexes --- as literals-only it could not see them, and the line-initial rule was
in fact polynomial (a lazy gap and the count after it could divide the same text).
It is two steps now, anchored at the head of the line with no division to try. All
six are safe, and all six are checked on every run.
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.
node scripts/check_regex_safety.mjs # the gate
node scripts/check_regex_safety.mjs --census # full classification, by kind
node scripts/check_regex_safety.mjs --self-testAn exponential regex does not fail a build, it stops one. VOID_TAGS_RE in
builder/render.mjs spelled a void tag's attribute list as
(?:\s+[^>/]+...)*. [^>/] matches a space and so does \s, so one run of
attribute text could be partitioned in exponentially many ways, and every
partition got tried whenever the match failed --- which it did on any / the
quoted-value alternative did not cover. Two alt strings in docs/ contained one,
Line/Column and /Packages/WinDevLib, and each hung a render worker outright:
two of 152 chunks stayed CLAIMED, the barriers behind them never reached a dep
count of zero, and the build printed its last line and sat there. Three such
processes accumulated in one session before the cause was found.
Nothing existing could have caught it. The regex looks ordinary, the corpus passed for as long as no page happened to contain the trigger, and the failure was a hang rather than an error. This gate asks the question of the regex itself, so it does not wait for content to ask it.
Two things it found immediately, and both are the argument for keeping it.
STANDALONE_INLINE_HTML_RE, sitting in the same file, was exponential too and
nobody knew: [^>]*\/? spells an optional slash that [^>] already covers, so
each tag parses two ways and an html_block of n of them parses 2^n ways ---
measured at 22 tags in 106 ms, rising ~4x per two added tags. And the first
fix for VOID_TAGS_RE --- narrowing the name class to [^\s>/]+, which does
stop both real alt strings --- was still exponential, because [^\s>/] still
matches =, " and ', so an attribute could be consumed either by the name
class or by the quoted-value alternative. That is the same 2^n one level down,
and hand-reasoning had pronounced it fixed. The witness is <BR\tG= followed by
""\t"=''\t=='/">'\tG= repeated: 186 characters took 97 ms.
Both regexes now match to the first > and nothing else ---
<(br|hr|...)\b([^>]*)> --- with the trailing / removed afterwards by
stripSelfClose(). [^>]* and the > after it share no character, so there is
nothing to partition. Verified byte-identical against every void tag in the built
site (4,137 distinct tags) and against a full two-tree build diff.
Do not reintroduce a per-attribute sub-pattern in either of them. It was written that way, fixed that way, and was wrong both times.
It gates on exponential only. recheck also reports polynomial blowup, and
about a fifth of the patterns here are polynomial --- nearly all the ordinary
<tag[^>]*> shape, degree 2, on bounded input. A gate that failed on those would
fail on day one against fifty findings, and a gate that fails on day one gets
switched off. Exponential is the class that turns a content edit into an
unbounded hang.
Never quote a degN from a census. The two backends disagree on it --- the
native agent says polynomial degree 2 where the pure-JavaScript fallback says
degree 3 on the same pattern --- while agreeing on exponential-or-not, which is
what the gate rests on, and on all eight classification probes. A degree is a
ranking aid for reading a census, not a number to write into prose.
Three implementation details are load-bearing:
- The self-test probes ride along inside the normal run, not behind a
--self-testnobody remembers. Eight classification probes, both directions: the three regexes this repo actually shipped (including the incomplete fix),^(a+)+$, and four that must not be flagged --- plus fourteen fold probes, below. A green line saying "no exponential regex" is otherwise indistinguishable from a gate that has stopped detecting. - Parallelism comes from separate processes. Importing
recheckspawns one long-lived agent and feeds it requests one at a time, so awaiting several checks concurrently in a single process buys nothing --- measured at 42.5 s for concurrency 1 against 37.5 s for 16. Sharding across the CPUs, each shard its own process and its own agent, is what takes it from ~19 s to ~4 s. - recheck 4.5.0 cannot find its own backend on Windows, and the failure is
quiet. It locates both
recheck-jarandrecheck-<platform>-<arch>by stripping/package.jsonwith a forward-slash regex from a path Node returns with backslashes, so the strip does nothing and it tries to executepackage.json: an "Invalid or corrupt jarfile" line, thenspawn EFTYPE, then a silent fall back to the pure-JS implementation --- correct, but roughly a hundred times slower. The script resolves the binary properly and setsRECHECK_BINitself. If no native backend is found it says so in its summary line rather than just being slow. Both backends classify all eight probes identically, so the fallback is slower, not weaker.
Honest limitation, which should not be papered over: a regex recheck cannot
decide comes back unknown, and an unknown is an unchecked regex rather than a
passing one (currently 0; --census prints them).
Building a pattern out of shared fragments is the ordinary way to avoid writing
a sub-pattern six times, and a literals-only scan cannot see one --- so a gate
whose coverage you leave by writing idiomatic JavaScript is not covering much.
check_gate_lists.mjs has six such patterns and one of them was polynomial; while
the scan counted constructions in --census instead of folding them, nothing in
the repository would have said so.
scripts/lib/regex-fold.mjs folds a construction to
the pattern it builds, where the source decides that: string and template
literals, + concatenation, String.raw, a const declared once in the file,
X.source of a const regex, A.join(sep) over a const array of string
literals, and a ternary (checked as both branches). Twelve of the tree's
eighteen constructions resolve; each is then checked exactly as a literal is.
One rule is a model rather than an exact fold, and it is marked as one. A
call to an escaping helper --- escapeRegExp(x) and anything written to the same
shape, recognised by body rather than by name so all three copies in the tree are
covered without a list --- yields a fixed character sequence with no regex
operator in it, whatever x holds. Those fold to a one-character placeholder and
are tagged modelled, in the census and in any finding. The gap is stated rather
than hidden: an escaped splice inside a quantified alternation could be
ambiguous with a sibling branch in a way the placeholder is not --- (${esc}|a)+
is exponential when esc holds a and safe when it holds x. A fixed sequence
cannot be a quantified atom by itself, so the surrounding pattern has to quantify
a group containing it; none of the three in the tree does.
The remaining six are a list with a reason each, not a count. pattern is a
function parameter --- check the call sites says where to look; re is a let,
so its value is not fixed says not to bother, because it is a glob compiler
building a pattern character by character. That is the difference between a blind
spot someone can close and one they can only watch.
Fourteen more probes ride along in the normal run, eight that must resolve to an exact pattern and six that must be refused with a reason. The negative six are the ones that matter: a folder that resolves less than it claims does not fail, it moves constructions into the unresolved list, where nothing checks them and the run goes green --- which is what the gate looked like before it could fold at all. A folder that resolves more than it can know is worse, and the negatives are what say it does not.
builder/vendor-assets.mjs is a seed task (vendorAssets, modelled on dot) that scans the discovered markdown for YouTube video markers and GitHub user-attachment URLs, downloads anything missing into docs/assets/thumbnails/ or docs/assets/attachments/, and hands the new files to the static-file copy pass. It is idempotent -- a present file is never re-fetched -- and the artifacts are committed to git exactly like the generated DOT SVGs.
CI never downloads. process.env.CI selects offline mode (--fetch-assets / --no-fetch-assets override it), and in offline mode a referenced-but-uncommitted asset throws rather than fetching. If CI could fetch, an author who wrote the markdown but forgot to commit the image would get a green build while the published site went on hotlinking a third party -- the exact failure the whole mechanism exists to prevent. A fetch failure in dev mode is softer: warn, keep building, and flip the exit code, so one dead video doesn't block a local preview.
The render-side halves are videoLinkPlugin (marked link -> poster frame + outbound link) and remoteImagePlugin (user-attachment <img src> -> the vendored copy), both in builder/render.mjs. Both emit root-absolute paths, because the PDF book flattens every page into one document and a page-relative src resolves against the book root there.