You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Decide option B of #15589: should introducing an exports map require a minor changeset with a consumer note? Measured, B as worded fires on 51 commits to catch a class that lives in 7 #15715
Filed out of #15589. That card proposed three options; option A (a consumer-specifier ledger, held against the exports maps) is implemented on branch claude/issue-15589-consumer-specifier-ledger. This is option B, deliberately split off rather than landed with it. Unassigned; it needs a maintainer decision, not an implementation.
B. check:published-files GATED, one step further. That gate already asserts every publishable package declares an exports map. It could additionally refuse a PR that introduces a map (or removes a subpath from one) unless the changeset for that package is at least minor and carries a BREAKING-style consumer note naming the deep paths that stop resolving.
What B would have refused — measured
Measured on a4421eb89, over every package.json this repo publishes:
reading
count
publishable packages that declare an exports map today
69
distinct commits that introduced one
51
packages born with the map (it is present in the commit that created the package.json)
56
packages retrofitted with a map (added to an already-existing package.json)
13
distinct commits behind those 13 retrofits
7
Method, so the numbers are re-derivable: for each publishable package.json declaring exports, the introducing commit is the oldest from git log -S'"exports"' -- PATH; the package counts as born-sealed when that commit equals the oldest --diff-filter=A commit for the same path. Non-shallow checkout (git rev-parse --is-shallow-repository = false).
Why that split IS the decision
A package born with an exports map seals nobody — there is no published predecessor whose consumers could be deep-importing it. Every incident this class has produced came from the 13 retrofits, and in fact from one of the 7 commits: 6a571d3f7 (#13123), the seal that broke cloud's objectos-runtime (#13662) and hotcrm's hook-body harness (#15325).
So B as worded fires on 51 commits to catch a class that lives in 7. For 44 of the 51 it would demand a minor bump and a BREAKING-style consumer note naming the deep paths that stop resolving — for a package that had no consumers and no deep paths that stopped resolving, because it had never been published without the map. That is roughly 7:1 against, and the false positives are not evenly spread: they land on new-package PRs, which is exactly where a "declare who this breaks" demand is least likely to be read closely and most likely to be discharged with boilerplate. A gate answered by boilerplate 44 times out of 51 stops being read by the 7th time it matters.
The 7 retrofit commits are also not uniformly the dangerous shape. 818e02700 is titled fix(cli): print init's "Created files" summary after install, not before — an exports change riding along on a commit about something else. That one is an argument for B: a discriminating gate would have made that rider announce itself.
Options
B1 — take B with a born-vs-retrofit discriminator. Gate only when the map is added to a package.json that already existed in the merge base (and on subpath removal from an existing map). Cost: the discriminator is a second git query inside the gate, and it is history-dependent, which no other check:published-files clause is. Catches all 7, waves through all 44.
B2 — take B as worded. Mechanical, no history, one rule. Cost: the 7:1 false-positive ratio above, paid on every new-package PR forever.
B4 — decline B. Option A already reds on the PR that seals a ledgered specifier, naming the consumer. B's distinct value is only over consumers nobody has ledgered yet.
Recommendation
B1, and if the history dependence is judged too costly for check:published-files, B3 over B2 — a rule that is silent on the ambiguous half beats one that cries wolf 44 times. B2 as literally worded should not be taken.
Note what A does and does not already cover, since it changes B's value: A reds on the PR that seals a specifier a named, cited out-of-repo consumer imports. It is silent about a consumer nobody has written down. B is the half that does not depend on the ledger being complete, which is why declining it (B4) is a real position and not merely inaction.
Boundary
⛔ Not a request to widen any exports map, and not a request to weaken check:published-files's existing GATED invariant. Sealing was correct in both incidents; the gap is visibility, not policy.
⛔ This card is not blocked on option A, and option A is not blocked on it.
Filed out of #15589. That card proposed three options; option A (a consumer-specifier ledger, held against the
exportsmaps) is implemented on branchclaude/issue-15589-consumer-specifier-ledger. This is option B, deliberately split off rather than landed with it. Unassigned; it needs a maintainer decision, not an implementation.The proposal, as #15589 words it
What B would have refused — measured
Measured on
a4421eb89, over everypackage.jsonthis repo publishes:exportsmap todaypackage.json)package.json)Method, so the numbers are re-derivable: for each publishable
package.jsondeclaringexports, the introducing commit is the oldest fromgit log -S'"exports"' -- PATH; the package counts as born-sealed when that commit equals the oldest--diff-filter=Acommit for the same path. Non-shallow checkout (git rev-parse --is-shallow-repository= false).Why that split IS the decision
A package born with an
exportsmap seals nobody — there is no published predecessor whose consumers could be deep-importing it. Every incident this class has produced came from the 13 retrofits, and in fact from one of the 7 commits:6a571d3f7(#13123), the seal that broke cloud'sobjectos-runtime(#13662) and hotcrm's hook-body harness (#15325).So B as worded fires on 51 commits to catch a class that lives in 7. For 44 of the 51 it would demand a
minorbump and aBREAKING-style consumer note naming the deep paths that stop resolving — for a package that had no consumers and no deep paths that stopped resolving, because it had never been published without the map. That is roughly 7:1 against, and the false positives are not evenly spread: they land on new-package PRs, which is exactly where a "declare who this breaks" demand is least likely to be read closely and most likely to be discharged with boilerplate. A gate answered by boilerplate 44 times out of 51 stops being read by the 7th time it matters.The 7 retrofit commits are also not uniformly the dangerous shape.
818e02700is titledfix(cli): print init's "Created files" summary after install, not before— anexportschange riding along on a commit about something else. That one is an argument for B: a discriminating gate would have made that rider announce itself.Options
package.jsonthat already existed in the merge base (and on subpath removal from an existing map). Cost: the discriminator is a second git query inside the gate, and it is history-dependent, which no othercheck:published-filesclause is. Catches all 7, waves through all 44.exportsmaps, and make "a published package declares one" a gate #13123, which introduced rather than removed.Recommendation
B1, and if the history dependence is judged too costly for
check:published-files, B3 over B2 — a rule that is silent on the ambiguous half beats one that cries wolf 44 times. B2 as literally worded should not be taken.Note what A does and does not already cover, since it changes B's value: A reds on the PR that seals a specifier a named, cited out-of-repo consumer imports. It is silent about a consumer nobody has written down. B is the half that does not depend on the ledger being complete, which is why declining it (B4) is a real position and not merely inaction.
Boundary
⛔ Not a request to widen any
exportsmap, and not a request to weakencheck:published-files's existing GATED invariant. Sealing was correct in both incidents; the gap is visibility, not policy.⛔ This card is not blocked on option A, and option A is not blocked on it.
Generated by Claude Code