From c65ee39fceba23505e9e0b410be4ba35049aeaba Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 05:25:20 +0000 Subject: [PATCH 1/2] ci(ui-merge): the pre-install import graph derives its population from the repository root's workflows GitHub runs only the root .github/workflows/. The gate read objectui's nested ui/.github/workflows/, which runs nowhere since the monorepo merge, and went red on ten dead steps that still name the deleted scripts/objectstack-pin.mjs. The population is now the root workflow steps, ahead of their job's install, that run an objectui gate: `pnpm --dir ui run ` resolved through ui/package.json, `node ui/scripts/`, or `pnpm --dir ui exec node scripts/`. A bare `node scripts/` in a root workflow is the root's own script and stays out. The self-test gains the four spelling cases. The test's floor lists the root rows: type-check-ui's coverage check and lint-ui's three gates. The rest of objectui#6148's table ran in workflows nothing runs. Each one is named with the roster row of the #25697 audit that carries it, or with the port that brings it back. The gate is not in its own derived population until the root Lint UI job runs it, so the test walks its graph by name. The header's pointer resolves against the root. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01A6SEitNngAMugGbuJHWsDS --- .../check-pre-install-import-graph.test.ts | 120 +++++++--- ui/scripts/check-pre-install-import-graph.mjs | 210 +++++++++++++++--- 2 files changed, 266 insertions(+), 64 deletions(-) diff --git a/ui/scripts/__tests__/check-pre-install-import-graph.test.ts b/ui/scripts/__tests__/check-pre-install-import-graph.test.ts index 44927145894..024010e3787 100644 --- a/ui/scripts/__tests__/check-pre-install-import-graph.test.ts +++ b/ui/scripts/__tests__/check-pre-install-import-graph.test.ts @@ -14,6 +14,7 @@ import { scan, staticSpecifiers, walkImportGraph, + workflowRoot, } from '../check-pre-install-import-graph.mjs'; import { REQUIRED_CONTEXTS } from '../dependabot-merge-gate.mjs'; @@ -33,15 +34,16 @@ import { selfTestCases, stripAnsi } from './helpers/child-verdict'; * What this file pins, in the order the gate can go wrong: * * 1. **the derivation is a derivation** — the population comes from the - * workflows, so moving a step across `pnpm install` moves the population. A + * workflows GitHub runs (the repository root's, since objectstack#25712), + * so moving a step across `pnpm install` moves the population. A * gate reporting "13 steps" while reading a constant is the defect it was * written to prevent, and the only way to tell the two apart is to move a * step in a fixture and watch the number follow; * 2. **the walk follows the graph** — a package ONE HOP away must be caught * and its chain named, because the assertion this generalises could not see * one; - * 3. **the floor over the real tree** — every step objectui#6148 measured is - * still derived, by workflow and job rather than by count; + * 3. **the floor over the real tree** — every step objectui#6148 measured + * that still runs is still derived, by workflow and job rather than by count; * 4. **the wiring** — a gate nobody runs is indistinguishable from a gate that * passes, so the workflow, the alias and the Dependabot classification are * asserted here rather than trusted by reading; @@ -59,6 +61,8 @@ import { selfTestCases, stripAnsi } from './helpers/child-verdict'; */ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const workflowDir = path.join(repoRoot, '.github/workflows'); +/** Where the gate reads `.github/workflows/`: the repository root, which GitHub runs. */ +const rootWorkflowRoot = workflowRoot(repoRoot); const SCRIPT = 'scripts/check-pre-install-import-graph.mjs'; const WORKFLOW = 'repo-gates.yml'; const CHECK_NAME = 'Pre-Install Import Graph Check'; @@ -76,7 +80,7 @@ const readWorkflow = (file: string): string => fs.readFileSync(path.join(workflo describe('the population follows the workflows', () => { const fixture = (installStepIndex: number): string => { - const gate = ' - name: Gate\n run: node scripts/probe.mjs\n'; + const gate = ' - name: Gate\n run: node ui/scripts/probe.mjs\n'; const install = ' - name: Install dependencies\n run: pnpm install --frozen-lockfile\n'; const filler = ' - name: Checkout code\n uses: actions/checkout@v7\n'; const steps = [filler, gate, filler]; @@ -98,7 +102,7 @@ describe('the population follows the workflows', () => { }); it('counts a job that never installs entirely', () => { - const noInstall = 'jobs:\n j:\n steps:\n - name: Gate\n run: node scripts/probe.mjs\n'; + const noInstall = 'jobs:\n j:\n steps:\n - name: Gate\n run: node ui/scripts/probe.mjs\n'; expect(derive(noInstall)).toEqual(['scripts/probe.mjs']); }); @@ -109,11 +113,11 @@ describe('the population follows the workflows', () => { - name: Install dependencies run: pnpm install --frozen-lockfile - name: Late - run: node scripts/late.mjs + run: node ui/scripts/late.mjs does-not: steps: - name: Early - run: node scripts/early.mjs + run: node ui/scripts/early.mjs `; expect(derive(twoJobs)).toEqual(['scripts/early.mjs']); }); @@ -140,7 +144,7 @@ describe('the population follows the workflows', () => { - name: Browsers are not node_modules run: pnpm exec playwright install chromium - name: Gate - run: node scripts/probe.mjs + run: node ui/scripts/probe.mjs `; expect(derive(tricky)).toEqual(['scripts/probe.mjs']); }); @@ -151,11 +155,11 @@ describe('the population follows the workflows', () => { steps: - name: Two legs run: | - node scripts/probe.mjs --self-test - node scripts/probe.mjs + node ui/scripts/probe.mjs --self-test + node ui/scripts/probe.mjs - name: Commented out run: | - # node scripts/ghost.mjs + # node ui/scripts/ghost.mjs echo done `; // One row per (step, script) — two invocations of one script in one step is @@ -163,6 +167,31 @@ describe('the population follows the workflows', () => { expect(derive(block)).toEqual(['scripts/probe.mjs']); }); + it("reads the root workflows' two spellings, and leaves the root's own scripts out", () => { + // objectstack#25712: the root `ci.yml` runs objectui's gates as + // `pnpm --dir ui run ` (resolved through objectui's package.json) or + // `node ui/scripts/`. A bare `node scripts/` there runs objectstack's + // own file of that name, and judging objectui's would be judging a file the + // step never loads. + const root = `jobs: + j: + steps: + - name: The root's own gate + run: node scripts/probe.mjs + - name: Through an alias + run: pnpm --dir ui run probe:alias + - name: Directly + run: node ui/scripts/direct.mjs +`; + const rows = derivePreInstallSteps([{ file: 'root.yml', text: root }], { + aliases: { 'probe:alias': 'node scripts/aliased.mjs --flag' }, + }) as Array<{ script: string; alias: string | null }>; + expect(rows.map((r) => [r.script, r.alias])).toEqual([ + ['scripts/aliased.mjs', 'probe:alias'], + ['scripts/direct.mjs', null], + ]); + }); + it('parses jobs and steps in order — the index is what "before" means', () => { const jobs = parseWorkflowJobs(fixture(2)) as Array<{ id: string; steps: Array<{ name: string }> }>; expect(jobs.map((j) => j.id)).toEqual(['probe']); @@ -254,23 +283,32 @@ describe('the real tree — every step objectui#6148 measured is still derived', const rows = new Set(result.steps.map((s) => `${s.workflow} : ${s.job} -> ${s.script}`)); /** - * The table objectui#6148 measured, plus `lint.yml`'s entry-guard step, which - * the card named separately. A FLOOR, not an inventory: the assertion is that - * none of these silently leaves the population. The gate's own `--list` is - * where the current total lives, and pinning a total here would drift. + * The steps of objectui#6148's table that still run, plus the root `Lint UI` + * job's other two pre-install gates. A FLOOR, not an inventory: the assertion + * is that none of these silently leaves the population. The gate's own + * `--list` is where the current total lives, and pinning a total here would + * drift. + * + * Since objectstack#25712 the rows are the repository root's `ci.yml`, the + * workflow GitHub runs. Two of objectui#6148's rows run there: + * `ci.yml : type-check` is now `type-check-ui`, and `lint.yml : lint` is now + * `lint-ui` (roster rows 7 and 16 of objectstack#25697). The others ran in + * workflows nothing runs since the monorepo merge: + * + * - `changeset-guard.yml : no-major`, `changeset-presence.yml : + * changeset-presence` and `ci.yml : changeset-check`: roster rows 3, 4 + * and 7 (→ sub-issue 4 of objectstack#25677); + * - the four `repo-gates.yml : repo-gates` rows (`check-control-bytes`, + * `check-doc-component-types`, `check-doc-links`, `check-skills-paths`): + * objectstack#25880 ports those gates into `lint-ui` ahead of its + * install, and they rejoin this floor under that job when + * `ui/.github/workflows/` is deleted (objectstack#25712). */ const MEASURED = [ - 'changeset-guard.yml : no-major -> scripts/check-changeset-no-major.mjs', - 'changeset-presence.yml : changeset-presence -> scripts/check-changeset-presence.mjs', - 'ci.yml : changeset-check -> scripts/check-changeset-fixed.mjs', - 'ci.yml : type-check -> scripts/check-type-check-coverage.mjs', - // objectui#12214 folded these four (and eleven more) into `repo-gates.yml`, - // ahead of that job's install step, so they stay in the population. - 'repo-gates.yml : repo-gates -> scripts/check-control-bytes.mjs', - 'repo-gates.yml : repo-gates -> scripts/check-doc-component-types.mjs', - 'repo-gates.yml : repo-gates -> scripts/check-doc-links.mjs', - 'repo-gates.yml : repo-gates -> scripts/check-skills-paths.mjs', - 'lint.yml : lint -> scripts/check-entry-guard.mjs', + 'ci.yml : type-check-ui -> scripts/check-type-check-coverage.mjs', + 'ci.yml : lint-ui -> scripts/check-lint-coverage.mjs', + 'ci.yml : lint-ui -> scripts/check-entry-guard.mjs', + 'ci.yml : lint-ui -> scripts/check-bash32-floor.mjs', ]; it.each(MEASURED)('still derives %s', (row) => { @@ -285,9 +323,27 @@ describe('the real tree — every step objectui#6148 measured is still derived', expect(result.modules.length).toBeGreaterThan(result.scripts.length); }); - it('is in its own population — the gate walks its own import graph', () => { + it('holds its own import graph to the floor — the gate walks itself', () => { // A floor that exempted its own enforcer would be the first thing to rot. - expect(result.scripts).toContain(SCRIPT); + // Its home was objectui's `repo-gates.yml`, which runs nowhere since the + // monorepo merge, so it is not in its own DERIVED population until the root + // `Lint UI` job runs it (objectstack#25712 adds that step when it deletes + // `ui/.github/workflows/`). Until then it is walked by name. + const read = (rel: string): string | null => { + try { + return fs.readFileSync(path.join(repoRoot, rel), 'utf8'); + } catch { + return null; + } + }; + const graph = walkImportGraph(SCRIPT, { read }) as { + modules: string[]; + violations: unknown[]; + unresolved: unknown[]; + }; + expect(graph.modules).toContain('scripts/invoked-as.mjs'); + expect(graph.violations).toEqual([]); + expect(graph.unresolved).toEqual([]); }); it('finds no pre-install gate reaching a package', () => { @@ -523,10 +579,12 @@ describe("the header's population is a pointer, not a copy", () => { expect(prose, `${WORKFLOW}'s header must name \`${dir}\` — the population's one live source`).toContain(dir); // …and the pointer must point at something. A named directory that holds no - // workflows is a measurement point in form only. - const present = fs.readdirSync(path.join(repoRoot, dir)).filter((f) => /\.ya?ml$/.test(f)); + // workflows is a measurement point in form only. The gate resolves it + // against the repository root (objectstack#25712), so this does too, and + // the root `ci.yml`, whose UI jobs carry the population, must be there. + const present = fs.readdirSync(path.join(rootWorkflowRoot, dir)).filter((f) => /\.ya?ml$/.test(f)); expect(present.length, `${dir} holds no workflow files — the pointer above resolves to nothing`).toBeGreaterThan(0); - expect(present).toContain(WORKFLOW); + expect(present).toContain('ci.yml'); }); /** diff --git a/ui/scripts/check-pre-install-import-graph.mjs b/ui/scripts/check-pre-install-import-graph.mjs index 7f8b7f22948..9bda197f517 100644 --- a/ui/scripts/check-pre-install-import-graph.mjs +++ b/ui/scripts/check-pre-install-import-graph.mjs @@ -44,7 +44,7 @@ * * ## Two design decisions, and the defect each one prevents * - * **The population is DERIVED from `.github/workflows/`, never hard-coded.** A + * **The population is DERIVED from the workflows, never hard-coded.** A * hard-coded list of scripts rots the first time someone moves a step above * `pnpm install` -- which is exactly the edit that needs catching. So this gate * parses every workflow, and for every job compares each step's index against @@ -74,14 +74,41 @@ * or `process.exit`. A gate that runs nine other gates to decide whether they * could run is a much larger blast radius than the question deserves. * + * ## Which workflows: the repository root's (objectstack#25712) + * + * GitHub runs only the repository root's `.github/workflows/`. Since objectui + * moved under `ui/` (objectstack#25825), its own `ui/.github/workflows/` runs + * nowhere. A population derived from there measured steps that never run, and + * it went red on one of them: ten of those dead steps still named the deleted + * `scripts/objectstack-pin.mjs`. So the workflows are read from + * `workflowRoot()`, the parent of this file's root, and only the steps there + * that run an objectui gate count. The root workflows spell those in two ways, + * and both are read: + * + * - `pnpm --dir ui run `, the spelling the root `ci.yml` prescribes for + * objectui's commands. The alias is resolved through `ui/package.json`'s + * `scripts`, and every `node scripts/` in its body is a row, since an + * alias runs with cwd = the objectui root; + * - `node ui/scripts/`, or `pnpm --dir ui exec node scripts/`. + * + * A bare `node scripts/` in a root workflow is objectstack's OWN script, + * resolved against the repository root, and is not in this population. + * Crediting it to `ui/scripts/` would judge a file of the same name that the + * step never runs; the root `ci.yml` header names the same trap for the + * gates there. + * * ## This gate satisfies its own rule, by construction * - * `repo-gates.yml` runs this file before its install step, so this - * file is IN its own derived population and walks itself. Its graph is + * This file runs before an install wherever it is wired, as every gate it + * judges does, so its own graph is held to the same floor. Its graph is * `node:fs`, `node:module`, `node:path`, `node:url`, plus `./invoked-as.mjs` * and `./js-comment-mask.mjs`, whose own graphs are builtins only. Nothing here - * may grow a package import without the gate reporting itself -- which is the - * intended arrangement, not a coincidence to be preserved by hand. + * may grow a package import without its test reporting it. + * + * ⚠️ Today it is not in its own DERIVED population. Its home was objectui's + * `repo-gates.yml`, and it joins the root `Lint UI` job, ahead of that job's + * install, when `ui/.github/workflows/` is deleted (objectstack#25712; the + * root `ci.yml` marks the place). Until then its test walks its graph directly. * * ## What is deliberately NOT judged * @@ -93,7 +120,7 @@ import { readFileSync, readdirSync } from 'node:fs'; import { isBuiltin } from 'node:module'; -import { dirname, join, resolve } from 'node:path'; +import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { isEntrypoint } from './invoked-as.mjs'; @@ -101,13 +128,24 @@ import { scanSource } from './js-comment-mask.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); -/** The repository root -- this file lives at `scripts/` depth 0. */ +/** The objectui root -- this file lives at `scripts/` depth 0 under it. */ export function repoRoot() { return resolve(HERE, '..'); } +/** + * The repository root whose `.github/workflows/` GitHub runs: the parent of the + * objectui root since the monorepo merge (objectstack#25825). + */ +export function workflowRoot(root = repoRoot()) { + return resolve(root, '..'); +} + const WORKFLOW_DIR = '.github/workflows'; +/** The objectui root as the root workflows spell it in a path. */ +const UI_DIR = 'ui'; + // --------------------------------------------------------------------------- // A workflow parser that needs no YAML package // --------------------------------------------------------------------------- @@ -300,7 +338,8 @@ function shellCode(run) { const INSTALL_RE = /(?:^|[;&|(])\s*(?:pnpm|npm|yarn|bun)\s+(?:install|ci|i)(?:\s|$)/m; /** - * `node scripts/whatever.mjs`, allowing `node --flag script.mjs`. + * `node scripts/whatever.mjs`, allowing `node --flag script.mjs`, read in an + * objectui `package.json` alias body, which runs with cwd = the objectui root. * * Deliberately looser than `INSTALL_RE`: any whitespace will do before `node`, * so a wrapped invocation (`xargs node scripts/x.mjs`, a `bash -c "..."`) is @@ -310,8 +349,49 @@ const INSTALL_RE = /(?:^|[;&|(])\s*(?:pnpm|npm|yarn|bun)\s+(?:install|ci|i)(?:\s */ const NODE_SCRIPT_RE = /(?:^|[\s;&|(])node\s+(?:--[^\s]+\s+)*(scripts\/[A-Za-z0-9._/-]+\.(?:mjs|cjs|js))/g; +const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +/** + * The objectui gates one root workflow step runs, in the order it runs them. + * + * The two spellings the header names, with the same loose left edge as + * `NODE_SCRIPT_RE`: `node /scripts/` (or `pnpm --dir exec node + * scripts/`), and `pnpm --dir run ` resolved through `aliases`. + * An alias that does not exist, or whose body runs no `node scripts/`, + * contributes nothing: the first fails its step loudly in CI, and the second has + * no JavaScript graph to walk, like any other non-`node` step. + * + * @param {string} code A step's `run:` with its shell comment lines dropped. + * @param {{ uiDir: string, aliases: Record }} options + * @returns {Array<{ script: string, alias: string | null }>} + */ +function uiInvocations(code, { uiDir, aliases }) { + const ui = escapeRe(uiDir); + const pnpmUi = String.raw`pnpm\s+(?:--dir(?:=|\s+)|-C\s*)${ui}`; + const flags = String.raw`(?:--[^\s]+\s+)*`; + // `node /scripts/`, or `pnpm --dir exec node scripts/`. A bare + // `node scripts/` matches neither: it is the root's own script. + const direct = new RegExp( + String.raw`(?:^|[\s;&|(])(?:node\s+${flags}${ui}\/|${pnpmUi}\s+exec\s+node\s+${flags})(scripts\/[A-Za-z0-9._/-]+\.(?:mjs|cjs|js))`, + 'g', + ); + const viaAlias = new RegExp(String.raw`(?:^|[\s;&|(])${pnpmUi}\s+run\s+([A-Za-z0-9:._-]+)`, 'g'); + + /** @type {Array<{ at: number, script: string, alias: string | null }>} */ + const hits = []; + for (const m of code.matchAll(direct)) hits.push({ at: m.index ?? 0, script: m[1], alias: null }); + for (const m of code.matchAll(viaAlias)) { + const body = Object.hasOwn(aliases, m[1]) ? aliases[m[1]] : null; + if (typeof body !== 'string') continue; + for (const n of shellCode(body).matchAll(NODE_SCRIPT_RE)) { + hits.push({ at: m.index ?? 0, script: n[1], alias: m[1] }); + } + } + return hits.sort((a, b) => a.at - b.at).map(({ script, alias }) => ({ script, alias })); +} + /** - * Every step that runs a `scripts/` JavaScript file before the first + * Every step that runs an objectui `scripts/` JavaScript file before the first * `pnpm install` in its own job. * * A job with NO install at all counts entirely: every `node` step in it is a @@ -319,10 +399,14 @@ const NODE_SCRIPT_RE = /(?:^|[\s;&|(])node\s+(?:--[^\s]+\s+)*(scripts\/[A-Za-z0- * built that way before objectui#12214 folded most of them into * `repo-gates.yml`, whose gates sit before its install step instead. * + * `uiDir` is the objectui root as the workflows spell it, and `aliases` is its + * `package.json` `scripts`; `scan()` passes both for the real tree. + * * @param {Array<{ file: string, text: string }>} workflows - * @returns {Array<{ workflow: string, job: string, step: number, stepName: string, script: string }>} + * @param {{ uiDir?: string, aliases?: Record }} [options] + * @returns {Array<{ workflow: string, job: string, step: number, stepName: string, script: string, alias: string | null }>} */ -export function derivePreInstallSteps(workflows) { +export function derivePreInstallSteps(workflows, { uiDir = UI_DIR, aliases = {} } = {}) { const found = []; for (const { file, text } of workflows) { @@ -336,15 +420,16 @@ export function derivePreInstallSteps(workflows) { // entry-guard step runs `--self-test` and then the gate itself, and two // identical rows would read as two steps. const seen = new Set(); - for (const m of code[i].matchAll(NODE_SCRIPT_RE)) { - if (seen.has(m[1])) continue; - seen.add(m[1]); + for (const { script, alias } of uiInvocations(code[i], { uiDir, aliases })) { + if (seen.has(script)) continue; + seen.add(script); found.push({ workflow: file, job: job.id, step: i, stepName: job.steps[i].name, - script: m[1], + script, + alias, }); } } @@ -354,7 +439,7 @@ export function derivePreInstallSteps(workflows) { return found; } -/** Read `.github/workflows/` from disk, sorted so output is stable. */ +/** Read `/.github/workflows/` from disk, sorted so output is stable. */ export function readWorkflows(root) { const dir = join(root, WORKFLOW_DIR); return readdirSync(dir) @@ -479,12 +564,25 @@ export function walkImportGraph(entry, { read }) { // The scan // --------------------------------------------------------------------------- +/** The objectui root's `package.json` `scripts`, the aliases a root step can run. */ +export function readAliases(root) { + try { + const scripts = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).scripts; + return scripts && typeof scripts === 'object' ? scripts : {}; + } catch { + return {}; + } +} + /** - * @param {string} root + * @param {string} root The objectui root: the scripts and aliases are read here. + * @param {{ workflows?: string }} [options] Where `.github/workflows/` is read; + * `workflowRoot(root)` by default. * @returns {{ steps: ReturnType, scripts: string[], modules: string[], findings: Array<{ script: string, chain: string[], specifier: string, kind: 'package' | 'unresolved' }> }} */ -export function scan(root) { - const steps = derivePreInstallSteps(readWorkflows(root)); +export function scan(root, { workflows = workflowRoot(root) } = {}) { + const uiDir = relative(workflows, root).split('\\').join('/'); + const steps = derivePreInstallSteps(readWorkflows(workflows), { uiDir, aliases: readAliases(root) }); const scripts = [...new Set(steps.map((s) => s.script))].sort(); const read = (rel) => { @@ -507,7 +605,8 @@ export function scan(root) { return { steps, scripts, modules: [...modules].sort(), findings }; } -const describe = (s) => `${s.workflow} : ${s.job} (step ${s.step}) -> ${s.script}`; +const describe = (s) => + `${s.workflow} : ${s.job} (step ${s.step}) -> ${s.script}` + (s.alias ? ` (via \`${s.alias}\`)` : ''); function main() { const result = scan(repoRoot()); @@ -558,16 +657,19 @@ Two ways out, and repairing the import is not this gate's job: worth paying. \`node scripts/check-pre-install-import-graph.mjs --list\` prints the derived -population and every module walked. The population is DERIVED from -${WORKFLOW_DIR}/ on every run, so moving a step across an install -changes it — nothing here is a hard-coded list to keep in sync.`); +population and every module walked. The population is DERIVED from the +repository root's ${WORKFLOW_DIR}/ on every run, so moving a step across an +install changes it — nothing here is a hard-coded list to keep in sync.`); process.exit(1); } function list() { const result = scan(repoRoot()); - console.log(`Derived from ${WORKFLOW_DIR}/ — steps running a scripts/ gate before any pnpm install:\n`); + console.log( + `Derived from the repository root's ${WORKFLOW_DIR}/ — steps running an objectui scripts/ gate ` + + 'before any pnpm install:\n', + ); for (const s of result.steps) console.log(` ${describe(s)}`); console.log(`\n${result.steps.length} step(s), ${result.scripts.length} distinct script(s).\n`); console.log('Modules reached by the walk:\n'); @@ -600,19 +702,21 @@ jobs: - name: Checkout code uses: actions/checkout@v7 - name: Gate - run: node scripts/probe-before.mjs + run: node ui/scripts/probe-before.mjs - name: Install dependencies run: pnpm install --frozen-lockfile - name: Late gate - run: node scripts/probe-after.mjs + run: node ui/scripts/probe-after.mjs never-installs: steps: - name: Checkout code uses: actions/checkout@v7 - name: Gate - run: node scripts/probe-no-install.mjs + run: pnpm --dir ui run probe:no-install `; - const derived = derivePreInstallSteps([{ file: 'probe.yml', text: WF }]); + const derived = derivePreInstallSteps([{ file: 'probe.yml', text: WF }], { + aliases: { 'probe:no-install': 'node scripts/probe-no-install.mjs' }, + }); const scripts = derived.map((s) => s.script); t( @@ -643,14 +747,14 @@ jobs: run: | # pnpm install --frozen-lockfile <- a SHELL comment, not an install echo "about to run the gate" - node scripts/probe-block.mjs + node ui/scripts/probe-block.mjs - name: Browsers are not node_modules - run: pnpm exec playwright install chromium + run: pnpm --dir ui exec playwright install chromium - name: Still pre-install - run: node scripts/probe-after-playwright.mjs + run: node ui/scripts/probe-after-playwright.mjs - name: Commented out run: | - # node scripts/probe-commented.mjs + # node ui/scripts/probe-commented.mjs echo done `; const block = derivePreInstallSteps([{ file: 'block.yml', text: BLOCK }]).map((s) => s.script); @@ -671,6 +775,46 @@ jobs: block.join(', '), ); + // -- the root workflows' spellings (objectstack#25712) ------------------- + const ROOT = `jobs: + j: + steps: + - name: The root's own gate + run: node scripts/root-own.mjs + - name: Through an alias + run: pnpm --dir ui run probe:alias --self-test + - name: Through exec + run: pnpm --dir ui exec node scripts/probe-exec.mjs + - name: An alias that runs no node script + run: pnpm --dir ui run probe:vitest + - name: An alias that does not exist + run: pnpm --dir ui run probe:missing +`; + const rooted = derivePreInstallSteps([{ file: 'root.yml', text: ROOT }], { + aliases: { 'probe:alias': 'node scripts/probe-alias.mjs', 'probe:vitest': 'vitest run' }, + }); + const rootScripts = rooted.map((s) => s.script); + t( + 'a `pnpm --dir ui run ` step is read through the objectui package.json', + rooted.some((s) => s.script === 'scripts/probe-alias.mjs' && s.alias === 'probe:alias'), + JSON.stringify(rooted), + ); + t( + '`pnpm --dir ui exec node scripts/` runs from the objectui root, so it counts', + rootScripts.includes('scripts/probe-exec.mjs'), + rootScripts.join(', '), + ); + t( + "a bare `node scripts/` in a root workflow is the root's own script, not objectui's", + !rootScripts.includes('scripts/root-own.mjs'), + rootScripts.join(', '), + ); + t( + 'an alias that runs no `node scripts/`, or does not exist, contributes no row', + rooted.length === 2, + rootScripts.join(', '), + ); + // -- the walk ------------------------------------------------------------ const graphOf = (files, entry) => walkImportGraph(entry, { read: (p) => (Object.hasOwn(files, p) ? files[p] : null) }); @@ -762,7 +906,7 @@ function runSelfTest() { } console.log( `✓ check-pre-install-import-graph self-test: ${cases.length} cases pass ` + - `(derivation boundary, block scalars, one-hop package, masked prose, cycles).`, + `(derivation boundary, block scalars, root spellings, one-hop package, masked prose, cycles).`, ); } From 57353f3d7110d92d69318766db719918319c9b30 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 05:25:30 +0000 Subject: [PATCH 2/2] chore(ui-merge): objectui's prompt files move to the root .github/prompts/, and check:prompt-keys reads them there VS Code reads prompt files from the workspace root's .github/prompts/, and objectui's nested .github/ is being retired. The three *.prompt.md files move unchanged. check-prompt-component-keys derives its registry under the objectui root and now reads the prompts under a separate root. promptsRoot() is the repository root. analyze() takes it as an option, and a fixture tree passes one directory for both roots, so the fixture cases are unchanged. The CLI defaults to the repository root and has a --prompts-root flag. The live-tree cases read the root prompts. Two new cases cover the move: no prompt file is left under the objectui root, and the bare command, as CI runs it, passes. The markdown-test-inputs ledger drops the three prompt paths from the two entries that declared them; they are no longer under the objectui tree. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01A6SEitNngAMugGbuJHWsDS --- .../prompts/component.prompt.md | 0 .../prompts/engine.prompt.md | 0 .../prompts/ui-library.prompt.md | 0 .../check-prompt-component-keys.test.ts | 33 +++++++++++++++---- ui/scripts/check-prompt-component-keys.mjs | 30 ++++++++++++++--- ui/scripts/markdown-test-inputs.mjs | 4 +-- 6 files changed, 54 insertions(+), 13 deletions(-) rename {ui/.github => .github}/prompts/component.prompt.md (100%) rename {ui/.github => .github}/prompts/engine.prompt.md (100%) rename {ui/.github => .github}/prompts/ui-library.prompt.md (100%) diff --git a/ui/.github/prompts/component.prompt.md b/.github/prompts/component.prompt.md similarity index 100% rename from ui/.github/prompts/component.prompt.md rename to .github/prompts/component.prompt.md diff --git a/ui/.github/prompts/engine.prompt.md b/.github/prompts/engine.prompt.md similarity index 100% rename from ui/.github/prompts/engine.prompt.md rename to .github/prompts/engine.prompt.md diff --git a/ui/.github/prompts/ui-library.prompt.md b/.github/prompts/ui-library.prompt.md similarity index 100% rename from ui/.github/prompts/ui-library.prompt.md rename to .github/prompts/ui-library.prompt.md diff --git a/ui/scripts/__tests__/check-prompt-component-keys.test.ts b/ui/scripts/__tests__/check-prompt-component-keys.test.ts index f764babcf5f..43c13bbc274 100644 --- a/ui/scripts/__tests__/check-prompt-component-keys.test.ts +++ b/ui/scripts/__tests__/check-prompt-component-keys.test.ts @@ -23,6 +23,7 @@ */ import { describe, expect, it } from 'vitest'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -39,12 +40,15 @@ import { partitionRegistry, placeholderSites, promptFiles, + promptsRoot, scanPromptKeys, scanPromptSections, } from '../check-prompt-component-keys.mjs'; import { INDIRECT_REGISTRATIONS, deriveRegistryKeys } from '../check-doc-component-types.mjs'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +/** The repository root, whose `.github/prompts/` the live prompts sit in (objectstack#25712). */ +const livePrompts = promptsRoot(repoRoot); const SCRIPT = 'scripts/check-prompt-component-keys.mjs'; interface Finding { @@ -270,12 +274,29 @@ describe('the repository this gate guards', () => { }); it('has a prompt surface with Keys bullets in it', () => { - expect(promptFiles(repoRoot).length).toBeGreaterThan(0); - expect(scanPromptKeys(repoRoot).bullets).toBeGreaterThan(0); + expect(promptFiles(livePrompts).length).toBeGreaterThan(0); + expect(scanPromptKeys(livePrompts).bullets).toBeGreaterThan(0); + }); + + it('reads the prompts at the repository root, and no copy is left under the objectui root', () => { + // objectstack#25712 moved them from `ui/.github/prompts/` to the root + // `.github/prompts/`, where VS Code reads prompt files. A second copy left + // behind would be the stale twin the next edit lands in. + expect(livePrompts).toBe(path.resolve(repoRoot, '..')); + expect(fs.existsSync(path.join(livePrompts, PROMPT_DIR))).toBe(true); + expect(promptFiles(repoRoot), 'a prompt file is still under the objectui root').toEqual([]); }); it('passes — every key the prompts teach is answered by a real renderer', () => { - expect((analyze(repoRoot) as { findings: Finding[] }).findings).toEqual([]); + expect((analyze(repoRoot, { promptsRoot: livePrompts }) as { findings: Finding[] }).findings).toEqual([]); + }); + + it('passes as CI runs it — the bare command reads the repository root prompts', () => { + // The root `Lint UI` job runs `pnpm --dir ui run check:prompt-keys` with no + // arguments, so the CLI's own default is the code path that gates. + const out = execFileSync('node', [SCRIPT], { cwd: repoRoot, encoding: 'utf8' }); + expect(out).toMatch(/^Scanned [1-9]\d* prompt file\(s\) under \.github\/prompts/); + expect(out).toContain('OK Every key taught as available'); }); }); @@ -526,7 +547,7 @@ describe('`Protocol Placeholders:` is judged the other way round', () => { // ── 7. the live tree, under the widened model ──────────────────────────────── describe('the prompt surface this gate now reads', () => { - const scan = () => scanPromptKeys(repoRoot) as { + const scan = () => scanPromptKeys(livePrompts) as { sites: { key: string; label: string; expect: string; scope: string }[]; bullets: number; blocks: number; @@ -572,7 +593,7 @@ describe('the prompt surface this gate now reads', () => { for (const tombstone of ['user:profile', 'ai:chat_window', 'view:kanban', 'view:gantt', 'view:map']) { expect(taught.has(tombstone), `${tombstone} must stay in prose, never in a judged list`).toBe(false); } - const prompt = fs.readFileSync(path.join(repoRoot, PROMPT_DIR, 'component.prompt.md'), 'utf8'); + const prompt = fs.readFileSync(path.join(livePrompts, PROMPT_DIR, 'component.prompt.md'), 'utf8'); for (const tombstone of ['user:profile', 'ai:chat_window', 'view:kanban']) { expect(prompt, `${tombstone} tombstone must still be written down`).toContain(`\`${tombstone}\``); } @@ -682,7 +703,7 @@ describe('a `## N.` section\'s `### X.` headings must run A, B, C, … without a // ── 9. the live tree, under the letter-run pin ─────────────────────────────── describe('the letter runs on the live prompt surface', () => { - const sections = () => scanPromptSections(repoRoot) as { + const sections = () => scanPromptSections(livePrompts) as { file: string; section: string; entries: { letter: string; line: number; text: string }[]; diff --git a/ui/scripts/check-prompt-component-keys.mjs b/ui/scripts/check-prompt-component-keys.mjs index 414740eb17c..c82d78885bc 100644 --- a/ui/scripts/check-prompt-component-keys.mjs +++ b/ui/scripts/check-prompt-component-keys.mjs @@ -13,6 +13,7 @@ * (objectui#8929). * * Run: node scripts/check-prompt-component-keys.mjs (`pnpm check:prompt-keys`) + * [--root ] [--prompts-root ] * Exit: 0 = every taught key resolves to a real component * 1 = a taught key resolves to nothing, or resolves only to the * placeholder panel, or this gate's own derivation collapsed @@ -136,9 +137,21 @@ import { closesFence, openFence } from './markdown-fence-scan.mjs'; const scriptDir = dirname(fileURLToPath(import.meta.url)); -/** The instruction surface this gate reads. */ +/** The instruction surface this gate reads, under `promptsRoot()`. */ export const PROMPT_DIR = '.github/prompts'; +/** + * Where `PROMPT_DIR` lives: the repository root, not the objectui root. VS Code + * reads prompt files from the workspace root's `.github/prompts/`, so they moved + * there from `ui/.github/prompts/` when objectui's nested `.github/` was retired + * (objectstack#25712). The registry the keys are judged against stays under the + * objectui root, which is why `analyze()` takes the two roots separately. A + * fixture tree that holds both passes one directory for both. + */ +export function promptsRoot(root = resolve(scriptDir, '..')) { + return resolve(root, '..'); +} + /** The namespace `INDIRECT_REGISTRATIONS` gives the opt-in placeholder module. */ export const PLACEHOLDER_NAMESPACE = 'protocol-placeholder'; @@ -475,8 +488,13 @@ export function letterRunFindings(runs) { /** * Judge the prompt surface. Throws when an input is missing, because a gate * that cannot see its inputs must not report a pass. + * + * `root` is the objectui root the registry is derived from; the prompts are read + * under `options.promptsRoot`, which defaults to `root` itself (a fixture tree). + * The real tree passes `promptsRoot()`. */ export function analyze(root, options = {}) { + const prompts = options.promptsRoot ?? root; const registry = deriveRegistryKeys(root, options); if (registry.findings.length > 0) { const lines = registry.findings.map((f) => ` ${f.reason} at ${f.site}: ${f.detail}`); @@ -507,7 +525,7 @@ export function analyze(root, options = {}) { throw new Error('the derivation produced no authorable keys — refusing to judge against an empty set.'); } - const { sites: taught, bullets, blocks, emptyBlocks } = scanPromptKeys(root); + const { sites: taught, bullets, blocks, emptyBlocks } = scanPromptKeys(prompts); if (bullets === 0) { throw new Error( `no \`**Keys:**\` bullet was found under ${PROMPT_DIR}. The surface this gate reads is gone or ` + @@ -523,7 +541,7 @@ export function analyze(root, options = {}) { ); } - const sections = scanPromptSections(root); + const sections = scanPromptSections(prompts); const runs = lettered(sections); if (sections.length > 0 && runs.length === 0) { throw new Error( @@ -549,7 +567,7 @@ export function analyze(root, options = {}) { return { findings, counters: { - promptFiles: promptFiles(root).length, + promptFiles: promptFiles(prompts).length, bullets, blocks, keys: taught.length, @@ -594,10 +612,12 @@ if (isEntrypoint(import.meta.url)) { return index > -1 ? process.argv[index + 1] : null; }; const root = resolve(argOf('--root') ?? resolve(scriptDir, '..')); + // A `--root` fixture tree holds its own prompts unless told otherwise. + const prompts = resolve(argOf('--prompts-root') ?? (argOf('--root') ? root : promptsRoot())); let result; try { - result = analyze(root); + result = analyze(root, { promptsRoot: prompts }); } catch (error) { console.error( `x ${error.message}\n\n` + diff --git a/ui/scripts/markdown-test-inputs.mjs b/ui/scripts/markdown-test-inputs.mjs index d63a1c16211..856db6a41e8 100644 --- a/ui/scripts/markdown-test-inputs.mjs +++ b/ui/scripts/markdown-test-inputs.mjs @@ -962,7 +962,7 @@ export const ADJUDICATED = new Map([ [ 'scripts/__tests__/ci-cd-pipeline-doc.test.ts', { - reads: ['.github/prompts/component.prompt.md', '.github/prompts/engine.prompt.md', '.github/prompts/ui-library.prompt.md', 'CONTRIBUTING.md', 'content/docs/guide/ci-cd-pipeline.md'], + reads: ['CONTRIBUTING.md', 'content/docs/guide/ci-cd-pipeline.md'], walker: 'not-markdown: `.github/workflows/*.yml`', }, ], @@ -1004,7 +1004,7 @@ export const ADJUDICATED = new Map([ [ 'scripts/__tests__/dollar-dialect-alias-census.test.ts', { - reads: ['./*', '.changeset/**', '.claude/skills/**', '.github/prompts/component.prompt.md', '.github/prompts/engine.prompt.md', '.github/prompts/ui-library.prompt.md', 'apps/**', 'content/docs/**', 'docs/ARCHITECTURE.md', 'docs/CONSOLE-STREAMLINING-SUMMARY.md', 'docs/adr/**', 'docs/audits/**', 'examples/**', 'packages/**', 'patches/README.md', 'skills/objectui/**'], + reads: ['./*', '.changeset/**', '.claude/skills/**', 'apps/**', 'content/docs/**', 'docs/ARCHITECTURE.md', 'docs/CONSOLE-STREAMLINING-SUMMARY.md', 'docs/adr/**', 'docs/audits/**', 'examples/**', 'packages/**', 'patches/README.md', 'skills/objectui/**'], }, ], [