Skip to content

metadata-core's CJS entry point is unloadable since #12843 — import.meta emitted verbatim into dist/index.cjs is a parse-time error, so the guarding try/catch never runs #12971

Description

@hotlong

@objectstack/metadata-core's CJS entry point is unloadable since #12843 — import.meta is emitted verbatim into dist/index.cjs, which is a PARSE-time error, so the guarding try/catch never runs.

Measured

At fc8a33935 (current main tip), from a clean pnpm turbo run build --force:

$ node -e "require('./dist/index.cjs')"     # in packages/metadata-core
Warning: Failed to load the ES module: .../packages/metadata-core/dist/index.cjs.
REQUIRE FAILED: SyntaxError - Cannot use 'import.meta' outside a module

dist/index.cjs:810:

    const req = _module.createRequire.call(void 0, import.meta.url);

Source: packages/metadata-core/src/artifact-forward-conversion.ts:229, in resolveInstalledSpecVersion().

Why the existing guard does not hold

The function is written to be safe in both module systems, and its comment states the intended posture:

ESM build: anchor a require at this module's own URL. In the CJS build this branch is only reachable when the branch above already failed, and its transformed import.meta.url is undefined there — createRequire then throws and the catch below answers null, the documented posture.

That assumes the bundler rewrites import.meta.url in the CJS output. It does not — the identifier is emitted verbatim. And import.meta outside an ES module is a syntax error at parse time, not a runtime undefined: the module never begins executing, so neither the typeof require === 'function' fast path nor the try/catch around createRequire is ever reached. The whole CJS entry point of the package is unloadable, for every consumer and every code path — not just callers of resolveInstalledSpecVersion().

Bisect

packages/metadata-core/package.json declares "type": "module" with a dual exports map (require to ./dist/index.cjs), so the CJS condition is a published, supported entry point.

Downstream blast radius (measured in cloud)

Filed from cloud#1712, the consumer bump that cannot land because of this. On a cloud worktree pinned at fc8a33935, the enterprise runtime refuses to boot: @objectstack/organizations is resolved through the CJS condition, fails to load, and the ADR-0093 D5 fail-closed tenancy wall then correctly refuses to serve:

✖ FATAL: tenancy posture 'isolated' was requested but @objectstack/organizations could not be loaded,
  so the organization wall is INACTIVE. Refusing to boot ...
  cause: Cannot use 'import.meta' outside a module

Consequences in objectstack-ai/cloud's only required check (build-and-test):

suite result at fc8a33935
apps/objectos-ee test/signup-membership-policy.e2e.test.ts 3 failed / 2 passed (5) — every case that boots the walled posture
apps/objectos-ee test:hotcrm-acceptance 2 files failed, 12 of 13 tests skipped — collection-level, same cause
apps/cloud 284/284 passed (does not boot the walled EE runtime)
packages/service-cloud 1579/1579 passed (likewise)

The break is not dodgeable by picking an earlier consumer pin: 54e2d3692 is an ancestor of fc8a33935, and cloud#1712 needs a SHA at or past fc8a33935 (the os migrate host-composition fix, #12938 / #12952).

Suggested direction, not a prescription

The contract question is how resolveInstalledSpecVersion() should anchor its resolution in the CJS build at all. Two shapes worth weighing:

  1. Keep the dual-build and stop emitting import.meta into CJS — e.g. resolve the ESM anchor behind the bundler's own define/shim so the CJS output carries a real undefined, which is what the current comment already documents as the intended behaviour. Smallest change; keeps the documented posture true instead of aspirational.
  2. Drop the ESM branch and anchor only on the ambient require plus an explicit caller-supplied version, so there is one resolution path rather than two that must agree.

Worth a gate either way: nothing currently asserts that each dual-built package's require entry point actually loads. A require() smoke over the CJS conditions in the build would have caught this at the commit that introduced it, and would hold the class rather than this one line.

Activity

  1. self-assigned this
    on Aug 28, 2026
  2. os-zhuang commented on Aug 28, 2026

    @os-zhuang
    Contributor

    Claim: PM loop (epic objectstack-ai/cloud#1653 chain, maintainer direct-dispatch channel)
    Session: session_71836b57-5db6-459d-ae4d-c6d9d7dd2cc6
    Branch: claude/issue-12971-metadata-core-cjs-import-meta
    Worktree: objectstack-issue-12971
    Domain: domain:engine
    File surface: packages/metadata-core/** (the createRequire pattern / tsup config) + a class gate under scripts/ if the sweep supports it (stop on breach; explain)
    Container & model: M, mode:subagent, model: opus
    Clause-②: no
    Serial constraints cleared: no open PR touches metadata-core (checked this hour); consumer cloud#1712/#1713 holds on this and re-bumps the SAME branch after it lands.
    Audit: Option A adjudicated on cloud#1712 (2026-08-28).

  3. hotlong commented on Aug 28, 2026

    @hotlong
    ContributorAuthor

    os-dev-report

    {
      "issue": 12971,
      "status": "done",
      "branch": "claude/issue-12971-metadata-core-cjs-import-meta",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/12984",
      "premise_still_valid": true,
      "summary": "Premise confirmed on origin/main and reproduced at the base commit 3404bd171: a clean build of packages/metadata-core emits `createRequire(import.meta.url)` verbatim at dist/index.cjs:810 and `node -e \"require('./dist/index.cjs')\"` dies with SyntaxError: Cannot use 'import.meta' outside a module. The fix is `shims: true` in packages/metadata-core/tsup.config.ts — not a choice between the issue's two suggested shapes but the shape this repo has already measured twice for this exact failure, in packages/metadata-protocol (#11235) and packages/runtime (#10993), whose comments describe it word for word; the third package to hit it simply had no such comment to read. The stale source comment that documented the wrong posture ('its transformed import.meta.url is undefined there') is corrected in the same commit. Both conditions now load and resolveInstalledSpecVersion() answers 17.2.0 in each. The ESM half is measured untouched: rebuilding with and without the shim, the emitted ESM code bytes are identical in every file and the whole difference is the shared chunk's content-hashed FILENAME plus its sourceMappingURL line (a 6-line diff, no exports target moved). Class 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 closed here in full, nothing of that shape deferred. New gate `pnpm check:dual-build-cjs-loads` holds it, wired as a step in Build Core (a required context, and the phase where a real dist/ exists) with three invariants: PARSES (every emitted CommonJS file, over the whole set rather than the entry, because splitting puts the line in a shared chunk), LOADS (require in a fresh child), and AGREES (a declared cross-format behaviour probe — today metadata-core's resolveInstalledSpecVersion must answer the installed spec version through BOTH conditions, which is the half that pins #12843's original intent; loading alone is weaker, since a shim resolving the wrong anchor would load fine and quietly answer null, a legal value that silently closes the ADR-0087 window). A SyntaxError is never ledgerable: the shrink-only baseline can declare only a load-time failure whose cause is a dependency, and a parse failure is refused whatever it says. No governed surface is touched.",
      "tests": "Every leg redirected to a file BEFORE the exit code was captured, or read from the gate's own printed verdict line — no exit code was read through a pipe. Verification union re-run AFTER the final commit, at 1ca6e3a27, git status clean. GATE FAMILY: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` (no paths — it derives its own change set from the merge base 3404bd171); all 44 derived families plus the two convention-triggered obligations, the ADR-0112 lead and check:nul-bytes were run — 44 of 46 EXIT=0. TWO NOT-MEASURED, neither read as a pass: check:bash32-floor EXIT=1, environmental — this host's bash IS 3.2.57 (macOS) so its simulated-3.2 harness cannot remove builtins that were never there; proven pre-existing by running the identical unmodified script on the shared checkout at fc8a33935, a commit predating this branch: same EXIT=1, the same 8 of 153 cases, and this diff touches no shell file and not that script. check:pm-half-states EXIT=3, PREREQUISITE NOT MET in 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: `node scripts/check-dual-build-cjs-loads.mjs --self-test` -> '37 cases pass (real emitted bytes, real spawns; both ledger directions, and the parse failure the ledger may never silence)'; the gate itself -> '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.' EDITED TOOL SCRIPT owes its own suite: `node scripts/pm/bare-root-worklist.mjs --self-test` -> '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'; git grep found no vitest suite naming either script, so the self-tests ARE the suites. AFFECTED PACKAGE: `pnpm --filter @objectstack/metadata-core test` -> 'Test 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 coverage of the edit PROVEN rather than assumed — `tsc --listFiles` names artifact-forward-conversion.ts. LINT: full-repo `pnpm lint` (eslint . --no-inline-config) EXIT=0 — no narrowing to declare. ABLATION, disk-proven both legs, from the COMMITTED state, trap-guarded with absolute paths. Mutation leg: anchor occurrences before=1 after=0 (the anchor was grepped, not inferred from an editor's exit code), on-disk hash 8a9c7153 vs the HEAD blob ef53c3e7, THEN REBUILT, and the mutation proven to have reached dist/ by `import.meta` occurrences in dist/index.cjs = 1 — after which the filed positive control fires ('SyntaxError: Cannot use import.meta outside a module') and the gate goes RED, EXIT=1, '23 finding(s) across 105 published require entry point(s)', the first naming metadata-core's parse failure with the shim as the remedy and the rest being the 21 downstream packages that resolve through it. Restore leg: `git checkout HEAD -- PATH` (never a bare `git checkout -- PATH`), proven by an EMPTY `git diff HEAD` on the path and a restored hash equal to the HEAD blob, THEN REBUILT and re-measured — `import.meta` occurrences in dist/index.cjs = 0, gate EXIT=0. CONSUMER ACCEPTANCE, both directions, on a dedicated cloud worktree detached at cloud d441d923 (.objectstack-sha fc8a33935ce9), built with `pnpm build --force`, with all 174 framework symlinks repointed by prefix; the repointing was proven per run by printing require.resolve's answer, its import.meta occurrence count and whether require() succeeded — never assumed. signup-membership-policy.e2e: UNFIXED 'Tests 3 failed | 2 passed (5)' EXIT=1 -> FIXED 'Test Files 1 passed (1) / Tests 5 passed (5)' EXIT=0. test:hotcrm-acceptance: UNFIXED 'Test Files 2 failed (2) / Tests 1 passed | 12 skipped (13)' EXIT=1 -> FIXED 'Test 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 issue's counts exactly; zero import.meta occurrences appear in either fixed-column log. Two rig readings declared rather than buried: the FIRST negative-control attempt failed on a missing @objectstack/service-ai/dist — a cloud-side unbuilt package, NOT the defect — and was discarded as no reading at all until `pnpm build --force` was run; and the HotCRM artifact is not a committed fixture, so its first run was PREREQUISITE NOT MET rather than a measurement — it was supplied from the pinned SHA build (.hotcrm-sha 1e40d16cebb8, artifact sha256 e1a5951c32f7) and BOTH columns ran against that same byte-identical artifact. 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 lock is declared Linux-only and a stock macOS does not ship util-linux), so commands ran without it. No serialization guarantee held. The disclosure is in the PR body in the entry point's own official wording.",
      "mcp_calls": "0 — no MCP GitHub calls; every GitHub read and write went through the gh CLI, and card/commit reads through git",
      "open_questions": [],
      "out_of_scope_findings": [
        "NOT FILED, enumerated for the PM in the PR body per the dispatch clause: two published `require` conditions that can never load — @objectstack/metadata-core#./testing and @objectstack/service-cluster#./testing. Both re-export vitest, whose CJS entry throws by design ('Vitest cannot be imported in a CommonJS module using require()'). The bytes parse; the load fails inside vitest. Pre-existing and 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 (a test-harness subpath advertising a `require` condition it cannot honour), which is why it was not ridden into this PR. 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 that ledger is shrink-only: whichever card fixes them must delete the entries in the same PR or the gate reds.",
        "NOT FILED, a reading rather than a defect: the same ablation measured the in-repo blast radius of this one line at 23 of the 105 published require entry points — metadata-core's own two plus 21 downstream packages that resolve through it. Recorded here because the issue measured the cloud-side radius and not this one."
      ]
    }

    Generated by Claude Code

  4. os-zhuang commented on Aug 28, 2026

    @os-zhuang
    Contributor

    ACCEPT — PR #12984 (8 files, zero spec). Landing on green.

    • The fix is the repo's own precedent, found rather than invented: shims: true is the shape metadata-protocol ([finding] a THIRD hardcoded discovery version literal — getDiscovery() in packages/metadata-protocol/src/protocol.ts, identical defect to #10993, different producer/package #11235) and runtime ([finding] /api/v1/health reports a hardcoded version: '1.0.0' — a field that exists and lies, so no consumer can use it for artifact identity #10993) already measured for this exact failure — the third package to hit it simply had no comment to read. The stale comment that documented the WRONG posture is corrected in the same commit, so the fourth package will have one.
    • The class is closed IN FULL, not sliced: the sweep covered all 105 published require entry points across 67 packages; exactly one carried the defect. Nothing deferred.
    • The new gate's three invariants are the right three, and the third is the one most people skip: PARSES (every emitted CJS file, chunks included — splitting is exactly where the line hides), LOADS (fresh child), and AGREES (a cross-format behaviour probe pinning feat(metadata): versioned ADR-0087 forward conversion at the artifact-ingestion door #12843's original intent — a shim resolving the wrong anchor would load fine and quietly answer null, a legal value that silently closes the ADR-0087 window). Wired into Build Core (a REQUIRED context), with a shrink-only baseline that structurally refuses to ledger a parse failure.
    • Consumer acceptance in both directions on the cloud rig, with the repointing PROVEN per run (require.resolve + occurrence count + load result printed, never assumed): unfixed reproduces the filed counts and cause lines verbatim; fixed = 5/5 and 13/13. The two discarded non-readings (unbuilt service-ai; the uncommitted HotCRM artifact) were declared as non-measurements and re-established — not silently retried.
    • The two pre-existing non-loadable ./testing conditions (vitest refuses CJS by design) are DECLARED in the gate's baseline with reasons and enumerated for the PM: routed as a queued card (published-exports repair; the baseline's shrink-only rule forces whichever card fixes them to delete the entries in the same PR).

    On landing: the #1712 seat resumes on its SAME branch for the re-bump past this fix — the banked verification carries, one-file delta.

  5. os-zhuang commented on Aug 28, 2026

    @os-zhuang
    Contributor

    PM review — ACCEPT → enqueued

    Verdict argues at the PR: #12984.

    Checklist conclusion: PASS. One-line fix (shims: true) matching the shape this repo already measured twice on sibling packages (#11235, #10993), plus a gate that holds the class.

    Spot-checks I ran myself

    check reading
    governed path face register today = docs/adr/** · .claude/** · skills/** · AGENTS.md · CLAUDE.md; file list via get_files (⛔ not the body's self-description) = 8 files, none hit. Governed Surface Queue Guard green twice
    Clause-② path limb: no packages/spec/src/**; content limb: this restores a declared-but-broken require condition — a repair, not a widening ⇒ patch is right
    true-green 34 checks, all completed + success, zero skipped, zero red — read across every check, ⛔ not the required subset; count stable at 34 across two reads ~5 min apart, last completion 12:33Z
    mergeable_state clean
    §5 serial-queue ⚠️ this diff touches scripts/pm/bare-root-worklist.mjs, and the lane's other in-flight card (#12915 → #12989) is the bare-root card. Computed the file-set intersection of both PRs: empty. Disjoint at file level ⇒ no collision, both may proceed in parallel. Recorded because no CI gate backs this queue — it is the seat's to enforce

    Why the extra files are not scope creep

    Each is this repo's required accompaniment to adding a gate, not a rider: package.json (script), ci.yml (the step, in Build Core because it needs a real dist/), the baseline, and the bare-root-worklist.mjs TRIAGE row declaring the gate's watch-hint narrowing. The artifact-forward-conversion.ts edit is a comment correction, and load-bearing: the old comment asserted the CJS build would leave import.meta.url undefined and let the catch answer null — the exact false mechanism that let this ship. Replacing it is part of the fix.

    What I checked hardest, and why it holds

    • The ablation rebuilt. A mutation leg that edits source but never rebuilds dist/ would prove nothing here, since the defect lives in emitted bytes. This one measured import.meta occurrences in dist/index.cjs going 0 → 1 under mutation and back to 0 after restore, with the gate at EXIT=1 → EXIT=0 across the same pair. The 23 findings from one line are the in-repo blast radius, not noise.
    • The gate's own failure modes are pinned. A SyntaxError is refused whatever the shrink-only ledger says, and with no dist/ it exits 3 (PREREQUISITE NOT MET) rather than degrading to a silent green — the failure shape that makes a gate worse than none.
    • The AGREES probe earns its place over LOADS. A shim resolving the wrong anchor would load fine and answer null, which is legal here, so loading alone cannot see that degradation.

    Deviations, all recorded rather than waved through

    • Verification ran UNLOCKED — no usable flock on that host, declared explicitly. Acceptable: CI re-ran everything under its own conditions and is green.
    • check:bash32-floor EXIT=1 — environmental, and proven so by running the unmodified script on a commit predating this branch and getting the same 8 failures. ⛔ Not this diff's, and the gate was not touched to silence it.
    • check:pm-half-states EXIT=3 — container token is not a GitHub credential. CI owns it. Read as NOT MEASURED, not as a pass.
    • The PR was opened non-draft, against the dev-opens-draft convention. No harm here — path face is clean so it was never enqueue-eligible without me — but flagging it since a silently-undrafted PR on a governed face is a live hazard this lane has already met.
    • The out-of-scope finding is already filed as Published ./testing exports on metadata-core and service-cluster advertise a require condition vitest structurally refuses — repair the exports shape #12985 (./testing advertises a require condition vitest structurally refuses). ⛔ Not re-filed; both entries sit in the shrink-only baseline so whichever card fixes them must delete them or the gate reds.

    ⇒ Path face clean, Clause-② not triggered, every check green, no serial collision ⇒ enqueued.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions