scripts/graph/

August 16, 2026 ยท View on GitHub

The layer that decides whether a step runs. A build step and a gate both cover a subject, and most runs touch none of it. What decides that a step can keep the answer it had lives here, and nothing else does: a phase is what a script belongs to, and deciding is not compiling, emitting or judging.

moduleanswers
graph.tsthe algebra over the declared set, and nothing about where it came from: needsOf, topoOrder, cyclePath, duplicateWriters, subscriptionProblems, unknownFeeds, selfFeeds. Resolution is handed in, so it holds no table and imports no script.
nodes.tsthe set itself: collectedScripts(root) walks the three phases and allNodes(root) imports each and keeps what exports a node. NEVER_SUBSCRIBES and NOT_YET_SUBSCRIBED say what is out.
pathspecs.tswhat a declared spec reaches: matchesSpec(spec, path) and resolveSpecs(specs, universe) against a path list, plus unreachedSpecs(specs, universe) and reachesNoDirectory(spec, universe), which is how a typo is told from a spec written ahead of the tree.
inputs.tswhat a file is, as a fingerprint: universe(root) walks the tree once, stampOf(path, previous) filters on the stat and arbitrates on the hash, and digestOf(paths, stamps) folds a list into one value.
script-closure.tsevery module under scripts/ a script reaches: relativeSpecifiers(source) and scriptClosure(entry, root).
fingerprint.tswhat a node is worth comparing: fingerprintOne(node, measure) and fingerprintNodes(...) over the whole set, in dependency order.
state.tswhat the last green run left behind, under .cache/: readFiles, writeFiles, readState, writeState, recordGreen, forget. It records the machine as well as the version, and discards on either.
plan.tswhether a node runs and the sentence saying why: decide(node, current, previous, onDisk).
run-build.tsthe build, in the order the graph derives.
graph-problems.tseverything check:graph asserts, so the gate under check/arena/ is a print and an exit.
handoff.tswhat a workflow building in one job and gating in another has to carry between them: handoffProblems(nodes, text, tracked) against the cache path lists of pr.yml, plus cachePathLists, sampleOf and trackedPaths.
fs-trace.ts, trace-preload.tswhat a run actually opens: the preload wraps the read entry points before the script's own imports run.
audit.tsauditProblems(node, script), which compares a traced run against the declaration. UNTRACEABLE names what spawns a process the tracer cannot enter.
gate-plan.tswhat check-all.ts asks the graph, kept out of the runner so that file stays the single authority for how the suite is invoked.

A script subscribes by editing itself

export const node = {
  name: 'generate:tokens',
  reads:  SOURCES.map((source) => `contracts/design/${source}`),
  writes: [...CSS_TARGETS, ...SCRIPT_TARGETS, BREAKPOINT_TARGET],
  feeds:  ['build:tailwind', 'build:angular-demo'],
};

name is the npm script, reads are the source pathspecs, writes are the artifacts, and feeds are the nodes that consume them. Edges are declared downstream. Everything reading the other direction goes through graph.ts:needsOf(nodes), so a node is added by editing one file.

The declaration reuses the constants the script already has, which is the whole reason it lives in the script and not in a table: a target list written twice drifts the first time one of them gains an entry. It is also why allNodes() imports rather than reading the text.

check:graph joins the two halves. If B's reads meet A's writes, A lists B in feeds, and nothing else does. A feeds entry no artifact carries fails as well: an edge nobody maintains is the same defect read backwards.

writes meeting a node's own reads is the shape, not a defect. generate:member-docs writes each contracted member's description into the component that declares it, and generate:prompt-api writes the @api region into the prompt it reads.

A spec opening with ! excludes, which is how a node claims a directory of hand-written sources without claiming the generated files beside them.

A reads reaching nothing is judged against the tree, and a writes is not. For a reads, reachesNoDirectory tells a typo from a spec written ahead of the tree: no directory fails, a directory with no matching file yet is reported and allowed. A writes names what the node CREATES, so a tree without it is the step not having run, and it is only ever reported. Judging it would make this gate answer one way where the step ran and another where it did not, which is exactly what an unbuilt CI checkout is: build:angular-tests writes frameworks/angular/build/test/, no build invocation runs it, and the gate must not care.

An artifact no clone checks out has to be carried to the job that gates it. Arena PR builds in one job and gates in four, so an output absent from a checkout reaches them only through actions/cache, and a gate handed a tree without it judges an absence rather than an artifact. handoff.ts asks the tracked list which outputs those are and holds pr.yml's cache paths to them. It reads neither the tree nor the cache: a spec is compared as a path it would reach, so the answer is the same on a machine that has built and on the unbuilt checkout where the hand-off is the only thing standing between a gate and nothing at all. A runsBeforeSuites step is out of it, since the job reading that output runs the step itself and no cache entry could carry it.

A step no build invocation should run says so, and says why. Two fields do it, because there are two reasons. releaseOnly is cost: ng-packagr and the declaration emit are not worth a development loop, and --assemble puts those steps back, which is how build:packages and build:release differ from build. runsBeforeSuites is ownership: bun run test runs the Angular emit immediately before the suites that read it, so no build invocation should, --assemble included. Both carry a reason and check:graph refuses one that is a label. The phase a script sits in says what it IS, and only the node can say that building it on every iteration is not worth the wait: build:react-package and build:angular-package write a dist/ only a release publishes. check:graph refuses a releaseOnly that is a label rather than a reason, the same way it refuses one in NEVER_SUBSCRIBES.

check:graph --audit answers what check:graph cannot

The gate holds the edges between declarations, so it finds a reader nobody subscribed. It cannot find a reads that is too narrow: a file no node claims is a file every declaration agrees is nobody's business, and there is no disagreement to detect. Only a run is a witness, so --audit runs each node under a tracer and reports what it opened and does not declare.

It found real gaps in declarations that had already passed the gate and a planted-defect test: generate:playgrounds read frameworks/Components.json without declaring it, check:cdk read the generated token sheets, and check:react-barrel probed a .jsx its spec did not reach.

Nothing under fs-trace.ts imports node:fs through ESM, and that rule is load-bearing rather than tidy. The first ESM import of a builtin fixes the bindings every later importer gets, so a preload that touches node:fs before patching wraps an object nobody will call. Measured: with an ESM import ahead of the patch, 0 of 3 calls are intercepted; without it, 3 of 3. The CommonJS object is reached through createRequire, is the same object, and has writable properties. audit.test.ts pins both halves, so the day the runtime changes, the suite says so.

A directory listing is not counted against a declaration. The digest is over the sorted list of path and hash pairs, so a file appearing under a directory a spec reaches already moves the fingerprint.

A node that spawns a process is reported unaudited and never clean. UNTRACEABLE names each with its reason: tsc, ngc, ng-packagr, a browser and the shipped CLI all read in a process the tracer cannot enter. Measuring nothing and finding nothing have to read differently.

Two lists say what is out, and both are keyed by path

A path is the key every script has and an npm name is not: check-release.ts and fetch-fonts.ts have none.

NEVER_SUBSCRIBES names what will never join, each with its reason, and a key naming a directory covers everything under it. Nothing in it is imported at all, and check-graph.ts is why: collecting reaches the gate that is running, whose own guard correctly answers that it IS the program, so importing it makes it re-enter itself once per collection.

NOT_YET_SUBSCRIBED names what has not joined yet. It is empty, and it stays because a script in neither list has to be a decision nobody made rather than a default: the next script added under build/, generate/ or check/ fails check:graph until it declares or is written down here.

A failure stops its dependents and nothing else

graph.ts:transitiveFeeds(nodes, name) is what decides. The failed step is FAIL, everything downstream of it is BLOCKED with the upstream named, and the rest of the run proceeds. This is the one place the graph pays for itself twice: it knows which steps are downstream, so it does not have to choose between stopping everything and running steps that cannot succeed.

A gate never stops another gate, and check:graph keeps that true rather than hoping for it: a node named check: that declares writes fails. The rest follows. Only an artifact can carry an edge, so a graph in which no gate writes is one in which no gate is downstream of another, and a sweep reporting every problem in one pass is a property of the shape instead of a rule to remember.

The fingerprint recorded is measured after the step, never the one that decided it

generate:member-docs writes each contracted member's description into the component that declares it, and generate:prompt-api writes the @api region into the prompt it reads. Their writes meet their reads, so a value measured before the step is stale the instant it succeeds and the node would never be kept. The value measured after is the converged one, and the next run recomputes exactly it.

The rule is applied to every node, not to those two. For a node whose writes miss its reads, before and after are the same value and nothing changes. It works because those generators converge, which is what the git diff --exit-code in every verify workflow already proves; one that did not converge would run every time, which is what the build did before any of this and not a wrong answer kept.

