Skip to content

fix(metadata-core, service-cluster): make the ./testing subpaths ESM-only - #13001

Merged
os-litant merged 1 commit into
mainfrom
claude/issue-12985-testing-exports-esm-only
Aug 28, 2026
Merged

fix(metadata-core, service-cluster): make the ./testing subpaths ESM-only#13001
os-litant merged 1 commit into
mainfrom
claude/issue-12985-testing-exports-esm-only

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes#12985

Both @objectstack/metadata-core#./testing and @objectstack/service-cluster#./testing
advertised a require condition that could never load. Both subpaths re-export
vitest, whose CJS entry is a single throw by design, so the bytes parse and the
load dies inside vitest itself. This drops the condition and deletes the two ledger
entries #12984 left behind for it, in the same change — the ledger is SHRINK-ONLY.

What changed (4 files)

1. The two manifests — the ./testing subpaths become ESM-only:

// FROM (identical in both packages)"./testing": {
"types": "./dist/testing.d.ts",
"import": "./dist/testing.js",
"require": "./dist/testing.cjs"
}
// TO"./testing": {
"types": "./dist/testing.d.ts",
"import": "./dist/testing.js"
}

The shape is copied, not invented. It is the repo's one existing ESM-only subpath:
create-objectstack#./created-summary spells exactly { "types", "import" } with no
require. Enumerated mechanically over all 74 manifests under packages/ — that
subpath is the only precedent, and ./testing on these two packages were the only
testing-ish subpaths in the tree.

2. scripts/dual-build-cjs-loads.baseline.json — the two entries deleted by name
(@objectstack/metadata-core#./testing, @objectstack/service-cluster#./testing), the
$comment untouched. The edit was done as a JSON round-trip whose fidelity was proved
first: re-serialising the unmodified file reproduced it byte-for-byte, so the whole
diff is the two deletions. That the ledger now reads "entries": {} is a fact about
this tree, not an acceptance criterion — the $comment is explicit that its steady
state is not empty, and no other entry was touched because there was no other entry.

3. A minor changeset for both packages carrying the FROM → TO and the one-line fix
for anyone who did spell the call as require.

The two things the card asked to measure, not assume

Is dist/testing.cjs still needed? — Decision: keep emitting it. Not deleted.

Both tsup.config.ts files declare format: ['esm', 'cjs'] for both entries
(src/index.ts, src/testing.ts), so dist/testing.cjs keeps being emitted. After this
change nothing in either manifest points at it, and neither package exports ./dist/*,
so it is unreachable through the package. It is therefore dead weight — but deleting it
is a build-config change, not an exports change:

  • tsup takes one format per config, so per-entry formats mean splitting each config
    into an array of two. Both configs also set clean: trueandsplitting: true;
    a second config would wipe the first's output and break chunk sharing between
    index and testing. That is a real risk with its own verification surface, and it
    is not folded into an exports repair.
  • Keeping it is not inert: check:dual-build-cjs-loads parses every emitted CommonJS
    file of any package that has a require entry point, and both packages still have one
    at ., so dist/testing.cjs stays under the PARSES sweep. Deleting it would remove
    that coverage.
  • No gate objects to it. check:published-files asks that the files whitelist covers
    every declared entry point (removal can never violate that) and that it admits no test
    or build tooling; dist/ is unchanged either way. Verdict on this branch:
    ✓ check:published-files — 69 publishable package(s) ... declare a files whitelist that covers every entry point.

Are there real require() consumers of these subpaths? — Measured: none.

grep -r over the working tree (a filesystem walk, so untracked files are included;
node_modules, .git, .turbo pruned) — deliberately notgit grep, which reads
tracked files only:

scanhits
@objectstack/metadata-core/testing11 — 8 import statements, 3 in CHANGELOG/doc prose
@objectstack/service-cluster/testing2 — 1 import, 1 docstring
a require( call naming metadata-core or service-cluster3 — all three are prose about this defect (this gate's own header, and #12984's changeset)
testing.cjs2 — the two manifest lines this PR removes

Zero require() call sites. Positive control on the same command shape, with a term in
no substring relation to the tested ones — a require( call naming any @objectstack/
specifier — returns 22 hits, so the scan is live rather than silently empty.

The in-repo consumers all reach these subpaths through import:
@objectstack/metadata-fs, @objectstack/metadata-protocol, @objectstack/rest,
@objectstack/runtime, @objectstack/service-cluster-redis.

Independently of any scan, a working CJS consumer cannot exist: the load fails inside
vitest for every caller and every code path, which is the whole reason the ledger entry
was written.

Pinned sibling (AGENTS.md §Workflow 4). objectui at the pinned .objectui-sha
190fbd01d0615e2e168faf9e08b8ad7844bc039d: 0 hits for either subpath, and 0 hits for
@objectstack/(metadata-core|service-cluster) at all. Positive control on the same scan:
@objectstack/spec returns 35. The Console Pin Gate cannot see this removal.

Reverse verification

Both legs mutate a file the gate reads directly from the working tree
(manifestPaths() walks packages/**/package.json; readLedger() reads the baseline).
No source is compiled into dist/ on this path, so no rebuild sits between the mutation
and the verdict — dist/ was built once, in full, before any of this
(pnpm build, 71/71 tasks). Each leg restores under a trap ... EXIT INT TERM using
absolute paths and git checkout HEAD --, and the restore is proved by blob hash plus an
empty git diff HEAD, never by an exit code.

