Skip to content

Commit 841ed38

Browse files
os-warrenclaude
andauthored
fix(spec): six dist-reading gates answer an unmet build prerequisite with exit 3, not a finding's 1 (#19463)
Fixes #19227 Refs #19450 Clause-②: no Six `packages/spec` gates refused an unmet **build** prerequisite with `process.exit(1)` — the code a real finding uses — while `scripts/import-prerequisite.mjs:250` declares `EXIT_PREREQUISITE_NOT_MET = 3` for exactly those two words and argues the case in its own docblock. This lands the six on that vocabulary. ⛔ Nothing about what a real finding returns moves: every `fail()`, `refuse()`, self-test, ratchet and diagnostics verdict still exits 1. ## Why it is not cosmetic — reproduced in this round `scripts/pm/dispatch-gates.mjs --ran` derives its NOT-MEASURED class **from the recorded exit code** (`dispatch-gates.mjs:13932`) and counts every other non-kill code as a family that RAN. Driven twice over the same derivation of this very branch, same tool, same tree, only the six recorded codes differing: | the six recorded as | reconciliation | |:---|:---| | `exit 1` (before this PR) | `65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN` — and the tool calls it *"a DERIVED zero — all 65 recorded an exit code and none of them is 3"* | | `exit 3` (after this PR) | `65 derived, 59 run, 6 NOT-MEASURED, 0 UNRUN`, each of the six named with *"recorded exit 3 … PREREQUISITE NOT MET"* | So the card's sharpest claim — filed as UNREPRODUCED by both the filing seat and the PM — holds: a gate that refused before reading a single declaration was reconciled as coverage the round did not have. That is a reconciliation instrument reporting coverage it does not have, not a code mismatch. ## The true population — six gates, seven sites The card verified four files with `grep -c 'process.exit(1)'` and could not locate `check-api-surface` / `check-api-surface-declarations` "under that spelling". Measured here against `origin/main` @ `8e368dc`, the population is the set of guarded refusals reached through `lib/dist-freshness.ts`: | gate script | site | the card's reading | |:---|:---|:---| | `build-api-surface.ts` | stale/missing `dist` | **this is `check:api-surface`** — spelled `build-*` because one file serves `gen:` and `check:` | | `check-browser-reachable-entries.ts` | stale/missing bundles | not in the card at all; it already PRINTED `NOT MEASURED` and then exited 1 | | `check-dual-source-exports.ts` | stale/missing `dist`, both modes | `exit(1)` x6 | | `check-entry-nameability.ts` | stale/missing `dist` | `exit(1)` x0 — **the instrument was blind**: this gate `return`s its code to `process.exit(main())` | | `check-exported-any.ts` | stale/missing `dist` | `exit(1)` x5 | | `check-skill-examples.ts` | stale `dist` **and** a self-package with no declarations (was `fail()`) | `exit(1)` x6; the refusal text the card could not find is at `check-skill-examples.ts:3219` | `check-api-surface-declarations` does not exist as a separate gate under any spelling. The nearest candidate, `build-declaration-map.ts` (`check:declaration-map`), reads no `dist` and has no prerequisite refusal; `build-export-origins.ts` says in its own header that it reads `src/`, never the built dist. Recorded as: **not present**. #19450 is the same defect measured independently on `check-skill-examples.ts` (the refusal fires when `packages/client-react/dist` holds no declarations; the built closure returns a true exit 0). Its site is covered here. ⛔ `Refs`, not `Fixes` — the dedupe/close call is triage's. ## Dispositioned, not changed — with readings - `check-entry-nameability.ts`'s dead-canary, foreign-diagnostic and zero-probe refusals keep `return 1`. The gate says so itself: *"Both are findings; neither is a pass."* - `check-skill-examples.ts`'s `refuse()` keeps exit 1 — a surface that never reached tsc is the gate's own invariant breaking, not an unbuilt workspace. - Every `--self-test` failure, battery floor and verdict handshake keeps exit 1: a broken detector is a finding about the instrument. - `check-dual-source-exports.ts`'s missing **tracked** baseline keeps exit 1 — a checked-in file that is absent is a repo-state finding, not a build-environment prerequisite. - `check-generated.ts:751` refuses a `readsDist` generator on a stale dist, but folds it into `failed` and exits `failed ? 1 : 0` across every sub-gate. There is no per-gate exit to move without changing what the aggregate returns for a real failure. Left alone, recorded here. ## What the change is `lib/dist-freshness.ts` is already this package's one home for the precondition ("they live in ONE home, this file, so the next bundle-reading gate finds them together"), so it gains `EXIT_PREREQUISITE_NOT_MET`, `EXIT_FINDINGS` and `prerequisiteNotMetText()`; the seven call sites use them. The verdict's own message — cause, damage, two-line build remedy — is passed through byte for byte; the wrapper adds the fleet phrase `GATE: PREREQUISITE NOT MET — …` that `check-dts-closure`, `check-dual-build-cjs-loads`, `check-i18n-bundles`, `check-i18n-coverage` and `check-closing-target-claim` all already print, plus the "Nothing was measured" paragraph that names the code. A number with no sentence beside it leaves the reader where they were. **Route (the brief offered both).** The shared `scripts/import-prerequisite.mjs` **frame** is not adopted: it is a dependency LOADER (`requireDependency` / `requireDefaultExport`) answering "can this gate import the package it names?", which is a different prerequisite reached a different way. The **constant** is hand-carried with the citation, because `scripts/import-prerequisite.d.mts` is partial by design — its own header records that the exit-code constants are deliberately left out — so importing the name is TS2305 until that root-tree mirror is extended, and extending it is outside this card's surface. Drift is closed by a pin on each side instead: that module's `--self-test` asserts `=== 3` there, and `dist-freshness.test.ts` asserts the same here **and** compares against the value read from that module itself. ## Verification `origin/main` (`8f6d831`) is merged; it touched none of `packages/spec/scripts/`, `scripts/import-prerequisite.mjs` or `scripts/pm/dispatch-gates.mjs`. Everything below is measured **after** that merge, on `c28fe19`, with the closure rebuilt on the merged sources (`turbo run build` over spec + client + client-react, 34 tasks, exit 0) — each exit captured before any pipe: ``` pnpm --filter @objectstack/spec run check:api-surface :: exit 0 pnpm --filter @objectstack/spec run check:browser-reachable-entries :: exit 0 pnpm --filter @objectstack/spec run check:dual-source-exports :: exit 0 pnpm --filter @objectstack/spec run check:entry-nameability :: exit 0 pnpm --filter @objectstack/spec run check:exported-any :: exit 0 pnpm --filter @objectstack/spec run check:skill-examples :: exit 0 pnpm --filter @objectstack/spec run check:generated :: exit 0 ``` Six green is the positive control: the change refuses nothing CI is legitimately in. **Ablation.** The refusal in `check-exported-any.ts` was put back to `console.error(freshness.message); process.exit(1);` through `scripts/ablation-replace.mjs` (anchor hit x1, blob `8e3498ca72bf -> 391020853d3a` — the mutation is proved on disk, not by an editor's exit code). `dist-freshness-adoption.test.ts` then read **1 failed | 9 passed**, the one failure being `check:exported-any … AssertionError: expected 1 to be 3`. Nine cases stayed green, so the pin is targeted rather than blanket. Restored through the same tool: blob back to `8e3498ca72bf`, equal to HEAD, `git diff HEAD` empty. No `dist` preflight applies — these gates run from source under `tsx` and the sandbox copies `packages/spec/scripts`, which is precisely why the mutation reached the run. Tests and gates, all exit 0: `check:scripts-typecheck` · `@objectstack/spec typecheck` · `check:cross-package-test-inputs` · `check:nul-bytes` · `check:test-source-alias` · `check:issue-citations` · `check:pm-dispatch-gates`. **Changeset: `skip-changeset`, measured.** `packages/spec`'s `files[]` is `dist json-schema liveness prompts llms.txt README.md src/**/*.zod.ts CHANGELOG.md api-surface spec-changes.json` — `scripts/` is not on it. After the build, `prerequisiteNotMetText`, `EXIT_PREREQUISITE_NOT_MET` and `inspectDistFreshness` each grep to **0** files under those paths, while the positive control (`QueryAST` 55, `ObjectSchema` 59) fires. Nothing here publishes. ## Governed surfaces: none, and none falsified The landing-path question, re-measured rather than inherited. The instrument reaches governed prose (control: 8 `exit` hits in `AGENTS.md`). Across `docs/adr/**`, `docs/NORTH-STAR.md`, `.claude/**`, `skills/**`, `AGENTS.md` and `CLAUDE.md`, **no file pins a number to these gates' prerequisite state.** The gate NAMES appear (`AGENTS.md:731,736,743`, `.claude/skills/spec-property-retirement/SKILL.md:285,321-322`) and carry no exit code. `.claude/skills/pm-dispatch/references/lanes/` — the surface #14008 had to repair — holds exactly one `exit` mention today, `director.md:66`, about a different tool. `AGENTS.md:736` ("a failure there is a real finding to fix") reads MORE true once a prerequisite stops being spelled as a finding. ⇒ no governed hunk is pulled in, and this stays an ordinary queue landing. ## Acceptance notes Found while sweeping for the true population; ⛔ not fixed here, to be filed rather than ridden in: 1. **`build-docs.ts:103-119` is a seventh site of the same class.** It refuses a stale or missing `json-schema` tree — a different artefact reached through a different predicate (`schemaTreeIsStale`, not `distIsStale`), outside the "six gates" this card rules on — and it, too, answers exit 1. Same remedy shape ("Generate it first"), same reader confusion. 2. **Four unguarded `throw` backstops reach the shell as exit 1 with a node stack trace** for the same event: `build-api-surface.ts:145`, `check-exported-any.ts:143`, `check-dual-source-exports.ts:125` ("Is the package built?") and `check-entry-nameability.ts:311` ("Build first"). These are exceptions, not refusals with a chosen code, so converting them is a behaviour change rather than a constant swap — which is why they are named here instead. 3. Noted, not filed: `AGENTS.md:735-737` calls `check:skill-examples`, `check:exported-any` and `check:dual-source-exports` "pure source audits", yet all three read the built `dist` and refuse on it. Pre-existing prose imprecision, untouched by this diff and carried by no gate. Successor: the next PR that edits that paragraph for another reason. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx --- _Generated by [Claude Code](https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent adabccf commit 841ed38

