landing-preconditions says where it is: a first line, a deadline on every child, a time on every step

A landing scan sat 10+ minutes in state S before printing step 1, with no
build under it, and the log could not say what it was waiting on. Now:
- the first line is written before any child starts (pid, node version);
- every child gets an empty stdin and a deadline (git 3 minutes, the
  freshness rebuild 20), and a timeout REFUSES by name, with the elapsed
  time and a pointer to main-capture diag, instead of holding the lock;
- each step prints how long the scan has run.
This commit is contained in:
Doxa Maps 2026-09-29 06:35:53 +00:00
parent 16f2d51531
commit a89c4f2e72

View file

@ -26,6 +26,11 @@
* 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, writeFileSync } from 'node:fs'
@ -40,13 +45,26 @@ const refuse = (why, detail = '') => {
if (detail) console.error(detail.split('\n').map((l) => ' ' + l).join('\n'))
process.exit(1)
}
const run = (cmd, args) => spawnSync(cmd, args, { cwd: ROOT, encoding: 'utf8', env: process.env })
// 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 = run('git', ['grep', '-I', '-l', '-E', '-e', `^(${OPEN}( |$)|${MID}$|${CLOSE}( |$))`, 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 + ':'), ''))
}
@ -64,18 +82,18 @@ if (process.argv.includes('--markers-self-test')) {
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')
console.log(`LANDING-PRECONDITIONS: 0 markers — no file in the commit holds a merge-conflict marker (${since()})`)
// 1. CLEAN
const dirty = run('git', ['status', '--porcelain', '--', 'public', 'plugin-designs', 'library', 'internal', 'vite.config.js', 'vite.doxa-maps-build-config.json', 'package.json'])
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')
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 = run('git', ['ls-tree', '--name-only', 'HEAD', 'public/'])
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()
@ -84,19 +102,19 @@ if (JSON.stringify(entries) !== JSON.stringify(want)) {
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}/`)
console.log(`LANDING-PRECONDITIONS: 2 one folder — public/ = README.md + ${TREE_NAME}/ (${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')
console.log(`LANDING-PRECONDITIONS: 3 sri — every leaf loads the bundle its integrity names (${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')
console.log(`LANDING-PRECONDITIONS: 3b mounts — every composite mounts in en and ar (${since()})`)
// 4. FRESH
if (process.env.DOXA_SKIP_FRESHNESS === '1') {
@ -106,15 +124,15 @@ if (process.env.DOXA_SKIP_FRESHNESS === '1') {
// 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 = run('git', ['merge-base', 'HEAD', `origin/${process.env.MAIN_CAPTURE_BRANCH || process.env.LANDING_BRANCH || 'development'}`]).stdout.trim()
const changed = base ? run('git', ['diff', '--name-only', base, 'HEAD']).stdout.split('\n').filter(Boolean) : []
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(run('git', ['show', `${ref}:package.json`]).stdout); return JSON.stringify([j.dependencies, j.devDependencies]) } catch { return '' } }
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
@ -124,7 +142,7 @@ if (process.env.DOXA_SKIP_FRESHNESS === '1') {
if (!changed.includes(CFG) || !base) return false
const keys = (ref) => {
try {
const j = JSON.parse(run('git', ['show', `${ref}:${CFG}`]).stdout)
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 }
@ -154,8 +172,8 @@ if (process.env.DOXA_SKIP_FRESHNESS === '1') {
// 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 = run('git', ['rev-parse', 'HEAD^{tree}']).stdout.trim()
const stampDir = resolve(ROOT, run('git', ['rev-parse', '--git-common-dir']).stdout.trim(), 'doxa-fresh')
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}`)
@ -173,6 +191,6 @@ if (process.env.DOXA_SKIP_FRESHNESS === '1') {
'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 ${run('git', ['rev-parse', '--short', 'HEAD']).stdout.trim()})\n`) }
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.')