Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/gentle-pears-invent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@object-ui/react': patch
'@object-ui/types': patch
'@object-ui/core': patch
'@object-ui/i18n': patch
---

Emit explicit file extensions on relative import specifiers, so the published
entries can be imported by Node's own ESM resolver.

`@object-ui/react`'s built entry re-exported through extensionless relative
specifiers (`export * from './SchemaRenderer'`). Node does not extension-search
relative specifiers, so `import('@object-ui/react')` under plain Node — an SSR
host, or any consumer without a bundler — failed with `ERR_MODULE_NOT_FOUND`.
Bundled consumers were never affected and are unchanged by this.

`@object-ui/types`, `@object-ui/core` and `@object-ui/i18n` carried the same
emission; `@object-ui/react`'s entry stayed unloadable until they were fixed
too, because evaluation crosses into them. No exported API changed.
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,24 @@ jobs:
if: steps.relevant.outputs.should_run == 'true'
run: pnpm check:self-import

# Node's ESM resolver does not extension-search relative specifiers, so an
# extensionless `./SchemaRenderer` in a published `dist/` is unloadable
# under plain Node — `@object-ui/react`'s entry died with
# ERR_MODULE_NOT_FOUND (objectui#4538). `tsc` never rewrites specifiers, so
# for a package whose build preserves them the SOURCE specifier is the
# emitted one, and the defect can be judged without building anything.
# That is why the cheap leg runs here: it reads sources, needs only the
# install, and catches a regression the moment it is written.
#
# The expensive leg — which BUILDS and actually imports each published
# entry — is `.github/workflows/node-esm-load-gate.yml`. It is not here on
# purpose: it needs a full-repo build, which this repository deliberately
# does not do per pull request (the same trade `published-dist-gate.yml`
# records at length).
- name: Verify published ESM packages emit resolvable relative specifiers
if: steps.relevant.outputs.should_run == 'true'
run: pnpm check:esm-specifiers

# A local type/const declared under a `@objectstack/spec` export's NAME reads
# to the next agent as the spec's own definition — four such symbols had
# already drifted from the spec they claimed to be (objectstack#4115). Needs
Expand Down
89 changes: 89 additions & 0 deletions .github/workflows/node-esm-load-gate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
name: Node ESM Load Gate

# The artifact-level half of objectui#4538: every published ESM package must be
# importable by Node's own resolver, with no bundler and no loader hooks. The
# gate, and why a resolution-only criterion was measured and rejected, are
# documented at length in scripts/check-node-esm-load.mjs.
#
# ── Why this is a scheduled workflow and NOT a pull-request job ──────────────
#
# The load leg has to EVALUATE each entry, so it has to build all 39 published
# packages. This repository deliberately has no per-PR full-repo build —
# `ci.yml`'s `Build & E2E` builds `@object-ui/console` alone, and its `Type
# Check` gets only the DEPENDENCY CLOSURE out of turbo's `dependsOn:
# ["^build"]`, so leaf packages such as `@object-ui/plugin-map` are never built
# there. `published-dist-gate.yml` records the same trade for the same reason,
# and the 2026-08-16 ruling on objectui#4846 rejected paying a full build per
# pull request to catch a defect that reaches a user at publish time.
#
# ⛔ Do not add `pull_request` here. The per-PR half of objectui#4538 is the
# CHEAP leg — `pnpm check:esm-specifiers`, a source-level scan that needs no
# build — and it already runs in `ci.yml`'s `Type Check` job. That leg is the
# ratchet; this workflow is the proof that the ratchet guards the right
# property, because it is the only thing that can catch a package broken by a
# DEPENDENCY rather than by its own sources (measured: four plugin packages
# failing on `packages/mobile/dist/useBreakpoint`, which is not their file).
#
# This workflow is therefore an ALARM, not a merge blocker: it cannot make
# anyone's pull request red, so a slow run costs a second look and nothing else.

on:
workflow_dispatch:

# Nightly at 04:17 UTC — after `published-dist-gate.yml`'s 03:41 so the two
# full builds do not contend, and off the top of the hour because GitHub's
# scheduled queue is most congested, and most delayed, at :00.
schedule:
- cron: '17 4 * * *'

# Changes to the gate itself get a real run. Its unit tests DO run per-PR
# inside the ordinary vitest suite, but they exercise the verdicts over
# synthetic fixtures and cannot exercise a real build.
push:
branches: [main]
paths:
- 'scripts/check-node-esm-load.mjs'
- '.github/workflows/node-esm-load-gate.yml'