9 files changed

Lines changed: 305 additions & 34 deletions

‎packages/spec/scripts/build-api-surface.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ import { createHash } from 'node:crypto';
6161
import { readFileSync, writeFileSync } from 'node:fs';
6262
import { resolve } from 'node:path';
6363
import { fileURLToPath } from 'node:url';
64-
import { inspectDistFreshness } from './lib/dist-freshness';
64+
import { EXIT_PREREQUISITE_NOT_MET, inspectDistFreshness, prerequisiteNotMetText } from './lib/dist-freshness';
6565
import {
6666
API_SURFACE_DIR_NAME,
6767
aggregateApiSurfaceShards,
@@ -84,8 +84,11 @@ const freshness = inspectDistFreshness(
8484
`pnpm --filter @objectstack/spec ${CHECK ? 'check' : 'gen'}:api-surface`,
8585
);
8686
if (!freshness.fresh) {
87-
console.error(freshness.message);
88-
process.exit(1);
87+
// PREREQUISITE NOT MET, not a finding (#19227): nothing below this line ran,
88+
// so the code says so rather than borrowing the one a real breaking-change
89+
// report uses.
90+
console.error(prerequisiteNotMetText(`${CHECK ? 'check' : 'gen'}:api-surface`, freshness));
91+
process.exit(EXIT_PREREQUISITE_NOT_MET);
8992
}
9093

9194
/** Public entry points → their built CJS `.d.ts`, read from the exports map. */

‎packages/spec/scripts/check-browser-reachable-entries.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,11 @@ import { fileURLToPath } from 'node:url';
154154

155155
import { buildStamp } from '../../../scripts/check-regen-pending.mjs';
156156
import { scanSource } from '../../../scripts/js-comment-mask.mjs';
157-
import { inspectBundleFreshness } from './lib/dist-freshness';
157+
import {
158+
EXIT_PREREQUISITE_NOT_MET,
159+
inspectBundleFreshness,
160+
prerequisiteNotMetText,
161+
} from './lib/dist-freshness';
158162

159163
const PKG_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..');
160164
const LEDGER_PATH = join(PKG_DIR, 'browser-reachable-entries.json');
@@ -518,8 +522,11 @@ function reconcile(exportsMap: ExportsMap, ledger: Ledger, problems: string[]):
518522
function audit(): never {
519523
const freshness = inspectBundleFreshness(PKG_DIR, 'check', RERUN);
520524
if (!freshness.fresh) {
521-
console.error(`❌ check:browser-reachable-entries — NOT MEASURED.${freshness.message}`);
522-
process.exit(1);
525+
// This refusal already SAID "NOT MEASURED" in prose and then exited with a
526+
// finding's code, so the sentence and the number disagreed and only the
527+
// number is machine-read (#19227). Both now say the same thing.
528+
console.error(prerequisiteNotMetText('check:browser-reachable-entries', freshness));
529+
process.exit(EXIT_PREREQUISITE_NOT_MET);
523530
}
524531

525532
const pkg = JSON.parse(readFileSync(join(PKG_DIR, 'package.json'), 'utf8')) as {

‎packages/spec/scripts/check-dual-source-exports.ts‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
6565
import { join, resolve } from 'node:path';
6666
import { tmpdir } from 'node:os';
6767
import { fileURLToPath } from 'node:url';
68-
import { inspectDistFreshness } from './lib/dist-freshness';
68+
import { EXIT_PREREQUISITE_NOT_MET, inspectDistFreshness, prerequisiteNotMetText } from './lib/dist-freshness';
6969

7070
const PKG_DIR = resolve(fileURLToPath(new URL('.', import.meta.url)), '..');
7171
const BASELINE_PATH = resolve(PKG_DIR, 'dual-source-exports.baseline.json');
@@ -396,8 +396,16 @@ const freshness = inspectDistFreshness(
396396
: 'pnpm --filter @objectstack/spec check:dual-source-exports',
397397
);
398398
if (!freshness.fresh) {
399-
console.error(freshness.message);
400-
process.exit(1);
399+
// PREREQUISITE NOT MET, not a finding (#19227) — in BOTH modes. `--update` is
400+
// the writing half, and a refusal there is still "nothing was measured": the
401+
// baseline on disk is untouched, so no ratchet verdict exists to report.
402+
console.error(
403+
prerequisiteNotMetText(
404+
UPDATE ? 'check-dual-source-exports.ts --update' : 'check:dual-source-exports',
405+
freshness,
406+
),
407+
);
408+
process.exit(EXIT_PREREQUISITE_NOT_MET);
401409
}
402410

403411
const entries = collectEntries();

‎packages/spec/scripts/check-entry-nameability.ts‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ import { fileURLToPath } from 'node:url';
163163

164164
import ts from 'typescript';
165165

166-
import { inspectDistFreshness } from './lib/dist-freshness';
166+
import { EXIT_PREREQUISITE_NOT_MET, inspectDistFreshness, prerequisiteNotMetText } from './lib/dist-freshness';
167167

168168
const HERE = path.dirname(fileURLToPath(import.meta.url));
169169
const PKG_DIR = path.resolve(HERE, '..');
@@ -583,8 +583,18 @@ function main(): number {
583583

584584
const freshness = inspectDistFreshness(PKG_DIR, 'check', RERUN);
585585
if (!freshness.fresh) {
586-
console.error(freshness.message);
587-
return 1;
586+
// PREREQUISITE NOT MET, not a finding (#19227). ⚠️ This gate returns its
587+
// code up to `process.exit(main())` rather than exiting inline, which is
588+
// why a `grep -c 'process.exit(1)'` over this file reads 0 while the
589+
// refusal was there the whole time — the instrument, not the tree.
590+
//
591+
// The three refusals BELOW keep `return 1` deliberately: a dead canary, a
592+
// probe that fails to compile for an unrelated reason and an enumeration
593+
// that found zero callable exports are all findings about this gate or its
594+
// subject, and each says so in its own text ("Both are findings; neither is
595+
// a pass"). Only the unmet build prerequisite is "nothing was measured".
596+
console.error(prerequisiteNotMetText('check:entry-nameability', freshness));
597+
return EXIT_PREREQUISITE_NOT_MET;
588598
}
589599

590600
const manifest = JSON.parse(fs.readFileSync(path.join(PKG_DIR, 'package.json'), 'utf8')) as {

‎packages/spec/scripts/check-exported-any.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ import { createRequire } from 'node:module';
8484
import { dirname, join, resolve } from 'node:path';
8585
import { tmpdir } from 'node:os';
8686
import { fileURLToPath } from 'node:url';
87-
import { inspectDistFreshness } from './lib/dist-freshness';
87+
import { EXIT_PREREQUISITE_NOT_MET, inspectDistFreshness, prerequisiteNotMetText } from './lib/dist-freshness';
8888

8989
const PKG_DIR = resolve(fileURLToPath(new URL('.', import.meta.url)), '..');
9090
const SELF_TEST = process.argv.includes('--self-test');
@@ -447,8 +447,11 @@ const freshness = inspectDistFreshness(
447447
'pnpm --filter @objectstack/spec check:exported-any',
448448
);
449449
if (!freshness.fresh) {
450-
console.error(freshness.message);
451-
process.exit(1);
450+
// PREREQUISITE NOT MET, not a finding (#19227). The `process.exit(1)` above —
451+
// the self-test's — stays exactly what it was: that one IS a finding about
452+
// the detector. This path measured nothing at all.
453+
console.error(prerequisiteNotMetText('check:exported-any', freshness));
454+
process.exit(EXIT_PREREQUISITE_NOT_MET);
452455
}
453456

454457
const entries = collectEntries();

‎packages/spec/scripts/check-skill-examples.ts‎

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,12 @@ import os from 'os';
297297
import path from 'path';
298298
import ts from 'typescript';
299299

300-
import { inspectDistFreshness } from './lib/dist-freshness';
300+
import {
301+
EXIT_PREREQUISITE_NOT_MET,
302+
inspectDistFreshness,
303+
prerequisiteNotMetText,
304+
type DistFreshness,
305+
} from './lib/dist-freshness';
301306

302307
// ── Paths ────────────────────────────────────────────────────────────────────
303308

@@ -1471,6 +1476,30 @@ function refuse(message: string): never {
14711476
process.exit(1);
14721477
}
14731478

1479+
/**
1480+
* PREREQUISITE NOT MET — "this gate never got to look", as distinct from BOTH
1481+
* of the two above: `fail()` looked and found something, `refuse()` produced no
1482+
* result because the gate's own invariant broke. This one produced no result
1483+
* because the WORKSPACE is not built (#19227).
1484+
*
1485+
* A third token for the same reason the file already carries two — the states
1486+
* need spellings a reader can tell apart at a glance — but the half that
1487+
* matters here is the EXIT CODE, which is what a machine reads:
1488+
* `scripts/pm/dispatch-gates.mjs --ran` derives its NOT-MEASURED class from the
1489+
* recorded code and counts every other non-kill code as a family that RAN. So
1490+
* an unmet build prerequisite spelled `exit 1` is reconciled as coverage the
1491+
* round does not have. ⛔ `fail()` and `refuse()` keep exit 1 on purpose: both
1492+
* are real verdicts, and this change must not move either.
1493+
*
1494+
* ⚠️ It exits rather than throwing, like its two siblings, so the `exit`
1495+
* listener installed by `withBuildDirCleanup` is what removes the build dirs —
1496+
* `finally` does not run through `process.exit()`.
1497+
*/
1498+
function prerequisiteNotMet(text: string): never {
1499+
console.error(`${text}\n`);
1500+
process.exit(EXIT_PREREQUISITE_NOT_MET);
1501+
}
1502+
14741503
// ── Self-test ────────────────────────────────────────────────────────────────
14751504

14761505
/**
@@ -3241,18 +3270,16 @@ function main() {
32413270
// dist-independent and worth reporting even when a build is stale. So the
32423271
// guard sits at the boundary rather than at the top: no verdict below it is
32433272
// computed for a stale surface, and no honest finding above it is suppressed.
3244-
let staleMessage: string | null = null;
3273+
let stale: Extract<DistFreshness, { fresh: false }> | null = null;
32453274
for (const pkgDir of surface.selfPackages) {
32463275
const freshness = inspectDistFreshness(pkgDir, 'check', 'pnpm --filter @objectstack/spec check:skill-examples');
32473276
if (!freshness.fresh) {
3248-
staleMessage = freshness.message;
3277+
stale = freshness;
32493278
break;
32503279
}
32513280
}
3252-
if (staleMessage) {
3253-
console.error(`\n[${surface.name}]`);
3254-
console.error(staleMessage);
3255-
process.exit(1);
3281+
if (stale) {
3282+
prerequisiteNotMet(prerequisiteNotMetText(`check:skill-examples [${surface.name}]`, stale));
32563283
}
32573284

32583285
const { paths, missing } = surfacePaths(surface.selfPackages);
@@ -3263,11 +3290,18 @@ function main() {
32633290
// mtime rule alone would read as fresh.
32643291
const unbuiltSelfPackages = surface.selfPackages.filter((dir) => !fs.existsSync(paths[pkgName(dir)]?.[0] ?? ''));
32653292
if (unbuiltSelfPackages.length > 0) {
3266-
fail(
3267-
`[${surface.name}] not built — no declarations to check examples against:\n\n` +
3268-
missing.map((m) => ` - ${m} (missing)`).join('\n') +
3269-
`\n\n Build first (CI does this in the "Build workspace packages" step):\n\n` +
3270-
unbuiltSelfPackages.map((d) => ` pnpm --filter ${pkgName(d)} build`).join('\n'),
3293+
// Same event as the staleness guard above, reached the other way, so it
3294+
// answers with the same code (#19227) — ⛔ not `fail()`, whose exit 1
3295+
// said "these examples are wrong" about examples nothing ever compiled.
3296+
prerequisiteNotMet(
3297+
prerequisiteNotMetText(`check:skill-examples [${surface.name}]`, {
3298+
headline: 'a self-package on this surface has no declarations to check examples against',
3299+
detail:
3300+
`\n` +
3301+
missing.map((m) => ` - ${m} (missing)`).join('\n') +
3302+
`\n\n Build first (CI does this in the "Build workspace packages" step):\n\n` +
3303+
unbuiltSelfPackages.map((d) => ` pnpm --filter ${pkgName(d)} build`).join('\n'),
3304+
}),
32713305
);
32723306
}
32733307

‎packages/spec/scripts/dist-freshness-adoption.test.ts‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ import os from 'node:os';
4343
import path from 'node:path';
4444
import { fileURLToPath } from 'node:url';
4545

46+
import { EXIT_PREREQUISITE_NOT_MET } from './lib/dist-freshness';
47+
4648
const HERE = path.dirname(fileURLToPath(import.meta.url));
4749
const PKG = path.resolve(HERE, '..');
4850
const REPO_ROOT = path.resolve(PKG, '../..');
@@ -181,9 +183,20 @@ function runGate(spec: string, script: string, args: string[] = []): SpawnSyncRe
181183
});
182184
}
183185

184-
/** Both halves of the refusal: it fired, and it named the right gate. */
186+
/**
187+
* All three halves of the refusal: it fired, it named the right gate, and it
188+
* answered with the code that says NOTHING WAS MEASURED.
189+
*
190+
* The third half moved in #19227 and this helper is where its pin lives. It was
191+
* `1` — a real finding's code — which is what let `dispatch-gates --ran`
192+
* reconcile a gate that refused before its first `.d.ts` read as a family that
193+
* ran. ⛔ Do not relax it back to "non-zero": that assertion passes for exactly
194+
* the defect this card removed.
195+
*/
185196
function expectRefusal(run: SpawnSyncReturns<string>, rerun: string): void {
186-
expect(run.status).toBe(1);
197+
expect(run.status).toBe(EXIT_PREREQUISITE_NOT_MET);
198+
expect(run.stderr).toContain('PREREQUISITE NOT MET');
199+
expect(run.stderr).toContain('Nothing was measured');
187200
expect(run.stderr).toContain('OLDER than packages/spec/src');
188201
expect(run.stderr).toContain('pnpm --filter @objectstack/spec build');
189202
expect(run.stderr).toContain(rerun);
@@ -245,7 +258,9 @@ describe('check:dual-source-exports refuses a stale dist (#7181)', () => {
245258
seedDualBaseline(tree.spec);
246259

247260
const run = runGate(tree.spec, DUAL, ['--update']);
248-
expect(run.status).toBe(1);
261+
// The WRITING half refuses with the same code (#19227): it wrote nothing,
262+
// so it has no ratchet verdict to report and did not measure one.
263+
expect(run.status).toBe(EXIT_PREREQUISITE_NOT_MET);
249264
expect(run.stderr).toContain('WRITE a baseline');
250265
expect(fs.readFileSync(path.join(tree.spec, DUAL_BASELINE), 'utf8')).toBe(SENTINEL);
251266
});

‎packages/spec/scripts/dist-freshness.test.ts‎

Lines changed: 93 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,13 @@ import os from 'node:os';
3131
import path from 'node:path';
3232
import { fileURLToPath } from 'node:url';
3333

34-
import { inspectDistFreshness, packageDirLabel } from './lib/dist-freshness';
34+
import {
35+
EXIT_FINDINGS,
36+
EXIT_PREREQUISITE_NOT_MET,
37+
inspectDistFreshness,
38+
packageDirLabel,
39+
prerequisiteNotMetText,
40+
} from './lib/dist-freshness';
3541
import { declarationStamp } from '../../../scripts/check-regen-pending.mjs';
3642

3743
const HERE = path.dirname(fileURLToPath(import.meta.url));
@@ -470,7 +476,7 @@ describe('build-api-surface.ts refuses a stale dist end to end (#7122)', () => {
470476
seed(tree.spec, { distMtime: OLD, srcMtime: NEW });
471477

472478
const run = runGenerator(tree.spec, []);
473-
expect(run.status).toBe(1);
479+
expect(run.status).toBe(EXIT_PREREQUISITE_NOT_MET);
474480
expect(run.stderr).toContain('OLDER than packages/spec/src');
475481
expect(run.stderr).toContain('pnpm --filter @objectstack/spec build');
476482
expect(baseline(tree.spec)).toBe(SENTINEL);
@@ -484,7 +490,7 @@ describe('build-api-surface.ts refuses a stale dist end to end (#7122)', () => {
484490
seed(tree.spec, { distMtime: OLD, srcMtime: NEW });
485491

486492
const run = runGenerator(tree.spec, ['--check']);
487-
expect(run.status).toBe(1);
493+
expect(run.status).toBe(EXIT_PREREQUISITE_NOT_MET);
488494
expect(run.stderr).toContain('FALSE GREEN');
489495
expect(run.stdout).not.toContain('unchanged');
490496
});
@@ -495,9 +501,92 @@ describe('build-api-surface.ts refuses a stale dist end to end (#7122)', () => {
495501

496502
for (const args of [[], ['--check']]) {
497503
const run = runGenerator(tree.spec, args);
498-
expect(run.status).toBe(1);
504+
expect(run.status).toBe(EXIT_PREREQUISITE_NOT_MET);
499505
expect(run.stderr).toContain('no .d.ts declarations');
500506
}
501507
expect(baseline(tree.spec)).toBe(SENTINEL);
502508
});
503509
});
510+
511+
// ── The exit code the refusal answers with (#19227) ──────────────────────────
512+
//
513+
// Until this card the five dist-reading gates refused with `process.exit(1)` —
514+
// a real finding's code — while `scripts/import-prerequisite.mjs:250` declared
515+
// `EXIT_PREREQUISITE_NOT_MET = 3` for these exact two words. The consumer that
516+
// makes the difference concrete is `scripts/pm/dispatch-gates.mjs --ran`: it
517+
// derives its NOT-MEASURED class from the recorded code and counts every other
518+
// non-kill code as a family that RAN.
519+
//
520+
// These pins are the spec-side half of keeping one vocabulary. The root-side
521+
// half is that module's own `--self-test`, which asserts the same 3 there; the
522+
// value is hand-carried between the two (the reason is in the constant's
523+
// docblock), so BOTH pins have to exist or the two declarations can fork in
524+
// silence.
525+
describe('the prerequisite exit code, and the refusal text that explains it (#19227)', () => {
526+
it('is 3, the number this repo already means by PREREQUISITE NOT MET', () => {
527+
expect(EXIT_PREREQUISITE_NOT_MET).toBe(3);
528+
});
529+
530+
it("is distinct from a finding's — the whole point, and the half a machine reads", () => {
531+
expect(EXIT_FINDINGS).toBe(1);
532+
expect(EXIT_PREREQUISITE_NOT_MET).not.toBe(EXIT_FINDINGS);
533+
expect(EXIT_PREREQUISITE_NOT_MET).not.toBe(0);
534+
});
535+
536+
it('agrees with the root frame that declares it, read from that module', async () => {
537+
// The drift check, against the authority rather than against a copy of it.
538+
// `packages/spec` already declares `scripts/**` in CROSS_PACKAGE_TEST_INPUTS,
539+
// so this read is inside a declared radius.
540+
//
541+
// ⚠️ The cast goes through `unknown` because `scripts/import-prerequisite.d.mts`
542+
// is PARTIAL BY DESIGN — its own header says the exit-code constants are
543+
// deliberately omitted from the mirror — so tsc sees a namespace without
544+
// them. That is exactly why the two `typeof` assertions are here rather
545+
// than implied: without them the cast would make a vanished export read as
546+
// `undefined` on both sides of a comparison nobody would notice.
547+
const frame = (await import('../../../scripts/import-prerequisite.mjs')) as unknown as {
548+
EXIT_PREREQUISITE_NOT_MET?: number;
549+
EXIT_FINDINGS?: number;
550+
};
551+
expect(typeof frame.EXIT_PREREQUISITE_NOT_MET).toBe('number');
552+
expect(typeof frame.EXIT_FINDINGS).toBe('number');
553+
expect(EXIT_PREREQUISITE_NOT_MET).toBe(frame.EXIT_PREREQUISITE_NOT_MET);
554+
expect(EXIT_FINDINGS).toBe(frame.EXIT_FINDINGS);
555+
});
556+
557+
it('carries the fleet phrase, so it greps beside its siblings', () => {
558+
// `check-dts-closure`, `check-dual-build-cjs-loads`, `check-i18n-bundles`,
559+
// `check-i18n-coverage` and `check-closing-target-claim` all print
560+
// `<gate>: PREREQUISITE NOT MET — …`. A reader who learned the phrase from
561+
// one of those must find this one with the same grep.
562+
write('dist/contracts/index.d.ts', 'export {};', OLD);
563+
write('src/contracts/job-service.ts', 'export interface JobRunOutcome { ok: boolean }', NEW);
564+
565+
const verdict = inspectDistFreshness(sandbox, 'check', 'pnpm run check:example');
566+
expect(verdict.fresh).toBe(false);
567+
if (verdict.fresh) throw new Error('unreachable: the sandbox dist is stale');
568+
569+
const text = prerequisiteNotMetText('check:example', verdict);
570+
expect(text).toContain('check:example: PREREQUISITE NOT MET');
571+
// The verdict's own words are passed through, not paraphrased.
572+
expect(text).toContain(verdict.message);
573+
// And the sentence a number alone cannot carry.
574+
expect(text).toContain('Nothing was measured');
575+
expect(text).toContain(`Exit code ${EXIT_PREREQUISITE_NOT_MET}`);
576+
expect(text).toContain(`distinct from a finding's ${EXIT_FINDINGS}`);
577+
});
578+
579+
it('wraps a caller-composed refusal the same way — the partial-dist arm', () => {
580+
// `check:skill-examples` refuses a self-package whose `.d.ts` was never
581+
// emitted, which the mtime rule reads as fresh, so that refusal is not an
582+
// `inspectDistFreshness` verdict at all. Same trailer, one source.
583+
const text = prerequisiteNotMetText('check:skill-examples [spec]', {
584+
headline: 'a self-package on this surface has no declarations',
585+
detail: '\n - packages/client-react/dist/index.d.ts (missing)',
586+
});
587+
expect(text).toContain('check:skill-examples [spec]: PREREQUISITE NOT MET');
588+
expect(text).toContain('a self-package on this surface has no declarations');
589+
expect(text).toContain('packages/client-react/dist/index.d.ts (missing)');
590+
expect(text).toContain('Nothing was measured');
591+
});
592+
});

0 commit comments

Comments
 (0)