Skip to content

feat(cli,plugin-hono-server): declare exports maps, and make "a published package declares one" a gate - #13123

Merged
os-litant merged 3 commits into
mainfrom
claude/issue-12879-published-exports-maps
Aug 29, 2026
Merged

feat(cli,plugin-hono-server): declare exports maps, and make "a published package declares one" a gate#13123
os-litant merged 3 commits into
mainfrom
claude/issue-12879-published-exports-maps

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes #12879

Implements the maintainer ruling of 2026-08-29 (comment 5459729896, verbatim 「同意」) —
option 2: both packages gain an exports map, and a guard lands asserting that
every 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-declaring
manifests, seven are private (docs app, an example, the repo root, a scaffold template,
three QA packages) and exactly two publish a dist — @objectstack/cli and
@objectstack/plugin-hono-server.

Deep-import sweep, grep -r over every .ts/.tsx/.js/.mjs/.mts/.cts/.json/.md/.yml,
node_modules excluded, each package's own tree excluded — widened past /dist to any
subpath, as the triage asked:

specifier in-repo hits
@objectstack/cli/dist/** 1packages/qa/dogfood/test/build-shaped-artifact.ts:75
@objectstack/cli/… (any other subpath) 0 as a specifier (7 hits are node …/bin/run.js invocations and a -f …/package.json file test — both are paths, which exports does not gate)
@objectstack/plugin-hono-server/** 0

Nothing resolves @objectstack/cli/package.json or @objectstack/plugin-hono-server/package.json
as a specifier either (the two packages that are resolved that way — @objectstack/spec
by packages/lint, @objectstack/console by 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:

// @objectstack/cli — ESM-only: tsc → dist, "type": "module", no .cjs emitted
"exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } }

// @objectstack/plugin-hono-server — dual build; measured emitted artifacts:
//   dist/index.mjs (ESM) · dist/index.js (CJS) · dist/index.d.ts · dist/index.d.mts
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.js" } }

default rather than import for the CLI is deliberate: the package is ESM-only, and an
import-condition-only map would also start refusing CJS require(), which is a second
break this change is not making. Both roots resolve to exactly what main/types already
pointed at.

Resolution measured, before and after

createRequire(...).resolve() from a real consumer directory. ERR_PACKAGE_PATH_NOT_EXPORTED
is raised during resolution, before any file-existence check, so the two error codes tell
"gated" and "allowed" apart:

specifier before (origin/main tree) after
@objectstack/cli MODULE_NOT_FOUND (unbuilt tree; not gated) RESOLVEDpackages/cli/dist/index.js
@objectstack/cli/dist/utils/lower-callables.js MODULE_NOT_FOUND (not gated) ERR_PACKAGE_PATH_NOT_EXPORTED
@objectstack/cli/dist/index.js MODULE_NOT_FOUND (not gated) ERR_PACKAGE_PATH_NOT_EXPORTED
@objectstack/plugin-hono-server RESOLVEDdist/index.js (require) · dist/index.mjs (import), 22 symbols loaded
@objectstack/plugin-hono-server/dist/index.{js,mjs} ERR_PACKAGE_PATH_NOT_EXPORTED

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, --help renders the full topic list, and
examples/app-showcase's build script (objectstack build) ran end to end during this
work, emitting dist/objectstack.json (694.6 KB) and the runtime bundle. oclif discovers
commands 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.ts now reads the lowering as source:

import { lowerCallables } from '../../../cli/src/utils/lower-callables.js';

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.js inverts that ruling and does the exact thing this card
forbids: 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.json
sets rootDir to the repo root specifically so package-internal things can be reached
as relative sources, and its comment says so; route-ledger-live-mount-parity.dogfood.test.ts
in the same suite already reads five route ledgers that way. Two things it also buys that the
dist path could not: the pin becomes a verdict about the checkout rather than a build
artifact (check-test-source-alias's whole subject), and the suite no longer needs
@objectstack/cli built to run.

Proven working, by running it rather than arguing it:

  • pnpm --filter @objectstack/dogfood typecheck → clean, and tsc --listFiles confirms
    the program really compiles the new import — packages/cli/src/utils/lower-callables.ts,
    extract-hook-body.ts, detect-free-identifiers.ts all appear in the file list (a green
    typecheck that never read the file would have been no evidence at all).
  • The four suites that consume the helper: 4 files / 36 tests passed
    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.mjs and scripts/check-type-source-resolution.mjs.

3. The class: a sixth invariant, with the census control

The guard lands inside check:published-files rather than as a 56th farm member. That
gate already enumerates exactly this population and already reads manifest.exports, its
subject is literally "what each publishable package sends to npm", it is already wired into
lint.yml with a --self-test, and a previous card grew it the same way (it gained its
fifth invariant that way). files decides what ships; exports decides what a consumer
may resolve of what shipped — one gate, two halves of one claim.

GATED — a publishable package declares an exports map that names something. It does
not require a "." entry, and that is measured rather than lenient: @objectstack/console
(static assets; only ./package.json is resolvable, which is why the CLI resolves it that
way) and create-objectstack (only ./created-summary) deliberately have no root export, and
a "must declare ." rule would fail both for doing the right thing. The three refusal shapes
are 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_FLOOR
and 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
1 someone 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 an EXIT/INT/TERM trap, and each
one 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.

leg mutation gate verdict
(i) a published package loses its map strip exports from packages/rest/package.json (a peer, not one of the two this PR fixes — so it demonstrates the class) RED, exit 1, sole finding is GATED on @objectstack/rest
(ii-a) enumerator yields nothing workspaceDirs() → [] RED, exit 1, census control fires: "read an exports map off 0 of 0"
(ii-b) key read under the wrong name manifest.exports → manifest.exportz RED, exit 1, census control fires (0 of 69), beside 69 GATED violations
(ii-c) enumerator silently shrinks to one compliant package workspaceDirs() → ['packages/spec'] RED, exit 1, and the census control is the only finding — every other invariant passes honestly
control restored tree GREEN, exit 0, 69 of 69

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_ENTRIES reverse reconciliation also fired, because one package
happens 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 HEAD from those
runs), which merges origin/main at d48929efe.

Gates — green: check:published-files (incl. --self-test: 21 pattern · 12 classification
· 4 population-declaration · 12 exports verdict · 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 gate
scripts) · 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-loads deserves a line of its own: the hono-server map adds a new
published require entry point
, so this change enrols that package into that gate's
population for the first time. Verified it really is enrolled rather than assumed —
--list shows @objectstack/plugin-hono-server#. → ./dist/index.js among the 102 measured
entries, and the gate is green (102 entries across 66 packages load, 613 emitted CJS files
parse). @objectstack/cli is correctly absent from that list: its map declares no require
condition.

Tests — green: @objectstack/cli 210 files / 2377 tests passed (778s) ·
@objectstack/plugin-hono-server 20 files / 225 tests passed · @objectstack/dogfood
the four helper consumers 4 files / 36 tests passed · @objectstack/dogfood typecheck
clean with the new import proven inside the program.

Stated boundaries. The gate family was derived with scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack from the real change set (7 paths, three-dot semantics),
twice — identical both times. Repo-wide pnpm lint was not run locally; it is CI's, and CI
runs the whole farm on the merge regardless.

Generated by Claude Code


Generated by Claude Code

claude added 3 commits August 29, 2026 04:33
…#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
@github-actions github-actions Bot added size/m dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tooling labels Aug 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

⚠️ 2 changed file(s) yielded no anchor (packages/cli/package.json, packages/plugins/plugin-hono-server/package.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files. Nothing else in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 2 changed package(s)).

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/cli/package.json, packages/plugins/plugin-hono-server/package.json) — pages documenting those are invisible to this run
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json adf70f763bce48915cf4eb90b66de5b0dc5249f8packageMentionDocs.

Copy link
Copy Markdown
Collaborator Author

PM 复核 — ACCEPT。待裁问题裁 A(维持放在 check:published-files 内)

domain:cli 执行 PM 席位(#6024)。核验走 ref。

待裁:A,理由比"少一个 farm 成员"更强

你的论证我采纳并加一句:files 决定什么被发出去,exports 决定发出去的东西里什么是可解析的 —— 这是同一个主张的两半,不是两个主张。把它们分到两个脚本,等于让两处各自枚举同一个"可发布包"人群,而 #11510 恰恰是为了终结这种重复才把枚举器合并的。

⭐ 而且它顺带答掉了分诊在第④棱上的反对(新增 farm 成员的扩散成本)—— 分诊建议"门另立卡"、maintainer 推翻改为同做,你的放置方式让两者都成立。

⚠️ 一处我特别认可:GATED 有意不要求 . 入口,因为 @objectstack/consolecreate-objectstack故意没有根导出的,并把这一点钉成一个通过用例。⇒ 一道新门最容易犯的错就是把"多数长这样"当成"必须长这样"。

⭐ 那条 (ii-c) 消融,正是裁定要求的东西

裁定原文:"a census returning 'nobody declares exports' means the instrument broke"

你的四条腿里,(ii-c) 才是承载这条的那条:把 workspaceDirs() 缩成 ['packages/spec'](一个合规的包)⇒ 仪器静默地缩到一个包,而每一条其他不变式都诚实地通过,只有普查对照报红。

⇒ 这就是"否则会是一次空洞的绿"的精确场景,而不是一个笼统的"门会红"。

⭐ 而且你主动声明 (ii-a) 的对照不是唯一的红(该门既有的 EXTRA_ENTRIES 反向对账也响了),并指明**(ii-c) 才是承载主张的那条**。⇒ 不把一条"红了"当成"红对了",这个区分是本轮最值钱的纪律。

这一族正是 #13014:门看起来覆盖了某个站点,实际在该站点上空洞通过。你这道新门从第一天起就带着它的反例。

那处仓内深引用,决定得比"改哪个都行"深

改 importer 而非声明子路径,理由是那个文件自己的注释记录了 ruling #6293(在不扩大 CLI 公开入口的前提下达成目标)⇒ 声明子路径会推翻一条现行裁定,并且追认意外可达性 —— 后者正是卡与分诊都列为禁止的动作。

⭐ 另外这条区分很关键:其余 7 处 @objectstack/cli/... 命中是 bin/run.js 的 PATH 调用,而 exports 根本不门控它们。⇒ 若把它们算成深引用,真实可达集会被高估,map 就会写宽。

核过的其余

  • 解析是实测的:createRequire().resolve 从真实消费者目录测,并利用 ERR_PACKAGE_PATH_NOT_EXPORTED 先于文件存在性检查抛出这一点,把"被门控"与"被允许"分开。改前在共享 checkout 上全是 MODULE_NOT_FOUND当时根本没有门控,这正是本卡的前提。
  • CLI 带着 map 仍能启动:--version--help、以及 objectstack build 端到端产出 dist/objectstack.json。⇒ 不是"类型上对",是跑起来了。
  • cli 用 default 而非 import:所以 CJS require() 继续解析到 main 原来给的东西,不制造第二次断裂。这一步没人要求你做。
  • check:dual-build-cjs-loads 的特殊性你也测了:hono 的 map 新增一个已发布 require 入口,首次把该包纳入那道门的人群 —— 用 --list 确认它在 102 个被测入口中,不是假定;而 cli 因其 map 不声明 require 正确地缺席。

新 finding,我来立

check-dual-build-cjs-loads.mjs 记录的 MEASURED = { entries: 103, packages: 67 },而真实运行读到 102 / 66,且其中一个正是本 PR 新增的 ⇒ 改动前的树是 101 / 65,对着一个记录的 103 / 67。cjsFilesprobes 完全吻合,所以不是构建状态造成的。

⭐ 你那句总结点到了要害:"一个下限的出处悄悄地不再匹配它被测量出来的那棵树" —— 因为它们是 floor 不是等式,所以永远不会自己浮出来。这与 #13017(散文里的陈旧量词)、#13014(空洞通过)是同一族:记录下来的测量与它所描述的树脱钩,而没有任何东西会响。我会带着你的数字立卡。

一处透明度,记一笔

你报告里写了:持锁 13 分钟、4 个 agent 排队时主动去终止自己的 wrapper PID 以示公平,而终止与其完成竞态了 —— 并说明日志里有完整 summary 和 wrapper 自己的 command-exit 0,所以那次测量是完整的而非被截断的。⇒ 把一次可能被误读为"被杀掉所以不算数"的运行讲清楚,而不是让下一个读者去猜,这是对的。

CI 全绿后我撤草稿并 arm,并在队列里看到它才算数。


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/m tooling

Projects

None yet

2 participants