feat(devx): check:published-files gates an exports map retrofitted onto an already-published package (#15715) - #16082
Merged
Conversation
…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>
os-steve
marked this pull request as ready for review
September 6, 2026 00:12
os-steve
enabled auto-merge
September 6, 2026 00:12
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-filesgains a seventh invariant, ANNOUNCED.The gap this closes
GATEDasserts anexportsmap 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 throughdist/stopped resolving at once. Two out-of-repo repos found out after publish, during an upgrade — cloud'sobjectos-runtime(#13662) and hotcrm's hook-body harness (#15325).Nothing here could have gone red, and that is structural rather than an oversight: an
exportsmap 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 apathsentry.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 --basealready uses here, and deliberately notgit log -Sarchaeology and not history beyond the merge base:package.jsonabsentprivate/unnamedThe
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/consolewhen./consoleis 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.jsdoes — that is the sentence #13662 needed and never got.Two properties that came out of measurement rather than design:
./consoleremoval went green because an unrelated changeset already onmaincontained the string./consolein 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.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 asGATEDcarriesEXPORTS_CENSUS_FLOOR.Where the base comes from in CI:
Lint & Repo Gateschecks out atfetch-depth: 0(.github/workflows/lint.yml, theCheckout repositorystep). That is precisely what makesactions/checkoutfetch+refs/heads/*:refs/remotes/origin/*, soorigin/mainexists locally at all — the same provisionpr-automation.ymldocuments for its own merge-base resolution. On apull_requestevent 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_BATTERIESwith 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-objectagainst the HEAD blob:got "unchanged", expected "retrofit")got "announced", expected "no-dead-path-named"And end-to-end against real commits and a real git base read, on a throwaway branch:
./consolesubpath removalpatchbump--basethat does not resolveNothing in the file is
exported.check:entry-guard's second rule is that ascripts/**file exporting a binding can be imported for it, whereupon this gate's top-level dispatch — and itsprocess.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. Noexportsmap is widened and the existingGATEDinvariant 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 isprivate, and no publishable package ships ascriptsentry in itsfileswhitelist (the gate itself forbids it).Verification, at the final commit
79f3ef721pnpm 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).node scripts/pm/dispatch-gates.mjs --changed --commands --repo objectstack-ai/objectstackgives 31 families, and the--ranreconciliation reports 31 derived, 31 run, 0 NOT-MEASURED, 0 UNRUN.eslint . --no-inline-config: 6071 files, 0 errors, 0 warnings, run underscripts/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 nonode_modules), not a finding. They were re-run afterpnpm installand 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