concurrency:
group: node-esm-load-gate-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
node-esm-load:
name: Node ESM Load Scan
runs-on: ubuntu-latest
# A full build of every published package plus one child `node` import per
# package. Generous, because a timeout here reads as a gate failure and this
# workflow blocks nobody.
timeout-minutes: 60

steps:
- name: Checkout code
uses: actions/checkout@v7
with:
submodules: true

- name: Enable Corepack
run: corepack enable

- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22.x'
cache: 'pnpm'

- name: Install dependencies
run: pnpm install --frozen-lockfile

# No `pnpm build` step before this one, on purpose. The gate runs the build
# ITSELF and fails when it cannot, so that "there were no artifacts,
# therefore nothing was wrong" can never be this workflow's verdict. A
# published package whose entry is missing after the build is reported as
# `no-build-output`, not skipped, and the run asserts a floor on how many
# entries actually evaluated.
- name: Build every published package and import its entry under plain Node
run: pnpm check:node-esm-load
41 changes: 40 additions & 1 deletion content/docs/guide/ci-cd-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ one has its own section below.
| `shadcn-check.yml` | Check Shadcn Components | Weekly cron `0 9 * * 1`; manual | n/a |
| `check-links.yml` | Check Links | Weekly cron `17 4 * * 0`; manual | n/a — reports, never gates |
| `published-dist-gate.yml` | Published Dist Tooling Scan | Nightly cron `41 3 * * *`; push to `main` touching the gate; manual | No — the blocking copy runs on the publish path, not here |
| `node-esm-load-gate.yml` | Node ESM Load Scan | Nightly cron `17 4 * * *`; push to `main` touching the gate; manual | No — the per-PR half is `pnpm check:esm-specifiers` in **Type Check** |

The path filters explain most "why did nothing run on my PR?" questions:

