Some checks are pending
build-gate / build (push) Waiting to run
The gate had been printing a NOTE on every green run: "the tree has outgrown its floors (leaves 46 vs floor 40, dirs 23 vs floor 17) -- they still catch a CATASTROPHICALLY gutted tree but no longer a partial one." It was right. At a floor of 17 it would have passed a tree missing eight directories. That matters more than it sounds. A totally wiped tree announces itself; a PARTIAL one is the state that produces a confident false measurement -- "that bundle is missing", "the dashboard tab does not exist" -- which this flight has already paid for several times over. MIN_DIRS 17 -> 21 (the manifest's current bundle count, the number that grows when a plugin is added, leaving headroom against the directories on disk). MIN_LEAVES 40 -> 44 (two leaves of headroom for a mid-build transient). Deliberately NOT derived from the live tree: a floor computed from what is there can never notice that something is missing. Negative-tested 5/5: healthy 25/46 passes; the OLD floors exactly (17/40) now FAIL as the partial wipe they are; losing 5 dirs fails; losing 3 leaves fails; mid-clean fails. And it still exits 0 on the real tree, with the stale-floor NOTE gone. check:mjs-parse passes. These floors stay coarse by design -- check:embed-targets remains the load-bearing assertion that every embed.iframe target actually resolves.
177 lines
11 KiB
JavaScript
177 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* settled-tree-gate.mjs — is the build output SAFE TO MEASURE OR PUBLISH right now?
|
|
*
|
|
* ★ WHY THIS IS A SCRIPT AND NOT A SHELL ONE-LINER (a silent gate failure, 2026-09-20)
|
|
* The documented gate was three shell checks. One of them was SILENTLY BROKEN for an entire
|
|
* session, and its failure mode was to always PASS:
|
|
*
|
|
* find public/doxa-maps -newermt '-45 seconds' -type f | wc -l # always printed 0
|
|
*
|
|
* `find` on this box is **bfs 4.1.1**, a drop-in replacement that is STRICTER than GNU
|
|
* findutils: it REJECTS relative timestamps and demands ISO 8601. The error went to stderr,
|
|
* `wc -l` counted the empty stdout, and the gate reported "churn = 0" every single time —
|
|
* including when **460 files had changed in the last 45 seconds**.
|
|
*
|
|
* A gate that cannot fail is worse than no gate: it grants false confidence at exactly the
|
|
* moment you need real information. This one very likely contributed to publishing an
|
|
* internally-inconsistent tree (see AP-single-bundle-vite-build-leaves-the-tree-internally-
|
|
* inconsistent) — the tree was believed quiet on the strength of a check that always passed.
|
|
*
|
|
* Node's fs.statSync gives mtimes directly, with no shell quoting, no `find` dialect, and no
|
|
* silent-stderr failure mode. Hence a script.
|
|
*
|
|
* ★ THE OTHER TWO SIGNALS, and why all three are needed
|
|
* - PROCESS: count the ONE real builder (`bun run build:unlocked`) plus queued waiters.
|
|
* Never `pgrep -f 'bun run build'` — that matches the command TEXT, so wrapper shells and
|
|
* even the inspecting `ps` itself count. The same loose pattern reported 10/8/4/3 builders
|
|
* from identical machine state, and once cost six minutes waiting for phantom builds.
|
|
* - CHURN: no output file written recently (this file's job).
|
|
* - SHAPE: at least MIN_DIRS bundle directories. `clean` rm -rf's the output at the START of
|
|
* every build, so a concurrent build makes the tree transiently near-empty; dirs is the
|
|
* cheapest way to notice you are looking at a half-built tree.
|
|
* The lock SERIALIZES builds but does NOT DEDUPE them, so N queued agents = N full rebuilds:
|
|
* wait for the QUEUE to drain, not for "a build" to finish.
|
|
*
|
|
* USAGE
|
|
* node internal/settled-tree-gate.mjs # exits 0 if settled, 1 if not
|
|
* QUIET_SECONDS=90 MIN_DIRS=17 node internal/settled-tree-gate.mjs
|
|
*/
|
|
import { readdirSync, statSync, existsSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { execSync } from 'node:child_process'
|
|
|
|
const TREE = process.env.TREE || 'public/doxa-maps'
|
|
const QUIET_SECONDS = Number(process.env.QUIET_SECONDS || 45)
|
|
// ★ RAISED 2026-09-23 from 17 to 21, on this gate's OWN advice. It had been printing a NOTE on
|
|
// every green run — "the tree has outgrown its floors ... they still catch a CATASTROPHICALLY gutted
|
|
// tree but no longer a partial one" — and it was right: the tree now emits 25 directories, so a
|
|
// floor of 17 would pass a tree missing EIGHT of them. A wipe detector that only fires on a total
|
|
// wipe has quietly become a gate that cannot fail for the case it matters most in, because a
|
|
// PARTIAL tree is the one that produces a confident false measurement rather than an obvious one.
|
|
//
|
|
// 21 = the manifest's current bundle count, which is the number that grows when a plugin is added,
|
|
// and it leaves 4 directories of headroom against the 25 on disk (assets/ and the per-bundle dirs
|
|
// that are not manifest bundles). NOT derived from the live tree on purpose: a floor computed from
|
|
// what is there can never notice that something is missing.
|
|
const MIN_DIRS = Number(process.env.MIN_DIRS || 21)
|
|
// ★ RECALIBRATED 2026-09-22 from 50 to 40 — the ARCHITECTURE changed, not the health of the tree.
|
|
// The old floor was set when generate-tree.mjs also emitted a <bundle>/index.html CHOOSER page for
|
|
// every bundle. Those 19 pages were removed deliberately (they returned HTTP 200, mounted NOTHING,
|
|
// were referenced by the manifest ZERO times, and their stylesheet was literally named
|
|
// "staging-forge" — they are the duplicate staging surface the Driver asked to archive). Removing
|
|
// them legitimately drops the recursive index.html count from 63 to 44.
|
|
//
|
|
// ★ WHY THIS EDIT MATTERS MORE THAN THE NUMBER: left at 50, a CORRECT tree fails this gate forever,
|
|
// and the cheapest way to make it green again is to restore the choosers — i.e. the stale floor
|
|
// would quietly pressure the next agent into undoing the change it was blocking. A floor that
|
|
// encodes a removed artifact does not protect the tree; it defends the old architecture.
|
|
//
|
|
// 40 keeps the original PURPOSE intact. The floor exists because a per-bundle `BUNDLE=x vite build`
|
|
// silently deletes every OTHER bundle's leaf (see the note at signal 4) — a partial-wipe leaves far
|
|
// fewer than 40. The expected healthy count is now 44: 1 root + 28 profile leaves + 15 instance
|
|
// leaves. Anything materially below that is the wipe this gate was built to catch.
|
|
//
|
|
// THE LOAD-BEARING CHECK IS NO LONGER THIS COUNT. What actually must not break is that every
|
|
// embed.iframe target in manifest.json still resolves — that is deployment method 1, one of only two
|
|
// of the five that are proven. Verified 2026-09-22: 43 iframe targets checked, 0 missing. Prefer
|
|
// check:embed-targets for that assertion; this floor is only a coarse wipe detector.
|
|
// ★ RAISED 2026-09-23 from 40 to 44, same reasoning as MIN_DIRS above: the tree emits 46 leaves, so
|
|
// a floor of 40 tolerated losing six of them silently. 44 keeps two leaves of headroom for a
|
|
// mid-build transient without tolerating a partial wipe. Still coarse by design — check:embed-targets
|
|
// remains the load-bearing assertion that every embed.iframe target actually resolves.
|
|
const MIN_LEAVES = Number(process.env.MIN_LEAVES || 44)
|
|
|
|
if (!existsSync(TREE)) {
|
|
console.error('SETTLED-TREE: FAIL — tree does not exist: ' + TREE + ' (a build may be mid-clean)')
|
|
process.exit(1)
|
|
}
|
|
|
|
// ── signal 1: processes ──────────────────────────────────────────────────────
|
|
// -x -f = match the EXACT full command line, so wrapper shells do not count.
|
|
function countExact(cmd) {
|
|
try {
|
|
const out = execSync('pgrep -x -f ' + JSON.stringify(cmd) + ' || true', { encoding: 'utf8' })
|
|
return out.split('\n').filter(Boolean).length
|
|
} catch { return 0 }
|
|
}
|
|
const builders = countExact('bun run build:unlocked')
|
|
const waiters = countExact('bun run build')
|
|
|
|
// ── signal 2: churn (the one that was silently broken) ───────────────────────
|
|
const cutoff = Date.now() - QUIET_SECONDS * 1000
|
|
let churn = 0
|
|
let newest = 0
|
|
;(function walk(dir) {
|
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
const p = join(dir, e.name)
|
|
if (e.isDirectory()) walk(p)
|
|
else {
|
|
const m = statSync(p).mtimeMs
|
|
if (m > newest) newest = m
|
|
if (m > cutoff) churn++
|
|
}
|
|
}
|
|
})(TREE)
|
|
|
|
// ── signal 3: shape ──────────────────────────────────────────────────────────
|
|
const dirs = readdirSync(TREE, { withFileTypes: true }).filter((e) => e.isDirectory()).length
|
|
|
|
// ── signal 4: LEAF COUNT ─────────────────────────────────────────────────────
|
|
// ★ ADDED because a per-bundle `BUNDLE=x bun x vite build` does something worse than leave a stale
|
|
// SRI hash: it EMPTIES public/doxa-maps/ of every OTHER bundle's leaf index.html. An agent ran it
|
|
// five times as a compile check and silently deleted 62 leaf pages across 18 bundles — including
|
|
// the root index.html and manifest.json — in bundles it had never touched.
|
|
// Nothing caught it. check:sri only compares a leaf's baked hash to its bundle, so a leaf that no
|
|
// longer EXISTS is simply not checked — the gate passes because there is nothing left to disagree.
|
|
// dirs alone does not catch it either: the directories survive, only their index.html vanishes.
|
|
// (Full builds regenerate the leaves via `bun run tree`, which is why the damage kept healing
|
|
// itself and stayed invisible.)
|
|
const countLeaves = (dir) => {
|
|
let n = 0
|
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
if (e.isDirectory()) n += countLeaves(join(dir, e.name))
|
|
else if (e.name === 'index.html') n++
|
|
}
|
|
return n
|
|
}
|
|
const leaves = countLeaves(TREE)
|
|
|
|
const quietFor = newest ? Math.round((Date.now() - newest) / 1000) : Infinity
|
|
const ok = builders === 0 && waiters === 0 && churn === 0 && dirs >= MIN_DIRS && leaves >= MIN_LEAVES
|
|
|
|
console.log(
|
|
'SETTLED-TREE: builders=' + builders + ' waiters=' + waiters +
|
|
' churn(' + QUIET_SECONDS + 's)=' + churn +
|
|
' dirs=' + dirs + '/' + MIN_DIRS + ' leaves=' + leaves + '/' + MIN_LEAVES +
|
|
' quietFor=' + (quietFor === Infinity ? 'n/a' : quietFor + 's')
|
|
)
|
|
|
|
// ★ DRIFT WARNING — a floor that is never revisited decays silently while the tree grows.
|
|
// This gate deliberately keeps LOOSE floors rather than deriving strict ones from manifest.json
|
|
// the way check:drop does, because it runs DURING builds where a transiently empty tree is the
|
|
// expected input; a strict floor here would fail on every clean phase. Different regime, different
|
|
// tolerance — do NOT "fix" this to match check:drop.
|
|
// But loose must not mean STALE. If the settled tree has outgrown a floor by a wide margin, the
|
|
// floor has stopped constraining anything, so say so out loud instead of quietly passing.
|
|
// (MIN_LEAVES=50 / MIN_DIRS=17 were set when the tree had ~58 leaves / 19 dirs — commit edb935f.)
|
|
const DRIFT = Number(process.env.DRIFT_WARN || 1.35)
|
|
if (ok) {
|
|
if (leaves >= MIN_LEAVES * DRIFT || dirs >= MIN_DIRS * DRIFT) {
|
|
console.log(
|
|
'SETTLED-TREE: NOTE — the tree has outgrown its floors (leaves ' + leaves + ' vs floor ' +
|
|
MIN_LEAVES + ', dirs ' + dirs + ' vs floor ' + MIN_DIRS + '). They still catch a CATASTROPHICALLY ' +
|
|
'gutted tree but no longer a partial one. Raise MIN_LEAVES/MIN_DIRS toward the current shape — ' +
|
|
'keeping headroom for mid-build transients, which is why they are not derived.'
|
|
)
|
|
}
|
|
console.log('SETTLED-TREE: PASS — safe to measure or publish.')
|
|
process.exit(0)
|
|
}
|
|
console.error('SETTLED-TREE: FAIL — do NOT measure or publish this tree.')
|
|
if (builders) console.error(' a build is HOLDING the lock — wait for it')
|
|
if (waiters) console.error(' ' + waiters + ' build(s) QUEUED — each will re-run clean and rebuild everything; wait for the QUEUE to drain, not just the current build')
|
|
if (churn) console.error(' ' + churn + ' file(s) written in the last ' + QUIET_SECONDS + 's — something is still writing')
|
|
if (dirs < MIN_DIRS) console.error(' only ' + dirs + ' bundle dirs (expected >=' + MIN_DIRS + ') — likely mid-clean or a half-built tree')
|
|
if (leaves < MIN_LEAVES) console.error(' only ' + leaves + ' leaf index.html (expected >=' + MIN_LEAVES + ') — a per-bundle `BUNDLE=x vite build` empties OTHER bundles\' leaves; run a FULL build to regenerate them')
|
|
process.exit(1)
|