Skip to content

fix(metadata-core): make the CJS entry point loadable, and gate the class - #12984

Merged
hotlong merged 4 commits into
mainfrom
claude/issue-12971-metadata-core-cjs-import-meta
Aug 28, 2026
Merged

fix(metadata-core): make the CJS entry point loadable, and gate the class#12984
hotlong merged 4 commits into
mainfrom
claude/issue-12971-metadata-core-cjs-import-meta

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#12971
Part of objectstack-ai/cloud#1653

What was broken

@objectstack/metadata-core declares "type": "module" with a dual exports map, so require('@objectstack/metadata-core') is a published, supported entry point. Since #12843 it was unloadable. resolveInstalledSpecVersion() anchors its @objectstack/spec lookup with createRequire(import.meta.url) — correct for the ESM output — and tsup emits that identifier verbatim into dist/index.cjs at this build target. import.meta outside an ES module is a parse-time error, so the module never begins executing: neither the typeof require === 'function' fast path above the line nor the try/catch around it ever runs, and the failure is total rather than partial.

The filed positive control, reproduced on this worktree at the base commit 3404bd171, from a clean build:

$ node -e "require('./dist/index.cjs')" # in packages/metadata-core
dist/index.cjs:810
const req = _module.createRequire.call(void 0, import.meta.url);
^^^^
SyntaxError: Cannot use 'import.meta' outside a module

The fix — one line, and it is the line this repo already measured twice

shims: true in packages/metadata-core/tsup.config.ts. tsup then rewrites import.meta.url in the CJS output to a real __filename-derived URL (its assets/cjs_shims.js), so both formats anchor on this module's own file and resolve the same@objectstack/spec/package.json.

That is not a guess between the issue's two suggested shapes — it is the shape already carried, for this exact measured reason, by packages/metadata-protocol/tsup.config.ts (#11235) and packages/runtime/tsup.config.ts (#10993). metadata-protocol's comment describes this failure mode word for word, on its own package. The third package to hit it had no such comment to read, which is the argument for the gate below rather than a fourth comment.

The source comment that documented the wrong posture ("its transformed import.meta.url is undefined there — createRequire then throws and the catch below answers null") is corrected in the same commit: the shim is what makes the line legal in CJS at all, and the catch cannot cover a missing shim.

Both conditions, after:

$ node -e "const m=require('./dist/index.cjs'); console.log(m.resolveInstalledSpecVersion())"
17.2.0
$ node --input-type=module -e "const m=await import('./dist/index.js'); console.log(m.resolveInstalledSpecVersion())"
17.2.0

The ESM half is untouched — measured, not assumed. Rebuilding with and without the shim and hashing every emitted ESM file: the code bytes are identical in all of them; the whole difference is the shared chunk's content-hashed filename (chunk-DDDKWTSW.js becomes chunk-46MG4YHS.js) and the one sourceMappingURL line naming it — a 6-line diff, no exports target moved.

The class gate — measured first, then built

The sweep. Over all 105 published require entry points across 67 publishable packages, import.meta in an emitted CommonJS output was exactly one file: this one. So the class is fixed here, in full, and nothing else is deferred for that shape.

The sweep did surface a different, pre-existing class worth a card of its own: two require conditions that can never load@objectstack/metadata-core#./testing and @objectstack/service-cluster#./testing. Both re-export vitest, and vitest refuses by design to be loaded from CommonJS. The bytes parse; the load fails inside vitest's own entry. Independent of this issue (the same condition is declared at b489d3c725e8, before the import.meta line landed), and the repair is a published-exports change. Enumerated below for the PM rather than ridden in here.

pnpm check:dual-build-cjs-loads (scripts/check-dual-build-cjs-loads.mjs) — three invariants per published require entry point:

  • PARSES — every emitted CommonJS file (.cjs, plus .js when the manifest is not "type": "module") parses as CommonJS. Checked over the whole emitted set, not just the entry, because code splitting puts the offending line in a shared chunk as easily as in index.cjs.
  • LOADSrequire(entry) in a fresh child process completes.
  • AGREES — for declared probes, the same exported call answers the same value through both conditions, and that value is the declared one. This is the half that pins feat(metadata): versioned ADR-0087 forward conversion at the artifact-ingestion door #12843's intent: today's one probe requires metadata-core's resolveInstalledSpecVersion() to answer the installed spec version through both conditions. Loading is the weaker claim — a shim resolving the wrong anchor would load fine and quietly answer null, which is a legal value here (it closes the ADR-0087 forward-conversion window), so that degradation is silent.