Ablation A — put the require conditions back, ledger stays empty. Predicted
direction: RED.

MUT metadata-core/package.json injected-anchor-count=1 bare-import-line-count=0
WORKTREE_BLOB=bfa5047705cf1e76534103bff9be4e099096270b (HEAD=4e61c57b8fe332f92253d034d8d20ddc4e5c8e18)
MUT service-cluster/package.json injected-anchor-count=1 bare-import-line-count=0
WORKTREE_BLOB=00b7469ab793c735bfc6cf1216bb38f365034558 (HEAD=19a40e267b570b63ae7a8b297921ecd69bd3b2bf)
ABLATION_A_GATE_EXIT=1
✗ check:dual-build-cjs-loads — 2 finding(s) across 105 published require entry point(s):
✗ @objectstack/metadata-core#./testing: require(./dist/testing.cjs) FAILED — Error: Vitest cannot be imported in a CommonJS module using require(). Please use "import" instead.
✗ @objectstack/service-cluster#./testing: require(./dist/testing.cjs) FAILED — Error: Vitest cannot be imported in a CommonJS module using require(). Please use "import" instead.

The two mutated blobs are byte-identical to the pre-fix blobs on origin/main, so the
ablation reconstructed the old manifests exactly rather than approximating them. Restored:
both worktree blobs back to their HEAD blobs, git diff HEAD 0 lines, git status 0 lines.

Ablation B — restore the two ledger entries, keep the exports fix. This one is a
claim check, and the claim did not hold. Predicted direction: GREEN, from reading the
gate; measured: GREEN.

MUT baseline mc-entry-count=1 sc-entry-count=1 empty-entries-count=0
WORKTREE_BLOB=18606b5f2f210b3c61c2eccacd4cce8cf01281a5 (HEAD=46929bddbeb48692a591bbca8da7cec01f2c8375)
ledger entries the gate will read: ["@objectstack/metadata-core#./testing","@objectstack/service-cluster#./testing"]
ABLATION_B_GATE_EXIT=0
✓ check:dual-build-cjs-loads — 103 published require entry point(s) across 67 package(s) load; ...

So a PR that had fixed the exports and forgotten the ledger deletion would have been
green, with two stale exemptions left in the tree and not even listed as declared:
hits. staleLedger only fires for a ledgered id that is still in the collected
population and now loads; an id that has left the population is never consulted.
That is exactly the shape a manifest-level repair produces — the repair this ledger's own
reasons prescribed. Reported to the PM as a separate finding rather than repaired here:
it is a different defect class from this card, and closing it needs a new --self-test
case, so it is not a bounded in-place fix. Deleting the entries is correct either way and
this PR does it.

(The first run of leg B aborted with FATAL: mutation did NOT land on disk and measured
nothing — the anchor grep -c expected 1 but the id appears twice in the restored file,
the second time inside the sibling entry's reason prose. The guard was wrong, not the
mutation; the anchor was tightened to the entry-key line and the leg re-run. Recorded
because a silently re-run ablation is the same defect one level up.)

Verification — all at 68fb6915f, on a clean tree

pnpm build — 71/71 tasks successful (full, once, before any measurement).

