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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions