From 29ffad59e56a7121ac0f28f9828655228c1bfa1f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 17:53:33 +0000 Subject: [PATCH 1/5] tooling(ui-wiring): workspace globs and root turbo tasks for the ui/ tree (objectstack#25697) pnpm-workspace.yaml gains ui, ui/packages/*, ui/apps/* and ui/examples/*. They match no directory until objectui is imported under ui/, so `pnpm install --frozen-lockfile` and `pnpm install --lockfile-only` leave pnpm-lock.yaml byte-identical and `pnpm -r ls --depth -1` is unchanged. objectui declares no onlyBuiltDependencies, so the union is this list. turbo.json gains the four task names objectui's packages use and the root lacks: type-check, lint, test:dist, test:watch, with objectui's inputs, outputs, dependsOn and cache settings. Its repo-root inputs are re-rooted under $TURBO_ROOT$/ui/. Two dependsOn edges are not carried: //#lint:root and //#type-check:e2e bind objectui's ROOT manifest, which is the `ui` member here, and a package-named dependsOn on a package that does not exist yet makes turbo refuse the whole run. The ui CI jobs run those two programs as their own steps instead. Its globalDependencies (pin plumbing) are not ported. `turbo run build --dry=json` lists the same 84 tasks with the same 84 hashes before and after. Two gates read "turbo.json describes something absent" as a defect, which is right except for a tree that is about to arrive: - check-turbo-task-graph: type-check, lint and test:dist are held by no member until the import. GENERIC_TASKS_AWAITING_UI_IMPORT holds them only while ui/package.json is absent; the hold is void once it exists, and a held key turbo.json does not declare is itself a finding. - check-cross-package-test-inputs: `ui` is a top-level segment git does not track yet. TOP_LEVEL_DIRECTORIES_AWAITING_IMPORT excuses it only while untracked; once tracked it owes a recognised or pending entry. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01RSDL3SFtJaKzcymvCD8RsU --- pnpm-workspace.yaml | 16 +++ scripts/check-cross-package-test-inputs.mjs | 40 ++++++- scripts/check-turbo-task-graph.mjs | 114 ++++++++++++++++++-- turbo.json | 33 ++++++ 4 files changed, 194 insertions(+), 9 deletions(-) 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-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/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"], From 011136ce2131637df912ecc32a5c2dc8e0bb1288 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 17:59:16 +0000 Subject: [PATCH 2/5] tooling(ui-wiring): CI jobs for the ui/ tree, skipped until it exists (objectstack#25697) ci.yml's filter job gains three outputs: `ui` (ui/** and the root files the ui jobs build from), `ui_consumers` (the seven packages objectui installs from this tree: spec, client, core, formula, lint, sdui-parser, types) and `ui_tree` (ui/package.json exists, from an unconditional step). Four jobs, ported from objectui's ci.yml and lint.yml: - Type Check UI: objectui's coverage guard, its check:* source gates and root tsc programs, then `turbo run type-check` over the ui packages, affected-only on pull_request / merge_group. Runs on `ui` OR `ui_consumers`, so a spec change compiles the Console in its own PR. - Test UI (n/16): objectui's vitest root, sharded by its own LPT sequencer. N stays 16: by package the 1.3 max/mean bound admits at most 4 legs (app-shell is 104.2 of 325.7 summed minutes); by file the bound never binds and 16 legs carry 20.4 summed minutes each. - Lint UI: objectui's lint job, with //#lint:root as its own step. - Build & E2E UI: the Console build and Playwright chromium, on `ui` OR `ui_consumers`. Every job's `if:` is `!cancelled() && ui_tree != 'false' && `, so with ui/ absent each one is skipped on every event, never failed and never started. None is a required context or an aggregator member. The .objectstack-pin plumbing is replaced by a turbo build of the seven @objectstack packages; objectui root scripts run with working-directory: ui, turbo runs from the root with directory filters. check-expected-skips declares the four names as by-design skips. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01RSDL3SFtJaKzcymvCD8RsU --- .github/workflows/ci.yml | 657 ++++++++++++++++++++++++++++ scripts/pm/check-expected-skips.mjs | 38 +- 2 files changed, 694 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6372b2d2bdb..251349c0776 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,608 @@ 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. + # - commands that are objectui ROOT scripts run with `working-directory: ui`, + # because they assume cwd = the objectui root; 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 + working-directory: ui + run: node scripts/check-type-check-coverage.mjs + + - 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 + working-directory: ui + run: pnpm run check:phantom-deps + + - name: Verify no package imports itself by name + working-directory: ui + run: pnpm run check:self-import + + - name: Verify no covered package ships a source file nothing reaches + working-directory: ui + run: pnpm run check:unreferenced-sources + + - name: Verify no doc comment prescribes a spelling a shared reader owns + working-directory: ui + run: pnpm run check:doc-example-readers + + - name: Verify every handler key a registered renderer reads is declared by its arm + working-directory: ui + run: pnpm run check:handler-key-reads + + - name: Verify every object-metadata write door applies the write guard + working-directory: ui + run: pnpm run check:metadata-write-doors + + - name: Verify published build tsconfigs exclude tooling directories + working-directory: ui + run: pnpm run check:published-tsconfig-exclude + + - name: Verify every sideEffects array names exactly its registering modules + working-directory: ui + run: pnpm run check:side-effects-array + + - name: Verify every ElementDataSourceGate wrapper reaches the declaration seam + working-directory: ui + run: pnpm run check:element-data-source-declaration + + - name: Verify published ESM packages emit resolvable relative specifiers + working-directory: ui + run: pnpm run check:esm-specifiers + + - name: Verify spec-named symbols are derived, not hand-written + working-directory: ui + run: pnpm run check:spec-symbols + + - name: Verify action renderers forward every key the runtime reads + working-directory: ui + run: pnpm run check:action-forward-parity + + - name: Verify designer field payloads declare only keys FieldSchema accepts + working-directory: ui + run: pnpm run check:designer-field-key-parity + + - name: Verify authored icon names are live lucide icons keys + working-directory: ui + run: pnpm run check:icon-record-names + + - name: Verify t() call-site keys exist in the en locale pack + working-directory: ui + run: pnpm run check:i18n-keys + + - name: Verify changed en strings were followed by the nine translations + working-directory: ui + run: pnpm run check:i18n-drift + + - name: Verify the metadata-admin designer string tables stay paired + working-directory: ui + run: pnpm run check:i18n-designer-parity + + - name: Type-check the ui scripts + working-directory: ui + run: pnpm run type-check:scripts + + - name: Type-check the ui root Vitest config + working-directory: ui + run: pnpm 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 + working-directory: ui + run: pnpm 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 + working-directory: ui + run: pnpm 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... + + - name: Run the ui tests (shard ${{ matrix.shard }}/16) + working-directory: ui + env: + OBJECTUI_TEST_TIMINGS_OUT: ${{ runner.temp }}/test-file-timings/shard-${{ matrix.shard }}.json + run: pnpm test --shard=${{ matrix.shard }}/16 --reporter=default --reporter=github-actions --reporter=./scripts/test-file-timings.mjs + + # The per-file timings the shard dataset is regenerated from. Only main's + # runs feed it, as in objectui; the refresh workflow itself is part of the + # governance-workflow audit (sub-issue 4 of the epic). + - name: Upload this shard's per-file timings + if: "!cancelled() && (github.event_name == 'push' || github.event_name == 'schedule')" + uses: actions/upload-artifact@v7 + with: + name: ui-test-file-timings-${{ matrix.shard }} + path: ${{ runner.temp }}/test-file-timings/ + retention-days: 7 + if-no-files-found: warn + + - 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 + working-directory: ui + run: node scripts/check-lint-coverage.mjs + + - name: Verify every ui scripts entry guard goes through one predicate + working-directory: ui + run: | + node scripts/check-entry-guard.mjs --self-test + node scripts/check-entry-guard.mjs + + - name: Verify the ui shell holds the bash 3.2 floor + working-directory: ui + run: | + node scripts/check-bash32-floor.mjs --self-test + node scripts/check-bash32-floor.mjs + + - 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 + working-directory: ui + run: pnpm 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 + working-directory: ui + run: node scripts/check-vi-mock-override-shape.mjs + + - name: Verify no ui test resolves a repository path from the process cwd + working-directory: ui + run: node scripts/check-test-path-roots.mjs + + - 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 + working-directory: ui + run: pnpm 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 + working-directory: ui/apps/console + env: + VITE_BASE_PATH: /console/ + run: pnpm 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 + working-directory: ui + run: | + set -eo pipefail + if ! version=$(pnpm 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' + working-directory: ui + run: pnpm exec playwright install chromium + + - name: Ensure Chromium can launch + working-directory: ui + run: bash scripts/ensure-chromium-ready.sh + + - name: Run the ui E2E tests + working-directory: ui + run: pnpm 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/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); From 26fb5ba5dc65cb089c3f6663e34edcb2154fc160 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 18:12:12 +0000 Subject: [PATCH 3/5] tooling(ui-wiring): spell objectui invocations so root-scoped gates do not misread them (objectstack#25697) This repo's workflow gates key `scripts/` and `pnpm ` against the repository root and do not read `working-directory`. Under `working-directory: ui`, objectui's `node scripts/check-entry-guard.mjs` was credited to objectstack's file of the same name, and `scripts/check-lint-coverage.mjs`, which only objectui has, read as a missing script (check-self-test-wired --self-test went red on it). Every objectui command in the ui jobs is now `pnpm --dir ui run ` or `pnpm --dir ui exec ...`. Test UI drops objectui's per-file timings reporter and its upload: both feed the dataset refresh workflow, which is in the governance-workflow audit, and the sequencer reads the committed dataset meanwhile. check-llms-txt opens every manifest under the workspace globs, so the ui roots are declared as `ui/**/package.json` beside the others. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01RSDL3SFtJaKzcymvCD8RsU --- .github/workflows/ci.yml | 147 +++++++++--------------- packages/spec/scripts/check-llms-txt.ts | 4 + 2 files changed, 61 insertions(+), 90 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 251349c0776..1ea57dcd62b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3294,10 +3294,18 @@ jobs: # 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. - # - commands that are objectui ROOT scripts run with `working-directory: ui`, - # because they assume cwd = the objectui root; 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 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). @@ -3361,8 +3369,7 @@ jobs: # 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 - working-directory: ui - run: node scripts/check-type-check-coverage.mjs + run: pnpm --dir ui run type-check:coverage - name: Install dependencies run: pnpm install --frozen-lockfile @@ -3382,86 +3389,66 @@ jobs: --filter=@objectstack/types... - name: Verify every imported package is declared by the package that ships it - working-directory: ui - run: pnpm run check:phantom-deps + run: pnpm --dir ui run check:phantom-deps - name: Verify no package imports itself by name - working-directory: ui - run: pnpm run check:self-import + run: pnpm --dir ui run check:self-import - name: Verify no covered package ships a source file nothing reaches - working-directory: ui - run: pnpm run check:unreferenced-sources + run: pnpm --dir ui run check:unreferenced-sources - name: Verify no doc comment prescribes a spelling a shared reader owns - working-directory: ui - run: pnpm run check:doc-example-readers + run: pnpm --dir ui run check:doc-example-readers - name: Verify every handler key a registered renderer reads is declared by its arm - working-directory: ui - run: pnpm run check:handler-key-reads + run: pnpm --dir ui run check:handler-key-reads - name: Verify every object-metadata write door applies the write guard - working-directory: ui - run: pnpm run check:metadata-write-doors + run: pnpm --dir ui run check:metadata-write-doors - name: Verify published build tsconfigs exclude tooling directories - working-directory: ui - run: pnpm run check:published-tsconfig-exclude + run: pnpm --dir ui run check:published-tsconfig-exclude - name: Verify every sideEffects array names exactly its registering modules - working-directory: ui - run: pnpm run check:side-effects-array + run: pnpm --dir ui run check:side-effects-array - name: Verify every ElementDataSourceGate wrapper reaches the declaration seam - working-directory: ui - run: pnpm run check:element-data-source-declaration + run: pnpm --dir ui run check:element-data-source-declaration - name: Verify published ESM packages emit resolvable relative specifiers - working-directory: ui - run: pnpm run check:esm-specifiers + run: pnpm --dir ui run check:esm-specifiers - name: Verify spec-named symbols are derived, not hand-written - working-directory: ui - run: pnpm run check:spec-symbols + run: pnpm --dir ui run check:spec-symbols - name: Verify action renderers forward every key the runtime reads - working-directory: ui - run: pnpm run check:action-forward-parity + run: pnpm --dir ui run check:action-forward-parity - name: Verify designer field payloads declare only keys FieldSchema accepts - working-directory: ui - run: pnpm run check:designer-field-key-parity + run: pnpm --dir ui run check:designer-field-key-parity - name: Verify authored icon names are live lucide icons keys - working-directory: ui - run: pnpm run check:icon-record-names + run: pnpm --dir ui run check:icon-record-names - name: Verify t() call-site keys exist in the en locale pack - working-directory: ui - run: pnpm run check:i18n-keys + run: pnpm --dir ui run check:i18n-keys - name: Verify changed en strings were followed by the nine translations - working-directory: ui - run: pnpm run check:i18n-drift + run: pnpm --dir ui run check:i18n-drift - name: Verify the metadata-admin designer string tables stay paired - working-directory: ui - run: pnpm run check:i18n-designer-parity + run: pnpm --dir ui run check:i18n-designer-parity - name: Type-check the ui scripts - working-directory: ui - run: pnpm run type-check:scripts + run: pnpm --dir ui run type-check:scripts - name: Type-check the ui root Vitest config - working-directory: ui - run: pnpm run type-check: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 - working-directory: ui - run: pnpm run type-check:e2e + 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 @@ -3501,8 +3488,7 @@ jobs: fi - name: Type-check ui repo-root vitest setup files - working-directory: ui - run: pnpm run type-check:vitest-setup + run: pnpm --dir ui run type-check:vitest-setup - name: Save Turbo cache (main only) if: always() && github.event_name == 'push' @@ -3605,23 +3591,13 @@ jobs: --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) - working-directory: ui - env: - OBJECTUI_TEST_TIMINGS_OUT: ${{ runner.temp }}/test-file-timings/shard-${{ matrix.shard }}.json - run: pnpm test --shard=${{ matrix.shard }}/16 --reporter=default --reporter=github-actions --reporter=./scripts/test-file-timings.mjs - - # The per-file timings the shard dataset is regenerated from. Only main's - # runs feed it, as in objectui; the refresh workflow itself is part of the - # governance-workflow audit (sub-issue 4 of the epic). - - name: Upload this shard's per-file timings - if: "!cancelled() && (github.event_name == 'push' || github.event_name == 'schedule')" - uses: actions/upload-artifact@v7 - with: - name: ui-test-file-timings-${{ matrix.shard }} - path: ${{ runner.temp }}/test-file-timings/ - retention-days: 7 - if-no-files-found: warn + 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 @@ -3676,20 +3652,17 @@ jobs: ${{ runner.os }}-turbo-build-core- - name: Verify ui lint coverage - working-directory: ui - run: node scripts/check-lint-coverage.mjs + run: pnpm --dir ui run lint:coverage - name: Verify every ui scripts entry guard goes through one predicate - working-directory: ui run: | - node scripts/check-entry-guard.mjs --self-test - node scripts/check-entry-guard.mjs + 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 - working-directory: ui run: | - node scripts/check-bash32-floor.mjs --self-test - node scripts/check-bash32-floor.mjs + pnpm --dir ui run check:bash32-floor --self-test + pnpm --dir ui run check:bash32-floor - name: Install dependencies run: pnpm install --frozen-lockfile @@ -3708,8 +3681,7 @@ jobs: # 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 - working-directory: ui - run: pnpm run lint:root + run: pnpm --dir ui run lint:root - name: Lint the ui packages env: @@ -3721,19 +3693,16 @@ jobs: --filter='./ui/examples/*' - name: Check every vi.mock override matches the declared export shape - working-directory: ui - run: node scripts/check-vi-mock-override-shape.mjs + run: pnpm --dir ui run check:vi-mock-override-shape - name: Verify no ui test resolves a repository path from the process cwd - working-directory: ui - run: node scripts/check-test-path-roots.mjs + 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 - working-directory: ui - run: pnpm run check + run: pnpm --dir ui run check - name: Save Turbo cache (main only) if: always() && github.event_name == 'push' @@ -3806,10 +3775,9 @@ jobs: --filter=@objectstack/types... - name: Build the Console for E2E - working-directory: ui/apps/console env: VITE_BASE_PATH: /console/ - run: pnpm exec vite build + run: pnpm --dir ui/apps/console exec vite build - name: Verify the Console build artifact run: | @@ -3821,10 +3789,9 @@ jobs: - name: Get the Playwright version id: playwright-version - working-directory: ui run: | set -eo pipefail - if ! version=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].version'); then + 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 @@ -3843,16 +3810,16 @@ jobs: - name: Install Playwright browsers if: steps.playwright-cache.outputs.cache-hit != 'true' - working-directory: ui - run: pnpm exec playwright install chromium + 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 - run: bash scripts/ensure-chromium-ready.sh + working-directory: ui/scripts + run: bash ensure-chromium-ready.sh - name: Run the ui E2E tests - working-directory: ui - run: pnpm run test:e2e --project=chromium + run: pnpm --dir ui run test:e2e --project=chromium - name: Upload failure artefacts if: ${{ !cancelled() && failure() }} 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. */ From 597471c344ab9feda6c686e6dfc867352fd749ad Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 18:28:00 +0000 Subject: [PATCH 4/5] tooling(ui-wiring): workspace-mirroring gates learn the four ui/ roots (objectstack#25697) Three gates hold a hand-written copy of pnpm-workspace.yaml's `packages:` block and red when the live file names a root they do not declare. Each now declares the ui roots, which match nothing until the import: - check-published-files: ROOT_DIR_WATCH_HINTS gains ui/*, ui/packages/*, ui/apps/*, ui/examples/* (ui/* stands for the bare `ui` member, spelled with a separator as the declaration requires). - check-test-source-alias: WORKSPACE_PARENT_GLOBS gains the three `/*` roots; the bare `ui` member is declared as WORKSPACE_BARE_MEMBER_MANIFESTS (`ui/package.json`), walked as the one package at that directory and reconciled against the live file like the globs. - check-undeclared-dep-imports: its parser refused any entry that is not `/*`, so the bare `ui` entry made the sweep fatal. A bare `` is now the one package at that directory, absent contributes nothing; two new refusal-battery cases drive both directions, and `ui/**` joins ROOT_DIR_WATCH_HINTS. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01RSDL3SFtJaKzcymvCD8RsU --- scripts/check-published-files.mjs | 7 ++++ scripts/check-test-source-alias.mjs | 48 +++++++++++++++++------- scripts/check-undeclared-dep-imports.mjs | 40 +++++++++++++++----- 3 files changed, 72 insertions(+), 23 deletions(-) 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-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', From 7ca50a8a7b19df983f8168eddf8c20c197026381 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 23:42:44 +0000 Subject: [PATCH 5/5] tooling(ui-wiring): two more workspace-mirroring gates learn the ui/ roots (objectstack#25697) PR A's first CI run turned two gates red that the derived gate list for its paths did not name, both because pnpm-workspace.yaml now declares four globs that match nothing until the import: - check-dispatcher-error-vocabulary refuses any workspace glob that expands to no member (#16649), in production and in its self-test. WORKSPACE_GLOBS_AWAITING_IMPORT excuses the four ui globs ONLY while ui/package.json is absent (awaitingImportGlobs); the self-test holds the map against the live workspace, pins the exact key set, and drives both marker states on a scratch root so the excuse voids itself at the import. The excuse is opt-in at the call site, never a default. - check-duration-unit-keys (packages/spec) mirrors the workspace roots as ROOT_DIR_WATCH_HINTS literals and holds them against the live file in both directions; the four ui roots are declared as `ui/src/**`, `ui/packages/*/src/**`, `ui/apps/*/src/**`, `ui/examples/*/src/**`. Both gates and their self-tests exit 0 on the PR merge commit; every gate the dispatch derivation names for these two files exits 0. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01RSDL3SFtJaKzcymvCD8RsU --- .../spec/scripts/check-duration-unit-keys.ts | 8 ++ scripts/check-dispatcher-error-vocabulary.mjs | 82 ++++++++++++++++++- 2 files changed, 88 insertions(+), 2 deletions(-) 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/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(