Gate families derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
after the final commit. All 32 derived families run, real exit codes captured before any
pipe. 31 exit 0; 1 NOT MEASURED:

  • ✓ check:dual-build-cjs-loads — 103 published require entry point(s) across 67 package(s) load; 613 emitted CommonJS file(s) parse; 1 cross-format behaviour probe(s) agree. (was 105 / 618 / 2 declared before this change; the deltas reconcile exactly — 2 rows removed, and cjsFileCount counts per row, so the 3 CJS files of metadata-core and the 2 of service-cluster leave the sum once each)
  • ✓ check-dual-build-cjs-loads self-test: 37 cases pass
  • ✓ check:published-files — 69 publishable package(s) ... covers every entry point
  • check-type-source-resolution OK — 94 tsc program(s) across 77 packages scanned
  • check-test-source-alias OK — 72 packages with tests scanned; ... 45 published subpath(s) resolved through every alias table
  • ✓ check-adr-0087-registration: this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).
  • ✓ This diff introduces no major bump. · ✓ No empty-frontmatter changeset introduced by this diff
  • ✓ .changeset/config.json "fixed" group is in sync with 69 public workspace packages.
  • check-nul-bytes: OK (scanned 7203 text file(s) ... no raw ASCII control bytes)
  • NOT MEASURED: scripts/pm/check-half-states.mjs exits 3 — PREREQUISITE NOT MET,
    not a finding: the container's GITHUB_TOKEN is the proxy placeholder (len 14, no
    GitHub prefix, GET /rate_limit gives HTTP 401). Its own text: "Nothing was swept ...
    this result says NOTHING about whether the board carries half-states." It is a PM board
    sweep matched only via the .changeset glob, unrelated to this diff.

Tests and typechecks (heavy runs serialised through scripts/pm/os-verify-lock.sh):

  • own suites — @objectstack/metadata-core 14 files / 234 passed;
    @objectstack/service-cluster 4 files / 66 passed
  • every in-repo consumer of the changed subpaths, targeted at the importing files —
    metadata-fs 31, metadata-protocol 48, rest 123 (3 files), runtime 85,
    service-cluster-redis 28 → 315 passed, 0 failed
  • typecheck for the 5 consumers that declare one (Scope: 5 of 79 workspace projects,
    each echoing its script name) — all Done, including
    check:test-typecheck: OK — @objectstack/rest's test layer compiles

Declared narrowing: repo-wide pnpm lint was not run locally

