Skip to content

fix(spec): check:dual-source-exports / check:exported-any / check:skill-examples refuse a stale dist (#7181) - #7421

Merged
os-help merged 1 commit into
mainfrom
claude/issue-7181-dist-freshness-three-gates
Aug 10, 2026
Merged

fix(spec): check:dual-source-exports / check:exported-any / check:skill-examples refuse a stale dist (#7181)#7421
os-help merged 1 commit into
mainfrom
claude/issue-7181-dist-freshness-three-gates

Conversation

@os-help

@os-help os-help commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Fixes #7181

check:dual-source-exports, check:exported-any and check:skill-examples each resolve their public entry points to the built dist/** declarations, each documented "run after pnpm --filter @objectstack/spec build" in prose, and none of them asserted it. All three now adopt #7122's inspectDistFreshness before the first declaration is read.

The mode question, measured

The card asked whether dist-freshness.ts needs a third mode value, since none of these three is gen-shaped and the check wording named check:api-surface by hand. Read against the code, it does not — mode is consumed in exactly two places and only one is semantic:

  • the damage sentence — "writing a wrong baseline" vs "agreeing with one". Those are the only two things a dist reader does, and every call site lands in one of them.
  • the re-run command, spelled ${gen|check}:api-surface by hand. That is not a semantic difference; it is the caller's own name, and hardcoding it is what made three adopted gates print a fourth gate's.

A third value would therefore have to mean "check-shaped, but print a different command" — text wearing a semantic label, still wrong for the fifth caller. So the re-run command is a required third argument instead: the compiler makes every new caller state how to re-run itself, and there is no default to inherit the wrong gate's identity from. It is a full command string rather than an npm script name because one call site (--update below) is not reachable through one.

Why this matters beyond tidiness: a developer told to re-run a gate they never ran runs something that was never refused, sees it pass, and reads the refusal as cleared.

Two premises of the card that did not survive verification

check-dual-source-exports.ts --update writes a tracked artifact. The card graded all three observation-class because they "are check-only — none writes a tracked artifact, so none can launder a wrong baseline into a commit the way gen:api-surface could". That holds for two of them. --update rewrites dual-source-exports.baseline.json, and the docblock and the no-baseline error both point at it. On a stale dist it writes a partition computed from declarations that predate the edit, and the shrink-only ratchet makes it wrong in both directions at once: a name that only became dual-source after the last build is written out as clean, and the plain run then compares that baseline against the same stale dist and agrees. That is #7122's laundering shape one artifact over, so that path gets mode: 'generate' and is pinned by a test asserting the baseline is left byte-identical.

The "anti-vacuity floors of their own" do not cover this state. They are real, but each is a floor against a broken detector or a missing dist: the self-test count assertions run against temp fixtures, and scan's Is the package built? throw fires only when a module symbol will not resolve at all. A dist that resolves fine and merely predates the edit passes every one of them and prints a green. Same for check:skill-examples, whose "is the spec built" guard answers absence only.

check:skill-examples reaches the dist by a different route

The card's A1 assumed all three share collectEntries + ts.createProgram. Two do. check-skill-examples.ts instead builds a tsconfig paths table from the exports map (specPaths()) and a spawned tsc follows it. A copy-pasted guard placement would therefore have been wrong here, so the guard sits at that boundary — immediately before specPaths() — rather than at the top of main(). Everything above it (extraction, the orphan-marker guard, the zero-block guard, the bare-any guard) is dist-independent and still speaks on a stale build; nothing below it is computed. A test pins that ordering from both sides.

For the same reason the two sibling guards sit after --self-test: that path compiles its own fixture in a temp dir and never reads dist/, so refusing it would refuse a run the stale dist cannot affect — and a gate that cannot be exercised without a build is a gate people stop exercising.

Reverse verification

Both directions, on the real tree, per gate. Fires (real packages/spec, dist aged by touch src/index.ts):

❌ packages/spec/dist/**/*.d.ts is OLDER than packages/spec/src -- the declarations on disk
   predate the sources. If you built with OS_SKIP_DTS=1, that build did not rebuild them.

   A verdict now would be computed against a build that no longer matches src, so this
   check would reach its conclusion without ever reading the declarations under test --
   a FALSE GREEN on exactly the change it exists to catch (#7122).

   Build first, then re-run:

     pnpm --filter @objectstack/spec build
     pnpm --filter @objectstack/spec check:dual-source-exports

— identically for check:exported-any and check:skill-examples, each naming itself, exit 1. --update prints the writing damage instead and leaves the baseline untouched.

Stays quiet, same tree after pnpm --filter @objectstack/spec build:

✅  no new dual-source exports: 4816 names across 16 entry points -- 170 re-exported (single declaration), 0 accepted dual-source (baseline).
✅  no exported type resolves to `any`: 2405 types + 1507 schemas across 16 entry points.
✅ 209 prose examples type-check against @objectstack/spec

scripts/dist-freshness-adoption.test.ts runs the same two directions per gate end to end in a repo-shaped sandbox, with a fresh-dist positive control for each so the refusal cases cannot pass merely because a script could not run there.

Gates

@objectstack/spec test 367 files / 9568 tests green (23 in the two freshness files); typecheck green; check:api-surface, check:generated, check:dual-source-exports, check:exported-any, check:skill-examples green; ESLint clean on the changed files; check-nul-bytes OK.

No changeset

Nothing in the published package changes — packages/spec/scripts/** is developer-facing gate behaviour only. #7122's own PR (#7183) landed the same way, changeset-free. Needs skip-changeset; left for the PM per the dispatch ruling.


Generated by Claude Code

…ll-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>
@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 10, 2026 10:41am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

No hand-written docs reference the 0 changed package(s). ✅

@os-help os-help added skip-changeset PR has no user-facing published change; bypasses the changeset gate and removed size/m tests tooling labels Aug 10, 2026 — with Claude
@os-help
os-help marked this pull request as ready for review August 10, 2026 11:09
@os-help
os-help added this pull request to the merge queue Aug 10, 2026
Merged via the queue into main with commit 3e163eb Aug 10, 2026
39 checks passed
@os-help
os-help deleted the claude/issue-7181-dist-freshness-three-gates branch August 10, 2026 11:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Three more dist-reading spec gates carry the same unenforced freshness precondition as gen:api-surface

2 participants