diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6372b2d2bdb..1ea57dcd62b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -150,6 +150,25 @@ jobs: # Gates the `test` job ONLY, ORed with `core` — see the `crosspkg` # filter below and the `test` job's `if:` (#9829, #10015). crosspkg: ${{ steps.changes.outputs.crosspkg || 'true' }} + # The objectui tree under `ui/` (objectstack#25677). Three outputs, read + # together by the four `* UI` jobs at the bottom of this file: + # ui — a path under ui/, or a root file the ui jobs build + # from (the lockfile, the workspace, turbo.json, ci.yml); + # ui_consumers — one of the seven packages objectui installs from this + # tree changed, so the Console is compiled (Type Check + # UI) and built (Build & E2E UI) against the new shape in + # the PR that moves it; + # ui_tree — `ui/package.json` exists in this checkout. + # `ui_tree` is what keeps the ui jobs SKIPPED, not failed, on a tree that + # does not carry ui/ yet: `ui` and `ui_consumers` fall back to 'true' on + # merge_group / schedule / dispatch (half 1 below), and on push to main a + # spec change sets `ui_consumers`, so neither path output alone can tell a + # job there is nothing to build. Its step runs on every event, so the + # `|| 'true'` fallback is reached only when this job died before it — and + # then THE FILTER CONTRACT's "run everything" is the intended reading. + ui: ${{ steps.changes.outputs.ui || 'true' }} + ui_consumers: ${{ steps.changes.outputs.ui_consumers || 'true' }} + ui_tree: ${{ steps.ui-tree.outputs.present || 'true' }} # ── THE FILTER CONTRACT, both halves (#4928) ────────────────────────── # # Half 1 is the `|| 'true'` above: when in doubt, RUN EVERYTHING. It @@ -185,6 +204,19 @@ jobs: - name: Checkout repository uses: actions/checkout@v7 + # No `if:` on purpose: every event needs this answer, and a skipped step + # would hand `ui_tree` the `|| 'true'` fallback on a tree without ui/. + - name: Detect the imported ui/ tree + id: ui-tree + run: | + if [ -f ui/package.json ]; then + echo 'present=true' >> "$GITHUB_OUTPUT" + echo 'ui/package.json is present: the ui jobs are scheduled by the ui / ui_consumers outputs.' + else + echo 'present=false' >> "$GITHUB_OUTPUT" + echo 'ui/package.json is absent: every ui job skips on this run.' + fi + # ⭐ SPELLED AS AN ALLOW-LIST, not as `!= 'merge_group'` (#16467). This # action reads a diff, and the only two events that hand it one it can # resolve unaided are `pull_request` (base vs head) and `push` @@ -435,6 +467,26 @@ jobs: # narrower audits entry is left as the package that declared it # spelled it, exactly as the two narrower `skills/` entries were. - 'docs/**' + # The objectui tree and the root files the ui jobs build from. A + # change to the guard runs the guard, so ci.yml itself is listed. + ui: + - 'ui/**' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'turbo.json' + - '.github/workflows/ci.yml' + # The seven packages objectui installs from this tree (the set its + # `.objectstack-pin` used to pack). A change here compiles and builds + # the Console in the same PR, which is the check Spec Shape Gate runs + # hourly against a clone of objectui main until the import retires it. + ui_consumers: + - 'packages/spec/**' + - 'packages/client/**' + - 'packages/core/**' + - 'packages/formula/**' + - 'packages/lint/**' + - 'packages/sdui-parser/**' + - 'packages/types/**' test: # Sharded 6-way BY PACKAGE: a core-touching PR ran the affected suite @@ -3222,3 +3274,575 @@ jobs: with: path: packages/console/dist key: ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh', 'scripts/assert-console-spec-injection.mjs', 'scripts/console-spec-probes.mjs', 'packages/spec/package.json', 'packages/spec/tsup.config.ts', 'packages/core/package.json', 'packages/types/package.json', 'packages/client/package.json', 'packages/formula/package.json', 'packages/sdui-parser/package.json', 'packages/lint/package.json') }} + + # ── THE ui/ JOBS (objectstack#25697, epic objectstack#25677) ────────────── + # objectui is imported under `ui/` as a subtree. These four jobs are its CI, + # ported from objectui's own ci.yml (`Type Check`, `Test (shard n/16)`, + # `Build & E2E`) and lint.yml (`Lint`), and they land BEFORE the import: + # + # - every one is gated on the `filter` job's `ui_tree` output, so on a tree + # without `ui/package.json` it is SKIPPED on every event, never failed — + # see the comment on the `ui` outputs at the top of this file; + # - none is a required context, and none is in an aggregator's `needs:`. + # Joining the required set is the maintainer's ruleset edit at cutover. + # + # What the port changed, everywhere, and why: + # - objectui's `.objectstack-pin` plumbing (restore / build / save the + # pinned @objectstack build) is gone. The seven packages objectui installs + # are workspace packages here, so each job builds them from this tree with + # turbo instead (its "Build the @objectstack packages objectui consumes" + # step; the seven names match the `ui_consumers` filter). + # - objectui's per-job "Decide whether this change needs a full run" step is + # replaced by the `filter` job's `ui` / `ui_consumers` outputs. + # - objectui's own commands are spelled `pnpm --dir ui run ` + # (or `pnpm --dir ui exec ...`), so they run with cwd = the objectui root, + # which they assume. ⛔ Not `node scripts/` or `pnpm run ` under + # `working-directory: ui`: this repo's workflow gates (check-self-test- + # wired, dispatch-gates' derivation and others) key `scripts/` and + # `pnpm ` against THIS repo's root and do not read + # `working-directory`, so objectui's `scripts/check-entry-guard.mjs` + # would be credited to objectstack's file of the same name, and a name + # only objectui has would read as a missing script; + # - turbo runs from this repo's root with directory filters, because + # `ui/package.json`'s own `lint` / `type-check` scripts are + # `turbo run ` and would recurse. + # - objectui's root-only turbo tasks `//#lint:root` and `//#type-check:e2e` + # are steps here (turbo.json carries no dependsOn on a package that does + # not exist yet; turbo refuses the whole run over one). + # - governance steps (the cross-repo closer contract) are not ported; they + # belong to the governance-workflow audit (sub-issue 4 of the epic). + + type-check-ui: + name: Type Check UI + needs: filter + if: ${{ !cancelled() && needs.filter.outputs.ui_tree != 'false' && (needs.filter.outputs.ui != 'false' || needs.filter.outputs.ui_consumers != 'false') }} + runs-on: ubuntu-latest + timeout-minutes: 40 + permissions: + contents: read + env: + # @object-ui/app-shell's and the Console's tsc need ~2.3 GB and OOM at + # Node's default heap (the import spike, objectstack#25678 finding 2). + NODE_OPTIONS: --max-old-space-size=4096 + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + # The affected scope below asks turbo for the packages changed since + # the base commit, which needs the base's history in the clone. + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22' + + - name: Setup pnpm + uses: ./.github/actions/setup-pnpm + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v6 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store-v3- + + - name: Restore Turbo cache + uses: actions/cache/restore@v6 + with: + path: .turbo/cache + key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}- + ${{ runner.os }}-turbo-${{ github.job }}- + ${{ runner.os }}-turbo-build-core-${{ github.ref_name }}- + ${{ runner.os }}-turbo-build-core- + + # objectui runs this first because it is instant and catches the + # structural mistake: turbo skips a package with no `type-check` script, + # so without it a package that lacks one reads as passing. + - name: Verify type-check coverage + run: pnpm --dir ui run type-check:coverage + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # The seven packages objectui installs from this tree, and their own + # dependencies. The repo-root programs below import them before any + # turbo type-check task has built them. + - name: Build the @objectstack packages objectui consumes + run: | + pnpm exec turbo run build --concurrency=4 \ + --filter=@objectstack/spec... \ + --filter=@objectstack/client... \ + --filter=@objectstack/core... \ + --filter=@objectstack/formula... \ + --filter=@objectstack/lint... \ + --filter=@objectstack/sdui-parser... \ + --filter=@objectstack/types... + + - name: Verify every imported package is declared by the package that ships it + run: pnpm --dir ui run check:phantom-deps + + - name: Verify no package imports itself by name + run: pnpm --dir ui run check:self-import + + - name: Verify no covered package ships a source file nothing reaches + run: pnpm --dir ui run check:unreferenced-sources + + - name: Verify no doc comment prescribes a spelling a shared reader owns + run: pnpm --dir ui run check:doc-example-readers + + - name: Verify every handler key a registered renderer reads is declared by its arm + run: pnpm --dir ui run check:handler-key-reads + + - name: Verify every object-metadata write door applies the write guard + run: pnpm --dir ui run check:metadata-write-doors + + - name: Verify published build tsconfigs exclude tooling directories + run: pnpm --dir ui run check:published-tsconfig-exclude + + - name: Verify every sideEffects array names exactly its registering modules + run: pnpm --dir ui run check:side-effects-array + + - name: Verify every ElementDataSourceGate wrapper reaches the declaration seam + run: pnpm --dir ui run check:element-data-source-declaration + + - name: Verify published ESM packages emit resolvable relative specifiers + run: pnpm --dir ui run check:esm-specifiers + + - name: Verify spec-named symbols are derived, not hand-written + run: pnpm --dir ui run check:spec-symbols + + - name: Verify action renderers forward every key the runtime reads + run: pnpm --dir ui run check:action-forward-parity + + - name: Verify designer field payloads declare only keys FieldSchema accepts + run: pnpm --dir ui run check:designer-field-key-parity + + - name: Verify authored icon names are live lucide icons keys + run: pnpm --dir ui run check:icon-record-names + + - name: Verify t() call-site keys exist in the en locale pack + run: pnpm --dir ui run check:i18n-keys + + - name: Verify changed en strings were followed by the nine translations + run: pnpm --dir ui run check:i18n-drift + + - name: Verify the metadata-admin designer string tables stay paired + run: pnpm --dir ui run check:i18n-designer-parity + + - name: Type-check the ui scripts + run: pnpm --dir ui run type-check:scripts + + - name: Type-check the ui root Vitest config + run: pnpm --dir ui run type-check:vitest-config + + # objectui's `//#type-check:e2e` root task, which its turbo.json made a + # dependency of every `type-check`; here it is this step. + - name: Type-check the ui e2e suite + run: pnpm --dir ui run type-check:e2e + + # A pull_request or merge_group run type-checks the ui packages turbo + # reports changed since the base, plus their dependents — a change to + # one of the seven @objectstack packages selects every ui package that + # depends on it, which is how a spec change compiles the Console here. + # Every other event, and any run without a base, type-checks every ui + # package. The exclusions keep the task on ui packages: this repo's own + # packages spell the script `typecheck` and Type Check · workspace in + # lint.yml owns them, and `./ui` is objectui's root manifest, whose + # `type-check` script is `turbo run type-check` itself. + - name: Run type-check over the ui packages + env: + EVENT: ${{ github.event_name }} + PR_BASE: ${{ github.event.pull_request.base.sha }} + QUEUE_BASE: ${{ github.event.merge_group.base_sha }} + TURBO_RUN_SUMMARY: 'true' + run: | + case "$EVENT" in + pull_request) base="$PR_BASE" ;; + merge_group) base="$QUEUE_BASE" ;; + *) base='' ;; + esac + if [ -n "$base" ]; then + echo "Affected scope: ui packages changed since $base, and their dependents." + pnpm exec turbo run type-check --concurrency=4 \ + "--filter=...[$base]" \ + --filter='!./ui' \ + --filter='!./packages/**' \ + --filter='!./apps/**' \ + --filter='!./examples/**' + else + echo "Full scope: every ui package ($EVENT run)." + pnpm exec turbo run type-check --concurrency=4 \ + --filter='./ui/packages/*' \ + --filter='./ui/apps/*' \ + --filter='./ui/examples/*' + fi + + - name: Type-check ui repo-root vitest setup files + run: pnpm --dir ui run type-check:vitest-setup + + - name: Save Turbo cache (main only) + if: always() && github.event_name == 'push' + uses: actions/cache/save@v6 + with: + path: .turbo/cache + key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }} + + # ── Test UI: the shard count ──────────────────────────────────────────── + # N = 16, objectui's own matrix, chosen the way scripts/partition-test-shards.mjs + # chooses Test Core's: the slowest leg within MAX_SHARD_OVER_MEAN (1.3) of the + # mean, and no more legs than that bound and the job timeout need. + # + # The unit is a FILE here, not a package, because objectui is one vitest root + # (`vitest run` with its unit / dom / dom-heavy projects) whose sequencer + # (ui/scripts/test-shard-sequencer.mjs) already places files on legs by + # measured duration, LPT, from ui/scripts/test-shard-timings.json — the same + # algorithm partition-test-shards.mjs runs over packages. Measured over that + # dataset (4979 files, 325.7 summed file-minutes, heaviest file 76.6 s): + # - by PACKAGE, @object-ui/app-shell alone is 104.2 of the 325.7 minutes, + # so the 1.3 bound admits at most 4 legs (325.7 x 1.3 / 104.2 = 4.06) + # and every leg would carry ~81 summed minutes; + # - by FILE, LPT gives max/mean = 1.000 at 6, 8, 12, 16 and 24 legs, so the + # bound never binds and N is set by the wall clock: 16 legs carry 20.4 + # summed file-minutes each, which objectui's 20-minute timeout was sized + # against (vitest runs those files across the runner's workers). + # Raising N past 16 only adds runners; lowering it pushes a leg past the + # timeout objectui measured. ⛔ The dataset's keys are paths relative to the + # vitest root, so the suite must run with cwd = ui/ (it does, below); run + # from this repo's root every key misses and every file weighs the median. + # + # Every event runs the whole suite. objectui's affected-test selector + # (ui/scripts/select-affected-tests.mjs) reads `git diff --name-only`, which + # prints paths relative to the GIT root — `ui/packages/...` here — against a + # package graph spelled relative to the objectui root, so it is not wired + # until objectui's repo-root lookups are migrated (sub-issue 2 of the epic). + test-ui: + name: Test UI (${{ matrix.shard }}/16) + needs: filter + if: ${{ !cancelled() && needs.filter.outputs.ui_tree != 'false' && needs.filter.outputs.ui != 'false' }} + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16] + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22' + + - name: Setup pnpm + uses: ./.github/actions/setup-pnpm + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v6 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store-v3- + + - name: Restore Turbo cache + uses: actions/cache/restore@v6 + with: + path: .turbo/cache + key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}- + ${{ runner.os }}-turbo-${{ github.job }}- + ${{ runner.os }}-turbo-build-core-${{ github.ref_name }}- + ${{ runner.os }}-turbo-build-core- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # objectui's suite resolves its own packages from source and the seven + # @objectstack packages from their built dist, which the pin used to + # supply prebuilt. + - name: Build the @objectstack packages objectui consumes + run: | + pnpm exec turbo run build --concurrency=4 \ + --filter=@objectstack/spec... \ + --filter=@objectstack/client... \ + --filter=@objectstack/core... \ + --filter=@objectstack/formula... \ + --filter=@objectstack/lint... \ + --filter=@objectstack/sdui-parser... \ + --filter=@objectstack/types... + + # objectui's full-battery runs also pass its per-file timings reporter + # and upload the result for the weekly dataset refresh. Both belong to + # that refresh workflow, which is in the governance-workflow audit + # (sub-issue 4 of the epic); until it is ported the committed dataset + # is what the sequencer reads. + - name: Run the ui tests (shard ${{ matrix.shard }}/16) + run: pnpm --dir ui run test --shard=${{ matrix.shard }}/16 --reporter=default --reporter=github-actions + + - name: Save Turbo cache (main only) + if: always() && github.event_name == 'push' && matrix.shard == 1 + uses: actions/cache/save@v6 + with: + path: .turbo/cache + key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }} + + lint-ui: + name: Lint UI + needs: filter + if: ${{ !cancelled() && needs.filter.outputs.ui_tree != 'false' && needs.filter.outputs.ui != 'false' }} + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22' + + - name: Setup pnpm + uses: ./.github/actions/setup-pnpm + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v6 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store-v3- + + - name: Restore Turbo cache + uses: actions/cache/restore@v6 + with: + path: .turbo/cache + key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}- + ${{ runner.os }}-turbo-${{ github.job }}- + ${{ runner.os }}-turbo-build-core-${{ github.ref_name }}- + ${{ runner.os }}-turbo-build-core- + + - name: Verify ui lint coverage + run: pnpm --dir ui run lint:coverage + + - name: Verify every ui scripts entry guard goes through one predicate + run: | + pnpm --dir ui run check:entry-guard --self-test + pnpm --dir ui run check:entry-guard + + - name: Verify the ui shell holds the bash 3.2 floor + run: | + pnpm --dir ui run check:bash32-floor --self-test + pnpm --dir ui run check:bash32-floor + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build the @objectstack packages objectui consumes + run: | + pnpm exec turbo run build --concurrency=4 \ + --filter=@objectstack/spec... \ + --filter=@objectstack/client... \ + --filter=@objectstack/core... \ + --filter=@objectstack/formula... \ + --filter=@objectstack/lint... \ + --filter=@objectstack/sdui-parser... \ + --filter=@objectstack/types... + + # objectui's `//#lint:root` root task, which its turbo.json made a + # dependency of every `lint`; here it is this step. + - name: Lint the ui repo-root files + run: pnpm --dir ui run lint:root + + - name: Lint the ui packages + env: + TURBO_RUN_SUMMARY: 'true' + run: | + pnpm exec turbo run lint --concurrency=4 \ + --filter='./ui/packages/*' \ + --filter='./ui/apps/*' \ + --filter='./ui/examples/*' + + - name: Check every vi.mock override matches the declared export shape + run: pnpm --dir ui run check:vi-mock-override-shape + + - name: Verify no ui test resolves a repository path from the process cwd + run: pnpm --dir ui run check:test-path-roots + + - name: Build the ui CLI the self-check runs + run: pnpm exec turbo run build --concurrency=4 --filter='./ui/packages/cli...' + + - name: Verify the ui CLI's own check command passes on the ui tree + run: pnpm --dir ui run check + + - name: Save Turbo cache (main only) + if: always() && github.event_name == 'push' + uses: actions/cache/save@v6 + with: + path: .turbo/cache + key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }} + + build-e2e-ui: + name: Build & E2E UI + needs: filter + if: ${{ !cancelled() && needs.filter.outputs.ui_tree != 'false' && (needs.filter.outputs.ui != 'false' || needs.filter.outputs.ui_consumers != 'false') }} + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: read + env: + # The Console's build runs tsc over ~3.5k files and OOMs at Node's default + # heap (the import spike, objectstack#25678 finding 2). + NODE_OPTIONS: --max-old-space-size=4096 + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22' + + - name: Setup pnpm + uses: ./.github/actions/setup-pnpm + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v6 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store-v3- + + - name: Restore Turbo cache + uses: actions/cache/restore@v6 + with: + path: .turbo/cache + key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}- + ${{ runner.os }}-turbo-${{ github.job }}- + ${{ runner.os }}-turbo-build-core-${{ github.ref_name }}- + ${{ runner.os }}-turbo-build-core- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build the @objectstack packages objectui consumes + run: | + pnpm exec turbo run build --concurrency=4 \ + --filter=@objectstack/spec... \ + --filter=@objectstack/client... \ + --filter=@objectstack/core... \ + --filter=@objectstack/formula... \ + --filter=@objectstack/lint... \ + --filter=@objectstack/sdui-parser... \ + --filter=@objectstack/types... + + - name: Build the Console for E2E + env: + VITE_BASE_PATH: /console/ + run: pnpm --dir ui/apps/console exec vite build + + - name: Verify the Console build artifact + run: | + if [ ! -f ui/apps/console/dist/index.html ]; then + echo "::error::ui/apps/console/dist/index.html is missing: the Console build produced nothing." + exit 1 + fi + echo "Console build artifact is ready." + + - name: Get the Playwright version + id: playwright-version + run: | + set -eo pipefail + if ! version=$(pnpm --dir ui list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].version'); then + echo "::error::Reading the @playwright/test version failed (pnpm list --json | jq). Refusing to write a Playwright browser cache key from it." + exit 1 + fi + if [ -z "$version" ] || [ "$version" = "null" ]; then + echo "::error::Could not resolve the @playwright/test version (got: '${version}'). Refusing to write an empty or null version into the Playwright browser cache key." + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Restore Playwright browsers + id: playwright-cache + uses: actions/cache/restore@v6 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }} + + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: pnpm --dir ui exec playwright install chromium + + # Run from inside ui/scripts: the script is cwd-agnostic apart from its + # `pnpm exec playwright`, which resolves ui/package.json upwards. + - name: Ensure Chromium can launch + working-directory: ui/scripts + run: bash ensure-chromium-ready.sh + + - name: Run the ui E2E tests + run: pnpm --dir ui run test:e2e --project=chromium + + - name: Upload failure artefacts + if: ${{ !cancelled() && failure() }} + uses: actions/upload-artifact@v7 + with: + name: ui-e2e-failure-artifacts + path: | + ui/playwright-report/ + ui/test-results/ + retention-days: 14 + + - name: Save Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v6 + timeout-minutes: 8 + continue-on-error: true + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }} + + - name: Save Turbo cache (main only) + if: always() && github.event_name == 'push' + uses: actions/cache/save@v6 + with: + path: .turbo/cache + key: ${{ runner.os }}-turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }} diff --git a/packages/spec/scripts/check-duration-unit-keys.ts b/packages/spec/scripts/check-duration-unit-keys.ts index f6705a4f958..dc7be2ef82b 100644 --- a/packages/spec/scripts/check-duration-unit-keys.ts +++ b/packages/spec/scripts/check-duration-unit-keys.ts @@ -313,6 +313,14 @@ export const ROOT_DIR_WATCH_HINTS = [ 'packages/connectors/*/src/**', 'apps/*/src/**', 'examples/*/src/**', + // The objectui tree, imported under `ui/` (objectstack#25677 / #25697): four + // workspace roots pnpm-workspace.yaml declares ahead of the import. Each is a + // live workspace root (the self-test holds both directions), and each walks + // nothing until `ui/` arrives. + 'ui/src/**', + 'ui/packages/*/src/**', + 'ui/apps/*/src/**', + 'ui/examples/*/src/**', ]; /** Canonical unit → every spelling the describe prose or a key token may use. */ diff --git a/packages/spec/scripts/check-llms-txt.ts b/packages/spec/scripts/check-llms-txt.ts index 10dc603ceac..de07faea986 100644 --- a/packages/spec/scripts/check-llms-txt.ts +++ b/packages/spec/scripts/check-llms-txt.ts @@ -227,6 +227,10 @@ export const DECLARED_WATCH_HINTS = [ 'packages/**/package.json', 'apps/*/package.json', 'examples/*/package.json', + // The objectui tree's workspace roots (`ui`, `ui/packages/*`, `ui/apps/*`, + // `ui/examples/*`, objectstack#25677). This gate opens every manifest under + // them once the tree is imported; until then the globs match nothing. + 'ui/**/package.json', ]; /** A domain directory with no `*.zod.ts` under it is not a schema domain. */ diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0e48f1449cd..83e4454c6bd 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,7 +10,23 @@ packages: - packages/connectors/* - apps/* - examples/* + # The objectui tree, once it is imported under ui/ (objectstack#25677). Until + # then these globs match no directory and pnpm resolves exactly the workspace + # above: the lockfile, `pnpm install` and `pnpm -r ls` are unchanged. `ui` + # itself is a member because objectui's root package.json carries the scripts + # its CI runs (`lint:root`, `type-check:e2e`, the `check:*` gates). objectui's + # own `docs` workspace entry is not carried: it names no package. + # objectui's pnpm overrides and its patchedDependencies entry arrive with the + # import, not here: an unused patch fails `pnpm install` and an override + # rewrites this lockfile. + - ui + - ui/packages/* + - ui/apps/* + - ui/examples/* +# objectui declares no onlyBuiltDependencies of its own (neither in its +# pnpm-workspace.yaml nor in its package.json `pnpm` field), so the union of +# the two allowlists is this list unchanged. onlyBuiltDependencies: - better-sqlite3 - esbuild diff --git a/scripts/check-cross-package-test-inputs.mjs b/scripts/check-cross-package-test-inputs.mjs index 2db8de4396c..3135e8aa8ac 100644 --- a/scripts/check-cross-package-test-inputs.mjs +++ b/scripts/check-cross-package-test-inputs.mjs @@ -426,7 +426,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ // turbo.json declares, one per recognised name -- so the pin moves when either // set does. That is the point: a directory dropping out of turbo.json is a // declaration disappearing, which is worth a red. - 'the TOP-LEVEL WHITELIST, reconciled against turbo.json (#18342)': 26, + 'the TOP-LEVEL WHITELIST, reconciled against turbo.json (#18342)': 30, }); // DELETING an entry silences that battery's floor exactly as effectively as @@ -1435,6 +1435,22 @@ export const TOP_LEVEL_DIRECTORIES_PENDING = Object.freeze({ '.github': 'over-collection census (#18342): two literals, both non-reads; disposition reserved to the seat', }); +/** + * Top-level directories a `$TURBO_ROOT$` input declares AHEAD of their arrival in + * the tree, each with the reason. A name here is excused from the "tracked as + * neither file nor directory" case ONLY while git tracks nothing under it; the + * moment it is tracked it lands in `directories` and owes a place in + * `RECOGNISED_TOP_LEVEL_DIRECTORIES` or `TOP_LEVEL_DIRECTORIES_PENDING` like any + * other, so the import commit decides how this collector treats it. + * + * `ui`: the objectui tree, imported under `ui/` (objectstack#25677). turbo.json's + * `type-check` and `lint` tasks carry objectui's repo-root inputs re-rooted there. + * Retire the entry with the import. + */ +export const TOP_LEVEL_DIRECTORIES_AWAITING_IMPORT = Object.freeze({ + ui: 'the objectui import (objectstack#25677): turbo.json declares its type-check and lint inputs ahead of the tree', +}); + /** Repo-root FILES declared as `$TURBO_ROOT$` inputs -- the fourth shape, pinned, not collected. */ export const TURBO_ROOT_FILE_INPUTS = Object.freeze(['sdui.manifest.json']); @@ -4353,7 +4369,27 @@ function selfTest() { // Non-vacuity first: a reading of zero would green every per-directory case // below by having none of them run. ok('turbo.json still declares $TURBO_ROOT$ inputs at all', live.directories.size > 0); - ok('no declared top-level segment is tracked as neither file nor directory', live.unknown.size === 0); + const awaitingImport = new Set(Object.keys(TOP_LEVEL_DIRECTORIES_AWAITING_IMPORT)); + const unexcused = [...live.unknown].filter((name) => !awaitingImport.has(name)); + ok( + `no declared top-level segment is tracked as neither file nor directory (unexcused: ${unexcused.join(', ') || 'none'})`, + unexcused.length === 0, + ); + // The excuse is narrow in both directions: it covers only the names it lists, + // and only while git tracks nothing under them. + ok( + 'an unknown segment the awaiting-import map does not list is still unexcused', + [...new Set(['zzqq-25697', ...live.unknown])].filter((name) => !awaitingImport.has(name)).includes('zzqq-25697'), + ); + ok( + 'an awaiting-import name git already tracks is NOT excused here (it must be recognised or pending)', + [...awaitingImport].every((name) => !live.directories.has(name) || recognised.has(name) || pending.has(name)), + ); + ok('the awaiting-import map holds exactly the measured arrivals', [...awaitingImport].sort().join() === 'ui'); + ok( + 'every awaiting-import entry carries a reason', + Object.values(TOP_LEVEL_DIRECTORIES_AWAITING_IMPORT).every((r) => typeof r === 'string' && r.length > 0), + ); for (const dir of [...live.directories].sort()) { ok( `turbo.json declares "${dir}/" and the collector accounts for it (recognised, or pending with a reason)`, diff --git a/scripts/check-dispatcher-error-vocabulary.mjs b/scripts/check-dispatcher-error-vocabulary.mjs index c3e52398d73..5787b0d02bf 100644 --- a/scripts/check-dispatcher-error-vocabulary.mjs +++ b/scripts/check-dispatcher-error-vocabulary.mjs @@ -288,7 +288,8 @@ * accept set, deliberately not taken here. */ -import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'; +import { readFileSync, readdirSync, statSync, existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; // [#15487] The SHARED half of the regex-literal recogniser: the 14-word // `REGEX_AFTER_KEYWORD` set, `IDENT_CHAR`, and the literal WALK (character // class, backslash sequences, line-terminator refusal, flags) live ONCE, in @@ -493,12 +494,46 @@ function expandWorkspaceMembers(root, glob) { * passes {@link expandWorkspaceMembers} — production and `--self-test` reading * two different populations is the defect #17145 carded, not a spare knob. */ -export function emptyWorkspaceGlobs({ readGlobs, expand }) { +export function emptyWorkspaceGlobs({ readGlobs, expand, awaiting = new Set() }) { return readGlobs() .filter((glob) => !isExclusionGlob(glob)) + .filter((glob) => !awaiting.has(glob)) .filter((glob) => expand(glob).length === 0); } +/** + * Workspace globs `pnpm-workspace.yaml` declares AHEAD of the tree they match: + * the objectui import under `ui/` (objectstack#25677, wired by #25697). Each + * is excused from the empty-glob refusal above ONLY while + * {@link AWAITING_IMPORT_MARKER} is absent; the moment the import lands, every + * one of them must expand to members or it is the finding it always was. The + * self-test holds the map against the live workspace (every key is a declared + * glob) and against both states of the marker. Retire the map with the import. + */ +export const WORKSPACE_GLOBS_AWAITING_IMPORT = Object.freeze({ + ui: 'objectui import (objectstack#25677): the `ui` root manifest itself (`object-ui-monorepo`)', + 'ui/packages/*': 'objectui import (objectstack#25677): the @object-ui/* packages', + 'ui/apps/*': 'objectui import (objectstack#25677): the Console and the docs site', + 'ui/examples/*': 'objectui import (objectstack#25677): objectui example apps', +}); + +/** The file whose presence ends the excuse: the imported tree's root manifest. */ +export const AWAITING_IMPORT_MARKER = 'ui/package.json'; + +/** + * The globs excused from the empty-glob refusal on the tree at `root`: the + * whole {@link WORKSPACE_GLOBS_AWAITING_IMPORT} key set while the marker is + * absent, nothing once it exists. + * + * @param {string} root + * @returns {Set} + */ +export function awaitingImportGlobs(root) { + return existsSync(join(root, AWAITING_IMPORT_MARKER)) + ? new Set() + : new Set(Object.keys(WORKSPACE_GLOBS_AWAITING_IMPORT)); +} + /** * [#16649] Every published workspace member's `src/`, as repo-relative prefixes. * @@ -6145,8 +6180,50 @@ function selfTest() { ok(emptyWorkspaceGlobs({ readGlobs: () => readWorkspaceGlobs(ROOT), expand: (g) => expandWorkspaceMembers(ROOT, g), + awaiting: awaitingImportGlobs(ROOT), }).length === 0, 'the LIVE workspace has a glob that expands to nothing — the production run refuses on this too'); + // [#25697] The awaiting-import excuse, narrow in every direction: only the + // globs it names, only while the marker is absent, and only globs the live + // workspace really declares (so the map cannot outlive the import unnoticed). + ok(emptyWorkspaceGlobs({ + readGlobs: () => ['packages/*', 'ui/packages/*', 'zz-gone/*'], + expand: (g) => (g === 'packages/*' ? ['a'] : []), + awaiting: new Set(['ui/packages/*']), + }).join(',') === 'zz-gone/*', + 'an empty glob the awaiting set names is excused; an empty glob it does not name is still reported by name'); + ok(emptyWorkspaceGlobs({ + readGlobs: () => ['ui/packages/*'], + expand: () => [], + }).join(',') === 'ui/packages/*', + 'with no awaiting set the same empty glob is a finding — the excuse is opt-in at the call site, never a default'); + { + const liveGlobs = new Set(readWorkspaceGlobs(ROOT)); + const keys = Object.keys(WORKSPACE_GLOBS_AWAITING_IMPORT); + ok(keys.every((g) => liveGlobs.has(g)), + `every awaiting-import glob is one pnpm-workspace.yaml declares — stale keys: ` + + `${keys.filter((g) => !liveGlobs.has(g)).join(', ') || 'none'}`); + ok(keys.every((g) => typeof WORKSPACE_GLOBS_AWAITING_IMPORT[g] === 'string' && WORKSPACE_GLOBS_AWAITING_IMPORT[g].length > 0), + 'every awaiting-import entry carries a reason'); + ok(keys.sort().join() === 'ui,ui/apps/*,ui/examples/*,ui/packages/*', + 'the awaiting-import map holds exactly the four ui roots and nothing else'); + // Both states of the marker, on a scratch root: absent → the whole key + // set is excused; present → nothing is, and the import commit meets the + // refusal with no further edit here. + const scratch = mkdtempSync(join(tmpdir(), 'dispatcher-awaiting-')); + try { + ok(awaitingImportGlobs(scratch).size === keys.length, + 'with the marker absent, every awaiting-import glob is excused'); + mkdirSync(join(scratch, 'ui'), { recursive: true }); + writeFileSync(join(scratch, 'ui', 'package.json'), '{"name":"object-ui-monorepo","private":true}\n'); + ok(awaitingImportGlobs(scratch).size === 0, + 'with the marker present, nothing is excused — the excuse voids itself at the import'); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } + ok(existsSync(join(ROOT, AWAITING_IMPORT_MARKER)) === (awaitingImportGlobs(ROOT).size === 0), + 'the live reading agrees with the live marker'); + } // [#17145] The same expansion, shown to be the MANIFEST-filtered one and not // the raw directory listing. Without this the two could drift apart again // silently, which is the whole defect: a glob resolving only to category @@ -6516,6 +6593,7 @@ function main() { const emptyGlobs = emptyWorkspaceGlobs({ readGlobs: () => readWorkspaceGlobs(ROOT), expand: (glob) => expandWorkspaceMembers(ROOT, glob), + awaiting: awaitingImportGlobs(ROOT), }); if (emptyGlobs.length > 0) { throw new Error( diff --git a/scripts/check-published-files.mjs b/scripts/check-published-files.mjs index 71c0412864c..d3c5c6386b8 100644 --- a/scripts/check-published-files.mjs +++ b/scripts/check-published-files.mjs @@ -458,6 +458,13 @@ const ROOT_DIR_WATCH_HINTS = [ 'packages/triggers/*', 'apps/*', 'examples/*', + // The objectui tree's roots (objectstack#25677). `ui/*` stands for the bare + // `ui` member (objectui's root manifest), spelled with a separator for the + // reason above; the other three match its own `/*` globs. + 'ui/*', + 'ui/packages/*', + 'ui/apps/*', + 'ui/examples/*', ]; /** diff --git a/scripts/check-test-source-alias.mjs b/scripts/check-test-source-alias.mjs index aaf00553ca4..5b314207c40 100644 --- a/scripts/check-test-source-alias.mjs +++ b/scripts/check-test-source-alias.mjs @@ -633,8 +633,21 @@ const WORKSPACE_PARENT_GLOBS = [ 'packages/triggers/*', 'apps/*', 'examples/*', + // The objectui tree's package roots (objectstack#25677); they match nothing + // until it is imported under ui/. + 'ui/packages/*', + 'ui/apps/*', + 'ui/examples/*', ]; +/** + * Workspace members pnpm-workspace.yaml names as a BARE directory (no `/*`): + * the one package AT that directory, declared by its manifest path so the entry + * keeps the separator the dispatch deriver needs. `ui` is objectui's root + * manifest (objectstack#25677); absent until the import, like the globs above. + */ +const WORKSPACE_BARE_MEMBER_MANIFESTS = ['ui/package.json']; + /** The parent directories those globs enumerate — each glob minus its leaf. */ const WORKSPACE_PARENT_DIRS = WORKSPACE_PARENT_GLOBS.map((glob) => glob.replace(/\/\*$/, '')); @@ -650,22 +663,24 @@ function readJson(file) { function listWorkspacePackages(root) { const out = []; + const candidates = []; for (const parent of WORKSPACE_PARENT_DIRS) { const abs = join(root, parent); if (!existsSync(abs)) continue; - for (const name of readdirSync(abs)) { - const dir = join(abs, name); - const manifest = join(dir, 'package.json'); - if (!existsSync(manifest)) continue; - let json; - try { - json = readJson(manifest); - } catch { - continue; - } - if (!json.name) continue; - out.push({ name: json.name, dir, rel: relative(root, dir), json }); + for (const name of readdirSync(abs)) candidates.push(join(abs, name)); + } + for (const manifestPath of WORKSPACE_BARE_MEMBER_MANIFESTS) candidates.push(dirname(join(root, manifestPath))); + for (const dir of candidates) { + const manifest = join(dir, 'package.json'); + if (!existsSync(manifest)) continue; + let json; + try { + json = readJson(manifest); + } catch { + continue; } + if (!json.name) continue; + out.push({ name: json.name, dir, rel: relative(root, dir), json }); } return out; } @@ -3322,7 +3337,7 @@ function selfTest() { // docblock (#9955). Asserted here rather than left to review because the // regression is a tidy-up nobody would flag. battery('the declared population must stay READABLE by the dispatch deriver'); - for (const glob of WORKSPACE_PARENT_GLOBS) { + for (const glob of [...WORKSPACE_PARENT_GLOBS, ...WORKSPACE_BARE_MEMBER_MANIFESTS]) { expect( glob.replace(/^(?:\.\.?(?:\/|$))+/, '').includes('/'), `workspace parent ${glob} carries no path separator, so scripts/pm/dispatch-gates.mjs refuses it as too generic and every package under it drops out of the derived gate list`, @@ -3346,7 +3361,12 @@ function selfTest() { // the declaration stays and the live parse becomes its CHECK, in both // directions, the shape check-published-files.mjs already uses. battery('the declaration must still BE the workspace (#11510)'); - const declaredParents = WORKSPACE_PARENT_GLOBS.map((g) => g.replace(/\/\*+$/, '')); + // A bare-directory member is declared by its manifest path; both spellings + // reduce to the directory pnpm-workspace.yaml names. + const declaredParents = [ + ...WORKSPACE_PARENT_GLOBS.map((g) => g.replace(/\/\*+$/, '')), + ...WORKSPACE_BARE_MEMBER_MANIFESTS.map((m) => m.replace(/\/package\.json$/, '')), + ]; const liveParents = readWorkspaceGlobs(REPO_ROOT) .filter((g) => !isExclusionGlob(g)) .map((g) => g.replace(/\/\*+$/, '')); diff --git a/scripts/check-turbo-task-graph.mjs b/scripts/check-turbo-task-graph.mjs index ebc1799c3b2..5b53a2bec79 100644 --- a/scripts/check-turbo-task-graph.mjs +++ b/scripts/check-turbo-task-graph.mjs @@ -159,7 +159,8 @@ * this gate read nothing -- never that it found nothing. */ -import { readFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -224,6 +225,36 @@ export const DECLARED_WATCH_HINTS = [ 'examples/*/package.json', ]; +/** + * Generic keys that are declared AHEAD of the packages that hold them: the + * objectui tree is imported under `ui/` (objectstack#25677), and its packages + * spell these task names, which no package of this workspace declares. The + * root turbo.json carries their definitions from the wiring PR onwards so the + * CI jobs for `ui/` are written once and run on the import commit. + * + * This is a hold, not an exemption from the rule above, and it has three limits: + * + * - it applies ONLY while `UI_IMPORT_MARKER` is absent from the checkout. Once + * `ui/package.json` exists the hold is void and every key below is judged + * like any other generic key, so a key the imported tree does not hold reds + * on the import commit itself; + * - no root script wraps any of these keys, so the shape this limb was written + * for (`pnpm ` exiting 0 over nothing, #12373) is not reachable here. + * The only callers are the `ui/` jobs in ci.yml, which skip while the tree + * is absent; + * - a listed key that turbo.json does not carry is itself a finding, so the + * list cannot outlive the entries it holds. + * + * Retire the whole map, and `UI_IMPORT_MARKER` with it, once the import has + * landed: from then on the hold can never apply. + */ +export const UI_IMPORT_MARKER = 'ui/package.json'; +export const GENERIC_TASKS_AWAITING_UI_IMPORT = Object.freeze({ + 'type-check': "objectui's per-package tsc script (`type-check`, hyphenated); run by the Type Check UI job", + lint: "objectui's per-package eslint script; run by the Lint UI job", + 'test:dist': "objectui's built-dist suite of @object-ui/components (dependsOn build)", +}); + /** The file this gate judges, as the reader spells it on disk. */ const TURBO_CONFIG_FILE = 'turbo.json'; @@ -347,17 +378,32 @@ export function holdersOf(task, scriptsByPackage) { * @param {Record} tasks turbo.json's `tasks` table * @param {Map>} scriptsByPackage package name -> script names * @param {Set} rootScripts the ROOT manifest's script names, for `//#` - * @returns {{ problems: string[], judged: number, genericJudged: number, rootJudged: number }} + * @param {Set} [held] generic keys under the `GENERIC_TASKS_AWAITING_UI_IMPORT` + * hold for THIS checkout -- empty once the import marker exists (see `heldGenericTasks`) + * @returns {{ problems: string[], judged: number, genericJudged: number, rootJudged: number, heldJudged: number }} */ -export function verdict(tasks, scriptsByPackage, rootScripts) { +export function verdict(tasks, scriptsByPackage, rootScripts, held = new Set()) { const problems = []; let judged = 0; let genericJudged = 0; let rootJudged = 0; + let heldJudged = 0; + for (const key of held) { + if (!Object.hasOwn(tasks, key)) { + problems.push( + `"${key}" is held as awaiting the ui/ import, but ${TURBO_CONFIG_FILE} declares no such key.\n` + + ` Delete it from GENERIC_TASKS_AWAITING_UI_IMPORT: a hold over nothing is a leftover.`, + ); + } + } for (const key of Object.keys(tasks)) { const split = splitTaskKey(key); if (!split) { genericJudged += 1; + if (holdersOf(key, scriptsByPackage).length === 0 && held.has(key)) { + heldJudged += 1; + continue; + } if (holdersOf(key, scriptsByPackage).length === 0) { const declaredAnywhere = new Set(); for (const scripts of scriptsByPackage.values()) for (const name of scripts) declaredAnywhere.add(name); @@ -434,7 +480,20 @@ export function verdict(tasks, scriptsByPackage, rootScripts) { ); } } - return { problems, judged, genericJudged, rootJudged }; + return { problems, judged, genericJudged, rootJudged, heldJudged }; +} + +/** + * The generic keys the ui/ import hold covers on the checkout at `root`: every + * key of `GENERIC_TASKS_AWAITING_UI_IMPORT` while `UI_IMPORT_MARKER` is absent, + * and none at all once it exists. + * + * @param {string} root + * @returns {Set} + */ +export function heldGenericTasks(root) { + if (existsSync(join(root, UI_IMPORT_MARKER))) return new Set(); + return new Set(Object.keys(GENERIC_TASKS_AWAITING_UI_IMPORT)); } /** @@ -563,7 +622,13 @@ function main() { throw err; } - const { problems, judged, genericJudged, rootJudged } = verdict(tasks, scriptsByPackage, rootScripts); + const held = heldGenericTasks(ROOT); + const { problems, judged, genericJudged, rootJudged, heldJudged } = verdict( + tasks, + scriptsByPackage, + rootScripts, + held, + ); if (genericJudged === 0) { console.error( @@ -615,7 +680,12 @@ function main() { : '') + ` — every package-scoped one names a package that exists (or the root token ` + `"${ROOT_PACKAGE_TOKEN}") and a script it declares, and every generic one is declared by at ` + - `least one member.`, + `least one member` + + (heldJudged + ? `, except ${heldJudged} held for the ui/ import while ${UI_IMPORT_MARKER} is absent ` + + `(${[...held].sort().join(', ')})` + : '') + + `.`, ); } @@ -659,7 +729,7 @@ function main() { // not red. A battery BELOW its floor means cases stopped running; the remedy is // to find what stopped registering. const SELF_TEST_BATTERIES = Object.freeze({ - 'check-turbo-task-graph self-test': 49, + 'check-turbo-task-graph self-test': 56, }); // DELETING an entry silences that battery's floor exactly as effectively as @@ -869,6 +939,36 @@ export function selfTest() { t('a near-miss of the root token is still an unknown package', notRoot.problems.length === 1); t('a near-miss of the root token is not in the root population', notRoot.rootJudged === 0); + // ── The ui/ import hold, both directions ── + // Held: a generic key nobody declares is NOT a finding while it is held. + const heldGeneric = verdict({ 'type-check': {} }, workspace, rootScripts, new Set(['type-check'])); + t('a generic key held for the ui/ import is not a finding', heldGeneric.problems.length === 0); + t('a held generic key is counted in the held population', heldGeneric.heldJudged === 1); + t('a held generic key is still counted in the generic population', heldGeneric.genericJudged === 1); + // Not held (the marker exists, so the hold set is empty): the same key reds. + const unheldGeneric = verdict({ 'type-check': {} }, workspace, rootScripts, new Set()); + t('the same key without the hold is a finding again', unheldGeneric.problems.length === 1); + // The hold names keys, not a shape: an unlisted inert key beside a held one still reds. + const heldBeside = verdict({ 'type-check': {}, 'test:e2e': {} }, workspace, rootScripts, new Set(['type-check'])); + t('an inert key the hold does not list still reds beside a held one', heldBeside.problems.length === 1 + && heldBeside.problems[0]?.includes('"test:e2e"')); + // A hold over a key turbo.json does not carry is a leftover. + const heldNothing = verdict({ build: {} }, workspace, rootScripts, new Set(['type-check'])); + t('a held key turbo.json does not declare is a finding', heldNothing.problems.length === 1 + && heldNothing.problems[0]?.includes('a hold over nothing')); + // The marker decides: an existing marker empties the hold, an absent one fills it. + t('the hold is void on a checkout carrying the import marker', (() => { + const dir = mkdtempSync(join(tmpdir(), 'turbo-task-graph-ui-hold-')); + try { + const without = heldGenericTasks(dir).size; + mkdirSync(join(dir, 'ui')); + writeFileSync(join(dir, UI_IMPORT_MARKER), '{}'); + return without === Object.keys(GENERIC_TASKS_AWAITING_UI_IMPORT).length && heldGenericTasks(dir).size === 0; + } finally { + rmSync(dir, { recursive: true, force: true }); + } + })()); + // ── Direction 2: the rule must stay GREEN on every legitimate shape ── // `gen:schema` stands where `test:e2e` used to: a generic key held by exactly // ONE package is legitimate, and this case is what keeps the limb above from diff --git a/scripts/check-undeclared-dep-imports.mjs b/scripts/check-undeclared-dep-imports.mjs index 527a16a7197..69edc7e8247 100644 --- a/scripts/check-undeclared-dep-imports.mjs +++ b/scripts/check-undeclared-dep-imports.mjs @@ -238,7 +238,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'the detector STAYS SILENT where it must': 6, 'the comment mask, in both directions': 3, 'tsconfig exclusions are honoured, and only where declared': 2, - 'refusals': 8, + 'refusals': 10, 'provenance: the record must stay reproducible, and visibly so': 8, 'ledger reconciliation, in both directions': 10, 'the declared watch hints stay equal to the real population': 2, @@ -276,7 +276,7 @@ const REPO_ROOT = resolve(HERE, '..'); * root reds here instead of quietly leaving part of the tree unswept and this * gate unnameable. */ -const ROOT_DIR_WATCH_HINTS = ['packages/**', 'apps/**', 'examples/**']; +const ROOT_DIR_WATCH_HINTS = ['packages/**', 'apps/**', 'examples/**', 'ui/**']; // --------------------------------------------------------------------------- // Refusal floors, and the provenance of the census they were derived from -- @@ -331,8 +331,10 @@ const LEDGER_KINDS = new Set(['optional-runtime-probe', 'type-only']); // Workspace discovery /** - * The `packages:` globs from pnpm-workspace.yaml. Only the `/*` shape this - * repo uses is understood; anything else REFUSES rather than being skipped, so + * The `packages:` globs from pnpm-workspace.yaml. Two shapes are understood: + * `/*` (every package one level under ``) and a bare `` with no + * wildcard at all (the one package AT `` -- the objectui root manifest, + * `ui`, objectstack#25677). Anything else REFUSES rather than being skipped, so * a new glob shape cannot silently shrink the population. */ export function workspaceGlobs(yamlText) { @@ -354,12 +356,16 @@ function discoverPackages(root, globs) { const found = []; const bad = []; for (const glob of globs) { - if (!glob.endsWith('/*') || glob.slice(0, -2).includes('*')) { bad.push(glob); continue; } - const parent = join(root, glob.slice(0, -2)); + const bare = !glob.includes('*'); + if (!bare && (!glob.endsWith('/*') || glob.slice(0, -2).includes('*'))) { bad.push(glob); continue; } + const parent = join(root, bare ? glob : glob.slice(0, -2)); + // An absent root contributes nothing, in both shapes: a workspace glob that + // matches no directory is legal pnpm and this gate walks what pnpm walks. if (!existsSync(parent)) continue; - for (const entry of readdirSync(parent, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const dir = join(parent, entry.name); + const candidates = bare + ? [parent] + : readdirSync(parent, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => join(parent, e.name)); + for (const dir of candidates) { const manifestPath = join(dir, 'package.json'); if (!existsSync(manifestPath)) continue; let manifest; @@ -914,6 +920,22 @@ function selfTest() { t('REFUSAL — a glob shape this parser does not understand is fatal, not skipped', typeof badGlob.fatal === 'string'); + // The bare `` shape, both directions: present, its one package is + // swept like any other; absent, it contributes nothing and is not fatal. + const bareDir = mkdtempSync(join(tmp, 'baredir-')); + fixture(bareDir, 'pnpm-workspace.yaml', 'packages:\n - packages/*\n - ui\n'); + fixture(bareDir, 'packages/subject/package.json', JSON.stringify(baseManifest, null, 2)); + fixture(bareDir, 'packages/subject/src/a.ts', 'export const ok = 1;\n'); + fixture(bareDir, 'ui/package.json', JSON.stringify({ name: 'ui-root' }, null, 2)); + fixture(bareDir, 'ui/src/a.ts', "import { x } from '@objectstack/undeclared';\n"); + const bareSwept = sweep(bareDir); + t('a bare `` workspace entry sweeps the one package at that directory', + bareSwept.fatal === undefined + && bareSwept.findings.length === 1 && bareSwept.findings[0].dep === '@objectstack/undeclared'); + const bareAbsent = run({ manifest: baseManifest, files: { 'src/a.ts': 'export const ok = 1;\n' }, workspace: 'packages:\n - packages/*\n - ui\n' }); + t('an ABSENT bare `` entry contributes nothing and is not fatal', + bareAbsent.fatal === undefined && bareAbsent.findings.length === 0); + t('FLOOR — an empty population trips the package floor rather than passing', floorProblems({ packages: [], scannedFiles: 99999, specifiers: 99999 }) !== null); t('FLOOR — a broken walk trips the file floor', diff --git a/scripts/pm/check-expected-skips.mjs b/scripts/pm/check-expected-skips.mjs index 41d4401a08b..114b35ed6c2 100644 --- a/scripts/pm/check-expected-skips.mjs +++ b/scripts/pm/check-expected-skips.mjs @@ -212,6 +212,10 @@ const EVENT_REASON = "job-level `if:` excludes the `labeled` / `unlabeled` / `edited` pull_request events; each such event is its own run on the same head, beside the `opened` / `synchronize` run that concluded"; const CORE_REASON = "gated on ci.yml's `filter` job `core` output; a diff outside the core path set skips it on the PR head, and the merge-queue build widens every output to 'true' and runs it on the merged tree"; +const UI_GATE = Object.freeze({ kind: 'filter-output', outputs: ['ui_tree', 'ui'] }); +const UI_CONSUMERS_GATE = Object.freeze({ kind: 'filter-output', outputs: ['ui_tree', 'ui', 'ui_consumers'] }); +const UI_TREE_REASON = + "gated on ci.yml's `filter` job `ui_tree` output: it skips on every event while `ui/package.json` is absent (before the objectui import), and is not a required context"; const FULL_BATTERY_REASON = "full battery only (ci.yml's THE TWO SPEEDS): its `if:` excludes `pull_request` and `merge_group`, so it skips on every PR head and every queue build and runs on main's push, hourly schedule and dispatch runs"; @@ -235,6 +239,10 @@ const FULL_BATTERY_REASON = * name the mechanism — a row added to silence an exit 4 without one is the * finding written somewhere quieter. * + * The four `* UI` rows were added with their jobs (objectstack#25697), not + * measured off landed heads: until objectui is imported under `ui/`, the + * `ui_tree` output skips them on EVERY head, so the skip is by construction. + * * These are the LISTED rows. The judge reads them beside the DERIVED ones — * `deriveNonPrEventSkips` below, one exact `if:` shape read off the tree — and * ⛔ a job that shape admits is never listed here: the derivation refuses a @@ -327,6 +335,34 @@ export const EXPECTED_SKIPS = Object.freeze([ gate: { kind: 'filter-output', outputs: ['console'] }, reason: `${FULL_BATTERY_REASON}; on a push it also needs the \`filter\` job's \`console\` output (the \`.objectui-sha\` pin, the console build/probe scripts, and the spec's entry layout: \`packages/spec/package.json\` and \`packages/spec/tsup.config.ts\`)`, }, + { + name: 'Type Check UI', + workflow: 'ci.yml', + job: 'type-check-ui', + gate: UI_CONSUMERS_GATE, + reason: `${UI_TREE_REASON}; with ui/ present it runs on a ui/ path or one of the seven packages objectui consumes (the \`ui_consumers\` output)`, + }, + { + name: 'Test UI (${{ matrix.shard }}/16)', + workflow: 'ci.yml', + job: 'test-ui', + gate: UI_GATE, + reason: `${UI_TREE_REASON}; with ui/ present it runs on a ui/ path; the raw matrix template is the pre-expansion name`, + }, + { + name: 'Lint UI', + workflow: 'ci.yml', + job: 'lint-ui', + gate: UI_GATE, + reason: `${UI_TREE_REASON}; with ui/ present it runs on a ui/ path`, + }, + { + name: 'Build & E2E UI', + workflow: 'ci.yml', + job: 'build-e2e-ui', + gate: UI_CONSUMERS_GATE, + reason: `${UI_TREE_REASON}; with ui/ present it runs on a ui/ path or one of the seven packages objectui consumes (the \`ui_consumers\` output)`, + }, ]); /** Conclusions that make a same-suite skip a dependency-skip suspect. */ @@ -1350,7 +1386,7 @@ export function selfTest() { t('roster: every row carries a reason', EXPECTED_SKIPS.every((r) => typeof r.reason === 'string' && r.reason.length > 20), true); t('roster: no duplicate names', new Set(EXPECTED_SKIPS.map((r) => r.name)).size, EXPECTED_SKIPS.length); t('roster: every gate kind is declared', EXPECTED_SKIPS.every((r) => GATE_KINDS.includes(r.gate.kind)), true); - t('roster: the two matrix templates are spelled raw, as the API reports them', EXPECTED_SKIPS.filter((r) => r.name.includes('${{ matrix.shard }}')).length, 2); + t('roster: the three matrix templates are spelled raw, as the API reports them', EXPECTED_SKIPS.filter((r) => r.name.includes('${{ matrix.shard }}')).length, 3); t('roster: the roster is frozen data', Object.isFrozen(EXPECTED_SKIPS), true); t('roster: a never-skipping required context is NOT in it (Lint & Repo Gates)', EXPECTED_SKIPS.some((r) => r.name === 'Lint & Repo Gates'), false); t('roster: …nor the ci.yml aggregates, which run if: always()', EXPECTED_SKIPS.some((r) => r.name === 'Test Core' || r.name === 'Dogfood Regression Gate'), false); diff --git a/turbo.json b/turbo.json index a49fecbac8f..9fe13f8f537 100644 --- a/turbo.json +++ b/turbo.json @@ -34,6 +34,39 @@ "outputs": [], "inputs": ["$TURBO_DEFAULT$", "!dist/**", "!coverage/**", "!.turbo/**"] }, + "type-check": { + "dependsOn": ["^build"], + "outputs": [], + "cache": true, + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/ui/scripts/vite-*.ts", + "$TURBO_ROOT$/ui/vitest.config.mts", + "$TURBO_ROOT$/ui/tsconfig.json", + "$TURBO_ROOT$/ui/tsconfig.tier-base.json", + "$TURBO_ROOT$/ui/apps/console/src/lib/auth-preflight.ts", + "$TURBO_ROOT$/ui/eslint-rules/no-unprefixed-query-params.js", + "$TURBO_ROOT$/ui/packages/cli/src/commands/generate.ts", + "$TURBO_ROOT$/ui/packages/cli/src/utils/authoring-face.ts", + "$TURBO_ROOT$/ui/packages/cli/src/utils/issue-path.ts", + "$TURBO_ROOT$/ui/packages/cli/src/utils/union-arm-diagnostics.ts", + "$TURBO_ROOT$/ui/scripts/check-doc-component-types.mjs", + "$TURBO_ROOT$/ui/scripts/markdown-fence-scan.mjs" + ] + }, + "lint": { + "outputs": [], + "cache": true, + "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/ui/eslint.config.js", "$TURBO_ROOT$/ui/eslint-rules/*.js"] + }, + "test:dist": { + "dependsOn": ["build"], + "cache": false + }, + "test:watch": { + "cache": false, + "persistent": true + }, "@objectstack/docs#build": { "dependsOn": ["^build"], "outputs": ["dist/**", "json-schema/**", ".next/**", "!.next/cache/**", "public/llms-full.txt"],