From 067dafac5849239d8878e1b8cc0cc5de7ec0a053 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 03:03:45 +0000 Subject: [PATCH] =?UTF-8?q?feat(devx):=20=E7=BB=99=20check:dev-prereqs=20?= =?UTF-8?q?=E8=A1=A5=E4=B8=8A=E3=80=8C=E9=99=88=E6=97=A7=20dist=E3=80=8D?= =?UTF-8?q?=E5=88=A4=E5=AE=9A(=E5=86=85=E5=AE=B9=E6=8C=87=E7=BA=B9,?= =?UTF-8?q?=E9=9D=9E=20mtime)=20(#5864)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check:dev-prereqs 此前只判存在性,#5726 的另一半——dist 在盘上但内容陈旧——仍无门禁, 而绿灯还替它作了一次没挣来的反向保证。 packages/spec 的 build 现在把自身构建输入的 sha256 打进 dist/.build-input-hash, 门禁重算并比对:两者不等即陈旧。判据是内容而非 mtime,所以 git worktree add / git checkout / touch / 时钟偏移都不会误报——这正是 PR #5863 拒绝做这半边的原因。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn --- .changeset/dev-prereqs-stale-dist-gate.md | 44 ++ AGENTS.md | 9 + packages/spec/package.json | 2 +- scripts/check-dev-prereqs.mjs | 576 +++++++++++++++++++--- 4 files changed, 558 insertions(+), 73 deletions(-) create mode 100644 .changeset/dev-prereqs-stale-dist-gate.md diff --git a/.changeset/dev-prereqs-stale-dist-gate.md b/.changeset/dev-prereqs-stale-dist-gate.md new file mode 100644 index 0000000000..bf5683b702 --- /dev/null +++ b/.changeset/dev-prereqs-stale-dist-gate.md @@ -0,0 +1,44 @@ +--- +"@objectstack/spec": patch +--- + +fix(devx): gate `pnpm dev` on a STALE `packages/spec/dist`, not only a missing one (#5864) + +`check:dev-prereqs` (PR #5863) asserted that every workspace package's declared +`dist/` entry point exists. That covered one half of #5726 — a **missing** +artifact, which fails loudly — and left the other half ungated: a dist that is +present but **stale**, which does not fail at all. It lies, and it lies about +somebody else's code. #5726's 20+ TypeScript errors read exactly like real +contract drift while `isAppResolvedDefaultToken` was exported from `src/` the +whole time and merely absent from a stale `packages/spec/dist`. + +Worse, the existence gate made that half slightly more misleading than before: +the developer was told the workspace was fine seconds before the fake drift +appeared, so a green line was vouching for something it had never checked. + +**The definition, so a gate can decide it.** `packages/spec`'s build now records +a sha256 of its own build inputs into `packages/spec/dist/.build-input-hash` as +its last step; `check:dev-prereqs` recomputes that hash and compares. Stale ⇔ +the two differ. Inputs are everything under `src/`, the package manifest, the +package's own tsconfig/tsup config, and turbo.json's `globalDependencies` — +read from turbo.json rather than restated, so the build's own declaration of a +global input is also the gate's. + +**Content, never mtime.** PR #5863 refused this half because comparing source +mtimes against `dist` false-reds after any checkout, and a gate that cries wolf +on day one gets switched off. A content hash is immune to all of it — `git +worktree add`, `git checkout`, restored backups, clock skew, `touch`. Verified +on the real tree: a source file rewritten with identical bytes and an mtime one +hour in the future (so `src` is strictly newer than `dist`) stays green. + +**Scope, stated rather than implied.** Freshness is asserted for `packages/spec` +alone — AGENTS.md §9's stale-artefact table names exactly one dist that presents +as *other people's* contract drift. The pass line now says which claim is which: +existence for all 67 packages, freshness for the one. An unstamped amplifier +dist is red rather than a warning, because that is precisely the tree #5726 was +run on, and a gate that cannot find its freshness input has verified nothing +(#4690). + +**For consumers of `@objectstack/spec`:** no API, type or runtime change. The +published tarball gains one 65-byte file, `dist/.build-input-hash`, which is the +build's own input digest and is read only by this repo's dev gate. diff --git a/AGENTS.md b/AGENTS.md index bf34f9cc26..f6ac8cf607 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -397,6 +397,15 @@ Even inside your own worktree, operate defensively: None of this is CI-visible: CI checks out fresh and installs clean. It costs only *your* time, which is exactly why it is worth recognising in one step rather than re-diagnosing per gate. + + **One row of that table now has a gate, and only one** (#5864). `pnpm dev` + refuses to boot on a stale `packages/spec/dist`: its build stamps a hash of + its own inputs into `dist/.build-input-hash`, and `check:dev-prereqs` + recomputes and compares. Content, never mtime — so a checkout, a `touch` or + a clock skew cannot false-red it, and `git worktree add` is invisible to it. + For every other row this prescription is still the whole remedy: the gate's + pass line says "existence, not freshness" about the other ~66 packages + precisely so its green cannot be read as vouching for them. 10. **A clean merge is not a working merge — but scope the re-check to the overlap.** Git conflicts on overlapping lines; nothing warns you when two changes are individually fine and jointly wrong. Real examples from one diff --git a/packages/spec/package.json b/packages/spec/package.json index ceb65316de..1e5edfc63b 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -182,7 +182,7 @@ "spec-changes.json" ], "scripts": { - "build": "pnpm gen:schema && pnpm gen:openapi && tsup && if [ -z \"$OS_SKIP_DTS\" ]; then NODE_OPTIONS=\"--max-old-space-size=12288\" BUILD_DTS=true tsup; fi", + "build": "pnpm gen:schema && pnpm gen:openapi && tsup && if [ -z \"$OS_SKIP_DTS\" ]; then NODE_OPTIONS=\"--max-old-space-size=12288\" BUILD_DTS=true tsup; fi && node ../../scripts/check-dev-prereqs.mjs --stamp", "dev": "tsc --watch", "clean": "rm -rf dist", "gen:schema": "OS_EAGER_SCHEMAS=1 tsx scripts/build-schemas.ts", diff --git a/scripts/check-dev-prereqs.mjs b/scripts/check-dev-prereqs.mjs index f93526e84a..4105c7d9fc 100644 --- a/scripts/check-dev-prereqs.mjs +++ b/scripts/check-dev-prereqs.mjs @@ -2,7 +2,8 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** - * check:dev-prereqs — confirm the workspace is BUILT before `pnpm dev` boots it. + * check:dev-prereqs — confirm the workspace is BUILT, and that the one artifact + * whose staleness lies is CURRENT, before `pnpm dev` boots it. * * ONE precondition, ONE fix. That framing is the whole point of this gate: an * unbuilt workspace is a single unmet precondition, and booting into it reports @@ -23,14 +24,16 @@ * and the flag merely boots a half-built workspace that serves errors. * A separate services-lane issue off #5726 teaches that message to * recognise the unbuilt cause; this gate stops `dev` before it is reached. - * - STILL LIVE. Hand-building the failing package prints 20+ TS errors that - * read exactly like real contract drift and are pure artefact of a stale - * `packages/spec/dist` (#5726 keeps that transcript as a vaccine). + * - ADDRESSED HERE SINCE #5864. Hand-building the failing package prints 20+ TS + * errors that read exactly like real contract drift and are pure artefact of + * a stale `packages/spec/dist` (#5726 keeps that transcript as a vaccine). + * That is the FRESHNESS half below. * * A 30-second build was packaged as an investigation. `dev` is the first local * entry point, so the cheapest place to answer it is here, before boot. * - * WHAT IT CHECKS (criterion: declared = enforced) + * ── WHAT IT CHECKS (1/2): EXISTENCE, for every workspace package ───────────── + * (criterion: declared = enforced) * Every workspace package whose resolved entry point — `exports["."]`, else * `main` — points INTO `dist/` must have that file on disk. A package that * declares `dist/index.js` and has no `dist/index.js` is unbuilt by its own @@ -50,20 +53,104 @@ * whose contents differ across branches, so a hand-written driver path would * have silently stopped covering the one package #5726 was actually about. * - * WHAT IT DELIBERATELY DOES NOT CHECK - * - Staleness. Existence only. Comparing src/ mtimes against dist/ would fire - * on every fresh `git worktree add` (checkout rewrites source mtimes), i.e. - * a false red for everyone, which is how gates get ignored. A dist that - * exists but is stale is still #5726's other half; AGENTS.md §9 is the - * standing remedy (`pnpm install --frozen-lockfile && pnpm build` after a - * merge) and is not something a cheap probe can see. + * ── WHAT IT CHECKS (2/2): FRESHNESS, for the declared AMPLIFIERS ───────────── + * (criterion: build-time content stamp, never mtime — #5864) + * Existence answers "was this ever built". It does not answer "was it built + * from the sources on disk now", and those are different failures: a MISSING + * dist fails loudly, a STALE one LIES. #5726's 20+ convincing type errors were + * all the second kind — `isAppResolvedDefaultToken` was exported from `src/` + * the whole time and merely absent from a stale `packages/spec/dist`. * - * That gap has a consequence this gate must not paper over: a green line here - * is reassurance, and on a STALE dist it is reassurance in the wrong - * direction — the developer has just been told the workspace is fine, so the - * fake drift that follows reads even more like a real bug. Hence the pass - * message says "artifacts present", never "the workspace is fine", and the - * gap is filed as #5864 rather than left implicit here. + * THE DEFINITION, so a gate can decide it: + * stale(pkg) ⇔ sha256(build inputs of pkg, now) ≠ contents of + * /dist/.build-input-hash + * The stamp is written by the package's OWN build script, as its last step + * (`node ../../scripts/check-dev-prereqs.mjs --stamp`), so the fact "this dist + * was produced from these bytes" is authored where it is true and read here. + * `scripts/check-console-sha.mjs` is the same shape one artifact along + * (`packages/console/dist/.objectui-sha` against the committed `.objectui-sha`). + * + * THE INPUT SET, and why each part is in it: + * - every file under `/src/` — what the build compiles; + * - `/package.json` — entry points, exports map, build script itself; + * - `/tsconfig.json`, `/tsup.config.ts` when present — how it compiles; + * - turbo.json's own `globalDependencies` — READ from turbo.json, not copied + * here, so the build's declaration of what is a global build input is also + * this gate's, and the two cannot drift apart silently. + * Absent files are hashed as absent, so *creating* a tsconfig is a change too. + * + * WHY CONTENT AND NOT mtime. PR #5863 rejected mtime and #5864 recorded why: + * a checkout rewrites source mtimes, so `src newer than dist` fires for reasons + * that have nothing to do with the build, and a gate that false-reds on day one + * is disabled by the first person it inconveniences. A content hash is immune + * to all of it — `git worktree add`, `git checkout`, restored backups, clock + * skew, NFS timestamps, `touch` — because none of them change file bytes. + * + * WHAT THIS DEFINITION GETS WRONG. Stated in both directions, because an + * unstated failure mode is how a gate loses its readers: + * + * FALSE GREEN — says fresh, is not: + * - DEPENDENCY DRIFT. `pnpm-lock.yaml` is deliberately NOT an input. A + * lockfile moves on most merges, and hashing it would demand a full + * rebuild of the amplifier every time — the routine false red this design + * exists to avoid — while the failure it would catch (a dependency's types + * changing under a dist that is otherwise current) is not the #5726 shape. + * `pnpm install --frozen-lockfile && pnpm build` (AGENTS.md §9) stays the + * remedy for that one. + * - TOOLCHAIN DRIFT. A dist emitted by an older tsup/tsc from byte-identical + * sources reads fresh. Same argument: node_modules is not hashable at this + * price, and the lie it produces is not the one #5726 documented. + * - `OS_SKIP_DTS=1`. That build emits JS and leaves whatever `.d.ts` was + * there before, then stamps. The JS is genuinely fresh and the stamp says + * so; the declarations may not be. This gate has never probed `.d.ts` + * (dev boot needs JS), and AGENTS.md §9 already names the flag as the one + * that cannot serve `gen:api-surface`. Recorded, not silently inherited. + * - A HAND-EDITED dist. The hash covers inputs, not outputs. Nothing here + * can see someone editing `dist/index.mjs` directly, and nothing should + * have to. + * + * FALSE RED — says stale, is fine: + * - A COMMENT-ONLY or formatting-only edit under `src/` changes the hash + * while the emitted JS is identical. Accepted: the remedy is the build the + * developer owes anyway, it is seconds, and the alternative (comparing + * emitted output) makes the gate cost more than the build it guards. + * - EDITING THE AMPLIFIER, THEN RUNNING `pnpm dev`. The gate reds. This is + * the case worth being sure about, and it is not a false red at all: dev + * boots the workspace from `dist`, so an edited-but-unbuilt `packages/spec` + * genuinely serves the old contract. Rebuild, or run the example directly + * (`pnpm --filter @objectstack/example-showcase dev`) to bypass on purpose. + * + * AN UNSTAMPED dist IS RED, NOT A WARNING — the one place this departs from + * check-console-sha, deliberately: + * check-console-sha warns on an unstamped console dist because its subject is + * OPTIONAL (the CLI degrades without it) and its rebuild is a slow, separate + * `pnpm objectui:build`. Neither is true here: an amplifier's dist is + * mandatory for booting at all (its ABSENCE is already a red), and the fix is + * the `pnpm build` a developer is one command away from. Degrading to a + * warning would also exempt exactly the tree that produced #5726 — a dist + * built by a build that predates this stamp — which is the whole subject. + * Absence of the freshness input is not licence to exit 0 (#4690). + * This costs one red per already-built worktree on the day it lands, and not + * even that in practice: this commit changes `packages/spec/package.json`, + * which is both a turbo build input and a hash input, so the amplifier had to + * be rebuilt anyway. + * + * WHY A DECLARED LIST INSTEAD OF EVERY PACKAGE. Measured on this repo: hashing + * `packages/spec/src` costs ~30ms (687 files, 9.7MB); hashing every package's + * sources costs ~125ms. Cost is therefore NOT the reason. The reason is the + * stamping side: freshness can only be asserted for a package whose build + * writes the stamp, and rolling that line into 60+ build scripts is a change to + * how every package builds, for packages whose stale dist fails loudly instead + * of lying. AGENTS.md §9's stale-artefact table names exactly one dist that + * presents as *other people's* contract drift, and it is `packages/spec`. + * Adding the next amplifier is two lines: its path in AMPLIFIERS, and `--stamp` + * at the end of its build script — and NEITHER half can be forgotten, because + * a listed package whose build script does not stamp fails this gate as a + * coverage error, and `--stamp` from an unlisted package exits 1. + * + * ── WHAT IT DELIBERATELY DOES NOT CHECK ───────────────────────────────────── + * - Freshness of the other ~60 packages. Existence only, as before; the pass + * line says so in those words, and AGENTS.md §9 remains the standing remedy. * - Type declarations. Only the JS entry is probed, never `.d.ts`, so a fast * `OS_SKIP_DTS=1` build stays green — dev boot needs JS, not declarations. * - packages/console/dist. It is built by scripts/build-console.sh, NOT by @@ -88,13 +175,16 @@ * Usage: * node scripts/check-dev-prereqs.mjs # gate the workspace * node scripts/check-dev-prereqs.mjs --self-test # prove it can go both ways + * node scripts/check-dev-prereqs.mjs --stamp # write dist/.build-input-hash + * # for the package in cwd * pnpm check:dev-prereqs # self-test, then gate * * WHY THE `dev` CHAIN CALLS THIS WITH `node` AND NOT `pnpm check:dev-prereqs` * A pnpm script hop costs ~0.7s before any of our code runs (measured on this * repo: `pnpm check:console-sha` 0.74s total for ~0.04s of work). A guard whose * entire value is being cheap should not spend that on the hot path, so the - * `dev` / `dev:*` chains invoke this file directly (~0.06s, no self-test) while + * `dev` / `dev:*` chains invoke this file directly (~0.06s existence, plus + * ~0.03s for the one amplifier's hash, no self-test) while * `pnpm check:dev-prereqs` keeps the conventional self-test-then-run shape for * humans and for any future non-dev caller. The existing `pnpm check:console-sha` * link keeps its form — it is not this change's business. @@ -107,17 +197,40 @@ * one precondition with one fix and stops, which is the entire lesson of #5726. * * Exit codes: - * 0 built (or nothing declares a dist entry point — nothing to verify) - * 1 not built; or the workspace layout could not be read (a gate that + * 0 built, and every amplifier's dist matches its sources (or nothing declares + * a dist entry point — nothing to verify) + * 1 not built; or an amplifier's dist is stale/unstamped; or the workspace + * layout, an amplifier or its build inputs could not be read (a gate that * cannot enumerate members must fail loudly, not pass vacuously — #4690) */ -import { existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +/** + * Packages whose dist is checked for FRESHNESS and not merely existence, as + * workspace-relative POSIX paths. See the header for the admission criterion + * ("a stale dist that presents as somebody else's contract drift") and for why + * this is a declared list rather than every package. + * + * Every entry MUST end its `build` script with STAMP_INVOCATION; a listed + * package that does not is a coverage error, not a silent pass. + */ +const AMPLIFIERS = ['packages/spec']; + +/** Where a build records the hash of the inputs it was built from. */ +const STAMP_BASENAME = '.build-input-hash'; + +/** What an amplifier's build script must contain for its stamp to be maintained. */ +const STAMP_INVOCATION = 'check-dev-prereqs.mjs --stamp'; + +/** Per-package build configuration that changes the output without being under src/. */ +const PACKAGE_BUILD_CONFIG = ['package.json', 'tsconfig.json', 'tsconfig.build.json', 'tsup.config.ts', 'tsdown.config.ts']; + /** Thrown for conditions that must fail the gate rather than shrink its coverage. */ class CoverageError extends Error {} @@ -182,16 +295,157 @@ function declaredEntry(pkg) { const isBuildArtifact = (entry) => /(^|\/)dist\//.test(entry.replace(/^\.\//, '')); const rel = (root, p) => path.relative(root, p) || '.'; +const posixRel = (root, p) => rel(root, p).split(path.sep).join('/'); + +/** + * Global build inputs, read from turbo.json's own `globalDependencies` rather + * than restated here: the build's declaration of what invalidates every package + * is the freshness definition's too, and a new entry there is covered without + * anyone remembering this file. Only literal paths are understood — a glob would + * silently hash fewer inputs, so it throws. + */ +function globalBuildInputs(root) { + const file = path.join(root, 'turbo.json'); + if (!existsSync(file)) throw new CoverageError(`turbo.json is missing — cannot determine the build's global inputs, so freshness cannot be judged.`); + let cfg; + try { + cfg = JSON.parse(readFileSync(file, 'utf-8')); + } catch (err) { + throw new CoverageError(`turbo.json is not readable as JSON (${err.message}) — cannot determine the build's global inputs.`); + } + const declared = cfg.globalDependencies ?? []; + if (!Array.isArray(declared)) throw new CoverageError(`turbo.json 'globalDependencies' is not an array — cannot determine the build's global inputs.`); + return declared.map((entry) => { + if (typeof entry !== 'string' || entry.includes('*') || entry.startsWith('$')) { + throw new CoverageError( + `turbo.json globalDependencies entry ${JSON.stringify(entry)} is not a shape this gate can hash.\n` + + ` Teach scripts/check-dev-prereqs.mjs the new shape — hashing fewer inputs than the build\n` + + ` reads would make the freshness verdict pass vacuously.`, + ); + } + return path.join(root, entry); + }); +} + +/** Every file under `dir`, sorted, node_modules excluded. */ +function filesUnder(dir, out = []) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name === 'node_modules') continue; + const p = path.join(dir, entry.name); + if (entry.isDirectory()) filesUnder(p, out); + else if (entry.isFile()) out.push(p); + } + return out; +} + +/** + * sha256 over a package's build inputs — the freshness definition, in one place, + * used by BOTH `--stamp` (at build time) and the gate (at boot time). They must + * be the same function or the comparison means nothing, which is why the stamper + * lives in this file rather than in a script of its own. + * + * Framing is length-prefixed (`:` then the bytes), so no separator + * can be forged by a file's contents and no control character is needed to + * delimit records. + */ +function buildInputHash(root, pkgDir) { + const src = path.join(pkgDir, 'src'); + if (!existsSync(src)) { + throw new CoverageError(`${posixRel(root, pkgDir)}/src does not exist, so there is nothing to hash — this gate cannot vouch for its dist.`); + } + const inputs = [...filesUnder(src)]; + for (const name of PACKAGE_BUILD_CONFIG) inputs.push(path.join(pkgDir, name)); + inputs.push(...globalBuildInputs(root)); + + const seen = new Set(); + const records = []; + for (const file of inputs) { + const key = posixRel(root, file); + if (seen.has(key)) continue; + seen.add(key); + records.push([key, file]); + } + records.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); + + const hash = createHash('sha256'); + for (const [key, file] of records) { + // An ABSENT input is hashed as absent rather than skipped: creating a + // tsconfig where there was none changes how the package builds, so it has + // to change the hash. + if (!existsSync(file)) { + hash.update(`${key}:absent\n`); + continue; + } + const bytes = readFileSync(file); + hash.update(`${key}:${bytes.length}\n`); + hash.update(bytes); + } + return hash.digest('hex'); +} /** - * The verdict, as data: which declared build artifacts are absent, and which - * single command fixes it. Split from the printing so --self-test can drive it. + * The freshness verdict for the declared amplifiers. Every way of NOT being able + * to answer throws (#4690): a listed package that is not a workspace member, has + * no manifest, has no `src/`, or whose build script no longer stamps, is a + * coverage error — never a quiet "nothing to check". */ -function inspect(root) { +function inspectFreshness(root, amplifiers, memberDirs) { + const results = []; + for (const relDir of amplifiers) { + const dir = path.join(root, relDir); + if (!memberDirs.has(path.resolve(dir))) { + throw new CoverageError( + `${relDir} is declared an amplifier in scripts/check-dev-prereqs.mjs but is not a workspace member.\n` + + ` Either it moved (update AMPLIFIERS) or the workspace list did — a freshness check with no\n` + + ` subject would pass forever without checking anything.`, + ); + } + const manifest = path.join(dir, 'package.json'); + if (!existsSync(manifest)) throw new CoverageError(`${relDir}/package.json is missing — cannot judge the freshness of its dist.`); + let pkg; + try { + pkg = JSON.parse(readFileSync(manifest, 'utf-8')); + } catch (err) { + throw new CoverageError(`${relDir}/package.json is not readable as JSON (${err.message}) — cannot judge the freshness of its dist.`); + } + const buildScript = typeof pkg.scripts?.build === 'string' ? pkg.scripts.build : ''; + if (!buildScript.includes(STAMP_INVOCATION)) { + throw new CoverageError( + `${relDir} is a declared amplifier but its build script no longer ends with '${STAMP_INVOCATION}'.\n` + + ` Nothing would write ${relDir}/dist/${STAMP_BASENAME}, so this check would pass on any dist,\n` + + ` however old. Restore the stamp step, or drop ${relDir} from AMPLIFIERS on purpose.`, + ); + } + + const expected = buildInputHash(root, dir); + const stampFile = path.join(dir, 'dist', STAMP_BASENAME); + const common = { name: pkg.name || relDir, dir: relDir, stamp: posixRel(root, stampFile), expected }; + if (!existsSync(stampFile)) { + results.push({ ...common, state: 'unstamped', stamped: '' }); + continue; + } + const stamped = readFileSync(stampFile, 'utf-8').trim(); + if (!/^[0-9a-f]{64}$/.test(stamped)) { + results.push({ ...common, state: 'unstamped', stamped: '' }); + continue; + } + results.push({ ...common, state: stamped === expected ? 'fresh' : 'stale', stamped }); + } + return results; +} + +/** + * The verdict, as data: which declared build artifacts are absent, which + * amplifiers no longer match their sources, and which single command fixes it. + * Split from the printing so --self-test can drive it. + */ +function inspect(root, amplifiers = AMPLIFIERS) { const missing = []; + const memberDirs = new Set(); let checked = 0; for (const dir of workspaceDirs(root)) { + memberDirs.add(path.resolve(dir)); const manifest = path.join(dir, 'package.json'); if (!existsSync(manifest)) continue; let pkg; @@ -210,43 +464,108 @@ function inspect(root) { if (!existsSync(target)) missing.push({ name: pkg.name, artifact: rel(root, target) }); } + // Freshness is only meaningful once existence holds: with no dist there is no + // stamp either, and reporting both would break the one-precondition-one-fix + // shape that is this gate's entire reason for existing. + const freshness = missing.length === 0 ? inspectFreshness(root, amplifiers, memberDirs) : []; + // A workspace with no dependencies installed cannot run `pnpm build` at all // (turbo is not there), so the one-line fix has to include the install. const installed = existsSync(path.join(root, 'node_modules')); - return { checked, missing, fix: installed ? 'pnpm build' : 'pnpm install && pnpm build' }; + return { checked, missing, freshness, fix: installed ? 'pnpm build' : 'pnpm install && pnpm build' }; } function report(verdict) { - const { checked, missing, fix } = verdict; - if (missing.length === 0) { - console.log(`✓ ${checked} package build artifacts present (existence, not freshness).`); - return 0; + const { checked, missing, freshness, fix } = verdict; + + if (missing.length > 0) { + const shown = missing.slice(0, 3); + const width = Math.max(...shown.map((m) => m.name.length)); + const list = shown.map((m) => ` ${m.name.padEnd(width)} ${m.artifact}`).join('\n'); + const more = missing.length > shown.length ? `\n … and ${missing.length - shown.length} more` : ''; + + console.error( + `\n✗ The workspace is not built — 1 unmet precondition, not a list of problems.\n\n` + + ` ${missing.length} of ${checked} workspace packages declare an entry point under dist/ that is not on disk:\n\n` + + `${list}${more}\n\n` + + ` Booting anyway never names this. Where that led once it was chased\n` + + ` (objectstack-ai/objectstack#5726): a 'datasource: connect failed' whose two\n` + + ` suggested fixes are both wrong for this cause — neither editing the datasource\n` + + ` config nor OS_ALLOW_DRIVER_CONNECT_FAILURE=1 builds a missing artifact — and then\n` + + ` 20+ TS errors from hand-building the package, reading exactly like real contract\n` + + ` drift while being nothing but a stale dist.\n\n` + + ` Fix:\n\n` + + ` ${fix}\n`, + ); + return 1; } - const shown = missing.slice(0, 3); - const width = Math.max(...shown.map((m) => m.name.length)); - const list = shown.map((m) => ` ${m.name.padEnd(width)} ${m.artifact}`).join('\n'); - const more = missing.length > shown.length ? `\n … and ${missing.length - shown.length} more` : ''; - - console.error( - `\n✗ The workspace is not built — 1 unmet precondition, not a list of problems.\n\n` + - ` ${missing.length} of ${checked} workspace packages declare an entry point under dist/ that is not on disk:\n\n` + - `${list}${more}\n\n` + - ` Booting anyway never names this. Where that led once it was chased\n` + - ` (objectstack-ai/objectstack#5726): a 'datasource: connect failed' whose two\n` + - ` suggested fixes are both wrong for this cause — neither editing the datasource\n` + - ` config nor OS_ALLOW_DRIVER_CONNECT_FAILURE=1 builds a missing artifact — and then\n` + - ` 20+ TS errors from hand-building the package, reading exactly like real contract\n` + - ` drift while being nothing but a stale dist.\n\n` + - ` Fix:\n\n` + - ` ${fix}\n`, - ); - return 1; + const notFresh = freshness.filter((f) => f.state !== 'fresh'); + if (notFresh.length > 0) { + const stale = notFresh.filter((f) => f.state === 'stale'); + const detail = notFresh + .map((f) => + f.state === 'stale' + ? ` ${f.name} (${f.stamp})\n` + + ` built from sources hashing ${f.stamped.slice(0, 16)}…\n` + + ` the sources on disk hash ${f.expected.slice(0, 16)}…` + : ` ${f.name} (${f.stamp})\n` + ` no readable build stamp — this dist predates the freshness stamp, so nothing can vouch for it.`, + ) + .join('\n'); + + console.error( + `\n✗ ${notFresh.length === 1 ? "A built package's dist no longer matches its sources" : "Built packages' dists no longer match their sources"} — 1 unmet precondition, not a list of problems.\n\n` + + ` All ${checked} declared build artifacts are present. ${stale.length > 0 ? 'They are not all current:' : 'Their currency cannot be established:'}\n\n` + + `${detail}\n\n` + + ` A stale dist does not fail — it LIES, and it lies about somebody else's code.\n` + + ` (objectstack-ai/objectstack#5726): 20+ TS errors that read exactly like real contract\n` + + ` drift, every one of them an artefact of a stale packages/spec/dist — the export they\n` + + ` named was in src/ the whole time. Chasing that is how correct code gets "fixed".\n\n` + + ` Fix:\n\n` + + ` ${fix}\n`, + ); + return 1; + } + + console.log(`✓ ${checked} package build artifacts present (existence, not freshness).`); + if (freshness.length > 0) { + console.log(`✓ ${freshness.map((f) => f.name).join(', ')} built from the sources on disk — the only freshness claim this line makes; everything else above is existence only.`); + } + return 0; +} + +/** + * --stamp — record, at the END of a package's own build, the hash of the inputs + * that build just consumed. Writing it INSIDE dist is load-bearing: `dist/**` is + * a turbo output, so the stamp is cached, restored and cleaned together with the + * artifact it describes. A stamp kept anywhere else would survive a cache + * restore or a `rm -rf dist` and start lying in the other direction. + */ +function stamp(root, cwd, amplifiers = AMPLIFIERS) { + const dir = path.resolve(cwd); + const relDir = posixRel(root, dir); + if (!amplifiers.includes(relDir)) { + console.error( + `\n✗ ${relDir} is not a declared freshness amplifier, so a stamp written here would be read by nobody.\n\n` + + ` Add '${relDir}' to AMPLIFIERS in scripts/check-dev-prereqs.mjs, or drop the --stamp step\n` + + ` from its build script.\n`, + ); + return 1; + } + const dist = path.join(dir, 'dist'); + if (!existsSync(dist)) { + console.error(`\n✗ ${relDir}/dist does not exist, so there is no build to stamp. --stamp runs as the LAST step of the build, not before it.\n`); + return 1; + } + const hash = buildInputHash(root, dir); + writeFileSync(path.join(dist, STAMP_BASENAME), `${hash}\n`); + console.log(`✓ ${relDir}/dist/${STAMP_BASENAME} ← ${hash.slice(0, 16)}…`); + return 0; } /** * --self-test — a gate only ever observed green is indistinguishable from a gate - * that matches nothing (#4690). These fixtures drive `inspect` to both verdicts + * that matches nothing (#4690). These fixtures drive `inspect` to every verdict * and pin the exclusions that keep it from printing a red whose fix is wrong. */ function selfTest() { @@ -275,13 +594,31 @@ function selfTest() { mkdirSync(path.join(rootDir, path.dirname(file)), { recursive: true }); writeFileSync(path.join(rootDir, file), body); }; - const fixture = (name, { members = ['packages/*'], installed = false } = {}) => { + const fixture = (name, { members = ['packages/*'], installed = false, turbo = { globalDependencies: ['tsconfig.json'] } } = {}) => { const rootDir = path.join(tmp, name); mkdirSync(rootDir, { recursive: true }); writeFileSync(path.join(rootDir, 'pnpm-workspace.yaml'), `packages:\n${members.map((m) => ` - ${m}\n`).join('')}\nonlyBuiltDependencies:\n - esbuild\n`); + if (turbo) writeFileSync(path.join(rootDir, 'turbo.json'), JSON.stringify(turbo)); if (installed) mkdirSync(path.join(rootDir, 'node_modules'), { recursive: true }); return rootDir; }; + /** A workspace whose `packages/spec` is a stamped, buildable amplifier. */ + const amplifierFixture = (name, { build = `tsup && node ../../scripts/${STAMP_INVOCATION}`, src = 'export const token = 1;\n' } = {}) => { + const rootDir = fixture(name, { members: ['packages/*'], installed: true }); + write(rootDir, 'tsconfig.json', '{ "compilerOptions": {} }'); + write(rootDir, 'packages/spec/package.json', JSON.stringify({ name: '@f/spec', main: 'dist/index.js', scripts: { build } })); + write(rootDir, 'packages/spec/src/index.ts', src); + write(rootDir, 'packages/spec/dist/index.js', 'module.exports = {};'); + return rootDir; + }; + const threwCoverage = (fn) => { + try { + fn(); + return 'nothing'; + } catch (err) { + return err instanceof CoverageError ? 'CoverageError' : 'other'; + } + }; try { // 1. Built workspace → green, and the count reflects what was inspected. @@ -290,7 +627,7 @@ function selfTest() { write(built, 'packages/a/dist/index.mjs', 'export {};'); write(built, 'packages/b/package.json', JSON.stringify({ name: '@f/b', main: 'dist/index.js' })); write(built, 'packages/b/dist/index.js', 'module.exports = {};'); - let v = inspect(built); + let v = inspect(built, []); expect('built/missing', v.missing.length, 0); expect('built/checked', v.checked, 2); expect('built/fix', v.fix, 'pnpm build'); @@ -300,7 +637,7 @@ function selfTest() { write(unbuilt, 'packages/a/package.json', JSON.stringify({ name: '@f/a', exports: { '.': './dist/index.mjs' } })); write(unbuilt, 'packages/b/package.json', JSON.stringify({ name: '@f/b', main: 'dist/index.js' })); write(unbuilt, 'packages/b/dist/index.js', 'module.exports = {};'); - v = inspect(unbuilt); + v = inspect(unbuilt, []); expect('unbuilt/missing', v.missing.length, 1); expect('unbuilt/name', v.missing[0]?.name, '@f/a'); expect('unbuilt/artifact', v.missing[0]?.artifact, path.join('packages', 'a', 'dist', 'index.mjs')); @@ -313,14 +650,14 @@ function selfTest() { expect('unbuilt/verdict-lines', red.text.split('\n').filter((l) => l.startsWith('✗')).length, 1); expect('unbuilt/names-artifact', red.text.includes(path.join('packages', 'a', 'dist', 'index.mjs')), true); expect('unbuilt/one-fix', (red.text.match(/pnpm build/g) || []).length, 1); - const green = capture(() => report(inspect(built))); + const green = capture(() => report(inspect(built, []))); expect('built/exit-code', green.code, 0); expect('built/one-line', green.text.trim().split('\n').length, 1); // 3. Dependencies absent → the one-line fix has to install first. const fresh = fixture('fresh'); write(fresh, 'packages/a/package.json', JSON.stringify({ name: '@f/a', main: 'dist/index.js' })); - expect('fresh/fix', inspect(fresh).fix, 'pnpm install && pnpm build'); + expect('fresh/fix', inspect(fresh, []).fix, 'pnpm install && pnpm build'); // 4. Exclusions: a package is only judged on an entry point under dist/. // Pins the three shapes that must NEVER produce a red here, because @@ -332,7 +669,7 @@ function selfTest() { write(excluded, 'packages/console/package.json', JSON.stringify({ name: '@f/console', exports: { './package.json': './package.json' }, files: ['dist'] })); write(excluded, 'packages/docs/package.json', JSON.stringify({ name: '@f/docs', scripts: { build: 'next build' } })); write(excluded, 'packages/example/package.json', JSON.stringify({ name: '@f/example', main: './objectstack.config.ts', exports: { '.': './objectstack.config.ts' } })); - v = inspect(excluded); + v = inspect(excluded, []); expect('excluded/checked', v.checked, 0); expect('excluded/missing', v.missing.length, 0); @@ -340,31 +677,123 @@ function selfTest() { const nested = fixture('nested', { members: ['packages/plugins/*', 'packages/spec'], installed: true }); write(nested, 'packages/plugins/driver-sql/package.json', JSON.stringify({ name: '@f/driver-sql', main: 'dist/index.js' })); write(nested, 'packages/spec/package.json', JSON.stringify({ name: '@f/spec', main: 'dist/index.js' })); - v = inspect(nested); + v = inspect(nested, []); expect('nested/checked', v.checked, 2); expect('nested/missing', v.missing.length, 2); // 6. A member pattern this gate cannot expand must fail loudly, never // silently cover fewer packages. const opaque = fixture('opaque', { members: ['packages/**'], installed: true }); - let threw = ''; - try { - inspect(opaque); - } catch (err) { - threw = err instanceof CoverageError ? 'CoverageError' : 'other'; - } - expect('opaque/throws', threw, 'CoverageError'); + expect('opaque/throws', threwCoverage(() => inspect(opaque, [])), 'CoverageError'); // 7. No pnpm-workspace.yaml at all → same loud failure. const rootless = path.join(tmp, 'rootless'); mkdirSync(rootless, { recursive: true }); - threw = ''; - try { - inspect(rootless); - } catch (err) { - threw = err instanceof CoverageError ? 'CoverageError' : 'other'; - } - expect('rootless/throws', threw, 'CoverageError'); + expect('rootless/throws', threwCoverage(() => inspect(rootless, [])), 'CoverageError'); + + // ── FRESHNESS (#5864) ──────────────────────────────────────────────────── + + // 8. Stamped by its own build → fresh, and the pass line says what it now + // vouches for AND what it still does not. + const stampedRoot = amplifierFixture('stamped'); + expect('stamp/exit-code', capture(() => stamp(stampedRoot, path.join(stampedRoot, 'packages/spec'), ['packages/spec'])).code, 0); + v = inspect(stampedRoot, ['packages/spec']); + expect('stamped/state', v.freshness[0]?.state, 'fresh'); + const stampedGreen = capture(() => report(v)); + expect('stamped/exit-code', stampedGreen.code, 0); + expect('stamped/claims-existence', stampedGreen.text.includes('existence, not freshness'), true); + expect('stamped/claims-freshness', stampedGreen.text.includes('built from the sources on disk'), true); + expect('stamped/bounds-its-claim', stampedGreen.text.includes('existence only'), true); + + // 9. THE POINT OF THE WHOLE CHANGE: a source edit after the build is stale, + // red, and named — with one fix, and #5726 named as the reason. + write(stampedRoot, 'packages/spec/src/index.ts', 'export const token = 2;\n'); + v = inspect(stampedRoot, ['packages/spec']); + expect('stale/state', v.freshness[0]?.state, 'stale'); + const staleRed = capture(() => report(v)); + expect('stale/exit-code', staleRed.code, 1); + expect('stale/verdict-lines', staleRed.text.split('\n').filter((l) => l.startsWith('✗')).length, 1); + expect('stale/names-package', staleRed.text.includes('@f/spec'), true); + expect('stale/one-fix', (staleRed.text.match(/pnpm build/g) || []).length, 1); + expect('stale/says-it-lies', staleRed.text.includes('LIES'), true); + + // 10. mtime is NOT the criterion — the whole reason PR #5863 refused to do + // this half. A source file touched into the future with byte-identical + // content stays fresh; an mtime comparison would red here, and reds like + // that are how gates get switched off. + const untouched = amplifierFixture('untouched'); + capture(() => stamp(untouched, path.join(untouched, 'packages/spec'), ['packages/spec'])); + const touched = path.join(untouched, 'packages/spec/src/index.ts'); + const future = new Date(Date.now() + 3_600_000); + writeFileSync(touched, readFileSync(touched)); // rewrite: new mtime, same bytes + utimesSync(touched, future, future); + expect('mtime/still-fresh', inspect(untouched, ['packages/spec']).freshness[0]?.state, 'fresh'); + + // 11. Absence of the freshness input is red, not a shrug (#4690): a dist + // built before this stamp existed is exactly #5726's tree. + const unstamped = amplifierFixture('unstamped'); + v = inspect(unstamped, ['packages/spec']); + expect('unstamped/state', v.freshness[0]?.state, 'unstamped'); + const unstampedRed = capture(() => report(v)); + expect('unstamped/exit-code', unstampedRed.code, 1); + expect('unstamped/names-stamp', unstampedRed.text.includes(STAMP_BASENAME), true); + expect('unstamped/one-fix', (unstampedRed.text.match(/pnpm build/g) || []).length, 1); + + // 12. A stamp that is not a sha256 (truncated, hand-written, half-flushed) + // is unverifiable, and unverifiable is red — never "close enough". + const garbled = amplifierFixture('garbled'); + capture(() => stamp(garbled, path.join(garbled, 'packages/spec'), ['packages/spec'])); + write(garbled, 'packages/spec/dist/' + STAMP_BASENAME, 'not-a-hash\n'); + expect('garbled/state', inspect(garbled, ['packages/spec']).freshness[0]?.state, 'unstamped'); + + // 13. Declared = enforced, in BOTH directions. An amplifier whose build + // script stopped stamping must fail loudly (otherwise this gate passes + // on any dist forever), and --stamp from an unlisted package must refuse + // (otherwise a stamp is written that nobody reads). + const unstamping = amplifierFixture('unstamping', { build: 'tsup' }); + expect('drift/build-script-lost-stamp', threwCoverage(() => inspect(unstamping, ['packages/spec'])), 'CoverageError'); + expect('drift/stamp-refuses-unlisted', capture(() => stamp(stampedRoot, path.join(stampedRoot, 'packages/spec'), [])).code, 1); + + // 14. Every other way the freshness half can lose its subject is red too. + const noMember = amplifierFixture('no-member'); + expect('coverage/not-a-member', threwCoverage(() => inspect(noMember, ['packages/nonexistent'])), 'CoverageError'); + const noSrc = amplifierFixture('no-src'); + rmSync(path.join(noSrc, 'packages/spec/src'), { recursive: true, force: true }); + expect('coverage/no-src', threwCoverage(() => inspect(noSrc, ['packages/spec'])), 'CoverageError'); + const noTurbo = amplifierFixture('no-turbo'); + rmSync(path.join(noTurbo, 'turbo.json'), { force: true }); + expect('coverage/no-turbo-json', threwCoverage(() => inspect(noTurbo, ['packages/spec'])), 'CoverageError'); + const globbedTurbo = amplifierFixture('globbed-turbo'); + write(globbedTurbo, 'turbo.json', JSON.stringify({ globalDependencies: ['configs/**'] })); + expect('coverage/turbo-glob', threwCoverage(() => inspect(globbedTurbo, ['packages/spec'])), 'CoverageError'); + + // 15. The hash reads the inputs it claims to. A global build input (from + // turbo.json) and the package manifest both move it; the stamp file + // itself, living inside dist, does not — otherwise stamping would + // invalidate the stamp it just wrote. + const inputs = amplifierFixture('inputs'); + const specDir = path.join(inputs, 'packages/spec'); + const base = buildInputHash(inputs, specDir); + write(inputs, 'tsconfig.json', '{ "compilerOptions": { "strict": true } }'); + const afterGlobal = buildInputHash(inputs, specDir); + expect('hash/global-input-moves-it', afterGlobal !== base, true); + write(inputs, 'packages/spec/package.json', JSON.stringify({ name: '@f/spec', main: 'dist/index.js', version: '2.0.0', scripts: { build: `tsup && node ../../scripts/${STAMP_INVOCATION}` } })); + expect('hash/manifest-moves-it', buildInputHash(inputs, specDir) !== afterGlobal, true); + const beforeStamp = buildInputHash(inputs, specDir); + capture(() => stamp(inputs, specDir, ['packages/spec'])); + expect('hash/own-stamp-does-not-move-it', buildInputHash(inputs, specDir), beforeStamp); + + // 16. Existence outranks freshness: a workspace that is not built reports + // ONE precondition, and it is the build — not two. + const halfBuilt = amplifierFixture('half-built'); + capture(() => stamp(halfBuilt, path.join(halfBuilt, 'packages/spec'), ['packages/spec'])); + write(halfBuilt, 'packages/other/package.json', JSON.stringify({ name: '@f/other', main: 'dist/index.js' })); + v = inspect(halfBuilt, ['packages/spec']); + expect('precedence/missing', v.missing.length, 1); + expect('precedence/freshness-not-consulted', v.freshness.length, 0); + const halfRed = capture(() => report(v)); + expect('precedence/verdict-lines', halfRed.text.split('\n').filter((l) => l.startsWith('✗')).length, 1); + expect('precedence/is-the-build-one', halfRed.text.includes('The workspace is not built'), true); } finally { rmSync(tmp, { recursive: true, force: true }); } @@ -375,7 +804,7 @@ function selfTest() { console.error(''); return 1; } - console.log('✓ check:dev-prereqs --self-test — both verdicts reachable, exclusions pinned (7 cases).'); + console.log('✓ check:dev-prereqs --self-test — every verdict reachable, exclusions and freshness coverage pinned (16 cases).'); return 0; } @@ -384,6 +813,9 @@ if (process.argv.includes('--self-test')) { } try { + if (process.argv.includes('--stamp')) { + process.exit(stamp(ROOT, process.cwd())); + } process.exit(report(inspect(ROOT))); } catch (err) { if (err instanceof CoverageError) {