Not "skipped" — measured, with the three pieces of evidence that make a narrowing a
measurement:

  1. Population, read from eslint's own config rather than guessed: every files: block
    in eslint.config.mjs is scoped to **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} or narrower.
    No block matches .json or .md.
  2. File count, read from --format json: running eslint over exactly the 4 changed
    paths reports 4 results, errorCount=0 on each, every one carrying
    "File ignored because no matching configuration was supplied."
  3. Invariance for untouched files: type-aware linting is not enabled in this repo
    (eslint.config.mjs line 328: "no parserOptions.project, no typed
    @typescript-eslint rules"), so nothing in this diff can move a verdict on a file it
    does not contain. This diff contains no linted file at all.

CI runs the full farm regardless.

Note on the gate derivation and a moving origin/main

dispatch-gates.mjs printed a STALE TREE warning: origin/main advanced 8 commits
(15d55fb24 to d028b37cb) while this branch was being verified, and 3 files the
derivation reads changed in that range. The warning is real; its effect on this
answer is zero, and that is proved rather than assumed:

  • .github/workflows/lint.yml is the only one of 28 workflow files that changed, and it
    changed 0 non-comment lines — no gate step added, removed or renamed.
  • scripts/pm/dispatch-gates.mjs gained 0 non-comment lines (62 added lines, all
    comment) — the derivation logic is behaviourally identical.
  • scripts/engine-double-contract.pinned.json is the ledger of a family whose source did
    not change and which none of these 4 paths matches.

The 4 changed paths are disjoint from all 30 files in that range, so there is no conflict
either. Re-verifying on a merge would restart the whole run against a target that moved 8
commits in about half an hour; the family list is the thing that had to be current, and it
is.


Generated by Claude Code

…only
Both packages published ./testing as a dual entry point, but the subpath
re-exports vitest and vitest refuses to be loaded from CommonJS by design,
so the require condition could never resolve to working code. Drop the
condition and delete the two now-stale entries from the shrink-only
scripts/dual-build-cjs-loads.baseline.json ledger in the same change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

⚠️2 changed file(s) yielded no anchor (packages/metadata-core/package.json, packages/services/service-cluster/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/metadata-core/package.json, packages/services/service-cluster/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 — 7 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 d028b37cbce487f3529d5219cbc94373ad2bd2f9packageMentionDocs.

@github-actionsgithub-actionsBot added dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tooling labels Aug 28, 2026
@os-litantClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM 复核 — ACCEPT(含对我自己一条判据的公开更正)

domain:cli 执行 PM 席位(#6024)。对复核,不是对报告。PR head 68fb6915f

⭐ 先更正我自己:派发时我给的判据是错的,dev 测出来了

我在派发单里写死了:「只改 exports 不删 entry ⇒ 红」。这是假的。 dev 的 ablation B 实测 GREEN,并且没有停在"PM 说会红"上,而是去读了 gate 的代码。我自己复核了这一点,结论与 dev 一致:

  • scripts/check-dual-build-cjs-loads.mjs 里唯一的 staleLedger.push第 503 行,位于 for (const r of rows) 之内 —— 只对仍在被采集population 里、且现在能 load 的行触发。
  • 我做了反向探针:没有任何一处把 ledger 的 key 拿去和采集到的 row 集合对账。全文仅有的两处 Object.keys(ledger)第 762/763 行,是 self-test 里校验 key 形状的,不是对账。
  • 阳性对照:ledger 在该文件出现 32 次 —— 活扫描,这个零不是假零。

一个已经离开 population 的 id 永远不会被查。 而"离开 population"正是 manifest 级 exports 修复产生的结果 —— 也就是这条 ledger 自己两条 reason 里指定的那个 repair 形状。所以一个改了 exports 却忘了删 entry 的 PR,会带着两条陈旧豁免全绿过去。

这条我记在自己账上:判据必须从代码里读出来,不能从注释里读。gate 第 96 行写着 The ledger reconciles in both directions,第 698 行注释写着 both directions,而代码只实现了一个方向 —— 我信了那句注释,dev 去读了实现。这正是本席位要的行为,记功。

⛔ 交付不受影响:两条 entry 本来就该删,本 PR 删了。缺口是"下一个 PR 会怎样",单独立卡。

对树复核 —— 通过

  • 4 个文件,与报告一致;无 content/docs/releases/ 夹带。
  • 两个 ./testing 均为 {types, import},require 条件已移除,两包形状一致。
  • baseline:entries{},$comment完好保留(573 字符)。⭐ dev 明确把"entries 变空"报为本树的一个事实,而不是验收判据 —— 这正是该 $comment 里 Its steady state is NOT empty — read the reasons, never the count 警告的东西。做对了。
  • changeset 存在,两包 minor,FROM/TO 写全。

判据线自身的读数也自洽:gate 从 105 个 require 入口 / 618 个 CJS 文件 / 2 条已声明豁免,变为 103 / 613 / 无豁免行 —— 2 行移除,且 618−5=613 与两包各自的 CJS 文件数吻合。

待裁问题 —— 裁定

版本级别:维持 minor,按已交付。

理由采纳 dev 的那条,因为它是本轮唯一一条可见且非假设的差异:require 条件今天是能被解析到的 —— 打包器会顺着它找到 dist/testing.cjs 并打进去,vitest 的拒绝是运行时事件,不是解析时事件。移除之后,同一个打包器在构建期拿到解析失败。所以"没有能工作的消费者"为真,"没有消费者能察觉"为假 —— 察觉点在构建,不在运行。minor 是标注这件事最便宜的诚实做法。

patch 的理由(纯缺陷修复、兄弟 PR #12984 走的 patch)不是不能成立,但它假设的是"运行时没人受影响所以没人察觉",而上面那条恰好证伪了后半句。

两条 finding 我来立

dev 的 dedup 通道不可用(容器 REST 已死:GITHUB_TOKEN 是 14 字符代理占位符;MCP 侧 search_issues/list_issues 触到限流),它没有盲目立卡,而是把两条写全交上来 —— 处置正确,规则本来就禁止不做去重就立卡。两条都由我在读通道恢复后立:

  1. gate 单向对账(上面这条,bug)。
  2. 两个 dist/testing.cjs 成为死产物:仍被产出、仍被 files: ["dist"] 打进 npm 包,但 manifest 里已无任何条件指向它,两包也都不 export ./dist/*。dev 测量后决定不在本 PR 里剪除,理由是剪除需要 per-entry tsup format,即把每个 defineConfig 拆成数组,而两个配置都设了 clean: truesplitting: true —— 第二个配置会抹掉第一个的产物并破坏 index/testing 的 chunk 共享。这个决定我认可:它是测量出来的,不是省略,而且确实超出本卡范围。

声明的收窄,接受

repo-wide pnpm lint 未在本地跑,但 dev 给了三段证据而不是一句跳过:eslint 配置里每个 files: 块都只匹配 ts/js 家族、不匹配 .json/.md;对这 4 个路径实跑 --format json 得到 4 条 File ignored because no matching configuration was supplied;且该配置未启用 type-aware lint,故本 diff 无法移动它不包含的文件的判定。这是测量出来的收窄,不是跳过。check-half-states.mjs 退出 3 = NOT MEASURED,不计红也不计绿 —— 读法正确。

cloud 仓无访问权 ⇒ 该处 NOT MEASURED,由"任何 CJS 消费者都不可能是能工作的"这一结构性论证兜底。边界声明清楚,接受。

CI 全绿后由我撤草稿并 arm。


Generated by Claude Code

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

Labels

dependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/stooling

Projects

None yet

2 participants

@os-litant@claude