Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions packages/spec/scripts/build-api-surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { inspectDistFreshness } from './lib/dist-freshness';
import { EXIT_PREREQUISITE_NOT_MET, inspectDistFreshness, prerequisiteNotMetText } from './lib/dist-freshness';
import {
API_SURFACE_DIR_NAME,
aggregateApiSurfaceShards,
Expand All @@ -84,8 +84,11 @@ const freshness = inspectDistFreshness(
`pnpm --filter @objectstack/spec ${CHECK ? 'check' : 'gen'}:api-surface`,
);
if (!freshness.fresh) {
console.error(freshness.message);
process.exit(1);
// PREREQUISITE NOT MET, not a finding (#19227): nothing below this line ran,
// so the code says so rather than borrowing the one a real breaking-change
// report uses.
console.error(prerequisiteNotMetText(`${CHECK ? 'check' : 'gen'}:api-surface`, freshness));
process.exit(EXIT_PREREQUISITE_NOT_MET);
}

/** Public entry points → their built CJS `.d.ts`, read from the exports map. */
Expand Down
13 changes: 10 additions & 3 deletions packages/spec/scripts/check-browser-reachable-entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,11 @@ import { fileURLToPath } from 'node:url';

import { buildStamp } from '../../../scripts/check-regen-pending.mjs';
import { scanSource } from '../../../scripts/js-comment-mask.mjs';
import { inspectBundleFreshness } from './lib/dist-freshness';
import {
EXIT_PREREQUISITE_NOT_MET,
inspectBundleFreshness,
prerequisiteNotMetText,
} from './lib/dist-freshness';

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

const pkg = JSON.parse(readFileSync(join(PKG_DIR, 'package.json'), 'utf8')) as {
Expand Down
14 changes: 11 additions & 3 deletions packages/spec/scripts/check-dual-source-exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { inspectDistFreshness } from './lib/dist-freshness';
import { EXIT_PREREQUISITE_NOT_MET, inspectDistFreshness, prerequisiteNotMetText } from './lib/dist-freshness';

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

const entries = collectEntries();
Expand Down
16 changes: 13 additions & 3 deletions packages/spec/scripts/check-entry-nameability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ import { fileURLToPath } from 'node:url';

import ts from 'typescript';

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

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

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

const manifest = JSON.parse(fs.readFileSync(path.join(PKG_DIR, 'package.json'), 'utf8')) as {
Expand Down
9 changes: 6 additions & 3 deletions packages/spec/scripts/check-exported-any.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ import { createRequire } from 'node:module';
import { dirname, join, resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { inspectDistFreshness } from './lib/dist-freshness';
import { EXIT_PREREQUISITE_NOT_MET, inspectDistFreshness, prerequisiteNotMetText } from './lib/dist-freshness';

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

const entries = collectEntries();
Expand Down
58 changes: 46 additions & 12 deletions packages/spec/scripts/check-skill-examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,12 @@ import os from 'os';
import path from 'path';
import ts from 'typescript';

import { inspectDistFreshness } from './lib/dist-freshness';
import {
EXIT_PREREQUISITE_NOT_MET,
inspectDistFreshness,
prerequisiteNotMetText,
type DistFreshness,
} from './lib/dist-freshness';

// ── Paths ────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -1471,6 +1476,30 @@ function refuse(message: string): never {
process.exit(1);
}

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

// ── Self-test ────────────────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -3241,18 +3270,16 @@ function main() {
// dist-independent and worth reporting even when a build is stale. So the
// guard sits at the boundary rather than at the top: no verdict below it is
// computed for a stale surface, and no honest finding above it is suppressed.
let staleMessage: string | null = null;
let stale: Extract<DistFreshness, { fresh: false }> | null = null;
for (const pkgDir of surface.selfPackages) {
const freshness = inspectDistFreshness(pkgDir, 'check', 'pnpm --filter @objectstack/spec check:skill-examples');
if (!freshness.fresh) {
staleMessage = freshness.message;
stale = freshness;
break;
}
}
if (staleMessage) {
console.error(`\n[${surface.name}]`);
console.error(staleMessage);
process.exit(1);
if (stale) {
prerequisiteNotMet(prerequisiteNotMetText(`check:skill-examples [${surface.name}]`, stale));
}

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

Expand Down
21 changes: 18 additions & 3 deletions packages/spec/scripts/dist-freshness-adoption.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

import { EXIT_PREREQUISITE_NOT_MET } from './lib/dist-freshness';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const PKG = path.resolve(HERE, '..');
const REPO_ROOT = path.resolve(PKG, '../..');
Expand Down Expand Up @@ -181,9 +183,20 @@ function runGate(spec: string, script: string, args: string[] = []): SpawnSyncRe
});
}

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

const run = runGate(tree.spec, DUAL, ['--update']);
expect(run.status).toBe(1);
// The WRITING half refuses with the same code (#19227): it wrote nothing,
// so it has no ratchet verdict to report and did not measure one.
expect(run.status).toBe(EXIT_PREREQUISITE_NOT_MET);
expect(run.stderr).toContain('WRITE a baseline');
expect(fs.readFileSync(path.join(tree.spec, DUAL_BASELINE), 'utf8')).toBe(SENTINEL);
});
Expand Down
97 changes: 93 additions & 4 deletions packages/spec/scripts/dist-freshness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,13 @@ import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

import { inspectDistFreshness, packageDirLabel } from './lib/dist-freshness';
import {
EXIT_FINDINGS,
EXIT_PREREQUISITE_NOT_MET,
inspectDistFreshness,
packageDirLabel,
prerequisiteNotMetText,
} from './lib/dist-freshness';
import { declarationStamp } from '../../../scripts/check-regen-pending.mjs';

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

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

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

for (const args of [[], ['--check']]) {
const run = runGenerator(tree.spec, args);
expect(run.status).toBe(1);
expect(run.status).toBe(EXIT_PREREQUISITE_NOT_MET);
expect(run.stderr).toContain('no .d.ts declarations');
}
expect(baseline(tree.spec)).toBe(SENTINEL);
});
});

// ── The exit code the refusal answers with (#19227) ──────────────────────────
//
// Until this card the five dist-reading gates refused with `process.exit(1)` —
// a real finding's code — while `scripts/import-prerequisite.mjs:250` declared
// `EXIT_PREREQUISITE_NOT_MET = 3` for these exact two words. The consumer that
// makes the difference concrete is `scripts/pm/dispatch-gates.mjs --ran`: it
// derives its NOT-MEASURED class from the recorded code and counts every other
// non-kill code as a family that RAN.
//
// These pins are the spec-side half of keeping one vocabulary. The root-side
// half is that module's own `--self-test`, which asserts the same 3 there; the
// value is hand-carried between the two (the reason is in the constant's
// docblock), so BOTH pins have to exist or the two declarations can fork in
// silence.
describe('the prerequisite exit code, and the refusal text that explains it (#19227)', () => {
it('is 3, the number this repo already means by PREREQUISITE NOT MET', () => {
expect(EXIT_PREREQUISITE_NOT_MET).toBe(3);
});

it("is distinct from a finding's — the whole point, and the half a machine reads", () => {
expect(EXIT_FINDINGS).toBe(1);
expect(EXIT_PREREQUISITE_NOT_MET).not.toBe(EXIT_FINDINGS);
expect(EXIT_PREREQUISITE_NOT_MET).not.toBe(0);
});

it('agrees with the root frame that declares it, read from that module', async () => {
// The drift check, against the authority rather than against a copy of it.
// `packages/spec` already declares `scripts/**` in CROSS_PACKAGE_TEST_INPUTS,
// so this read is inside a declared radius.
//
// ⚠️ The cast goes through `unknown` because `scripts/import-prerequisite.d.mts`
// is PARTIAL BY DESIGN — its own header says the exit-code constants are
// deliberately omitted from the mirror — so tsc sees a namespace without
// them. That is exactly why the two `typeof` assertions are here rather
// than implied: without them the cast would make a vanished export read as
// `undefined` on both sides of a comparison nobody would notice.
const frame = (await import('../../../scripts/import-prerequisite.mjs')) as unknown as {
EXIT_PREREQUISITE_NOT_MET?: number;
EXIT_FINDINGS?: number;
};
expect(typeof frame.EXIT_PREREQUISITE_NOT_MET).toBe('number');
expect(typeof frame.EXIT_FINDINGS).toBe('number');
expect(EXIT_PREREQUISITE_NOT_MET).toBe(frame.EXIT_PREREQUISITE_NOT_MET);
expect(EXIT_FINDINGS).toBe(frame.EXIT_FINDINGS);
});

it('carries the fleet phrase, so it greps beside its siblings', () => {
// `check-dts-closure`, `check-dual-build-cjs-loads`, `check-i18n-bundles`,
// `check-i18n-coverage` and `check-closing-target-claim` all print
// `<gate>: PREREQUISITE NOT MET — …`. A reader who learned the phrase from
// one of those must find this one with the same grep.
write('dist/contracts/index.d.ts', 'export {};', OLD);
write('src/contracts/job-service.ts', 'export interface JobRunOutcome { ok: boolean }', NEW);

const verdict = inspectDistFreshness(sandbox, 'check', 'pnpm run check:example');
expect(verdict.fresh).toBe(false);
if (verdict.fresh) throw new Error('unreachable: the sandbox dist is stale');

const text = prerequisiteNotMetText('check:example', verdict);
expect(text).toContain('check:example: PREREQUISITE NOT MET');
// The verdict's own words are passed through, not paraphrased.
expect(text).toContain(verdict.message);
// And the sentence a number alone cannot carry.
expect(text).toContain('Nothing was measured');
expect(text).toContain(`Exit code ${EXIT_PREREQUISITE_NOT_MET}`);
expect(text).toContain(`distinct from a finding's ${EXIT_FINDINGS}`);
});

it('wraps a caller-composed refusal the same way — the partial-dist arm', () => {
// `check:skill-examples` refuses a self-package whose `.d.ts` was never
// emitted, which the mtime rule reads as fresh, so that refusal is not an
// `inspectDistFreshness` verdict at all. Same trailer, one source.
const text = prerequisiteNotMetText('check:skill-examples [spec]', {
headline: 'a self-package on this surface has no declarations',
detail: '\n - packages/client-react/dist/index.d.ts (missing)',
});
expect(text).toContain('check:skill-examples [spec]: PREREQUISITE NOT MET');
expect(text).toContain('a self-package on this surface has no declarations');
expect(text).toContain('packages/client-react/dist/index.d.ts (missing)');
expect(text).toContain('Nothing was measured');
});
});
Loading
Loading