scripts/utils/

August 18, 2026 ยท View on GitHub

Pure functions that name nothing of Arena. Flat, with no domain directories under it, because a domain is a statement about the vocabulary a module speaks and what is here speaks none: walkFiles takes a directory and a predicate, readJson takes a path, and neither knows what a layer, a token, a contract or a phase is. That is the whole difference from ../lib/AGENTS.md, which is where a module goes the moment it does.

The boundary is the import list, and it is checked. A module here imports node: builtins and another util, and nothing else. Not repo-root.ts, not layers.ts, not a shape module, not a package. A util that needs one of those is a lib/ module in the wrong directory, and moving it is the fix rather than widening this rule. Its suite holds to the same list, which is also why the test proving the boundary lives in scripts/check/arena/script-imports.test.ts and not here: it needs the repository root to walk to, and reaching for that is the thing being forbidden. A suite is in scope there where the specifier scan beside it excludes one, because running a suite proves its imports resolve and proves nothing about where they point.

A util owns no policy. walkFiles throws on a root that is not there, because whether an absent tree is a legitimate state or a typo is the caller's knowledge, and a walk answering [] of its own accord turns the second into a clean-looking pass over nothing. The same reasoning keeps the eight different directory exclusion lists at their call sites: they are eight deliberate configurations and not one shared rule, and the module that tried to hold all eight would be holding an Arena fact.

A file here classifies as the arena domain, which already means what belongs to no one layer, so the suites are counted by the reporter rather than dropped as belonging nowhere. The one branch that says so is in scripts/lib/arena/domains.ts, and utils is deliberately not a sixth entry in DOMAINS: a directory that speaks no vocabulary cannot name one.