Only a green run writes an entry

Every other outcome deletes it. A SKIP recorded as green is a node that never runs again, and a node with no entry cannot be kept, so a machine that cannot run a step is honest about it for ever rather than once. The entry is written after each node, so a run that stops halfway keeps the greens before the failure.

.cache/ belongs to one machine and one operating system

Both files record platform and arch beside version, and a mismatch is discarded whole, exactly as an older version is. A fingerprint is a claim about what a step produced here, and the schema being identical is precisely why version alone cannot carry it: the shape agrees and the answer does not. The prebuilt @tailwindcss/oxide, rollup and lightningcss binaries differ by architecture, ng-packagr and tsc emit through a platform's own path handling, and a step kept on the strength of another machine's run is a step that has never run on this one.

One working tree can be reached by two operating systems, which is what makes this a rule rather than a precaution: a WSL2 clone under /mnt/c is visited by Windows-bun and Linux-bun in turn, over the same .cache/. That clone also pays a coarser mtime through the 9p layer, which widens the one blind spot below from "takes a deliberate mtime restore" to "happens". Neither is worth a mechanism: clone into the Linux filesystem, and --force answers the rest.

What a run prints

Every step says whether it ran and why, or that it was kept and at what fingerprint. A skipped step is the one thing a reader cannot see happening, so the reason is not decoration:

run-build: generate:tokens runs, because its sources have moved since the last green run
run-build: build:tailwind runs, because generate:tokens ran, so what it reads was rewritten under it
run-build: generate:skills comes from the cache (595efd571d07)

run-build: all 12 step(s) passed, 4 ran, 8 came from the cache

The tail never collapses the count, so a cheap run cannot be read as a whole one.

Flat, and the reason is the opposite of utils/

utils/ carries no domain grid because a util speaks no vocabulary. graph/ carries none because each module here speaks every one at once: a node's inputs are contracts, both framework layers, the Tailwind preset and the repository root together, so the domain would be arena for all of it and the directory would say nothing. That is the second exception to the grid, and an exception with no argument beside it is how the grid stops meaning anything.

The stat filters and the hash arbitrates

A file's fingerprint is its content hash, and the stat is what decides whether that hash has to be recomputed. Size and mtime agreeing with the record is taken as the file and nothing is read; either moving costs one read, and a hash that comes back equal updates the stat and leaves the fingerprint where it was. Checking out another branch and coming back rewrites every mtime and invalidates nothing. A touch invalidates nothing. Only a changed byte does.

The one blind spot is a file rewritten to the same size and the same mtime, which takes a deliberate mtime restore to reach. It is the trade every incremental build makes, and --force is what answers it.

.cache/artifacts/ is not this layer's and nothing here reads it: lib/arena/artifact-cache.ts keeps the answer a step would recompute where this keeps the stamps that decide whether it runs at all, and the separation costs nothing to maintain, since .cache is skipped by the walk above and .cache/**/* is in audit.ts:NOT_AN_INPUT, so an artifact is invisible to a fingerprint and to a declaration alike.

Nothing here asks git what it tracks. universe(root) is a walk, so an artifact under .gitignore is fingerprinted like any other file, a fresh clone that has not built is a smaller universe rather than a special case, and no rule about ignored files has to exist to be forgotten.

What a digest is over

digestOf(paths, stamps) hashes the list of path and hash pairs, never the concatenated contents. A file appearing adds a row and a file leaving removes one, so a gate that walks a directory is sensitive to a file arriving and not only to one changing, and that falls out of the shape rather than out of a rule. A path with no stamp still occupies its row, which is what makes a deletion visible.

Reading a script rather than importing it

scriptClosure(entry, root) scans text. Importing a script to read its imports runs it, and a script under build/, generate/ or check/ is held to doing no work when it is imported by check/arena/script-imports.test.ts:importTimeEffects(path) precisely so that collecting from it is safe; a scan that resolved by importing would be relying on that guarantee to establish it.

A specifier inside a string literal is one a generator is writing, and an interpolated one names no file, so both are dropped. check/arena/script-imports.test.ts:unresolvedSpecifiers(path) reads the same specifiers to prove they resolve, and takes them from here, because the same pattern written twice is two patterns the day one of them is fixed.

The closure stops at the edge of scripts/. A file in a framework layer is something a node declares reading, and following it here would count it twice under two different names.