Skip to content

gen:api-surface / gen:export-origins read the dist without asserting it is FRESH — a stale dist launders a phantom breaking removal into the committed baseline #7122

Description

@os-zhuang

Out-of-scope finding from implementing #6227 (PR #7114), recorded per PD #10. Unassigned, not queued. Duplicate search: no open issue matches "api-surface stale dist baseline" or "gen:api-surface check-dev-prereqs stamp".

Fact, with the instance that produced it

packages/spec/scripts/build-api-surface.ts reads the built dist — its own docblock says so:

Reads the built dist — run after pnpm --filter @objectstack/spec build.

That is a documented precondition and nothing enforces it. Neither build-api-surface.ts nor build-export-origins.ts consults any freshness signal; they parse whatever .d.ts happens to be on disk.

Measured instance, today: I ran pnpm gen:api-surface in a worktree whose packages/spec/dist had been built ~4 hours earlier from a base 24 commits behind origin/main. The generator did not complain. It wrote a baseline that deleted a line:

--- a/packages/spec/api-surface/contracts.json
+++ b/packages/spec/api-surface/contracts.json
@@ -163,7 +163,6 @@
     "JobRetryPolicy (interface)",
-    "JobRunOutcome (interface)",
     "JobSchedule (interface)",

JobRunOutcome is a live export (packages/spec/src/contracts/job-service.ts:61) and is present in the origin/main baseline. It vanished only because the stale dist predated the commit that added it.

Why this is worse than an ordinary stale-artifact trip

By this generator's own rule, that diff is a breaking change:

A REMOVED export or a CHANGED factory signature is breaking (bump major).

So the failure mode is not a red build — it is a green one carrying a phantom breaking removal:

  1. Stale dist ⇒ generator writes a baseline missing a real export.
  2. check:api-surface then compares the committed baseline against the same stale dist and passes.
  3. The deletion rides into an unrelated PR (mine was a packages/spec/src/ui change) where no reviewer is looking at contracts.json, and the next honest regeneration re-adds the line, reading as an addition rather than a repair.

Every gate is green at every step. This is the same structural blindness check-adr-0087-registration.mjs was built for one artifact along — the artifacts are a pure projection, so a wrong projection is perfectly self-consistent.

The primitive to fix it already exists and already names this exact package

scripts/check-dev-prereqs.mjs defines staleness precisely, and by content hash rather than mtime (#5864):

stale(pkg)  ⇔  sha256(build inputs of pkg, now) ≠ contents of <pkg>/dist/.build-input-hash

Its own commentary calls out this very package for this very reason — "packages/spec/dist (the fake-drift amplifier)" — and its rationale is exactly the failure above: "a MISSING dist fails loudly, a STALE one LIES."

The stamp is written by packages/spec's build as its last step. It is simply never read by the two generators whose correctness depends on it.

Suggested direction (not pre-judged)

  • Have build-api-surface.ts and build-export-origins.ts assert dist/.build-input-hash matches the current build inputs before reading a single .d.ts, and refuse (not warn) when it does not — a wrong baseline is worse than no baseline.
  • Refusing in --check mode too, so CI cannot pass against a stale dist either.
  • Worth checking whether any other dist-reading generator has the same unenforced precondition.

Why it did not corrupt PR #7114

Caught by eye, not by a gate: the regenerated contracts.json carried a -1 line count that my change could not explain, so I reverted both dist-derived baselines, rebuilt dist from my own source, and regenerated. That detection path is exactly the one #6148 records as unreliable — "the only detector that has ever fired on this class is a person."

Refs: #6227, PR #7114, #5726, #5864, #7090.


Generated by Claude Code

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions