check:composite refuses a verdict built from files no commit has (unverified)
UNVERIFIED: written, not yet run end to end; the handoff stopped it here. The rebuild runs in the working tree, so it can read what no commit carries: an untracked locale a glob picks up, a VITE_* key from an untracked root .env that the code reads (apiBaseUrl.js reads VITE_API_BASE_URL), a build env var such as DOXA_QA. Bytes built from those prove nothing about freshness, and the landing precondition caches a PASS per tree id. Build env vars are now scrubbed from each rebuild; vite.config.js writes down every file a build read when DOXA_BUILD_INPUTS is set (only this gate sets it); a bundle whose rebuild read an untracked file or such a key is NOT MEASURED (exit 75), naming the files. Measured before the stop: an untracked .env.local with an unreferenced VITE_ key leaves all-people-groups byte-identical (cf536c63), so only keys the code reads are held against a bundle.
This commit is contained in:
parent
f8d0a7d9c0
commit
8eb4e498fc
2 changed files with 64 additions and 5 deletions
|
|
@ -34,13 +34,24 @@
|
|||
* NEGATIVE-TESTED 2026-09-25: an edit to people-groups-grid.vue with no rebuild fails the
|
||||
* composites that compile it in; `touch` on every child source (no content change) still PASSES on
|
||||
* a fresh tree — the mtime false alarm is gone.
|
||||
*
|
||||
* ★ INPUTS NO COMMIT HAS (2026-09-29). The rebuild runs in THIS working tree, so it can read what no
|
||||
* commit will ever carry: an untracked or gitignored file a glob picks up (a locale JSON under
|
||||
* library/i18n/locales/, a ./*.vue, a colors/*.js), a VITE_* key from an untracked root .env* that the code reads (Vite compiles it
|
||||
* in: apiBaseUrl.js reads VITE_API_BASE_URL), a build env var (DOXA_QA, DX_SHIP_ONE_LOCALE, NODE_ENV).
|
||||
* Bytes built from those are reproducible from no commit, so they say nothing about freshness, matching
|
||||
* or not — and landing-preconditions caches a PASS per tree id, which is sound only if the verdict is a
|
||||
* function of the tree. So build env vars are SCRUBBED from every rebuild; the build writes down every
|
||||
* file it read (vite.config.js, DOXA_BUILD_INPUTS); and a bundle whose rebuild read an untracked file, or
|
||||
* such a .env key, is NOT MEASURED (exit 75) with the files named. Tracked files are read as they are on
|
||||
* disk, as before (a landing is refused anyway unless the tree is clean).
|
||||
*/
|
||||
import { readFileSync, existsSync, readdirSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { readFileSync, existsSync, readdirSync, mkdtempSync, rmSync, realpathSync } from 'node:fs'
|
||||
import { isWebBuilt } from './plugin-targets.mjs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { resolve, dirname, join, relative, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { TREE_NAME, GATED_PROFILES } from './_tree-name.mjs'
|
||||
|
||||
|
|
@ -80,8 +91,38 @@ const resolveEntry = (e, parent) => { const s = String(e); const head = s.split(
|
|||
return byTag.get(s) || null }
|
||||
|
||||
const sha = (p) => createHash('sha256').update(readFileSync(p)).digest('hex')
|
||||
const SCRUB = /^(VITE_|DOXA_|DX_)|^NODE_ENV$/
|
||||
const buildEnv = Object.fromEntries(Object.entries(process.env).filter(([k]) => !SCRUB.test(k)))
|
||||
const scrubbed = Object.keys(process.env).filter((k) => SCRUB.test(k)).sort()
|
||||
const ls = spawnSync('git', ['ls-files', '-z'], { cwd: ROOT, encoding: 'utf8', maxBuffer: 1 << 27 })
|
||||
if (ls.status !== 0) {
|
||||
console.log('COMPOSITE-FRESHNESS: ✗ NOT MEASURED — not a git checkout, so what is committed cannot be told from what is not.')
|
||||
process.exit(75)
|
||||
}
|
||||
const tracked = new Set(ls.stdout.split('\0').filter(Boolean))
|
||||
const ROOTS = [...new Set([ROOT, realpathSync(ROOT)])].map((r) => r + sep)
|
||||
// Vite (mode production) loads these from the root; a VITE_* key in one is compiled in wherever the code reads it.
|
||||
const envFiles = ['.env', '.env.local', '.env.production', '.env.production.local']
|
||||
.filter((f) => existsSync(join(ROOT, f)) && !tracked.has(f))
|
||||
.map((f) => ({ f, keys: [...readFileSync(join(ROOT, f), 'utf8').matchAll(/^\s*(?:export\s+)?(VITE_\w+)\s*=/gm)].map((m) => m[1]) }))
|
||||
.filter((e) => e.keys.length)
|
||||
const srcOf = new Map()
|
||||
const readsKey = (rel, key) => {
|
||||
if (!srcOf.has(rel)) { let t = ''; try { t = readFileSync(join(ROOT, rel), 'utf8') } catch { /* gone: reads nothing */ } srcOf.set(rel, t) }
|
||||
return new RegExp(`import\\.meta\\.env\\??\\.${key}\\b`).test(srcOf.get(rel))
|
||||
}
|
||||
/** The files a rebuild read that no commit has, from the list the build wrote (null = it wrote none). */
|
||||
function notCommitted(listFile) {
|
||||
if (!existsSync(listFile)) return null
|
||||
const read = [...new Set(readFileSync(listFile, 'utf8').split('\n').map((id) => id.split('?')[0])
|
||||
.map((id) => { const r = ROOTS.find((x) => id.startsWith(x)); return r ? id.slice(r.length) : null })
|
||||
.filter((rel) => rel && !rel.split(sep).includes('node_modules')))]
|
||||
if (!read.length) return null
|
||||
return [...read.filter((rel) => !tracked.has(rel.split(sep).join('/'))),
|
||||
...envFiles.flatMap(({ f, keys }) => keys.filter((k) => read.some((rel) => readsKey(rel, k))).map((k) => `${f} (${k})`))]
|
||||
}
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'composite-freshness-'))
|
||||
const failures = [], unresolved = [], fresh = []
|
||||
const failures = [], unresolved = [], fresh = [], unmeasured = []
|
||||
let composites = 0
|
||||
try {
|
||||
for (const bundle of [...bundles].sort()) {
|
||||
|
|
@ -96,12 +137,16 @@ try {
|
|||
const committed = join(TREE, bundle, `${bundle}.js`)
|
||||
if (!existsSync(committed)) { failures.push(`${bundle}: no committed bundle at public/${TREE_NAME}/${bundle}/${bundle}.js`); continue }
|
||||
const out = join(tmp, bundle)
|
||||
const r = spawnSync('bun', ['x', 'vite', 'build'], { cwd: ROOT, env: { ...process.env, BUNDLE: bundle, DOXA_OUT_ROOT: out }, encoding: 'utf8' })
|
||||
const inputs = join(tmp, `${bundle}.inputs`)
|
||||
const r = spawnSync('bun', ['x', 'vite', 'build'], { cwd: ROOT, env: { ...buildEnv, BUNDLE: bundle, DOXA_OUT_ROOT: out, DOXA_BUILD_INPUTS: inputs }, encoding: 'utf8' })
|
||||
const built = join(out, bundle, `${bundle}.js`)
|
||||
if (r.status !== 0 || !existsSync(built)) {
|
||||
failures.push(`${bundle}: the rebuild FAILED (exit ${r.status}) — ${String(r.stderr || r.stdout).trim().split('\n').slice(-2).join(' / ').slice(0, 200)}`)
|
||||
continue
|
||||
}
|
||||
const foreign = notCommitted(inputs)
|
||||
if (foreign === null) { unmeasured.push(`${bundle}: the build did not write down what it read (vite.config.js DOXA_BUILD_INPUTS)`); continue }
|
||||
if (foreign.length) { unmeasured.push(`${bundle}: its rebuild read ${foreign.join(', ')} — in no commit, so its bytes prove nothing either way. Move it aside or commit it.`); continue }
|
||||
if (sha(built) !== sha(committed)) {
|
||||
failures.push(`${bundle} is STALE — the committed bundle is not what its source builds today (committed ${sha(committed).slice(0, 12)}, rebuilt ${sha(built).slice(0, 12)}). Rebuild it: BUNDLE=${bundle} bun x vite build, then bun run fix:sri.`)
|
||||
} else fresh.push(bundle)
|
||||
|
|
@ -109,6 +154,7 @@ try {
|
|||
} finally { rmSync(tmp, { recursive: true, force: true }) }
|
||||
|
||||
console.log(`COMPOSITE-FRESHNESS: ${composites} bundle(s) rebuilt into a temp dir and compared byte for byte with the committed bundle.`)
|
||||
if (scrubbed.length) console.log(` (build env scrubbed from every rebuild: ${scrubbed.join(', ')})`)
|
||||
if (fresh.length) console.log(` ✓ ${fresh.join(', ')}`)
|
||||
for (const u of unresolved) console.log(` ! UNRESOLVED ${u}`)
|
||||
if (failures.length) {
|
||||
|
|
@ -116,4 +162,9 @@ if (failures.length) {
|
|||
for (const f of failures) console.log(` ✗ ${f}`)
|
||||
process.exit(1)
|
||||
}
|
||||
if (unmeasured.length) {
|
||||
console.log(`\nCOMPOSITE-FRESHNESS: NOT MEASURED — ${unmeasured.length} bundle(s); not a pass, not a failure:`)
|
||||
for (const u of unmeasured) console.log(` ✗ NOT MEASURED ${u}`)
|
||||
process.exit(75)
|
||||
}
|
||||
console.log('COMPOSITE-FRESHNESS: PASS — every committed composite is exactly what its source builds today.')
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import { backendDevPlugin, devBackendProxy } from './internal/backend-dev-plugin
|
|||
import { devSyncPlugin } from './internal/dev-sync-plugin.mjs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { readdirSync, statSync, existsSync, readFileSync } from 'node:fs'
|
||||
import { readdirSync, statSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { TREE_NAME, GATED_PROFILES } from './internal/_tree-name.mjs'
|
||||
|
||||
|
|
@ -232,6 +232,14 @@ export default defineConfig(({ command, mode }) => {
|
|||
autoSpaDevPlugin(env.VITE_MAPBOX_TOKEN || process.env.VITE_MAPBOX_TOKEN || ''),
|
||||
backendDevPlugin(__dirname, env),
|
||||
devSyncPlugin(__dirname, env),
|
||||
// DOXA_BUILD_INPUTS=<file> (set only by check:composite): write down every file this build READ — its
|
||||
// module graph plus watched files such as CSS @imports — so the gate can refuse a verdict built from a
|
||||
// file git does not track (an untracked locale a glob matched). Unset, the plugin is not added at all.
|
||||
process.env.DOXA_BUILD_INPUTS && {
|
||||
name: 'doxa-record-build-inputs',
|
||||
apply: 'build',
|
||||
buildEnd() { writeFileSync(process.env.DOXA_BUILD_INPUTS, [...this.getModuleIds(), ...this.getWatchFiles()].join('\n')) },
|
||||
},
|
||||
],
|
||||
resolve: {
|
||||
alias: { '@map': resolve(__dirname, 'library') },
|
||||
|
|
|
|||
Loading…
Reference in a new issue