Uh oh!
There was an error while loading. Please reload this page.
feat(cli,plugin-hono-server): declare exports maps, and make "a published package declares one" a gate - #13123
Conversation
…#12879) Both packages declared `main` + `files` and no `exports`, so every module under `dist/` was importable from outside — the only two of 69 publishable packages in that shape. Each now declares exactly the entry it means to offer. The one in-repo deep import (dogfood's build-shaped-artifact helper) reads the CLI's `lowerCallables` as source by relative path rather than having its subpath ratified as public surface, and `check:published-files` gains a sixth invariant (GATED) with the census control that makes a broken reading fail loudly instead of passing vacuously. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
…12879) `check:type-source-resolution` keeps the same per-package ledger as `check:test-source-alias`, one level up (types rather than values). The dogfood helper no longer resolves `@objectstack/cli` through `dist/`, so its row there was stale in exactly the same way and shrinks in the same direction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
…blished-exports-maps
📓 Docs Drift Check
What this run could not see
Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
os-litant
commented
Aug 29, 2026
PM 复核 — ACCEPT。待裁问题裁 A(维持放在 |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#12879
Implements the maintainer ruling of 2026-08-29 (comment 5459729896, verbatim 「同意」) —
option 2: both packages gain an
exportsmap, and a guard lands asserting thatevery published package declares one. The triage's own preference (fix the two instances,
file the guard separately) is superseded by that ruling.
1. The reachable set, measured BEFORE the maps were written
Re-ran the card's census with an independent scan (80 tracked manifests, 71 declaring
exports, 9 not) and it reproduces the triage reading exactly: of the nine non-declaringmanifests, seven are private (docs app, an example, the repo root, a scaffold template,
three QA packages) and exactly two publish a dist —
@objectstack/cliand@objectstack/plugin-hono-server.Deep-import sweep,
grep -rover every.ts/.tsx/.js/.mjs/.mts/.cts/.json/.md/.yml,node_modules excluded, each package's own tree excluded — widened past
/distto anysubpath, as the triage asked:
@objectstack/cli/dist/**packages/qa/dogfood/test/build-shaped-artifact.ts:75@objectstack/cli/…(any other subpath)node …/bin/run.jsinvocations and a-f …/package.jsonfile test — both are paths, whichexportsdoes not gate)@objectstack/plugin-hono-server/**Nothing resolves
@objectstack/cli/package.jsonor@objectstack/plugin-hono-server/package.jsonas a specifier either (the two packages that are resolved that way —
@objectstack/specby
packages/lint,@objectstack/consoleby the CLI itself — already declare./package.json,which is why theirs is in their map and neither of these needs one).
So the intended entry set is: the root, for both packages, and nothing else. The maps
were written from that, not copied from a peer:
defaultrather thanimportfor the CLI is deliberate: the package is ESM-only, and animport-condition-only map would also start refusing CJSrequire(), which is a secondbreak this change is not making. Both roots resolve to exactly what
main/typesalreadypointed at.
Resolution measured, before and after
createRequire(...).resolve()from a real consumer directory.ERR_PACKAGE_PATH_NOT_EXPORTEDis raised during resolution, before any file-existence check, so the two error codes tell
"gated" and "allowed" apart:
@objectstack/clipackages/cli/dist/index.js@objectstack/cli/dist/utils/lower-callables.js@objectstack/cli/dist/index.js@objectstack/plugin-hono-serverdist/index.js(require) ·dist/index.mjs(import), 22 symbols loaded@objectstack/plugin-hono-server/dist/index.{js,mjs}The CLI still boots with the map in place —
node bin/run.js --version→@objectstack/cli/17.2.0 linux-x64 node-v22.22.2,--helprenders the full topic list, andexamples/app-showcase's build script (objectstack build) ran end to end during thiswork, emitting
dist/objectstack.json(694.6 KB) and the runtime bundle. oclif discoverscommands and hooks by path under the package root, so the map does not reach it.
2. The one in-repo deep import — decided explicitly
Chosen: change the importer. Not: declare the subpath.
packages/qa/dogfood/test/build-shaped-artifact.tsnow reads the lowering as source:Why not the subpath. That file's own comment recorded the standing ruling it was written
under — #6293: reach the goal without growing
@objectstack/cli's public entry. Declaring./dist/utils/lower-callables.jsinverts that ruling and does the exact thing this cardforbids: it ratifies an accidental reachability as contract, after which every internal
refactor of the CLI's compiler utils owes a minor bump.
Why this shape is the house one, not an improvisation:
packages/qa/dogfood/tsconfig.jsonsets
rootDirto the repo root specifically so package-internal things can be reachedas relative sources, and its comment says so;
route-ledger-live-mount-parity.dogfood.test.tsin the same suite already reads five route ledgers that way. Two things it also buys that the
distpath could not: the pin becomes a verdict about the checkout rather than a buildartifact (
check-test-source-alias's whole subject), and the suite no longer needs@objectstack/clibuilt to run.Proven working, by running it rather than arguing it:
pnpm --filter @objectstack/dogfood typecheck→ clean, andtsc --listFilesconfirmsthe program really compiles the new import —
packages/cli/src/utils/lower-callables.ts,extract-hook-body.ts,detect-free-identifiers.tsall appear in the file list (a greentypecheck that never read the file would have been no evidence at all).
showcase-declarative-endpoints,meta-published-and-state-routes,showcase-object-extension-meta-read,showcase-object-extension-scalar-divergence.Two sibling registries then had a stale row for
@objectstack/dogfood → @objectstack/cli,because the dep is no longer dist-resolved. Both shrink (both are shrink-only, and both are
set-equality reconciled, so leaving them would be red):
scripts/check-test-source-alias.mjsandscripts/check-type-source-resolution.mjs.3. The class: a sixth invariant, with the census control
The guard lands inside
check:published-filesrather than as a 56th farm member. Thatgate already enumerates exactly this population and already reads
manifest.exports, itssubject is literally "what each publishable package sends to npm", it is already wired into
lint.ymlwith a--self-test, and a previous card grew it the same way (it gained itsfifth invariant that way).
filesdecides what ships;exportsdecides what a consumermay resolve of what shipped — one gate, two halves of one claim.
GATED — a publishable package declares an
exportsmap that names something. It doesnot require a
"."entry, and that is measured rather than lenient:@objectstack/console(static assets; only
./package.jsonis resolvable, which is why the CLI resolves it thatway) and
create-objectstack(only./created-summary) deliberately have no root export, anda "must declare
." rule would fail both for doing the right thing. The three refusal shapesare kept apart — absent (everything under
dist/resolves) versus empty/null/non-map(nothing resolves, the package's own entry included) — because they are opposite defects and
one merged message would send an author of the second kind hunting the first kind's fix.
The census control, as the ruling requires. This invariant reads a positive signal off
every manifest, so its silent-failure mode is a reading that returns nothing: enumerate no
members, read the key under a wrong name, drop manifests in a parse — each makes "nobody
violates GATED" true, and green. So the run asserts the count against
EXPORTS_CENSUS_FLOORand fails in its own words ("a census returning nobody declares exports means THIS
INSTRUMENT BROKE"), distinct from any package's violation. The floor is itself controlled by
the self-test, in both directions against the live tree: never above the live publishable
count (a floor no tree can reach is a red gate about nothing) and never below half of it (the
1someone reaches for to quiet a red run would wave through a census that found one package).Current reading: 69 of 69 publishable packages declare a map; floor 50.
Ablations — every leg mutated, proven on disk, restored, proven restored
Restores are
git checkout HEAD -- "$ABS"(an absolute path) under anEXIT/INT/TERMtrap, and eachone is proven by blob-hash equality against the HEAD blob plus an empty
git diff HEAD—never by an exit code. Mutations are proven by grep counts of the injected and the removed
text, never by the editor's exit code.
exportsfrompackages/rest/package.json(a peer, not one of the two this PR fixes — so it demonstrates the class)@objectstack/restworkspaceDirs() → []exportsmap off 0 of 0"manifest.exports → manifest.exportzworkspaceDirs() → ['packages/spec']Leg (ii-c) is the one that matters: it is the shape where the instrument breaks and nothing
else notices, and there the control is the entire difference between red and a vacuous green.
Stated for the record rather than glossed: in (ii-a) the control was not the sole red — the
gate's pre-existing
EXTRA_ENTRIESreverse reconciliation also fired, because one packagehappens to register extras; in a tree with no such registration it would not have.
4.⚠️ The risk this PR cannot measure
Whether any published, out-of-repo consumer deep-imports these two packages is not
measurable from inside this repo. The in-repo count is one, and it is fixed here — that is
not the same as "no impact". Any such consumer breaks at its import, immediately and loudly,
with
ERR_PACKAGE_PATH_NOT_EXPORTED. The changeset carries this as the release's known risk,with the migration (import from the root; if the symbol is not there, it was never an offered
surface — file a card naming the use case rather than adding a map entry that ratifies it).
5. Verification
All commands below were run at HEAD
c88259f2f(git rev-parse --short HEADfrom thoseruns), which merges
origin/mainatd48929efe.Gates — green:
check:published-files(incl.--self-test: 21 pattern · 12 classification· 4 population-declaration · 12
exportsverdict · 3 census-floor cases) ·check:test-source-alias·check:type-source-resolution·check:nul-bytes·check:changeset-gate-self-tests·check:empty-changeset·check:adr-0087-registration·check:changeset-no-major·check:dual-build-cjs-loads·check:pm-dispatch-gates·scripts/pm/bare-root-worklist.mjs --self-test(both convention-triggered by editing gatescripts) ·
check:watch-hint-literal·check:parse-guard·check:entry-guard·check:cross-package-test-inputs·check:undeclared-dep-imports·check:keyed-text-bounds·check:comment-mask-adoption·check:agent-test-spelling·check:pnpm-filter-targets·check:bash32-floor.check:dual-build-cjs-loadsdeserves a line of its own: the hono-server map adds a newpublished
requireentry point, so this change enrols that package into that gate'spopulation for the first time. Verified it really is enrolled rather than assumed —
--listshows@objectstack/plugin-hono-server#. → ./dist/index.jsamong the 102 measuredentries, and the gate is green (102 entries across 66 packages load, 613 emitted CJS files
parse).
@objectstack/cliis correctly absent from that list: its map declares norequirecondition.
Tests — green:
@objectstack/cli210 files / 2377 tests passed (778s) ·@objectstack/plugin-hono-server20 files / 225 tests passed ·@objectstack/dogfoodthe four helper consumers 4 files / 36 tests passed ·
@objectstack/dogfood typecheckclean with the new import proven inside the program.
Stated boundaries. The gate family was derived with
scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackfrom the real change set (7 paths, three-dot semantics),twice — identical both times. Repo-wide
pnpm lintwas not run locally; it is CI's, and CIruns the whole farm on the merge regardless.
Generated by Claude Code
Generated by Claude Code