diff --git a/scripts/pm/dispatch-gates.data.mjs b/scripts/pm/dispatch-gates.data.mjs index 9dac7991da6..b0013b96c7d 100644 --- a/scripts/pm/dispatch-gates.data.mjs +++ b/scripts/pm/dispatch-gates.data.mjs @@ -900,9 +900,52 @@ export const ESCAPABLE_LITERAL_LEDGER_ROWS = Object.freeze([ * already has. ⛔ Nor does either gate happening to go quiet: three of the * five measured instances involved a green that proved nothing, because a * sweep that cannot see your file is not evidence about your file. + * - ui-scripts-suite entry: when a workflow runs the `ui/scripts/__tests__` + * suite as a step of its own, under an invocation the discovery reads, the + * row can name that family instead of standing in for a CI job — and when + * the `Test UI` job stops running the suite at all, the binding goes STALE + * by itself. ⛔ A path predicate that narrows the four roots to the files + * each test "really" reads does NOT qualify: that map is a second thing + * that goes stale, and the run is cheap enough not to need one (measured + * below). * * * Delete an entry the day its criterion is met, not before. + * + * ## Why the ui-scripts-suite entry stands in for a CI JOB (#26318) + * + * The 180 test files under `ui/scripts/__tests__/` (178 that run, 2 skipped) + * belong to no package and run + * only inside the 16 `Test UI` shards, as the "always-on" files the affected-test + * selector adds to every run because no package owns them. They pin facts a + * change under ui package source, the Console app, the objectui skill bundle or + * `ui/scripts` itself moves — turbo build inputs, the skill-examples floor, the + * SchemaRenderer channel keys — and no derived family named them: measured on + * PR #26274's head 1ef5c482, three reds there were that PR's own and had passed + * every derived local gate. + * + * Every other gate in this table is a family the workflows run, resolved by + * name. This one cannot be: the only step that runs the suite is the `Test UI` + * job's vitest line, which carries a shard and the selector's filter list, and + * no workflow spells a narrower invocation. So its gate declares the job and the + * runner prefix it narrows (`ciJob`, `ciRunner`), and the engine's + * `ciJobRunsRunner` resolves it against the live workflows the same way a family + * is resolved: a job renamed or a runner respelled renders it STALE, and the + * census guard in the self-test reds on the PR that does it. + * + * It names the WHOLE suite, never an affected subset: a subset map is a second + * list that rots. Measured at 6390fe52 on a 4-vCPU container, after the seven + * @objectstack packages the job builds: 178 files passing in 284 s and 286 s + * (two runs; the card's "about a minute" was read elsewhere), nearly all of it + * module import — so a subset would save minutes, not a CI round trip, and the + * reach it would have to model is the tree itself: by a static scan of each + * file and the helpers it imports, 165 of the 180 read ui package source, 141 + * the ui apps, 16 the objectui skill bundle and 170 ui/scripts. What it does not see is + * stated rather than hidden: a ui path outside the four roots (a ui package's + * tests outside `src`, its `package.json`, a root config) is not named here, + * though CI's `Test UI` job still schedules the suite for it — the full battery + * on `main` remains the backstop for that, as it is for the selector's own + * misses. */ export const CHANGE_KIND_ROWS = [ { @@ -1013,8 +1056,37 @@ export const CHANGE_KIND_ROWS = [ }, ], }, + { + kind: 'edits a file the ui/scripts/__tests__ suite reads (ui package source, the ui apps, the objectui skill bundle, or ui/scripts itself)', + matches: 'ui-scripts-suite-input', + gates: [ + { + name: 'pnpm --dir ui exec vitest run scripts/', + ciJob: 'Test UI', + ciRunner: 'pnpm --dir ui exec vitest run', + why: "the 178 test files under ui/scripts/__tests__ belong to no package and run only in CI's 16 Test UI shards, where the affected-test selector adds them to every run, so no package suite and no check family covers them: they pin turbo build inputs, the skill-examples floor, the SchemaRenderer channel keys and the other facts a change under ui package source, the ui apps, skills/objectui or ui/scripts moves. The whole suite, never a subset: measured at about five minutes on a 4-vCPU container (284 s and 286 s, 178 files passing, import-bound), against the CI round trip of about 25 minutes it saves. It needs the seven @objectstack packages Test UI builds first, or about twenty files red on an import of a missing dist — build them exactly as the job does: pnpm exec turbo run build --filter=@objectstack/spec... --filter=@objectstack/client... --filter=@objectstack/core... --filter=@objectstack/formula... --filter=@objectstack/lint... --filter=@objectstack/sdui-parser... --filter=@objectstack/types... . Positional filter, no bare `--`, from the repo root: ui/scripts/vitest-invocation-guard.mjs refuses every other spelling", + }, + ], + }, ]; +/** + * The roots whose change the `ui/scripts/__tests__` suite can see (#26318), in CI's own `paths:` language + * — the engine matches them with `triggerListCovers`, the reader of every workflow trigger. They are the + * card's four, as written there: ui package SOURCE (any depth below `ui/packages`, so a nested package + * counts), the ui apps, the objectui skill bundle the suite reads through `ui/scripts/skill-bundle-root.mjs`, + * and the suite's own tree. The bare `src` directory form is listed beside its subtree because a dispatch + * surface is often given as that directory, and the trigger language reads a directory only through a + * pattern's literal prefix. + */ +export const UI_SCRIPTS_SUITE_ROOTS = Object.freeze([ + 'ui/packages/**/src', + 'ui/packages/**/src/**', + 'ui/apps/**', + 'skills/objectui/**', + 'ui/scripts/**', +]); + /** * The globs that MANDATE a model tier for any card whose file surface touches * them, as DATA. This is the one list in this file besides CHANGE_KIND_GATES, diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index 3f3229dfbb7..b113bc65768 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -473,6 +473,7 @@ import { RETIRED_TIER_WORDS, ESCAPABLE_LITERAL_LEDGER_ROWS, CHANGE_KIND_ROWS, + UI_SCRIPTS_SUITE_ROOTS, MANDATORY_TIER_GLOB_ROWS, SUSPECT_TIER_GLOB_ROWS, GENERATED_MODULE_SOURCES, @@ -491,6 +492,7 @@ export { TIER_DEFAULT, RETIRED_TIER_WORDS, GENERATED_MODULE_SOURCES, + UI_SCRIPTS_SUITE_ROOTS, }; // Re-exported so this tool's self-test drives the SAME predicates the gate @@ -11043,6 +11045,45 @@ export function reachesMetadataFormModule(path, modulePaths) { return modulePaths.some((m) => m === path || m.startsWith(`${path}/`)); } +/** + * Does this input path sit under a root the `ui/scripts/__tests__` suite reads (#26318)? + * + * The roots are `UI_SCRIPTS_SUITE_ROOTS` in the data module, and they are matched in CI's own `paths:` + * language through `triggerListCovers`, so a directory surface counts exactly as it counts for a workflow + * trigger and this file grows no second glob dialect. A leading `./` is tolerated for the reason + * `isGateScriptPath` tolerates one: a seat pastes paths as its shell printed them. + */ +export function readByUiScriptsSuite(path, roots = UI_SCRIPTS_SUITE_ROOTS) { + return triggerListCovers(roots, path.replace(/^\.\//, '')) !== null; +} + +/** + * Does a live workflow still run the CI job a change-kind gate is the local SUBSET of (#26318)? + * + * Every other gate in the change-kind roster is named by an invocation the workflows run, and it resolves + * against the discovered families. The `ui/scripts/__tests__` suite cannot: CI runs it only inside the + * `Test UI` job's vitest step, which passes a shard and the affected-test selector's filter list, so that + * step is no family a dev can paste and the workflows spell no narrower invocation. Such a gate declares + * `ciJob` (the job's display-name prefix) and `ciRunner` (the command prefix that job's step spells), and + * its own `name` is that prefix narrowed to the subset a card owes. It resolves to that `name` while some + * workflow job of that name still runs a `run:` line beginning with the prefix, and to null once none + * does — the STALE report a renamed family gets, so the row cannot outlive the job it stands in for. + * + * ⛔ Not a second discovery. It adds nothing to the families, the residue partition or any path verdict; + * it answers one question for one table row, from the same `workflowEntries` the discovery read. + */ +export function ciJobRunsRunner({ ciJob, ciRunner }, workflowEntries) { + for (const { text } of workflowEntries) { + for (const job of extractJobBlocks(text)) { + if (!job.name.startsWith(ciJob)) continue; + for (const command of runCommandTexts(job.text)) { + if (joinLineContinuations(command).split('\n').some((line) => line.trim().startsWith(`${ciRunner} `))) return true; + } + } + } + return false; +} + /** * The change-kind roster is data: `CHANGE_KIND_ROWS` in `dispatch-gates.data.mjs` carries each kind, the * NAME of the predicate that recognises it, and the gates it names — with the docblock that governs the @@ -11058,6 +11099,7 @@ const CHANGE_KIND_PREDICATES = Object.freeze({ 'error-code-literal': stampsAnErrorCodeLiteral, 'http-status-emit': emitsAnHttpStatus, 'durability-seam': guardsADurabilityCriticalCall, + 'ui-scripts-suite-input': (path) => readByUiScriptsSuite(path), }); export function bindChangeKindRows(rows, predicates = CHANGE_KIND_PREDICATES) { @@ -11069,6 +11111,17 @@ export function bindChangeKindRows(rows, predicates = CHANGE_KIND_PREDICATES) { `known: ${Object.keys(predicates).join(', ')}`, ); } + // A CI-job-subset gate (`ciJobRunsRunner`) carries both halves of its binding, and its name narrows + // the runner it stands in for — refused at load, so a half-declared row cannot resolve by accident. + for (const gate of row.gates) { + const bound = gate.ciJob !== undefined || gate.ciRunner !== undefined; + if (bound && !(typeof gate.ciJob === 'string' && typeof gate.ciRunner === 'string' && gate.name.startsWith(`${gate.ciRunner} `))) { + throw new Error( + `dispatch-gates: change-kind row '${row.kind}' gate '${gate.name}' declares a CI-job binding that is not whole — ` + + 'a gate standing in for a CI job carries both `ciJob` and `ciRunner`, and its name begins with that runner', + ); + } + } return { ...row, matches }; }); } @@ -11103,7 +11156,14 @@ export function changeKindGates(paths, resolveInvocation, kinds = CHANGE_KIND_GA groups.push({ kind, hits, - gates: gates.map(({ name, why }) => ({ name, why, command: resolveInvocation(name) })), + // The gate rides into the resolver beside its name: a CI-job-subset gate resolves through its + // binding, never through the family map (`ciJobRunsRunner`). + gates: gates.map((gate) => ({ + name: gate.name, + why: gate.why, + command: resolveInvocation(gate.name, gate), + ...(gate.ciJob ? { ciJob: gate.ciJob, ciRunner: gate.ciRunner } : {}), + })), }); } return groups; @@ -11113,11 +11173,13 @@ export function changeKindLines(paths, resolveInvocation, kinds = CHANGE_KIND_GA const lines = []; for (const { kind, hits, gates } of changeKindGates(paths, resolveInvocation, kinds)) { lines.push(` ${kind}: ${hits.join(', ')}`); - for (const { name, why, command } of gates) { + for (const { name, why, command, ciJob, ciRunner } of gates) { lines.push( command ? ` - ${command} — ${why}` - : ` - ⚠ ${name}: STALE — no workflow runs a gate under this name. It was renamed or retired; fix CHANGE_KIND_GATES in this script.`, + : ciJob + ? ` - ⚠ ${name}: STALE — no workflow job named '${ciJob}…' runs '${ciRunner}' any more. The job was renamed or retired; fix CHANGE_KIND_GATES in this script.` + : ` - ⚠ ${name}: STALE — no workflow runs a gate under this name. It was renamed or retired; fix CHANGE_KIND_GATES in this script.`, ); } } @@ -14545,7 +14607,8 @@ function derive(paths, { showResidue = false, mode = 'human', runRecord = [], si // `--commands` list and the `--json` document are readings of these same // rows: the card this section answers is about two renderings of one // derivation drifting apart, so a second traversal here would reintroduce it. - const resolveInvocation = (name) => { + const resolveInvocation = (name, gate = null) => { + if (gate?.ciJob) return ciJobRunsRunner(gate, workflowEntries) ? name : null; const entry = byCheck.get(name); return entry ? runnableInvocation(entry) : null; }; diff --git a/scripts/pm/dispatch-gates.self-test.mjs b/scripts/pm/dispatch-gates.self-test.mjs index 22af658b7c1..d8c9437ec00 100644 --- a/scripts/pm/dispatch-gates.self-test.mjs +++ b/scripts/pm/dispatch-gates.self-test.mjs @@ -57,6 +57,7 @@ import { CHANGESET_PROBE_PATH, CHANGE_KIND_GATES, bindChangeKindRows, + ciJobRunsRunner, bindMandatoryTierRows, bindSuspectTierRows, COMPOUND_ANCHOR_KEYS, @@ -279,6 +280,8 @@ import { withoutAnchor, workflowEnvValues, zeroSegmentForms, + readByUiScriptsSuite, + UI_SCRIPTS_SUITE_ROOTS, } from './dispatch-gates.mjs'; import { COMPOUND_ANCHOR_LEDGER, @@ -4831,6 +4834,76 @@ export function selfTest({ tier = 'full' } = {}) { t('an undiscoverable durability guard renders STALE for this entry', seamStale.filter((l) => l.includes(`⚠ ${DURABILITY_SEAM_GATE}: STALE`)).length === 1); + // ── The ui/scripts/__tests__ suite (#26318) ───────────────────────────────── + // + // The 178 files run only in CI's Test UI shards, so no family named them and a + // card under ui package source or skills/objectui learned of a red one CI round + // later. The entry stands in for that CI JOB (`ciJobRunsRunner`): pinned here + // are the predicate on all four roots and outside them, the rendering and the + // runnable union through the whole table, the `--ran` verdict without it, the + // STALE branch, and the binding against the live workflows. + const UI_SUITE = 'pnpm --dir ui exec vitest run scripts/'; + const UI_SUITE_SPECIMENS = Object.freeze({ + 'ui/packages/**/src': 'ui/packages/sdui-parser/src/expression-carriage.ts', + 'ui/apps/**': 'ui/apps/console/src/App.tsx', + 'skills/objectui/**': 'skills/objectui/SKILL.md', + 'ui/scripts/**': 'ui/scripts/check-skill-examples.mjs', + }); + const UI_SUITE_CONTROLS = Object.freeze([ + 'packages/core/src/x.ts', + 'ui/packages/core/package.json', + 'skills/objectstack/SKILL.md', + 'scripts/pm/dispatch-gates.mjs', + ]); + const uiEntry = CHANGE_KIND_GATES.filter((k) => k.gates.some((g) => g.name === UI_SUITE)); + t('exactly one entry in the table names the ui/scripts suite, bound to the Test UI job', + uiEntry.length === 1 && uiEntry[0].matches('ui/scripts/x.mjs') && uiEntry[0].gates[0].ciJob === 'Test UI' && UI_SUITE.startsWith(`${uiEntry[0].gates[0].ciRunner} `)); + t('the roots are the card\'s four, in the trigger language', ['ui/packages/**/src/**', 'ui/apps/**', 'skills/objectui/**', 'ui/scripts/**'].every((r) => UI_SCRIPTS_SUITE_ROOTS.includes(r))); + for (const [root, specimen] of Object.entries(UI_SUITE_SPECIMENS)) { + t(`a path under ${root} is read by the ui/scripts suite (${specimen})`, readByUiScriptsSuite(specimen)); + } + t('a dispatch surface given as a ui package\'s src DIRECTORY counts too', readByUiScriptsSuite('ui/packages/core/src') && readByUiScriptsSuite('./ui/scripts')); + for (const control of UI_SUITE_CONTROLS) { + t(`CONTROL: ${control} is outside the four roots`, !readByUiScriptsSuite(control)); + } + // A resolver that resolves a family by its `pnpm` spelling and a CI-job gate by + // its own name — the shape `derive`'s resolver has. + const uiResolved = (name, gate = null) => (gate?.ciJob ? name : `pnpm ${name}`); + const throughTableUi = (p) => changeKindLines([p], uiResolved).some((l) => l.includes(`- ${UI_SUITE} —`)); + for (const [root, specimen] of Object.entries(UI_SUITE_SPECIMENS)) { + t(`through the whole table, a diff under ${root} derives the suite, anchored on the delimiter`, throughTableUi(specimen)); + t(`…and the runnable union carries it for ${specimen}`, commandsFor({ kindGroups: changeKindGates([specimen], uiResolved) }).includes(UI_SUITE)); + } + for (const control of UI_SUITE_CONTROLS) { + t(`…and ${control} derives no suite line and no suite command`, + !throughTableUi(control) && !commandsFor({ kindGroups: changeKindGates([control], uiResolved) }).includes(UI_SUITE)); + } + const uiLine = changeKindLines([UI_SUITE_SPECIMENS['skills/objectui/**']], uiResolved, uiEntry).find((l) => l.includes(`- ${UI_SUITE} —`)) ?? ''; + t('the suite line states its build prerequisite, runnably', /seven @objectstack packages/.test(uiLine) && uiLine.includes('turbo run build') && uiLine.includes('--filter=@objectstack/sdui-parser...')); + t('…and says it is the whole suite, never a subset', /The whole suite, never a subset/.test(uiLine)); + const uiDerived = commandsFor({ kindGroups: changeKindGates([UI_SUITE_SPECIMENS['ui/apps/**']], uiResolved) }); + const uiWithout = runReconciliation({ + derived: uiDerived, + record: parseRunRecord(uiDerived.filter((c) => c !== UI_SUITE).map((c) => `${c} :: exit 0`).join('\n')), + }); + t('⭐ a run record carrying every derived command BUT the suite is NOT ok, and names the suite unrun', !uiWithout.ok && uiWithout.unrun.some((u) => u.command === UI_SUITE)); + const uiWith = runReconciliation({ derived: uiDerived, record: parseRunRecord(uiDerived.map((c) => `${c} :: exit 0`).join('\n')) }); + t('CONTROL: the same record WITH the suite is ok', uiWith.ok); + const uiStale = changeKindLines([UI_SUITE_SPECIMENS['ui/scripts/**']], () => null, uiEntry); + t('an unresolvable binding renders STALE, naming the job and the runner it lost', + uiStale.filter((l) => l.includes(`⚠ ${UI_SUITE}: STALE`) && l.includes("job named 'Test UI…'") && l.includes("'pnpm --dir ui exec vitest run'")).length === 1); + // The binding itself, against fixtures and then the LIVE workflows. + const uiJobFixture = (name, run) => [{ file: 'ci.yml', text: `on: pull_request\njobs:\n test-ui:\n name: ${name}\n runs-on: ubuntu-latest\n steps:\n - name: Run\n run: |\n ${run}\n` }]; + const uiBinding = uiEntry[0]?.gates[0] ?? {}; + t('the binding resolves where the job runs the runner', ciJobRunsRunner(uiBinding, uiJobFixture('Test UI (${{ matrix.shard }}/16)', 'pnpm --dir ui exec vitest run --shard=1/16 "${filters[@]}"'))); + t('…and not once the job is renamed', !ciJobRunsRunner(uiBinding, uiJobFixture('UI tests (${{ matrix.shard }}/16)', 'pnpm --dir ui exec vitest run --shard=1/16'))); + t('…nor once the runner is respelled', !ciJobRunsRunner(uiBinding, uiJobFixture('Test UI (${{ matrix.shard }}/16)', 'pnpm --dir ui run test --shard=1/16'))); + t('⭐ the binding resolves against the LIVE workflows — the census guard for a gate no family carries', ciJobRunsRunner(uiBinding, discoverFamilies().workflowEntries)); + t('a half-declared CI-job binding REFUSES at load', + (() => { try { bindChangeKindRows([{ kind: 'k', matches: 'test-file', gates: [{ name: 'x y', ciJob: 'Test UI', why: 'w' }] }]); return false; } catch (e) { return String(e.message).includes('CI-job binding that is not whole'); } })()); + t('…and so does one whose name does not narrow its runner', + (() => { try { bindChangeKindRows([{ kind: 'k', matches: 'test-file', gates: [{ name: 'pnpm vitest run', ciJob: 'Test UI', ciRunner: 'pnpm --dir ui exec vitest run', why: 'w' }] }]); return false; } catch (e) { return String(e.message).includes('CI-job binding that is not whole'); } })()); + // ── The metadata-form edge (#9116) ──────────────────────────────────────── // // The bundles' OTHER producer, and the half no owning-package test can reach: @@ -5867,7 +5940,9 @@ export function selfTest({ tier = 'full' } = {}) { } } t('the live workflows discover a farm at all (the guard is not vacuous)', liveFamilies.size > 20); - const declared = CHANGE_KIND_GATES.flatMap((k) => k.gates.map((g) => g.name)); + // A CI-job-subset gate (#26318) is no family by construction; its census is + // the live-binding case under the ui/scripts entry, so it is left out here. + const declared = CHANGE_KIND_GATES.flatMap((k) => k.gates.filter((g) => !g.ciJob).map((g) => g.name)); const missing = declared.filter((n) => !liveFamilies.has(n)); t(`every convention gate named in the table is a live family (missing: ${missing.join(', ') || 'none'})`, missing.length === 0); // The two gates this card moved out of the closing prose, pinned individually @@ -7474,7 +7549,8 @@ export function selfTest({ tier = 'full' } = {}) { // 131 -> 133 (#22998): the header gained two lines naming the full-battery-only jobs. // 133 -> 137 (#25867): the header gained four lines on the ui jobs' queue-diff outputs. 'scripts/pm/check-expected-skips.mjs:137 self-test-reads', - 'scripts/pm/dispatch-gates.mjs:736 inherited-population', + // 736 -> 738 (#26318): the data import and its re-export each gained UI_SCRIPTS_SUITE_ROOTS. + 'scripts/pm/dispatch-gates.mjs:738 inherited-population', ].join(' · '), censusRows.join(' · '), );