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-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/__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-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).`, ); } 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/**'], }, ], [