Expand Down Expand Up @@ -172,7 +173,7 @@ it green — which is how two of `type-check`'s gates came to be missing from th
| Job key | Appears as | What it runs | When |
|---|---|---|---|
| `changeset-check` | Changeset Fixed Group Check | `scripts/check-changeset-fixed.mjs` — every workspace package must be in the changeset `fixed` group or explicitly ignored. It checks group *membership*; it does **not** check whether the PR added a changeset. | Every run |
| `type-check` | Type Check | `scripts/check-type-check-coverage.mjs`, then `pnpm check:phantom-deps`, then `pnpm check:self-import`, then `pnpm check:spec-symbols`, then `pnpm check:action-forward-parity`, then `pnpm check:i18n-keys`, then `pnpm check:i18n-drift`, then `pnpm type-check:scripts`, then `pnpm type-check`, then `pnpm type-check:vitest-setup`. The coverage guard runs first because turbo silently skips packages that have no `type-check` script, so a package without one would otherwise read as passing (#2911). `pnpm check:phantom-deps` fails when a released package imports a bare specifier its own `package.json` does not declare — a *phantom dependency*, invisible locally because the workspace root's `devDependencies` sit on the upward resolution path from every package directory and on no consumer's, so `require.resolve('react', { paths: ['packages/core/src'] })` succeeds while `@object-ui/core` declares react in no field at all ([#4394](https://github.com/objectstack-ai/objectui/issues/4394)). `pnpm check:self-import` runs next because it reuses that gate's parser: it fails when a file inside a package names its OWN package, a specifier that resolves through the package's `exports` map to `dist/` while `type-check` waits on `^build` — the *dependencies'* builds, never the package's own — so on a cold cache the declarations do not exist yet and the file fails with `TS2307`. Locally it is always green, because every local workflow builds before it type-checks and leaves a `dist/` behind; PR #4789's first run was red on exactly one such line ([#4801](https://github.com/objectstack-ai/objectui/issues/4801)). `pnpm check:action-forward-parity` fails when an action renderer's forward whitelist drops a key the action runtime reads — the class that shipped six times one key at a time, each time green, because the key parses and publishes while the payload is dropped one hop before the runner ([#4050](https://github.com/objectstack-ai/objectui/issues/4050)). The two locale gates sit in the middle because both parse the sources with `typescript`: they need the install and nothing built. `pnpm check:i18n-keys` fails when a `t()` call site asks for a key the `en` pack does not define ([#3530](https://github.com/objectstack-ai/objectui/issues/3530)); `pnpm check:i18n-drift` fails when a change to an `en` string is not accompanied by the nine translation packs ([#3650](https://github.com/objectstack-ai/objectui/issues/3650)), and it is why this job's checkout sets `fetch-depth: 0` — it diffs against the merge base, which a depth-1 clone cannot resolve. `pnpm type-check:scripts` (`tsconfig.scripts.json`) covers `scripts/**/*.ts`, which `pnpm type-check` cannot reach at all — `scripts/` has no package.json, so turbo never walks it, and the coverage guard decides coverage per *package*. Until [#3494](https://github.com/objectstack-ai/objectui/issues/3494) that left the pin tests in `scripts/__tests__/` — including the one pinning this very page — compiled by nothing. `pnpm type-check:vitest-setup` (`tsconfig.vitest-setup.json`) closes the same gap for the four repo-root `vitest.setup.*` files, uncovered until [#3515](https://github.com/objectstack-ai/objectui/issues/3515); it runs *last*, after `pnpm type-check`, because `vitest.setup.dom.tsx` side-effect-imports four `@object-ui/*` packages and resolves them through the declarations that turbo's `^build` produces. | Every run; on a PR the steps short-circuit when only ignored paths changed |
| `type-check` | Type Check | `scripts/check-type-check-coverage.mjs`, then `pnpm check:phantom-deps`, then `pnpm check:self-import`, then `pnpm check:esm-specifiers`, then `pnpm check:spec-symbols`, then `pnpm check:action-forward-parity`, then `pnpm check:i18n-keys`, then `pnpm check:i18n-drift`, then `pnpm type-check:scripts`, then `pnpm type-check`, then `pnpm type-check:vitest-setup`. The coverage guard runs first because turbo silently skips packages that have no `type-check` script, so a package without one would otherwise read as passing (#2911). `pnpm check:phantom-deps` fails when a released package imports a bare specifier its own `package.json` does not declare — a *phantom dependency*, invisible locally because the workspace root's `devDependencies` sit on the upward resolution path from every package directory and on no consumer's, so `require.resolve('react', { paths: ['packages/core/src'] })` succeeds while `@object-ui/core` declares react in no field at all ([#4394](https://github.com/objectstack-ai/objectui/issues/4394)). `pnpm check:self-import` runs next because it reuses that gate's parser: it fails when a file inside a package names its OWN package, a specifier that resolves through the package's `exports` map to `dist/` while `type-check` waits on `^build` — the *dependencies'* builds, never the package's own — so on a cold cache the declarations do not exist yet and the file fails with `TS2307`. Locally it is always green, because every local workflow builds before it type-checks and leaves a `dist/` behind; PR #4789's first run was red on exactly one such line ([#4801](https://github.com/objectstack-ai/objectui/issues/4801)). `pnpm check:esm-specifiers` follows it for the same reason — sources only, no build: it fails when a published package whose build preserves import specifiers (a bare emitting `tsc`, which never rewrites them) writes a relative specifier with no file extension. Node's ESM resolver does not extension-search relative specifiers, so such a specifier makes the published entry unloadable outside a bundler; `@object-ui/react`'s entry died with `ERR_MODULE_NOT_FOUND` while every bundler-based consumer, the whole test suite and CI stayed green ([#4538](https://github.com/objectstack-ai/objectui/issues/4538)). The half that actually *imports* each built entry needs a full build and runs in `node-esm-load-gate.yml`. `pnpm check:action-forward-parity` fails when an action renderer's forward whitelist drops a key the action runtime reads — the class that shipped six times one key at a time, each time green, because the key parses and publishes while the payload is dropped one hop before the runner ([#4050](https://github.com/objectstack-ai/objectui/issues/4050)). The two locale gates sit in the middle because both parse the sources with `typescript`: they need the install and nothing built. `pnpm check:i18n-keys` fails when a `t()` call site asks for a key the `en` pack does not define ([#3530](https://github.com/objectstack-ai/objectui/issues/3530)); `pnpm check:i18n-drift` fails when a change to an `en` string is not accompanied by the nine translation packs ([#3650](https://github.com/objectstack-ai/objectui/issues/3650)), and it is why this job's checkout sets `fetch-depth: 0` — it diffs against the merge base, which a depth-1 clone cannot resolve. `pnpm type-check:scripts` (`tsconfig.scripts.json`) covers `scripts/**/*.ts`, which `pnpm type-check` cannot reach at all — `scripts/` has no package.json, so turbo never walks it, and the coverage guard decides coverage per *package*. Until [#3494](https://github.com/objectstack-ai/objectui/issues/3494) that left the pin tests in `scripts/__tests__/` — including the one pinning this very page — compiled by nothing. `pnpm type-check:vitest-setup` (`tsconfig.vitest-setup.json`) closes the same gap for the four repo-root `vitest.setup.*` files, uncovered until [#3515](https://github.com/objectstack-ai/objectui/issues/3515); it runs *last*, after `pnpm type-check`, because `vitest.setup.dom.tsx` side-effect-imports four `@object-ui/*` packages and resolves them through the declarations that turbo's `^build` produces. | Every run; on a PR the steps short-circuit when only ignored paths changed |
| `test` | Test (shard N/4) | `pnpm test --shard=N/4` across a 4-runner matrix with `fail-fast: false`, so every shard reports its own failures. No coverage instrumentation — v8 adds 40–100% overhead. | Pull requests and merge-queue builds (everything but `push`); steps short-circuit on a PR that changed only ignored paths |
| `test-coverage` | Test (coverage) | One unsharded `pnpm test:coverage`, uploaded to Codecov. Nothing blocks on it, which is why it is not sharded. | **Push only** |
| `e2e` | Build & E2E | Builds the console with `vite build` (`VITE_BASE_PATH=/console/`), verifies the artifact, then `pnpm test:e2e --project=chromium`. Uploads the Playwright report on failure. | Every run; on a PR the steps short-circuit when only ignored paths changed |
Expand Down Expand Up @@ -744,6 +745,44 @@ Three things about it are easy to get wrong and are written down in the script's
Check** gets only the dependency closure from turbo's `dependsOn: ["^build"]`, so leaf packages
are never built there. The blocking copy runs on the publish path instead — see below.

### Node ESM Load Gate (`node-esm-load-gate.yml`)

**Trigger:** Nightly cron `17 4 * * *`; push to `main` that touches the gate script or this
workflow; manual. **It carries no `pull_request` trigger, on purpose.**

Every published ESM package must be importable by Node's own resolver — no bundler, no loader
hooks. The gate is `scripts/check-node-esm-load.mjs` and it has two legs, only one of which runs
here:

- **The specifier leg** (`pnpm check:esm-specifiers`) reads *sources* and needs no build, so it
runs per pull request in **Type Check**, not in this workflow. It is the ratchet: for a package
whose build preserves import specifiers, the emitted specifier *is* the source specifier,
because `tsc` never rewrites them.
- **The load leg** (`pnpm check:node-esm-load`) builds every published package and then actually
`import()`s each entry in a child `node`. That is what runs here, and it needs the full build
this repository has no per-PR copy of — the same trade `published-dist-gate.yml` records above.

Both legs exist because neither is honest alone
([#4538](https://github.com/objectstack-ai/objectui/issues/4538)):

- **Resolving the entry is not enough.** The card was filed from `@object-ui/plugin-charts`,
whose own emitted entry resolves perfectly; the failure appeared only once evaluation crossed
into `@object-ui/react`. A check that stops at resolution passes while the tree is broken, so
the load leg evaluates.
- **The specifier leg cannot see a defect that arrives through a dependency.** In the first full
run, four plugin packages failed on `packages/mobile/dist/useBreakpoint` — not their file. The
gate therefore attributes a missing module to the package that *owns* it, so one cause produces
one finding against one owner.
- **It must never pass vacuously.** "Imported nothing, found nothing" is the verdict this gate may
never give, so both legs assert a floor on how much they inspected, and a published entry that
is missing after the build is a finding (`no-build-output`), not a skip.

Packages that still carry the defect are named in the script's `SPECIFIER_DEBT` ledger with a
reason each. The ledger is a ratchet, not a mute button: an entry whose package has become clean
is itself a failure, so it cannot outlive the debt it records. Packages that are not importable
by design — a `bin`-only CLI, or a built web app whose `dist` is `index.html` — are printed on
every run rather than skipped silently.

### Changeset Guard (`changeset-guard.yml`)

**Trigger:** PR to `main`/`develop`, and push to `main`, **when `.changeset/**` changes** — the
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
"check:action-forward-parity": "node scripts/check-action-forward-parity.mjs",
"check:phantom-deps": "node scripts/check-phantom-dependencies.mjs",
"check:self-import": "node scripts/check-package-self-import.mjs",
"check:esm-specifiers": "node scripts/check-node-esm-load.mjs --specifiers-only",
"check:node-esm-load": "node scripts/check-node-esm-load.mjs",
"check:control-bytes": "node scripts/check-control-bytes.mjs",
"check:published-dist": "node scripts/check-published-dist-tooling.mjs",
"check:i18n-keys": "node scripts/check-i18n-call-site-keys.mjs",
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/actions/ActionEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* ActionSchema[] → ActionEngine → ActionRunner → ActionResult
*/

import { ActionRunner, type ActionDef, type ActionContext, type ActionResult } from './ActionRunner';
import { ActionRunner, type ActionDef, type ActionContext, type ActionResult } from './ActionRunner.js';
import { toPredicateInput } from '../evaluator/predicateInput.js';
import { hasDeclaredPredicate } from '../evaluator/declaredPredicate.js';

Expand Down
Loading
Loading