doxa-maps/internal/landing-preconditions.mjs

226 lines
18 KiB
JavaScript

#!/usr/bin/env node
/**
* landing-preconditions — what must be true of a commit BEFORE it reaches main. Run by `main-capture scan`
* (via .main-capture/prepush) inside the lock, on the exact commit about to be pushed; any failure REFUSES
* the landing. A check beside the push can be skipped; a precondition of the push cannot.
*
* WHY (2026-09-25): check:composite-freshness found SEVEN composites stale on main — their committed
* bundles were not what their source builds. Cause: a fleet rebuild (d49b6c80) ran in a worktree that had
* not merged the dx-maps rename, so it wrote the OLD folder (public/doxa-maps/) and left public/dx-maps/
* stale; later landings resolved artifact conflicts by taking one side and rebuilding only a subset.
* Every one of those landings passed its own checks. Nothing refused them.
*
* PRECONDITIONS, in order (cheap first):
* 0. NO MERGE MARKERS no file in the COMMIT (public/ included) holds a conflict marker. d6bf410a put them
* into 10 built files under public/ and every other precondition passed; a built bundle with
* markers throws on load while its SRI still matches. --markers-self-test proves the scan refuses.
* 1. CLEAN the working tree equals HEAD for everything the checks read — otherwise they would judge
* files that are not in the commit.
* 2. ONE FOLDER public/ holds exactly the configured tree (vite.doxa-maps-build-config.json "name") and
* README.md. A build made under a different tree name writes a different folder: refused.
* 2b. TREE regenerating the tree (generate-tree) changes 0 files: a meta.json change must reach the
* leaf pages, not only the source.
* 3. SRI every leaf's baked integrity matches the bundle it loads (check:sri).
* 3c. SIZE every shipped bundle is under the 1,500,000-byte ceiling (verify-bundles-shipped).
* 3b. MOUNTS every capability-4/5 composite mounts headless in en and ar (check:composites-mount).
* 4. FRESH every composite rebuilds to exactly its committed bytes (check:composite-freshness).
*
* USAGE node internal/landing-preconditions.mjs exit 0 = may land · 1 = refused
* node internal/landing-preconditions.mjs --markers-self-test proves step 0 refuses a conflict
* DOXA_SKIP_FRESHNESS=1 skips (4) — for a DRY check only; main-capture scan never sets it.
*/
// ★ STEP 0 PRINTS BEFORE ANYTHING CAN WAIT (2026-09-29). A scan once sat 10+ minutes in state S before
// printing step 1, with no build running under it, and the log could not say where. So the first line
// is written before any child is started, every child has a deadline, and each step prints its time.
// ESM hoists imports, so this is the earliest this module can speak; the imports below read no disk.
process.stdout.write(`LANDING-PRECONDITIONS: start — pid ${process.pid}, node ${process.version}\n`)
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { TREE_NAME } from './_tree-name.mjs'
import { bundleGraph, areasForBundle } from './i18n-areas.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const refuse = (why, detail = '') => {
console.error(`LANDING-PRECONDITIONS: REFUSED — ${why}`)
if (detail) console.error(detail.split('\n').map((l) => ' ' + l).join('\n'))
process.exit(1)
}
// Every child gets a deadline and an EMPTY stdin: a child that waits on its input (or on a lock) fails
// loudly, by name, instead of holding the landing lock with nothing in the log. 20 minutes covers the
// freshness rebuild, the one step that legitimately takes that long; git steps get 3.
const T0 = Date.now()
const since = () => `${((Date.now() - T0) / 1000).toFixed(1)}s`
function run(cmd, args, timeoutMs = 20 * 60 * 1000) {
const r = spawnSync(cmd, args, { cwd: ROOT, encoding: 'utf8', env: process.env, input: '', timeout: timeoutMs, maxBuffer: 256 * 1024 * 1024 })
if (r.error && r.error.code === 'ETIMEDOUT') {
refuse(`${cmd} ${args.slice(0, 3).join(' ')} did not finish in ${Math.round(timeoutMs / 1000)}s`,
`It was killed at ${since()}. If this repeats, run main-capture diag on the child before it is killed.`)
}
return r
}
const git = (args) => run('git', args, 3 * 60 * 1000)
// 0. NO MERGE MARKERS — scanned in the COMMIT's tree (git grep), so public/ and every file is covered,
// whatever the working tree holds. Markers are built from char codes so this file never contains one.
const OPEN = String.fromCharCode(60).repeat(7), MID = String.fromCharCode(61).repeat(7), CLOSE = String.fromCharCode(62).repeat(7)
function markerFiles(ref) {
const g = git(['grep', '-I', '-l', '-E', '-e', `^(${OPEN}( |$)|${MID}$|${CLOSE}( |$))`, ref, '--'])
if (g.status > 1) refuse('could not scan the commit for merge markers', g.stderr.trim())
return g.stdout.split('\n').filter(Boolean).map((l) => l.replace(new RegExp('^' + ref + ':'), ''))
}
if (process.argv.includes('--markers-self-test')) {
// A throwaway commit object (no branch, no working-tree change) holding one built file with a textbook
// conflict; the scan must name it.
const blob = spawnSync('git', ['hash-object', '-w', '--stdin'], { cwd: ROOT, encoding: 'utf8', input: `a\n${OPEN} HEAD\nmine\n${MID}\ntheirs\n${CLOSE} other\n` }).stdout.trim()
const tree = spawnSync('git', ['mktree'], { cwd: ROOT, encoding: 'utf8', input: `100644 blob ${blob}\tpoison.js\n` }).stdout.trim()
const pub = spawnSync('git', ['mktree'], { cwd: ROOT, encoding: 'utf8', input: `040000 tree ${tree}\tpublic\n` }).stdout.trim()
const commit = spawnSync('git', ['commit-tree', pub, '-m', 'marker self-test'], { cwd: ROOT, encoding: 'utf8' }).stdout.trim()
const hits = markerFiles(commit)
if (hits.includes('public/poison.js')) { console.log('LANDING-PRECONDITIONS: markers self-test PASS — a conflict in a public/ file is refused.'); process.exit(0) }
console.error('LANDING-PRECONDITIONS: markers self-test FAIL — a textbook conflict under public/ was not found.'); process.exit(1)
}
const markers = markerFiles('HEAD')
if (markers.length) refuse(`${markers.length} file(s) in this commit hold merge-conflict markers`,
markers.slice(0, 10).join('\n') + '\nFor a BUILT file: take either side, REBUILD it, commit the rebuild. Never `git add -A public` mid-merge.')
console.log(`LANDING-PRECONDITIONS: 0 markers — no file in the commit holds a merge-conflict marker (${since()})`)
// 1. CLEAN
const dirty = git(['status', '--porcelain', '--', 'public', 'plugin-designs', 'library', 'internal', 'vite.config.js', 'vite.doxa-maps-build-config.json', 'package.json'])
if (dirty.status !== 0) refuse('could not read git status')
if (dirty.stdout.trim()) refuse('the working tree differs from the commit being landed — the checks would judge files that are not in it', dirty.stdout.trim().split('\n').slice(0, 8).join('\n'))
console.log(`LANDING-PRECONDITIONS: 1 clean — the working tree is the commit being landed (${since()})`)
// 2. ONE FOLDER, and it is the configured one
// What LANDS is the commit, so read public/ from HEAD, not from the disk: a gitignored, untracked leftover
// (a pre-rename public/assets/ in an older worktree) refused a clean README-only landing (repo, 2026-09-25).
const tree = git(['ls-tree', '--name-only', 'HEAD', 'public/'])
if (tree.status !== 0) refuse('could not read public/ from HEAD')
const entries = tree.stdout.split('\n').filter(Boolean).map((l) => l.replace(/^public\//, '')).sort()
const want = ['README.md', TREE_NAME].sort()
if (JSON.stringify(entries) !== JSON.stringify(want)) {
const extra = entries.filter((e) => !want.includes(e)), missing = want.filter((e) => !entries.includes(e))
refuse(`public/ must hold exactly ${want.join(' + ')} (the tree name comes from vite.doxa-maps-build-config.json)`,
[extra.length ? `extra: ${extra.join(', ')} — e.g. a build made under an OLD tree name writes its old folder` : '', missing.length ? `missing: ${missing.join(', ')}` : ''].filter(Boolean).join('\n'))
}
console.log(`LANDING-PRECONDITIONS: 2 one folder — public/ = README.md + ${TREE_NAME}/ (${since()})`)
// 2b. TREE — regenerating the tree changes nothing. fix:sri rehashes leaves but does not carry a
// <profile>.meta.json change (pinHint, a label, the mobile flag) into the leaf pages' BASE_CONFIG: PPLR's
// pinHint:false sat invisible in the tree until a later landing happened to regenerate it. The generator is
// deterministic (a clean tree regenerates to 0 changed files), so a stale leaf config or a stale integrity
// both show up here as a diff, in ~3 s.
const regen = run(process.execPath, ['internal/generate-tree.mjs'], 5 * 60 * 1000)
if (regen.status !== 0) refuse('the tree generator failed', (regen.stdout + regen.stderr).trim().split('\n').slice(-6).join('\n'))
const drift = git(['status', '--porcelain', '--', 'public'])
if (drift.status !== 0) refuse('could not read git status after regenerating the tree')
if (drift.stdout.trim()) refuse('regenerating the tree changes the commit: a meta.json or config change reached the source without reaching public/',
drift.stdout.trim().split('\n').slice(0, 10).join('\n') + '\nRun `bun run tree` (and `bun run fix:sri` if a bundle changed), commit public/, and land again.')
console.log(`LANDING-PRECONDITIONS: 2b tree — regenerating it changes 0 files (${since()})`)
// 3. SRI
const sri = run(process.execPath, ['internal/sri-consistency-check.mjs'])
if (sri.status !== 0) refuse('check:sri failed', (sri.stdout + sri.stderr).trim().split('\n').slice(-6).join('\n'))
console.log(`LANDING-PRECONDITIONS: 3 sri — every leaf loads the bundle its integrity names (${since()})`)
// 3c. SIZE — every shipped bundle is under the ceiling (verify-bundles-shipped). It lived only inside
// `verify`, in gates:post, so a landing that grew a bundle past 1,500,000 bytes landed and THEN went red
// (694b00991, country-posters at 1,662,962). Here it runs on the rebuilt tree, before the push. ~0.2 s.
const size = run(process.execPath, ['library/scripts/research/verify-bundles-shipped.mjs'])
if (size.status !== 0) refuse('a shipped bundle is over the size ceiling or missing (verify-bundles-shipped)', (size.stdout + size.stderr).trim().split('\n').filter((l) => /✗|FAIL|FATAL/.test(l)).slice(0, 8).join('\n'))
console.log(`LANDING-PRECONDITIONS: 3c size — every shipped bundle is under the ceiling (${since()})`)
// 3b. MOUNTS — every capability-4/5 composite mounts headless in en and ar without a setup/watcher/mount
// error (check:composites-mount, ~20 s). 08f89f55 left Big Picture and Simple Research BLANK while every
// other check passed; a landing that blanks a composite is now refused.
const mounts = run(process.execPath, ['internal/check-composites-mount.mjs'])
if (mounts.status !== 0) refuse('a composite no longer mounts (check:composites-mount)', (mounts.stdout + mounts.stderr).trim().split('\n').filter((l) => /FAIL|✗|Error/.test(l)).slice(0, 8).join('\n'))
console.log(`LANDING-PRECONDITIONS: 3b mounts — every composite mounts in en and ar (${since()})`)
// 4. FRESH
if (process.env.DOXA_SKIP_FRESHNESS === '1') {
console.log('LANDING-PRECONDITIONS: 4 fresh — SKIPPED (DOXA_SKIP_FRESHNESS=1; dry check only, never set by main-capture scan)')
} else {
// SCOPE: what can this landing have made stale? A change to shared code (library/, the build config, the
// dependencies) reaches ANY bundle — measured 2026-09-25: one Arabic catalogue edit in library/i18n left 12
// of 25 bundles stale, most of them not composites, and a composite-only check saw none of it. Otherwise:
// every composite, plus each bundle whose own folder the landing touched.
const base = git(['merge-base', 'HEAD', `origin/${process.env.MAIN_CAPTURE_BRANCH || process.env.LANDING_BRANCH || 'development'}`]).stdout.trim()
const changed = base ? git(['diff', '--name-only', base, 'HEAD']).stdout.split('\n').filter(Boolean) : []
// ★ A library/ change no longer means EVERY bundle. It means every bundle whose SOURCE GRAPH contains a
// changed file, or that reads a changed i18n area: measured, a one-file library fix reached 1 of 24
// bundles, and rebuilding all 24 cost ~10 min per landing round. Only the build itself (config,
// dependencies, the tree generator) can reach every bundle, so only that still means --all.
const pkgDepsChanged = () => {
if (!changed.includes('package.json') || !base) return false
const deps = (ref) => { try { const j = JSON.parse(git(['show', `${ref}:package.json`]).stdout); return JSON.stringify([j.dependencies, j.devDependencies]) } catch { return '' } }
return deps(base) !== deps('HEAD')
}
// The build config reaches every bundle only through a key the BUILD reads. dropin (where sync-dropin copies
// to) and the "//" comment keys build nothing: a dropin.dest fix verified every bundle, ~10 min, for nothing.
const buildCfgChanged = () => {
const CFG = 'vite.doxa-maps-build-config.json'
if (!changed.includes(CFG) || !base) return false
const keys = (ref) => {
try {
const j = JSON.parse(git(['show', `${ref}:${CFG}`]).stdout)
for (const k of Object.keys(j)) if (k === 'dropin' || k.startsWith('//')) delete j[k]
return JSON.stringify(j)
} catch { return null }
}
const a = keys(base), b = keys('HEAD')
return a === null || b === null || a !== b
}
const everything = !base || pkgDepsChanged() || buildCfgChanged() || changed.some((f) => /^(vite\.config\.js$|bun\.lockb?$)/.test(f))
const libChanged = changed.filter((f) => f.startsWith('library/'))
const shared = everything || libChanged.length > 0
const own = new Set(changed.map((f) => (f.match(/^plugin-designs\/([^/]+)\//) || [])[1]).filter(Boolean))
if (!everything && libChanged.length) {
const libSet = new Set(libChanged)
const areas = new Set(libChanged.map((f) => (f.match(/^library\/i18n\/locales\/[^/]+\/([^/]+)\.json$/) || [])[1]).filter(Boolean))
const manifest = JSON.parse(readFileSync(resolve(ROOT, 'public', TREE_NAME, 'manifest.json'), 'utf8'))
// ★ EVERY SHIPPED BUNDLE, NOT ONLY THE MANIFEST'S. A bundle the tree ships but does not list (template-bundle)
// was never in scope, so a library change it imports left it stale with no landing to rebuild it; a final
// all-bundle freshness run found it weeks of library changes behind. Shipped = a plugin with an index.js
// whose built <bundle>/<bundle>.js is in the tree.
const shipped = readdirSync(resolve(ROOT, 'plugin-designs')).filter((d) => existsSync(resolve(ROOT, 'plugin-designs', d, 'index.js'))
&& existsSync(resolve(ROOT, 'public', TREE_NAME, d, d + '.js')))
const candidates = [...new Set([...manifest.bundles.map((b) => b.bundle), ...shipped])]
for (const bundle of candidates) {
if (!existsSync(resolve(ROOT, 'plugin-designs', bundle, 'index.js'))) continue
const graph = bundleGraph(ROOT, bundle).map((f) => f.slice(ROOT.length + 1))
if (graph.some((f) => libSet.has(f)) || (areas.size && areasForBundle(ROOT, bundle).areas.some((a) => areas.has(a)))) own.add(bundle)
}
}
const scope = everything ? ['--all'] : (own.size ? ['--bundles', [...own].sort().join(',')] : [])
console.log(`LANDING-PRECONDITIONS: 4 fresh — scope: ${everything ? 'EVERY bundle (the build itself changed' + (base ? '' : '; no merge-base') + ')' : (shared ? 'the bundles this library change reaches' : 'composites') + (own.size ? ': ' + [...own].sort().join(', ') : '')}`)
// ★ ONE PASS PER TREE, NOT ONE PER SCAN. This check rebuilds every bundle in its scope (~10 min for
// --all), and it used to run INSIDE the merge-queue lock, so a build-owner landing held main for 30+ min
// while every other landing queued (2026-09-25). The verdict is a function of the committed tree and the
// scope, so a PASS is recorded against both; the landing script runs this precondition BEFORE taking the
// lock, and the scan inside the lock finds the record and does not rebuild again. A different tree or a
// wider scope has no record and is checked in full. The record lives in the repo's own git dir.
const treeId = git(['rev-parse', 'HEAD^{tree}']).stdout.trim()
const stampDir = resolve(ROOT, git(['rev-parse', '--git-common-dir']).stdout.trim(), 'doxa-fresh')
// The scope can be a long bundle list now, and a filename has a length limit: hash it.
const scopeKey = scope.length ? createHash('sha1').update(scope.join(' ')).digest('hex').slice(0, 16) : 'composites'
const stamp = resolve(stampDir, `${treeId}.${scopeKey}`)
if (treeId && existsSync(stamp)) {
console.log(`LANDING-PRECONDITIONS: 4 fresh — this exact tree (${treeId.slice(0, 10)}) already passed at this scope, ${readFileSync(stamp, 'utf8').trim()}`)
console.log('LANDING-PRECONDITIONS: PASS — this commit may land.')
process.exit(0)
}
const fresh = run(process.execPath, ['internal/check-composite-freshness.mjs', ...scope])
if (fresh.status !== 0) refuse(
'a committed composite is not what its source builds (stale consumers).\n' +
'README rule 5: ONE agent owns the full build. If your change touches library/ or anything compiled into\n' +
'other plugins, do not rebuild everyone yourself: commit it on your branch and hand the landing to the\n' +
'build owner ("please land <branch>@<sha>: library change, consumers need rebuilding"). If only YOUR\n' +
'own plugin is stale, rebuild just it: BUNDLE=<name> bun x vite build && bun run tree && bun run fix:sri.',
(fresh.stdout + fresh.stderr).trim().split('\n').filter((l) => /✗|STALE|FAIL/.test(l)).slice(0, 8).join('\n'))
console.log('LANDING-PRECONDITIONS: 4 fresh — every composite rebuilds to its committed bytes')
if (treeId) { mkdirSync(stampDir, { recursive: true }); writeFileSync(stamp, `${new Date().toISOString()} (commit ${git(['rev-parse', '--short', 'HEAD']).stdout.trim()})\n`) }
}
console.log('LANDING-PRECONDITIONS: PASS — this commit may land.')