Green on this tree:

✓ check:dual-build-cjs-loads — 105 published require entry point(s) across 67 package(s) load;
618 emitted CommonJS file(s) parse; 1 cross-format behaviour probe(s) agree;
2 declared non-loadable entr(ies) still justified.

Design points worth review:

  • A SyntaxError is never ledgerable.scripts/dual-build-cjs-loads.baseline.json is shrink-only and hand-edited, and it can only declare a load-time failure whose cause is a dependency. An entry whose emitted bytes do not parse is refused whatever the ledger says, and the finding says the ledger was ignored. A load failure is a fact about a dependency; a parse failure is always a fact about what we emitted. Pinned in --self-test in both directions, as is the stale-exemption direction (an entry that starts loading must be deleted).
  • It runs in Build Core, beside "Verify capability packages ship a runtime entry" — same genre, same phase, required context, and a real dist/ exists there. With no dist/ it exits 3 (PREREQUISITE NOT MET) naming pnpm build; it never degrades to a silent green.
  • Why the behaviour probe is not a vitest suite: it can only be asked of built output, and Test Core has none. turbo's test task declares dependsOn: ["^build"] (dependencies only, never the package's own dist) and excludes dist/** from its inputs — a suite reading its own dist/ would be unbuilt in CI and un-invalidated by a rebuild. The package's own unit suite still covers the function; what needs built bytes is the cross-format claim.
  • --self-test: 37 cases, on a real fixture tree with real emitted bytes and real spawns — including a poisoned shared chunk the entry never imports (which a require-only smoke misses), and a package that parses and throws at load (which a parse-only check misses).
  • One declaration was measured and refused. The gate declares packages/**/package.json (74 files, 1.0% of the tree) and packages/**/tsup.config.ts (20, 0.3%) as its dispatch-gates watch hints. packages/**/src/** has the best recall — feat(metadata): versioned ADR-0087 forward conversion at the artifact-ingestion door #12843 arrived through exactly one source line — but measures 4482 files, 62.2% of the tracked tree, wider than the 39% rows scripts/pm/bare-root-worklist.mjs already records as REFUSE-WIDE on this trade, and the gate does not read those files at all. The row recorded there is DECLARED-NARROWER, carrying that measurement. The recall is not lost: the gate is a required-context step on every PR, so the omission costs one CI round trip, not a missed defect.

Ablation — disk-proven, both legs, rebuilt

From the committed state, with a trap restore and absolute paths.

Mutation leg — delete the shims: true line: anchor occurrences before=1 after=0; on-disk hash 8a9c7153… against the HEAD blob ef53c3e7…; rebuilt; import.meta occurrences in dist/index.cjs = 1, so the mutation demonstrably reached dist/. Then:

$ node -e "require('./dist/index.cjs')"
SyntaxError: Cannot use 'import.meta' outside a module
$ node scripts/check-dual-build-cjs-loads.mjs
GATE EXIT=1
✗ check:dual-build-cjs-loads — 23 finding(s) across 105 published require entry point(s):
✗ @objectstack/metadata-core#.: emitted CommonJS does NOT parse — packages/metadata-core/dist/index.cjs:
SyntaxError: Cannot use 'import.meta' outside a module. The output carries `import.meta`, which is a
PARSE-time error outside an ES module — add `shims: true` to this package's tsup.config.ts …
✗ @objectstack/metadata-fs#.: require(./dist/index.cjs) FAILED — SyntaxError: Cannot use 'import.meta' …
✗ @objectstack/metadata-protocol#.: require(./dist/index.cjs) FAILED — SyntaxError: Cannot use 'import.meta' …
… 20 more

23 findings from one line is the in-repo blast radius: 21 downstream packages resolve metadata-core through the require condition and inherit the parse failure.

Restore leggit checkout HEAD -- <path> (never a bare git checkout -- <path>), proven by an emptygit diff HEAD on the path and a restored hash ef53c3e7… equal to the HEAD blob, then rebuilt and re-measured: import.meta occurrences in dist/index.cjs = 0, gate EXIT=0.

Consumer acceptance — the two suites cloud#1712 named, both directions

Run on a dedicated cloud worktree detached at cloud d441d923 (the pin commit on cloud#1713's branch, .objectstack-sha = fc8a33935ce9), built with pnpm build --force, with all 174 framework symlinks repointed by prefix between the two checkouts. The repointing was proven per run, not assumed: require.resolve printed the resolved file, its import.meta occurrence count, and whether require() succeeded.

suiteshared checkout at fc8a33935 (unfixed)this branch
apps/objectos-eetest/signup-membership-policy.e2e.test.tsTests 3 failed | 2 passed (5), EXIT=1Test Files 1 passed (1) / Tests 5 passed (5), EXIT=0
apps/objectos-eetest:hotcrm-acceptanceTest Files 2 failed (2) / Tests 1 passed | 12 skipped (13), EXIT=1Test Files 2 passed (2) / Tests 13 passed (13), EXIT=0

Both unfixed columns carry the filed cause lines verbatim — cause: Cannot use 'import.meta' outside a module and FATAL: tenancy posture 'isolated' was requested but @objectstack/organizations could not be loaded — and reproduce the counts in the issue exactly. Zero import.meta occurrences appear in either fixed-column log.

Declared: the HotCRM artifact is not a committed fixture, and a first run was PREREQUISITE NOT MET for that reason rather than a measurement. It was supplied from the pinned SHA build (.hotcrm-sha1e40d16cebb8, artifact sha256 e1a5951c32f7…) rather than re-cloned, and both columns above ran against that same byte-identical artifact.

What the cloud pin bump should target

cloud#1713 holds on its own branch per the Option A adjudication. After this PR lands, re-run scripts/bump-objectstack.sh on that same branch to any objectstack main SHA at or after this PR's merge commit — the only constraint the card carries is "at or past fc8a33935", and every such SHA now also carries this fix. No earlier SHA works: 54e2d3692 (which introduced the break) is an ancestor of fc8a33935, so no commit both satisfies cloud#1712 and excludes the defect.

Verification

Union re-run after the final commit, at 1ca6e3a27 (git status clean).

  • Derived gate familynode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths; the script derives its own change set from the merge base). All 44 derived families plus the two convention-triggered ones (bare-root-worklist --self-test, check:pm-dispatch-gates), the ADR-0112 lead (check:dispatcher-error-vocabulary) and check:nul-bytes were run, each exit code captured before any pipe. Green at their own verdict lines, with two exceptions, both read as NOT MEASURED rather than as passes:
    • check:bash32-floor — EXIT=1, and environmental, not this diff: this host's bashis 3.2.57 (macOS), so the self-test's simulated-3.2 harness cannot remove builtins that were never there, and 8 of 153 cases fail on "the probe is shell this host can parse". Proven pre-existing by running the identical, unmodified script on the shared checkout at fc8a33935 (a commit that predates this branch): same EXIT=1, the same 8 cases. This diff touches no shell file and not that script. CI's ubuntu runner ships bash 5.
    • check:pm-half-states / scripts/pm/check-half-states.mjs — EXIT=3, PREREQUISITE NOT MET by its own words ("Nothing was swept … it is no reading at all"): the container token is not a GitHub credential. CI owns it.
  • New gate, both modesnode scripts/check-dual-build-cjs-loads.mjs --self-test✓ 36 cases pass at the time of the gate commit, 37 after the watch-hint narrowing; the gate itself → the verdict line quoted above.
  • scripts/pm/bare-root-worklist.mjs --self-test (its own suite, owed because this diff edits it) → OK self-test: 47 live row(s), 40 unreachable as spelled, 40 recorded verdict(s) — none stale, none missing, none contradicted.pnpm check:pm-dispatch-gates✓ dispatch-gates self-test: 834 cases pass. No dedicated vitest suite names either script (git greped; the self-tests are the suites).
  • Affected packagepnpm --filter @objectstack/metadata-core testTest Files 14 passed (14) / Tests 234 passed (234), EXIT=0. pnpm --filter @objectstack/metadata-core typecheck (tsc --noEmit plus the test program) → EXIT=0, and the edited source really is in that program: --listFiles names artifact-forward-conversion.ts.
  • Lint — full-repo pnpm lint (eslint . --no-inline-config) → EXIT=0. No narrowing to declare.

Declared narrowing — verification ran UNLOCKED.scripts/pm/os-verify-lock.sh could not take the shared verify lock on this host: no usable flock. The shared verify lock is declared Linux-only (flock is util-linux, and a stock macOS does not ship it), so the commands were run directly, without the lock — a declared narrowing, not a silent one. No serialization guarantee held for these runs, nor for any sibling agent in this container while they ran.

Out of scope, for the PM to file

A require condition that declares an entry point vitest itself refuses to serve: @objectstack/metadata-core#./testing and @objectstack/service-cluster#./testing both re-export vitest, whose CJS entry throws Vitest cannot be imported in a CommonJS module using require(). Pre-existing (present at b489d3c725e8), a different class from this one, and the repair is a published-exports change — a test-harness subpath advertising a require condition it cannot honour. Both are declared with their reasons in scripts/dual-build-cjs-loads.baseline.json, so the new gate reports them rather than passing over them, and the ledger is shrink-only: whichever card fixes them must delete the entries in the same PR or the gate reds.

Generated by Claude Code

hotlongand others added 4 commits August 28, 2026 19:38
…ire entry point loads
tsup emits `import.meta` verbatim into `dist/index.cjs` at this build
target, and `import.meta` outside an ES module is a parse-time error, so
the package's whole CJS condition was unloadable and the guarding
try/catch never ran. `shims: true` rewrites it to a real
`__filename`-derived URL, matching the measured precedent already carried
by packages/metadata-protocol and packages/runtime.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ust load
check:dual-build-cjs-loads parses every emitted CommonJS file and require()s
every published require entry point in the workspace (105 entries across 67
packages), plus a declared cross-format behaviour probe pinning that
metadata-core's resolveInstalledSpecVersion() answers the same installed spec
version through both conditions. Runs as a step in Build Core, where a real
dist/ exists; exits 3 PREREQUISITE NOT MET rather than degrading to a silent
green when it does not.
A SyntaxError in our own emitted bytes is never ledgerable — the shrink-only
baseline can only declare a load-time failure whose cause is a dependency
(today: two vitest-backed ./testing subpaths).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DECLARED-NARROWER: the gate declares packages/**/package.json (1.0% of the
tree) and packages/**/tsup.config.ts (0.3%) beside SCAN_ROOT. The wider
packages/**/src/** spelling — best recall, since #12971 arrived through one
source line — is measured at 62.2% of the tracked tree and REFUSED on the
same trade this map records for the 39% rows, and because the gate does not
read those files at all.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The emitted ESM code bytes are identical with and without the shim; the whole
difference is the shared chunk's content-hashed filename and its
sourceMappingURL line. Measured by rebuilding both ways and hashing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 — 3 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 2b4178aa53ca62089f43e2cfae0b7838cf340dd1packageMentionDocs.

Which tree this was computed on

This run read content/docs from f7a816c7e75413c43d857b4ccfdf36ea1748f3bc — the merge of head 1ca6e3a27679814336f133bec83ced3d740484ba into base 2b4178aa53ca62089f43e2cfae0b7838cf340dd1, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f7a816c7e75413c43d857b4ccfdf36ea1748f3bc && git checkout f7a816c7e75413c43d857b4ccfdf36ea1748f3bc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2b4178aa53ca62089f43e2cfae0b7838cf340dd1 1ca6e3a27679814336f133bec83ced3d740484ba && git checkout -B drift-repro 2b4178aa53ca62089f43e2cfae0b7838cf340dd1 && git merge --no-ff 1ca6e3a27679814336f133bec83ced3d740484ba
node scripts/docs-audit/affected-docs.mjs --json 2b4178aa53ca62089f43e2cfae0b7838cf340dd1

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added ci/cd dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tooling labels Aug 28, 2026
@hotlong
hotlong marked this pull request as ready for review August 28, 2026 12:32
@hotlong
hotlong added this pull request to the merge queueAug 28, 2026
Merged via the queue into main with commit 15d55fbAug 28, 2026
35 checks passed
@hotlong
hotlong deleted the claude/issue-12971-metadata-core-cjs-import-meta branch August 28, 2026 12:56
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cddependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/ltooling

Projects

None yet

1 participant

@hotlong