modulewhy it exists
walk-files.tsOne recursive directory walk, in place of the thirty-three that wrote the same three lines. skip is asked about every entry, a file and a directory alike, which is the shape twenty of those copies already had and what lets one predicate carry a dotfile rule, a name list and an anchored path at once. Each level is read sorted by code unit rather than by locale, so a walk is the same on every machine and a comparison against one is not reading the filesystem's hash order. It returns an array and not a generator because every copy it replaces consumed its walk whole.
main-module.tsWhether a module is the program, for every script that asks. The comparison is process.argv[1] against import.meta.url and never the module's own filename, because a gate matching its own name stops running the day it is renamed, exits 0 having read nothing, and check-all reports that as PASS. It takes the union of the two shapes the copies had drifted into: the raw comparison answers first, and only a mismatch is retried through realpathSync on both sides, where a symlink can turn a missed match into a found one and never the reverse. That second half is what a symlinked entry needs, and an npm bin/ link is exactly one.
captures.tsOne read of a regex capture group, for the parsers that match first and read after. noUncheckedIndexedAccess types m[1] as possibly-undefined even where the pattern guarantees the group, and the alternative at every one of those sites is ?? '', which turns a regex that has lost a capture into an empty string nobody ever sees. captured() throws instead, naming the group and what did match, so an edit that drops a capture fails at the read rather than three functions downstream. A caller whose miss deserves its own message still tests the match and throws its own; this is for the ones with nothing better to say. It was lib/arena/captures.ts until it was measured against the rule here and found to name nothing of Arena.
case.tscamel, pascal and kebab, from the four implementations that had drifted. camel and what serialize-script.ts called scriptName were one function under two names, and their character classes differed: one lifted a letter after a hyphen and the other a letter or a digit, so sp-4 became sp4 in one and stayed sp-4 in the other. The wider class is kept, and it costs nothing at the narrower site, whose pattern captures [a-z-]+, a CSS property name, which cannot hold a digit at all. kebab is the inverse of pascal and not of camel, since it lowercases the first letter too.
posix-path.tstoPosix, in place of nineteen sites spelling it three ways. It is for a path that leaves this process: an import specifier, a citation, a key in a stamp file, a name in a problem line. A path handed back to node needs none of it, since node takes a forward slash everywhere, so a call here says the result is going somewhere a backslash would be wrong rather than merely different. isInside is the other question, asked through relative and never a string prefix, because the two prefix spellings it replaced were each wrong in their own direction: serve.ts compared startsWith(root) with no separator boundary and let /repo-evil pass as /repo, and static-server.ts had the boundary but hardcoded '/', so on Windows it refused every nested path and all four browser gates 403ed. relPosix is the two of them composed, and the one spelling of a repo-relative path that leaves this process: relative answers in the host separator, so toPosix(relative(a, b)) written as two calls is a conversion a caller can drop without noticing, which is how a manifest key reached five readers that split it on '/'. All three take the path module, which is the whole reason the win32 half is covered: those cases run on Linux, where no runner would ever show them, and toPosix asked without one can only be asked the question whose answer is already its input.
text.tslineOf, because a reader opens a file at a line and never at a byte; escapeRegExp, written twice with a byte-identical character class; and globToRegExp, which keeps the wider of the two expansions beside it. Keeping the wider one is free: a pattern holding no ** splits into one part and reduces to exactly what the narrower one built.
with-timeout.tsA deadline on a promise, for the gates that drive a browser and would otherwise hang until the runner kills them with nothing said. Three copies wrote it and the third differed in a way the other two would call a bug: it never cleared the timer and reached for unref to keep the leak from holding the process open. This clears instead, and deliberately does not unref: an unreffed timer lets the process exit while the raced promise is still pending, so a hang that should have arrived as the caller's message arrives as a silent exit 0, and a gate reporting nothing is the one failure a deadline exists to prevent.
concurrency.tsmapWithConcurrency and interleaveForDispatch, moved rather than extracted: both were already generic and both were exported from inside a gate, so a second gate wanting either had to import a gate. Results are indexed rather than pushed, so the answer is in input order however the calls settled, which is what lets a caller compare its output against a sorted list. The interleave reads a row-major list back column-major, so items that were adjacent do not land in the same first wave.
compare.tsbyCodeUnit and byKey, in place of seven sites that sorted with localeCompare. That comparison answers by the runtime's locale: 'a' sorts before 'B' under en-US and after it by code unit, so a generator emitted one order here and another on a machine whose locale differs, and the git diff --exit-code every workflow runs would read the second machine's build as a generator out of step rather than as the environment it is. The line is what the order is FOR: a list shown to a person may be locale-aware, and none of these is, because every one is written to a file something else then compares. walk-files.ts had claimed the rule in prose and restated it inline, and now consumes it. arena-to-prod.ts keeps its own spelling and is the one exception with an argument: it ships inside both packages, where scripts/ does not exist, so it can import no util at all. check:portability is what holds the rest, since a rule this cheap to undo is one a reviewer will miss.
memo.tsOne derivation computed once per process, in place of the private Map one library had grown and the copy every other caller was paying for. A gate resolves the same tree several times over, and bun test holds 325 suite files in ONE process, so a copy per caller is a copy resident for the whole run. A derivation taking a root keys on that root, which is why memoBy takes the key rather than assuming one: keyed on nothing, a suite passing a fixture directory is handed the repository's answer, and the assertion after it is about a tree it never wrote. clear() is the other half, for a suite that rewrites one root in place, and nothing mechanical catches that one. A memoised parameter is annotated even where it carries a default: an initializer with no annotation takes its type from the generic rest tuple being inferred, which is circular, so the whole call collapses to unknown and every reader downstream reports a type error naming neither. The hit is wrapped rather than read back through has, so a derivation whose answer IS undefined is cached like any other rather than rebuilt for ever.
module-graph.tsWhat a page loads as JavaScript, read out of its own text, for the two sides that have to agree about it: the one assembling the site and the gate holding it. A <script type="module"> names an entry and the entry names the next module, so what a page needs is a graph, and copying the entry alone published playgrounds whose every href answered 200 and whose root stayed empty. An HTML target and an import specifier are two notations and get two questions: a src is a URL, so anything without a scheme is a path, while a specifier is an ES one, so a bare name belongs to the import map and following it as a path reports the map's own key as a file nobody wrote. Both patterns anchor at a statement boundary and require the from, because export function ArenaBreadcrumbs({ separator = "/" }) opens with export and quotes a default argument, and read as a specifier that is an import of the root of the filesystem.
read-file.tsreadJson, which is the one-liner every site would otherwise repeat, with the name of the file added to the failure, because JSON.parse reports Unexpected token and names nothing, and a gate reading a directory of manifests in a loop then dies naming none of them. It hands back any and not a type parameter defaulting to it, because a bare call in a contextual position infers unknown rather than the default and every such site then needs a cast; any is what JSON.parse itself returns, so a caller that states a shape gets it from its own annotation and one that states none is exactly where it was. readIfExists keeps its existsSync rather than catching ENOENT, so a document that is not there and one that cannot be read stay two different answers.

Every X.test.ts beside a module covers that module.