|
| 1 | +#!/usr/bin/env node |
| 2 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 3 | + |
| 4 | +/** |
| 5 | + * The other half of the `merge=os-regen` driver (#4675): make the deferred |
| 6 | + * regeneration **mandatory** instead of remembered. |
| 7 | + * |
| 8 | + * The driver resolves generator-owned artifacts without text-merging them and |
| 9 | + * records each one in `$GIT_DIR/os-regen-pending`. It cannot regenerate them |
| 10 | + * itself — git runs merge drivers before the sources are merged, so anything |
| 11 | + * computed there describes a half-merged tree (see `git-merge-regen.mjs`). This |
| 12 | + * runs from `pre-commit`, where the merged tree finally exists, and refuses the |
| 13 | + * commit while any pending artifact is still stale. |
| 14 | + * |
| 15 | + * It **verifies, then clears** — it does not regenerate. Blanket regeneration |
| 16 | + * from a hook would rewrite artifacts whose staleness nobody saw, which is the |
| 17 | + * signal-destroying behaviour `check:generated` already refuses for the same |
| 18 | + * reason. And a marker cannot get stuck: the moment the artifacts check clean, |
| 19 | + * whether you regenerated them or the merge simply did not change them, the |
| 20 | + * marker is removed and the commit proceeds. |
| 21 | + * |
| 22 | + * ## The dist trap |
| 23 | + * |
| 24 | + * `gen:api-surface` reads the BUILT `dist/*.d.ts`. On a stale dist it does not |
| 25 | + * fail — it emits a plausible surface missing every export added since the last |
| 26 | + * build. So for `readsDist` artifacts this refuses to even run the gate unless |
| 27 | + * the build is newer than the sources, because a phantom "breaking removal" has |
| 28 | + * cost real triage time before (#4687, and the trap is recorded in AGENTS.md). |
| 29 | + * |
| 30 | + * Usage: |
| 31 | + * node scripts/check-regen-pending.mjs # pre-commit |
| 32 | + * node scripts/check-regen-pending.mjs --self-test # no repo state touched |
| 33 | + */ |
| 34 | + |
| 35 | +import { execFileSync, execSync } from 'node:child_process'; |
| 36 | +import { existsSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs'; |
| 37 | +import { dirname, join, resolve } from 'node:path'; |
| 38 | +import { fileURLToPath } from 'node:url'; |
| 39 | + |
| 40 | +import { PENDING_MARKER, entryForPath } from './regen-artifacts.mjs'; |
| 41 | + |
| 42 | +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); |
| 43 | +const SPEC_DIR = join(REPO_ROOT, 'packages/spec'); |
| 44 | + |
| 45 | +/** Newest mtime under `dir` for files matching `pred`, or 0 when there are none. */ |
| 46 | +function newestMtime(dir, pred, depth = 0) { |
| 47 | + if (depth > 12 || !existsSync(dir)) return 0; |
| 48 | + let newest = 0; |
| 49 | + for (const e of readdirSync(dir, { withFileTypes: true })) { |
| 50 | + if (e.name === 'node_modules' || e.name.startsWith('.')) continue; |
| 51 | + const p = join(dir, e.name); |
| 52 | + if (e.isDirectory()) newest = Math.max(newest, newestMtime(p, pred, depth + 1)); |
| 53 | + else if (pred(e.name)) newest = Math.max(newest, statSync(p).mtimeMs); |
| 54 | + } |
| 55 | + return newest; |
| 56 | +} |
| 57 | + |
| 58 | +/** |
| 59 | + * Is `packages/spec/dist` older than the sources it claims to describe? Missing |
| 60 | + * counts as stale. Deliberately conservative: a false "stale" costs a build, a |
| 61 | + * false "fresh" costs a silently wrong artifact. |
| 62 | + */ |
| 63 | +export function distIsStale(specDir = SPEC_DIR) { |
| 64 | + const dist = newestMtime(join(specDir, 'dist'), (n) => n.endsWith('.d.ts')); |
| 65 | + if (!dist) return true; |
| 66 | + return newestMtime(join(specDir, 'src'), (n) => n.endsWith('.ts')) > dist; |
| 67 | +} |
| 68 | + |
| 69 | +function markerPath() { |
| 70 | + const gitDir = execFileSync('git', ['rev-parse', '--absolute-git-dir'], { encoding: 'utf8' }).trim(); |
| 71 | + return join(gitDir, PENDING_MARKER); |
| 72 | +} |
| 73 | + |
| 74 | +function readPending(marker) { |
| 75 | + if (!existsSync(marker)) return []; |
| 76 | + return [...new Set(readFileSync(marker, 'utf8').split('\n').map((l) => l.trim()).filter(Boolean))]; |
| 77 | +} |
| 78 | + |
| 79 | +function runCheck(script) { |
| 80 | + try { |
| 81 | + execSync(`pnpm -s ${script}`, { cwd: SPEC_DIR, stdio: ['ignore', 'pipe', 'pipe'] }); |
| 82 | + return { ok: true, output: '' }; |
| 83 | + } catch (err) { |
| 84 | + return { ok: false, output: `${err?.stdout?.toString() ?? ''}${err?.stderr?.toString() ?? ''}`.trim() }; |
| 85 | + } |
| 86 | +} |
| 87 | + |
| 88 | +function main() { |
| 89 | + const marker = markerPath(); |
| 90 | + const pending = readPending(marker); |
| 91 | + if (!pending.length) return 0; |
| 92 | + |
| 93 | + const entries = pending.map((p) => ({ path: p, entry: entryForPath(p) })).filter((x) => x.entry); |
| 94 | + const unknown = pending.filter((p) => !entryForPath(p)); |
| 95 | + |
| 96 | + console.error( |
| 97 | + `\nos-regen: ${pending.length} generated artifact(s) were merged WITHOUT a text merge and must be ` |
| 98 | + + `regenerated from the merged tree before this commit.\n`, |
| 99 | + ); |
| 100 | + |
| 101 | + // Group by gate: `gen:schema` owns two artifacts, so running it twice is waste. |
| 102 | + const byCheck = new Map(); |
| 103 | + for (const { path, entry } of entries) { |
| 104 | + const g = byCheck.get(entry.check) ?? { entry, paths: [] }; |
| 105 | + g.paths.push(path); |
| 106 | + byCheck.set(entry.check, g); |
| 107 | + } |
| 108 | + |
| 109 | + let blocked = 0; |
| 110 | + for (const [check, { entry, paths }] of byCheck) { |
| 111 | + if (entry.readsDist && distIsStale()) { |
| 112 | + blocked++; |
| 113 | + console.error( |
| 114 | + ` ✗ ${paths.join(', ')}\n` |
| 115 | + + ` ${check} reads packages/spec/dist, which is older than src — NOT running it.\n` |
| 116 | + + ` On a stale dist this gate reports phantom removals and the generator WRITES them.\n` |
| 117 | + + ` pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec ${entry.gen}`, |
| 118 | + ); |
| 119 | + continue; |
| 120 | + } |
| 121 | + const { ok, output } = runCheck(check); |
| 122 | + if (ok) { |
| 123 | + console.error(` ✓ ${paths.join(', ')} — current`); |
| 124 | + continue; |
| 125 | + } |
| 126 | + blocked++; |
| 127 | + const detail = output.split('\n').filter(Boolean).slice(0, 3).map((l) => ` ${l}`).join('\n'); |
| 128 | + console.error(` ✗ ${paths.join(', ')} — stale\n${detail ? `${detail}\n` : ''}` |
| 129 | + + ` pnpm --filter @objectstack/spec ${entry.gen}`); |
| 130 | + } |
| 131 | + |
| 132 | + for (const p of unknown) { |
| 133 | + blocked++; |
| 134 | + console.error(` ✗ ${p} — recorded as pending but absent from scripts/regen-artifacts.mjs (cannot verify)`); |
| 135 | + } |
| 136 | + |
| 137 | + if (blocked) { |
| 138 | + console.error( |
| 139 | + `\nRegenerate the ${blocked} stale artifact(s) above, \`git add\` them, and commit again.\n` |
| 140 | + + ' This check clears itself the moment they are current — nothing to reset by hand.\n' |
| 141 | + + ' Bypass with --no-verify only if you intend CI to catch it: every one of these has a\n' |
| 142 | + + ' required gate on the PR.\n', |
| 143 | + ); |
| 144 | + return 1; |
| 145 | + } |
| 146 | + |
| 147 | + rmSync(marker, { force: true }); |
| 148 | + console.error('os-regen: all deferred artifacts are current — marker cleared.\n'); |
| 149 | + return 0; |
| 150 | +} |
| 151 | + |
| 152 | +// `check:generated --fix` imports `distIsStale` from here, so nothing may run on |
| 153 | +// import — only when this file IS the entry point. |
| 154 | +const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); |
| 155 | + |
| 156 | +if (invokedDirectly) { |
| 157 | + if (process.argv.includes('--self-test')) { |
| 158 | + // Touches no repo state: the interesting logic is the staleness rule, and its |
| 159 | + // dangerous direction is "says fresh when stale". |
| 160 | + const ok = distIsStale(join(REPO_ROOT, 'scripts')) === true; |
| 161 | + console.log(`${ok ? '✓' : '✗'} a directory with no dist/ reads as STALE (conservative default)`); |
| 162 | + console.log(ok ? '\n✓ check-regen-pending self-test passed.' : '\n✗ self-test failed.'); |
| 163 | + process.exit(ok ? 0 : 1); |
| 164 | + } |
| 165 | + process.exit(main()); |
| 166 | +} |
0 commit comments