Skip to content

feat(devx): check:published-files gates an exports map retrofitted onto an already-published package (#15715) - #16082

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-15715-exports-map-retrofit-gate
Sep 6, 2026
Merged

feat(devx): check:published-files gates an exports map retrofitted onto an already-published package (#15715)#16082
os-steve merged 2 commits into
mainfrom
claude/issue-15715-exports-map-retrofit-gate

Conversation

@os-steve

@os-steve os-steve commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Fixes #15715

Implements the maintainer's B1 ruling (director seat record 5550130523, summon #14, verbatim reply 「同意」 to 「推荐 B1(以 merge-base 对照实现,不用 git log -S 考古),回退 B3,⛔ 不取 B2」). The card body still reads as an undecided decision card; the ruling comment supersedes it.

check:published-files gains a seventh invariant, ANNOUNCED.

The gap this closes

GATED asserts an exports map exists. It is silent on the transition, and the transition is where the damage is: 17.3.0 added a map to @objectstack/cli, which had published without one, and every deep path a consumer reached through dist/ stopped resolving at once. Two out-of-repo repos found out after publish, during an upgrade — cloud's objectos-runtime (#13662) and hotcrm's hook-body harness (#15325).

Nothing here could have gone red, and that is structural rather than an oversight: an exports map is a packaging contract, and inside this monorepo nothing is sealed, because every in-repo consumer reaches any file through a relative import, a vitest alias or a paths entry.

This is the second, independent half of #15589. Option A (the consumer-specifier ledger, PR #15717) imports the missing knowledge from outside and reds when a named, cited consumer's specifier is sealed. ANNOUNCED's whole value is that it does not depend on that ledger being complete: it fires on the PR that narrows the surface, whether or not anyone has written the consumer down. Option A is untouched by this PR.

Why it discriminates — born-sealed is not a narrowing

A package born with a map seals nobody: there is no published predecessor whose consumers could have been deep-importing it. The card's census: 69 publishable packages declare a map from 51 introducing commits, but 56 were born with it and only 13 were retrofitted, from 7 commits. Gating all 51 would demand a consumer note 44 times from packages that had no consumers and no deep paths that stopped resolving — and every one of those 44 lands on a new-package PR, where the demand is least likely to be read and most likely to be discharged with boilerplate.

So the discriminator is a base-vs-HEAD comparison, the same shape check-adr-0087-registration --base already uses here, and deliberately not git log -S archaeology and not history beyond the merge base:

at the merge base at HEAD verdict
package.json absent any map born — passes
present but private/unnamed any map born — passes (npm never published it)
present, publishable, no usable map a map retrofit — gated
subpath declared in the map no longer resolves removal — gated
anything else (unchanged, or widened) passes

The private-at-base cell is the one addition to the ruling's four: a package going public for the first time has no published predecessor either, so gating it is the same false positive as gating a new one.

What satisfies the announcement

A minor-or-greater changeset on that package whose body names deep paths that genuinely stop resolving — checked against the head map rather than for a form of words. Writing @objectstack/cli/console when ./console is in the new map does not satisfy it, because that path still resolves and naming it tells a consumer nothing. @objectstack/cli/dist/utils/console.js does — that is the sentence #13662 needed and never got.

Two properties that came out of measurement rather than design:

  • Only the changesets this change introduces or edits can announce it. An earlier draft accepted any pending changeset, and a real ./console removal went green because an unrelated changeset already on main contained the string ./console in a sentence about a different release. The stock is ~1300 files of prose about these same packages, so "some changeset mentions this path" is satisfied by accident constantly.
  • The removal half reads the changeset body, not the whole file — the frontmatter names the package on every changeset and would otherwise let the bump line answer the note.

Absence is never a pass

This is the only history-dependent read in the gate, so it is the only place it can go vacuously green: every failure mode of the base read — no origin/main, no merge base, an unreadable .changeset/ — makes every package look born-sealed and the clause silently unanimous. Each is a refusal that names itself, and the read carries its own census control (baseReadControl) exactly as GATED carries EXPORTS_CENSUS_FLOOR.

Where the base comes from in CI: Lint & Repo Gates checks out at fetch-depth: 0 (.github/workflows/lint.yml, the Checkout repository step). That is precisely what makes actions/checkout fetch +refs/heads/*:refs/remotes/origin/*, so origin/main exists locally at all — the same provision pr-automation.yml documents for its own merge-base resolution. On a pull_request event the merge base with the merge-ref HEAD lands exactly on the PR's branch point, so the diff is this PR's own side and nothing else.

The HEAD side is read from the working tree, so the clause fires on an uncommitted retrofit too, before the commit rather than after the push. In CI the two are the same tree.

Evidence

Both self-test batteries are registered in SELF_TEST_BATTERIES with floors pinned at their measured counts (26 and 31), so a case that stops running names itself.

Every branch was ablated and observed to go red — restore proven byte-identical by git hash-object against the HEAD blob:

ablation result
retrofit branch deleted 2 cell-2 cases red (got "unchanged", expected "retrofit")
removal branch deleted 2 cell-3 cases red
absent-at-base no longer born cell-1 case red
anti-boilerplate check removed got "announced", expected "no-dead-path-named"
base-read control disarmed 3 control cases red

And end-to-end against real commits and a real git base read, on a throwaway branch:

leg exit
real ./console subpath removal 1 (gated)
real retrofit (base commit with no map, HEAD re-adds it) 1 (gated)
the same retrofit + a changeset naming a dead deep path 0 (announced)
the same retrofit + a note naming only a path that still resolves 1 (boilerplate refused)
the same retrofit + a patch bump 1
--base that does not resolve 1 (refusal, not a pass)
a base with no merge base (orphan commit) 1 (refusal, not a pass)

Nothing in the file is exported. check:entry-guard's second rule is that a scripts/** file exporting a binding can be imported for it, whereupon this gate's top-level dispatch — and its process.exit — runs inside the importer; the self-test is in the same module and needs no exports.

Scope

One file: scripts/check-published-files.mjs. No exports map is widened and the existing GATED invariant is untouched — sealing was correct in both incidents; the gap was visibility, not policy. Not a governed surface (Clause-② no, per the ruling).

skip-changeset: the diff publishes nothing from any released package — the root manifest is private, and no publishable package ships a scripts entry in its files whitelist (the gate itself forbids it).

Verification, at the final commit 79f3ef721

  • pnpm check:published-files — self-test and run both green (✓ ... 69 publishable package(s) ... none narrows its resolvable surface against the merge base without a minor changeset naming the deep paths that stop resolving).
  • Gate family derived from the tree, not from a hand-kept list: node scripts/pm/dispatch-gates.mjs --changed --commands --repo objectstack-ai/objectstack gives 31 families, and the --ran reconciliation reports 31 derived, 31 run, 0 NOT-MEASURED, 0 UNRUN.
  • Repo-wide eslint . --no-inline-config: 6071 files, 0 errors, 0 warnings, run under scripts/pm/os-verify-lock.sh (VERDICT command-exit 0, held the lock 76s). No narrowing claimed — the whole scan ran.

One process note worth recording, since it is the failure mode this repo names repeatedly: on the first sweep 7 of the 31 exited 3, which is PREREQUISITE NOT MET (a fresh worktree has no node_modules), not a finding. They were re-run after pnpm install and all 7 are green. An exit 3 read as a red — or worse, an exit 3 counted as "ran" — would have made this record false in both directions.


Generated by Claude Code

…to an already-published package

GATED asserts an `exports` map exists; it is silent on the TRANSITION, and the
transition is where the damage is. 17.3.0 added a map to `@objectstack/cli`,
which had published without one, and every deep path a consumer reached through
`dist/` stopped resolving at once -- found after publish, by the consumer,
twice (#13662, #15325).

ANNOUNCED is the second half of #15589, ruled B1 on #15715: a base-vs-HEAD
comparison, the same shape `check-adr-0087-registration --base` already uses,
and deliberately not `git log -S` archaeology. A `package.json` absent at the
merge base -- or present but private/unnamed there -- is born-sealed and passes,
because it has no published predecessor whose consumers could be deep-importing
it. Measured when #15715 was filed: 69 publishable packages declare a map from
51 introducing commits, but 56 were born with it and only 13 retrofitted, from
7 commits. Gating all 51 would demand a consumer note 44 times from packages
that had no consumers, every one of them on a new-package PR -- and a gate
answered by boilerplate 44 times out of 51 has stopped being read by the 7th
time it matters.

What the announcement must say is checked against the head map rather than for
a form of words: a `minor`-or-greater changeset on the package whose body names
a deep specifier the new map does NOT resolve. Naming a path that still
resolves tells a consumer nothing and does not satisfy it.

Absence is never a pass (#4690). Every way the base read can fail -- no
`origin/main`, no merge base, an unreadable `.changeset/` -- makes every package
look born-sealed and the clause vacuously green, so each is a refusal that names
itself, and the read carries its own census control (BASE_READ_FLOOR) exactly as
GATED carries EXPORTS_CENSUS_FLOOR. In CI the base comes from `Lint & Repo
Gates` checking out at `fetch-depth: 0`.

Claude-Session: https://claude.ai/code/session_01PU9zBGbH2s2ZtxSyu963M3
Co-authored-by: Claude <noreply@anthropic.com>
…it narrows

Measured while ablating the clause: a real `./console` subpath removal went
GREEN because an unrelated changeset already on main contained the string
`./console` in a sentence about a different release. The pending stock is ~1300
files of prose about these same packages, so "some changeset mentions this path"
is satisfied by accident constantly -- the boilerplate-answered gate this clause
exists to avoid, reached from the other direction.

The subject is now the diff: a changeset absent at the merge base, or one whose
text differs from its base copy. The removal half also reads the changeset BODY
rather than the whole file, since the frontmatter names the package on every
changeset and would otherwise let the bump line answer the note.

Nothing in the file is exported any more either. `check:entry-guard`'s second
rule is that a `scripts/**` file exporting a binding can be imported for it,
whereupon this gate's top-level dispatch -- and its `process.exit` -- runs inside
the importer; the self-test is in the same module and needs no exports.

Claude-Session: https://claude.ai/code/session_01PU9zBGbH2s2ZtxSyu963M3
Co-authored-by: Claude <noreply@anthropic.com>
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 5, 2026
@github-actions github-actions Bot added the size/l label Sep 5, 2026
@os-steve
os-steve marked this pull request as ready for review September 6, 2026 00:12
@os-steve
os-steve enabled auto-merge September 6, 2026 00:12
@os-steve
os-steve added this pull request to the merge queue Sep 6, 2026
Merged via the queue into main with commit 64011dd Sep 6, 2026
36 checks passed
@os-steve
os-steve deleted the claude/issue-15715-exports-map-retrofit-gate branch September 6, 2026 01:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

2 participants