Skip to content

Commit 58d89cd

Browse files
committed
fix(spec): check:dual-source-exports / check:exported-any / check:skill-examples refuse a stale dist (#7181)
All three read the built `dist/*.d.ts` and documented that as a precondition without enforcing it, so each could report a verdict about a build nobody made. They now adopt #7122's `inspectDistFreshness` before the first declaration is read. The primitive's refusal named `check:api-surface` by hand, which would have made three gates prescribe a fourth gate's command -- following it re-runs something that was never refused, so the refusal reads as cleared. The caller now passes its own re-run command; a third `mode` value was measured and rejected, because `mode` selects the damage sentence (writing a wrong baseline vs agreeing with one) and both existing values are semantically right for these callers. Two findings that change the card's framing, both pinned by tests: - `check-dual-source-exports.ts --update` REWRITES a tracked baseline, so the "all three are check-only, none can launder a bad baseline" premise does not hold for it. That path gets `mode: 'generate'`. - the anti-vacuity floors these gates carry are self-test and missing-dist floors. None of them fires on a present-but-stale dist, which is the state being refused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent ef678d0 commit 58d89cd

7 files changed

Lines changed: 462 additions & 17 deletions

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,11 @@ const CHECK = process.argv.includes('--check');
7373
// `ts.createProgram` has run over a stale dist, every answer below it is
7474
// confidently wrong, and both writing it and checking against it are worse than
7575
// stopping here.
76-
const freshness = inspectDistFreshness(PKG_DIR, CHECK ? 'check' : 'generate');
76+
const freshness = inspectDistFreshness(
77+
PKG_DIR,
78+
CHECK ? 'check' : 'generate',
79+
`pnpm --filter @objectstack/spec ${CHECK ? 'check' : 'gen'}:api-surface`,
80+
);
7781
if (!freshness.fresh) {
7882
console.error(freshness.message);
7983
process.exit(1);

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,24 @@
4848
* chunk, so distinct dist declarations imply distinct source declarations; the
4949
* self-test pins the detector itself, and the count assertions keep a silent
5050
* resolution failure from reading as "clean".
51+
*
52+
* That precondition is enforced since #7181, and `--update` is why it matters
53+
* here more than in this file's two sibling gates. #7181 was filed on the reading
54+
* that all three "are check-only — none writes a tracked artifact, so none can
55+
* launder a wrong baseline into a commit". This one does: `--update` REWRITES
56+
* `dual-source-exports.baseline.json`, and on a stale dist it writes a partition
57+
* computed from declarations that predate the edit. The ratchet then makes that
58+
* self-consistent in both directions — a name that only became dual-source after
59+
* the last build is written out as clean, and the plain run compares the same
60+
* baseline against the same stale dist and agrees. That is #7122's laundering
61+
* shape exactly, one artifact over. See lib/dist-freshness.ts.
5162
*/
5263
import ts from 'typescript';
5364
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
5465
import { join, resolve } from 'node:path';
5566
import { tmpdir } from 'node:os';
5667
import { fileURLToPath } from 'node:url';
68+
import { inspectDistFreshness } from './lib/dist-freshness';
5769

5870
const PKG_DIR = resolve(fileURLToPath(new URL('.', import.meta.url)), '..');
5971
const BASELINE_PATH = resolve(PKG_DIR, 'dual-source-exports.baseline.json');
@@ -218,6 +230,23 @@ if (SELF_TEST) selfTest();
218230

219231
// ── Audit ────────────────────────────────────────────────────────────────────
220232

233+
// BEFORE a single `.d.ts` is read (#7181, adopting #7122's primitive). `--update`
234+
// is `generate`-shaped — it writes a tracked baseline — so it gets the writing
235+
// damage, and the plain run gets the false-green one. Placed after `--self-test`
236+
// on purpose: that path builds its own fixture in a temp dir and never reads
237+
// `dist/`, so refusing it on a stale dist would refuse a run that is unaffected.
238+
const freshness = inspectDistFreshness(
239+
PKG_DIR,
240+
UPDATE ? 'generate' : 'check',
241+
UPDATE
242+
? 'pnpm --filter @objectstack/spec exec tsx scripts/check-dual-source-exports.ts --update'
243+
: 'pnpm --filter @objectstack/spec check:dual-source-exports',
244+
);
245+
if (!freshness.fresh) {
246+
console.error(freshness.message);
247+
process.exit(1);
248+
}
249+
221250
const entries = collectEntries();
222251
const { findings, names, reExports } = scan(makeProgram(Object.values(entries)), entries);
223252

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,21 @@
7070
* whose green result is "nothing found".
7171
*
7272
* Reads the built dist — run after `pnpm --filter @objectstack/spec build`.
73+
*
74+
* That last sentence is a PRECONDITION, and since #7181 it is enforced rather
75+
* than merely documented: the audit refuses a dist that is missing or older than
76+
* `src/`. The self-test above is the anti-vacuity floor for a broken DETECTOR; it
77+
* says nothing about the vintage of the declarations the audit then reads, and on
78+
* a stale dist this gate reports "no exported type resolves to `any`" without
79+
* having read the export the developer just added. See lib/dist-freshness.ts.
7380
*/
7481
import ts from 'typescript';
7582
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
7683
import { createRequire } from 'node:module';
7784
import { dirname, join, resolve } from 'node:path';
7885
import { tmpdir } from 'node:os';
7986
import { fileURLToPath } from 'node:url';
87+
import { inspectDistFreshness } from './lib/dist-freshness';
8088

8189
const PKG_DIR = resolve(fileURLToPath(new URL('.', import.meta.url)), '..');
8290
const SELF_TEST = process.argv.includes('--self-test');
@@ -266,6 +274,26 @@ if (SELF_TEST) selfTest();
266274

267275
// ── Audit ────────────────────────────────────────────────────────────────────
268276

277+
// BEFORE a single `.d.ts` is read (#7181, adopting #7122's primitive). The
278+
// existing floors — the self-test's count assertions, and the "Could not resolve
279+
// module symbol … Is the package built?" throw in `scan` — cover a MISSING dist
280+
// and a broken detector. Neither can see the case this refuses: a dist that is
281+
// present and resolves fine but predates the edit under test. There the audit
282+
// runs to completion and prints `✅ no exported type resolves to \`any\`` about a
283+
// build nobody made, which is a false green on exactly the export the developer
284+
// just wrote. It sits after `--self-test` deliberately: that path compiles a temp
285+
// fixture against the real zod and never touches `dist/`, so refusing it on a
286+
// stale dist would be over-reach.
287+
const freshness = inspectDistFreshness(
288+
PKG_DIR,
289+
'check',
290+
'pnpm --filter @objectstack/spec check:exported-any',
291+
);
292+
if (!freshness.fresh) {
293+
console.error(freshness.message);
294+
process.exit(1);
295+
}
296+
269297
const entries = collectEntries();
270298
const { violations, declared, types, schemas } = scan(makeProgram(Object.values(entries)), entries, KNOWN_ANY);
271299

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@
5252
* step in CI — alongside `check:api-surface` / the example-app typecheck, its
5353
* fellow "real consumer" gates — not before it like `check:skill-refs`.
5454
*
55+
* Since #7181 that ordering is enforced rather than assumed: the type-check half
56+
* refuses a dist that is missing or older than `src/`. The existing "is the spec
57+
* built" guard below only answers ABSENCE; a present-but-stale dist type-checks
58+
* every example against the previous build and prints `✅ N prose examples
59+
* type-check against @objectstack/spec` — a green about a rename the developer
60+
* has already made and this run never saw. See lib/dist-freshness.ts.
61+
*
5562
* ── The third anti-idle assertion: no bare `any` in a marked block (#5943) ───
5663
* A marker is the author's claim "this block compiles", and the two guards above
5764
* (orphan marker, zero blocks) exist because a gate that checks nothing must not
@@ -101,6 +108,8 @@ import os from 'os';
101108
import path from 'path';
102109
import ts from 'typescript';
103110

111+
import { inspectDistFreshness } from './lib/dist-freshness';
112+
104113
// ── Paths ────────────────────────────────────────────────────────────────────
105114

106115
const REPO_ROOT = path.resolve(__dirname, '../../..');
@@ -676,6 +685,33 @@ function main() {
676685
);
677686
}
678687

688+
// BEFORE any declaration is resolved (#7181, adopting #7122's primitive).
689+
//
690+
// Placement differs from the two sibling gates on purpose, because the ROUTE to
691+
// the dist differs: those resolve entry points in-process with
692+
// `ts.createProgram`, so their first `.d.ts` read is their first statement of
693+
// work. Here the declarations are reached indirectly — `specPaths()` turns the
694+
// exports map into a tsconfig `paths` table and a spawned `tsc` follows it — and
695+
// everything above this line (extraction, the orphan-marker guard, the zero-block
696+
// guard, the bare-`any` guard) is dist-independent and worth reporting even when
697+
// the build is stale. So the guard sits at the boundary rather than at the top:
698+
// no verdict below it is computed, and no honest finding above it is suppressed.
699+
//
700+
// The `missing` check immediately following is NOT redundant. It answers "was
701+
// the package built at all", which this also covers via `state: 'missing'`; but
702+
// it stays because it is the one that survives a PARTIAL dist — a subpath whose
703+
// `.d.ts` was never emitted while the newest declaration on disk is still newer
704+
// than `src/`, which the mtime rule reads as fresh.
705+
const freshness = inspectDistFreshness(
706+
SPEC_DIR,
707+
'check',
708+
'pnpm --filter @objectstack/spec check:skill-examples',
709+
);
710+
if (!freshness.fresh) {
711+
console.error(freshness.message);
712+
process.exit(1);
713+
}
714+
679715
const { paths, missing } = specPaths();
680716
if (missing.some((m) => m.endsWith('index.d.ts')) && !fs.existsSync(paths['@objectstack/spec']?.[0] ?? '')) {
681717
fail(

0 commit comments

Comments
 (0)