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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions scripts/pm/dispatch-gates.data.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down Expand Up @@ -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,
Expand Down
71 changes: 67 additions & 4 deletions scripts/pm/dispatch-gates.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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 };
});
}
Expand Down Expand Up @@ -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;
Expand All @@ -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.`,
);
}
}
Expand Down Expand Up @@ -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;
};
Expand Down
Loading
Loading