diff --git a/.github/workflows/action-ref-convention.yml b/.github/workflows/action-ref-convention.yml deleted file mode 100644 index 6bbbf40def..0000000000 --- a/.github/workflows/action-ref-convention.yml +++ /dev/null @@ -1,94 +0,0 @@ -name: Action Ref Convention - -# ── THE CONVENTION THIS REPOSITORY USES FOR ACTION REFERENCES ──────────────── -# -# Default spelling: a floating major tag, `owner/action@vN`. Every `uses:` in -# this directory is written that way. -# -# An exception is marked by an entry in `DECLARED_EXCEPTIONS` in -# `scripts/check-action-ref-convention.mjs`, carrying the workflow file, the -# action path, the issue that owns the decision, and a real reason. That is the -# only form an exception takes. A trailing `# v9.0.0`-style comment beside a ref -# is a version HINT, not a reason, and the gate does not read it as one. -# -# ── WHY IT IS WRITTEN DOWN, WHICH IS NOT THE SAME AS WHICH SPELLING IT PICKS ── -# -# objectui#8465. There were 13 distinct action references in this directory. -# Exactly ONE was spelled differently from the other twelve — a commit SHA on -# `actions/stale` — and it was the only reference in the repository that had -# never resolved: 236 scheduled runs since 2026-01-16, 0 successes, every one -# failing in `Set up job`, unnoticed for eight months because nothing downstream -# consumes that job. The broken reference itself is objectui#8126. -# -# ⛔ The finding is the SOLE INSTANCE, not the pinning. SHA pinning is normally -# the MORE secure spelling and supply-chain guidance recommends it. What failed -# was one ref written in a form nothing else in the tree used, with no convention -# that would have made the odd one out visible and nothing verifying it. A -# floating tag that stops resolving is loud on the next run of every workflow -# that uses it; a lone off-convention ref that never resolved is silent for as -# long as nobody happens to look. -# -# ⛔ Do NOT read the default as a ruling against SHA pinning, and ⛔ do not -# "unify" this directory in either direction under cover of this gate. Moving the -# repository's pinning posture is a supply-chain decision and belongs on its own -# card (objectui#8465 triage, 2026-09-08). The value of the convention is -# independent of which spelling it names: once one exists, the next odd one out -# is loud instead of visible-only-if-you-look. -# -# ── WHY THIS IS ITS OWN WORKFLOW ───────────────────────────────────────────── -# -# Same reason as `control-bytes.yml`, and its header says it at length: a change -# that touches only `.github/workflows/**` runs nothing expensive in `ci.yml` or -# `lint.yml`, so a gate living inside one of those would be least likely to run -# on exactly the pull requests it exists to read. Dependabot opens -# `github-actions` update pull requests monthly here, and -# `dependabot-auto-merge.yml` can merge them — action refs are a surface that -# changes without a human choosing each edit. -# -# It needs no install and no build — a checkout plus one `node` call over -# `.github/workflows/`, a few seconds. Keep it that way. -# -# No `paths` and no `paths-ignore`, deliberately: the population it scans is this -# directory, so a pull request that edits only this directory must start it. - -on: - pull_request: - branches: [main, develop] - push: - branches: [main, develop] - # Merge queue: this gate reports on a queue build so it can be required later - # without stalling the queue. See `ci.yml`'s trigger block for the full note — - # a required context that does not report on a queue build does not fail the - # queue, it stalls it until the ruleset's 60-minute status-check timeout. - merge_group: - types: [checks_requested] - workflow_dispatch: - -concurrency: - group: action-ref-convention-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - action-ref-convention: - name: Action Ref Convention - runs-on: ubuntu-latest - timeout-minutes: 5 - - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: '22.x' - - # Reads `.github/workflows/` off the checkout, so no install is required. - # The gate refuses to report a verdict at all if its own census collapses: - # a scan that stops finding references renders identically to a clean - # repository, which is the one way a check like this lies. - - name: Check action reference spelling against the declared convention - run: node scripts/check-action-ref-convention.mjs diff --git a/.github/workflows/check-links.yml b/.github/workflows/check-links.yml index 62cbb16fab..1d65ce8792 100644 --- a/.github/workflows/check-links.yml +++ b/.github/workflows/check-links.yml @@ -3,7 +3,8 @@ name: Check Links # Lychee is the EXTERNAL link checker. It makes real network requests and is # flaky by nature. Site-internal routes (`/docs/...`) are a different problem # with a different tool — `scripts/check-doc-links.mjs`, run by -# `docs-links.yml`, which reads the checkout and nothing else and is therefore +# `repo-gates.yml` (objectui#12214; `docs-links.yml` before it), which reads the +# checkout and nothing else and is therefore # the one that gates pull requests. The two are complementary, not redundant; # do not move either one's job onto the other. # diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6046ac760b..21e712dda5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,20 @@ on: # build it came from never cancel each other. merge_group: types: [checks_requested] + # ── Two speeds (objectui#12214, objectstack#22998) ───────────────────── + # A `pull_request` or `merge_group` run carries the FAST SET: the `test` + # matrix below runs the test files the change affects, selected by + # `scripts/select-affected-tests.mjs`. `push` to `main` and this hourly + # `schedule` carry the FULL BATTERY: every test file in twelve shards, plus + # the four-shard coverage lane. Nothing is deleted or weakened; the whole + # suite moved to `main`, where it runs on every merge and at least hourly. + # GitHub runs `schedule` on the default branch only. The minute is off the + # hour on purpose: scheduled workflows queue behind everyone else's `:00`. + # On `schedule` the concurrency group below falls through to `github.ref` + # (`refs/heads/main`), which no push (a sha) or pull request (a number) + # shares; an hourly run is cancelled only by the next one, an hour later. + schedule: + - cron: '26 * * * *' # ── Concurrency (objectui#5422) ─────────────────────────────────── # On `pull_request` the group is the PR number and `cancel-in-progress` cancels @@ -748,10 +762,22 @@ jobs: # context, so the width here is an ordinary workflow edit rather than a # repository-settings change that renames four live required contexts. # + # ⭐ 8 -> 12 (objectui#12208, PR-1; ruling 6121816480, letter B). At eight + # shards the job was back against the same `timeout-minutes: 20`: measured + # on that card over the 1,728 shard jobs of the trailing seven days, the + # shard wall sat at 14.4 min median, 17.4 min p95 and 20.3 min max. Same + # remedy, same three refusals: ⛔ the ceiling is NOT raised, ⛔ no test is + # skipped, and ⛔ the relevance gate is not widened -- the work is spread + # over half again as many runners. Duration-balanced sharding is that + # card's second pull request, not this edit. The width is one number on + # both speeds (objectui#12214): the affected run on a pull request or queue + # build and the full battery on `push` and `schedule` both use these twelve + # legs, so `test-aggregate`'s `--shards` has one value to agree with. + # # Vitest shards by hashing each test file's path, so `unit` and `dom` files # interleave across shards on their own — no manual balancing needed. test: - name: Test (shard ${{ matrix.shard }}/8) + name: Test (shard ${{ matrix.shard }}/12) # Coverage instrumentation (v8 adds 40-100% overhead) is skipped on PRs; # the `test-coverage` job below runs the coverage lane on push. # @@ -769,7 +795,14 @@ jobs: # report is published as an artifact rather than uploaded to Codecov). PR # and push behaviour is unchanged — only the previously impossible third # case moves. - if: github.event_name != 'push' + # + # ⭐ Two speeds (objectui#12214): the event guard that kept this matrix off + # `push` is GONE. Every event runs it now, at one of two speeds: on + # `pull_request` and `merge_group` the `Select the tests this change + # affects` step below narrows each shard to the affected set; on `push` and + # `schedule` it is the whole suite, beside the coverage lane. The width + # is the same on both speeds, so `test-aggregate` keeps one `--shards` and + # still refuses a partial set. runs-on: ubuntu-latest # Bound the job so a stalled runner / non-exiting test worker fails fast and # is retryable, instead of hanging up to GitHub's 6h default. @@ -779,7 +812,7 @@ jobs: # Report every shard's failures, not just the first one to break. fail-fast: false matrix: - shard: [1, 2, 3, 4, 5, 6, 7, 8] + shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] steps: - name: Checkout code @@ -934,18 +967,84 @@ jobs: if: steps.relevant.outputs.should_run == 'true' run: pnpm install --frozen-lockfile + # ── The two speeds (objectui#12214, objectstack#22998) ────────────── + # `push` and `schedule` are the full battery: nothing is selected, every + # test file runs. On `pull_request` and `merge_group` the selector names + # the affected set -- the packages the change sits in and every package + # that depends on them, plus the test files the package graph cannot + # place, which run on every change -- or answers `mode=full` when the + # change reaches something every test runs through. Its header states the + # rules and what it does not see; the full battery on `main` is the + # backstop for every miss. + # + # Fails OPEN, like every gate in this file: a selector that exits + # non-zero, prints no verdict, or is handed no base sha means the full + # suite, never a skip. The exit status is captured beside the output, not + # through a command substitution (objectui#6221). Every shard computes the + # same selection from the same checkout, so `--shard` partitions one set. + - name: Select the tests this change affects + id: select + if: steps.relevant.outputs.should_run == 'true' + env: + EVENT: ${{ github.event_name }} + PR_BASE: ${{ github.event.pull_request.base.sha }} + QUEUE_BASE: ${{ github.event.merge_group.base_sha }} + run: | + case "$EVENT" in + pull_request) base="$PR_BASE" ;; + merge_group) base="$QUEUE_BASE" ;; + *) + echo 'mode=full' >> "$GITHUB_OUTPUT" + echo "A $EVENT run is the full battery: every test file, twelve shards." + exit 0 + ;; + esac + status=0 + if [ -z "$base" ]; then + echo "::warning::no base sha on this $EVENT event; running the full suite rather than guessing." + status=2 + else + node scripts/select-affected-tests.mjs --base "$base" --out "$RUNNER_TEMP/affected-tests.txt" \ + > "$RUNNER_TEMP/select.out" || status=$? + cat "$RUNNER_TEMP/select.out" + fi + verdict='' + if [ "$status" -eq 0 ]; then + verdict=$(tail -n 1 "$RUNNER_TEMP/select.out") + fi + case "$verdict" in + mode=affected | mode=full) ;; + *) + echo "::warning::the affected-test selector reached no verdict (exit $status); running the full suite rather than skipping silently." + verdict='mode=full' + ;; + esac + echo "$verdict" >> "$GITHUB_OUTPUT" + # Run the canonical root Vitest project once. Running `turbo run test` # here starts one Vitest process per package; several package configs # intentionally inherit the root monorepo project list, so CI ends up # repeating large chunks of the suite and can starve slower plugin tests. - - name: Run tests (shard ${{ matrix.shard }}/8) + # The affected speed is the same root run with the selector's filters as + # positionals -- paths relative to the repository root, no `--`, which is + # the only spelling `scripts/vitest-invocation-guard.mjs` admits. + - name: Run tests (shard ${{ matrix.shard }}/12) if: steps.relevant.outputs.should_run == 'true' - run: pnpm test --shard=${{ matrix.shard }}/8 + env: + MODE: ${{ steps.select.outputs.mode }} + run: | + if [ "$MODE" = 'mode=affected' ]; then + mapfile -t filters < "$RUNNER_TEMP/affected-tests.txt" + echo "Affected speed: ${#filters[@]} vitest filter(s), shard ${{ matrix.shard }} of 12." + pnpm exec vitest run --shard=${{ matrix.shard }}/12 "${filters[@]}" + else + pnpm test --shard=${{ matrix.shard }}/12 + fi # ── The built-artifact pin lane, lifted out of the shard matrix ────────── # (objectui#7183 for the lane itself; objectui#9499 for this job.) # - # `pnpm test --shard=N/8` above resolves every workspace package to its `src` + # `pnpm test --shard=N/12` above resolves every workspace package to its `src` # -- that is what the root alias map is for -- so no test in it can observe # its own package's BUILT output. This is the only place a `dist`-importing # pin runs, and `turbo run test:dist` carries `dependsOn: ["build"]` for the @@ -973,10 +1072,10 @@ jobs: # line of it is on the `test` job above; it is not repeated here. test-dist-pins: name: Test (dist pins) - # Same event guard as the shard matrix above: the push lane is the coverage - # lane, and `test-aggregate` below asserts this job's result, so the two - # must skip together or the aggregator would judge a job that never ran. - if: github.event_name != 'push' + # No event guard, like the shard matrix above (objectui#12214): the push + # lane used to be the coverage lane alone, and this job skipped with the + # shards. `test-aggregate` below asserts this job's result, so the two + # still run together -- now on every event. runs-on: ubuntu-latest # The same bound the matrix carries. This job builds one package and runs a # handful of files, so it sits nowhere near it -- the bound is here for the @@ -1074,7 +1173,7 @@ jobs: # ⚠️ `needs.test.result` ALONE WOULD BE A PHANTOM GATE, and that is why this # job runs a script rather than reading an expression. A matrix job's # `needs..result` is a single ROLLUP over every leg, and a rollup cannot - # distinguish "all eight ran green" from "five ran green and three were + # distinguish "all twelve ran green" from "nine ran green and three were # SKIPPED". A shard dropped from the matrix, or given a job-level `if:` that # is false, would leave this context GREEN while a fraction of the suite never # ran -- the objectui#8857 / objectui#3523 shape, a check reporting success @@ -1086,12 +1185,13 @@ jobs: # `if: always()` so a red, skipped or cancelled shard still produces a verdict # here instead of skipping this job -- a skipped required context counts as # SUCCESS in branch protection, which on the one context that speaks for the - # whole test lane is the failure mode itself. The event guard matches the two - # jobs above: on `push` they do not run, and neither does this. + # whole test lane is the failure mode itself. No event guard, matching the + # two jobs above (objectui#12214): on `push` and `schedule` this is the full + # battery's test verdict for the commit on `main`, read the same way. test-aggregate: name: Test needs: [test, test-dist-pins] - if: always() && github.event_name != 'push' + if: always() runs-on: ubuntu-latest timeout-minutes: 10 # Declaring any `permissions:` block sets every unlisted scope to `none`, @@ -1118,7 +1218,7 @@ jobs: TEST_MATRIX_ROLLUP: ${{ needs.test.result }} run: | echo "needs.test.result (the matrix rollup this gate deliberately does NOT trust) = $TEST_MATRIX_ROLLUP" - node scripts/check-test-shard-results.mjs --shards 8 --job-name 'Test' + node scripts/check-test-shard-results.mjs --shards 12 --job-name 'Test' # The dist-pin lane is NOT a matrix, so its `needs` result is that one # job's own conclusion and `skipped` is visible in it -- no API read is @@ -1202,7 +1302,9 @@ jobs: # than left as a claim the run does not actually support. test-coverage: name: Test (coverage shard ${{ matrix.shard }}/4) - if: github.event_name == 'push' + # The full battery's coverage half: every merge to `main` (`push`) and the + # hourly `schedule` run (objectui#12214). + if: github.event_name == 'push' || github.event_name == 'schedule' runs-on: ubuntu-latest timeout-minutes: 40 @@ -1399,9 +1501,9 @@ jobs: name: Test (coverage) needs: test-coverage # `always()` so the verdict step below still runs when a shard failed — - # that is the entire point. The event guard keeps this lane push-only, as - # the unsharded job was. - if: always() && github.event_name == 'push' + # that is the entire point. The event guard matches `test-coverage`: the + # full battery's events, `push` and `schedule` (objectui#12214). + if: always() && (github.event_name == 'push' || github.event_name == 'schedule') runs-on: ubuntu-latest timeout-minutes: 20 @@ -2039,7 +2141,8 @@ jobs: # The docs *link* check is deliberately NOT here any more — do not add it # back. `node scripts/check-doc-links.mjs` ran in this job from #3213 / # #3292 (PR #3450) until #3448 promoted it to its own workflow, - # `docs-links.yml`, with no path filters. The reason THEN was this + # `docs-links.yml` (a step of `repo-gates.yml` since objectui#12214), with no + # path filters. The reason THEN was this # workflow's own `paths-ignore`, which at the time listed `content/**` and # `'**/*.md'` on both triggers: GitHub has no per-job path filter, so a # docs-ONLY pull request never started `ci.yml` at all and the link check diff --git a/.github/workflows/control-bytes.yml b/.github/workflows/control-bytes.yml deleted file mode 100644 index 43f7cb1c39..0000000000 --- a/.github/workflows/control-bytes.yml +++ /dev/null @@ -1,82 +0,0 @@ -name: Control Bytes - -# Why this is its own workflow instead of a job in `ci.yml` or `lint.yml`: on a -# markdown-only change neither of those runs anything expensive, so a gate living -# inside one of them would never see it. A raw control byte lands in markdown -# exactly as easily as in TypeScript — objectstack#4890 was a NUL in a `.claude/` -# skill file, emitted by the very PR that was writing the rule against it — so a -# gate that cannot see a markdown-only PR rebuilds the hole it exists to close. -# `changeset-guard.yml` sits in this repo for the same reason and says so in its -# own header. -# -# That is no longer the reason this header used to give, and objectui#4381 -# corrected it. It said both of those workflows list `'**/*.md'`, `content/**`, -# `docs/**` and `.changeset/**` under `paths-ignore`, with no per-job path filter -# available in GitHub Actions, so a markdown-only PR started neither. -# objectui#3523 step 2 deleted `paths-ignore` from their `pull_request` trigger; -# it remains ONLY on `push`. Such a PR does start both workflows and does produce -# their contexts — measured: PR #3856 (one markdown file) 16 checks, PR #4339 -# (one line added to AGENTS.md) 17. The correction is objectui#3857; an author -# had already acted on the old sentence and got the opposite result. -# -# What #3523 moved rather than deleted is the path DECISION: it is now the -# `Decide whether this change needs a full run` step in `ci.yml`, with a twin in -# `lint.yml`, and its exclusion list is that `push` filter unchanged — markdown -# among it, held identical to it by -# `scripts/__tests__/merge-queue-reporting.test.ts`. Both workflows therefore -# start, report, and skip every expensive step on a markdown-only PR, and both -# still skip the run outright on a markdown-only push to `main`. The conclusion -# outlived its premise: this gate has to run outside that in-job switch. -# -# Hence: no `paths` and no `paths-ignore` here, deliberately. -# `scripts/__tests__/check-control-bytes.test.ts` fails if either is ever added. -# -# It needs no install and no build — a checkout plus one `node` call over -# `git ls-files`, a few seconds — so keep it that way if you add checks to it. - -on: - pull_request: - branches: [main, develop] - push: - branches: [main, develop] - # Merge queue (objectui#3523 — see `ci.yml`'s trigger block for the full note - # and the measurements behind it). This repository's queue is enforced by a - # ruleset but had zero `merge_group` subscribers, so its required-check set - # could only ever be empty. This gate is one of the two the audit found safe - # to require today — deliberately unfiltered, so it reports on every shape of - # pull request — and a required check that does not report on a queue build - # stalls the queue until the ruleset's 60-minute timeout fails it. `types:` is - # named although `checks_requested` is currently the only one GitHub defines. - merge_group: - types: [checks_requested] - workflow_dispatch: - -concurrency: - group: control-bytes-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - control-bytes: - name: Control Byte Scan - runs-on: ubuntu-latest - timeout-minutes: 5 - - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: '22.x' - - # A single raw U+0000 makes grep and ripgrep classify the whole file as - # binary and print no matching lines at all, so the file drops out of code - # search and out of every grep-based lint — with no error to say so. git - # will not warn either: it decides binary-ness from the first 8000 bytes - # only. Reads `git ls-files`, so no install is required. - - name: Scan tracked text files for raw control bytes - run: node scripts/check-control-bytes.mjs diff --git a/.github/workflows/doc-component-types.yml b/.github/workflows/doc-component-types.yml deleted file mode 100644 index 69fb725fd0..0000000000 --- a/.github/workflows/doc-component-types.yml +++ /dev/null @@ -1,114 +0,0 @@ -name: Doc Component Types - -# Why this is its own workflow instead of a step in `ci.yml` or `lint.yml`: the -# defect this gate exists for arrives in a DOCS-ONLY pull request, and that is -# precisely the shape both of those workflows skip. `ci.yml`'s `type-check` job -# decides whether to run its expensive steps with a `git diff` that excludes -# `content/**`, `'**/*.md'`, `docs/**` and `apps/site/**` — so a PR that edits -# only `content/docs/**` reports the context and runs none of the gates inside -# it. Note the `'**/*.md'` term especially: since objectui#5342 this gate reads -# the `.md` guides too, which is exactly the extension that diff excludes. A gate against a wrong `type` in a teaching snippet, wired there, -# would be blind to every change that can introduce one. -# -# This is the fifth instance of the shape in this repo and the reasoning is -# borrowed, not invented: `docs-links.yml`'s header records the link check -# spending #3213 to #3448 inside `ci.yml`'s `docs` job, unable to see the one -# class of PR most likely to break a link; `control-bytes.yml`'s header names the -# consequence — a gate that cannot see a markdown-only change "rebuilds the hole -# it exists to close". `changeset-guard.yml` and `skills-paths.yml` are the third -# and fourth. -# -# Hence: no `paths` and no `paths-ignore` here, deliberately. -# `scripts/__tests__/check-doc-component-types.test.ts` fails if either is ever -# added, and fails too if a second workflow starts running the same script — one -# gate, one home. -# -# It needs no install and no build. The script reads the checkout with `node:fs` -# only: every `.mdx` AND `.md` page under `content/docs/**` and `apps/*/docs/**`, -# plus the root pages in `ROOT_PAGES` — `.md` has been in that population since -# objectui#5342, which is the extension `ci.yml`'s diff excludes — for the fenced -# snippets AND for the plugin key tables (objectui#5106), and the `packages/` + -# `apps/` sources for the registered-key universe it compares them against. A few -# seconds. Keep it that way if you add checks to it — the moment this needs -# `pnpm install` it stops being cheap enough to run unfiltered, and the filter is -# the hole. -# -# ⛔ Deliberately NO page count is written here, and none may be added -# (objectui#7448). The sentence above used to carry one — a total and its -# per-extension split — and every part of it had drifted, because nothing fails -# on a stale number in a comment, which is precisely why it rots. Refreshing the -# literal only restarts that clock; the rule above has no clock to restart, since -# it states the population instead of counting it. This is the fix -# `check-doc-snippet-types.mjs`'s `UNGATED_DOCS` header records being applied to -# itself once both halves of its own copied count had drifted ("a pointer to the -# list now rather than a copy of its length"). Two durable readings, neither a -# copy: -# -# * HOW MANY — the run below prints it, in the gate's own verdict line: -# "Scanned N doc file(s) (.mdx + .md), …". `pnpm check:doc-types` reprints it -# on demand. -# * WHICH — `scanDocs` in `scripts/check-doc-component-types.mjs`, which -# derives the population from the tree on every run. -# -# `scripts/__tests__/check-doc-component-types.test.ts` fails if a population -# count reappears here, so the rule above is a gate rather than an intention. - -on: - pull_request: - branches: [main, develop] - push: - branches: [main, develop] - # Merge queue (objectui#3523 — see `ci.yml`'s trigger block for the full note - # and the measurements behind it). A required check that does not report on a - # queue build stalls the queue until the ruleset's 60-minute timeout fails it, - # so an unfiltered gate that could become required subscribes here from the - # start. `types:` is named although `checks_requested` is currently the only - # activity type GitHub defines for `merge_group`. - merge_group: - types: [checks_requested] - workflow_dispatch: - -concurrency: - group: doc-component-types-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - doc-component-types: - name: Doc Component Type Check - runs-on: ubuntu-latest - timeout-minutes: 5 - - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: '22.x' - - # A `type` string in a `content/docs/**` code block is not rendered, - # not parsed and not compared against anything, so a snippet can name a - # component that does not exist and every check in the repo stays green — - # while a reader who copies it gets the renderer's red "Unknown component - # type" panel (OBJUI-001). That defect landed three times before this gate - # (objectui#4786 `stats-card`, objectui#4796 `plugin:grid` and - # `plugin:map`), each found by a human probe. The catalog side has had the - # equivalent ratchet since objectui#4616 - # (`examples/schema-catalog/test/catalog-gallery-render.test.tsx`); this is - # the missing half. Reads the checkout and nothing else, so no install. - - name: Check documented component types against the registry - run: node scripts/check-doc-component-types.mjs - - # Same shape, same blind spot, one file further out: `.github/prompts/**` - # is markdown, so `ci.yml`'s type-check diff excludes it under `'**/*.md'` - # and a prompt-only PR starts that gate nowhere. The step above asks "does - # this type exist"; this one asks the stricter question a file read by an - # AI AUTHOR has to survive — "does it render, or is it answered only by the - # opt-in placeholder panel". A retired key passes the first and fails the - # second, which is the whole of objectui#8929. Reads the checkout only. - - name: Check prompt-taught component keys render - run: node scripts/check-prompt-component-keys.mjs diff --git a/.github/workflows/doc-example-ids.yml b/.github/workflows/doc-example-ids.yml deleted file mode 100644 index c0f511701d..0000000000 --- a/.github/workflows/doc-example-ids.yml +++ /dev/null @@ -1,90 +0,0 @@ -name: Doc Example Ids - -# Why this is its own workflow instead of a step in `ci.yml` or `lint.yml`: the -# defect this gate exists for arrives in a DOCS-ONLY pull request, and that is -# precisely the shape both of those workflows skip. `ci.yml`'s `type-check` job -# decides whether to run its expensive steps with a `git diff` that excludes -# `content/**`, `'**/*.md'`, `docs/**` and `apps/site/**` — so a pull request -# that edits only `content/docs/**` reports the context and runs none of the -# gates inside it. A gate against a stale ``, wired -# there, would be blind to every change that can introduce one: the id is typed -# into an MDX page and nothing else has to move. -# -# The reasoning is borrowed rather than invented — `doc-component-types.yml`'s -# header records it for the neighbouring gate over these same pages, and -# `control-bytes.yml`'s names the consequence: a gate that cannot see a -# markdown-only change "rebuilds the hole it exists to close". -# -# Hence: no `paths` and no `paths-ignore` here, deliberately. -# `scripts/__tests__/check-doc-example-ids.test.ts` fails if either is ever -# added, and fails too if a second workflow starts running the same script — one -# gate, one home. -# -# It needs no install and no build. The script reads the checkout with `node:fs` -# only: every `.mdx` and `.md` page under `content/docs/**` for its -# `` references, and the generated schema-catalog index for the -# id universe it resolves them against. A few seconds. Keep it that way — the -# moment this needs `pnpm install` it stops being cheap enough to run -# unfiltered, and the filter is the hole. -# -# ⛔ Deliberately NO population count is written here, and none may be added. -# The neighbouring gate paid for that lesson (objectui#7448): a total in a -# comment has nothing that fails when it drifts, which is exactly why it rots, -# and refreshing the literal only restarts the clock. Two durable readings, -# neither a copy: -# -# * HOW MANY — the run below prints it, in the gate's own funnel line: -# "Doc example ids — walked N page(s) …, found M reference(s) …". `pnpm -# check:doc-example-ids` reprints it on demand. -# * WHICH — `scanDocs` in `scripts/check-doc-example-ids.mjs`, which derives -# the population from the tree on every run. -# -# `scripts/__tests__/check-doc-example-ids.test.ts` fails if a population count -# reappears here, so the rule above is a gate rather than an intention. - -on: - pull_request: - branches: [main, develop] - push: - branches: [main, develop] - # Merge queue (objectui#3523 — see `ci.yml`'s trigger block for the full note - # and the measurements behind it). A required check that does not report on a - # queue build stalls the queue until the ruleset's 60-minute timeout fails it, - # so an unfiltered gate that could become required subscribes here from the - # start. `types:` is named although `checks_requested` is currently the only - # activity type GitHub defines for `merge_group`. - merge_group: - types: [checks_requested] - workflow_dispatch: - -concurrency: - group: doc-example-ids-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - doc-example-ids: - name: Doc Example Id Check - runs-on: ubuntu-latest - timeout-minutes: 5 - - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: '22.x' - - # A `` in a docs page resolves through - # `getExample(id)`, which THROWS on an unknown id rather than degrading — - # the honest runtime behaviour, and the reason a mistyped or stale id is a - # crash in the published docs rather than a blank tile. Nothing asked the - # question before render: the catalog's own suite resolves every registry - # ENTRY, which cannot see a page pointing at an id that is not there. - # Reads the checkout and nothing else, so no install. - - name: Check documented example ids against the catalog registry - run: node scripts/check-doc-example-ids.mjs diff --git a/.github/workflows/doc-fence-languages.yml b/.github/workflows/doc-fence-languages.yml deleted file mode 100644 index 179f1c9eb6..0000000000 --- a/.github/workflows/doc-fence-languages.yml +++ /dev/null @@ -1,109 +0,0 @@ -name: Doc Fence Languages - -# Why this is its own workflow rather than a step in `ci.yml` or `lint.yml`: the -# defect it exists for arrives in a DOCS-ONLY pull request, and that is exactly -# the shape both of those workflows skip — their "does this change need a full -# run" diff excludes `content/**` and `'**/*.md'`, which is the entire surface -# this gate reads. `doc-component-types.yml`'s header records the reasoning in -# full; this is the sixth instance of the same shape, and `control-bytes.yml`'s -# header names the consequence of getting it wrong: a gate that cannot see a -# markdown-only change "rebuilds the hole it exists to close". -# -# Hence: no `paths` and no `paths-ignore` here, deliberately. -# `scripts/__tests__/check-doc-fence-languages.test.ts` fails if either is ever -# added, and fails too if a second workflow starts running the same script — one -# gate, one home. -# -# It needs no install and no build. The script reads the checkout with `node:fs` -# only — exactly the documents `check-doc-snippet-types` covers — and it -# deliberately re-implements that gate's document walk rather than importing it, -# because that gate imports `typescript` and an install-gated docs check is one -# a docs-only pull request skips. The copy is held to the original by the pin -# test above, which imports BOTH walks and compares them. Keep this job -# install-free: the moment it needs `pnpm install` it stops being cheap enough -# to run unfiltered, and the filter is the hole. -# -# ⛔ Deliberately NO document count is written here, and none may be added -# (objectui#7448). The sentence above used to state one as a literal, and it was -# already wrong by the time anyone read it back — nothing fails on a stale number -# in a comment, which is precisely why it rots. Refreshing the literal only -# restarts that clock; a pointer has no clock to restart. This is the fix -# `check-doc-snippet-types.mjs`'s `UNGATED_DOCS` header records being applied to -# itself once both halves of its own copied count had drifted ("a pointer to the -# list now rather than a copy of its length"), and it is why the pin test -# deliberately does not assert the size of the baseline either. Two durable -# readings, neither of them a copy: -# -# * HOW MANY — the run below prints it, in the gate's own verdict line: "every -# TypeScript block in N document(s) is fenced ts/tsx/typescript". `pnpm -# check:doc-fences` reprints it on demand. -# * WHICH — `listDocuments` in `scripts/check-doc-fence-languages.mjs`, which -# derives the population from the tree on every run. -# -# What IS pinned is the claim that survives a document being added or deleted: -# that the two populations are the same SET. The pin test compares the walks -# themselves rather than a copy of their length — the only number it holds is a -# non-vacuity FLOOR, which no addition or deletion can falsify — and it also -# fails if a population count reappears in this header, so the paragraph above -# is a gate rather than an intention. - -on: - pull_request: - branches: [main, develop] - push: - branches: [main, develop] - # Merge queue (objectui#3523 — see `ci.yml`'s trigger block for the full note - # and the measurements behind it). A required check that does not report on a - # queue build stalls the queue until the ruleset's 60-minute timeout fails it, - # so an unfiltered gate that could become required subscribes here from the - # start. `types:` is named although `checks_requested` is currently the only - # activity type GitHub defines for `merge_group`. - merge_group: - types: [checks_requested] - workflow_dispatch: - -concurrency: - group: doc-fence-languages-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - doc-fence-languages: - name: Doc Fence Language Check - runs-on: ubuntu-latest - timeout-minutes: 5 - - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: '22.x' - - # `check-doc-snippet-types` compiles `ts` / `tsx` / `typescript` fences and - # nothing else, so a TypeScript block fenced any other way is invisible to - # it — objectui#5867, whose remediation lane collected its population from - # ```plaintext fences only. `plaintext` is not the only spelling of an - # unhighlighted fence: objectui#6135 measured a ```text block opening - # `interface FileUploadSchema {` sitting outside the gate AND outside the - # lane that exists to close it, for no reason but how its fence is spelled. - # - # This gate reads block BODIES with objectui#5867's own triage classifier, - # so no list of languages is on the enforcement path and `txt`, `console`, - # `raw` or a bare fence with no info string at all cannot reopen the gap. - # Today's residue is declared, SHRINK-ONLY, and is that lane's remaining - # population per file. - # - # `--self-test` runs FIRST and is the half that stops the gate rotting into - # decoration: it drives the real scanner over fixture sources, including - # the three spellings named on objectui#6135, and pins the shrink-only - # baseline in every direction it can move. A scanner whose recogniser is - # broken reports a clean tree. - - name: Check that no fence spelling hides a TypeScript block - run: | - node scripts/check-doc-fence-languages.mjs --self-test - node scripts/check-doc-fence-languages.mjs diff --git a/.github/workflows/doc-snippet-types.yml b/.github/workflows/doc-snippet-types.yml deleted file mode 100644 index 57e9a00710..0000000000 --- a/.github/workflows/doc-snippet-types.yml +++ /dev/null @@ -1,199 +0,0 @@ -name: Doc Snippet Types - -# Compiles every fenced `ts` / `tsx` snippet in the documents this gate covers, -# `--strict`, against the packages' BUILT `dist/*.d.ts`. The gate itself, the -# fragment rule, the three self-controls and the coverage ledger are documented -# at length in `scripts/check-doc-snippet-types.mjs`. -# -# ── Why this is its own workflow ──────────────────────────────────────────── -# -# Same reason `doc-component-types.yml`, `docs-links.yml` and `control-bytes.yml` -# are theirs, and it is worth restating because it is the whole point: the change -# that breaks a documentation snippet is a DOCS-ONLY change, and that is exactly -# the shape `ci.yml`'s expensive jobs short-circuit (their `git diff` excludes -# `content/**` and `'**/*.md'`). A snippet gate wired in there would be blind to -# every pull request most likely to introduce a defect. Hence: no `paths` and no -# `paths-ignore` here, deliberately, and -# `scripts/__tests__/check-doc-snippet-types.test.ts` fails if either is added, -# or if a second workflow starts running the same script. -# -# ── Why it builds, and why that is NOT the build a ruling rejected ────────── -# -# Unlike its install-free sibling `doc-component-types.yml`, this gate cannot -# read the checkout alone: its whole criterion is the PUBLISHED type surface, so -# the packages the covered snippets import have to exist as `dist/*.d.ts` first. -# Resolving against `src/` instead would be a different and weaker check — the -# root `tsconfig.json` maps the workspace to source, so that mistake is one -# inherited config away, and the script's RESOLUTION control fails the run rather -# than letting it pass quietly. -# -# The 2026-08-16 ruling on objectui#4846 (recorded in -# `.github/workflows/published-dist-gate.yml`) rejected a per-PR FULL-REPO build -# — all 39 published packages, on every pull request. This is not that, and the -# difference is mechanical rather than a matter of opinion: the build is filtered -# to the packages the COVERED documents actually import, and that list is emitted -# by the gate itself (`--build-filter`) rather than hand-maintained here. Today -# it is a minority of the workspace. It grows only when coverage grows, and when -# it does, the growth is visible in this job's log rather than hidden in a -# workflow edit. -# -# ⛔ Do not replace the filtered build with `pnpm build`. The filter is the reason -# this job is allowed to run on every pull request at all. -# -# ── Why the `@example` gate rides in this job rather than a workflow of its own ─ -# -# `scripts/check-doc-example-types.mjs` (`pnpm check:doc-examples`) is a SIBLING -# of the gate above, not a fork: it imports that script's compiler host, its -# built-`.d.ts` resolution and every one of its controls, and it reuses that -# script's `analyze()` wholesale. So its precondition is the SAME filtered build -# this job already pays for, derived by the SAME `--build-filter` call — a -# workflow of its own would install the workspace a second time and rebuild the -# same closure a second time to judge a corpus that needs neither. -# -# It also could not derive that filter without naming -# `scripts/check-doc-snippet-types.mjs` in a second workflow file, which -# `scripts/__tests__/check-doc-snippet-types.test.ts` fails on by design -# ("lives in exactly one workflow — one gate, one home"). -# -# It runs AFTER the blocking snippet gate, deliberately. Both are blocking, so -# whichever runs second is skipped when the first is red; putting the newly -# wired gate first would let it mask an established one, which is the worse of -# the two directions. -# -# It was declared in `package.json` and run by no workflow at all until -# objectui#8757 — measured there: a defect it catches reached `main` and was -# repaired 71 minutes later with nothing observing either direction, and the -# card that reported the red was written from a stale merge base because no run -# existed to read. - -on: - pull_request: - branches: [main, develop] - push: - branches: [main, develop] - # Merge queue (objectui#3523 — `ci.yml`'s trigger block carries the full note). - # A required check that does not report on a queue build stalls the queue until - # the ruleset times it out, so an unfiltered gate subscribes from the start. - merge_group: - types: [checks_requested] - workflow_dispatch: - -concurrency: - group: doc-snippet-types-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - doc-snippet-types: - name: Doc Snippet Type Check - runs-on: ubuntu-latest - timeout-minutes: 25 - - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Enable Corepack and download the pinned pnpm - run: bash scripts/ci-setup-pnpm.sh - - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: '22.x' - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - # The filter comes from the gate, so it can never drift from what the - # covered documents import. A package the snippets need but nothing built - # is reported by the gate as `unbuilt-package` — its own failure reason, - # never as a page full of broken imports. - # - # ⛔ Do not fold this back into `echo "args=$(node …)" >> "$GITHUB_OUTPUT"` - # (objectui#6221). A command substitution contributes its STDOUT to the - # surrounding word and nothing else — the step's status is `echo`'s — so a - # gate that failed reads as a gate that named no packages, `args` is - # silently empty, and the step below expands to a bare `turbo run build` - # over the whole workspace: the one thing this workflow's header forbids, - # with no signal anywhere. `set -o pipefail` is not the remedy and would - # not help; there is no pipe here. Capture the status, then write the - # output. - - name: Derive the packages the covered snippets import - id: filter - run: | - status=0 - args="$(node scripts/check-doc-snippet-types.mjs --build-filter)" || status=$? - if [ "$status" -ne 0 ]; then - echo "::error::Could not derive the build filter: \`node scripts/check-doc-snippet-types.mjs --build-filter\` exited $status. Refusing to continue — carrying on would build every package in the workspace instead of the ones the covered snippets import." >&2 - exit "$status" - fi - echo "args=$args" >> "$GITHUB_OUTPUT" - - # The empty-filter refusal is the second half, deliberately kept HERE - # rather than beside the status check above: it holds for every route to - # an empty filter, including a gate that exits 0 while naming nothing. - # An empty filter can only ever mean something went wrong — the covered - # document population is never zero and the gate's own floors already - # refuse that — and an unfiltered `turbo run build` is a far worse answer - # than a red step. `args` arrives through the environment so the check has - # a value to test; it stays unquoted on the `turbo` line because it is a - # LIST of `--filter=` words that must word-split. - - name: Build those packages - env: - FILTER_ARGS: ${{ steps.filter.outputs.args }} - run: | - case "$FILTER_ARGS" in - *--filter=*) ;; - *) - echo "::error::The derived build filter names no package (got: '$FILTER_ARGS'). Refusing to run an unfiltered build — see this workflow's header." >&2 - exit 1 - ;; - esac - pnpm exec turbo run build $FILTER_ARGS --concurrency=2 - - # REPORT-ONLY (objectui#7864). Code a generator EMITS from a template - # literal under `packages/*/src/**` is compiled by nothing: `tsc` sees a - # string, `tsup` copies it through, and this gate's own scan surface stops - # at the authored pages — `content/docs`, the per-app docs trees, the - # package READMEs, the root `README.md` (objectui#7115), the top level of - # the root `docs/` tree (objectui#7856 card 1) and, recursively, - # `docs/adr/**` and `docs/audits/**` (objectui#7856 card 2, which brought - # them in LEDGER-FIRST: they are records, so their blocks are declared on - # `UNGATED_DOCS` rather than compiled). This - # step censuses that class through the same `compileSnippets()` the doc - # blocks go through, against the closure the step above just built. - # - # It runs BEFORE the blocking gate on purpose. The census is meant to be - # re-read on every run, and placed after it the number would be skipped on - # exactly the runs where the corpus moved. It exits 0 whatever it finds — - # one known member (objectui#7472) is an OPEN card — and non-zero only when - # the instrument itself is broken: a walk that collapsed, or a failed - # harness control. A check that runs, goes green and looked at nothing is - # the counterfeit the gate's own header is built against. - - name: Census the code emitted from template literals (report-only) - run: node scripts/check-doc-snippet-types.mjs --emit-census - - - name: Compile documentation snippets against the built types - run: node scripts/check-doc-snippet-types.mjs - - # objectui#8757. Same corpus discipline as the gate above: a block that - # cannot compile is either fixed or DECLARED in the script's - # `UNGATED_EXAMPLES` ledger with a written reason — ⛔ never made green by - # narrowing what the gate scans. - - name: Compile JSDoc `@example` blocks against the built types - run: node scripts/check-doc-example-types.mjs - - # objectui#5250 (the maintainer ruling's JSON-fence slice). Every `json` / - # `jsonc` node document in `content/docs` is judged through the STRICT - # authoring face — `StrictAnyComponentSchema`, read from the - # `@object-ui/types` the build step above produced (the snippet gate's - # closure carries it) — the face `objectui validate` judges with. A fence - # that is deliberately not a complete node document is declared on the - # line above it, and the declaration is re-judged on every run. Last of - # the three, for the reason the step above gives: a newly wired blocking - # gate must not mask an established one. - - name: Judge JSON doc fences through the strict authoring face - run: node scripts/check-doc-json-fences.mjs diff --git a/.github/workflows/docs-links.yml b/.github/workflows/docs-links.yml deleted file mode 100644 index f2692436f0..0000000000 --- a/.github/workflows/docs-links.yml +++ /dev/null @@ -1,91 +0,0 @@ -name: Docs Links - -# Why this is its own workflow instead of a step in `ci.yml`'s `docs` job, which -# is where this check lived when it first reached CI (#3213 / #3292, PR #3450): -# `ci.yml` THEN listed `'**/*.md'`, `content/**`, `docs/**` and `apps/site/**` -# under the `paths-ignore` of both its triggers, and GitHub has no per-job path -# filter. The published site is built from `content/docs/**`, so a docs-ONLY -# pull request matched every ignore pattern, started no workflow at all, and was -# never link-checked — while a docs-only PR is the likeliest way an internal link -# breaks in the first place. The step therefore only ever saw PRs that touched -# docs *alongside code*, plus pushes to `main`; a broken link could land through -# a pure-docs PR and only turn `main` red later, under an unrelated author -# (#3448). -# -# Read that paragraph as history, and do not act on its present tense — the -# lead-in used to be written as one, which is what objectui#4381 corrected. -# objectui#3523 step 2 deleted `paths-ignore` from `ci.yml`'s `pull_request` -# trigger; it survives ONLY on `push`. A docs-only PR therefore DOES start -# `ci.yml` today and DOES produce its contexts — measured: PR #3856 (one markdown -# file) 16 checks, PR #4339 (one line added to AGENTS.md) 17. objectui#3857 -# pinned that correction after an author acted on the old sentence and got the -# opposite result. Nor would the in-job switch keep a link check out the way it -# keeps the changeset gates out: `ci.yml`'s `docs` job gates its steps on -# `apps/site/` or `content/` having CHANGED, which a docs-only PR satisfies. -# -# Two reasons outlived that premise, and they are why nothing moves back. -# `ci.yml` still filters its `push` lane, so a docs-only push to `main` starts it -# not at all and this workflow is the only thing that link-checks that merge. And -# #3448 settled one gate, one home: this workflow's trigger set is a strict -# superset of that job's, so a copy there could only add a second red check for -# one broken link, and a second place to forget. -# -# `control-bytes.yml` hit the same wall and its header names the consequence: a -# gate that cannot see a markdown-only PR "rebuilds the hole it exists to close". -# `changeset-guard.yml` is the second instance of the shape in this repo. -# -# Hence: no `paths` and no `paths-ignore` here, deliberately. -# `scripts/__tests__/docs-links-workflow.test.ts` fails if either is ever added. -# A `paths: content/**` filter would look tighter and buy nothing measurable: the -# whole run is a checkout plus one `node` call — no install, no network, a few -# seconds — and the filter would be a second, drift-prone copy of the script's -# own scan surface. Keep it that way if you add checks here. - -on: - pull_request: - branches: [main, develop] - push: - branches: [main, develop] - # Merge queue (objectui#3523 — see `ci.yml`'s trigger block for the full note - # and the measurements behind it). This repository's queue is enforced by a - # ruleset but had zero `merge_group` subscribers, so its required-check set - # could only ever be empty. This gate is one of the two the audit found safe - # to require today — deliberately unfiltered, so it reports on every shape of - # pull request — and a required check that does not report on a queue build - # stalls the queue until the ruleset's 60-minute timeout fails it. `types:` is - # named although `checks_requested` is currently the only one GitHub defines. - merge_group: - types: [checks_requested] - workflow_dispatch: - -concurrency: - group: docs-links-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - docs-links: - name: Internal Docs Link Check - runs-on: ubuntu-latest - timeout-minutes: 5 - - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: '22.x' - - # Resolves every relative / `/docs/...` markdown link found in - # `content/docs/**` against the files actually on disk. Reads the checkout - # and nothing else, so no install is required. External URLs are a - # different problem with a different tool — Lychee, in `check-links.yml`, - # which sweeps both `content/docs/` and `docs/` on a weekly cron and on - # demand, and deliberately gates nothing (#3449 fixed its scope; #3213 - # decided it stays off pull requests). - - name: Check internal docs links - run: node scripts/check-doc-links.mjs diff --git a/.github/workflows/docs-route-eager-closure.yml b/.github/workflows/docs-route-eager-closure.yml deleted file mode 100644 index 27dcc37693..0000000000 --- a/.github/workflows/docs-route-eager-closure.yml +++ /dev/null @@ -1,88 +0,0 @@ -name: Docs Route Eager Closure - -# The gate `check:eager-closure` is not (objectui#6316). -# -# `scripts/check-eager-closure-budget.mjs` reads -# `apps/console/dist/eager-closure.json` and `performance-budget.yml` builds -# `@object-ui/console`, so that budget governs the console bundle. The Next docs -# site is weighed by nothing — and `apps/site/app/components/registerCatalogBlocks.ts` -# adds side-effect imports to `/docs/[[...slug]]`, a route shared by every docs -# page. The only measurement of it that has ever existed was reconstructed by -# hand, once, from the prerendered route on disk. -# -# `scripts/check-docs-route-eager-closure.mjs` is the cheap instrument that -# ruling chose over a second byte budget: it asserts structurally that every -# package the registrar names is already reachable from that route's module -# graph, so a genuinely NEW graph becomes something a human argues for in review -# instead of something that lands unmeasured. No docs build, no ceiling, no -# bytes. -# -# ## Why this is its own workflow, and why it is unfiltered -# -# The gate's inputs are the route's whole module graph — `apps/site/**`, -# `content/docs/**` (the compiled MDX modules are most of that graph) and -# `packages/**` (a refactor that drops `import { ObjectGrid }` from -# `packages/plugin-view/src/ObjectView.tsx` is exactly the change that turns a -# FREE declaration into a new graph) — plus the gate's own closure in -# `scripts/`. A `paths:` filter listing all of that is indistinguishable from no -# filter, and one that misses a directory cannot be exercised by the pull -# request that changes it, which is the defect objectui#6321 records. -# -# Hence: no `paths` and no `paths-ignore` here, deliberately. -# `scripts/__tests__/check-docs-route-eager-closure.test.ts` fails if either is -# added, and `scripts/dependabot-merge-gate.mjs` classifies `Docs Route Eager -# Closure Check` as a required context — an unclassified blocking check is one a -# Dependabot merge would be let past (objectui#6135). -# -# It needs no install and no build — a checkout plus one `node` call over the -# source tree, ~1.3 s measured on `b116a0684` — so keep it that way if you add -# checks to it. The moment it needs `pnpm install` it stops being affordable -# unfiltered, and the argument above stops holding. - -on: - pull_request: - branches: [main, develop] - push: - branches: [main, develop] - # Merge queue (objectui#3523 — see `ci.yml`'s trigger block for the full note - # and the measurements behind it). A required check that does not report on a - # queue build stalls the queue until the ruleset's 60-minute timeout fails it, - # so an unfiltered gate that can become required subscribes here from the - # start. `types:` is named although `checks_requested` is currently the only - # activity type GitHub defines for `merge_group`. - merge_group: - types: [checks_requested] - workflow_dispatch: - -concurrency: - group: docs-route-eager-closure-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - docs-route-eager-closure: - name: Docs Route Eager Closure Check - runs-on: ubuntu-latest - timeout-minutes: 5 - - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Enable Corepack and download the pinned pnpm - run: bash scripts/ci-setup-pnpm.sh - - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: '22.x' - - # Reads source only — no `node_modules`, no `dist`, no `.source`. The three - # halves (declarations, ledger, gauge) are all printed before any of them - # decides the exit code: exit 1 is a verdict about the registrar, exit 2 - # says the gauge itself is not trustworthy and is never reported as the - # former. - - name: Weigh the docs route eager closure - run: pnpm check:docs-route-closure diff --git a/.github/workflows/hook-selftests.yml b/.github/workflows/hook-selftests.yml index 63e3ed807e..b016b1c1ad 100644 --- a/.github/workflows/hook-selftests.yml +++ b/.github/workflows/hook-selftests.yml @@ -73,9 +73,10 @@ name: Hook Self-Tests # loses nothing and the step still FAILS when any of them does, naming # every one that failed. ⛔ Do not "simplify" it into a bare loop. # -# ── Why path-filtered, unlike `control-bytes.yml` ─────────────────────────── +# ── Why path-filtered, unlike the control-byte gate ───────────────────────── # -# `control-bytes.yml`'s header explains at length why ITS gate carries no `paths` +# The control-byte gate (`control-bytes.yml`, a step of `repo-gates.yml` since +# objectui#12214) explains at length why IT carries no `paths` # filter: a markdown-only PR can trip a raw control byte just as easily as a # TypeScript one, so an unfiltered gate is the only kind that can see it. That # reasoning does not transfer here — the self-tests build their own hermetic diff --git a/.github/workflows/line-citation-gate.yml b/.github/workflows/line-citation-gate.yml index dbaf98110b..a15101980b 100644 --- a/.github/workflows/line-citation-gate.yml +++ b/.github/workflows/line-citation-gate.yml @@ -20,9 +20,9 @@ name: Line Citation Gate # changeset-only change. Those are precisely the changes that carry citations: # the ruling names 注释、消息串、文档、changeset、脚本 in that order. A gate # that cannot see the pull request shape most likely to trip it rebuilds the -# hole it exists to close — the same conclusion `shell-escape-residue.yml`, -# `docs-links.yml`, `control-bytes.yml` and `changeset-presence.yml` each -# reached in their own headers. One gate, one home. +# hole it exists to close — the same conclusion the shell-escape, docs-link and +# control-byte gates (folded into `repo-gates.yml` by objectui#12214) and +# `changeset-presence.yml` each reached in their own headers. One gate, one home. # # Hence no `paths` and no `paths-ignore`, deliberately. # @@ -37,7 +37,8 @@ name: Line Citation Gate # # It needs no install and no build: a checkout plus one `node` call over this # branch's own diff. Keep it that way; the import graph is node builtins plus -# repo-relative modules only, which `pre-install-import-graph.yml` enforces. +# repo-relative modules only, which `repo-gates.yml`'s Pre-Install Import Graph +# Check enforces. on: pull_request: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 7fb94a61cf..69edb76646 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -350,8 +350,8 @@ jobs: # made both of those gates print a BYTE-IDENTICAL verdict line and exit 0 # (objectui#8083, fixed by PR #8902). # - # Its two siblings live in `vi-mock-specifiers.yml`, which runs before any - # install so that it sees every pull request shape. This one cannot: it + # Its two siblings live in `repo-gates.yml` (objectui#12214), in steps that + # run before that job's install so that they see every pull request shape. This one cannot: it # imports `typescript` to read declared return types, and # `check-pre-install-import-graph.mjs` fails any pre-install gate that # reaches a package — measured, by injecting the import into the sibling @@ -376,7 +376,7 @@ jobs: # # This step is a SECOND signal, not the gate. The gate is # `scripts/__tests__/check-test-path-roots.test.ts`, which runs inside - # `Test (shard N/8)`. Those shards are not required contexts themselves + # `Test (shard N/12)`. Those shards are not required contexts themselves # since objectui#9499 — the `Test` aggregator reports for all of them, and # it is required and subscribes `merge_group` — so the verdict still # blocks a queue build, through one name instead of N. This step reaches the same verdict sooner diff --git a/.github/workflows/lockfile-integrity.yml b/.github/workflows/lockfile-integrity.yml index 35c0c4b5ac..678e68424d 100644 --- a/.github/workflows/lockfile-integrity.yml +++ b/.github/workflows/lockfile-integrity.yml @@ -12,7 +12,7 @@ name: Lockfile Integrity # `fetch-depth: 0` and fetches the base branch, which no other gate here pays # for. Keeping it separate keeps that cost off every other job. # -# ── Why it IS path-filtered, unlike shell-escape-residue.yml and friends ───── +# ── Why it IS path-filtered, unlike the gates in repo-gates.yml ───────────── # # Those gates read the whole tree, so any change can trip them and a path filter # would build the hole they exist to close. This one reads exactly one file and @@ -77,7 +77,7 @@ jobs: # No install and no build: the gate is one `node` call over two text # files, builtins plus one repo-relative module only, which - # `pre-install-import-graph.yml` enforces (objectui#6148). + # `repo-gates.yml`'s Pre-Install Import Graph Check enforces (objectui#6148). - name: Fetch the base branch so the merge base exists env: BASE_REF: ${{ github.base_ref || 'main' }} diff --git a/.github/workflows/pre-install-import-graph.yml b/.github/workflows/pre-install-import-graph.yml deleted file mode 100644 index 16c81e79b5..0000000000 --- a/.github/workflows/pre-install-import-graph.yml +++ /dev/null @@ -1,85 +0,0 @@ -name: Pre-Install Import Graph - -# Why this is its own workflow rather than a step in `lint.yml` or `ci.yml`: -# what it judges is the ARRANGEMENT of the workflows themselves — which steps -# run a `scripts/` gate before their job installs anything. Its whole input is -# `.github/workflows/**` plus the `scripts/` files those steps name, so a run -# lives or dies on files that both `ci.yml` and `lint.yml` keep OUT of their -# expensive lanes. Putting it inside either one would make it skip on exactly -# the change most likely to break it: a workflow edit that moves a step across -# `pnpm install`. -# -# Same shape and the same reasoning as `docs-links.yml`, `control-bytes.yml`, -# `skills-paths.yml` and `changeset-presence.yml`, whose headers record the -# conclusion this repository reached four times already — a gate that cannot see -# the PR shape most likely to trip it "rebuilds the hole it exists to close". -# One gate, one home. -# -# Hence: no `paths` and no `paths-ignore` here, deliberately. Reporting on every -# pull request is also what makes the check requirable, and -# `scripts/dependabot-merge-gate.mjs` classifies it as a required context — -# an unclassified blocking check is one a Dependabot merge would be let past -# (objectui#6135). -# -# It needs no install and no build — a checkout plus one `node` call over every -# file in `.github/workflows/` and the `scripts/` gates their pre-install steps -# name, well under a second. ⛔ No population count here, deliberately: this -# sentence used to state one as a literal, it went stale by double digits, and -# nothing went red over that whole distance, because nothing fails on a number -# written in a comment (objectui#8606, objectui#7448). The population is DERIVED -# from `.github/workflows/` on every run, so the live reading is a command — -# `node scripts/check-pre-install-import-graph.mjs --list` prints it — and -# `scripts/__tests__/check-pre-install-import-graph.test.ts` refuses a count -# written back here. Keep it that way: the gate exists to protect install-free -# gates, so an install here would be the joke telling itself. - -on: - pull_request: - branches: [main, develop] - push: - branches: [main, develop] - # Merge queue (objectui#3523 — see `ci.yml`'s trigger block for the full note - # and the measurements behind it). A required check that does not report on a - # queue build stalls the queue until the ruleset's 60-minute timeout fails it, - # so an unfiltered gate that can become required subscribes here from the - # start. `types:` is named although `checks_requested` is currently the only - # activity type GitHub defines for `merge_group`. - merge_group: - types: [checks_requested] - workflow_dispatch: - -concurrency: - group: pre-install-import-graph-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - pre-install-import-graph: - name: Pre-Install Import Graph Check - runs-on: ubuntu-latest - timeout-minutes: 5 - - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: '22.x' - - # Two legs, in this order and in one step, for the reason `lint.yml` runs - # the entry-guard gate the same way: a scan whose recogniser is broken - # reports a clean tree. The self-test pins the workflow parser and the - # graph walk against fixtures, so a green scan below means something. - # - # This step is itself in the population the gate derives — it runs a - # `scripts/` file before any install, in a job that never installs — so - # the gate walks its own import graph on every run. That is deliberate: a - # floor that exempted its own enforcer would be the first thing to rot. - - name: Check the pre-install gates' import graphs - run: | - node scripts/check-pre-install-import-graph.mjs --self-test - node scripts/check-pre-install-import-graph.mjs diff --git a/.github/workflows/readme-exports.yml b/.github/workflows/readme-exports.yml deleted file mode 100644 index 85a8a1c0a4..0000000000 --- a/.github/workflows/readme-exports.yml +++ /dev/null @@ -1,104 +0,0 @@ -name: README Exports - -# Why this is its own workflow, and not a step in `ci.yml`. -# -# The two changes that introduce this drift are (1) a README edit and (2) a -# source edit that renames or drops an export. `ci.yml` cannot see the first at -# all: every one of its jobs opens with the `id: relevant` short-circuit whose -# diff excludes `**/*.md`, so on a README-only pull request its expensive steps -# are skipped by design (objectui#3523 moved the path filter into the jobs on -# purpose, and `merge-queue-reporting.test.ts` holds it there). A gate against -# fabricated README imports, living behind a switch that skips README-only pull -# requests, would rebuild the hole it exists to close — the conclusion -# `vi-mock-specifiers.yml`, `docs-links.yml`, `control-bytes.yml`, -# `skills-paths.yml` and `changeset-presence.yml` each record in their own -# headers. One gate, one home. -# -# Hence: no `paths` and no `paths-ignore` here, deliberately. -# `scripts/__tests__/check-readme-exports.test.ts` fails if either is added. -# -# ## Why this one pays for an install and a build, when its neighbours do not -# -# The cheap-tier gates above run on a checkout plus one `node` call. This one -# cannot: the export set is read SYMBOL-LEVEL out of each package's declared -# type entry, which for 36 of the 39 packages is a built `dist/index.d.ts`. The -# card (objectui#5043) measured why an approximation is not an option — a bare -# `grep` for `GanttSchema` in `packages/types/src` has six hits, every one of -# them a substring of `ObjectGanttSchema`, so a text-level export set calls a -# fabricated name real. -# -# Measured cost of the extra steps on the tree this landed on: `turbo run build` -# over all 39 packages, cold cache, concurrency 2, on a CONTENDED container: -# 2m42s. That is well inside the same order as `ci.yml`'s own `Type Check` job -# and an order below `Build & E2E`, which is what makes an unfiltered per-PR run -# affordable here where it would not be for a full E2E. -# -# `scripts/dependabot-merge-gate.mjs` classifies `README Export Check` as a -# required context — an unclassified blocking check is one a Dependabot merge -# would be let past (objectui#6135), and since objectui#6160 the `merge_group` -# floor below DERIVES from that same list. - -on: - pull_request: - branches: [main, develop] - push: - branches: [main, develop] - # Merge queue (objectui#3523 — see `ci.yml`'s trigger block for the full note - # and the measurements behind it). A required check that does not report on a - # queue build stalls the queue until the ruleset's 60-minute timeout fails it, - # so an unfiltered gate that can become required subscribes here from the - # start. `types:` is named although `checks_requested` is currently the only - # activity type GitHub defines for `merge_group`. - merge_group: - types: [checks_requested] - workflow_dispatch: - -concurrency: - group: readme-exports-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - readme-exports: - name: README Export Check - runs-on: ubuntu-latest - timeout-minutes: 25 - - steps: - - name: Checkout code - uses: actions/checkout@v7 - with: - submodules: true - - - name: Enable Corepack and download the pinned pnpm - run: bash scripts/ci-setup-pnpm.sh - - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: '22.x' - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - # The gate reads each package's DECLARED type entry. Without this step - # those files do not exist, and the gate FAILS with `unbuilt` rather than - # reporting every README import as fabricated or quietly judging nothing — - # see the "a package whose types are not on disk" section in the script. - - name: Build every package, so the declared type entries exist - run: pnpm exec turbo run build --filter='./packages/*' - - # A README in `packages//` teaching `import { X } from - # '@object-ui/'` for an `X` the package does not export ships in the - # npm tarball and gives the reader TS2305 or a TypeError. One manual sweep - # (objectui#5043) found it in seven packages and recorded that as a LOWER - # bound, because the method could only see single-line imports. - # - # GREEN AT REST — zero drift in the tree when this landed and there should - # stay zero — so it prints its census rather than a bare "OK", and FAILS if - # the population collapses to nothing. - - name: Check every README self-import against the real export surface - run: pnpm check:readme-exports diff --git a/.github/workflows/repo-gates.yml b/.github/workflows/repo-gates.yml new file mode 100644 index 0000000000..4bfdf6a35b --- /dev/null +++ b/.github/workflows/repo-gates.yml @@ -0,0 +1,462 @@ +name: Repo Gates + +# ── One workflow, one checkout, one setup (objectui#12214) ────────────────── +# +# This file is the fifteen one-check gate workflows that used to sit beside it, +# folded into ONE job under the two-speed doctrine (objectstack#22998): each of +# them paid its own runner, checkout and Node setup on every pull request, push +# and queue build, to run a check that mostly takes seconds. Nothing was +# weakened in the fold: +# +# - every check runs the same command, on the same events, with no path +# filter, exactly as it did in its own file; +# - every check keeps its name: each step is named ` — `, where +# `` is the job name the check used to report under; +# - every gate step runs `if: ${{ !cancelled() }}` (or on the install / build +# it needs), so one red check never hides another -- the job is red when +# any of them is, and the step list says which. +# +# ⚠️ What DID move: the check-run each gate used to produce. None of them was +# in the merge queue's required set (`node scripts/check-required-check-set.mjs` +# reads the live set; the ruleset names `Governed Surface Queue Guard`, +# `Changeset Declaration` and `Spec Main Shape Gate`, which is why those three +# workflows were NOT folded), so the queue is unchanged. The one in-tree reader +# of the old names, `scripts/dependabot-merge-gate.mjs`, now waits for the one +# context this job produces, `Repo Gates`. +# +# ── Why no `paths` and no `paths-ignore`, deliberately ────────────────────── +# +# The conclusion each folded header reached on its own, and which still holds +# for all of them together: the change most likely to trip these gates is a +# markdown-only, docs-only, skills-only or workflow-only change -- precisely the +# shape `ci.yml` and `lint.yml` decide inside their jobs to skip, and the shape +# their `push` triggers filter out. A gate living behind that switch "rebuilds +# the hole it exists to close" (objectui#3448, objectui#3523, objectui#4381). +# Their pin tests under `scripts/__tests__/` fail if a filter is ever added +# here, and each fails too if a second workflow starts running its script -- +# one gate, one home, and this file is that home for all of them. +# +# ── Why the order is install-free gates, then install, then build ─────────── +# +# Twelve of the gates read the checkout with `node:fs` and nothing else, so +# they run BEFORE `pnpm install`, which is what keeps them inside the +# population `scripts/check-pre-install-import-graph.mjs` derives and holds to +# an import graph free of `node_modules` -- the reason they were cheap enough +# to run unfiltered in the first place. Keep any check added to that group +# install-free. +# +# Three gates judge the PUBLISHED type surface and need built `dist/*.d.ts`: +# the README export check builds every package under `packages/`, and the two +# snippet gates each derive the closure they need with `--build-filter`. One +# build serves all three: the union of `./packages/*` and both derived +# filters. ⛔ It is never an unfiltered `turbo run build` -- the 2026-08-16 +# ruling on objectui#4846 (recorded in `published-dist-gate.yml`) rejected a +# per-PR build of the whole workspace -- and a derived filter that names no +# package is refused at the step that derives it. +# +# ⛔ No population count is written in this header, and none may be added +# (objectui#7448): each gate prints its own census in its verdict line, and the +# pin tests that read this header fail if a count of documents, pages or files +# reappears here. + +on: + pull_request: + branches: [main, develop] + push: + branches: [main, develop] + # Merge queue (objectui#3523 — see `ci.yml`'s trigger block for the full note + # and the measurements behind it). A required check that does not report on a + # queue build stalls the queue until the ruleset's 60-minute timeout fails it, + # so an unfiltered gate that can become required subscribes here from the + # start. `types:` is named although `checks_requested` is currently the only + # activity type GitHub defines for `merge_group`. + merge_group: + types: [checks_requested] + workflow_dispatch: + +concurrency: + group: repo-gates-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + repo-gates: + name: Repo Gates + runs-on: ubuntu-latest + # The three gates that build used to carry 25 minutes each, in parallel; + # here they share one install and one build, after the install-free gates. + timeout-minutes: 30 + + steps: + # `submodules: true` is carried over from the README export check's own + # checkout, unchanged. The tree declares no submodule today (no + # `.gitmodules`), so it fetches nothing; it is kept so the fold changes + # no gate's inputs, not because any gate here needs it. + - name: Checkout code + uses: actions/checkout@v7 + with: + submodules: true + + # pnpm is on the path before any gate runs: `Docs Route Eager Closure + # Check` calls its gate through `pnpm check:docs-route-closure`, with no + # install. + - name: Enable Corepack and download the pinned pnpm + run: bash scripts/ci-setup-pnpm.sh + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22.x' + cache: 'pnpm' + + # ── The install-free gates ─────────────────────────────────────────── + # A single raw U+0000 makes grep and ripgrep classify the whole file as + # binary and print no matching lines at all, so the file drops out of code + # search and out of every grep-based lint — with no error to say so. git + # will not warn either: it decides binary-ness from the first 8000 bytes + # only. Reads `git ls-files`, so no install is required. + - name: Control Byte Scan — Scan tracked text files for raw control bytes + if: ${{ !cancelled() }} + run: node scripts/check-control-bytes.mjs + + # Reads `.github/workflows/` off the checkout, so no install is required. + # The gate refuses to report a verdict at all if its own census collapses: + # a scan that stops finding references renders identically to a clean + # repository, which is the one way a check like this lies. + - name: Action Ref Convention — Check action reference spelling against the declared convention + if: ${{ !cancelled() }} + run: node scripts/check-action-ref-convention.mjs + + # Two legs, in this order and in one step, for the reason `lint.yml` runs + # the entry-guard gate the same way: a scan whose recogniser is broken + # reports a clean tree. The self-test pins the workflow parser and the + # graph walk against fixtures, so a green scan below means something. + # + # This step is itself in the population the gate derives — it runs a + # `scripts/` file before this job installs anything — so + # the gate walks its own import graph on every run. That is deliberate: a + # floor that exempted its own enforcer would be the first thing to rot. + - name: Pre-Install Import Graph Check — Check the pre-install gates' import graphs + if: ${{ !cancelled() }} + run: | + node scripts/check-pre-install-import-graph.mjs --self-test + node scripts/check-pre-install-import-graph.mjs + + # objectui#5150: the `git commit -F -` example in AGENTS.md §9 shipped with + # its heredoc terminator wrapped in the single-quote-inside-single-quote + # shell escape. Copied verbatim it does not fail with a message — it HANGS, + # on a terminator that never matches, and a reader does not attribute a hung + # terminal to the document. objectui#5151 measured the full derived gate + # union against the replanted bytes: control-bytes, doc-links and both + # changeset gates all exited 0, none of them negligently — the residue is + # printable ASCII inside a code block and no scan surface reached it. + # + # ⛔ This gate checks an ENUMERATED LITERAL. It does NOT make fenced shell + # examples executable-by-construction, and nothing in this repository does; + # `bash -n` per block is objectui#5151's unbuilt "direction 1". The script + # header states the boundary and the test suite asserts it as a fact. + # + # GREEN AT REST — there is nothing to find on an ordinary day — so it prints + # its per-root census rather than a bare "OK", and it FAILS when a scan root + # does not resolve or the population collapses. A scan that silently finds + # nothing reads as coverage, which is this gate's own defect one level up. + - name: Shell Escape Residue Scan — Check for machine-produced shell-escape residue in fenced examples + if: ${{ !cancelled() }} + run: node scripts/check-shell-escape-residue.mjs + + # A `vi.mock` whose relative specifier resolves to no file does NOT error. + # Vitest registers the mock against a module id nothing imports, the run + # proceeds with the real module everywhere, and the suite passes — + # identically to a correct one, with no warning and no smaller assertion + # count. objectui#5646: the one known instance (PR #5645) passed even with + # the code under test reverted to the shape the suite was written to + # catch, and only an ablation leg exposed it. + # + # This gate is GREEN AT REST — there are zero unresolvable specifiers in + # the tree and there should stay zero — so it prints its census rather + # than a bare "OK", and it FAILS if the population collapses to nothing. + # A scan that silently finds nothing reads as coverage, which is this + # gate's own defect one level up. + - name: Inert vi.mock Specifier Check — Check every relative vi.mock specifier resolves + if: ${{ !cancelled() }} + run: node scripts/check-vi-mock-specifiers.mjs + + # The sibling property of the same call sites: a factory that HAND-LISTS + # the exports it returns freezes the mock's export surface. The next + # export any module in the file's import graph reads AT MODULE SCOPE then + # kills the file during COLLECTION -- `Test Files 3 failed | 546 passed` + # with `Tests 6694 passed`, ZERO failed assertions, because the tests in + # those files never ran (objectui#6768). It reads as flake to whoever + # sees it next, and the bill lands on whoever added the export. + # + # The recogniser is SEMANTIC, never a grep for `importOriginal`: that + # spelling mis-counted eleven correct files as broken AND missed one + # broken file entirely (objectui#6849). It asks whether the factory + # OBTAINS the real module -- a callback parameter under any name, or + # `vi.importActual` of the same specifier -- and SPREADS it. + - name: Inert vi.mock Specifier Check — Check every covered vi.mock factory inherits the real export surface + if: ${{ !cancelled() }} + run: node scripts/check-vi-mock-inherit.mjs + + # Resolves every relative / `/docs/...` markdown link found in + # `content/docs/**` against the files actually on disk. Reads the checkout + # and nothing else, so no install is required. External URLs are a + # different problem with a different tool — Lychee, in `check-links.yml`, + # which sweeps both `content/docs/` and `docs/` on a weekly cron and on + # demand, and deliberately gates nothing (#3449 fixed its scope; #3213 + # decided it stays off pull requests). + - name: Internal Docs Link Check — Check internal docs links + if: ${{ !cancelled() }} + run: node scripts/check-doc-links.mjs + + # `check-doc-snippet-types` compiles `ts` / `tsx` / `typescript` fences and + # nothing else, so a TypeScript block fenced any other way is invisible to + # it — objectui#5867, whose remediation lane collected its population from + # ```plaintext fences only. `plaintext` is not the only spelling of an + # unhighlighted fence: objectui#6135 measured a ```text block opening + # `interface FileUploadSchema {` sitting outside the gate AND outside the + # lane that exists to close it, for no reason but how its fence is spelled. + # + # This gate reads block BODIES with objectui#5867's own triage classifier, + # so no list of languages is on the enforcement path and `txt`, `console`, + # `raw` or a bare fence with no info string at all cannot reopen the gap. + # Today's residue is declared, SHRINK-ONLY, and is that lane's remaining + # population per file. + # + # `--self-test` runs FIRST and is the half that stops the gate rotting into + # decoration: it drives the real scanner over fixture sources, including + # the three spellings named on objectui#6135, and pins the shrink-only + # baseline in every direction it can move. A scanner whose recogniser is + # broken reports a clean tree. + - name: Doc Fence Language Check — Check that no fence spelling hides a TypeScript block + if: ${{ !cancelled() }} + run: | + node scripts/check-doc-fence-languages.mjs --self-test + node scripts/check-doc-fence-languages.mjs + + # A `` in a docs page resolves through + # `getExample(id)`, which THROWS on an unknown id rather than degrading — + # the honest runtime behaviour, and the reason a mistyped or stale id is a + # crash in the published docs rather than a blank tile. Nothing asked the + # question before render: the catalog's own suite resolves every registry + # ENTRY, which cannot see a page pointing at an id that is not there. + # Reads the checkout and nothing else, so no install. + - name: Doc Example Id Check — Check documented example ids against the catalog registry + if: ${{ !cancelled() }} + run: node scripts/check-doc-example-ids.mjs + + # A `type` string in a `content/docs/**` code block is not rendered, + # not parsed and not compared against anything, so a snippet can name a + # component that does not exist and every check in the repo stays green — + # while a reader who copies it gets the renderer's red "Unknown component + # type" panel (OBJUI-001). That defect landed three times before this gate + # (objectui#4786 `stats-card`, objectui#4796 `plugin:grid` and + # `plugin:map`), each found by a human probe. The catalog side has had the + # equivalent ratchet since objectui#4616 + # (`examples/schema-catalog/test/catalog-gallery-render.test.tsx`); this is + # the missing half. Reads the checkout and nothing else, so no install. + - name: Doc Component Type Check — Check documented component types against the registry + if: ${{ !cancelled() }} + run: node scripts/check-doc-component-types.mjs + + # Same shape, same blind spot, one file further out: `.github/prompts/**` + # is markdown, so `ci.yml`'s type-check diff excludes it under `'**/*.md'` + # and a prompt-only PR starts that gate nowhere. The step above asks "does + # this type exist"; this one asks the stricter question a file read by an + # AI AUTHOR has to survive — "does it render, or is it answered only by the + # opt-in placeholder panel". A retired key passes the first and fails the + # second, which is the whole of objectui#8929. Reads the checkout only. + - name: Doc Component Type Check — Check prompt-taught component keys render + if: ${{ !cancelled() }} + run: node scripts/check-prompt-component-keys.mjs + + # The guides under `skills/` are a direct input to every agent that writes + # code here, and their prose gives in-repo paths as coordinates. A dead one + # produces no compile error — just "file not found" from a Read, and a + # wasted lap re-locating a symbol that does exist (#3713 and #3730 were + # 13+ of these in one guide, both rounds found by eye). Reads the checkout + # and nothing else, so no install is required. + - name: Skill Guide Path Check — Check paths stated in the skill guides + if: ${{ !cancelled() }} + run: node scripts/check-skills-paths.mjs + + # The self-test runs FIRST. A probe that cannot fail is not a probe: it + # plants an untaught token that must go red, a substring-only match that + # must go red, a `must_not_contain` entry that must NOT, and a baseline row + # that must suppress exactly its own row — so a gate broken into permanent + # green is caught here rather than by nobody. + - name: Skill Eval Token Check — Self-test the oracle and the whole-token rule, both directions + if: ${{ !cancelled() }} + run: node scripts/check-skill-eval-tokens.mjs --self-test + + # Reads the checkout and nothing else, so no install is required. + - name: Skill Eval Token Check — Check every eval must_contain token against the guides + if: ${{ !cancelled() }} + run: node scripts/check-skill-eval-tokens.mjs + + # Reads source only — no `node_modules`, no `dist`, no `.source`. The three + # halves (declarations, ledger, gauge) are all printed before any of them + # decides the exit code: exit 1 is a verdict about the registrar, exit 2 + # says the gauge itself is not trustworthy and is never reported as the + # former. + - name: Docs Route Eager Closure Check — Weigh the docs route eager closure + if: ${{ !cancelled() }} + run: pnpm check:docs-route-closure + + # ── The gates that read the built type surface ─────────────────────── + - name: Install dependencies + id: install + if: ${{ !cancelled() }} + run: pnpm install --frozen-lockfile + + # The filter comes from the gate, so it can never drift from what the + # marked fences import. + # + # ⛔ Do not fold this back into `echo "args=$(node …)" >> "$GITHUB_OUTPUT"` + # (objectui#6221). A command substitution contributes its STDOUT to the + # surrounding word and nothing else — the step's status is `echo`'s — so a + # gate that failed reads as a gate that named no packages, `args` is + # silently empty, and the build that reads it judges a closure nobody + # derived -- with no signal anywhere. Capture the status, then write the output. + - name: Skill Example Check — Derive the packages the marked examples import + id: skill-filter + if: ${{ !cancelled() && steps.install.outcome == 'success' }} + run: | + status=0 + args="$(node scripts/check-skill-examples.mjs --build-filter)" || status=$? + if [ "$status" -ne 0 ]; then + echo "::error::Could not derive the build filter: \`node scripts/check-skill-examples.mjs --build-filter\` exited $status. Refusing to continue — carrying on would build every package in the workspace instead of the ones the marked examples import." >&2 + exit "$status" + fi + case "$args" in + *--filter=*) ;; + *) + echo "::error::The derived build filter names no package (got: '$args'). Refusing to continue -- see this workflow's header." >&2 + exit 1 + ;; + esac + echo "args=$args" >> "$GITHUB_OUTPUT" + + # The filter comes from the gate, so it can never drift from what the + # covered documents import. A package the snippets need but nothing built + # is reported by the gate as `unbuilt-package` — its own failure reason, + # never as a page full of broken imports. + # + # ⛔ Do not fold this back into `echo "args=$(node …)" >> "$GITHUB_OUTPUT"` + # (objectui#6221). A command substitution contributes its STDOUT to the + # surrounding word and nothing else — the step's status is `echo`'s — so a + # gate that failed reads as a gate that named no packages, `args` is + # silently empty, and the build that reads it judges a closure nobody + # derived -- with no signal anywhere. `set -o pipefail` is not the remedy and would + # not help; there is no pipe here. Capture the status, then write the + # output. + - name: Doc Snippet Type Check — Derive the packages the covered snippets import + id: snippet-filter + if: ${{ !cancelled() && steps.install.outcome == 'success' }} + run: | + status=0 + args="$(node scripts/check-doc-snippet-types.mjs --build-filter)" || status=$? + if [ "$status" -ne 0 ]; then + echo "::error::Could not derive the build filter: \`node scripts/check-doc-snippet-types.mjs --build-filter\` exited $status. Refusing to continue — carrying on would build every package in the workspace instead of the ones the covered snippets import." >&2 + exit "$status" + fi + case "$args" in + *--filter=*) ;; + *) + echo "::error::The derived build filter names no package (got: '$args'). Refusing to continue -- see this workflow's header." >&2 + exit 1 + ;; + esac + echo "args=$args" >> "$GITHUB_OUTPUT" + + # One build for the three gates below: every package under `packages/` + # (the README export check reads each package's declared type entry), in + # union with the two closures the snippet gates derived above, so a + # derived filter that ever names a package outside `packages/` is still + # built. The derived filters are LISTS of `--filter=` words and must + # word-split, so they stay unquoted. A derive step that failed contributes + # nothing here and is red on its own; its gate then reports `unbuilt` + # rather than judging a tree it cannot see. + - name: Build every package the gates below read + id: build + if: ${{ !cancelled() && steps.install.outcome == 'success' }} + env: + SKILL_FILTER: ${{ steps.skill-filter.outputs.args }} + SNIPPET_FILTER: ${{ steps.snippet-filter.outputs.args }} + run: pnpm exec turbo run build --filter='./packages/*' $SKILL_FILTER $SNIPPET_FILTER + + # The self-test runs BEFORE the corpus, and after the build because its + # compiler legs need the same built tree. A probe that cannot fail is not a + # probe: it plants a fence that must go red and a marker that must be + # reported, so a harness broken into permanent green is caught here rather + # than by nobody. + - name: Skill Example Check — Self-test the marker convention, both directions + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: node scripts/check-skill-examples.mjs --self-test + + - name: Skill Example Check — Check the marked skill examples against the built types + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: node scripts/check-skill-examples.mjs + + # REPORT-ONLY (objectui#7864). Code a generator EMITS from a template + # literal under `packages/*/src/**` is compiled by nothing: `tsc` sees a + # string, `tsup` copies it through, and this gate's own scan surface stops + # at the authored pages — `content/docs`, the per-app docs trees, the + # package READMEs, the root `README.md` (objectui#7115), the top level of + # the root `docs/` tree (objectui#7856 card 1) and, recursively, + # `docs/adr/**` and `docs/audits/**` (objectui#7856 card 2, which brought + # them in LEDGER-FIRST: they are records, so their blocks are declared on + # `UNGATED_DOCS` rather than compiled). This + # step censuses that class through the same `compileSnippets()` the doc + # blocks go through, against the closure the step above just built. + # + # It runs BEFORE the blocking gate on purpose. The census is meant to be + # re-read on every run, and placed after it the number would be skipped on + # exactly the runs where the corpus moved. It exits 0 whatever it finds — + # one known member (objectui#7472) is an OPEN card — and non-zero only when + # the instrument itself is broken: a walk that collapsed, or a failed + # harness control. A check that runs, goes green and looked at nothing is + # the counterfeit the gate's own header is built against. + - name: Doc Snippet Type Check — Census the code emitted from template literals (report-only) + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: node scripts/check-doc-snippet-types.mjs --emit-census + + - name: Doc Snippet Type Check — Compile documentation snippets against the built types + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: node scripts/check-doc-snippet-types.mjs + + # objectui#8757. Same corpus discipline as the gate above: a block that + # cannot compile is either fixed or DECLARED in the script's + # `UNGATED_EXAMPLES` ledger with a written reason — ⛔ never made green by + # narrowing what the gate scans. + - name: Doc Snippet Type Check — Compile JSDoc `@example` blocks against the built types + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: node scripts/check-doc-example-types.mjs + + # objectui#5250 (the maintainer ruling's JSON-fence slice). Every `json` / + # `jsonc` node document in `content/docs` is judged through the STRICT + # authoring face — `StrictAnyComponentSchema`, read from the + # `@object-ui/types` the build step above produced (the snippet gate's + # closure carries it) — the face `objectui validate` judges with. A fence + # that is deliberately not a complete node document is declared on the + # line above it, and the declaration is re-judged on every run. Last of + # the three, for the reason the step above gives: a newly wired blocking + # gate must not mask an established one. + - name: Doc Snippet Type Check — Judge JSON doc fences through the strict authoring face + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: node scripts/check-doc-json-fences.mjs + + # A README in `packages//` teaching `import { X } from + # '@object-ui/'` for an `X` the package does not export ships in the + # npm tarball and gives the reader TS2305 or a TypeError. One manual sweep + # (objectui#5043) found it in seven packages and recorded that as a LOWER + # bound, because the method could only see single-line imports. + # + # GREEN AT REST — zero drift in the tree when this landed and there should + # stay zero — so it prints its census rather than a bare "OK", and FAILS if + # the population collapses to nothing. + - name: README Export Check — Check every README self-import against the real export surface + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: pnpm check:readme-exports diff --git a/.github/workflows/shell-escape-residue.yml b/.github/workflows/shell-escape-residue.yml deleted file mode 100644 index d14f47e0e7..0000000000 --- a/.github/workflows/shell-escape-residue.yml +++ /dev/null @@ -1,84 +0,0 @@ -name: Shell Escape Residue - -# Why this is its own workflow rather than a step in `ci.yml` or `lint.yml`: both -# of those decide inside the job whether the change "needs a full run", with an -# exclusion list that skips every expensive step on a markdown-only or -# changeset-only change — and a markdown-only change is precisely the shape that -# can introduce this defect. A gate that cannot see the pull request shape most -# likely to trip it "rebuilds the hole it exists to close": the conclusion -# `docs-links.yml`, `control-bytes.yml`, `skills-paths.yml`, -# `changeset-presence.yml`, `pre-install-import-graph.yml` and -# `vi-mock-specifiers.yml` have each reached in their own headers. One gate, one -# home. -# -# Hence: no `paths` and no `paths-ignore` here, deliberately. -# `scripts/__tests__/check-shell-escape-residue.test.ts` fails if either is ever -# added, and fails too if a second workflow starts running the same script. -# Reporting on every pull request is also what makes the check requirable, and -# `scripts/dependabot-merge-gate.mjs` classifies it as a required context — an -# unclassified blocking check is one a Dependabot merge would be let past -# (objectui#6135), and the `merge_group` floor now DERIVES from that same list -# (objectui#6160). -# -# It needs no install and no build — a checkout plus one `node` call over ~200 -# markdown documents and ~1300 fenced blocks. Keep it that way; the import graph -# is builtins plus repo-relative modules only, which `pre-install-import-graph.yml` -# enforces (objectui#6148). - -on: - pull_request: - branches: [main, develop] - push: - branches: [main, develop] - # Merge queue (objectui#3523 — see `ci.yml`'s trigger block for the full note - # and the measurements behind it). A required check that does not report on a - # queue build stalls the queue until the ruleset's 60-minute timeout fails it, - # so an unfiltered gate that can become required subscribes here from the - # start. `types:` is named although `checks_requested` is currently the only - # activity type GitHub defines for `merge_group`. - merge_group: - types: [checks_requested] - workflow_dispatch: - -concurrency: - group: shell-escape-residue-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - shell-escape-residue: - name: Shell Escape Residue Scan - runs-on: ubuntu-latest - timeout-minutes: 5 - - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: '22.x' - - # objectui#5150: the `git commit -F -` example in AGENTS.md §9 shipped with - # its heredoc terminator wrapped in the single-quote-inside-single-quote - # shell escape. Copied verbatim it does not fail with a message — it HANGS, - # on a terminator that never matches, and a reader does not attribute a hung - # terminal to the document. objectui#5151 measured the full derived gate - # union against the replanted bytes: control-bytes, doc-links and both - # changeset gates all exited 0, none of them negligently — the residue is - # printable ASCII inside a code block and no scan surface reached it. - # - # ⛔ This gate checks an ENUMERATED LITERAL. It does NOT make fenced shell - # examples executable-by-construction, and nothing in this repository does; - # `bash -n` per block is objectui#5151's unbuilt "direction 1". The script - # header states the boundary and the test suite asserts it as a fact. - # - # GREEN AT REST — there is nothing to find on an ordinary day — so it prints - # its per-root census rather than a bare "OK", and it FAILS when a scan root - # does not resolve or the population collapses. A scan that silently finds - # nothing reads as coverage, which is this gate's own defect one level up. - - name: Check for machine-produced shell-escape residue in fenced examples - run: node scripts/check-shell-escape-residue.mjs diff --git a/.github/workflows/skill-eval-tokens.yml b/.github/workflows/skill-eval-tokens.yml deleted file mode 100644 index 10920cc2ec..0000000000 --- a/.github/workflows/skill-eval-tokens.yml +++ /dev/null @@ -1,91 +0,0 @@ -name: Skill Eval Tokens - -# Holds every `must_contain` token of every `skills//evals/*.json` to -# the guides that bundle actually ships: the token must occur as a WHOLE token -# (never a substring) somewhere in the bundle's own markdown. The oracle choice -# — bundle-wide over per-guide, argued on four axes and measured both ways — -# the whole-token boundary rule, the `must_not_contain` polarity and the -# declared exit codes are documented at length in -# `scripts/check-skill-eval-tokens.mjs`. -# -# ── Why this is its own workflow, with NO path filter ─────────────────────── -# -# Same reason `skills-paths.yml`, `skill-examples.yml`, `docs-links.yml` and -# `control-bytes.yml` are theirs. This gate's whole input is the markdown and -# JSON under `skills/`, and both `ci.yml` and `lint.yml` list `'**/*.md'`, -# `content/**` and `docs/**` under the `paths-ignore` of their `push` trigger. -# GitHub has no per-job path filter, so a push that only edits a guide would -# start neither — and editing only a guide is exactly how a token an eval grades -# stops being taught. `control-bytes.yml`'s header names the consequence: a gate -# that cannot see a markdown-only change "rebuilds the hole it exists to close". -# -# Hence: no `paths` and no `paths-ignore` here, deliberately. -# `scripts/__tests__/check-skill-eval-tokens.test.ts` fails if either is ever -# added, and fails too if a second workflow starts running the same script — -# one gate, one home. -# -# ── Why it does NOT install, unlike its sibling `skill-examples.yml` ──────── -# -# `skill-examples.yml` installs and builds because its criterion is the -# PUBLISHED type surface, so the packages its marked fences import have to exist -# as `dist/*.d.ts` first. This gate's criterion is text: it reads eval JSON and -# guide markdown out of the checkout and nothing else. So it takes -# `skills-paths.yml`'s shape instead — a checkout plus two `node` calls — and -# `check-pre-install-import-graph.mjs` picks it up automatically and enforces -# that this script's whole static import graph stays free of `node_modules`. -# -# That install-free shape is also why folding this check into -# `check-skill-examples.mjs` as a second phase was rejected. That script exits 2 -# (PRECONDITION NOT MET) on an unbuilt tree BEFORE judging anything, so an -# eval-token phase inside it would simply never run wherever the workspace is -# not built — and its own header already lists this oracle, in "Deliberately NOT -# answered here" item 4, as a different question over a different corpus. - -on: - pull_request: - branches: [main, develop] - push: - branches: [main, develop] - # Merge queue (objectui#3523 — see `ci.yml`'s trigger block for the full note - # and the measurements behind it). A required check that does not report on a - # queue build stalls the queue until the ruleset's 60-minute timeout fails it, - # so an unfiltered gate that could become required subscribes here from the - # start. `types:` is named although `checks_requested` is currently the only - # activity type GitHub defines for `merge_group`. - merge_group: - types: [checks_requested] - workflow_dispatch: - -concurrency: - group: skill-eval-tokens-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - skill-eval-tokens: - name: Skill Eval Token Check - runs-on: ubuntu-latest - timeout-minutes: 5 - - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: '22.x' - - # The self-test runs FIRST. A probe that cannot fail is not a probe: it - # plants an untaught token that must go red, a substring-only match that - # must go red, a `must_not_contain` entry that must NOT, and a baseline row - # that must suppress exactly its own row — so a gate broken into permanent - # green is caught here rather than by nobody. - - name: Self-test the oracle and the whole-token rule, both directions - run: node scripts/check-skill-eval-tokens.mjs --self-test - - # Reads the checkout and nothing else, so no install is required. - - name: Check every eval must_contain token against the guides - run: node scripts/check-skill-eval-tokens.mjs diff --git a/.github/workflows/skill-examples.yml b/.github/workflows/skill-examples.yml deleted file mode 100644 index 691ba81e06..0000000000 --- a/.github/workflows/skill-examples.yml +++ /dev/null @@ -1,141 +0,0 @@ -name: Skill Examples - -# Compiles every MARKED `ts` / `tsx` / `typescript` fence under `skills/`, -# `--strict`, against the packages' BUILT `dist/*.d.ts`, and parses every marked -# `json` / `jsonc` fence. The opt-in marker convention, the adjacency rule, the -# inherited harness controls and the declared exit codes are documented at length -# in `scripts/check-skill-examples.mjs`. -# -# ── Why this is its own workflow, with NO path filter ─────────────────────── -# -# Same reason `skills-paths.yml`, `docs-links.yml`, `control-bytes.yml` and -# `doc-snippet-types.yml` are theirs, and their headers say it best: this gate's -# entire scan surface is markdown under `skills/`, and both `ci.yml` and -# `lint.yml` list `'**/*.md'`, `content/**` and `docs/**` under the `paths-ignore` -# of their `push` trigger. GitHub has no per-job path filter, so a push that only -# edits a guide would start neither — and editing only a guide is the single most -# likely way a worked example goes stale. `control-bytes.yml`'s header names the -# consequence: a gate that cannot see a markdown-only change "rebuilds the hole -# it exists to close". -# -# Hence: no `paths` and no `paths-ignore` here, deliberately. -# `scripts/__tests__/check-skill-examples.test.ts` fails if either is ever added, -# and fails too if a second workflow starts running the same script — one gate, -# one home. -# -# ── Why it builds, unlike its install-free sibling `skills-paths.yml` ─────── -# -# `skills-paths.yml`'s header asks that it stay a checkout plus one `node` call, -# and this gate cannot be that: its whole criterion is the PUBLISHED type -# surface, so the packages the marked fences import have to exist as `dist/*.d.ts` -# first. That is why this is a separate workflow rather than a second step there. -# Resolving against `src/` instead would be a weaker check the root -# `tsconfig.json` makes one inherited config away, and the script's RESOLUTION -# control fails the run rather than letting it pass quietly. -# -# The build is FILTERED to the packages the MARKED fences import, emitted by the -# gate itself (`--build-filter`) rather than hand-maintained here, so it grows -# only as the marked population grows and the growth is visible in this job's log -# rather than hidden in a workflow edit. That is what keeps this inside the -# 2026-08-16 ruling on objectui#4846 (recorded in `published-dist-gate.yml`), -# which rejected a per-PR FULL-REPO build. -# -# ⛔ Do not replace the filtered build with `pnpm build`. The filter is the reason -# this job is allowed to run on every pull request at all. - -on: - pull_request: - branches: [main, develop] - push: - branches: [main, develop] - # Merge queue (objectui#3523 — see `ci.yml`'s trigger block for the full note - # and the measurements behind it). A required check that does not report on a - # queue build stalls the queue until the ruleset's 60-minute timeout fails it, - # so an unfiltered gate that could become required subscribes here from the - # start. `types:` is named although `checks_requested` is currently the only - # activity type GitHub defines for `merge_group`. - merge_group: - types: [checks_requested] - workflow_dispatch: - -concurrency: - group: skill-examples-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - skill-examples: - name: Skill Example Check - runs-on: ubuntu-latest - timeout-minutes: 25 - - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Enable Corepack and download the pinned pnpm - run: bash scripts/ci-setup-pnpm.sh - - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: '22.x' - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - # The filter comes from the gate, so it can never drift from what the - # marked fences import. - # - # ⛔ Do not fold this back into `echo "args=$(node …)" >> "$GITHUB_OUTPUT"` - # (objectui#6221). A command substitution contributes its STDOUT to the - # surrounding word and nothing else — the step's status is `echo`'s — so a - # gate that failed reads as a gate that named no packages, `args` is - # silently empty, and the step below expands to a bare `turbo run build` - # over the whole workspace: the one thing this workflow's header forbids, - # with no signal anywhere. Capture the status, then write the output. - - name: Derive the packages the marked examples import - id: filter - run: | - status=0 - args="$(node scripts/check-skill-examples.mjs --build-filter)" || status=$? - if [ "$status" -ne 0 ]; then - echo "::error::Could not derive the build filter: \`node scripts/check-skill-examples.mjs --build-filter\` exited $status. Refusing to continue — carrying on would build every package in the workspace instead of the ones the marked examples import." >&2 - exit "$status" - fi - echo "args=$args" >> "$GITHUB_OUTPUT" - - # The empty-filter refusal is the second half, deliberately kept HERE - # rather than beside the status check above: it holds for every route to an - # empty filter, including a gate that exits 0 while naming nothing. The - # gate's own empty-population floor already refuses a run in which nothing - # is marked, and an unfiltered `turbo run build` is a far worse answer than - # a red step. `args` arrives through the environment so the check has a - # value to test; it stays unquoted on the `turbo` line because it is a LIST - # of `--filter=` words that must word-split. - - name: Build those packages - env: - FILTER_ARGS: ${{ steps.filter.outputs.args }} - run: | - case "$FILTER_ARGS" in - *--filter=*) ;; - *) - echo "::error::The derived build filter names no package (got: '$FILTER_ARGS'). Refusing to run an unfiltered build — see this workflow's header." >&2 - exit 1 - ;; - esac - pnpm exec turbo run build $FILTER_ARGS --concurrency=2 - - # The self-test runs BEFORE the corpus, and after the build because its - # compiler legs need the same built tree. A probe that cannot fail is not a - # probe: it plants a fence that must go red and a marker that must be - # reported, so a harness broken into permanent green is caught here rather - # than by nobody. - - name: Self-test the marker convention, both directions - run: node scripts/check-skill-examples.mjs --self-test - - - name: Check the marked skill examples against the built types - run: node scripts/check-skill-examples.mjs diff --git a/.github/workflows/skills-paths.yml b/.github/workflows/skills-paths.yml deleted file mode 100644 index ddd393c1a6..0000000000 --- a/.github/workflows/skills-paths.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: Skills Paths - -# Why this is its own workflow instead of a step in `ci.yml` or `lint.yml`: this -# gate's ENTIRE scan surface is markdown (`skills/**` and `.claude/skills/**`, -# guide prose — the roots are `SCAN_ROOTS` in the script), and both of those -# workflows list `'**/*.md'`, `content/**` and `docs/**` under the -# `paths-ignore` of their `push` trigger, with no per-job path filter available in -# GitHub Actions. A push that only edits a guide would therefore start neither — -# and editing only a guide is the single most likely way a stated path goes dead. -# -# This is the fourth instance of the shape in this repo, and the reasoning is -# borrowed rather than invented: `docs-links.yml`'s header records that the link -# check spent from #3213 to #3448 inside `ci.yml`'s `docs` job, unable to see the -# one class of PR most likely to break a link, and `control-bytes.yml`'s header -# names the consequence — a gate that cannot see a markdown-only change -# "rebuilds the hole it exists to close". `changeset-guard.yml` is the third. -# -# Hence: no `paths` and no `paths-ignore` here, deliberately. -# `scripts/__tests__/check-skills-paths.test.ts` fails if either is ever added, -# and fails too if a second workflow starts running the same script — one gate, -# one home. -# -# It needs no install and no build — a checkout plus one `node` call over the -# markdown under those roots, a couple of seconds — so keep it that way if you -# add checks to it. No file count is stated on purpose: it goes stale on every -# guide added, which is how the previous one drifted. - -on: - pull_request: - branches: [main, develop] - push: - branches: [main, develop] - # Merge queue (objectui#3523 — see `ci.yml`'s trigger block for the full note - # and the measurements behind it). A required check that does not report on a - # queue build stalls the queue until the ruleset's 60-minute timeout fails it, - # so an unfiltered gate that could become required subscribes here from the - # start. `types:` is named although `checks_requested` is currently the only - # activity type GitHub defines for `merge_group`. - merge_group: - types: [checks_requested] - workflow_dispatch: - -concurrency: - group: skills-paths-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - skills-paths: - name: Skill Guide Path Check - runs-on: ubuntu-latest - timeout-minutes: 5 - - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: '22.x' - - # The guides under `skills/` are a direct input to every agent that writes - # code here, and their prose gives in-repo paths as coordinates. A dead one - # produces no compile error — just "file not found" from a Read, and a - # wasted lap re-locating a symbol that does exist (#3713 and #3730 were - # 13+ of these in one guide, both rounds found by eye). Reads the checkout - # and nothing else, so no install is required. - - name: Check paths stated in the skill guides - run: node scripts/check-skills-paths.mjs diff --git a/.github/workflows/spec-main-shape-gate.yml b/.github/workflows/spec-main-shape-gate.yml index 5dfd6f7071..9aed98d6d4 100644 --- a/.github/workflows/spec-main-shape-gate.yml +++ b/.github/workflows/spec-main-shape-gate.yml @@ -69,6 +69,31 @@ name: Spec Main Shape Gate # needs its directory added here, because nothing derives the cone from # that map yet. # +# ## The spec is built only when objectstack `main` moved (objectui#12214) +# +# The two-speed doctrine (objectstack#22998) keeps this gate on every event, +# and adds an hourly `schedule` run on `main` -- the gate's place in the full +# battery. That scheduled run also PUBLISHES what it built: the packed spec, +# with the sha it was built at, as the artifact `objectstack-spec-`, kept +# one day. Every other run first looks for that artifact at the sha it just +# resolved and, when there is one, injects it instead of building it again: +# the clone, the spec's own install and everything after the pack run as +# before, so `inject` still reads the dependencies the spec resolves. +# +# Only an artifact from a `main` run of THIS repository is reused. A pull +# request or a queue build runs the workflow file of its own branch, so an +# artifact it uploaded is a file anybody with a branch could have shaped; a +# run on `main` executes reviewed YAML. The lookup is a READ (`actions: read`); +# nothing here writes through the token, and the upload uses the runner's own +# artifact credential. A lookup that fails, a download that fails, or an +# artifact whose recorded sha is not the one resolved above all mean BUILD -- +# never a verdict about a spec this job did not hold. +# +# ⚠️ What this buys is the build and the pack, not the compile: the forced +# typecheck below is the bulk of this job and is deliberately never cached. +# The measured split and the hit rate are on objectui#12214's pull request, +# not copied here. +# # ## ⛔ The turbo cache is a FALSE GREEN here # # `turbo`'s hash covers this repository's sources, its lockfile and a declared env @@ -84,11 +109,18 @@ on: merge_group: types: [checks_requested] workflow_dispatch: {} + # The full battery's hourly run on `main` (objectui#12214). Off the hour on + # purpose, and on a different minute from `ci.yml`'s. + schedule: + - cron: '48 * * * *' # Least privilege: the job reads this repository, reads a public one over HTTPS, -# and writes nothing anywhere. +# and writes nothing anywhere through the token. `actions: read` is the +# artifact lookup and download of the reused spec (objectui#12214); the +# scheduled upload goes through the runner's own artifact credential. permissions: contents: read + actions: read concurrency: group: spec-main-shape-gate-${{ github.ref }} @@ -143,6 +175,53 @@ jobs: echo "commit=${sha:0:12} · $authored · $subject" >> "$GITHUB_OUTPUT" echo "Compiling against objectstack-ai/objectstack@$sha ($authored)" + # ── Reuse the spec a `main` run already built at that sha ──────────── + # See "The spec is built only when objectstack `main` moved" in the + # header. Any failure here is `hit=false`, i.e. BUILD. + - name: Look for the spec a main run already built at that commit + id: reuse + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + UPSTREAM_SHA: ${{ steps.upstream.outputs.sha }} + REPOSITORY_ID: ${{ github.repository_id }} + run: | + set -uo pipefail + listing="$RUNNER_TEMP/spec-artifacts.json" + if ! curl -sS --fail-with-body \ + -H "Authorization: Bearer $GH_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/$GITHUB_REPOSITORY/actions/artifacts?name=objectstack-spec-$UPSTREAM_SHA&per_page=30" \ + -o "$listing"; then + echo "hit=false" >> "$GITHUB_OUTPUT" + echo "::notice::could not list artifacts; building the spec at $UPSTREAM_SHA" + exit 0 + fi + run_id=$(jq -r --arg repo "$REPOSITORY_ID" ' + [.artifacts[] + | select(.expired == false + and .workflow_run.head_branch == "main" + and ((.workflow_run.repository_id | tostring) == $repo))] + | sort_by(.created_at) | last | .workflow_run.id // empty' "$listing") + if [ -n "$run_id" ]; then + echo "hit=true" >> "$GITHUB_OUTPUT" + echo "run_id=$run_id" >> "$GITHUB_OUTPUT" + echo "Reusing the spec main run $run_id built at $UPSTREAM_SHA" + else + echo "hit=false" >> "$GITHUB_OUTPUT" + echo "No main run has built the spec at $UPSTREAM_SHA yet; building it here" + fi + + - name: Download that spec + id: download + if: steps.reuse.outputs.hit == 'true' + continue-on-error: true + uses: actions/download-artifact@v8 + with: + name: objectstack-spec-${{ steps.upstream.outputs.sha }} + path: ${{ runner.temp }}/spec-built + run-id: ${{ steps.reuse.outputs.run_id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + - name: Fetch packages/spec from that commit (sparse, blobless — never a full clone) env: UPSTREAM_SHA: ${{ steps.upstream.outputs.sha }} @@ -180,26 +259,72 @@ jobs: # ── Build the spec from source, and PACK it ────────────────────────── # `--filter @objectstack/spec...` installs the spec and its dependencies - # only; the rest of that workspace is not even in the checkout. + # only; the rest of that workspace is not even in the checkout. The + # install runs on a reuse too: `inject` reads the dependencies the + # spec's build resolved out of this checkout (objectui#10229). - name: Build @objectstack/spec from objectstack main + env: + REUSED: ${{ steps.download.outcome == 'success' }} working-directory: ${{ runner.temp }}/objectstack run: | set -euo pipefail pnpm install --frozen-lockfile --filter @objectstack/spec... + if [ "$REUSED" = 'true' ]; then + echo "Not building: a main run already built and packed the spec at this sha (objectui#12214)." + exit 0 + fi pnpm --filter @objectstack/spec build # What gets injected is what a RELEASE from that commit would ship — the # `files` field's file set, not a working tree. `--ignore-scripts` because # the build above already ran; a lifecycle script re-running here would # pack something the build step never produced. + # + # A reused spec is the tarball a `main` run packed, beside the sha it was + # packed at. Anything but exactly one tarball recorded at the sha resolved + # above is refused: compiling against it would report a verdict about a + # spec this job cannot name. - name: Pack the built spec id: pack + env: + REUSED: ${{ steps.download.outcome == 'success' }} + UPSTREAM_SHA: ${{ steps.upstream.outputs.sha }} + BUILT: ${{ runner.temp }}/spec-built working-directory: ${{ runner.temp }}/objectstack/packages/spec run: | set -euo pipefail - tarball=$(npm pack --ignore-scripts --pack-destination "$RUNNER_TEMP" | tail -n 1) - echo "tarball=$RUNNER_TEMP/$tarball" >> "$GITHUB_OUTPUT" - ls -l "$RUNNER_TEMP/$tarball" + if [ "$REUSED" = 'true' ]; then + recorded=$(cat "$BUILT/sha.txt" 2>&1 || true) + count=$(find "$BUILT" -maxdepth 1 -name '*.tgz' | wc -l) + if [ "$recorded" != "$UPSTREAM_SHA" ] || [ "$count" -ne 1 ]; then + echo "::error::the reused spec artifact records sha '$recorded' and holds $count tarball(s); expected exactly one at $UPSTREAM_SHA" + exit 1 + fi + tarball=$(find "$BUILT" -maxdepth 1 -name '*.tgz') + else + mkdir -p "$BUILT" + packed=$(npm pack --ignore-scripts --pack-destination "$BUILT" | tail -n 1) + tarball="$BUILT/$packed" + echo "$UPSTREAM_SHA" > "$BUILT/sha.txt" + fi + echo "tarball=$tarball" >> "$GITHUB_OUTPUT" + ls -l "$tarball" + + # Publish what this scheduled `main` run built, for every run that + # resolves the same objectstack sha in the next day. `main` only -- see + # the header for why no other event's artifact is ever reused. A failed + # upload costs the next run a build and says nothing about the code + # under test, so it cannot speak for the verdict. + - name: Publish the built spec for runs at the same objectstack commit + if: github.event_name == 'schedule' && steps.download.outcome != 'success' + continue-on-error: true + timeout-minutes: 5 + uses: actions/upload-artifact@v7 + with: + name: objectstack-spec-${{ steps.upstream.outputs.sha }} + path: ${{ runner.temp }}/spec-built + retention-days: 1 + if-no-files-found: error - name: Install objectui dependencies run: pnpm install --frozen-lockfile diff --git a/.github/workflows/vi-mock-specifiers.yml b/.github/workflows/vi-mock-specifiers.yml deleted file mode 100644 index 996bf72344..0000000000 --- a/.github/workflows/vi-mock-specifiers.yml +++ /dev/null @@ -1,112 +0,0 @@ -name: Inert vi.mock Specifiers - -# Two gates over ONE population, in one home. Both read the `vi.mock` call sites -# of this tree and both catch a mock that is silently not doing what it looks -# like it is doing: `check-vi-mock-specifiers.mjs` catches a specifier that -# resolves to no file (an inert stand-in), and `check-vi-mock-inherit.mjs` -# catches a factory that hand-lists its exports (a frozen export surface, -# objectui#6849). They share the call-site pattern deliberately -- a population -# that drifted between them would be a hole neither one reports -- so they share -# a workflow rather than each registering a required context of its own. -# -# The workflow `name:` and the job `name:` are therefore BROADER than the older -# of the two gates. They are left unchanged on purpose: those two strings are -# the check-run context that branch protection and -# `scripts/dependabot-merge-gate.mjs` name, and renaming a required context -# silently un-requires it. -# -# Why this is its own workflow rather than a step in `ci.yml` or `lint.yml`: a -# module mock can be written into any package, in any shape of pull request, and -# both of those workflows decide inside the job whether the change "needs a full -# run" — with an exclusion list that skips every expensive step on a -# markdown-only or changeset-only change. This gate costs a checkout plus one -# `node` call, so there is nothing to gain by putting it behind that switch and -# a whole class of pull request to lose. -# -# Same shape and the same reasoning as `docs-links.yml`, `control-bytes.yml`, -# `skills-paths.yml`, `changeset-presence.yml` and `pre-install-import-graph.yml`, -# whose headers record the conclusion this repository has now reached five -# times: a gate that cannot see the pull request shape most likely to trip it -# "rebuilds the hole it exists to close". One gate, one home. -# -# Hence: no `paths` and no `paths-ignore` here, deliberately. -# `scripts/__tests__/check-vi-mock-specifiers.test.ts` fails if either is ever -# added, and fails too if a second workflow starts running the same script. -# Reporting on every pull request is also what makes the check requirable, and -# `scripts/dependabot-merge-gate.mjs` classifies it as a required context — an -# unclassified blocking check is one a Dependabot merge would be let past -# (objectui#6135). -# -# It needs no install and no build — a checkout plus one `node` call over ~3.7k -# tracked source files, measured at ~2.7s on this tree. Keep it that way; the -# import graph is builtins plus repo-relative modules only, which -# `pre-install-import-graph.yml` enforces (objectui#6148). - -on: - pull_request: - branches: [main, develop] - push: - branches: [main, develop] - # Merge queue (objectui#3523 — see `ci.yml`'s trigger block for the full note - # and the measurements behind it). A required check that does not report on a - # queue build stalls the queue until the ruleset's 60-minute timeout fails it, - # so an unfiltered gate that can become required subscribes here from the - # start. `types:` is named although `checks_requested` is currently the only - # activity type GitHub defines for `merge_group`. - merge_group: - types: [checks_requested] - workflow_dispatch: - -concurrency: - group: vi-mock-specifiers-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - vi-mock-specifiers: - name: Inert vi.mock Specifier Check - runs-on: ubuntu-latest - timeout-minutes: 5 - - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: '22.x' - - # A `vi.mock` whose relative specifier resolves to no file does NOT error. - # Vitest registers the mock against a module id nothing imports, the run - # proceeds with the real module everywhere, and the suite passes — - # identically to a correct one, with no warning and no smaller assertion - # count. objectui#5646: the one known instance (PR #5645) passed even with - # the code under test reverted to the shape the suite was written to - # catch, and only an ablation leg exposed it. - # - # This gate is GREEN AT REST — there are zero unresolvable specifiers in - # the tree and there should stay zero — so it prints its census rather - # than a bare "OK", and it FAILS if the population collapses to nothing. - # A scan that silently finds nothing reads as coverage, which is this - # gate's own defect one level up. - - name: Check every relative vi.mock specifier resolves - run: node scripts/check-vi-mock-specifiers.mjs - - # The sibling property of the same call sites: a factory that HAND-LISTS - # the exports it returns freezes the mock's export surface. The next - # export any module in the file's import graph reads AT MODULE SCOPE then - # kills the file during COLLECTION -- `Test Files 3 failed | 546 passed` - # with `Tests 6694 passed`, ZERO failed assertions, because the tests in - # those files never ran (objectui#6768). It reads as flake to whoever - # sees it next, and the bill lands on whoever added the export. - # - # The recogniser is SEMANTIC, never a grep for `importOriginal`: that - # spelling mis-counted eleven correct files as broken AND missed one - # broken file entirely (objectui#6849). It asks whether the factory - # OBTAINS the real module -- a callback parameter under any name, or - # `vi.importActual` of the same specifier -- and SPREADS it. - - name: Check every covered vi.mock factory inherits the real export surface - run: node scripts/check-vi-mock-inherit.mjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7ad6bca6f0..1e5428c845 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -484,7 +484,7 @@ Fumadocs is configured with `baseUrl: '/docs'`, which means all documentation pa Two separate checks with two different jobs — knowing which is which saves a wasted debugging round: -- **Internal links are the PR gate.** `scripts/check-doc-links.mjs`, run by `.github/workflows/docs-links.yml` on every pull request to `main` / `develop` (and on pushes to them). It resolves each `/docs/...` route and each relative path against the files in the checkout — reading the tree and nothing else, no network — so it is deterministic, and a failure blocks the merge. The workflow deliberately carries no `paths` filter, so a docs-only PR is checked too. Run the same script locally before you push: +- **Internal links are the PR gate.** `scripts/check-doc-links.mjs`, run by `.github/workflows/repo-gates.yml` (its Internal Docs Link Check step) on every pull request to `main` / `develop` (and on pushes to them). It resolves each `/docs/...` route and each relative path against the files in the checkout — reading the tree and nothing else, no network — so it is deterministic, and a failure blocks the merge. The workflow deliberately carries no `paths` filter, so a docs-only PR is checked too. Run the same script locally before you push: ```bash pnpm docs:check-links diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index a57fd8a77e..ab242239a6 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -23,27 +23,13 @@ one has its own section below. | Workflow file | Appears as | Runs on | Blocks a PR? | |---|---|---|---| -| `ci.yml` | CI | Push / PR to `main`, `develop`; merge-queue builds | **Yes** — every job but the two coverage-lane jobs (`test-coverage` and `coverage-report`, push only) runs on PRs and on queue builds | +| `ci.yml` | CI | Push / PR to `main`, `develop`; merge-queue builds; hourly `schedule` on `main` | **Yes** — every job but the two coverage-lane jobs (`test-coverage` and `coverage-report`, push and schedule only) runs on PRs and on queue builds | | `lint.yml` | Lint | Push / PR to `main`, `develop`; merge-queue builds; manual | **Yes** — ESLint **errors** only | | `changeset-guard.yml` | Changeset Bump Policy, Changeset Overwrite Report | PR / push touching `.changeset/**` or either gate itself | **Yes** — the bump policy job only; the overwrite job is report-only | | `changeset-presence.yml` | Changeset Declaration | PR to `main`, `develop` — **no path filter**; merge-queue builds | **Yes** — when a released package's `src/` changed and no changeset was added | -| `control-bytes.yml` | Control Byte Scan | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** | -| `action-ref-convention.yml` | Action Ref Convention | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a `uses:` ref is not spelled as a floating major tag and is not a declared exception, when a declared exception matches nothing, or when the scan's own census collapses | -| `docs-links.yml` | Internal Docs Link Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** | -| `skills-paths.yml` | Skill Guide Path Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a path stated in a `skills/` guide does not exist | -| `skill-examples.yml` | Skill Example Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a MARKED fenced example in a `skills/` or `.claude/skills/` guide no longer compiles against the packages' built types, no longer parses as JSON, uses a bare `any`, or carries a marker that opts nothing in | -| `skill-eval-tokens.yml` | Skill Eval Token Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when an eval assertion's `must_contain` token is not taught as a whole token anywhere in its own `skills/` bundle | -| `doc-component-types.yml` | Doc Component Type Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a `content/docs/**.mdx` snippet teaches a `type` nothing registers | -| `doc-snippet-types.yml` | Doc Snippet Type Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a covered documentation snippet no longer compiles against the packages' built types, **or** when a covered JSDoc `@example` block does, **or** when a `json` / `jsonc` node document in `content/docs` is refused by the strict authoring face (three gates, one job — see below) | -| `doc-fence-languages.yml` | Doc Fence Language Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a TypeScript block sits under a fence the snippet gate does not read | -| `doc-example-ids.yml` | Doc Example Id Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a `content/docs/**` page references a schema-catalog example id the registry does not carry, or when the gate's own population collapses | -| `pre-install-import-graph.yml` | Pre-Install Import Graph Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a gate a workflow runs *before* `pnpm install` reaches a package anywhere in its import graph | -| `vi-mock-specifiers.yml` | Inert vi.mock Specifier Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a `vi.mock` / `vi.doMock` relative specifier resolves to no file, or the scan's population collapses | -| `shell-escape-residue.yml` | Shell Escape Residue Scan | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a fenced block in `AGENTS.md`, `CLAUDE.md`, `skills/**` or `content/docs/**` carries the enumerated machine-produced shell escape, or a scan root fails to resolve | -| `readme-exports.yml` | README Export Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a `packages/**/README.md` imports a name from its own package that the package does not export, or the scan's population collapses | -| `docs-route-eager-closure.yml` | Docs Route Eager Closure Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a package named in `apps/site/app/components/registerCatalogBlocks.ts` is not already reachable from the docs route's module graph (exit 1), or when the gate's own gauge cannot be trusted (exit 2) | +| `repo-gates.yml` | Repo Gates | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — fifteen gates as steps of one job ([#12214](https://github.com/objectstack-ai/objectui/issues/12214)); the job is red when any step is, and `scripts/dependabot-merge-gate.mjs` declares the context in `REQUIRED_CONTEXTS` | | `line-citation-gate.yml` | Line Citation Gate | PR to `main`, `develop` — **no path filter**; manual | No — **report-only** while it beds in; it exits 0 whatever it finds, and exits 1 only when one of its own synthetic controls fails. It declares no `merge_group` trigger, so it cannot be a required context in its current state | -| `spec-main-shape-gate.yml` | Spec Main Shape Gate | PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — a shape break is a real red naming the objectui file and the objectstack commit ([#9860](https://github.com/objectstack-ai/objectui/issues/9860)). The maintainer enrolled the context under [#9969](https://github.com/objectstack-ai/objectui/issues/9969)'s ruling A, and `scripts/dependabot-merge-gate.mjs` declares it in `REQUIRED_CONTEXTS`, so a Dependabot bump waits for it too | +| `spec-main-shape-gate.yml` | Spec Main Shape Gate | PR to `main`, `develop` — **no path filter**; merge-queue builds; manual; hourly `schedule` on `main` | **Yes** — a shape break is a real red naming the objectui file and the objectstack commit ([#9860](https://github.com/objectstack-ai/objectui/issues/9860)). The maintainer enrolled the context under [#9969](https://github.com/objectstack-ai/objectui/issues/9969)'s ruling A, and `scripts/dependabot-merge-gate.mjs` declares it in `REQUIRED_CONTEXTS`, so a Dependabot bump waits for it too | | `governed-surface-guard.yml` | Governed Surface Queue Guard | PR to `main`, `develop` (incl. `ready_for_review`) — **no path filter**; merge-queue builds | **Yes on a queue build only** — a governed-surface diff with no authorized approval record (on any commit) is refused there, and so is any merge group whose queued pull requests still carry `needs:contract-review`; on the pull request itself it is deliberately green and prints an early warning | | `performance-budget.yml` | Bundle Analysis | Push / PR touching `packages/**`, `apps/console/**`, `pnpm-lock.yaml` | **Yes** — the console entry gzip budget | | `lockfile-integrity.yml` | Lockfile Integrity Check | PR to `main`, `develop` touching `pnpm-lock.yaml` or the gate's own two files; manual | No — **deliberately not a blocking context** ([#8326](https://github.com/objectstack-ai/objectui/issues/8326)); it names the packages and the Dependabot merge gate classifies it `NOT_A_GATE` | @@ -86,10 +72,11 @@ The path filters explain most "why did nothing run on my PR?" questions: *forgot* its changeset does not touch `.changeset/**`, so the inverse filter guarantees the one check that could notice never runs. It therefore carries **no** filter and decides from the diff inside its script. -- `control-bytes.yml` and `docs-links.yml` carry **no** filter of any kind, which is equally - deliberate: both guard markdown, and a gate that a markdown-only PR cannot start is no gate on - the change most likely to trip it. Both cost a checkout plus one `node` call. -- `docs-route-eager-closure.yml` carries **no** filter for the opposite reason — not that its +- `repo-gates.yml` carries **no** filter of any kind, which is equally deliberate: its gates guard + markdown among the rest (the Control Byte Scan and the Internal Docs Link Check first of all), and + a gate that a markdown-only PR cannot start is no gate on the change most likely to trip it. Its + install-free gates cost one `node` call each, on one runner. +- Its Docs Route Eager Closure Check carries **no** filter for the opposite reason — not that its subject is invisible to a filter, but that a filter naming everything it reads would be indistinguishable from having none. Its inputs are the whole `/docs/[[...slug]]` module graph: `apps/site/**`, `content/docs/**` (the compiled MDX modules are most of that graph) and @@ -122,8 +109,8 @@ the rule that decides membership, not the instances: a gate that carries no path every pull request and is therefore requirable — and a requirable context that skips the queue build does not fail it, it stalls it. -That rule was learned the expensive way. `ci.yml`, `lint.yml`, `control-bytes.yml` and -`docs-links.yml` did not subscribe at all until +That rule was learned the expensive way. `ci.yml`, `lint.yml` and the control-byte and docs-link +gates did not subscribe at all until [#3523](https://github.com/objectstack-ai/objectui/issues/3523), and the consequence was not subtle. A queue whose required set is empty validates nothing: it rebuilds the PR, sees no failing required check because there are no required checks, and merges. On 2026-08-07 three pull requests @@ -214,9 +201,23 @@ paths decide only whether it does any work. The `docs` job has worked this way s The gate fails **open** — if the diff cannot be computed the job runs everything, rather than reporting green having built nothing. +**Two speeds** ([#12214](https://github.com/objectstack-ai/objectui/issues/12214), the doctrine of +[objectstack#22998](https://github.com/objectstack-ai/objectstack/issues/22998)). A pull request or a +merge-queue build runs the *affected* test files: `scripts/select-affected-tests.mjs` names the +packages the change sits in and every package that depends on them, plus the test files the package +graph cannot place (tests outside every package, tests whose relative imports read the disk or leave +their package, tests that import a package their own does not depend on), and answers "run +everything" when the change reaches something every test runs through — a `package.json`, a +`tsconfig*.json`, a vitest or vite config, a root install or toolchain file, a file the root vitest +config imports, or a package a broad setup file imports. A `push` to `main` and the hourly `schedule` +run are the *full battery*: every test file, plus the coverage lane. Nothing was deleted; the whole +suite moved to `main`, where every merge and every hour runs it, and whatever the fast set misses +turns `main` red there. The selector's header states the rules and the edges it cannot see; this page +does not restate its measurements. + Most jobs run in parallel. Two `needs:` edges exist and both point at an aggregator: `coverage-report` waits on the four coverage shards ([#5403](https://github.com/objectstack-ai/objectui/issues/5403)), -and `test-aggregate` waits on the eight test shards and the dist-pin job +and `test-aggregate` waits on the twelve test shards and the dist-pin job ([#9499](https://github.com/objectstack-ai/objectui/issues/9499)). Nothing else waits for anything. As with the workflow inventory above, this page states **no job count**: the table *is* the list, and @@ -236,13 +237,13 @@ it green — which is how two of `type-check`'s gates came to be missing from th |---|---|---|---| | `changeset-check` | Changeset Fixed Group Check | `scripts/check-changeset-fixed.mjs` — every workspace package must be in the changeset `fixed` group or explicitly ignored. It checks group *membership*; it does **not** check whether the PR added a changeset. | Every run | | `type-check` | Type Check | `scripts/check-type-check-coverage.mjs`, then `pnpm check:phantom-deps`, then `pnpm check:self-import`, then `pnpm check:unreferenced-sources`, then `pnpm check:doc-example-readers`, then `pnpm check:handler-key-reads`, then `pnpm check:metadata-write-doors`, then `pnpm check:published-tsconfig-exclude`, then `pnpm check:side-effects-array`, then `pnpm check:element-data-source-declaration`, then `pnpm check:esm-specifiers`, then `pnpm check:spec-symbols`, then `pnpm check:action-forward-parity`, then `pnpm check:designer-field-key-parity`, then `pnpm check:icon-record-names`, then `pnpm check:i18n-keys`, then `pnpm check:i18n-drift`, then `pnpm check:i18n-designer-parity`, then `pnpm type-check:scripts`, then `pnpm type-check:vitest-config`, then `pnpm type-check`, then `pnpm type-check:vitest-setup`. The coverage guard runs first because turbo silently skips packages that have no `type-check` script, so a package without one would otherwise read as passing (#2911). `pnpm check:phantom-deps` fails when a released package imports a bare specifier its own `package.json` does not declare — a *phantom dependency*, invisible locally because the workspace root's `devDependencies` sit on the upward resolution path from every package directory and on no consumer's, so `require.resolve('react', { paths: ['packages/core/src'] })` succeeds while `@object-ui/core` declares react in no field at all ([#4394](https://github.com/objectstack-ai/objectui/issues/4394)). `pnpm check:self-import` runs next because it reuses that gate's parser: it fails when a file inside a package names its OWN package, a specifier that resolves through the package's `exports` map to `dist/` while `type-check` waits on `^build` — the *dependencies'* builds, never the package's own — so on a cold cache the declarations do not exist yet and the file fails with `TS2307`. Locally it is always green, because every local workflow builds before it type-checks and leaves a `dist/` behind; PR #4789's first run was red on exactly one such line ([#4801](https://github.com/objectstack-ai/objectui/issues/4801)). `pnpm check:unreferenced-sources` runs next, reusing the same parser again: it fails when a covered package ships a source file that nothing reaches — not the package's declared entry, and not its build config. Until [#7515](https://github.com/objectstack-ai/objectui/issues/7515) no gate here could see one: `check-dist-completeness` asks whether `dist/` holds what `tsc` emits, `check-readme-exports` compares documented exports against shipped ones, and a file that is in the tarball while being reachable from nothing is outside both — so the detection mechanism was a human reading unrelated code, which is how both instances found in one week were found ([#7319](https://github.com/objectstack-ai/objectui/issues/7319), [#7397](https://github.com/objectstack-ai/objectui/issues/7397)). The hazard is not the bytes: the file #7319 removed carried the same export name as a live engine one package over and evaluated no predicate, so name-completion alone could have wired a silently wrong renderer into a published package. Reachability has TWO roots, and the second is the whole difficulty — `packages/components` reaches its two `use-sync-external-store` shims only through `vite.config.ts` `resolve.alias` entries whose importer is a bundled dependency no source file names, so a walk that skips that leg reports exactly those two live files as dead on its first run, and a gate that cries wolf gets switched off rather than fixed. Scope is DECLARED per package in `COVERED_PACKAGES` and the uncovered remainder is printed as a count derived from the workspace on every run, because the alias mechanisms differ per package and a gate that covers one package correctly beats one that covers forty with false positives. An alias expression it cannot evaluate is a FINDING rather than a skip, since skipping one would make it accuse whatever file that alias points at. `pnpm check:doc-example-readers` runs next, on the same parser again: it fails when an exported symbol's own JSDoc `@example` hand-spells a resolution that its REAL call sites obtain by calling a shared reader. A doc comment is what the next call site is copied from, so prose that outlives the ruling it encoded re-seeds every later copy — measured at two cards and three copied call sites, both cards closed by pointing the prose at `resolveRecordSourceObjectName` (`b041b9c0c`, `2ce2612df`). Nothing here could see either one, and `check-spec-symbol-derivation` was credited with the class twice — in the second card's body and then in the dispatch that repeated it — while its rule 4 judges `@objectstack/spec` citations at member granularity and says nothing about prose prescribing a LOCAL spelling ([#7652](https://github.com/objectstack-ai/objectui/issues/7652)). It fires on four conditions at once — the example calls the symbol it documents, a real call site fills the same argument slot by calling an exported single-`return` reader, the example does not, and what the example writes there is that reader's own return expression or one of the rungs it resolves between — which is what keeps it off the literals and placeholders an example legitimately carries. It does NOT judge whether a prescribed spelling is correct: on the day either card was filed the prose and every copy of it agreed, and no gate reading only the tree can know a ruling. What it catches is the state right after, when the call sites move and the prose does not. `pnpm check:handler-key-reads` runs next, on the same parser again: it fails when an `on*` handler key that a REGISTERED renderer reads off the authored document is not a declared member of the zod arm for the type it is registered under. `BaseSchema` is `.passthrough()`, so an undeclared key is not refused — it stops being judged and the value is KEPT, then reaches the renderer that reads it; measured on the built dist, `{ type: 'kanban', columns: [], onCardClick: { action: 'toast' } }` went from REFUSED to ACCEPTED with the object surviving into the parsed output ([#7664](https://github.com/objectstack-ai/objectui/issues/7664)). Every gate stayed green, because the [#6124](https://github.com/objectstack-ai/objectui/issues/6124) ledger's population is two hand-written arrays of tuples and that change re-keyed the arm by SUBSTITUTION — so its length assertion held, and a count ratchet would have been green too, which is why `72498f257`, the commit that landed this gate, rejected that option on the instance itself. This gate derives BOTH populations: the arms from every `type: z.literal(…)` in `packages/types/src/zod`, and the read sites from every real `ComponentRegistry.register(…)` call — read off the AST, because one types file NAMES that call in prose eleven times and registers nothing. It follows the document one component at a time rather than every JSX child, because most children are handed a DIFFERENT document (a dashboard's widgets each get their own), and the chain it must reach is four hops long: `register('kanban', ObjectKanbanRenderer)` names a component, that component is an HOC, the document arrives at `ObjectKanban` through a render-prop parameter and at `KanbanRenderer` through an object spread. It says nothing about keys that reach a renderer only through a `{...props}` spread onto a Radix root or a DOM listener slot — there is no read site to derive from — nor about the ledger's `?: never` tombstones, which have no read site by construction; `KNOWN_UNDECLARED_READS` is an exemption list that only shrinks, each row naming the card that owns the fix, and a row whose read site the gate can no longer find fails it. It lives in `scripts/` because the read sites are spread across `@object-ui/plugin-*` and `packages/components`, which `@object-ui/types` may not import — `check:phantom-deps` rejects it and it would close a cycle. `pnpm check:metadata-write-doors` runs next, on the same parser again: it fails when an in-repo DOOR that can PUT an object-metadata document does not apply the object-metadata write guard. [#7714](https://github.com/objectstack-ai/objectui/issues/7714) ruled one client behaviour — a half-filled relationship is held client-side and the PUT body never carries one without a non-empty `reference` — and its PR implemented that ruling by ENUMERATING the writers it knew of, which were two. The identical defect was then reproduced on a THIRD writer neither guard covered, in that card's own required dogfood (recorded in `9662aca56`, the commit that landed this gate), and [#8676](https://github.com/objectstack-ai/objectui/issues/8676) swept and found nine more. The half that outlives the count is that the sweep the question is naturally asked in cannot see its own subject: `git grep 'client\.save('` returns ZERO over the file that third writer lives in, because the call is `client.save(type, …)` and the generic argument sits between the name and the paren — and a hand-rolled `fetch` PUT to `/api/v1/meta/object/:name` is not that spelling at all. So this gate enumerates DOORS, never writers: the writer set is OPEN and nobody has to announce a new member, while the transport set is CLOSED and this repository owns it, so guarding the doors covers every writer past and future without a list anywhere. It derives every call carrying a `method: 'PUT'` literal whose URL RESOLVES to a `/meta` path — resolved through templates, fields and helper return values rather than read, because the repo's central door spells its URL three hops from the call and says nothing at the call site — plus every call to the SDK's `meta.saveItem`, which lives in a package this repo does not own and so cannot be guarded from the inside. A door whose type is a string literal other than `object` is exempt; a door whose type is a runtime value is judged CAPABLE, which is the fail-closed direction. It answers COVERAGE only — whether a guarded door's body is correct is the guard's own pins — and it refuses to report OK unless it found at least one door of each kind and at least one guarded door, so a renamed transport turns it red rather than green. Same placement rationale as the gates around it: it parses the sources with `typescript`, so it needs the install and nothing built. `pnpm check:published-tsconfig-exclude` follows, config reads only: it fails when a published package's build `tsconfig.json` excludes tooling by FILE NAME (`*.test.ts`) without also excluding the tooling DIRECTORIES (`**/__tests__/**` and its two siblings, derived from `TOOLING_FILE` rather than retyped). A name-only exclude stops the files that happen to be named that way and nothing else, so the first shared helper added to a `__tests__/` directory becomes a program input and an emitting program writes it into the published `dist` — three times so far, each found by a human and never by a gate ([#4006](https://github.com/objectstack-ai/objectui/issues/4006), [#4836](https://github.com/objectstack-ai/objectui/issues/4836), and a third in the same package as the first, all three recorded in `6aeba673c`, the commit that landed this gate). [#7212](https://github.com/objectstack-ai/objectui/issues/7212) measured the standing exposure — 29 published packages carrying the name form with ZERO offending files, green because nobody had added such a helper yet — and the gate landed together with their conversion so `main` was green on merge. It reads `exclude` arrays and nothing else: no build, no artifact, no emit model, which is the narrower scope that keeps it clear of the modelling [#4846](https://github.com/objectstack-ai/objectui/issues/4846) declined for the artifact-level gate. Six published packages are named carve-outs, each re-proving its own reason on every run: `cli`, `create-plugin` and `data-objectstack` emit from a `tsup` entry graph, `plugin-charts` keeps its tooling exclude in the `dts()` options, and `console` and `runner` are Vite applications with `noEmit: true` and no `dts()` plugin. `pnpm check:side-effects-array` runs next, sources only and no build: it fails when a package's `sideEffects` ARRAY and its module bodies disagree in either direction — a module that registers something at load time and is not named (a bundler drops it, and the registration is gone from a *consumer's* app with no error, no warning and exit 0), or a name whose module no longer registers anything. `@object-ui/app-shell` declares such an array because both simpler answers are measurably wrong for it: omitting the field makes the whole package unshakeable, and `"sideEffects": false` silently drops three live SDUI widget registrations to zero chunks ([#6535](https://github.com/objectstack-ai/objectui/issues/6535), [#6683](https://github.com/objectstack-ai/objectui/issues/6683)). The enumeration is re-derived from the module bodies on every run rather than listed, so there is no second copy to rot. The artifact half of the same contract — do those registrations survive a real bundler — cannot run in this job at all: it needs a built console, so it lives in the SDUI registration pin step of `performance-budget.yml`. `pnpm check:element-data-source-declaration` runs next, sources only and no build: it fails when a source that consumes `ElementDataSourceGate` does not also pass through `elementDataSourceBlock()`, the seam that declares the `dataSource` key the gate reads. A block that wraps the gate off-seam publishes an authoring surface missing the one key its own runtime honours, and the html tier reports that key with the same `unknown-prop` warning it gives the spellings that do nothing ([#6678](https://github.com/objectstack-ai/objectui/issues/6678)). `pnpm check:esm-specifiers` follows it for the same reason — sources only, no build: it fails when a published package whose build preserves import specifiers (a bare emitting `tsc`, which never rewrites them) writes a relative specifier with no file extension. Node's ESM resolver does not extension-search relative specifiers, so such a specifier makes the published entry unloadable outside a bundler; `@object-ui/react`'s entry died with `ERR_MODULE_NOT_FOUND` while every bundler-based consumer, the whole test suite and CI stayed green ([#4538](https://github.com/objectstack-ai/objectui/issues/4538)). The half that actually *imports* each built entry needs a full build and runs in `node-esm-load-gate.yml`. `pnpm check:action-forward-parity` fails when an action renderer's forward whitelist drops a key the action runtime reads — the class that shipped six times one key at a time, each time green, because the key parses and publishes while the payload is dropped one hop before the runner ([#4050](https://github.com/objectstack-ai/objectui/issues/4050)). `pnpm check:designer-field-key-parity` fails when one of the field designers' statically declared payload shapes (`FieldMetadataPayload`, `ServerFieldSchema`, `DesignerFieldDefinition`) declares a key the installed `@objectstack/spec` `FieldSchema` refuses by NAME. Such a key makes `PUT /api/v1/meta/object/:name` return a hard 422 `INVALID_METADATA` that blocks *every subsequent save* of that object, and the author cannot tell from the designer UI which key did it — the class had been filed three times, each closed with a per-key tombstone written after the instance was found in production, with nothing detecting the next one ([#4644](https://github.com/objectstack-ai/objectui/issues/4644) `indexed`, [#4687](https://github.com/objectstack-ai/objectui/issues/4687) `distance_metric`, [#4676](https://github.com/objectstack-ai/objectui/issues/4676) `placeholder`, gated by [#5761](https://github.com/objectstack-ai/objectui/issues/5761)). It reads the accept set off the schema itself rather than from a list, and it covers a deliberately documented *subset* of the write path: a key that reaches the payload only through a `patchDef` spread or an index signature is outside its reach, and the boundary is stated in the script's own docblock. Its draft-I/O half — the `readFields`/`writeFields` round-trip, which has no declared shape to read — runs in the test suite as `object-fields-io.spec-keys.test.ts`. Same placement rationale as the gates around it: it parses the sources with `typescript` and imports the installed spec, so it needs the install and nothing built. `pnpm check:icon-record-names` fails when an authored icon NAME that reaches a resolver reading lucide's runtime `icons` record is not a live key of that record. lucide retires a spelling by dropping it from that record while keeping it as a deprecated named export, so the retired name still imports, still type-checks and still renders wherever it is used as a *component* — `Edit === SquarePen` is true — and resolves to nothing wherever it is used as a *string*: nothing goes red in either direction, which is why the class was repaired twice in two packages before anyone gated it ([#5586](https://github.com/objectstack-ai/objectui/issues/5586), [#5622](https://github.com/objectstack-ai/objectui/issues/5622), [#5633](https://github.com/objectstack-ai/objectui/issues/5633)). It carries no list of retired spellings — the record itself is the judgement — and it re-discovers the resolver population from source on every run, which is how its first pass found four record-reading resolvers nobody had catalogued. It sits here because it parses the sources with `typescript` and reads the installed lucide: the install, and nothing built. The three locale gates sit in the middle because all of them parse the sources with `typescript`: they need the install and nothing built. `pnpm check:i18n-keys` fails when a `t()` call site asks for a key the `en` pack does not define ([#3530](https://github.com/objectstack-ai/objectui/issues/3530)); `pnpm check:i18n-drift` fails when a change to an `en` string is not accompanied by the nine translation packs ([#3650](https://github.com/objectstack-ai/objectui/issues/3650)), and it is why this job's checkout sets `fetch-depth: 0` — it diffs against the merge base, which a depth-1 clone cannot resolve. `pnpm check:i18n-designer-parity` fails when the metadata-admin designer's own module-local string tables come apart — an `en` row with no `zh` row, or a shared row whose two values carry different `{placeholders}` ([#8834](https://github.com/objectstack-ai/objectui/issues/8834)). The two gates before it are blind to that file by construction — the first classifies the module `module-local table` by declaration and skips it, the second read only the ten locale packs and this table is not one of them — so the same card also gave `pnpm check:i18n-drift` that table as a SECOND population, which is the half that catches a changed `en` value whose `zh` row did not follow. `pnpm type-check:scripts` (`tsconfig.scripts.json`) covers `scripts/**/*.ts`, which `pnpm type-check` cannot reach at all — `scripts/` has no package.json, so turbo never walks it, and the coverage guard decides coverage per *package*. Until [#3494](https://github.com/objectstack-ai/objectui/issues/3494) that left the pin tests in `scripts/__tests__/` — including the one pinning this very page — compiled by nothing. `pnpm type-check:vitest-config` runs `apps/console/tsconfig.node.json` directly — the program that already lists `../../vitest.config.mts` and `apps/console/vitest.config.ts` ([#3476](https://github.com/objectstack-ai/objectui/issues/3476)) — because until [#7328](https://github.com/objectstack-ai/objectui/issues/7328) the only thing that ran it was the console's own `type-check` script, reached through the task runner, whose `type-check` task waits on `^build`. (Named in prose rather than as a code span on purpose: the pin below reads this cell as this job's gate list, so spelling that invocation out would credit the job with a command it does not run.) The cheapest compiler that reads the root Vitest config was therefore reachable only through the most expensive job here, and PR #7291 paid for it: a conditionally spread `dist` project whose literal `extends: true` widened to `boolean` degraded the whole `projects` array to `never[]`, every gate its author ran was green, and CI reported three errors, two of them at `../../vitest.config.mts`. It sits in the cheap half beside `pnpm type-check:scripts` for the same measured reason — nothing in its program imports an `@object-ui/*` package, so it needs the install and nothing built. `pnpm type-check:vitest-setup` (`tsconfig.vitest-setup.json`) closes the same gap for the four repo-root `vitest.setup.*` files, uncovered until [#3515](https://github.com/objectstack-ai/objectui/issues/3515); it runs *last*, after `pnpm type-check`, because `vitest.setup.dom.tsx` side-effect-imports four `@object-ui/*` packages and resolves them through the declarations that turbo's `^build` produces. | Every run; on a PR the steps short-circuit when only ignored paths changed | -| `test` | Test (shard N/8) | When a pull request changed nothing outside the exclusion list, the decision step runs `scripts/markdown-test-inputs.mjs` before anything else, and the job runs in full when the answer is yes ([#8861](https://github.com/objectstack-ai/objectui/issues/8861)). The exclusions drop every markdown path, and a markdown document can be a TEST'S INPUT: [#8857](https://github.com/objectstack-ai/objectui/issues/8857) changed one package README, this job reported success in ten seconds having run nothing, and the merge-queue build then failed the same shard in 907 seconds and dequeued it. That script carries the derived class — which documents a test reads, and which test reads each — and audits itself against the tree. It is this job's stage only, because this is the job that runs those tests. [#9096](https://github.com/objectstack-ai/objectui/issues/9096) then added `scripts` to that scan, because the gate tests under `scripts/__tests__` read this repository's documentation as data and run in these same shards — measured by running all 46 of them under an fs trace, **every** tracked markdown file in the tree is opened by at least one of them. ⇒ for this job the markdown half of the exclusion list is now inert: **any markdown-only pull request runs the shards**. Priced over the 513 first-parent commits available then: 36 reached this stage, 21 already ran, and the widening moves 14 of the remaining 15 from skip to run. The rest of the exclusion list still pays — a non-markdown change under `content/**`, `docs/**` or `apps/site/**` skips as before. Then `pnpm test --shard=N/8` across an 8-runner matrix with `fail-fast: false`, so every shard reports its own failures. No coverage instrumentation — v8 adds 40–100% overhead. **4 → 8 shards, and the built-artifact pin step moved out of this job entirely, in [#9499](https://github.com/objectstack-ai/objectui/issues/9499).** At four shards this job had grown into its own `timeout-minutes: 20`: measured over the 14 most recent `merge_group` runs and the 14 most recent `pull_request` runs, the longest *succeeding* shard-1 job was 1199 s against the 1200 s ceiling — a one-second margin — and two runs crossed it and were **cancelled**, one of them dequeuing a pull request whose tests had passed, because the merge queue cannot tell `cancelled` from `failure`. The ceiling is **not** raised, no test is skipped and the exclusion list is not widened; the work is spread over twice the runners, and the one step that was never sharded at all is now the `test-dist-pins` job below. No shard is a required context any more — `test-aggregate` reports for all of them. | Pull requests and merge-queue builds (everything but `push`); steps short-circuit on a PR that changed only ignored paths | -| `test-dist-pins` | Test (dist pins) | The same decision step as `test` above, `scripts/markdown-test-inputs.mjs` stage included — deliberately verbatim, because this lane used to run whenever that job's gate said run, and a narrower gate here would silently stop running the built-artifact pins on the markdown-only pull requests [#9096](https://github.com/objectstack-ai/objectui/issues/9096) brought into scope. Then `pnpm test:dist` — the built-artifact lane ([#7183](https://github.com/objectstack-ai/objectui/issues/7183)). It delegates to a turbo task scoped to the one package that holds built-artifact pins; that task depends on the package's OWN build (`dependsOn: ["build"]`, not `^build`), so the bundle exists before the pins read it, and then runs the `dist` vitest project, whose pins import a package's BUILT bundle instead of its `src` — a claim the source-aliased suite above is structurally unable to make, since the root config aliases every workspace package to `src`. **Why it is a job and not a step on shard 1** ([#9499](https://github.com/objectstack-ai/objectui/issues/9499)): it was never sharded, so it made shard 1 structurally the longest leg of a matrix bounded by a 20-minute ceiling, and in **both** observed ceiling kills the job died in this step with the test step already green — the tests passed and the check reported `cancelled`. Measured across 28 full CI runs it costs 31–58 s (median ≈53 s); a separate job pays for one more runner and takes the asymmetry out of the matrix without touching a budget. | Pull requests and merge-queue builds (everything but `push`); steps short-circuit on a PR that changed only ignored paths | -| `test-aggregate` | Test | `scripts/check-test-shard-results.mjs` — the **single required test context** ([#9499](https://github.com/objectstack-ai/objectui/issues/9499), director ruling letter C). It `needs` the shard matrix and `test-dist-pins`, and runs `if: always()` so a red, skipped or cancelled shard still produces a verdict here instead of skipping this job — a skipped required context counts as *success* in branch protection, which on the one context that speaks for the whole lane is the failure mode itself. ⛔ It does **not** gate on `needs.test.result`: a matrix job's `needs..result` is a single rollup over every leg, and a rollup reads `success` when some legs succeeded and the rest were **skipped**, so an aggregator built on that expression is a check reporting success having run nothing — [#8857](https://github.com/objectstack-ai/objectui/issues/8857) and [#3523](https://github.com/objectstack-ai/objectui/issues/3523) in one line of YAML. The gate reads this run's own job list from the Actions API instead (`actions: read`, nothing written) and asserts each shard **by name and by its own conclusion**; `skipped` is not a pass. It fails closed — an unreadable answer is exit 2 and a red context — and refuses any verdict over a job list that does not contain this very job, which is the control that the reading reached the right run at all. The dist-pin job is asserted from `needs` directly, since a non-matrix job's result is its own. **Why one name:** naming the legs made the shard *count* a member of a repository-settings surface no workflow edit can reach — widening the matrix renamed four live required contexts at once. | Pull requests and merge-queue builds (everything but `push`) | -| `test-coverage` | Test (coverage shard N/4) | `pnpm test:coverage --reporter=blob --reporter=default --reporter=github-actions --shard=N/4` across a 4-runner matrix with `fail-fast: false`. Each shard writes `.vitest-reports/blob-N-4.json` — raw coverage and test results in one file — and uploads it as an artifact even when the shard is red (vitest deletes `coverage/` on a red run unless `coverage.reportOnFailure` is set, [#5402](https://github.com/objectstack-ai/objectui/issues/5402)). The two reporters after the blob are what keep a red shard *readable*: a CLI `--reporter` replaces the default reporter set rather than adding to it, so `--reporter=blob` on its own ended a failing shard's log at `blob report written to …` with no test name and no assertion text, leaving the download-only artifact as the only copy of the failure ([#9177](https://github.com/objectstack-ai/objectui/issues/9177)); `--reporter=default` restores the log and `--reporter=github-actions` restores the per-test `::error` annotation. The flags are pinned by `scripts/__tests__/coverage-shard-reporter-readability.test.ts`. The configured coverage thresholds are neutralised on the shard legs, because a quarter of the suite judged against a whole-suite threshold is not a defect signal; they are enforced once, on the merged report, by the job below ([#5403](https://github.com/objectstack-ai/objectui/issues/5403)). | **Push only** | -| `coverage-report` | Test (coverage) | Downloads the four blob reports, refuses to continue unless all four arrived, merges them with `pnpm test:coverage --merge-reports` into one complete report — which is where the configured coverage thresholds are enforced, over the whole merged map, the shard legs having overridden them to zero — and publishes that report as the `coverage-report` artifact (kept 7 days, the same as the blobs it is derived from). Its last step runs on every path and states the outcome: the job is **red, with an error annotation**, whenever the gate did not run for the commit — before [#5403](https://github.com/objectstack-ai/objectui/issues/5403) the final step carried the implicit `success()` and was silently skipped by 311 of 373 coverage jobs, which is how four days of a 100%-failing coverage job went unnoticed. A breach of the thresholds is reported *separately* from a lane that never delivered, because the two call for opposite actions. ⛔ It never merges a report from fewer than four shards: a wrong coverage number is worse than a missing one. The Codecov upload this job used to carry was retired by [#5436](https://github.com/objectstack-ai/objectui/issues/5436) — `CODECOV_TOKEN` was never set, so it failed on every push; the trend dashboard and PR coverage comments are gone with it, the gate is not. | **Push only** | +| `test` | Test (shard N/12) | When a pull request changed nothing outside the exclusion list, the decision step runs `scripts/markdown-test-inputs.mjs` before anything else, and the job runs in full when the answer is yes ([#8861](https://github.com/objectstack-ai/objectui/issues/8861)). The exclusions drop every markdown path, and a markdown document can be a TEST'S INPUT: [#8857](https://github.com/objectstack-ai/objectui/issues/8857) changed one package README, this job reported success in ten seconds having run nothing, and the merge-queue build then failed the same shard in 907 seconds and dequeued it. That script carries the derived class — which documents a test reads, and which test reads each — and audits itself against the tree. It is this job's stage only, because this is the job that runs those tests. [#9096](https://github.com/objectstack-ai/objectui/issues/9096) then added `scripts` to that scan, because the gate tests under `scripts/__tests__` read this repository's documentation as data and run in these same shards — measured by running all 46 of them under an fs trace, **every** tracked markdown file in the tree is opened by at least one of them. ⇒ for this job the markdown half of the exclusion list is now inert: **any markdown-only pull request runs the shards**. Priced over the 513 first-parent commits available then: 36 reached this stage, 21 already ran, and the widening moves 14 of the remaining 15 from skip to run. The rest of the exclusion list still pays — a non-markdown change under `content/**`, `docs/**` or `apps/site/**` skips as before. On a pull request or a queue build the job then runs `scripts/select-affected-tests.mjs` against the event's base sha and, when it answers `mode=affected`, `vitest run` over the filters it wrote, sharded the same way; a selector that cannot decide means the full suite, never a skip ([#12214](https://github.com/objectstack-ai/objectui/issues/12214)). Otherwise — and always on `push` and `schedule` — `pnpm test --shard=N/12` across a 12-runner matrix with `fail-fast: false`, so every shard reports its own failures. No coverage instrumentation — v8 adds 40–100% overhead. **4 → 8 shards, and the built-artifact pin step moved out of this job entirely, in [#9499](https://github.com/objectstack-ai/objectui/issues/9499).** At four shards this job had grown into its own `timeout-minutes: 20`: measured over the 14 most recent `merge_group` runs and the 14 most recent `pull_request` runs, the longest *succeeding* shard-1 job was 1199 s against the 1200 s ceiling — a one-second margin — and two runs crossed it and were **cancelled**, one of them dequeuing a pull request whose tests had passed, because the merge queue cannot tell `cancelled` from `failure`. The ceiling is **not** raised, no test is skipped and the exclusion list is not widened; the work is spread over twice the runners, and the one step that was never sharded at all is now the `test-dist-pins` job below. **8 → 12 shards in [#12208](https://github.com/objectstack-ai/objectui/issues/12208)** (its first pull request, folded into [#12214](https://github.com/objectstack-ai/objectui/issues/12214)'s): at eight the job was back against the same ceiling — 14.4 min median, 17.4 min p95 and 20.3 min max over the 1,728 shard jobs of one week, measured on that card — and the same three refusals hold: the ceiling is not raised, no test is skipped, the relevance gate is not widened. Both speeds use the same twelve legs. No shard is a required context any more — `test-aggregate` reports for all of them. | Every event: the affected set on pull requests and merge-queue builds, the whole suite on `push` and the hourly `schedule`; steps short-circuit on a PR that changed only ignored paths | +| `test-dist-pins` | Test (dist pins) | The same decision step as `test` above, `scripts/markdown-test-inputs.mjs` stage included — deliberately verbatim, because this lane used to run whenever that job's gate said run, and a narrower gate here would silently stop running the built-artifact pins on the markdown-only pull requests [#9096](https://github.com/objectstack-ai/objectui/issues/9096) brought into scope. Then `pnpm test:dist` — the built-artifact lane ([#7183](https://github.com/objectstack-ai/objectui/issues/7183)). It delegates to a turbo task scoped to the one package that holds built-artifact pins; that task depends on the package's OWN build (`dependsOn: ["build"]`, not `^build`), so the bundle exists before the pins read it, and then runs the `dist` vitest project, whose pins import a package's BUILT bundle instead of its `src` — a claim the source-aliased suite above is structurally unable to make, since the root config aliases every workspace package to `src`. **Why it is a job and not a step on shard 1** ([#9499](https://github.com/objectstack-ai/objectui/issues/9499)): it was never sharded, so it made shard 1 structurally the longest leg of a matrix bounded by a 20-minute ceiling, and in **both** observed ceiling kills the job died in this step with the test step already green — the tests passed and the check reported `cancelled`. Measured across 28 full CI runs it costs 31–58 s (median ≈53 s); a separate job pays for one more runner and takes the asymmetry out of the matrix without touching a budget. | Every event (the `push` guard went with the shard matrix's in [#12214](https://github.com/objectstack-ai/objectui/issues/12214)); steps short-circuit on a PR that changed only ignored paths | +| `test-aggregate` | Test | `scripts/check-test-shard-results.mjs` — the **single required test context** ([#9499](https://github.com/objectstack-ai/objectui/issues/9499), director ruling letter C). It `needs` the shard matrix and `test-dist-pins`, and runs `if: always()` so a red, skipped or cancelled shard still produces a verdict here instead of skipping this job — a skipped required context counts as *success* in branch protection, which on the one context that speaks for the whole lane is the failure mode itself. ⛔ It does **not** gate on `needs.test.result`: a matrix job's `needs..result` is a single rollup over every leg, and a rollup reads `success` when some legs succeeded and the rest were **skipped**, so an aggregator built on that expression is a check reporting success having run nothing — [#8857](https://github.com/objectstack-ai/objectui/issues/8857) and [#3523](https://github.com/objectstack-ai/objectui/issues/3523) in one line of YAML. The gate reads this run's own job list from the Actions API instead (`actions: read`, nothing written) and asserts each shard **by name and by its own conclusion**; `skipped` is not a pass. It fails closed — an unreadable answer is exit 2 and a red context — and refuses any verdict over a job list that does not contain this very job, which is the control that the reading reached the right run at all. The dist-pin job is asserted from `needs` directly, since a non-matrix job's result is its own. **Why one name:** naming the legs made the shard *count* a member of a repository-settings surface no workflow edit can reach — widening the matrix renamed four live required contexts at once. | Every event — on `push` and `schedule` it is the full battery's test verdict | +| `test-coverage` | Test (coverage shard N/4) | `pnpm test:coverage --reporter=blob --reporter=default --reporter=github-actions --shard=N/4` across a 4-runner matrix with `fail-fast: false`. Each shard writes `.vitest-reports/blob-N-4.json` — raw coverage and test results in one file — and uploads it as an artifact even when the shard is red (vitest deletes `coverage/` on a red run unless `coverage.reportOnFailure` is set, [#5402](https://github.com/objectstack-ai/objectui/issues/5402)). The two reporters after the blob are what keep a red shard *readable*: a CLI `--reporter` replaces the default reporter set rather than adding to it, so `--reporter=blob` on its own ended a failing shard's log at `blob report written to …` with no test name and no assertion text, leaving the download-only artifact as the only copy of the failure ([#9177](https://github.com/objectstack-ai/objectui/issues/9177)); `--reporter=default` restores the log and `--reporter=github-actions` restores the per-test `::error` annotation. The flags are pinned by `scripts/__tests__/coverage-shard-reporter-readability.test.ts`. The configured coverage thresholds are neutralised on the shard legs, because a quarter of the suite judged against a whole-suite threshold is not a defect signal; they are enforced once, on the merged report, by the job below ([#5403](https://github.com/objectstack-ai/objectui/issues/5403)). | **Push and the hourly schedule only** | +| `coverage-report` | Test (coverage) | Downloads the four blob reports, refuses to continue unless all four arrived, merges them with `pnpm test:coverage --merge-reports` into one complete report — which is where the configured coverage thresholds are enforced, over the whole merged map, the shard legs having overridden them to zero — and publishes that report as the `coverage-report` artifact (kept 7 days, the same as the blobs it is derived from). Its last step runs on every path and states the outcome: the job is **red, with an error annotation**, whenever the gate did not run for the commit — before [#5403](https://github.com/objectstack-ai/objectui/issues/5403) the final step carried the implicit `success()` and was silently skipped by 311 of 373 coverage jobs, which is how four days of a 100%-failing coverage job went unnoticed. A breach of the thresholds is reported *separately* from a lane that never delivered, because the two call for opposite actions. ⛔ It never merges a report from fewer than four shards: a wrong coverage number is worse than a missing one. The Codecov upload this job used to carry was retired by [#5436](https://github.com/objectstack-ai/objectui/issues/5436) — `CODECOV_TOKEN` was never set, so it failed on every push; the trend dashboard and PR coverage comments are gone with it, the gate is not. | **Push and the hourly schedule only** | | `e2e` | Build & E2E | Builds the console with `vite build` (`VITE_BASE_PATH=/console/`), verifies the artifact, then `pnpm test:e2e --project=chromium`. On failure it uploads `test-results/` — the screenshots, traces and `error-context.md` Playwright writes for failing specs — as the `e2e-failure-artifacts` upload; the `github` reporter this lane runs on CI writes annotations, so there is no HTML report in it. | Every run; on a PR the steps short-circuit when only ignored paths changed | -| `docs` | Build Docs | `turbo run build --filter='@object-ui/site'`. On a PR it first diffs against the base and skips the build only when nothing that build consumes changed. ⛔ The path set is **not enumerated here**, deliberately: it is derived rather than curated — from the workspace packages turbo builds on the way to `@object-ui/site`, the root-level inputs `turbo.json` declares for the `build` task, and the workspace manifests, plus the workflow file itself so a change to this gate is validated by the gate — and a path list copied into prose is a stale list the moment the closure moves, which is the class [#8629](https://github.com/objectstack-ai/objectui/issues/8629) and [#7448](https://github.com/objectstack-ai/objectui/issues/7448) each record. `scripts/__tests__/docs-build-trigger.test.ts` re-derives all three populations on every PR and executes the step's own shell against them, so the live answer is the pathspec in the step and a red test is what happens when it stops covering them. Until [#8647](https://github.com/objectstack-ai/objectui/issues/8647) the filter named the site's **output** surface only — the docs content and the site app — while its **input** surface is everything turbo builds before `next build` runs, so a pull request touching only `packages/**` skipped the build and still reported `success`, and a skipped build and a passed build are the same green to every reader downstream. The merge-queue leg always built, so what the filter cost was early detection rather than the guarantee at merge time. Then `scripts/check-doc-expression-carriage.mjs`, which is **report-only**: it censuses every `json` fence on the surface `check:doc-types` walks (`content/docs/**`, every `apps//docs/**` tree, and the root `README.md` — widened from `content/docs/**` alone in `59a3a233d`) for a `${…}` authored on a key `SchemaRenderer` never evaluates — the class that reached `main` four times under green gates, because `check:doc-types` judges the `type` literal only and `check:doc-snippets` compiles the ts/tsx blocks only ([#7851](https://github.com/objectstack-ai/objectui/issues/7851)). It prints its findings and **exits 0 regardless**, so it can block no merge; it exits 1 only when the instrument itself is broken — a derivation that matched nothing, a missing `@objectstack/spec` artifact, or a failed built-in control — because a check that runs, goes green and looked at nothing is worse than none. Report-only is a ruling, not an oversight: three cards of the class it reports ([#7440](https://github.com/objectstack-ai/objectui/issues/7440), [#7444](https://github.com/objectstack-ai/objectui/issues/7444), [#7838](https://github.com/objectstack-ai/objectui/issues/7838)) are open and each fixes its own sites. It does **not** check docs links any more — that moved to `docs-links.yml` (#3448), because this workflow's `paths-ignore` then hid exactly the docs-only PRs a link check needs to see. #3523 has since removed that filter from the `pull_request` trigger, but the check stays in its own home: `docs-links.yml` still runs where this workflow does not (a docs-only push to `main`), and one gate with one home was the point of #3448. | Every run (build itself conditional) | +| `docs` | Build Docs | `turbo run build --filter='@object-ui/site'`. On a PR it first diffs against the base and skips the build only when nothing that build consumes changed. ⛔ The path set is **not enumerated here**, deliberately: it is derived rather than curated — from the workspace packages turbo builds on the way to `@object-ui/site`, the root-level inputs `turbo.json` declares for the `build` task, and the workspace manifests, plus the workflow file itself so a change to this gate is validated by the gate — and a path list copied into prose is a stale list the moment the closure moves, which is the class [#8629](https://github.com/objectstack-ai/objectui/issues/8629) and [#7448](https://github.com/objectstack-ai/objectui/issues/7448) each record. `scripts/__tests__/docs-build-trigger.test.ts` re-derives all three populations on every PR and executes the step's own shell against them, so the live answer is the pathspec in the step and a red test is what happens when it stops covering them. Until [#8647](https://github.com/objectstack-ai/objectui/issues/8647) the filter named the site's **output** surface only — the docs content and the site app — while its **input** surface is everything turbo builds before `next build` runs, so a pull request touching only `packages/**` skipped the build and still reported `success`, and a skipped build and a passed build are the same green to every reader downstream. The merge-queue leg always built, so what the filter cost was early detection rather than the guarantee at merge time. Then `scripts/check-doc-expression-carriage.mjs`, which is **report-only**: it censuses every `json` fence on the surface `check:doc-types` walks (`content/docs/**`, every `apps//docs/**` tree, and the root `README.md` — widened from `content/docs/**` alone in `59a3a233d`) for a `${…}` authored on a key `SchemaRenderer` never evaluates — the class that reached `main` four times under green gates, because `check:doc-types` judges the `type` literal only and `check:doc-snippets` compiles the ts/tsx blocks only ([#7851](https://github.com/objectstack-ai/objectui/issues/7851)). It prints its findings and **exits 0 regardless**, so it can block no merge; it exits 1 only when the instrument itself is broken — a derivation that matched nothing, a missing `@objectstack/spec` artifact, or a failed built-in control — because a check that runs, goes green and looked at nothing is worse than none. Report-only is a ruling, not an oversight: three cards of the class it reports ([#7440](https://github.com/objectstack-ai/objectui/issues/7440), [#7444](https://github.com/objectstack-ai/objectui/issues/7444), [#7838](https://github.com/objectstack-ai/objectui/issues/7838)) are open and each fixes its own sites. It does **not** check docs links any more — that moved to a workflow of its own (#3448), because this workflow's `paths-ignore` then hid exactly the docs-only PRs a link check needs to see — today the Internal Docs Link Check step of `repo-gates.yml` (#12214). #3523 has since removed that filter from the `pull_request` trigger, but the check stays out of this job: `repo-gates.yml` still runs where this workflow does not (a docs-only push to `main`), and one gate with one home was the point of #3448. | Every run (build itself conditional) | Uses: Node 22.x, pnpm via `corepack`, `actions/cache` over `.turbo/cache`. @@ -304,7 +305,7 @@ first, which is what stops a scanner that recognises nothing from reporting a cl path; through a symlink, on the same tree, it exits 0 in silence ([#6092](https://github.com/objectstack-ai/objectui/issues/6092)). (Named without its `scripts/` path on purpose: this section reads as the `lint` job's gate list, and that script runs in - `skills-paths.yml`, so spelling the path here would credit this job with a gate it does not run.) + `repo-gates.yml`, so spelling the path here would credit this job with a gate it does not run.) - `scripts/check-bash32-floor.mjs` — this repository's own shell must run on bash 3.2, which is what macOS ships and what CI never exercises. Under bash 5 a `mapfile`, a `declare -A` or an unguarded `$EPOCHSECONDS` in a hand-run script is invisible: the defect *and* its repair both read green, @@ -316,7 +317,7 @@ first, which is what stops a scanner that recognises nothing from reporting a cl - Then `pnpm lint`. - Then `scripts/check-vi-mock-override-shape.mjs` — a `vi.mock` factory that overrides a typed export must hand back the shape that export declares. Its two siblings run in - `vi-mock-specifiers.yml` and judge different properties of the same call sites: whether a relative + `repo-gates.yml`'s Inert vi.mock Specifier Check and judge different properties of the same call sites: whether a relative specifier resolves, and whether the factory inherits the real module. Neither judges what the override is **worth**, and `tsc` never looks — a `vi.mock` factory is untyped, so the compiler never compares a stub against the export's type. Measured rather than argued: thirteen @@ -391,124 +392,6 @@ warnings print and leave the exit code alone. This gate neither promotes those w nor suppresses them from the log — [#5127](https://github.com/objectstack-ai/objectui/issues/5127) owns that arm and is open. -## Control Bytes (`control-bytes.yml`) - -**Triggers:** Push and PR to `main`/`develop`, plus manual dispatch — with **no path filter at -all**, which is the point of the workflow. It appears in the checks list as **Control Byte Scan**. - -Runs `scripts/check-control-bytes.mjs`, which reads `git ls-files` and rejects raw control -characters in every tracked **text** file: the C0 range apart from tab, line feed and carriage -return, plus U+007F. No install, no build — a checkout and one `node` call. - -**Why it blocks a merge.** A single raw U+0000 makes grep and ripgrep classify the *entire* file -as binary: they print `binary file matches` and no matching line, so the file silently drops out -of code search and out of every grep-based lint. Nothing else catches it — git decides -binary-ness from the first 8000 bytes only, so a control byte past that offset keeps diffing as -ordinary text, and review cannot see a character that renders as nothing. objectui had no such -guard until objectstack#5425, by which time five files had accumulated the defect. - -The two byte classes carry different harms and the report says which: - -| Byte | Harm | Measured behaviour | -|---|---|---| -| U+0000 | Code-search outage | GNU grep 3.11 and ripgrep 14 both refuse to print matching lines | -| Every other control byte | Invisible, unreviewable literal | Both tools print the line normally | - -Covering only U+0000 would reproduce a known miss: objectstack#5140 shipped a NUL *and* a U+0001 -fourteen bytes away, and the NUL-only scanner reported OK on the second one (objectstack#5157). - -**Why it is a separate workflow.** `ci.yml` and `lint.yml` used to list `'**/*.md'`, `content/**`, -`docs/**` and `.changeset/**` under `paths-ignore` on *every* trigger, and GitHub has no per-job -path filter. Markdown -is exactly the carrier the worst instance of this bug used — objectstack#4890 was a raw NUL in a -`.claude/` skill file, emitted by the PR that was writing the rule forbidding it, leaving the agent -instructions unfindable by `grep -r` with no signal that anything was missing. A path-filtered gate -could not have seen that PR. [#3523](https://github.com/objectstack-ai/objectui/issues/3523) has since taken that filter off their -`pull_request` triggers, so a markdown-only PR does start them now — but their jobs short-circuit -to nothing on such a change, and both keep the filter on `push`. This gate stays where it is, and -its unfiltered trigger set is why it is one of only two contexts that audit found safe to make -required today. `scripts/__tests__/check-control-bytes.test.ts` fails if a `paths` or -`paths-ignore` key is ever added here. - -**If it fails:** write the escape sequence (backslash, lowercase `u`, four zeroes) instead of the -byte — the resulting string is byte-identical at runtime. Better still, if the byte was only ever -"a character the data cannot contain" (a join/split separator, a sentinel), use something a reader -can verify: a newline, a comma, or `JSON.stringify`, which needs no impossible character at all. -When writing *about* these bytes in prose or in a tool payload, name them as `U+0000` — a backslash -escape typed into an agent's tool payload gets decoded into the real byte before it reaches disk, -which is how two of the five incidents in this family happened. - -**Known pre-existing offenders.** `KNOWN_OFFENDERS` in the script baselines the files that already -carried a control byte when the gate landed, so it could be switched on as a ratchet. It is not a -skip-list: the scan fails on an entry whose file has been cleaned or deleted, so a fix that forgets -to remove its entry is as red as a new offender. Entries carry the issue tracking their removal -(objectstack#5450) and the map is expected to reach empty and stay there. - -## Action Ref Convention (`action-ref-convention.yml`) - -**Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — with **no -path filter**, for the same reason `control-bytes.yml` has none: the population this gate scans *is* -`.github/workflows/`, and a change touching only that directory runs nothing expensive in `ci.yml` -or `lint.yml`. It appears in the checks list as **Action Ref Convention**. - -Runs `scripts/check-action-ref-convention.mjs` — a checkout and one `node` call, no install. - -### The convention - -**Every `uses:` in `.github/workflows/**` is written as a floating major tag, `owner/action@vN`.** - -**An exception is marked by an entry in `DECLARED_EXCEPTIONS`** in that script, carrying the -workflow file, the action path, the issue that owns the decision, and a real reason. That is the -only form an exception takes. A trailing `# v9.0.0`-style comment beside a ref is a version *hint*, -not a reason, and the gate does not read it as one. - -### Why it exists — the finding is the sole instance, not the pinning - -[objectui#8465](https://github.com/objectstack-ai/objectui/issues/8465). There were 13 distinct -action references in this directory. Exactly **one** was spelled differently from the other twelve — -a commit SHA on `actions/stale` — and it was the only reference in the repository that had **never -resolved**: 236 scheduled runs of the stale-issues workflow since 2026-01-16, **0 successes**, every -one failing in `Set up job`, unnoticed for eight months because nothing downstream consumes that -job. The broken reference itself was -[objectui#8126](https://github.com/objectstack-ai/objectui/issues/8126). - -That workflow no longer exists. [objectui#8548](https://github.com/objectstack-ai/objectui/issues/8548) -retired it under enforce-or-remove — a declared automation with zero successes, zero consumers and no -external authors on the open board is removed rather than repaired — and #8126 closed with it. The -`DECLARED_EXCEPTIONS` entry that had covered its SHA pin was deleted in the same commit: an entry -matching nothing is red under the second rule below, so a deletion that left it behind would have -reddened this gate on `main` for every pull request. - -This is **not** an argument that SHA pinning is wrong — it is normally the *more* secure spelling and -supply-chain guidance recommends it. The failure was the *shape*: one ref written in a form nothing -else in the tree used, with no convention that would have made the odd one out visible and nothing -verifying it. A floating tag that stops resolving is loud on the next run of every workflow that uses -it; a lone off-convention ref that never resolved is silent for as long as nobody happens to look. - -So the default is the floating major tag because that is what the tree already was — 12 of 13, and -the repository's one deliberately reasoned per-ref pin, `changesets/action@v1`, is itself a tag (held -there by `scripts/__tests__/changeset-release-action-ref-pin.test.ts`). Declaring it moved no -reference and closed no door. **Do not read it as a ruling against SHA pinning, and do not "unify" -this directory in either direction under cover of this gate** — changing the repository's pinning -posture is a supply-chain decision and belongs on its own card. - -### What it does not do - -It checks **spelling, not resolvability**. A SHA that points at no commit and one that points at a -real commit are indistinguishable without the network. That is deliberate: what failed in #8465 was -that the odd ref had no reason attached and no second instance to compare against, and both of those -are checkable offline. - -### The three ways it goes red - -1. An off-convention ref with no entry in `DECLARED_EXCEPTIONS`. -2. An entry in `DECLARED_EXCEPTIONS` that matches nothing any more — the same staleness check - `KNOWN_OFFENDERS` and `DOCUMENTATION_EXEMPT` carry elsewhere in this repository, because an escape - hatch nobody removes is how a baseline turns into a permanent skip-list. -3. Its own census collapsing. A scan that stops finding references reports an empty offender list, - which renders exactly like a clean repository, so the floors and a positive control - (`actions/checkout` must be found) are asserted in the same run as the counts. - ## Performance Budget (`performance-budget.yml`) **Triggers:** Push and PR when changes touch `packages/`, `apps/console/`, or `pnpm-lock.yaml`. @@ -668,11 +551,165 @@ text prescribed (objectui#11073, the maintainer's ruling on objectui#11111): `pl declares the whole family at exactly 1.7.3 and no longer imports that export, so holding 1.7.2 on it made the lane test a combination nobody ships, and the seeded sign-in never answered. -## Internal Docs Links (`docs-links.yml`) +## Repo Gates (`repo-gates.yml`) + +**Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — with **no +path filter at all**. **Appears as:** Repo Gates. + +One job, one checkout, one setup, fifteen checks +([#12214](https://github.com/objectstack-ai/objectui/issues/12214), the two-speed doctrine of +[objectstack#22998](https://github.com/objectstack-ai/objectstack/issues/22998)). Each check below +used to be a workflow of its own, paying its own runner, checkout and Node setup on every pull +request, push and queue build, to run a gate that mostly takes seconds. They were folded into one +job, and nothing was weakened in the fold: + +- every check runs the same command on the same events, with no path filter, as it did alone; +- every check keeps its name — each step is named ` — `, where `` is the job + name it used to report under — so a red **Repo Gates** context names the gate in its step list; +- every gate step runs `if: ${{ !cancelled() }}` (or on the install or build it needs), so one red + gate never hides another. + +**What moved:** the check runs. None of the fifteen was a required context of the merge queue — +`scripts/check-required-check-set.mjs` reads the live set — which is also why `changeset-presence.yml` +(**Changeset Declaration**), `spec-main-shape-gate.yml` and `governed-surface-guard.yml` were not +folded: their contexts are required, and a step inside a job is not a check run. The one in-tree +reader of the old names, `REQUIRED_CONTEXTS` in `scripts/dependabot-merge-gate.mjs`, now waits for +the one context this job produces. + +**The order is install-free gates, then the install, then the build.** The install-free gates read +the checkout with `node:fs` and nothing else, and run *before* `pnpm install` — which is what keeps +them inside the population `scripts/check-pre-install-import-graph.mjs` derives and holds to an +import graph free of `node_modules`. The three gates that judge the *published* type surface share +one build: `turbo run build` over every package under `packages/` (the README export check reads +each package's declared type entry), in union with the closures the two snippet gates derive with +`--build-filter`. ⛔ It is never an unfiltered workspace build, and a derived filter that names no +package is refused at the step that derives it. + +The sections below are the fifteen gates, each under the name its step carries. + +### Control Bytes — the Control Byte Scan step **Triggers:** Push and PR to `main`/`develop`, plus manual dispatch — with **no path filter at -all**, which is the point of the workflow. It appears in the checks list as **Internal Docs Link -Check**. +all**, which is the point of the workflow. It runs as the **Control Byte Scan** step of the **Repo Gates** job. + +Runs `scripts/check-control-bytes.mjs`, which reads `git ls-files` and rejects raw control +characters in every tracked **text** file: the C0 range apart from tab, line feed and carriage +return, plus U+007F. No install, no build — a checkout and one `node` call. + +**Why it blocks a merge.** A single raw U+0000 makes grep and ripgrep classify the *entire* file +as binary: they print `binary file matches` and no matching line, so the file silently drops out +of code search and out of every grep-based lint. Nothing else catches it — git decides +binary-ness from the first 8000 bytes only, so a control byte past that offset keeps diffing as +ordinary text, and review cannot see a character that renders as nothing. objectui had no such +guard until objectstack#5425, by which time five files had accumulated the defect. + +The two byte classes carry different harms and the report says which: + +| Byte | Harm | Measured behaviour | +|---|---|---| +| U+0000 | Code-search outage | GNU grep 3.11 and ripgrep 14 both refuse to print matching lines | +| Every other control byte | Invisible, unreviewable literal | Both tools print the line normally | + +Covering only U+0000 would reproduce a known miss: objectstack#5140 shipped a NUL *and* a U+0001 +fourteen bytes away, and the NUL-only scanner reported OK on the second one (objectstack#5157). + +**Why it is a separate workflow.** `ci.yml` and `lint.yml` used to list `'**/*.md'`, `content/**`, +`docs/**` and `.changeset/**` under `paths-ignore` on *every* trigger, and GitHub has no per-job +path filter. Markdown +is exactly the carrier the worst instance of this bug used — objectstack#4890 was a raw NUL in a +`.claude/` skill file, emitted by the PR that was writing the rule forbidding it, leaving the agent +instructions unfindable by `grep -r` with no signal that anything was missing. A path-filtered gate +could not have seen that PR. [#3523](https://github.com/objectstack-ai/objectui/issues/3523) has since taken that filter off their +`pull_request` triggers, so a markdown-only PR does start them now — but their jobs short-circuit +to nothing on such a change, and both keep the filter on `push`. This gate stays where it is, and +its unfiltered trigger set is why it is one of only two contexts that audit found safe to make +required today. `scripts/__tests__/check-control-bytes.test.ts` fails if a `paths` or +`paths-ignore` key is ever added here. + +**If it fails:** write the escape sequence (backslash, lowercase `u`, four zeroes) instead of the +byte — the resulting string is byte-identical at runtime. Better still, if the byte was only ever +"a character the data cannot contain" (a join/split separator, a sentinel), use something a reader +can verify: a newline, a comma, or `JSON.stringify`, which needs no impossible character at all. +When writing *about* these bytes in prose or in a tool payload, name them as `U+0000` — a backslash +escape typed into an agent's tool payload gets decoded into the real byte before it reaches disk, +which is how two of the five incidents in this family happened. + +**Known pre-existing offenders.** `KNOWN_OFFENDERS` in the script baselines the files that already +carried a control byte when the gate landed, so it could be switched on as a ratchet. It is not a +skip-list: the scan fails on an entry whose file has been cleaned or deleted, so a fix that forgets +to remove its entry is as red as a new offender. Entries carry the issue tracking their removal +(objectstack#5450) and the map is expected to reach empty and stay there. + +### Action Ref Convention — the Action Ref Convention step + +**Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — with **no +path filter**, for the same reason the Control Byte Scan has none: the population this gate scans *is* +`.github/workflows/`, and a change touching only that directory runs nothing expensive in `ci.yml` +or `lint.yml`. It runs as the **Action Ref Convention** step of the **Repo Gates** job. + +Runs `scripts/check-action-ref-convention.mjs` — a checkout and one `node` call, no install. + +#### The convention + +**Every `uses:` in `.github/workflows/**` is written as a floating major tag, `owner/action@vN`.** + +**An exception is marked by an entry in `DECLARED_EXCEPTIONS`** in that script, carrying the +workflow file, the action path, the issue that owns the decision, and a real reason. That is the +only form an exception takes. A trailing `# v9.0.0`-style comment beside a ref is a version *hint*, +not a reason, and the gate does not read it as one. + +#### Why it exists — the finding is the sole instance, not the pinning + +[objectui#8465](https://github.com/objectstack-ai/objectui/issues/8465). There were 13 distinct +action references in this directory. Exactly **one** was spelled differently from the other twelve — +a commit SHA on `actions/stale` — and it was the only reference in the repository that had **never +resolved**: 236 scheduled runs of the stale-issues workflow since 2026-01-16, **0 successes**, every +one failing in `Set up job`, unnoticed for eight months because nothing downstream consumes that +job. The broken reference itself was +[objectui#8126](https://github.com/objectstack-ai/objectui/issues/8126). + +That workflow no longer exists. [objectui#8548](https://github.com/objectstack-ai/objectui/issues/8548) +retired it under enforce-or-remove — a declared automation with zero successes, zero consumers and no +external authors on the open board is removed rather than repaired — and #8126 closed with it. The +`DECLARED_EXCEPTIONS` entry that had covered its SHA pin was deleted in the same commit: an entry +matching nothing is red under the second rule below, so a deletion that left it behind would have +reddened this gate on `main` for every pull request. + +This is **not** an argument that SHA pinning is wrong — it is normally the *more* secure spelling and +supply-chain guidance recommends it. The failure was the *shape*: one ref written in a form nothing +else in the tree used, with no convention that would have made the odd one out visible and nothing +verifying it. A floating tag that stops resolving is loud on the next run of every workflow that uses +it; a lone off-convention ref that never resolved is silent for as long as nobody happens to look. + +So the default is the floating major tag because that is what the tree already was — 12 of 13, and +the repository's one deliberately reasoned per-ref pin, `changesets/action@v1`, is itself a tag (held +there by `scripts/__tests__/changeset-release-action-ref-pin.test.ts`). Declaring it moved no +reference and closed no door. **Do not read it as a ruling against SHA pinning, and do not "unify" +this directory in either direction under cover of this gate** — changing the repository's pinning +posture is a supply-chain decision and belongs on its own card. + +#### What it does not do + +It checks **spelling, not resolvability**. A SHA that points at no commit and one that points at a +real commit are indistinguishable without the network. That is deliberate: what failed in #8465 was +that the odd ref had no reason attached and no second instance to compare against, and both of those +are checkable offline. + +#### The three ways it goes red + +1. An off-convention ref with no entry in `DECLARED_EXCEPTIONS`. +2. An entry in `DECLARED_EXCEPTIONS` that matches nothing any more — the same staleness check + `KNOWN_OFFENDERS` and `DOCUMENTATION_EXEMPT` carry elsewhere in this repository, because an escape + hatch nobody removes is how a baseline turns into a permanent skip-list. +3. Its own census collapsing. A scan that stops finding references reports an empty offender list, + which renders exactly like a clean repository, so the floors and a positive control + (`actions/checkout` must be found) are asserted in the same run as the counts. + +### Internal Docs Links — the Internal Docs Link Check step + +**Triggers:** Push and PR to `main`/`develop`, plus manual dispatch — with **no path filter at +all**, which is the point of the workflow. It runs as the **Internal Docs Link +Check** step of the **Repo Gates** job. Runs `scripts/check-doc-links.mjs`, which walks every `.md` / `.mdx` file in the surfaces listed in its `SCAN_ROOTS` (17 rows as of objectui#6280) — `content/docs/`, `examples/`, the internal `docs/` @@ -762,8 +799,8 @@ in CI sees it: the site build succeeds with a dead link in it. The script itself gate — it existed, worked, and was wired to nothing under `.github/`, so it had never run in CI at all, and `main` sat with a broken link it would have caught (objectui#3213, objectui#3292). -**Why it is a separate workflow.** This is the second instance of the lesson `control-bytes.yml` -records, and it was found by the PR that first put this check into CI. That PR added it as a step +**Why it was a separate workflow, and is a `repo-gates.yml` step rather than a `ci.yml` one.** This +is the second instance of the lesson the Control Byte Scan records, and it was found by the PR that first put this check into CI. That PR added it as a step in `ci.yml`'s `docs` job — where it could never see the PRs that matter. `ci.yml` *then* listed `'**/*.md'`, `content/**`, `docs/**` and `apps/site/**` under the `paths-ignore` of **both** its triggers, GitHub's `paths-ignore` skips the *whole workflow* when every changed file matches, and @@ -789,12 +826,12 @@ page it points at has moved or been renamed — fix the link, or restore the tar checked as *routes*, so `/docs/guide/foo` is what belongs in the markdown, not `content/docs/guide/foo.md`. Run it locally with `pnpm docs:check-links`. -## Skill Guide Paths (`skills-paths.yml`) +### Skill Guide Paths — the Skill Guide Path Check step **Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — with **no path filter at all**, for the same reason as the two sections above: this gate's entire scan surface -is markdown, and `ci.yml` still lists `'**/*.md'` under the `paths-ignore` of its `push` trigger. It -appears in the checks list as **Skill Guide Path Check**. +is markdown, and `ci.yml` still lists `'**/*.md'` under the `paths-ignore` of its `push` trigger. It runs as the **Skill Guide Path Check** step of the **Repo Gates** +job. Runs `scripts/check-skills-paths.mjs`, which reads every markdown file under `skills/` and `.claude/skills/` and asks, of each in-repo path the prose states inside a backtick code span, @@ -845,12 +882,12 @@ the sentence's whole point is that the path does not exist. Run it locally with `pnpm check:skills-paths`, or `node scripts/check-skills-paths.mjs --list` to see every candidate and how it was classified. -## Skill Examples (`skill-examples.yml`) +### Skill Examples — the Skill Example Check step **Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — with **no path filter at all**, for the same reason as the section above: the scan surface is markdown under `skills/` and `.claude/skills/`, and both `ci.yml` and `lint.yml` list `'**/*.md'` under the -`paths-ignore` of their `push` trigger. It appears in the checks list as **Skill Example Check**. +`paths-ignore` of their `push` trigger. It runs as the **Skill Example Check** step of the **Repo Gates** job. Runs `scripts/check-skill-examples.mjs`. Where the section above checks the *paths* a guide states in prose, this one checks the *worked examples* themselves: a marked `ts` / `tsx` / `typescript` fence @@ -914,11 +951,12 @@ the run. A lenient rule would opt in nothing while its author believed otherwise repository's recurring "looks like enforcement, isn't" class. A marker shown as *example text* inside another fence is neither an opt-in nor an orphan: it is not at top level, so it claims nothing. -**It builds, unlike `skills-paths.yml`:** the criterion is the *published* type surface, so the -packages the marked fences import must exist as `dist/*.d.ts` first. The job therefore runs -`turbo run build`, filtered to exactly those packages, and the filter is emitted by the gate itself -(`node scripts/check-skill-examples.mjs --build-filter`) rather than hand-maintained in the workflow, -so it grows only as the marked population grows. +**It builds, unlike the Skill Guide Path Check:** the criterion is the *published* type surface, so the +packages the marked fences import must exist as `dist/*.d.ts` first. The filter is emitted by the +gate itself (`node scripts/check-skill-examples.mjs --build-filter`) rather than hand-maintained in +the workflow, so it grows only as the marked population grows; the job's one `turbo run build` takes +it in union with every package under `packages/`, which the README export check needs anyway +([#12214](https://github.com/objectstack-ai/objectui/issues/12214)). **Three exit codes, not two.** `0` = every marked example held up. `1` = the gate ran and found errors — a verdict about a guide. `2` = the gate **could not run**: the packages are unbuilt or typed @@ -937,18 +975,17 @@ silent unmark, exactly the move this floor exists to catch. Run it locally with `pnpm check:skill-examples`, `node scripts/check-skill-examples.mjs --list` to see every candidate fence and its verdict, and `--measure` to judge every candidate whether marked or not. -## Skill Eval Tokens (`skill-eval-tokens.yml`) +### Skill Eval Tokens — the Skill Eval Token Check step **Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — with **no path filter at all**, for the same reason as the two sections above: the whole input is the markdown -and JSON under `skills/`, which `ci.yml` and `lint.yml` structurally cannot see. It appears in the -checks list as **Skill Eval Token Check**. +and JSON under `skills/`, which `ci.yml` and `lint.yml` structurally cannot see. It runs as the **Skill Eval Token Check** step of the **Repo Gates** job. Runs `scripts/check-skill-eval-tokens.mjs`. The two sections above check the *paths* a guide states and the *worked examples* it ships; this one checks the **evals** — the `skills//evals/*.json` files that grade an answering agent. Every `must_contain` token must occur as a **whole token** in the markdown of its own skill bundle. Unlike its sibling it does **not** install or build: it reads text -out of the checkout, so it is a checkout plus two `node` calls, the shape `skills-paths.yml` uses. +out of the checkout, so it is a checkout plus two `node` calls, the shape the Skill Guide Path Check uses. **Why it was needed:** nothing checked that a token an eval grades on is something the skill actually teaches, so an eval could require a word the guides never say and grade nothing forever. The class had @@ -994,14 +1031,14 @@ it stays visible, and a row whose red goes away fails as stale until its line is locally with `pnpm check:skill-eval-tokens`, `--list` for every row under both oracles, and `--measure` for the two red lists side by side. -## Documented Component Types (`doc-component-types.yml`) +### Documented Component Types — the Doc Component Type Check step **Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — with **no path filter at all**, and here the reason is sharper than in the three sections above. `ci.yml`'s `type-check` job decides whether to run its gates with a `git diff` that *excludes* `content/**`, so a pull request editing only `content/docs/**.mdx` reports that context and runs nothing inside it — and a docs-only pull request is exactly the change that introduces the defect this gate exists for. -It appears in the checks list as **Doc Component Type Check**. +It runs as the **Doc Component Type Check** step of the **Repo Gates** job. Runs `scripts/check-doc-component-types.mjs`, which reads every fenced code block under `content/docs/**` and asks, of each `type` string literal in one, whether the repository registers a @@ -1058,19 +1095,19 @@ spell the registered key (`grep -rn "ComponentRegistry.register(" packages/` for if the value belongs to another vocabulary — add the declaration with its reason. Run it locally with `pnpm check:doc-types`. -## Documented Snippet Types (`doc-snippet-types.yml`) +### Documented Snippet Types — the Doc Snippet Type Check step **Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — with **no path filter at all**, for the same reason as the section above: the change that breaks a documentation snippet is a docs-only change, and that is exactly the shape `ci.yml`'s expensive jobs -short-circuit. It appears in the checks list as **Doc Snippet Type Check**. +short-circuit. It runs as the **Doc Snippet Type Check** step of the **Repo Gates** job. Runs `scripts/check-doc-snippet-types.mjs`, which extracts every fenced `ts` / `tsx` block from the documents it covers and compiles them `--strict` against each package's **built** `dist/*.d.ts` — the surface a reader who copies the snippet actually imports. **The second dimension, and why it is separate from the first.** -`doc-component-types.yml` answers whether a `type` literal names a registered component. It says so +The Doc Component Type Check answers whether a `type` literal names a registered component. It says so in its own header, and [#5138](https://github.com/objectstack-ai/objectui/issues/5138) measured what the gap beside it allowed: both plugin-report documents taught the pre-9.0 report form for the whole interval after the ADR-0021 cutover, and every gate was green on that prose — because the `type` @@ -1083,8 +1120,10 @@ beside them. The harness that catches those had by then been hand-rolled three t rather than new capability. **Why this one builds.** Its criterion is the *published* type surface, so the packages the covered -snippets import must exist as `dist/*.d.ts` first. The build is filtered to exactly those packages, -and the filter is emitted by the gate itself (`node scripts/check-doc-snippet-types.mjs +snippets import must exist as `dist/*.d.ts` first. Since +[#12214](https://github.com/objectstack-ai/objectui/issues/12214) that closure is built in union with +every package under `packages/`, in the one build the README export check needs anyway, and the +filter is emitted by the gate itself (`node scripts/check-doc-snippet-types.mjs --build-filter`) rather than hand-maintained in the workflow — so it can never drift from what the documents import, and the cost grows only when coverage grows. Each emitted filter carries pnpm and turbo's dependency-closure suffix (`--filter=@object-ui/react...`), because the packages the @@ -1158,7 +1197,7 @@ at the harness. Either fix what the snippet teaches, or — if the block is genu it with a reason. Run it locally with `pnpm check:doc-snippets` (after building the packages it names: `pnpm exec turbo run build $(node scripts/check-doc-snippet-types.mjs --build-filter)`). -### JSDoc `@example` Types — the second gate in this job (`doc-snippet-types.yml`) +#### JSDoc `@example` Types — the second gate in this job The same job's second blocking step runs `scripts/check-doc-example-types.mjs` (`pnpm check:doc-examples`). It asks the snippet gate's question of a different corpus: every fenced @@ -1180,7 +1219,7 @@ copying it out of an IDE hover got TS2322, and nothing in this repository had ev **Why it shares this job rather than getting a workflow.** It is a *sibling* of the gate above, not a fork: it imports that script's compiler host, its built-`.d.ts` resolution and every one of its controls, and reuses its `analyze()` wholesale — so an `@example` block is judged by exactly the -program a documentation fence is judged by, and its precondition is the very filtered build this job +program a documentation fence is judged by, and its precondition is the very build this job already pays for. A workflow of its own would install the workspace and rebuild the same closure a second time for no additional coverage; and it could not even derive that filter without naming `scripts/check-doc-snippet-types.mjs` in a second workflow file, which that gate's own pin @@ -1213,7 +1252,7 @@ example, or declare it. Run it locally exactly as this job does: build first (`pnpm exec turbo run build $(node scripts/check-doc-snippet-types.mjs --build-filter)`), then `pnpm check:doc-examples`; the gate prints that same recipe when it exits `2`. -### JSON Node Documents — the third gate in this job (`doc-snippet-types.yml`) +#### JSON Node Documents — the third gate in this job The job's **last** step runs `scripts/check-doc-json-fences.mjs` (`pnpm check:doc-json`). The two gates above read `ts` / `tsx` only, so a `json` fence was checked by nothing: @@ -1265,11 +1304,11 @@ key by name and path, anything else by the face's own message. Remove the key, o the declared key its renderer reads; declare a deliberate fragment. Run it locally with `pnpm --filter @object-ui/types build`, then `pnpm check:doc-json`. -## Fence Languages (`doc-fence-languages.yml`) +### Fence Languages — the Doc Fence Language Check step **Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — **no path -filter**, for the same reason as the two sections above. It appears in the checks list as **Doc Fence -Language Check**. +filter**, for the same reason as the two sections above. It runs as the **Doc Fence +Language Check** step of the **Repo Gates** job. Runs `scripts/check-doc-fence-languages.mjs`. It answers the question the gate above cannot ask about itself: *is every TypeScript block in the documentation actually fenced as TypeScript?* @@ -1313,13 +1352,13 @@ which is why the probe runs before the verdict. block ```ts (or ```tsx) and fix whatever `check-doc-snippets` then reports, then lower the file's number. Run it locally with `pnpm check:doc-fences`; it needs no install and no build. -## Documented Example Ids (`doc-example-ids.yml`) +### Documented Example Ids — the Doc Example Id Check step **Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — with **no path filter at all**, for the same reason as the three doc sections above: the change that introduces this defect is an id typed into an MDX page, and a docs-only pull request is exactly the -shape `ci.yml`'s expensive jobs short-circuit. It appears in the checks list as **Doc Example Id -Check**. +shape `ci.yml`'s expensive jobs short-circuit. It runs as the **Doc Example Id +Check** step of the **Repo Gates** job. Runs `scripts/check-doc-example-ids.mjs`, which walks every `.mdx` and `.md` page under `content/docs/**`, reads the `id` prop off each `SchemaExample` tag, and resolves it against the ids @@ -1360,13 +1399,13 @@ the schema under `examples/schema-catalog/src/schemas/` and regenerate the index do not add a registry entry just to make a reference resolve. Run it locally with `pnpm check:doc-example-ids`; it needs no install and no build. -## Pre-Install Import Graphs (`pre-install-import-graph.yml`) +### Pre-Install Import Graphs — the Pre-Install Import Graph Check step **Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — with **no path filter at all**. What this gate judges is the arrangement of the workflows themselves, so its input is `.github/workflows/**` plus the `scripts/` files those workflows name, and the change most -likely to break it is a workflow edit. It appears in the checks list as **Pre-Install Import Graph -Check**. +likely to break it is a workflow edit. It runs as the **Pre-Install Import Graph +Check** step of the **Repo Gates** job. Runs `scripts/check-pre-install-import-graph.mjs`. Several gates in this repository deliberately run **before any `pnpm install`** — that is what lets them run unfiltered on every pull request shape for @@ -1418,23 +1457,24 @@ the import is deliberately *not* this gate's job: either drop the package, or mo the derived population and every module walked, or `--self-test` to exercise the parser and the walk against fixtures. -## Inert vi.mock Specifiers (`vi-mock-specifiers.yml`) +### Inert vi.mock Specifiers — the Inert vi.mock Specifier Check step **Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — with **no path filter at all**. A module mock can be written into any package in any shape of pull request, and the scan costs a checkout plus one `node` call, so there is nothing to gain by hiding it behind a -filter. It appears in the checks list as **Inert vi.mock Specifier Check**. +filter. It runs as the **Inert vi.mock Specifier Check** step of the **Repo Gates** job. **Two gates run here, over one population, and both block a merge.** `scripts/check-vi-mock-specifiers.mjs` and `scripts/check-vi-mock-inherit.mjs` are steps of the same -job, so either one failing fails the check — and `dependabot-merge-gate.mjs` classifies **Inert -vi.mock Specifier Check** as a required context. (Named without its `scripts/` path on purpose: this +job, so either one failing fails it — and `dependabot-merge-gate.mjs` classifies **Repo Gates**, the +context both report through since [#12214](https://github.com/objectstack-ai/objectui/issues/12214), +as a required context. (Named without its `scripts/` path on purpose: this section reads as this job's gate list, and that script runs in `dependabot-auto-merge.yml`, so spelling the path here would credit this job with a gate it does not run.) They read the same `vi.mock` call sites deliberately: a population that drifted between them would be a hole neither -one reports, which is why they share a workflow instead of each registering a context of its own. -The workflow `name:` and the job `name:` are therefore broader than the older of the two gates, and -are left that way on purpose — renaming a required context silently un-requires it. +one reports, which is why they shared a workflow instead of each registering a context of its own; +both steps carry the **Inert vi.mock Specifier Check** name, broader than the older of the two gates, +because that was the context name they reported under before the fold. **The specifier gate.** `scripts/check-vi-mock-specifiers.mjs` walks every tracked JS/TS-family source file, finds each `vi.mock` / `vi.doMock` call site, and resolves the **relative** specifiers @@ -1496,12 +1536,12 @@ test and checking that the suite goes red. The inherit gate names each frozen ca `pnpm check:vi-mock-specifiers` and `pnpm check:vi-mock-inherit`, either with `--list` after the script path to see every call site the walk found. Neither needs an install or a build. -## Shell Escape Residue (`shell-escape-residue.yml`) +### Shell Escape Residue — the Shell Escape Residue Scan step **Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — with **no path filter at all**. The scan surface is markdown that any shape of pull request can touch, and a -markdown-only change is exactly the shape `ci.yml` and `lint.yml` skip their expensive steps on. It -appears in the checks list as **Shell Escape Residue Scan**. +markdown-only change is exactly the shape `ci.yml` and `lint.yml` skip their expensive steps on. It runs as the **Shell Escape Residue Scan** step of the **Repo Gates** +job. Runs `scripts/check-shell-escape-residue.mjs`. It walks `AGENTS.md`, `CLAUDE.md`, every `.md`/`.mdx` under `skills/`, every one under `.claude/skills/` and every one under `content/docs/`, and fails when @@ -1575,10 +1615,10 @@ request. A finding under `content/docs/**` is an ordinary docs fix. Run it locally with `pnpm check:shell-escape-residue`, or `node scripts/check-shell-escape-residue.mjs --list` to see the per-root census. It needs no install and no build. -## README Exports (`readme-exports.yml`) +### README Exports — the README Export Check step **Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — with **no -path filter at all**. It appears in the checks list as **README Export Check**. +path filter at all**. It runs as the **README Export Check** step of the **Repo Gates** job. The absent filter is the point. The two edits that introduce this drift are a README change and a source change that renames or drops an export, and `ci.yml` structurally cannot see the first: every @@ -1652,14 +1692,14 @@ that card the same state was an uncaught `TypeError` in the row formatter, which no row past the first unjudgeable declaration, and left exit 1, indistinguishable from the fabricated-name failure the gate exists to report. -## Docs Route Eager Closure (`docs-route-eager-closure.yml`) +### Docs Route Eager Closure — the Docs Route Eager Closure Check step **Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — with **no -path filter at all** (the reason is in the [inventory](#workflow-inventory) bullets above). It -appears in the checks list as **Docs Route Eager Closure Check**, and `REQUIRED_CONTEXTS` in -`scripts/dependabot-merge-gate.mjs` declares that context blocking — which is also what puts this -workflow inside the derived `merge_group` floor, because a required check that never reports on a -queue build does not fail it, it stalls it for the ruleset's 60 minutes. +path filter at all** (the reason is in the [inventory](#workflow-inventory) bullets above). It runs as the **Docs Route Eager Closure Check** step of the **Repo Gates** +job, and `REQUIRED_CONTEXTS` in +`scripts/dependabot-merge-gate.mjs` declares that job's context blocking — which is also what puts +`repo-gates.yml` inside the derived `merge_group` floor, because a required check that never reports +on a queue build does not fail it, it stalls it for the ruleset's 60 minutes. Runs `scripts/check-docs-route-eager-closure.mjs` (`pnpm check:docs-route-closure`): a checkout plus one `node` call over the source tree, **no install and no build**, ~1.3 s. @@ -1818,8 +1858,8 @@ governed diff through, and the wiring is pinned by ## Line Citations (`line-citation-gate.yml`) -**Triggers:** Pull requests to `main`/`develop`, with **no path filter**, plus manual dispatch. It -appears in the checks list as **Line Citation Gate**. +**Triggers:** Pull requests to `main`/`develop`, with **no path filter**, plus manual dispatch. It runs as the **Line Citation Gate** step of the **Repo Gates** +job. **What it runs:** `node scripts/check-new-cross-file-line-citations.mjs` — one `node` call over the pull request's own diff. No install, no build. @@ -1891,8 +1931,7 @@ right instrument for asking how large the existing class is — never for decidi ## Lockfile Integrity (`lockfile-integrity.yml`) **Triggers:** Pull requests to `main`/`develop` that touch `pnpm-lock.yaml`, -`scripts/check-lockfile-integrity.mjs` or the workflow file itself, plus manual dispatch. It appears -in the checks list as **Lockfile Integrity Check**. +`scripts/check-lockfile-integrity.mjs` or the workflow file itself, plus manual dispatch. It runs as the **Lockfile Integrity Check** step of the **Repo Gates** job. Runs `scripts/check-lockfile-integrity.mjs`, which compares the pull request's `pnpm-lock.yaml` against the **merge base's** and reports two things: @@ -1940,8 +1979,7 @@ pull request as input. **Trigger:** Pull requests to `main` and `develop` that touch `pnpm-lock.yaml`, this gate's own runtime closure (`scripts/check-lockfile-dedupe.mjs`, `scripts/invoked-as.mjs`, -`scripts/ci-setup-pnpm.sh`) or the workflow file itself, plus manual dispatch. It appears in the -checks list as **Lockfile Dedupe Check**. +`scripts/ci-setup-pnpm.sh`) or the workflow file itself, plus manual dispatch. It runs as the **Lockfile Dedupe Check** step of the **Repo Gates** job. Runs `scripts/check-lockfile-dedupe.mjs`, which shells out to `pnpm dedupe --check` and requires the committed lockfile to be **already deduped** — that is, `pnpm dedupe` must have nothing left to @@ -1999,7 +2037,7 @@ There are **two** link checkers, and they cover different things (objectui#3213) | | Covers | Network | Runs | |---|---|---|---| -| `scripts/check-doc-links.mjs` | **Internal** links in `content/docs/` (relative hrefs, `/docs/...` routes, every other site-absolute href against `apps/site`), and, as paths on disk: `examples/`, the internal `docs/` tree, every package and app `README.md`, the rest of each package's and app's directory tree (everything but `README.md`/`CHANGELOG.md`), every nested `README.md`, and the root-level markdown files (`README.md`, `CONTRIBUTING.md`, `ROADMAP.md`, `AGENTS.md`, `CHANGELOG.md`, `CLAUDE.md`, `LICENSE-THIRD-PARTY.md`, `QUICK_REFERENCE.md`) — plus this repo's own `blob/main/` and `tree/main/` GitHub URLs and this site's own `objectui.org` URLs everywhere — **except** anything inside a code fence | No | `docs-links.yml` — every push and PR, no path filter (previous section) | +| `scripts/check-doc-links.mjs` | **Internal** links in `content/docs/` (relative hrefs, `/docs/...` routes, every other site-absolute href against `apps/site`), and, as paths on disk: `examples/`, the internal `docs/` tree, every package and app `README.md`, the rest of each package's and app's directory tree (everything but `README.md`/`CHANGELOG.md`), every nested `README.md`, and the root-level markdown files (`README.md`, `CONTRIBUTING.md`, `ROADMAP.md`, `AGENTS.md`, `CHANGELOG.md`, `CLAUDE.md`, `LICENSE-THIRD-PARTY.md`, `QUICK_REFERENCE.md`) — plus this repo's own `blob/main/` and `tree/main/` GitHub URLs and this site's own `objectui.org` URLs everywhere — **except** anything inside a code fence | No | `repo-gates.yml`'s Internal Docs Link Check — every push and PR, no path filter | | Lychee (this workflow) | **External** URLs, plus **relative** in-repo file links, in `content/docs/`, `docs/` and `README.md` | Yes | Weekly cron and manual dispatch | Lychee sweeps **both** documentation trees plus `README.md`: the **published** tree @@ -2041,7 +2079,7 @@ Uses [Lychee](https://github.com/lycheeverse/lychee) with configuration from `ly ## Spec Main Shape Gate (`spec-main-shape-gate.yml`) -**Trigger:** PR to `main`, `develop` — **no path filter**; merge-queue builds; manual. +**Trigger:** PR to `main`, `develop` — **no path filter**; merge-queue builds; manual; hourly `schedule` on `main`. **Appears as:** Spec Main Shape Gate. It builds `@objectstack/spec` from objectstack `main` at a git sha and compiles this repository @@ -2083,6 +2121,18 @@ objectstack repository root. A packed codeload tarball was measured against it a tarball endpoint has no server-side path filter, so it ships the whole repository however little of it the job needs. +**The spec is built only when objectstack `main` moved** +([#12214](https://github.com/objectstack-ai/objectui/issues/12214)). The hourly `schedule` run on +`main` — this gate's place in the full battery — publishes the spec it packed as the artifact +`objectstack-spec-`, with the sha it was packed at, kept one day. Every other run first looks +for that artifact at the sha it just resolved and injects it instead of building again; the clone +and the spec's own install still run, because the injection reads the dependencies the spec's +build resolved. Only an artifact from a `main` run of this repository is reused: a pull request or +a queue build runs its own branch's workflow file, so what it could upload is not evidence about a +spec. A failed lookup or download, or an artifact recording another sha, means *build*. What this +saves is the build and the pack — the forced compile below is most of the job and is never cached; +the measured split is on #12214's pull request, not restated here. + **The compile is `TURBO_FORCE=true pnpm type-check --continue`, and every word of it is load-bearing.** That task dependsOn `^build`, so the job builds every workspace package against the injected spec and then type-checks against those declarations — both halves of the consumer @@ -3047,7 +3097,7 @@ without a caller. `.claude/**` is governed surface. It only gives the existing matrices a caller that fails the build the moment any one of them goes red, the same way any other required check does. -**Path-filtered, unlike `control-bytes.yml`.** That gate carries no `paths` filter because a raw +**Path-filtered, unlike the Control Byte Scan.** That gate carries no `paths` filter because a raw control byte can land in a markdown-only PR just as easily as a TypeScript one. That reasoning does not transfer here: the self-tests assert the CURRENT hook script's behaviour against a fixture they build themselves, so nothing about a docs-only or dependency-bump PR can move the @@ -3085,8 +3135,10 @@ So the wait is now explicit and this workflow owns it every context it declares has reported `success`, and only then may the two mutations — approve, enqueue — run. The declared set is the unfiltered blocking contexts (**Test** — since [#9499](https://github.com/objectstack-ai/objectui/issues/9499) the single aggregator context that -reports for the eight test shards and the dist-pin job — **Type -Check**, **Lint**, **Build & E2E**, **Build Docs** and the five one-`node`-call gates); the +reports for the twelve test shards and the dist-pin job — **Type +Check**, **Lint**, **Build & E2E**, **Build Docs**, **Repo Gates** — one context for the fifteen gates +[#12214](https://github.com/objectstack-ai/objectui/issues/12214) folded into one job — and the rest of +`REQUIRED_CONTEXTS`); the path-filtered ones (**Bundle Analysis**, **Changeset Bump Policy**, **Hook Self-Tests**) must be green *if they reported*; everything else is listed with the reason it cannot gate. A context that is missing, still running at the deadline, or anything other than `success` is **not** green: nothing merges, @@ -3278,8 +3330,8 @@ step because nothing in that job calls pnpm. from Corepack plus the root `packageManager` field instead, through `scripts/ci-setup-pnpm.sh`. - `readme-exports.yml` (see the **README Exports** section above) is a good one to read: it - is short, runs on every pull request, and its setup is the complete pattern most new + `repo-gates.yml` (see the **Repo Gates** section above) is a good one to read: it runs on + every pull request, and its setup is the complete pattern most new build/test/lint workflows need — checkout, the shared pnpm setup, `actions/setup-node` with pnpm's own cache, `pnpm install --frozen-lockfile`, then a `turbo run build` step for whatever it needs built. Two of its steps hold for any workflow no matter which Node diff --git a/lychee.toml b/lychee.toml index c5fbf8f495..b33a5c1fe4 100644 --- a/lychee.toml +++ b/lychee.toml @@ -5,7 +5,8 @@ # file links (`docs/**` links into `../../packages/**` source files rely on # that). Site-ABSOLUTE routes — `/docs/...`, `/api/...`, `/protocol/...` — are # not its job; those belong to `scripts/check-doc-links.mjs`, run by -# `docs-links.yml`. See the `root_dir` section below. +# `repo-gates.yml` (the Internal Docs Link Check step). See the `root_dir` +# section below. # Maximum number of concurrent requests max_concurrency = 10 diff --git a/scripts/__tests__/check-action-ref-convention.test.ts b/scripts/__tests__/check-action-ref-convention.test.ts index 3835032d7c..4b8c6d40dd 100644 --- a/scripts/__tests__/check-action-ref-convention.test.ts +++ b/scripts/__tests__/check-action-ref-convention.test.ts @@ -132,14 +132,14 @@ describe('the gate can fail (non-vacuity)', () => { it('goes red on an undeclared SHA pin — the exact shape of objectui#8465', () => { const result = scanMutated((files) => { - const name = 'control-bytes.yml'; + const name = 'repo-gates.yml'; files.set( name, files.get(name)!.replace('uses: actions/checkout@v7', 'uses: actions/checkout@' + 'a'.repeat(40)), ); }); expect(result.offenders.map((o: { file: string; kind: string }) => `${o.file} ${o.kind}`)).toContain( - 'control-bytes.yml sha', + 'repo-gates.yml sha', ); }); @@ -147,7 +147,7 @@ describe('the gate can fail (non-vacuity)', () => { // The convention is a spelling, not an anti-SHA rule. `@main` is the other // direction of the same defect: a reference nothing around it looks like. const result = scanMutated((files) => { - const name = 'control-bytes.yml'; + const name = 'repo-gates.yml'; files.set(name, files.get(name)!.replace('uses: actions/checkout@v7', 'uses: actions/checkout@main')); }); expect(result.offenders.map((o: { ref: string }) => o.ref)).toContain('actions/checkout@main'); @@ -168,14 +168,14 @@ describe('the gate can fail (non-vacuity)', () => { const declared = [ ...DECLARED_EXCEPTIONS, { - workflow: 'control-bytes.yml', + workflow: 'repo-gates.yml', action: 'actions/checkout', issue: 'objectui#8465', reason: 'synthetic entry for the non-vacuity proof below — not a real exception', }, ]; const shaPin = (files: Map) => { - const name = 'control-bytes.yml'; + const name = 'repo-gates.yml'; files.set( name, files.get(name)!.replace('uses: actions/checkout@v7', 'uses: actions/checkout@' + 'a'.repeat(40)), @@ -194,10 +194,10 @@ describe('the gate can fail (non-vacuity)', () => { // permanently red `main` behind it. const orphaned = scanMutated((files) => { shaPin(files); - files.delete('control-bytes.yml'); + files.delete('repo-gates.yml'); }, declared); expect(orphaned.offenders).toEqual([]); - expect(orphaned.stale.map((e: { workflow: string }) => e.workflow)).toEqual(['control-bytes.yml']); + expect(orphaned.stale.map((e: { workflow: string }) => e.workflow)).toEqual(['repo-gates.yml']); }); it('goes red when the census collapses, instead of reporting a clean tree', () => { @@ -216,7 +216,7 @@ describe('the gate can fail (non-vacuity)', () => { // entry whose effect this case reads. const fake = [ ...DECLARED_EXCEPTIONS, - { workflow: 'control-bytes.yml', action: 'actions/checkout', issue: 'objectui#1', reason: 'x'.repeat(50) }, + { workflow: 'repo-gates.yml', action: 'actions/checkout', issue: 'objectui#1', reason: 'x'.repeat(50) }, ]; // Two off-convention refs, in two different workflows, and `fake` names only // the first. The second is the whole point of the case: until objectui#8548 @@ -225,8 +225,8 @@ describe('the gate can fail (non-vacuity)', () => { // half — a gate that reported nothing at all would pass that alone. const result = scanMutated((files) => { files.set( - 'control-bytes.yml', - files.get('control-bytes.yml')!.replace('uses: actions/checkout@v7', 'uses: actions/checkout@main'), + 'repo-gates.yml', + files.get('repo-gates.yml')!.replace('uses: actions/checkout@v7', 'uses: actions/checkout@main'), ); files.set( 'shadcn-check.yml', @@ -264,8 +264,8 @@ describe('prose cannot be mistaken for a reference', () => { }); it("does not count this gate's own workflow header as references", () => { - const source = fs.readFileSync(path.join(dirOf(), 'action-ref-convention.yml'), 'utf8'); - expect(parseRefs(source, 'action-ref-convention.yml').map((r: { ref: string }) => r.ref)).toEqual([ + const source = fs.readFileSync(path.join(dirOf(), 'repo-gates.yml'), 'utf8'); + expect(parseRefs(source, 'repo-gates.yml').map((r: { ref: string }) => r.ref)).toEqual([ 'actions/checkout@v7', 'actions/setup-node@v7', ]); @@ -298,7 +298,7 @@ describe('classification', () => { }); describe('the gate is wired into CI', () => { - const workflow = path.join(workflowDir(repoRoot), 'action-ref-convention.yml'); + const workflow = path.join(workflowDir(repoRoot), 'repo-gates.yml'); it('has a workflow that runs it', () => { expect(fs.existsSync(workflow), 'action-ref-convention.yml must exist').toBe(true); diff --git a/scripts/__tests__/check-control-bytes.test.ts b/scripts/__tests__/check-control-bytes.test.ts index d04cc47758..8f6e9174a4 100644 --- a/scripts/__tests__/check-control-bytes.test.ts +++ b/scripts/__tests__/check-control-bytes.test.ts @@ -475,7 +475,7 @@ describe('objectstack#5450 — the four baselined files are clean', () => { describe('wiring — the gate is actually reachable and actually runs', () => { const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); - const workflowPath = path.join(repoRoot, '.github/workflows/control-bytes.yml'); + const workflowPath = path.join(repoRoot, '.github/workflows/repo-gates.yml'); it('is exposed as a root package script', () => { expect(pkg.scripts['check:control-bytes']).toBe('node scripts/check-control-bytes.mjs'); diff --git a/scripts/__tests__/check-doc-component-types.test.ts b/scripts/__tests__/check-doc-component-types.test.ts index 43bb2e1ab0..de57ff7b11 100644 --- a/scripts/__tests__/check-doc-component-types.test.ts +++ b/scripts/__tests__/check-doc-component-types.test.ts @@ -2098,7 +2098,7 @@ describe('objectui#7896 — every package README is inside the scan surface', () describe('wiring — the gate is reachable and a docs-only PR starts it', () => { const workflowDir = path.join(repoRoot, '.github/workflows'); - const workflowPath = path.join(workflowDir, 'doc-component-types.yml'); + const workflowPath = path.join(workflowDir, 'repo-gates.yml'); const workflowFiles = fs.readdirSync(workflowDir).filter((f) => f.endsWith('.yml')); /** @@ -2122,7 +2122,7 @@ describe('wiring — the gate is reachable and a docs-only PR starts it', () => it('has a workflow that gates pull requests, not just pushes', () => { expect(fs.existsSync(workflowPath), 'a check nothing runs is not a gate').toBe(true); - const yaml = yamlOf('doc-component-types.yml'); + const yaml = yamlOf('repo-gates.yml'); expect(yaml).toMatch(new RegExp(`run:\\s*node\\s+${SCRIPT.replace(/[.]/g, '\\.')}`)); expect(yaml).toMatch(/^\s*pull_request:/m); expect(yaml).toMatch(/^\s*push:/m); @@ -2145,15 +2145,20 @@ describe('wiring — the gate is reachable and a docs-only PR starts it', () => }); it('has exactly one home', () => { - expect(workflowFiles.filter((f) => yamlOf(f).includes(SCRIPT))).toEqual(['doc-component-types.yml']); + expect(workflowFiles.filter((f) => yamlOf(f).includes(SCRIPT))).toEqual(['repo-gates.yml']); }); it('needs no install, so it can afford to run unfiltered', () => { // The moment this needs `pnpm install` it stops being cheap enough to run on // every PR shape, and the filter that follows is the hole. - const yaml = yamlOf('doc-component-types.yml'); - expect(yaml).not.toContain('pnpm install'); - expect(yaml).not.toContain('corepack'); + // Since objectui#12214 the home is `repo-gates.yml`, ONE job that installs + // after its install-free gates: the property is now an ORDER -- this gate's + // step sits before the job's `pnpm install` -- and no longer an absence. + const yaml = yamlOf('repo-gates.yml'); + const gateAt = yaml.indexOf(`run: node ${SCRIPT}`); + const installAt = yaml.indexOf('pnpm install'); + expect(gateAt).toBeGreaterThan(-1); + expect(installAt === -1 || gateAt < installAt, 'the gate step moved below the install step').toBe(true); // Walk the WHOLE static import graph, not just the gate's own first line. // objectui#6092 converted this gate's entry guard to `./invoked-as.mjs`, a diff --git a/scripts/__tests__/check-doc-example-ids.test.ts b/scripts/__tests__/check-doc-example-ids.test.ts index 612df7afaa..96f0d717ad 100644 --- a/scripts/__tests__/check-doc-example-ids.test.ts +++ b/scripts/__tests__/check-doc-example-ids.test.ts @@ -317,7 +317,7 @@ describe('this repository', () => { describe('wiring — the gate is reachable and a docs-only PR starts it', () => { const workflowDir = path.join(repoRoot, '.github/workflows'); - const workflowFile = 'doc-example-ids.yml'; + const workflowFile = 'repo-gates.yml'; const workflowPath = path.join(workflowDir, workflowFile); const workflowFiles = fs.readdirSync(workflowDir).filter((f) => f.endsWith('.yml')); @@ -376,9 +376,14 @@ describe('wiring — the gate is reachable and a docs-only PR starts it', () => }); it('needs no install, so it can afford to run unfiltered', () => { + // Since objectui#12214 the home is `repo-gates.yml`, ONE job that installs + // after its install-free gates: the property is now an ORDER -- this gate's + // step sits before the job's `pnpm install` -- and no longer an absence. const yaml = yamlOf(workflowFile); - expect(yaml).not.toContain('pnpm install'); - expect(yaml).not.toContain('corepack'); + const gateAt = yaml.indexOf('scripts/check-doc-example-ids.mjs'); + const installAt = yaml.indexOf('pnpm install'); + expect(gateAt).toBeGreaterThan(-1); + expect(installAt === -1 || gateAt < installAt, 'the gate step moved below the install step').toBe(true); }); it('carries no population count in its header — a number in a comment has nothing that fails when it drifts', () => { diff --git a/scripts/__tests__/check-doc-example-types.test.ts b/scripts/__tests__/check-doc-example-types.test.ts index dbe3e1f61b..5acef19d42 100644 --- a/scripts/__tests__/check-doc-example-types.test.ts +++ b/scripts/__tests__/check-doc-example-types.test.ts @@ -534,7 +534,7 @@ describe('the sibling harness is imported, not forked', () => { describe('wiring — the gate runs in CI, and in a workflow a docs-only pull request can start', () => { const GATE = 'scripts/check-doc-example-types.mjs'; const SIBLING = 'scripts/check-doc-snippet-types.mjs'; - const HOME = 'doc-snippet-types.yml'; + const HOME = 'repo-gates.yml'; const workflowDir = path.join(repoRoot, '.github/workflows'); const workflowFiles = fs.readdirSync(workflowDir).filter((f) => f.endsWith('.yml')); diff --git a/scripts/__tests__/check-doc-fence-languages.test.ts b/scripts/__tests__/check-doc-fence-languages.test.ts index b590918f8b..21f6353fb9 100644 --- a/scripts/__tests__/check-doc-fence-languages.test.ts +++ b/scripts/__tests__/check-doc-fence-languages.test.ts @@ -37,7 +37,7 @@ import { selfTestCases, stripAnsi } from './helpers/child-verdict'; const ROOT = path.resolve(fileURLToPath(import.meta.url), '../../..'); const GUARD = 'scripts/check-doc-fence-languages.mjs'; -const WORKFLOW = 'doc-fence-languages.yml'; +const WORKFLOW = 'repo-gates.yml'; /** * A numeral that qualifies a document-population noun, as `match: text`. @@ -441,7 +441,7 @@ describe('check-doc-fence-languages: non-vacuity, through the shipped module', ( describe('check-doc-fence-languages is wired, not merely present', () => { const workflow = parseYaml(fs.readFileSync(path.join(ROOT, '.github/workflows', WORKFLOW), 'utf8')); - const steps: Array> = workflow.jobs['doc-fence-languages'].steps; + const steps: Array> = workflow.jobs['repo-gates'].steps; const gateSteps = steps.filter((s) => typeof s.run === 'string' && (s.run as string).includes(GUARD)); it('the guard script exists', () => { @@ -482,9 +482,17 @@ describe('check-doc-fence-languages is wired, not merely present', () => { expect(Object.keys(on)).toContain('merge_group'); }); - it('needs no install — nothing in the job runs pnpm', () => { - const runs = steps.map((s) => (typeof s.run === 'string' ? s.run : '')).join('\n'); - expect(runs).not.toContain('pnpm'); + it('needs no install — no pnpm runs before this gate in its job', () => { + // Since objectui#12214 the home is `repo-gates.yml`, ONE job that installs + // after its install-free gates: the property is now an ORDER -- nothing + // before this gate's step runs `pnpm install` -- and no longer an absence. + const gateAt = steps.findIndex((s) => typeof s.run === 'string' && (s.run as string).includes(GUARD)); + expect(gateAt).toBeGreaterThan(-1); + const before = steps + .slice(0, gateAt) + .map((s) => (typeof s.run === 'string' ? s.run : '')) + .join('\n'); + expect(before).not.toContain('pnpm install'); }); it('its self-test passes — the half that makes a green scan mean something', () => { diff --git a/scripts/__tests__/check-doc-json-fences.test.ts b/scripts/__tests__/check-doc-json-fences.test.ts index 921166da6c..4219ff88f1 100644 --- a/scripts/__tests__/check-doc-json-fences.test.ts +++ b/scripts/__tests__/check-doc-json-fences.test.ts @@ -516,7 +516,7 @@ describe('main — exit codes', () => { describe('wiring — the gate runs in CI, after the build and after its siblings', () => { const GATE = 'scripts/check-doc-json-fences.mjs'; const SIBLINGS = ['scripts/check-doc-snippet-types.mjs', 'scripts/check-doc-example-types.mjs']; - const HOME = 'doc-snippet-types.yml'; + const HOME = 'repo-gates.yml'; const workflowDir = path.join(repoRoot, '.github/workflows'); const workflowFiles = fs.readdirSync(workflowDir).filter((f) => f.endsWith('.yml')); const yamlOf = (file: string): string => diff --git a/scripts/__tests__/check-doc-snippet-emitted-census.test.ts b/scripts/__tests__/check-doc-snippet-emitted-census.test.ts index c6d0244e7c..3bec08f0b6 100644 --- a/scripts/__tests__/check-doc-snippet-emitted-census.test.ts +++ b/scripts/__tests__/check-doc-snippet-emitted-census.test.ts @@ -406,7 +406,7 @@ describe('on this repository — the walk is non-vacuous and reaches both known describe('wiring — a census nothing runs is not a census', () => { const workflow = fs.readFileSync( - path.join(repoRoot, '.github/workflows/doc-snippet-types.yml'), + path.join(repoRoot, '.github/workflows/repo-gates.yml'), 'utf8', ); diff --git a/scripts/__tests__/check-doc-snippet-types.test.ts b/scripts/__tests__/check-doc-snippet-types.test.ts index f98cdda521..0dcd5fcdcc 100644 --- a/scripts/__tests__/check-doc-snippet-types.test.ts +++ b/scripts/__tests__/check-doc-snippet-types.test.ts @@ -1864,7 +1864,7 @@ describe('the printed build command says what it does NOT build (objectui#7795)' describe('wiring — a script nothing runs is not a gate', () => { const workflowDir = path.join(repoRoot, '.github/workflows'); - const workflowPath = path.join(workflowDir, 'doc-snippet-types.yml'); + const workflowPath = path.join(workflowDir, 'repo-gates.yml'); const workflowFiles = fs.readdirSync(workflowDir).filter((f) => f.endsWith('.yml')); /** A workflow's YAML with whole-line comments removed — the headers in this @@ -1878,7 +1878,7 @@ describe('wiring — a script nothing runs is not a gate', () => { it('has a workflow that gates pull requests, not just pushes', () => { expect(fs.existsSync(workflowPath), 'a check nothing runs is not a gate').toBe(true); - const yaml = yamlOf('doc-snippet-types.yml'); + const yaml = yamlOf('repo-gates.yml'); expect(yaml).toContain('pull_request:'); expect(yaml).toContain(SCRIPT); }); @@ -1895,11 +1895,11 @@ describe('wiring — a script nothing runs is not a gate', () => { }); it('lives in exactly one workflow — one gate, one home', () => { - expect(workflowFiles.filter((f) => yamlOf(f).includes(SCRIPT))).toEqual(['doc-snippet-types.yml']); + expect(workflowFiles.filter((f) => yamlOf(f).includes(SCRIPT))).toEqual(['repo-gates.yml']); }); it('builds a FILTER, never the whole workspace', () => { - const yaml = yamlOf('doc-snippet-types.yml'); + const yaml = yamlOf('repo-gates.yml'); expect(yaml).toContain('--build-filter'); expect(yaml, 'the 2026-08-16 ruling on objectui#4846 rejected a per-PR full-repo build').not.toMatch( /run: pnpm( exec turbo run)? build\s*$/m, @@ -1907,7 +1907,7 @@ describe('wiring — a script nothing runs is not a gate', () => { }); it('builds BEFORE it invokes the gate, so a precondition exit is not a state CI can reach', () => { - const yaml = yamlOf('doc-snippet-types.yml'); + const yaml = yamlOf('repo-gates.yml'); const build = yaml.indexOf('turbo run build'); const invoke = yaml.search(new RegExp(`run: node ${SCRIPT.replace(/[.\\/]/g, '\\$&')}\\s*$`, 'm')); expect(build, 'the workflow must build the packages the covered snippets import').toBeGreaterThan(-1); @@ -1981,13 +1981,13 @@ describe('wiring — a script nothing runs is not a gate', () => { */ describe('the build-filter steps propagate failure instead of silently building everything (objectui#6221)', () => { const stepScript = (name: string): string => { - const workflow = parseYaml(fs.readFileSync(path.join(repoRoot, '.github/workflows/doc-snippet-types.yml'), 'utf8')) as { + const workflow = parseYaml(fs.readFileSync(path.join(repoRoot, '.github/workflows/repo-gates.yml'), 'utf8')) as { jobs: Record; }; const step = Object.values(workflow.jobs) .flatMap((job) => job.steps ?? []) .find((s) => s.name === name); - expect(step?.run, `doc-snippet-types.yml must keep a step named "${name}" with a run: script`).toBeTypeOf( + expect(step?.run, `repo-gates.yml must keep a step named "${name}" with a run: script`).toBeTypeOf( 'string', ); return step!.run!; @@ -2021,12 +2021,20 @@ describe('the build-filter steps propagate failure instead of silently building }; }; + // objectui#12214 folded this gate into `repo-gates.yml`: its derive step is + // named after the check, and ONE build step serves the three gates that read + // built types. The empty-filter refusal moved into the derive step, because + // the shared build always carries `--filter='./packages/*'` and so can no + // longer turn an empty filter into an unfiltered workspace build. + const DERIVE = 'Doc Snippet Type Check — Derive the packages the covered snippets import'; + const BUILD = 'Build every package the gates below read'; const failingGate = '#!/usr/bin/env bash\necho "the filter could not be derived" >&2\nexit 3\n'; + const silentGate = '#!/usr/bin/env bash\nexit 0\n'; const healthyGate = '#!/usr/bin/env bash\necho "--filter=@object-ui/core --filter=@object-ui/react"\n'; const recordingPnpm = '#!/usr/bin/env bash\necho "$*" > "$TURBO_ARGV"\n'; it('fails the filter step when the gate fails, and writes no output at all', () => { - const result = runStep(stepScript('Derive the packages the covered snippets import'), { node: failingGate }); + const result = runStep(stepScript(DERIVE), { node: failingGate }); expect(result.status, 'a failed filter must not read as a successful step').not.toBe(0); expect(result.stderr, 'the step must say what failed, not just fail').toContain('--build-filter'); expect(result.stderr, 'a failure the log does not annotate is a failure someone has to go looking for').toContain( @@ -2039,33 +2047,40 @@ describe('the build-filter steps propagate failure instead of silently building }); it('writes the derived filter through unchanged when the gate succeeds', () => { - const result = runStep(stepScript('Derive the packages the covered snippets import'), { node: healthyGate }); + const result = runStep(stepScript(DERIVE), { node: healthyGate }); expect(result.status).toBe(0); expect(result.githubOutput.trim()).toBe('args=--filter=@object-ui/core --filter=@object-ui/react'); }); - it('refuses an empty filter in the build step rather than building the whole workspace', () => { - const result = runStep(stepScript('Build those packages'), { pnpm: recordingPnpm }, { FILTER_ARGS: '' }); + it('refuses an empty filter in the derive step, and writes no output', () => { + const result = runStep(stepScript(DERIVE), { node: silentGate }); expect(result.status, 'an empty filter can only mean something upstream went wrong').not.toBe(0); - expect(result.turboInvoked, 'turbo must not run at all on an empty filter').toBe(false); - expect(result.stderr, 'the refusal must be the step\'s own, not a shell error that happens to mention a filter').toContain( + expect(result.stderr, "the refusal must be the step's own, not a shell error that happens to mention a filter").toContain( '::error::', ); + expect(result.githubOutput).toBe(''); }); - it('hands turbo the derived packages as separate words when the filter is real', () => { - const result = runStep(stepScript('Build those packages'), { pnpm: recordingPnpm }, { - FILTER_ARGS: '--filter=@object-ui/core --filter=@object-ui/react', + it('hands turbo the derived packages as separate words, beside every package under packages/', () => { + const result = runStep(stepScript(BUILD), { pnpm: recordingPnpm }, { + SKILL_FILTER: '', + SNIPPET_FILTER: '--filter=@object-ui/core --filter=@object-ui/react', }); expect(result.status).toBe(0); expect(result.turboArgv).toBe( - 'exec turbo run build --filter=@object-ui/core --filter=@object-ui/react --concurrency=2', + 'exec turbo run build --filter=./packages/* --filter=@object-ui/core --filter=@object-ui/react', ); }); + it('never runs an unfiltered workspace build, even with no derived filter at all', () => { + const result = runStep(stepScript(BUILD), { pnpm: recordingPnpm }, { SKILL_FILTER: '', SNIPPET_FILTER: '' }); + expect(result.status).toBe(0); + expect(result.turboArgv).toBe('exec turbo run build --filter=./packages/*'); + }); + it('never puts the gate back inside a command substitution whose status is discarded', () => { expect( - stepScript('Derive the packages the covered snippets import'), + stepScript(DERIVE), 'the step status would be `echo`\'s again, and a failed gate would read as an empty filter', ).not.toMatch(/echo\s+"?args=\$\(/); }); diff --git a/scripts/__tests__/check-docs-route-eager-closure.test.ts b/scripts/__tests__/check-docs-route-eager-closure.test.ts index 32048b9c55..8fa497b86d 100644 --- a/scripts/__tests__/check-docs-route-eager-closure.test.ts +++ b/scripts/__tests__/check-docs-route-eager-closure.test.ts @@ -370,7 +370,7 @@ describe('the real tree -- the properties that keep this gate from being decorat describe('wiring -- the gate is actually reachable and actually runs', () => { const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); - const workflowPath = path.join(repoRoot, '.github/workflows/docs-route-eager-closure.yml'); + const workflowPath = path.join(repoRoot, '.github/workflows/repo-gates.yml'); it('is exposed as a root package script', () => { expect(pkg.scripts['check:docs-route-closure']).toBe( diff --git a/scripts/__tests__/check-eager-closure-budget.test.ts b/scripts/__tests__/check-eager-closure-budget.test.ts index d1e5b4e667..2dc6a9fa21 100644 --- a/scripts/__tests__/check-eager-closure-budget.test.ts +++ b/scripts/__tests__/check-eager-closure-budget.test.ts @@ -1897,7 +1897,7 @@ describe('main', () => { * purpose. `docs-route-eager-closure.yml` and `performance-budget.yml` are * not among this repo's required merge-queue contexts, so a regression in * the fold would not block a merge through the gate's own job. This file - * runs inside `Test (shard N/8)`, whose verdict is required through the + * runs inside `Test (shard N/12)`, whose verdict is required through the * `Test` aggregator since objectui#9499 (objectui#9098 landed * the same reasoning one card earlier). */ diff --git a/scripts/__tests__/check-pre-install-import-graph.test.ts b/scripts/__tests__/check-pre-install-import-graph.test.ts index 0559539904..4492714589 100644 --- a/scripts/__tests__/check-pre-install-import-graph.test.ts +++ b/scripts/__tests__/check-pre-install-import-graph.test.ts @@ -60,7 +60,7 @@ import { selfTestCases, stripAnsi } from './helpers/child-verdict'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const workflowDir = path.join(repoRoot, '.github/workflows'); const SCRIPT = 'scripts/check-pre-install-import-graph.mjs'; -const WORKFLOW = 'pre-install-import-graph.yml'; +const WORKFLOW = 'repo-gates.yml'; const CHECK_NAME = 'Pre-Install Import Graph Check'; /** A workflow's YAML with whole-line comments removed — every file here discusses the shapes in prose. */ @@ -264,10 +264,12 @@ describe('the real tree — every step objectui#6148 measured is still derived', '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', - 'control-bytes.yml : control-bytes -> scripts/check-control-bytes.mjs', - 'doc-component-types.yml : doc-component-types -> scripts/check-doc-component-types.mjs', - 'docs-links.yml : docs-links -> scripts/check-doc-links.mjs', - 'skills-paths.yml : skills-paths -> scripts/check-skills-paths.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', ]; @@ -312,9 +314,13 @@ describe('the gate is wired, not merely present', () => { expect(yaml.split('\n').some((l) => l.trim() === `node ${SCRIPT}`)).toBe(true); }); - it('needs no install, which is the property it exists to protect', () => { - expect(yaml).not.toMatch(/pnpm install/); - expect(yaml).not.toMatch(/corepack/); + it('runs before its job installs anything, which is the property it exists to protect', () => { + // Since objectui#12214 the home is `repo-gates.yml`, one job that installs + // AFTER its install-free gates; this gate must stay on the near side. + const gateAt = yaml.indexOf(`node ${SCRIPT} --self-test`); + const installAt = yaml.indexOf('pnpm install'); + expect(gateAt).toBeGreaterThan(-1); + expect(installAt === -1 || gateAt < installAt, 'the gate step moved below the install step').toBe(true); }); it('carries NO path filter of any kind', () => { @@ -341,7 +347,8 @@ describe('the gate is wired, not merely present', () => { // objectui#6135: an UNCLASSIFIED blocking check is one a Dependabot merge // would be let past. `dependabot-merge-gate.test.ts` asserts the partition // itself; this asserts the direction that matters for this check. - expect(REQUIRED_CONTEXTS).toContain(CHECK_NAME); + // Since objectui#12214 it reports as a step of the one `Repo Gates` context. + expect(REQUIRED_CONTEXTS).toContain('Repo Gates'); }); it('names the same check in the workflow as the gate requires', () => { diff --git a/scripts/__tests__/check-prompt-component-keys.test.ts b/scripts/__tests__/check-prompt-component-keys.test.ts index 8ac19b6dc7..da58cd26e3 100644 --- a/scripts/__tests__/check-prompt-component-keys.test.ts +++ b/scripts/__tests__/check-prompt-component-keys.test.ts @@ -317,9 +317,14 @@ describe('wiring', () => { }); it('needs no install, so it can afford to run unfiltered', () => { + // Since objectui#12214 the home is `repo-gates.yml`, ONE job that installs + // after its install-free gates: the property is now an ORDER -- this gate's + // step sits before the job's `pnpm install` -- and no longer an absence. const yaml = yamlOf(homes[0]); - expect(yaml).not.toContain('pnpm install'); - expect(yaml).not.toContain('corepack'); + const gateAt = yaml.indexOf('scripts/check-prompt-component-keys.mjs'); + const installAt = yaml.indexOf('pnpm install'); + expect(gateAt).toBeGreaterThan(-1); + expect(installAt === -1 || gateAt < installAt, 'the gate step moved below the install step').toBe(true); // Walk the WHOLE static import graph, not just this gate's first line: it // imports a sibling gate, and "needs no node_modules" has to stay true of diff --git a/scripts/__tests__/check-readme-exports.test.ts b/scripts/__tests__/check-readme-exports.test.ts index 33b99bdf92..8286160aec 100644 --- a/scripts/__tests__/check-readme-exports.test.ts +++ b/scripts/__tests__/check-readme-exports.test.ts @@ -1406,7 +1406,7 @@ describe('`--list` REFUSES on an unbuilt tree instead of dying in it (objectui#9 describe('wiring — the gate is reachable and every pull-request shape starts it', () => { const workflowDir = path.join(repoRoot, '.github/workflows'); const workflowFiles = fs.readdirSync(workflowDir).filter((f) => f.endsWith('.yml')); - const workflow = fs.readFileSync(path.join(workflowDir, 'readme-exports.yml'), 'utf8'); + const workflow = fs.readFileSync(path.join(workflowDir, 'repo-gates.yml'), 'utf8'); const uncommented = workflow .split('\n') .filter((line) => !/^\s*#/.test(line)) @@ -1421,7 +1421,7 @@ describe('wiring — the gate is reachable and every pull-request shape starts i const runners = workflowFiles.filter((file) => fs.readFileSync(path.join(workflowDir, file), 'utf8').includes('check:readme-exports'), ); - expect(runners).toEqual(['readme-exports.yml']); + expect(runners).toEqual(['repo-gates.yml']); }); it('carries NO trigger-level path filter — a README-only PR must start it', () => { @@ -1437,7 +1437,10 @@ describe('wiring — the gate is reachable and every pull-request shape starts i }); it('is classified as a required context, so a Dependabot merge waits for it', () => { - expect(REQUIRED_CONTEXTS).toContain('README Export Check'); + // Since objectui#12214 the check reports as a step of the ONE `Repo Gates` + // context, which is the name the classification and the floor read. + expect(REQUIRED_CONTEXTS).toContain('Repo Gates'); + expect(uncommented).toContain('name: Repo Gates'); expect(uncommented).toContain('name: README Export Check'); }); diff --git a/scripts/__tests__/check-shell-escape-residue.test.ts b/scripts/__tests__/check-shell-escape-residue.test.ts index 0be09f0699..cfa32071ba 100644 --- a/scripts/__tests__/check-shell-escape-residue.test.ts +++ b/scripts/__tests__/check-shell-escape-residue.test.ts @@ -687,7 +687,7 @@ describe('repo state — the gate is green on this tree, over a real population' describe('wiring — the gate is reachable and every PR shape starts it', () => { const SCRIPT = 'scripts/check-shell-escape-residue.mjs'; - const WORKFLOW = 'shell-escape-residue.yml'; + const WORKFLOW = 'repo-gates.yml'; const CHECK_NAME = 'Shell Escape Residue Scan'; const workflowDir = path.join(repoRoot, '.github/workflows'); const workflowFiles = fs.readdirSync(workflowDir).filter((f) => f.endsWith('.yml')); @@ -727,7 +727,9 @@ describe('wiring — the gate is reachable and every PR shape starts it', () => // `REQUIRED_CONTEXTS` to decide which workflows must subscribe the queue, // and `dependabot-merge-gate.mjs` would let a Dependabot merge past an // unclassified blocking check (objectui#6135). - expect(REQUIRED_CONTEXTS).toContain(CHECK_NAME); + // Since objectui#12214 the check reports as a step of the ONE `Repo Gates` + // context, which is the name the classification and the floor read. + expect(REQUIRED_CONTEXTS).toContain('Repo Gates'); }); it('runs it in NO path-filtered workflow', () => { @@ -747,8 +749,13 @@ describe('wiring — the gate is reachable and every PR shape starts it', () => }); it('installs nothing before running the gate — the cheap tier, mechanically', () => { + // Since objectui#12214 the home is `repo-gates.yml`, ONE job that installs + // after its install-free gates: the property is now an ORDER -- this gate's + // step sits before the job's `pnpm install` -- and no longer an absence. const yaml = yamlOf(WORKFLOW); - expect(yaml).not.toMatch(/pnpm install/); - expect(yaml.indexOf(SCRIPT)).toBeGreaterThan(-1); + const gateAt = yaml.indexOf(SCRIPT); + const installAt = yaml.indexOf('pnpm install'); + expect(gateAt).toBeGreaterThan(-1); + expect(installAt === -1 || gateAt < installAt, 'the gate step moved below the install step').toBe(true); }); }); diff --git a/scripts/__tests__/check-skill-eval-tokens.test.ts b/scripts/__tests__/check-skill-eval-tokens.test.ts index ab1bd1a192..b84eb36017 100644 --- a/scripts/__tests__/check-skill-eval-tokens.test.ts +++ b/scripts/__tests__/check-skill-eval-tokens.test.ts @@ -364,7 +364,7 @@ describe('the real corpus', () => { describe('wiring — the gate is reachable and a markdown-only PR starts it', () => { const SCRIPT = 'scripts/check-skill-eval-tokens.mjs'; const workflowDir = path.join(repoRoot, '.github/workflows'); - const workflowPath = path.join(workflowDir, 'skill-eval-tokens.yml'); + const workflowPath = path.join(workflowDir, 'repo-gates.yml'); const workflowFiles = fs.readdirSync(workflowDir).filter((f) => f.endsWith('.yml')); /** @@ -387,7 +387,7 @@ describe('wiring — the gate is reachable and a markdown-only PR starts it', () it('has a workflow that gates pull requests, not just pushes', () => { expect(fs.existsSync(workflowPath), 'a check nothing runs is not a gate').toBe(true); - const yaml = yamlOf('skill-eval-tokens.yml'); + const yaml = yamlOf('repo-gates.yml'); expect(yaml).toMatch(new RegExp(`run:\\s*node\\s+${SCRIPT.replace(/[.]/g, '\\.')}\\s*$`, 'm')); expect(yaml).toMatch(/^\s*pull_request:/m); expect(yaml).toMatch(/^\s*push:/m); @@ -395,7 +395,7 @@ describe('wiring — the gate is reachable and a markdown-only PR starts it', () }); it('runs the self-test too — a probe nobody runs is a probe that passes', () => { - expect(yamlOf('skill-eval-tokens.yml')).toMatch( + expect(yamlOf('repo-gates.yml')).toMatch( new RegExp(`run:\\s*node\\s+${SCRIPT.replace(/[.]/g, '\\.')}\\s+--self-test`), ); }); @@ -406,9 +406,16 @@ describe('wiring — the gate is reachable and a markdown-only PR starts it', () // pre-install step, so the whole static import graph must stay free of // `node_modules`. Folding this gate into `check-skill-examples.mjs` would // have lost exactly this, since that one exits 2 on an unbuilt tree. - const yaml = yamlOf('skill-eval-tokens.yml'); - expect(yaml).not.toMatch(/pnpm install/); - expect(yaml).not.toMatch(/turbo run build/); + // Since objectui#12214 the home is `repo-gates.yml`, ONE job that installs + // after its install-free gates: the property is now an ORDER -- this gate's + // step sits before the job's `pnpm install` -- and no longer an absence. + const yaml = yamlOf('repo-gates.yml'); + const gateAt = yaml.indexOf(`run: node ${SCRIPT} --self-test`); + expect(gateAt).toBeGreaterThan(-1); + for (const marker of ['pnpm install', 'turbo run build']) { + const at = yaml.indexOf(marker); + expect(at === -1 || gateAt < at, `the gate step moved below \`${marker}\``).toBe(true); + } expect(yaml).not.toMatch(/pnpm build/); }); @@ -425,19 +432,28 @@ describe('wiring — the gate is reachable and a markdown-only PR starts it', () }); it('has exactly one home', () => { - expect(workflowFiles.filter((f) => yamlOf(f).includes(SCRIPT))).toEqual(['skill-eval-tokens.yml']); + expect(workflowFiles.filter((f) => yamlOf(f).includes(SCRIPT))).toEqual(['repo-gates.yml']); }); it('does not run a NEIGHBOUR gate from this workflow', () => { // `check-skill-examples.test.ts` pins that its own script lives in exactly // one workflow. Invoking it from this YAML would break that pin and give // one gate two homes. - expect(yamlOf('skill-eval-tokens.yml')).not.toContain('scripts/check-skill-examples.mjs'); + // Since objectui#12214 every gate shares `repo-gates.yml`, so "this + // workflow" is now this gate's OWN steps: the ones named after its check. + const own = yamlOf('repo-gates.yml') + .split(/^(?= {6}- name: )/m) + .filter((step) => step.startsWith(' - name: Skill Eval Token Check')); + expect(own.length).toBeGreaterThan(0); + for (const step of own) expect(step).not.toContain('scripts/check-skill-examples.mjs'); }); it('is classified as a blocking context rather than defaulting into silence', () => { + // Since objectui#12214 the check reports as a step of the ONE `Repo Gates` + // context, which is the name the classification and the floor read. const gate = fs.readFileSync(path.join(repoRoot, 'scripts/dependabot-merge-gate.mjs'), 'utf8'); - expect(gate).toContain("'Skill Eval Token Check'"); + expect(gate).toContain("'Repo Gates'"); + expect(yamlOf('repo-gates.yml')).toContain('name: Skill Eval Token Check'); }); it('has a section on the CI page, named by heading', () => { @@ -446,6 +462,6 @@ describe('wiring — the gate is reachable and a markdown-only PR starts it', () // the workflow is added, not later. const doc = fs.readFileSync(path.join(repoRoot, 'content/docs/guide/ci-cd-pipeline.md'), 'utf8'); const headings = doc.split('\n').filter((line) => /^#{1,6}\s/.test(line)); - expect(headings.some((h) => h.includes('skill-eval-tokens.yml'))).toBe(true); + expect(headings.some((h) => h.includes('repo-gates.yml'))).toBe(true); }); }); diff --git a/scripts/__tests__/check-skill-examples.test.ts b/scripts/__tests__/check-skill-examples.test.ts index 7e142c8874..cba5108d05 100644 --- a/scripts/__tests__/check-skill-examples.test.ts +++ b/scripts/__tests__/check-skill-examples.test.ts @@ -51,7 +51,7 @@ import { * * Everything here runs on an UNBUILT tree: the fence scanner, the marker * convention, the JSON dialects, and the wiring. That boundary is not tidiness — - * this suite runs inside `ci.yml`'s `Test (shard N/8)` jobs, which do not build + * this suite runs inside `ci.yml`'s `Test (shard N/12)` jobs, which do not build * the workspace, and a test that needed `dist/*.d.ts` would either be flaky or * would quietly assert nothing there. * @@ -645,7 +645,7 @@ describe('the printed build command says what it does NOT build (objectui#7811)' describe('wiring — the gate is reachable and a markdown-only PR starts it', () => { const SCRIPT = 'scripts/check-skill-examples.mjs'; const workflowDir = path.join(repoRoot, '.github/workflows'); - const workflowPath = path.join(workflowDir, 'skill-examples.yml'); + const workflowPath = path.join(workflowDir, 'repo-gates.yml'); const workflowFiles = fs.readdirSync(workflowDir).filter((f) => f.endsWith('.yml')); /** @@ -668,7 +668,7 @@ describe('wiring — the gate is reachable and a markdown-only PR starts it', () it('has a workflow that gates pull requests, not just pushes', () => { expect(fs.existsSync(workflowPath), 'a check nothing runs is not a gate').toBe(true); - const yaml = yamlOf('skill-examples.yml'); + const yaml = yamlOf('repo-gates.yml'); expect(yaml).toMatch(new RegExp(`run:\\s*node\\s+${SCRIPT.replace(/[.]/g, '\\.')}\\s*$`, 'm')); expect(yaml).toMatch(/^\s*pull_request:/m); expect(yaml).toMatch(/^\s*push:/m); @@ -676,13 +676,13 @@ describe('wiring — the gate is reachable and a markdown-only PR starts it', () }); it('runs the self-test too — a probe nobody runs is a probe that passes', () => { - expect(yamlOf('skill-examples.yml')).toMatch( + expect(yamlOf('repo-gates.yml')).toMatch( new RegExp(`run:\\s*node\\s+${SCRIPT.replace(/[.]/g, '\\.')}\\s+--self-test`), ); }); it('derives its build filter from the gate rather than hand-maintaining one', () => { - const yaml = yamlOf('skill-examples.yml'); + const yaml = yamlOf('repo-gates.yml'); expect(yaml).toContain('--build-filter'); // ⛔ The one thing the header forbids: an unfiltered workspace build. expect(yaml).not.toMatch(/turbo run build\s*$/m); @@ -709,7 +709,7 @@ describe('wiring — the gate is reachable and a markdown-only PR starts it', () it('has exactly one home', () => { // A second copy in a path-filtered workflow is how a gate ends up looking // covered while the change it exists for still slips past. - expect(workflowFiles.filter((f) => yamlOf(f).includes(SCRIPT))).toEqual(['skill-examples.yml']); + expect(workflowFiles.filter((f) => yamlOf(f).includes(SCRIPT))).toEqual(['repo-gates.yml']); }); it('does not run a NEIGHBOUR gate from this workflow', () => { @@ -717,7 +717,13 @@ describe('wiring — the gate is reachable and a markdown-only PR starts it', () // exactly one workflow. This gate imports that script's harness as a MODULE, // which is the intended reuse; invoking it from this YAML would break that // pin and give one gate two homes. - expect(yamlOf('skill-examples.yml')).not.toContain('scripts/check-doc-snippet-types.mjs'); + // Since objectui#12214 every gate shares `repo-gates.yml`, so "this + // workflow" is now this gate's OWN steps: the ones named after its check. + const own = yamlOf('repo-gates.yml') + .split(/^(?= {6}- name: )/m) + .filter((step) => step.startsWith(' - name: Skill Example Check')); + expect(own.length).toBeGreaterThan(0); + for (const step of own) expect(step).not.toContain('scripts/check-doc-snippet-types.mjs'); }); it('is classified as a blocking context rather than defaulting into silence', () => { @@ -725,8 +731,11 @@ describe('wiring — the gate is reachable and a markdown-only PR starts it', () // produced check-run set exactly, and `merge-queue-reporting.test.ts` // derives the `merge_group` floor from the required bucket. Asserted here // too, in this gate's own file, so the reason travels with the gate. + // Since objectui#12214 the check reports as a step of the ONE `Repo Gates` + // context, which is the name the classification and the floor read. const gate = fs.readFileSync(path.join(repoRoot, 'scripts/dependabot-merge-gate.mjs'), 'utf8'); - expect(gate).toContain("'Skill Example Check'"); + expect(gate).toContain("'Repo Gates'"); + expect(yamlOf('repo-gates.yml')).toContain('name: Skill Example Check'); }); it('has a section on the CI page, named by heading', () => { @@ -735,7 +744,7 @@ describe('wiring — the gate is reachable and a markdown-only PR starts it', () // the workflow is added, not later. const doc = fs.readFileSync(path.join(repoRoot, 'content/docs/guide/ci-cd-pipeline.md'), 'utf8'); const headings = doc.split('\n').filter((line) => /^#{1,6}\s/.test(line)); - expect(headings.some((h) => h.includes('skill-examples.yml'))).toBe(true); + expect(headings.some((h) => h.includes('repo-gates.yml'))).toBe(true); }); }); diff --git a/scripts/__tests__/check-skills-paths.test.ts b/scripts/__tests__/check-skills-paths.test.ts index 93d2c8564f..5d9df32999 100644 --- a/scripts/__tests__/check-skills-paths.test.ts +++ b/scripts/__tests__/check-skills-paths.test.ts @@ -509,7 +509,7 @@ describe('objectui#3713 / #3730 — the guide those two rounds corrected by hand describe('wiring — the gate is reachable and a markdown-only PR starts it', () => { const SCRIPT = 'scripts/check-skills-paths.mjs'; const workflowDir = path.join(repoRoot, '.github/workflows'); - const workflowPath = path.join(workflowDir, 'skills-paths.yml'); + const workflowPath = path.join(workflowDir, 'repo-gates.yml'); const workflowFiles = fs.readdirSync(workflowDir).filter((f) => f.endsWith('.yml')); /** @@ -532,7 +532,7 @@ describe('wiring — the gate is reachable and a markdown-only PR starts it', () it('has a workflow that gates pull requests, not just pushes', () => { expect(fs.existsSync(workflowPath), 'a check nothing runs is not a gate').toBe(true); - const yaml = yamlOf('skills-paths.yml'); + const yaml = yamlOf('repo-gates.yml'); expect(yaml).toMatch(new RegExp(`run:\\s*node\\s+${SCRIPT.replace(/[.]/g, '\\.')}`)); expect(yaml).toMatch(/^\s*pull_request:/m); expect(yaml).toMatch(/^\s*push:/m); @@ -559,7 +559,7 @@ describe('wiring — the gate is reachable and a markdown-only PR starts it', () it('has exactly one home', () => { // A second copy in a path-filtered workflow is how a gate ends up looking // covered while the change it exists for still slips past. - expect(workflowFiles.filter((f) => yamlOf(f).includes(SCRIPT))).toEqual(['skills-paths.yml']); + expect(workflowFiles.filter((f) => yamlOf(f).includes(SCRIPT))).toEqual(['repo-gates.yml']); }); it('ships its baseline next to the script', () => { diff --git a/scripts/__tests__/check-test-path-roots.test.ts b/scripts/__tests__/check-test-path-roots.test.ts index a4c15be95a..b2b46d826a 100644 --- a/scripts/__tests__/check-test-path-roots.test.ts +++ b/scripts/__tests__/check-test-path-roots.test.ts @@ -4,7 +4,7 @@ * * ## Why the enforcement lives HERE and not only in a workflow step * - * Every test file under `scripts` runs inside `Test (shard N/8)`, reported by + * Every test file under `scripts` runs inside `Test (shard N/12)`, reported by * the `Test` aggregator (objectui#9499), which is a * REQUIRED context and subscribes `merge_group` — so the scan below is what * actually blocks a queue build. The `Lint` step added alongside it is a second, faster diff --git a/scripts/__tests__/check-test-shard-results.test.ts b/scripts/__tests__/check-test-shard-results.test.ts index 4e5914711a..700d39cf04 100644 --- a/scripts/__tests__/check-test-shard-results.test.ts +++ b/scripts/__tests__/check-test-shard-results.test.ts @@ -379,8 +379,10 @@ describe('ci.yml wires the gate to its own matrix (#9499)', () => { it('needs every job whose result it speaks for, and reports whatever they did', () => { expect(aggregate).toMatch(/^ {4}needs: \[test, test-dist-pins\]$/m); // `always()` — without it a red or skipped shard SKIPS this job, and a - // skipped required context counts as SUCCESS in branch protection. - expect(aggregate).toMatch(/^ {4}if: always\(\) && github\.event_name != 'push'$/m); + // skipped required context counts as SUCCESS in branch protection. No + // event guard since objectui#12214: on `push` and `schedule` this is the + // full battery's test verdict, read the same way. + expect(aggregate).toMatch(/^ {4}if: always\(\)$/m); // `actions: read` is what the API read needs; a `permissions:` block sets // every unlisted scope to `none`, so its absence is a 403 and exit 2. expect(aggregate).toMatch(/^ {6}actions: read$/m); diff --git a/scripts/__tests__/check-vi-mock-inherit.test.ts b/scripts/__tests__/check-vi-mock-inherit.test.ts index 5af03ae5b5..9687cdc96b 100644 --- a/scripts/__tests__/check-vi-mock-inherit.test.ts +++ b/scripts/__tests__/check-vi-mock-inherit.test.ts @@ -1106,7 +1106,7 @@ describe('repo state — the gate is green on this tree', () => { describe('wiring — the gate is reachable and every PR shape starts it', () => { const SCRIPT = 'scripts/check-vi-mock-inherit.mjs'; - const WORKFLOW = 'vi-mock-specifiers.yml'; + const WORKFLOW = 'repo-gates.yml'; const workflowDir = path.join(repoRoot, '.github/workflows'); const workflowFiles = fs.readdirSync(workflowDir).filter((f) => f.endsWith('.yml')); @@ -1151,9 +1151,14 @@ describe('wiring — the gate is reachable and every PR shape starts it', () => }); it('installs nothing before running the gate — the cheap tier, mechanically', () => { + // Since objectui#12214 the home is `repo-gates.yml`, ONE job that installs + // after its install-free gates: the property is now an ORDER -- this gate's + // step sits before the job's `pnpm install` -- and no longer an absence. const yaml = yamlOf(WORKFLOW); - expect(yaml).not.toMatch(/pnpm install/); - expect(yaml.indexOf(SCRIPT)).toBeGreaterThan(-1); + const gateAt = yaml.indexOf(SCRIPT); + const installAt = yaml.indexOf('pnpm install'); + expect(gateAt).toBeGreaterThan(-1); + expect(installAt === -1 || gateAt < installAt, 'the gate step moved below the install step').toBe(true); }); }); diff --git a/scripts/__tests__/check-vi-mock-override-shape.test.ts b/scripts/__tests__/check-vi-mock-override-shape.test.ts index 8ee5b0526d..3e4f806410 100644 --- a/scripts/__tests__/check-vi-mock-override-shape.test.ts +++ b/scripts/__tests__/check-vi-mock-override-shape.test.ts @@ -473,7 +473,7 @@ describe('wiring', () => { // gate reaches the package `typescript`, and that workflow deliberately has // no install step. Measured on this tree by injecting the same import into // `check-vi-mock-inherit.mjs`: that gate went from exit 0 to exit 1. - const sibling = fs.readFileSync(path.join(repoRoot, '.github/workflows/vi-mock-specifiers.yml'), 'utf8'); + const sibling = fs.readFileSync(path.join(repoRoot, '.github/workflows/repo-gates.yml'), 'utf8'); expect(sibling).not.toContain(GATE); }); diff --git a/scripts/__tests__/check-vi-mock-specifiers.test.ts b/scripts/__tests__/check-vi-mock-specifiers.test.ts index 2e69641d2c..f74de8a56c 100644 --- a/scripts/__tests__/check-vi-mock-specifiers.test.ts +++ b/scripts/__tests__/check-vi-mock-specifiers.test.ts @@ -447,7 +447,7 @@ describe('objectui#5646 — the real file the instance was written in', () => { describe('wiring — the gate is reachable and every PR shape starts it', () => { const SCRIPT = 'scripts/check-vi-mock-specifiers.mjs'; - const WORKFLOW = 'vi-mock-specifiers.yml'; + const WORKFLOW = 'repo-gates.yml'; const workflowDir = path.join(repoRoot, '.github/workflows'); const workflowFiles = fs.readdirSync(workflowDir).filter((f) => f.endsWith('.yml')); @@ -503,8 +503,13 @@ describe('wiring — the gate is reachable and every PR shape starts it', () => }); it('installs nothing before running the gate — the cheap tier, mechanically', () => { + // Since objectui#12214 the home is `repo-gates.yml`, ONE job that installs + // after its install-free gates: the property is now an ORDER -- this gate's + // step sits before the job's `pnpm install` -- and no longer an absence. const yaml = yamlOf(WORKFLOW); - expect(yaml).not.toMatch(/pnpm install/); - expect(yaml.indexOf(SCRIPT)).toBeGreaterThan(-1); + const gateAt = yaml.indexOf(SCRIPT); + const installAt = yaml.indexOf('pnpm install'); + expect(gateAt).toBeGreaterThan(-1); + expect(installAt === -1 || gateAt < installAt, 'the gate step moved below the install step').toBe(true); }); }); diff --git a/scripts/__tests__/ci-cd-pipeline-doc.test.ts b/scripts/__tests__/ci-cd-pipeline-doc.test.ts index d11ffcff80..5ec787e515 100644 --- a/scripts/__tests__/ci-cd-pipeline-doc.test.ts +++ b/scripts/__tests__/ci-cd-pipeline-doc.test.ts @@ -920,6 +920,37 @@ function commandParity( }; } +/** + * objectui#12214: fifteen one-check workflows became steps of ONE job in + * `repo-gates.yml`, each step named ` — `. A section that documents + * one of those checks is paired against the steps that carry its check name -- + * plus, for the gates that read built types, the job's one shared build step, + * which runs on their behalf -- never against the whole job, which would credit + * every section with every other gate's commands. + */ +const REPO_GATES_SHARED_BUILD_STEP = 'Build every package the gates below read'; +function checkParity( + checkName: string, + documentation: string, + label: string, + { withSharedBuild = false }: { withSharedBuild?: boolean } = {}, +): CommandParity { + const yaml = fs.readFileSync(path.join(workflowDir, 'repo-gates.yml'), 'utf8'); + const steps = jobBlock(yaml, 'repo-gates', 'repo-gates.yml') + .split(/^(?= {6}- name: )/m) + .filter( + (step) => + step.startsWith(` - name: ${checkName} — `) || + (withSharedBuild && step.startsWith(` - name: ${REPO_GATES_SHARED_BUILD_STEP}`)), + ); + expect(steps.length, `repo-gates.yml has no step named after ${checkName}`).toBeGreaterThan(0); + return { + label, + ran: firstPartyCommands(runSteps(steps.join('\n')).join('\n')), + named: firstPartyCommands(documentation), + }; +} + /** Commands a job runs that the prose documenting it does not name. */ function undocumentedCommands(units: CommandParity[]): string[] { return units.flatMap((u) => [...u.ran].filter((c) => !u.named.has(c)).map((c) => `${u.label}: ${c}`)); @@ -2662,17 +2693,16 @@ function section(heading: string): string { * the two spellings are one gate. */ describe('ci-cd-pipeline.md — the vi-mock and shadcn sections', () => { - const VI_MOCK_HEADING = '## Inert vi.mock Specifiers (`vi-mock-specifiers.yml`)'; + const VI_MOCK_HEADING = '### Inert vi.mock Specifiers — the Inert vi.mock Specifier Check step'; const SHADCN_HEADING = '### Shadcn Component Check (`shadcn-check.yml`)'; function units(): CommandParity[] { return [ byGate( - commandParity( - 'vi-mock-specifiers.yml', - 'vi-mock-specifiers', + checkParity( + 'Inert vi.mock Specifier Check', section(VI_MOCK_HEADING), - 'vi-mock-specifiers.yml `vi-mock-specifiers`', + 'repo-gates.yml `Inert vi.mock Specifier Check`', ), ), byGate( @@ -2715,7 +2745,8 @@ describe('ci-cd-pipeline.md — the vi-mock and shadcn sections', () => { // no assertion here reads and no section here documents, so it comes through // this test first — the shape `lint.yml`'s pin uses, one workflow each. for (const [file, keys] of [ - ['vi-mock-specifiers.yml', ['vi-mock-specifiers']], + // objectui#12214: the vi.mock gates are steps of `repo-gates.yml`'s one job. + ['repo-gates.yml', ['repo-gates']], ['shadcn-check.yml', ['check-components']], ] as const) { expect( @@ -2875,7 +2906,7 @@ const DECLARED_NON_RUN_COMMANDS = new Map([ */ describe('ci-cd-pipeline.md — the four sections measured as parity defects', () => { const PERF_HEADING = '## Performance Budget (`performance-budget.yml`)'; - const SKILL_EXAMPLES_HEADING = '## Skill Examples (`skill-examples.yml`)'; + const SKILL_EXAMPLES_HEADING = '### Skill Examples — the Skill Example Check step'; const CHANGESET_RELEASE_HEADING = '### Changeset Release (`changeset-release.yml`)'; const SPEC_RANGE_FLOORS_HEADING = '### Spec Range Floors (`spec-range-floors.yml`)'; @@ -2886,15 +2917,23 @@ describe('ci-cd-pipeline.md — the four sections measured as parity defects', ( */ const PINNED = [ ['performance-budget.yml', 'bundle-analysis', PERF_HEADING], - ['skill-examples.yml', 'skill-examples', SKILL_EXAMPLES_HEADING], ['changeset-release.yml', 'release', CHANGESET_RELEASE_HEADING], ['spec-range-floors.yml', 'spec-range-floors', SPEC_RANGE_FLOORS_HEADING], ] as const; function units(): CommandParity[] { - return PINNED.map(([file, job, heading]) => - byGate(commandParity(file, job, section(heading), `${file} \`${job}\``)), - ); + return [ + ...PINNED.map(([file, job, heading]) => + byGate(commandParity(file, job, section(heading), `${file} \`${job}\``)), + ), + // objectui#12214: the skill-example gate is a set of `repo-gates.yml` steps, + // and its `turbo run build` is the job's shared build step. + byGate( + checkParity('Skill Example Check', section(SKILL_EXAMPLES_HEADING), 'repo-gates.yml `Skill Example Check`', { + withSharedBuild: true, + }), + ), + ]; } it('documents every job these four workflows define', () => { @@ -2902,7 +2941,7 @@ describe('ci-cd-pipeline.md — the four sections measured as parity defects', ( // reads and no section here documents, so it comes through this test first. for (const [file, keys] of [ ['performance-budget.yml', ['bundle-analysis']], - ['skill-examples.yml', ['skill-examples']], + ['repo-gates.yml', ['repo-gates']], ['changeset-release.yml', ['lane', 'release']], ['spec-range-floors.yml', ['spec-range-floors']], ] as const) { @@ -3196,8 +3235,8 @@ describe('ci-cd-pipeline.md — the four sections measured as parity defects', ( * would be a repair with nothing able to notice it come back. */ describe('ci-cd-pipeline.md — populations are pointed at, never counted in prose', () => { - const EAGER_HEADING = '## Docs Route Eager Closure (`docs-route-eager-closure.yml`)'; - const RESIDUE_HEADING = '## Shell Escape Residue (`shell-escape-residue.yml`)'; + const EAGER_HEADING = '### Docs Route Eager Closure — the Docs Route Eager Closure Check step'; + const RESIDUE_HEADING = '### Shell Escape Residue — the Shell Escape Residue Scan step'; const PATROL_HEADING = '### Half-State Patrol (`half-state-patrol.yml`)'; /** A section of the page as one line — the unit every assertion here judges. */ @@ -3563,7 +3602,8 @@ const SWEEP_DECLARED_NON_RUN_COMMANDS = new Map([ [ 'check-links.yml: pnpm docs:check-links', "The SIBLING checker, named in this section's two-checker comparison table whose own `Runs` " + - 'column says `docs-links.yml`. ⚠️ Dual-eligible: this job has no `run:` step at all (its ' + + "column names `repo-gates.yml`'s Internal Docs Link Check. ⚠️ Dual-eligible: this job has " + + 'no `run:` step at all (its ' + 'work is `lycheeverse/lychee-action`), so the rule reads no command on the workflow side ' + 'either.', ], @@ -3575,12 +3615,18 @@ const SWEEP_DECLARED_NON_RUN_COMMANDS = new Map([ 'job builds (objectui#12178).', ], [ - 'docs-route-eager-closure.yml: scripts/dependabot-merge-gate.mjs', + 'repo-gates.yml: scripts/check-required-check-set.mjs', + 'Named as the one thing in this tree that can READ the merge queue\'s required set, in the ' + + 'sentence saying none of the folded gates was in it (objectui#12214). A different ' + + "workflow's instrument, cited to keep the claim falsifiable.", + ], + [ + 'repo-gates.yml: scripts/dependabot-merge-gate.mjs', 'Named as the registry whose `REQUIRED_CONTEXTS` declares this context blocking — the ' + 'declaration, not a command.', ], [ - 'docs-route-eager-closure.yml: pnpm check:eager-closure', + 'repo-gates.yml: pnpm check:eager-closure', 'Named expressly to say it weighs the CONSOLE and not this route: "The cards that added to ' + 'that list said the cost was governed by `check:eager-closure`. It was not." Deleting it ' + 'would delete the correction.', @@ -3652,12 +3698,12 @@ const SWEEP_DECLARED_NON_RUN_COMMANDS = new Map([ 'so it runs per pull request in Type Check, not in this workflow".', ], [ - 'pre-install-import-graph.yml: scripts/invoked-as.mjs', + 'repo-gates.yml: scripts/invoked-as.mjs', 'Named as the module every pre-install script imports since objectui#6092 — data the gate ' + 'walks, and the tail of the illustrative failure chain under "If it fails".', ], [ - 'pre-install-import-graph.yml: scripts/some-gate.mjs', + 'repo-gates.yml: scripts/some-gate.mjs', 'An illustrative PLACEHOLDER in the example failure chain ' + '`scripts/some-gate.mjs -> scripts/invoked-as.mjs -> typescript`. No such file exists.', ], diff --git a/scripts/__tests__/dependabot-merge-gate.test.ts b/scripts/__tests__/dependabot-merge-gate.test.ts index 9e160d327c..39ad76a0fc 100644 --- a/scripts/__tests__/dependabot-merge-gate.test.ts +++ b/scripts/__tests__/dependabot-merge-gate.test.ts @@ -162,6 +162,13 @@ const RENAMED_SINCE_4959 = [ 'Test (shard 2/4)', 'Test (shard 3/4)', 'Test (shard 4/4)', + // objectui#12214 folded the one-check gate workflows into `repo-gates.yml`: + // these four are STEP names of the one `Repo Gates` context now, and no + // workflow produces a check run by these names any more. + 'Skill Guide Path Check', + 'Control Byte Scan', + 'Internal Docs Link Check', + 'Doc Component Type Check', ]; /** @@ -323,13 +330,15 @@ describe('absence is never green (#3523 in mirror image)', () => { }); it('refuses a required context that reported `skipped`', () => { + // `Repo Gates` since objectui#12214 folded `Control Byte Scan`, the context + // this case used to skip, into it. const runs = allGreenSnapshot().map((run) => - run.name === 'Control Byte Scan' ? { ...run, conclusion: 'skipped' } : run, + run.name === 'Repo Gates' ? { ...run, conclusion: 'skipped' } : run, ); const result = evaluateGate({ checkRuns: runs }); expect(result.verdict).toBe('red'); - expect(result.failing).toEqual(['Control Byte Scan (skipped)']); + expect(result.failing).toEqual(['Repo Gates (skipped)']); }); it('refuses `cancelled` and `timed_out` as well as `failure`', () => { @@ -565,7 +574,7 @@ describe('the declared buckets partition what a pull request actually produces', it('found the workflows and the shard matrix (the parser still parses)', () => { expect(workflows.map((w) => w.file)).toContain('ci.yml'); expect([...produced.keys()]).toEqual( - expect.arrayContaining(['Test (shard 1/8)', 'Test (shard 8/8)', 'Test', 'Lint', 'dependabot', 'label']), + expect.arrayContaining(['Test (shard 1/12)', 'Test (shard 12/12)', 'Test', 'Lint', 'dependabot', 'label']), ); // No unexpanded template survived the matrix expansion. for (const name of produced.keys()) expect(name).not.toContain('${{'); diff --git a/scripts/__tests__/docs-links-workflow.test.ts b/scripts/__tests__/docs-links-workflow.test.ts index 3d4efabc60..4b40d042ee 100644 --- a/scripts/__tests__/docs-links-workflow.test.ts +++ b/scripts/__tests__/docs-links-workflow.test.ts @@ -43,7 +43,7 @@ import { fileURLToPath } from 'node:url'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const workflowDir = path.join(repoRoot, '.github/workflows'); -const workflowPath = path.join(workflowDir, 'docs-links.yml'); +const workflowPath = path.join(workflowDir, 'repo-gates.yml'); const SCRIPT = 'scripts/check-doc-links.mjs'; /** @@ -98,7 +98,7 @@ describe('docs-links.yml — the internal link gate is reachable', () => { const runners = workflowFiles.filter((f) => withoutComments(fs.readFileSync(path.join(workflowDir, f), 'utf8')).includes(SCRIPT), ); - expect(runners).toEqual(['docs-links.yml']); + expect(runners).toEqual(['repo-gates.yml']); }); it('every workflow that runs it is one a docs-only PR can start', () => { diff --git a/scripts/__tests__/merge-queue-reporting.test.ts b/scripts/__tests__/merge-queue-reporting.test.ts index cb44b1b553..e65c9803f6 100644 --- a/scripts/__tests__/merge-queue-reporting.test.ts +++ b/scripts/__tests__/merge-queue-reporting.test.ts @@ -79,8 +79,6 @@ const doc = fs.readFileSync(path.join(repoRoot, DOC), 'utf8'); const MUST_SUBSCRIBE_MERGE_GROUP = new Map([ ['ci.yml', 'produces Type Check, Build & E2E, Test (the shard aggregator) and Changeset Fixed Group Check'], ['lint.yml', 'produces Lint — the ESLint error ratchets'], - ['control-bytes.yml', 'produces Control Byte Scan, one of the two contexts #3523 found safe to require today'], - ['docs-links.yml', 'produces Internal Docs Link Check, the other one'], [ 'changeset-presence.yml', 'produces Changeset Declaration — added by objectui#3387 with no path filter at all, for the ' + @@ -88,49 +86,13 @@ const MUST_SUBSCRIBE_MERGE_GROUP = new Map([ 'requirable context that skips the queue build stalls it', ], [ - 'skills-paths.yml', - 'produces Skill Guide Path Check — added by objectui#3735, same shape as the two above: its ' + - 'entire scan surface is markdown, so it carries no path filter, reports on every pull ' + - 'request, and is therefore requirable', - ], - [ - 'skill-examples.yml', - 'produces Skill Example Check — added by objectui#7359. Its scan surface is the markdown under ' + - '`skills/`, the shape `ci.yml` and `lint.yml` structurally cannot see (both list `**/*.md` ' + - 'under the `paths-ignore` of their `push` trigger), so it carries no path filter, reports on ' + - 'every pull request, and is therefore requirable; `scripts/dependabot-merge-gate.mjs` ' + - 'classifies it as a required context', - ], - [ - 'skill-eval-tokens.yml', - 'produces Skill Eval Token Check — added by objectui#7461. It holds every eval assertion\'s ' + - '`must_contain` token to the guides its own skill bundle ships, and its whole input is the ' + - 'markdown and JSON under `skills/` — the shape `ci.yml` and `lint.yml` structurally cannot ' + - 'see (both list `**/*.md` under the `paths-ignore` of their `push` trigger), so it carries ' + - 'no path filter, reports on every pull request, and is therefore requirable; ' + - '`scripts/dependabot-merge-gate.mjs` classifies it as a required context', - ], - [ - 'pre-install-import-graph.yml', - 'produces Pre-Install Import Graph Check — added by objectui#6148. What it judges is the ' + - 'arrangement of the workflows themselves, so it carries no path filter, reports on every ' + - 'pull request, and is requirable; `scripts/dependabot-merge-gate.mjs` already classifies ' + - 'it as a required context', - ], - [ - 'vi-mock-specifiers.yml', - 'produces Inert vi.mock Specifier Check — added by objectui#5646. A module mock can be ' + - 'written into any package in any shape of pull request, and the gate costs a checkout plus ' + - 'one node call, so it carries no path filter, reports on every pull request, and is ' + - 'requirable; `scripts/dependabot-merge-gate.mjs` already classifies it as a required context', - ], - [ - 'readme-exports.yml', - 'produces README Export Check — added by objectui#5043. A fabricated self-import can be ' + - 'written into any package README, and a README-only pull request is the shape `ci.yml` ' + - 'structurally cannot see (its jobs short-circuit on a diff that excludes `**/*.md`), so ' + - 'this gate carries no path filter, reports on every pull request, and is requirable; ' + - '`scripts/dependabot-merge-gate.mjs` already classifies it as a required context', + 'repo-gates.yml', + 'produces Repo Gates — objectui#12214 folded fifteen one-check gate workflows into this one ' + + 'job (the control-byte scan and docs-link check among them, the two contexts #3523 first ' + + 'found safe to require). Their shared reason carries over unchanged: each gate reads the ' + + 'tree or a markdown-only surface, so the workflow carries no path filter, reports on every ' + + 'pull request, and is requirable; `scripts/dependabot-merge-gate.mjs` classifies it as a ' + + 'required context', ], [ 'governed-surface-guard.yml', @@ -617,7 +579,11 @@ const HISTORY_ANCHOR = 'objectui/issues/3523'; * objectui#3523 step 1. Asserted below to be a subset of the map, so the * exemption cannot be widened into a second live list by adding names to it. */ -const HISTORY_NON_SUBSCRIBERS = ['ci.yml', 'lint.yml', 'control-bytes.yml', 'docs-links.yml']; +const HISTORY_NON_SUBSCRIBERS = ['ci.yml', 'lint.yml']; +// The dated paragraph also names `control-bytes.yml` and `docs-links.yml`, the +// other two PR #3722 subscribed. objectui#12214 folded both into +// `repo-gates.yml`, so they are no longer subscribers at all: the live rule +// above cannot flag a name no workflow carries, and they need no exemption. /** * The `## Merge Queue` section, from its heading to the next `## `. @@ -722,7 +688,7 @@ describe('ci-cd-pipeline.md does not keep its own copy of the subscriber list (# } }); - it('keeps the dated exemption honest — its four names are all map entries', () => { + it('keeps the dated exemption honest — its names are all map entries', () => { for (const file of HISTORY_NON_SUBSCRIBERS) { expect( [...MUST_SUBSCRIBE_MERGE_GROUP.keys()], diff --git a/scripts/__tests__/select-affected-tests.test.ts b/scripts/__tests__/select-affected-tests.test.ts new file mode 100644 index 0000000000..da7f601c94 --- /dev/null +++ b/scripts/__tests__/select-affected-tests.test.ts @@ -0,0 +1,351 @@ +import { afterAll, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +import { + closureSummaries, + dedupeFilters, + listWorkspacePackages, + moduleSpecifiers, + packageClosures, + parseWorkspaceGlobs, + readHeavyProject, + selectAffectedTests, + trackedFiles, +} from '../select-affected-tests.mjs'; + +/** + * objectui#12214 — the affected-test selector behind the two-speed `Test` lane. + * + * The fixture tree below is the smallest workspace that exercises every rule + * the selector's header states, each in BOTH directions: a change that must + * reach a test, and a change that must not. The live-tree cases at the end hold + * the selector to the real repository: the package graph agrees with turbo's, + * the `dom-heavy` project still parses, and the CLI reaches a verdict. + */ +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +const tmpRoots: string[] = []; +afterAll(() => { + for (const dir of tmpRoots) fs.rmSync(dir, { recursive: true, force: true }); +}); + +function fixture(files: Record): { root: string; tracked: string[] } { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'select-affected-12214-')); + tmpRoots.push(root); + for (const [rel, text] of Object.entries(files)) { + fs.mkdirSync(path.join(root, path.dirname(rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), text); + } + return { root, tracked: Object.keys(files).sort() }; +} + +const manifest = (name: string, deps: Record = {}) => + JSON.stringify({ name, version: '0.0.0', dependencies: deps }); + +/** A root config carrying the two shapes `readHeavyProject` reads. */ +const ROOT_CONFIG = `import path from 'path'; +import { guard } from './scripts/guard.mjs'; +guard(); +const heavyDomTests = [ + // a comment line the reader must skip + 'packages/heavy-user/src/heavy.test.tsx', +]; +export default { + test: { + projects: [ + { + extends: true, + test: { + name: 'dom-heavy', + environment: 'happy-dom', + setupFiles: [path.resolve(__dirname, 'vitest.setup.dom.tsx')], + include: [...heavyDomTests], + }, + }, + ], + }, +}; +`; + +function workspace() { + return fixture({ + 'pnpm-workspace.yaml': "packages:\n - 'packages/*'\n", + 'package.json': manifest('root'), + 'vitest.config.mts': ROOT_CONFIG, + 'vitest.setup.dom-light.tsx': "import '@fx/locales';\n", + 'vitest.setup.dom.tsx': "import './vitest.setup.dom-light';\nimport '@fx/registry';\n", + 'scripts/guard.mjs': 'export function guard() {}\n', + 'scripts/helper.mjs': 'export const x = 1;\n', + 'scripts/__tests__/gate.test.ts': "import '../helper.mjs';\n", + 'README.md': '# fixture\n', + 'content/page.md': 'docs\n', + 'packages/locales/package.json': manifest('@fx/locales'), + 'packages/locales/src/index.ts': 'export const en = {};\n', + 'packages/registry/package.json': manifest('@fx/registry'), + 'packages/registry/src/index.ts': 'export const r = 1;\n', + 'packages/a/package.json': manifest('@fx/a'), + 'packages/a/tsconfig.json': '{}\n', + 'packages/a/src/index.ts': 'export const a = 1;\n', + 'packages/a/src/a.test.ts': "import { a } from './index';\n", + 'packages/b/package.json': manifest('@fx/b', { '@fx/a': 'workspace:*' }), + 'packages/b/src/index.ts': "import { a } from '@fx/a';\nexport const b = a;\n", + 'packages/b/src/b.test.ts': "import { b } from './index';\n", + 'packages/c/package.json': manifest('@fx/c'), + 'packages/c/src/index.ts': 'export const c = 1;\n', + 'packages/c/src/plain.test.ts': "import { c } from './index';\n", + 'packages/c/src/plain.test.tsx': "import { c } from './index';\n", + 'packages/c/src/reads.test.ts': "import './helpers/tree';\n", + 'packages/c/src/helpers/tree.ts': "import fs from 'node:fs';\nexport const t = fs.existsSync('.');\n", + 'packages/c/src/undeclared.test.ts': "import { a } from '@fx/a';\n", + 'packages/c/src/escapes.test.ts': "import '../../../scripts/helper.mjs';\n", + 'packages/heavy-user/package.json': manifest('@fx/heavy-user'), + 'packages/heavy-user/src/heavy.test.tsx': 'export {};\n', + }); +} + +type Affected = Extract, { mode: 'affected' }>; + +function affected(root: string, tracked: string[], changed: string[]): Affected { + const verdict = selectAffectedTests({ root, tracked, changed }); + expect(verdict.mode, `expected an affected verdict for ${changed.join(', ')}`).toBe('affected'); + return verdict as Affected; +} + +function full(root: string, tracked: string[], changed: string[]): string[] { + const verdict = selectAffectedTests({ root, tracked, changed }); + expect(verdict.mode, `expected a full run for ${changed.join(', ')}`).toBe('full'); + return (verdict as { reasons: string[] }).reasons; +} + +describe('select-affected-tests — the package graph (objectui#12214)', () => { + it('selects the changed package and its dependents, and not an unrelated package', () => { + const { root, tracked } = workspace(); + const v = affected(root, tracked, ['packages/a/src/index.ts']); + expect(v.changedPackages).toEqual(['@fx/a']); + expect(v.affectedPackages).toEqual(['@fx/a', '@fx/b']); + expect(v.filters).toContain('packages/a/'); + expect(v.filters).toContain('packages/b/'); + expect(v.filters).not.toContain('packages/c/'); + expect(v.filters.some((f) => f.includes('plain.test'))).toBe(false); + }); + + it('a dependency does not pull in its own dependencies', () => { + const { root, tracked } = workspace(); + const v = affected(root, tracked, ['packages/b/src/index.ts']); + expect(v.affectedPackages).toEqual(['@fx/b']); + expect(v.filters).not.toContain('packages/a/'); + }); +}); + +describe('select-affected-tests — the always-on set', () => { + const { root, tracked } = workspace(); + const v = affected(root, tracked, ['content/page.md']); + const why = new Map(v.alwaysOn.map((a) => [a.file, a.why])); + + it('a docs-only change selects the always-on set and no package', () => { + expect(v.affectedPackages).toEqual([]); + expect(v.filters.length).toBeGreaterThan(0); + }); + + it('keeps a test outside every package', () => { + expect(why.get('scripts/__tests__/gate.test.ts')).toBe('outside every workspace package'); + }); + + it('keeps a test whose RELATIVE closure reads the disk, not only one that imports fs itself', () => { + expect(why.get('packages/c/src/reads.test.ts')).toBe('reads the disk or spawns a process'); + }); + + it('keeps a test that imports a workspace package its own package does not depend on', () => { + expect(why.get('packages/c/src/undeclared.test.ts')).toMatch(/imports @fx\/a, which @fx\/c does not depend on/); + }); + + it('keeps a test that reaches a file outside its own package', () => { + expect(why.get('packages/c/src/escapes.test.ts')).toMatch(/reaches the repository root outside packages\/c/); + }); + + it('leaves out a test the package graph can place', () => { + expect(why.has('packages/c/src/plain.test.ts')).toBe(false); + expect(v.filters.some((f) => f.includes('plain.test'))).toBe(false); + }); +}); + +describe('select-affected-tests — what forces a full run', () => { + const { root, tracked } = workspace(); + + it.each([ + ['package.json', /package\.json change is a full-run trigger/], + ['packages/c/package.json', /package\.json change is a full-run trigger/], + ['packages/a/tsconfig.json', /tsconfig\.json change is a full-run trigger/], + ['vitest.config.mts', /vitest\.config\.mts change is a full-run trigger/], + ['pnpm-lock.yaml', /repository-root file that is not documentation/], + ['patches/x.patch', /dependency patch/], + ['scripts/guard.mjs', /root vitest config or a setup file imports it/], + ['vitest.setup.dom-light.tsx', /root vitest config or a setup file imports it/], + ['packages/locales/src/index.ts', /@fx\/locales is affected and a broad root vitest setup file imports it/], + ])('%s', (changed, reason) => { + expect(full(root, tracked, [changed]).join('\n')).toMatch(reason); + }); + + it('a root documentation file is not a full-run trigger', () => { + affected(root, tracked, ['README.md']); + }); + + it('a script no config imports is not a full-run trigger', () => { + affected(root, tracked, ['scripts/helper.mjs']); + }); +}); + +describe('select-affected-tests — the heavy DOM setup', () => { + it('a package only the heavy setup imports adds the dom-heavy test files instead of running everything', () => { + const { root, tracked } = workspace(); + const v = affected(root, tracked, ['packages/registry/src/index.ts']); + expect(v.heavyEdge).toEqual(['@fx/registry']); + expect(v.filters).toContain('packages/heavy-user/src/heavy.test.tsx'); + }); + + it('no heavy edge, no heavy tests', () => { + const { root, tracked } = workspace(); + const v = affected(root, tracked, ['packages/c/src/index.ts']); + expect(v.heavyEdge).toEqual([]); + expect(v.filters).not.toContain('packages/heavy-user/src/heavy.test.tsx'); + }); + + it('refuses a config whose dom-heavy project it cannot read, rather than guessing', () => { + expect(() => readHeavyProject('export default {}')).toThrow(/dom-heavy/); + expect(() => + readHeavyProject(ROOT_CONFIG.replace(/'packages\/heavy-user\/src\/heavy\.test\.tsx',/, '')), + ).toThrow(/empty/); + }); +}); + +describe('select-affected-tests — readers', () => { + it('drops a filter another one already matches, as the vitest invocation guard requires (objectui#7814)', () => { + expect(dedupeFilters(['packages/c/src/plain.test.tsx', 'packages/c/src/plain.test.ts', 'packages/c/'])).toEqual([ + 'packages/c/', + ]); + expect(dedupeFilters(['x/a.test.tsx', 'x/a.test.ts'])).toEqual(['x/a.test.ts']); + }); + + it('reads every import spelling the selector depends on', () => { + const { specifiers, globsTheTree } = moduleSpecifiers( + [ + "import a from './a';", + "import type { B } from './b';", + "export * from './c';", + "import './d';", + "const e = await import('./e');", + "const f = require('./f');", + "vi.mock('./g', async (importOriginal) => ({ ...(await importOriginal()) }));", + 'import {\n h,\n i,\n} from "./h";', + "const files = import.meta.glob('../**/*.md');", + ].join('\n'), + ); + expect(specifiers.sort()).toEqual(['./a', './b', './c', './d', './e', './f', './g', './h']); + expect(globsTheTree).toBe(true); + }); + + it('refuses a workspace glob shape it does not know', () => { + expect(parseWorkspaceGlobs("packages:\n - 'packages/*'\n - docs\n")).toEqual(['packages/*', 'docs']); + expect(() => parseWorkspaceGlobs("packages:\n - 'packages/**'\n")).toThrow(/does not know/); + expect(() => parseWorkspaceGlobs('catalog: {}\n')).toThrow(/declares no packages/); + }); + + it('summarises an import CYCLE without looping, every member seeing the whole cycle', () => { + const { root } = fixture({ + 'p/x.ts': "import './y';\n", + 'p/y.ts': "import './x';\nimport 'node:fs';\n", + }); + const summaries = closureSummaries(root, ['p/x.ts'], new Set(), () => 'p'); + expect(summaries.get('p/x.ts')?.readsDisk).toBe(true); + expect(summaries.get('p/y.ts')?.readsDisk).toBe(true); + }); +}); + +describe('select-affected-tests — the live tree', () => { + const packages = listWorkspacePackages(repoRoot); + const { dependentClosure } = packageClosures(packages); + + it('agrees with turbo on who depends on whom', () => { + // The header promises `turbo ls --affected`'s answer. Held, not assumed, on + // a leaf, a mid-graph and a root package. + const turbo = path.join(repoRoot, 'node_modules/.bin/turbo'); + for (const name of ['@object-ui/plugin-kanban', '@object-ui/app-shell', '@object-ui/i18n']) { + const out = execFileSync(turbo, ['ls', name, '--output=json'], { cwd: repoRoot, encoding: 'utf8' }); + const reading = JSON.parse(out) as { packages: Array<{ name: string; dependents: string[] }> }; + const theirs = reading.packages.find((p) => p.name === name)?.dependents ?? []; + expect(theirs.length, `turbo named no dependents for ${name}; a broken reading, not a leaf`).toBeGreaterThan( + name === '@object-ui/app-shell' ? 0 : 1, + ); + expect([...(dependentClosure.get(name) ?? [])].sort()).toEqual([...theirs].sort()); + } + }); + + it('reads the real dom-heavy project, and every file it names is tracked', () => { + const heavy = readHeavyProject(fs.readFileSync(path.join(repoRoot, 'vitest.config.mts'), 'utf8')); + expect(heavy.setupFile).toMatch(/^vitest\.setup\.[^/]+$/); + const tracked = new Set(trackedFiles(repoRoot)); + expect(tracked.has(heavy.setupFile)).toBe(true); + expect(heavy.tests.filter((t) => !tracked.has(t))).toEqual([]); + }); + + it('reaches a verdict from the command line, and an empty change still selects the always-on set', () => { + const out = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'select-affected-cli-')), 'filters.txt'); + tmpRoots.push(path.dirname(out)); + const run = spawnSync(process.execPath, ['scripts/select-affected-tests.mjs', '--base', 'HEAD', '--out', out], { + cwd: repoRoot, + encoding: 'utf8', + }); + expect(run.status, run.stderr).toBe(0); + expect(run.stdout.trim().split('\n').pop()).toBe('mode=affected'); + const filters = fs.readFileSync(out, 'utf8').trim().split('\n'); + expect(filters.length).toBeGreaterThan(0); + expect(filters.every((f) => fs.existsSync(path.join(repoRoot, f)))).toBe(true); + }); + + it('answers "could not decide" with a non-zero exit, never a verdict', () => { + const run = spawnSync(process.execPath, ['scripts/select-affected-tests.mjs', '--base', 'no-such-ref-12214', '--explain'], { + cwd: repoRoot, + encoding: 'utf8', + }); + expect(run.status).toBe(2); + expect(run.stdout).not.toMatch(/^mode=/m); + }); +}); + +describe('ci.yml wires the selector fail-open (objectui#12214)', () => { + const ci = fs.readFileSync(path.join(repoRoot, '.github/workflows/ci.yml'), 'utf8'); + const code = ci + .split('\n') + .filter((l) => !/^\s*#/.test(l)) + .join('\n'); + + it('runs the full battery hourly on main, beside push', () => { + expect(code).toMatch(/^ {2}schedule:\n {4}- cron: '[^']+'$/m); + expect(code).toMatch(/^ {4}if: github\.event_name == 'push' \|\| github\.event_name == 'schedule'$/m); + }); + + it('selects only on pull_request and merge_group, and treats every other event as full', () => { + const step = code.slice(code.indexOf('- name: Select the tests this change affects')); + expect(step).toMatch(/pull_request\) base="\$PR_BASE"/); + expect(step).toMatch(/merge_group\) base="\$QUEUE_BASE"/); + expect(step).toMatch(/\*\)\n\s+echo 'mode=full' >> "\$GITHUB_OUTPUT"/); + }); + + it('turns a selector that cannot decide into a full run, not a skip', () => { + const step = code.slice(code.indexOf('- name: Select the tests this change affects')); + expect(step).toContain('|| status=$?'); + expect(step).toMatch(/verdict='mode=full'/); + // objectui#6221: the status must not be swallowed by a command substitution. + expect(step).not.toMatch(/\$\(node scripts\/select-affected-tests\.mjs/); + }); + + it('keeps the matrix width and the full-suite command for the full battery', () => { + const run = code.slice(code.indexOf('- name: Run tests (shard ${{ matrix.shard }}/12)')); + expect(run).toContain('pnpm test --shard=${{ matrix.shard }}/12'); + expect(run).toContain('pnpm exec vitest run --shard=${{ matrix.shard }}/12 "${filters[@]}"'); + }); +}); diff --git a/scripts/__tests__/spec-main-shape-gate.test.ts b/scripts/__tests__/spec-main-shape-gate.test.ts index d6051acd9c..e9c07d7f6c 100644 --- a/scripts/__tests__/spec-main-shape-gate.test.ts +++ b/scripts/__tests__/spec-main-shape-gate.test.ts @@ -104,6 +104,61 @@ describe('the gate exists and is wired the way a requirable check has to be', () }); }); +describe('the spec is built only when objectstack main moved (objectui#12214)', () => { + /** One step of the stripped body, from its `- name:` line to the next one. */ + function step(name: string): string { + const text = body(); + const at = text.indexOf(`- name: ${name}`); + expect(at, `the workflow must still carry the step "${name}"`).toBeGreaterThan(-1); + const rest = text.slice(at + 1); + const next = rest.search(/^ {6}- name: /m); + return next === -1 ? rest : rest.slice(0, next); + } + + it('runs hourly on main as well — the gate in the full battery', () => { + expect(body()).toMatch(/^ {2}schedule:\n {4}- cron: '[^']+'$/m); + }); + + it('reuses only an artifact a main run of THIS repository built, named by the resolved sha', () => { + // A pull request or queue build runs its own branch's YAML, so what it + // uploads is not evidence about a spec; a main run executes reviewed YAML. + const lookup = step('Look for the spec a main run already built at that commit'); + expect(lookup).toContain('actions/artifacts?name=objectstack-spec-$UPSTREAM_SHA'); + expect(lookup).toContain('.workflow_run.head_branch == "main"'); + expect(lookup).toContain('(.workflow_run.repository_id | tostring) == $repo'); + expect(lookup).toContain('.expired == false'); + // A lookup that cannot be taken means BUILD. + expect(lookup).toMatch(/if ! curl[\s\S]*?echo "hit=false" >> "\$GITHUB_OUTPUT"/); + const download = step('Download that spec'); + expect(download).toContain('name: objectstack-spec-${{ steps.upstream.outputs.sha }}'); + expect(download).toContain('continue-on-error: true'); + }); + + it('skips only the build on a reuse, never the install inject reads from', () => { + const build = step('Build @objectstack/spec from objectstack main'); + expect(build).toContain("REUSED: ${{ steps.download.outcome == 'success' }}"); + const install = build.indexOf('pnpm install --frozen-lockfile --filter @objectstack/spec...'); + const skip = build.indexOf("if [ \"$REUSED\" = 'true' ]"); + expect(install).toBeGreaterThan(-1); + expect(skip, 'the reuse branch must come AFTER the install').toBeGreaterThan(install); + }); + + it('refuses a reused tarball that does not record the sha this run resolved', () => { + const pack = step('Pack the built spec'); + expect(pack).toContain('recorded=$(cat "$BUILT/sha.txt"'); + expect(pack).toMatch(/if \[ "\$recorded" != "\$UPSTREAM_SHA" \] \|\| \[ "\$count" -ne 1 \]; then[\s\S]*?exit 1/); + expect(pack).toContain('echo "$UPSTREAM_SHA" > "$BUILT/sha.txt"'); + }); + + it('publishes only from the scheduled main run, for a day, and the publish cannot speak for the verdict', () => { + const publish = step('Publish the built spec for runs at the same objectstack commit'); + expect(publish).toContain("if: github.event_name == 'schedule' && steps.download.outcome != 'success'"); + expect(publish).toContain('continue-on-error: true'); + expect(publish).toContain('retention-days: 1'); + expect(publish).toContain('name: objectstack-spec-${{ steps.upstream.outputs.sha }}'); + }); +}); + describe('the two ways this gate could report a verdict it never took', () => { it('bypasses the turbo cache when it compiles', () => { // turbo's hash covers sources, the lockfile and a declared env list — never diff --git a/scripts/check-doc-fence-languages.mjs b/scripts/check-doc-fence-languages.mjs index 77eaa5a709..9bb14d290a 100644 --- a/scripts/check-doc-fence-languages.mjs +++ b/scripts/check-doc-fence-languages.mjs @@ -127,8 +127,8 @@ * It is re-implemented here rather than imported so * this gate needs NO install — that gate imports `typescript`, and an * install-gated docs check is one that a docs-only pull request skips, which is - * the shape objectui#5174 and `doc-component-types.yml`'s header both record as - * the hole. The copy is not left to drift: + * the shape objectui#5174 records as the hole (and `repo-gates.yml`'s header, + * which carries the folded workflows' reasoning since objectui#12214). The copy is not left to drift: * `scripts/__tests__/check-doc-fence-languages.test.ts` imports BOTH walks and * fails if they ever return different document lists, and pins this file's * TypeScript-fence set against the gate's own `TS_FENCE_LANGUAGES`. diff --git a/scripts/check-doc-json-fences.mjs b/scripts/check-doc-json-fences.mjs index bd7a227515..bad2180925 100644 --- a/scripts/check-doc-json-fences.mjs +++ b/scripts/check-doc-json-fences.mjs @@ -58,8 +58,9 @@ * `@object-ui/types` to source, which is one inherited config away from judging * the docs against a face nobody installs; the RESOLUTION control below prints * the resolved file and refuses one outside the package's `dist/`. The - * workflow (`doc-snippet-types.yml`) runs this gate after it has built the - * snippet gate's `--build-filter` closure, which carries `@object-ui/types`. + * workflow (`repo-gates.yml`, objectui#12214) runs this gate after it has built + * every package plus the snippet gate's `--build-filter` closure, which carries + * `@object-ui/types`. * * ## The scan surface, declared once (objectui#12131) * diff --git a/scripts/check-doc-snippet-types.mjs b/scripts/check-doc-snippet-types.mjs index 24f3fe8b07..6293a8d42d 100644 --- a/scripts/check-doc-snippet-types.mjs +++ b/scripts/check-doc-snippet-types.mjs @@ -21,9 +21,9 @@ * ## Why "could not run" is its own exit code (objectui#5465) * * Both 1 and 2 are non-zero, so no caller's pass/fail changes: this gate is - * invoked from exactly one workflow step (`doc-snippet-types.yml`), which fails - * on any non-zero, and which builds this gate's own `--build-filter` closure - * BEFORE invoking it — an unbuilt tree is not a state CI can reach. The + * invoked from exactly one workflow step (in `repo-gates.yml` since objectui#12214), + * which fails on any non-zero, and whose job builds this gate's own + * `--build-filter` closure BEFORE invoking it — an unbuilt tree is not a state CI can reach. The * distinction exists for the reader. "I could not run" and "I ran and found * errors" are different facts, and while they shared one exit code they were * indistinguishable at the exit-code level: three separate agents in one evening diff --git a/scripts/check-pre-install-import-graph.mjs b/scripts/check-pre-install-import-graph.mjs index 7b186f258a..7f8b7f2294 100644 --- a/scripts/check-pre-install-import-graph.mjs +++ b/scripts/check-pre-install-import-graph.mjs @@ -12,8 +12,9 @@ * A handful of this repository's gates deliberately run before any install: * they read the checkout and nothing else, which is what lets them run * UNFILTERED on every pull request shape for the price of a checkout plus one - * `node` call. `docs-links.yml`, `skills-paths.yml`, `control-bytes.yml` and - * `changeset-presence.yml` exist in that shape on purpose -- their headers each + * `node` call. `repo-gates.yml`'s install-free steps (the Internal Docs Link + * Check, Skill Guide Path Check and Control Byte Scan among them, objectui#12214) + * and `changeset-presence.yml` exist in that shape on purpose -- their headers * say a gate a markdown-only PR cannot start "rebuilds the hole it exists to * close". * @@ -75,7 +76,7 @@ * * ## This gate satisfies its own rule, by construction * - * `pre-install-import-graph.yml` runs this file before any install, so this + * `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 * `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 @@ -313,9 +314,10 @@ const NODE_SCRIPT_RE = /(?:^|[\s;&|(])node\s+(?:--[^\s]+\s+)*(scripts\/[A-Za-z0- * Every step that runs a `scripts/` JavaScript file before the first * `pnpm install` in its own job. * - * A job with NO install at all counts entirely: `skills-paths.yml` and - * `docs-links.yml` never install, so every `node` step in them is a - * pre-install step. That is not an edge case, it is four of the nine. + * A job with NO install at all counts entirely: every `node` step in it is a + * pre-install step. That is not an edge case -- several gate workflows were + * built that way before objectui#12214 folded most of them into + * `repo-gates.yml`, whose gates sit before its install step instead. * * @param {Array<{ file: string, text: string }>} workflows * @returns {Array<{ workflow: string, job: string, step: number, stepName: string, script: string }>} diff --git a/scripts/check-skill-examples.mjs b/scripts/check-skill-examples.mjs index 2c95a3ef51..36aa5f085a 100644 --- a/scripts/check-skill-examples.mjs +++ b/scripts/check-skill-examples.mjs @@ -1537,8 +1537,9 @@ export function buildFilterArgs(packages) { * packages, and expanding each one's dependency closure puts 30 in scope — so * running the printed command exactly as given leaves 10 packages with no * `dist/`, and nothing distinguishes that from a build that half-failed. The - * trimming is deliberate (`skill-examples.yml`'s header forbids the unfiltered - * build in as many words, under the 2026-08-16 ruling on objectui#4846), so what + * trimming is deliberate (the workflow that runs this gate -- `repo-gates.yml` + * since objectui#12214 -- forbids the unfiltered build in its header, under the + * 2026-08-16 ruling on objectui#4846), so what * was missing is the sentence, never a wider filter. * * Both counts are ARGUMENTS and the text names no package on purpose. A list of diff --git a/scripts/check-test-shard-results.mjs b/scripts/check-test-shard-results.mjs index 86fe49e98c..5effccc6ae 100644 --- a/scripts/check-test-shard-results.mjs +++ b/scripts/check-test-shard-results.mjs @@ -5,8 +5,8 @@ * check-test-shard-results -- the `Test` aggregator's verdict, taken one shard * at a time instead of from the matrix rollup. * - * node scripts/check-test-shard-results.mjs --shards 8 --job-name 'Test' - * node scripts/check-test-shard-results.mjs --shards 8 --fixture + * node scripts/check-test-shard-results.mjs --shards 12 --job-name 'Test' + * node scripts/check-test-shard-results.mjs --shards 12 --fixture * * Exit: 0 = a reading was taken and every shard reported `success` * 2 = the reading COULD NOT BE TAKEN -- never a pass diff --git a/scripts/check-vi-mock-override-shape.mjs b/scripts/check-vi-mock-override-shape.mjs index 83f52f0d45..bed1f3d2ec 100644 --- a/scripts/check-vi-mock-override-shape.mjs +++ b/scripts/check-vi-mock-override-shape.mjs @@ -57,9 +57,9 @@ * * Reading a DECLARED shape means parsing TypeScript -- return-type annotations, * re-export chains, type-only exports. This file therefore imports `typescript`. - * `check-vi-mock-inherit.mjs` runs in `.github/workflows/vi-mock-specifiers.yml`, - * which deliberately carries NO install step so that it can run on every pull - * request shape at the cost of a checkout plus one `node` call; the import graph + * `check-vi-mock-inherit.mjs` runs in `.github/workflows/repo-gates.yml`, in a + * step deliberately placed BEFORE that job's install step so that it can run on + * every pull request shape at the cost of a checkout plus one `node` call; the import graph * of every pre-install gate is enforced to be node builtins plus repo-relative * modules only (objectui#6148). Adding the import to the sibling was measured * rather than argued -- injected on disk, blob hash before/after recorded: diff --git a/scripts/ci-setup-pnpm.sh b/scripts/ci-setup-pnpm.sh index 82ca796ea2..a535d250e9 100755 --- a/scripts/ci-setup-pnpm.sh +++ b/scripts/ci-setup-pnpm.sh @@ -18,11 +18,13 @@ # opposite conclusion. That control is what distinguishes a transient read from a # diff-caused failure, and it is why this file exists rather than a re-run. # -# The cost is not one re-run. `README Export Check` is a REQUIRED context -# (`scripts/dependabot-merge-gate.mjs`), and since objectui#6160 the `merge_group` -# subscription floor derives from that same list. `readme-exports.yml`'s own -# trigger block states the consequence, and `changeset-presence.yml`, `ci.yml` and -# `control-bytes.yml` each state it independently: +# The cost is not one re-run. `README Export Check` was a REQUIRED context +# (`scripts/dependabot-merge-gate.mjs`; since objectui#12214 it is a step of +# `Repo Gates`, which is), and since objectui#6160 the `merge_group` +# subscription floor derives from that same list. The README Export Check's +# trigger block (`readme-exports.yml` then, `repo-gates.yml` since objectui#12214) +# states the consequence, and `changeset-presence.yml` and `ci.yml` each state it +# independently: # # "A required check that does not report on a queue build stalls the queue # until the ruleset's 60-minute timeout fails it." diff --git a/scripts/coverage-red-cause-census.mjs b/scripts/coverage-red-cause-census.mjs index 99fbf180b7..9ef0cefb8f 100644 --- a/scripts/coverage-red-cause-census.mjs +++ b/scripts/coverage-red-cause-census.mjs @@ -67,7 +67,10 @@ * vitest's own sequencer, so this is arithmetic on the tree, not a guess. * (2) WHETHER THE SAME TREE PASSED UNINSTRUMENTED, from the `merge_group` * lane, which runs the same 4-way split over the same files without - * `--coverage` (ci.yml gives `test` `if: github.event_name != 'push'`). + * `--coverage` (ci.yml gave `test` `if: github.event_name != 'push'` + * when this was written; since objectui#12214 a `merge_group` run + * carries only the affected set, and the uninstrumented whole suite + * runs on `push` and `schedule` beside the coverage lane). * * ⚠️ Reading (2) has a selection trap that has to be named: a commit only lands * if its merge_group run went green, so "every landed sha had a green diff --git a/scripts/dependabot-merge-gate.mjs b/scripts/dependabot-merge-gate.mjs index e10ecb096e..d7ed53137d 100644 --- a/scripts/dependabot-merge-gate.mjs +++ b/scripts/dependabot-merge-gate.mjs @@ -139,25 +139,21 @@ import { isEntrypoint } from './invoked-as.mjs'; * ci.yml Changeset Fixed Group Check, Type Check, Test, * Build & E2E, Build Docs * lint.yml Lint - * control-bytes.yml Control Byte Scan - * docs-links.yml Internal Docs Link Check - * skills-paths.yml Skill Guide Path Check - * skill-examples.yml Skill Example Check - * skill-eval-tokens.yml Skill Eval Token Check + * repo-gates.yml Repo Gates * changeset-presence.yml Changeset Declaration - * doc-component-types.yml Doc Component Type Check - * doc-snippet-types.yml Doc Snippet Type Check - * doc-fence-languages.yml Doc Fence Language Check - * doc-example-ids.yml Doc Example Id Check - * pre-install-import-graph.yml Pre-Install Import Graph Check - * vi-mock-specifiers.yml Inert vi.mock Specifier Check - * shell-escape-residue.yml Shell Escape Residue Scan - * readme-exports.yml README Export Check - * docs-route-eager-closure.yml Docs Route Eager Closure Check * governed-surface-guard.yml Governed Surface Queue Guard - * action-ref-convention.yml Action Ref Convention * spec-main-shape-gate.yml Spec Main Shape Gate * + * `Repo Gates` is ONE context for fifteen checks (objectui#12214): the + * one-check workflows that each produced a context of their own -- the + * control-byte scan, the docs-link check, the skill-guide path, skill-example + * and eval-token checks, the doc component-type, snippet-type, fence-language + * and example-id checks, the pre-install import graph, the inert vi.mock check, + * the shell-escape residue scan, the README export check, the docs-route eager + * closure check and the action-ref convention -- were folded into one job whose + * steps keep those names. The job is red when any of its steps is, so waiting + * for this one name waits for all fifteen verdicts. + * * `Governed Surface Queue Guard` is the newest and the one whose reading here * differs from every other row, so it is worth a sentence. On a PULL REQUEST it * is deliberately green whatever it finds — a governed pull request parked as a @@ -196,28 +192,15 @@ export const REQUIRED_CONTEXTS = Object.freeze([ 'Build & E2E', 'Build Docs', 'Lint', - 'Control Byte Scan', - 'Internal Docs Link Check', - 'Skill Guide Path Check', - 'Skill Example Check', - 'Skill Eval Token Check', + // objectui#12214: the fifteen one-check gate workflows, folded into one job. + // Among its steps, the action-ref convention (objectui#8465) is worth naming: + // it reads `.github/workflows/**`, which is exactly what a Dependabot + // `github-actions` bump edits -- the pull requests most likely to introduce + // an off-convention reference and the least likely to have a human reading + // each edited line. + 'Repo Gates', 'Changeset Declaration', - 'Doc Component Type Check', - 'Doc Snippet Type Check', - 'Doc Fence Language Check', - 'Doc Example Id Check', - 'Pre-Install Import Graph Check', - 'Inert vi.mock Specifier Check', - 'Shell Escape Residue Scan', - 'README Export Check', - 'Docs Route Eager Closure Check', 'Governed Surface Queue Guard', - // objectui#8465. Worth listing deliberately rather than by reflex: this gate - // reads `.github/workflows/**`, which is exactly what a Dependabot - // `github-actions` bump edits — those bumps are the pull requests most likely - // to introduce an off-convention reference and the least likely to have a - // human reading each edited line. - 'Action Ref Convention', // objectui#9969, ruling A — the residual of objectui#9860's ruling C. This // name sat in `NOT_A_GATE` until two facts that were somebody else's to // establish had both been established, and it moved only after both were: @@ -276,10 +259,10 @@ export const OPTIONAL_CONTEXTS = Object.freeze({ * whichever shard happens to exist. */ const COVERAGE_SHARD_NOT_A_GATE = - "ci.yml's push-only coverage lane (`if: github.event_name == 'push'`), so on a pull request it reports conclusion=skipped by design. Coverage is deliberately not recomputed per pull request — v8 instrumentation adds 40-100% overhead — so the PR lane is the `Test (shard N/8)` matrix, which reports through the `Test` aggregator above. ⛔ This lane's own 4-way sharding is unrelated to that matrix's width and objectui#9499 did not touch it."; + "ci.yml's coverage lane runs on the full battery's events only (`push` and the hourly `schedule`, objectui#12214), so on a pull request it reports conclusion=skipped by design. Coverage is deliberately not recomputed per pull request — v8 instrumentation adds 40-100% overhead — so the PR lane is the `Test (shard N/12)` matrix, which reports through the `Test` aggregator above. ⛔ This lane's own 4-way sharding is unrelated to that matrix's width and objectui#9499 did not touch it."; /** - * The eight PR test shards share one reason, spelled once. Every one of them is + * The twelve test shards share one reason, spelled once. Every one of them is * a real blocking leg — the difference from the coverage shards above is that * something else reports their verdict, not that they have none. * @@ -292,7 +275,7 @@ const COVERAGE_SHARD_NOT_A_GATE = * count used to be welded to (objectui#9499). */ const PR_TEST_SHARD_NOT_A_GATE = - "ci.yml's PR/merge-queue test matrix. It is a blocking leg and it is NOT waited for by name: since objectui#9499 the `test-aggregate` job (`Test`, above) is the single required test context, and it reports only after every shard has finished, red unless each shard's OWN conclusion is `success` — `skipped` included. Waiting for the legs here as well would put the shard COUNT back into a second declaration, which is the coupling that card removed."; + "ci.yml's test matrix — the affected set on a pull request or queue build, the whole suite on `push` and `schedule` (objectui#12214). It is a blocking leg and it is NOT waited for by name: since objectui#9499 the `test-aggregate` job (`Test`, above) is the single required test context, and it reports only after every shard has finished, red unless each shard's OWN conclusion is `success` — `skipped` included. Waiting for the legs here as well would put the shard COUNT back into a second declaration, which is the coupling that card removed."; /** * Everything else a pull request to `main` produces, and why it cannot gate. @@ -301,20 +284,24 @@ const PR_TEST_SHARD_NOT_A_GATE = * defaulting into silence. */ export const NOT_A_GATE = Object.freeze({ - 'Test (shard 1/8)': PR_TEST_SHARD_NOT_A_GATE, - 'Test (shard 2/8)': PR_TEST_SHARD_NOT_A_GATE, - 'Test (shard 3/8)': PR_TEST_SHARD_NOT_A_GATE, - 'Test (shard 4/8)': PR_TEST_SHARD_NOT_A_GATE, - 'Test (shard 5/8)': PR_TEST_SHARD_NOT_A_GATE, - 'Test (shard 6/8)': PR_TEST_SHARD_NOT_A_GATE, - 'Test (shard 7/8)': PR_TEST_SHARD_NOT_A_GATE, - 'Test (shard 8/8)': PR_TEST_SHARD_NOT_A_GATE, + 'Test (shard 1/12)': PR_TEST_SHARD_NOT_A_GATE, + 'Test (shard 2/12)': PR_TEST_SHARD_NOT_A_GATE, + 'Test (shard 3/12)': PR_TEST_SHARD_NOT_A_GATE, + 'Test (shard 4/12)': PR_TEST_SHARD_NOT_A_GATE, + 'Test (shard 5/12)': PR_TEST_SHARD_NOT_A_GATE, + 'Test (shard 6/12)': PR_TEST_SHARD_NOT_A_GATE, + 'Test (shard 7/12)': PR_TEST_SHARD_NOT_A_GATE, + 'Test (shard 8/12)': PR_TEST_SHARD_NOT_A_GATE, + 'Test (shard 9/12)': PR_TEST_SHARD_NOT_A_GATE, + 'Test (shard 10/12)': PR_TEST_SHARD_NOT_A_GATE, + 'Test (shard 11/12)': PR_TEST_SHARD_NOT_A_GATE, + 'Test (shard 12/12)': PR_TEST_SHARD_NOT_A_GATE, 'Test (dist pins)': "ci.yml's built-artifact pin lane, lifted out of the shard matrix by objectui#9499 (the lane itself is objectui#7183). Same reading as the shards above: it is blocking, and the `Test` aggregator asserts its result — `needs.test-dist-pins.result` is one job's own conclusion, so `skipped` is visible there without an API read — so the whole test lane still speaks through one required name.", dependabot: 'This workflow itself. The gate runs inside this job, so requiring it would deadlock at its own deadline.', 'Test (coverage)': - "ci.yml's push lane (`if: github.event_name == 'push'`), so on a pull request it reports conclusion=skipped by design. Since objectui#5403 this is the job at the END of the coverage lane — it merges the four shard blob reports into one complete report, enforces the configured coverage thresholds over it, publishes it as the `coverage-report` artifact, and goes red whenever any of that did not happen (the Codecov upload it also carried was retired by objectui#5436). The PR lane is the `Test (shard N/8)` matrix, reported through the `Test` aggregator above.", + "ci.yml's full-battery lane (`push` and the hourly `schedule`, objectui#12214), so on a pull request it reports conclusion=skipped by design. Since objectui#5403 this is the job at the END of the coverage lane — it merges the four shard blob reports into one complete report, enforces the configured coverage thresholds over it, publishes it as the `coverage-report` artifact, and goes red whenever any of that did not happen (the Codecov upload it also carried was retired by objectui#5436). The PR lane is the `Test (shard N/12)` matrix, reported through the `Test` aggregator above.", 'Test (coverage shard 1/4)': COVERAGE_SHARD_NOT_A_GATE, 'Test (coverage shard 2/4)': COVERAGE_SHARD_NOT_A_GATE, 'Test (coverage shard 3/4)': COVERAGE_SHARD_NOT_A_GATE, diff --git a/scripts/github-slug.mjs b/scripts/github-slug.mjs index a70bea3606..bf569ff5b3 100644 --- a/scripts/github-slug.mjs +++ b/scripts/github-slug.mjs @@ -11,8 +11,9 @@ * `--list` names it): its whole static import graph must be node builtins plus * repo-relative modules, or the gate stops running on exactly the markdown-only * pull requests it exists for. A `github-slugger` import would be an - * `ERR_MODULE_NOT_FOUND` in that job, and `docs-links.yml` has no install step - * to add one to without giving up that property. + * `ERR_MODULE_NOT_FOUND` in that step, which `repo-gates.yml` runs before its + * install step on purpose and could not move after it without giving up that + * property. * * ## Why the table is copied verbatim and not re-derived * diff --git a/scripts/markdown-test-inputs.mjs b/scripts/markdown-test-inputs.mjs index 326d13143d..4277efc151 100644 --- a/scripts/markdown-test-inputs.mjs +++ b/scripts/markdown-test-inputs.mjs @@ -1124,6 +1124,16 @@ export const ADJUDICATED = new Map([ walker: 'not-markdown: package and app directories', }, ], + // objectui#12214. Its fixtures write a `README.md` into a temp directory to + // prove a root documentation change is not a full-run trigger; the selector + // it tests never opens a markdown file. + [ + 'scripts/__tests__/select-affected-tests.test.ts', + { + reads: [], + notRead: ['README.md'], + }, + ], [ 'scripts/__tests__/sync-quick-reference-release.test.ts', { diff --git a/scripts/select-affected-tests.mjs b/scripts/select-affected-tests.mjs new file mode 100644 index 0000000000..b6f509cc48 --- /dev/null +++ b/scripts/select-affected-tests.mjs @@ -0,0 +1,714 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * select-affected-tests -- the test files a pull request or merge-queue run + * owes, under the two-speed doctrine (objectui#12214, objectstack#22998). + * + * node scripts/select-affected-tests.mjs --base --out + * node scripts/select-affected-tests.mjs --base --explain + * + * The LAST line on stdout is the verdict, one of: + * + * mode=full run the whole suite (`pnpm test --shard=N/12`) + * mode=affected run the vitest filters written to `--out`, one per line + * + * Exit 0 means a verdict was reached -- either one. Any other exit means the + * question could not be answered, and `ci.yml` treats that as `mode=full`: a + * selector that cannot tell must RUN, never skip (the fail-open rule + * `merge-queue-reporting.test.ts` holds every in-job gate of that workflow to). + * + * ## The two speeds + * + * `push` to `main` and the hourly `schedule` run carry the FULL battery: every + * test file, twelve shards, plus the four-shard coverage lane. Nothing reads + * this script there. A `pull_request` or `merge_group` run carries the FAST + * SET this script selects. Whatever the fast set misses, the full battery on + * `main` still runs within the hour -- that is the doctrine's trade, stated so + * nobody reads the fast set as the whole suite. + * + * ## What the fast set is + * + * The package graph, plus everything the package graph cannot see. + * + * 1. AFFECTED PACKAGES. Every workspace package (`pnpm-workspace.yaml`) that a + * changed file sits in, plus every package that depends on one of them, + * transitively, through `dependencies`, `devDependencies`, + * `peerDependencies` or `optionalDependencies`. This is what + * `turbo ls --affected` answers; `select-affected-tests.test.ts` holds the + * two to the same set. Each affected package contributes its whole + * directory as a vitest filter. + * + * 2. ALWAYS-ON TEST FILES. A test the package graph cannot place is selected + * on every run, whatever changed: + * - it lives outside every workspace package (`scripts/**`, + * `eslint-rules/**`): no package owns it, so no package change + * reaches it; + * - it, or a module it reaches through RELATIVE imports, reads the disk + * or spawns a process (`fs`, `child_process`, `import.meta.glob`): + * its input is the tree, and the tree is not a package; + * - it reaches, through relative imports, a file outside its own + * package directory: that edge is in no `package.json`; + * - it imports a workspace package by name that is not its own and not + * in its own package's dependency closure: the root vitest alias map + * resolves every `@object-ui/*` name to source whether or not the + * package declares it, so the edge is real at test time and absent + * from the graph. + * + * 3. FULL RUN instead, when the change reaches something every test runs + * through: + * - a `package.json`, `vitest.config.*` or `vite.config.*` anywhere -- + * vitest's own `forceRerunTriggers` default, mirrored so `--changed` + * and this script would agree; + * - a `tsconfig*.json` anywhere (it steers how every test is transformed); + * - a file at the repository root that is not documentation, or anything + * under `patches/` (installs, lockfile, toolchain pins); + * - a file the root vitest config or one of its setup files reaches + * through relative imports (`scripts/vitest-invocation-guard.mjs` is + * one); + * - an affected package that a BROAD root setup file imports by name: + * the light DOM setup installs the i18n catalogues into every `dom` + * test, an edge no test file states. + * + * 4. THE HEAVY SETUP. The setup of the `dom-heavy` project registers four + * packages' components into every test of that project and of each + * config that loads the same setup (`apps/console`). When an affected + * package is imported by that setup and by no broad one, the selection + * adds those projects' test files instead of running everything. Both the + * setup file and the `heavyDomTests` list are read out of + * `vitest.config.mts`, never copied here; a config this reader cannot + * parse is a refusal, and a refusal is a full run. + * + * ## Why the package graph and not vitest `--changed` + * + * Measured on this tree for objectui#12214, one leaf file changed + * (`packages/plugin-kanban/src/KanbanColumnSummary.ts`): `vitest list + * --changed` selected a much smaller set than the package graph, but spent + * minutes transforming every test file's import graph in every project before + * running anything, and it is blind to the edges in item 2 and item 3 (a setup + * file is not in any test file's import graph; neither is a file read off + * disk). The package graph costs a `git diff` and a walk of `package.json` + * files. The figures are on objectui#12214's pull request, not here: a number + * in a comment is derived once and never again (AGENTS.md #9). + * + * ## What it does not see -- stated, not hidden + * + * - A non-relative edge to a file outside the tree's packages that is NOT a + * workspace package name (a bare-path alias other than the root map's). + * - A test whose disk read goes through a workspace package's export rather + * than through `fs` in its own relative closure. + * - Behaviour that depends on WHEN a test runs relative to another (shared + * module state in the `unit` project); the full battery runs them together. + * + * Each of these is caught by the full battery on `main`, which is the + * doctrine's answer to every fast-set miss. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +import { isEntrypoint } from './invoked-as.mjs'; + +export const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +/** The root files every test run is configured by. Their relative closure is a full-run trigger. */ +export const ROOT_TEST_CONFIG = Object.freeze(['vitest.config.mts']); + +/** Root files whose change is documentation and reaches no test through an import. */ +const ROOT_DOC_FILE = /^(?:[^/]+\.mdx?|LICENSE[^/]*|NOTICE[^/]*|CODEOWNERS)$/; + +/** `forceRerunTriggers`' vitest default, plus tsconfig files: a change here runs everything. */ +const FULL_RUN_BASENAME = /^(?:package\.json|vitest\.config\.[^/]+|vite\.config\.[^/]+|tsconfig[^/]*\.json)$/; + +/** A test file, as the root vitest config's projects include them. */ +const TEST_FILE = /\.test\.(?:ts|tsx|js|mjs|cjs|mts|jsx)$/; + +/** Module specifiers whose import means "this test reads the tree, or runs something that does". */ +const DISK_SPECIFIERS = new Set([ + 'fs', + 'node:fs', + 'fs/promises', + 'node:fs/promises', + 'child_process', + 'node:child_process', + 'fs-extra', + 'glob', + 'fast-glob', + 'globby', + 'tinyglobby', +]); + +const RESOLVE_EXTENSIONS = ['', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.json']; + +const toPosix = (p) => p.split(path.sep).join('/'); + +/** + * The `packages:` globs of `pnpm-workspace.yaml`. Only the shapes this + * repository writes are read -- `dir/*` and a bare `dir` -- and anything else is + * a refusal, never a guess (an unread glob would silently drop packages out of + * the graph). + * + * @param {string} text + * @returns {string[]} + */ +export function parseWorkspaceGlobs(text) { + const globs = []; + let inPackages = false; + for (const raw of text.split('\n')) { + const line = raw.replace(/#.*$/, '').trimEnd(); + if (!line.trim()) continue; + if (/^\S/.test(line)) { + inPackages = /^packages:\s*$/.test(line); + continue; + } + if (!inPackages) continue; + const m = line.match(/^\s*-\s*['"]?([^'"]+?)['"]?\s*$/); + if (!m) throw new Error(`pnpm-workspace.yaml: cannot read the packages entry ${JSON.stringify(raw)}`); + if (!/^[A-Za-z0-9._-]+(?:\/\*)?$/.test(m[1])) { + throw new Error(`pnpm-workspace.yaml: the packages glob ${JSON.stringify(m[1])} is a shape this reader does not know`); + } + globs.push(m[1]); + } + if (globs.length === 0) throw new Error('pnpm-workspace.yaml declares no packages globs'); + return globs; +} + +/** + * Every workspace package: its name, its repository-relative directory and the + * workspace packages it names in any dependency field. + * + * @param {string} root + * @returns {Array<{ name: string, dir: string, deps: string[] }>} + */ +export function listWorkspacePackages(root = REPO_ROOT) { + const globs = parseWorkspaceGlobs(fs.readFileSync(path.join(root, 'pnpm-workspace.yaml'), 'utf8')); + const dirs = []; + for (const glob of globs) { + if (glob.endsWith('/*')) { + const parent = glob.slice(0, -2); + const abs = path.join(root, parent); + if (!fs.existsSync(abs)) continue; + for (const entry of fs.readdirSync(abs, { withFileTypes: true })) { + if (entry.isDirectory()) dirs.push(`${parent}/${entry.name}`); + } + } else { + dirs.push(glob); + } + } + const manifests = []; + for (const dir of dirs.sort()) { + const file = path.join(root, dir, 'package.json'); + if (!fs.existsSync(file)) continue; + const pkg = JSON.parse(fs.readFileSync(file, 'utf8')); + if (typeof pkg.name !== 'string') continue; + manifests.push({ name: pkg.name, dir, pkg }); + } + const names = new Set(manifests.map((m) => m.name)); + return manifests.map(({ name, dir, pkg }) => { + const deps = new Set(); + for (const field of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) { + for (const dep of Object.keys(pkg[field] ?? {})) if (names.has(dep) && dep !== name) deps.add(dep); + } + return { name, dir, deps: [...deps].sort() }; + }); +} + +/** + * @param {Array<{ name: string, deps: string[] }>} packages + * @returns {{ dependencyClosure: Map>, dependentClosure: Map> }} + */ +export function packageClosures(packages) { + const direct = new Map(packages.map((p) => [p.name, p.deps])); + const reverse = new Map(packages.map((p) => [p.name, []])); + for (const p of packages) for (const d of p.deps) reverse.get(d)?.push(p.name); + const walk = (start, edges) => { + const seen = new Set(); + const stack = [...(edges.get(start) ?? [])]; + while (stack.length) { + const next = stack.pop(); + if (seen.has(next)) continue; + seen.add(next); + stack.push(...(edges.get(next) ?? [])); + } + return seen; + }; + return { + dependencyClosure: new Map(packages.map((p) => [p.name, walk(p.name, direct)])), + dependentClosure: new Map(packages.map((p) => [p.name, walk(p.name, reverse)])), + }; +} + +/** The workspace package a repository-relative path sits in, or null. */ +export function owningPackage(rel, packages) { + let best = null; + for (const p of packages) { + if (rel === p.dir || rel.startsWith(`${p.dir}/`)) { + if (!best || p.dir.length > best.dir.length) best = p; + } + } + return best; +} + +/** + * Every module specifier a source file names: static and dynamic imports, + * re-exports, `require`, and the `vi.mock` family (a mocked module is an input + * of the test whether or not the factory imports it). `import.meta.glob` is + * reported as a disk read. Comments are not stripped: a specifier spelled in a + * comment can only ADD a test to the selection, never remove one. + * + * @param {string} source + * @returns {{ specifiers: string[], globsTheTree: boolean }} + */ +export function moduleSpecifiers(source) { + const specifiers = new Set(); + const patterns = [ + /\b(?:import|export)\s+(?:type\s+)?(?:[^'";]*?\s+from\s+)?['"]([^'"\n]+)['"]/g, + /\bimport\(\s*['"]([^'"\n]+)['"]\s*\)/g, + /\brequire\(\s*['"]([^'"\n]+)['"]\s*\)/g, + /\bvi\.(?:mock|doMock|unmock|importActual|importMock)\(\s*['"]([^'"\n]+)['"]/g, + ]; + for (const re of patterns) for (const m of source.matchAll(re)) specifiers.add(m[1]); + return { specifiers: [...specifiers], globsTheTree: /\bimport\.meta\.glob(?:Eager)?\s*\(/.test(source) }; +} + +/** A relative specifier resolved against the importer, as a repository-relative path, or null. */ +export function resolveRelative(root, importerRel, specifier) { + const base = path.join(root, path.dirname(importerRel), specifier); + const candidates = []; + for (const ext of RESOLVE_EXTENSIONS) candidates.push(base + ext); + // TypeScript's ESM spelling: `./x.js` names `./x.ts`. + const jsLike = base.match(/^(.*)\.(?:js|jsx|mjs|cjs)$/); + if (jsLike) for (const ext of ['.ts', '.tsx', '.mts', '.cts']) candidates.push(jsLike[1] + ext); + for (const ext of RESOLVE_EXTENSIONS.slice(1)) candidates.push(path.join(base, `index${ext}`)); + for (const c of candidates) { + try { + if (fs.statSync(c).isFile()) return toPosix(path.relative(root, c)); + } catch { + // not this candidate + } + } + return null; +} + +/** The workspace package a bare specifier names (`@object-ui/x` or `@object-ui/x/sub`), or null. */ +export function workspaceNameOf(specifier, names) { + if (names.has(specifier)) return specifier; + const parts = specifier.split('/'); + const head = specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]; + return names.has(head) ? head : null; +} + +/** + * The relative-import graph reachable from `seeds`, and for every node what + * its WHOLE closure touches: the workspace packages it names, the package + * directories (or the repository root, as `''`) its files sit in, and whether + * anything in it reads the disk. Bare specifiers are not followed: a workspace + * package is the package graph's business, and `node_modules` the lockfile's. + * + * One pass of Tarjan's strongly-connected components over the graph, then one + * reverse-topological fold -- import cycles are ordinary here, and a closure + * walked once per test file was measured in minutes on this tree. + * + * @param {string} root + * @param {string[]} seeds repository-relative files + * @param {Set} names workspace package names + * @param {(rel: string) => string} dirOf the package directory a file sits in, or `''` + * @returns {Map, dirs: Set, readsDisk: boolean, files: number }>} + */ +export function closureSummaries(root, seeds, names, dirOf) { + /** @type {Map, readsDisk: boolean }>} */ + const nodes = new Map(); + const resolved = new Map(); + const queue = [...new Set(seeds)]; + while (queue.length) { + const rel = queue.pop(); + if (nodes.has(rel)) continue; + let source = ''; + try { + source = fs.readFileSync(path.join(root, rel), 'utf8'); + } catch { + source = ''; + } + const info = /\.(?:[cm]?[jt]sx?)$/.test(rel) ? moduleSpecifiers(source) : { specifiers: [], globsTheTree: false }; + const node = { edges: [], packageNames: new Set(), readsDisk: info.globsTheTree }; + for (const spec of info.specifiers) { + if (DISK_SPECIFIERS.has(spec)) { + node.readsDisk = true; + continue; + } + if (spec.startsWith('.')) { + const key = `${path.dirname(rel)}\0${spec}`; + let target = resolved.get(key); + if (target === undefined) { + target = resolveRelative(root, rel, spec); + resolved.set(key, target); + } + if (target && !target.split('/').includes('node_modules')) { + node.edges.push(target); + if (!nodes.has(target)) queue.push(target); + } + continue; + } + const name = workspaceNameOf(spec, names); + if (name) node.packageNames.add(name); + } + nodes.set(rel, node); + } + + // Tarjan, iteratively (the graph is deep enough to overflow a recursive walk). + let index = 0; + const idx = new Map(); + const low = new Map(); + const onStack = new Set(); + const stack = []; + const sccOf = new Map(); + /** @type {string[][]} */ + const sccs = []; + for (const start of nodes.keys()) { + if (idx.has(start)) continue; + const work = [[start, 0]]; + idx.set(start, index); + low.set(start, index); + index++; + stack.push(start); + onStack.add(start); + while (work.length) { + const frame = work[work.length - 1]; + const [v, i] = frame; + const edges = nodes.get(v).edges; + if (i < edges.length) { + frame[1]++; + const w = edges[i]; + if (!idx.has(w)) { + idx.set(w, index); + low.set(w, index); + index++; + stack.push(w); + onStack.add(w); + work.push([w, 0]); + } else if (onStack.has(w)) { + low.set(v, Math.min(low.get(v), idx.get(w))); + } + continue; + } + work.pop(); + if (work.length) { + const parent = work[work.length - 1][0]; + low.set(parent, Math.min(low.get(parent), low.get(v))); + } + if (low.get(v) === idx.get(v)) { + const members = []; + let w; + do { + w = stack.pop(); + onStack.delete(w); + sccOf.set(w, sccs.length); + members.push(w); + } while (w !== v); + sccs.push(members); + } + } + } + + // Tarjan emits components in reverse topological order: every component a + // component points at has already been emitted, so one forward fold is exact. + const sccSummary = []; + for (let c = 0; c < sccs.length; c++) { + const summary = { packageNames: new Set(), dirs: new Set(), readsDisk: false, files: 0 }; + for (const member of sccs[c]) { + const node = nodes.get(member); + summary.files++; + summary.dirs.add(dirOf(member)); + if (node.readsDisk) summary.readsDisk = true; + for (const n of node.packageNames) summary.packageNames.add(n); + for (const e of node.edges) { + const d = sccOf.get(e); + if (d === c) continue; + const child = sccSummary[d]; + if (child.readsDisk) summary.readsDisk = true; + for (const n of child.packageNames) summary.packageNames.add(n); + for (const x of child.dirs) summary.dirs.add(x); + summary.files += child.files; + } + } + sccSummary.push(summary); + } + const out = new Map(); + for (const rel of nodes.keys()) out.set(rel, sccSummary[sccOf.get(rel)]); + return out; +} + +/** Tracked files, repository-relative. */ +export function trackedFiles(root = REPO_ROOT) { + return execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 }) + .split('\0') + .filter(Boolean); +} + +/** The files changed between the merge base of `base` and `head`, and `head`. */ +export function changedFiles(root, base, head = 'HEAD') { + return execFileSync('git', ['diff', '--name-only', '--no-renames', `${base}...${head}`], { + cwd: root, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + }) + .split('\n') + .filter(Boolean); +} + +/** The root vitest config and every `vitest.setup*` file beside it. */ +export function rootTestConfigFiles(tracked) { + return tracked.filter((f) => ROOT_TEST_CONFIG.includes(f) || /^vitest\.setup[^/]*\.[cm]?[jt]sx?$/.test(f)).sort(); +} + +/** + * The `dom-heavy` project's setup file and test list, read out of the root + * config text the way `scripts/unit-registry-collision.mjs` reads `domTsTests`. + * Throws when the shape is not there: the caller's answer to "cannot read" is a + * full run. + * + * @param {string} configText `vitest.config.mts` + * @returns {{ setupFile: string, tests: string[] }} + */ +export function readHeavyProject(configText) { + const block = configText.match( + /name: 'dom-heavy',[\s\S]*?setupFiles: \[path\.resolve\(__dirname, '([^']+)'\)\],[\s\S]*?include: \[\.\.\.heavyDomTests\]/, + ); + if (!block) throw new Error("vitest.config.mts: cannot find the `dom-heavy` project's setupFiles and `include: [...heavyDomTests]`"); + const list = configText.match(/const heavyDomTests = \[([\s\S]*?)\n\];/); + if (!list) throw new Error('vitest.config.mts: cannot find `const heavyDomTests = [...]`'); + const tests = list[1] + .split('\n') + .map((l) => l.trim()) + .filter((l) => l.startsWith("'") || l.startsWith('"')) + .map((l) => l.replace(/^['"]/, '').replace(/['"],?$/, '')); + if (tests.length === 0) throw new Error('vitest.config.mts: `heavyDomTests` read as empty -- a broken reader, not an empty project'); + return { setupFile: block[1], tests }; +} + +/** + * The verdict for one change. + * + * @param {{ root?: string, changed: string[], tracked?: string[] }} input + * @returns {{ mode: 'full', reasons: string[] } | { mode: 'affected', changedPackages: string[], affectedPackages: string[], heavyEdge: string[], alwaysOn: Array<{ file: string, why: string }>, filters: string[], testFileCount: number, selectedTestFileCount: number }} + */ +export function selectAffectedTests({ root = REPO_ROOT, changed, tracked = trackedFiles(root) }) { + const packages = listWorkspacePackages(root); + const names = new Set(packages.map((p) => p.name)); + const { dependencyClosure, dependentClosure } = packageClosures(packages); + const dirOf = (rel) => owningPackage(rel, packages)?.dir ?? ''; + + // What the root config and its setup files reach. Walked file by file + // because the answer needed is the FILES, not a summary. + const reach = (startFiles) => { + const files = new Set(); + const pkgs = new Set(); + const queue = [...startFiles]; + while (queue.length) { + const rel = queue.pop(); + if (files.has(rel)) continue; + files.add(rel); + let source = ''; + try { + source = fs.readFileSync(path.join(root, rel), 'utf8'); + } catch { + source = ''; + } + for (const spec of moduleSpecifiers(source).specifiers) { + if (spec.startsWith('.')) { + const target = resolveRelative(root, rel, spec); + if (target && !target.split('/').includes('node_modules')) queue.push(target); + continue; + } + const name = workspaceNameOf(spec, names); + if (name) pkgs.add(name); + } + } + return { files, pkgs }; + }; + const configFiles = rootTestConfigFiles(tracked); + const heavy = readHeavyProject(fs.readFileSync(path.join(root, ROOT_TEST_CONFIG[0]), 'utf8')); + const configReach = new Set(); + const broadSetupPackages = new Set(); + const heavySetupPackages = reach([heavy.setupFile]).pkgs; + for (const file of configFiles) { + const r = reach([file]); + for (const f of r.files) if (!owningPackage(f, packages)) configReach.add(f); + // A setup file that loads the heavy one is part of the heavy family; the + // root config names every package the setups do only through them. + if (file === heavy.setupFile || r.files.has(heavy.setupFile) || ROOT_TEST_CONFIG.includes(file)) continue; + for (const n of r.pkgs) broadSetupPackages.add(n); + } + // Configs other than the root one that load the heavy setup: their whole + // directory takes the heavy edge (`apps/console`). + const heavyConfigDirs = tracked + .filter((f) => /(?:^|\/)vitest\.config\.[^/]+$/.test(f) && f.includes('/') && !ROOT_TEST_CONFIG.includes(f)) + .filter((f) => { + try { + return fs.readFileSync(path.join(root, f), 'utf8').includes(path.basename(heavy.setupFile)); + } catch { + return false; + } + }) + .map((f) => `${path.posix.dirname(f)}/`); + + const reasons = []; + const changedPackages = new Set(); + for (const rel of changed) { + const base = rel.split('/').pop() ?? rel; + if (FULL_RUN_BASENAME.test(base)) { + reasons.push(`${rel}: a ${base} change is a full-run trigger (vitest's forceRerunTriggers, plus tsconfig)`); + continue; + } + if (configReach.has(rel)) { + reasons.push(`${rel}: the root vitest config or a setup file imports it, so every test runs through it`); + continue; + } + const owner = owningPackage(rel, packages); + if (owner) { + changedPackages.add(owner.name); + continue; + } + if (!rel.includes('/') && !ROOT_DOC_FILE.test(rel)) { + reasons.push(`${rel}: a repository-root file that is not documentation (install, toolchain or config input)`); + continue; + } + if (rel.startsWith('patches/')) { + reasons.push(`${rel}: a dependency patch changes what is installed for every test`); + continue; + } + // Anything else outside a package -- `scripts/`, `eslint-rules/`, docs, + // workflows, skills -- reaches tests only through the always-on set below. + } + + const affected = new Set(changedPackages); + for (const name of changedPackages) for (const d of dependentClosure.get(name) ?? []) affected.add(d); + for (const name of [...affected].sort()) { + if (broadSetupPackages.has(name)) { + reasons.push(`${name} is affected and a broad root vitest setup file imports it, so it reaches every test of that setup's project`); + } + } + if (reasons.length > 0) return { mode: 'full', reasons }; + const heavyEdge = [...affected].filter((n) => heavySetupPackages.has(n)).sort(); + + const testFiles = tracked.filter((f) => TEST_FILE.test(f) && !f.split('/').includes('node_modules')); + const placed = testFiles.filter((f) => { + const owner = owningPackage(f, packages); + return owner && !affected.has(owner.name); + }); + const summaries = closureSummaries(root, placed, names, dirOf); + const alwaysOn = []; + for (const file of testFiles) { + const owner = owningPackage(file, packages); + if (!owner) { + alwaysOn.push({ file, why: 'outside every workspace package' }); + continue; + } + if (affected.has(owner.name)) continue; // its directory is selected whole + const closure = summaries.get(file); + if (closure.readsDisk) { + alwaysOn.push({ file, why: 'reads the disk or spawns a process' }); + continue; + } + const outside = [...closure.dirs].find((d) => d !== owner.dir); + if (outside !== undefined) { + alwaysOn.push({ file, why: `reaches ${outside === '' ? 'the repository root' : outside} outside ${owner.dir}` }); + continue; + } + const allowed = dependencyClosure.get(owner.name) ?? new Set(); + const undeclared = [...closure.packageNames].find((n) => n !== owner.name && !allowed.has(n)); + if (undeclared) { + alwaysOn.push({ file, why: `imports ${undeclared}, which ${owner.name} does not depend on` }); + } + } + + const dirFilters = packages + .filter((p) => affected.has(p.name) && testFiles.some((f) => f.startsWith(`${p.dir}/`))) + .map((p) => `${p.dir}/`); + const heavyFilters = heavyEdge.length > 0 ? [...heavy.tests.filter((t) => tracked.includes(t)), ...heavyConfigDirs] : []; + const filters = dedupeFilters([...dirFilters, ...heavyFilters, ...alwaysOn.map((a) => a.file)]); + const selected = testFiles.filter((f) => filters.some((flt) => f.toLowerCase().includes(flt.toLowerCase()))); + + return { + mode: 'affected', + changedPackages: [...changedPackages].sort(), + affectedPackages: [...affected].sort(), + heavyEdge, + alwaysOn, + filters, + testFileCount: testFiles.length, + selectedTestFileCount: selected.length, + }; +} + +/** + * Vitest matches each positional filter as a SUBSTRING of the test path and + * takes the union, and `scripts/vitest-invocation-guard.mjs` refuses an argv in + * which one filter contains another (objectui#7814) -- `a.test.ts` is inside + * `a.test.tsx`. Keep the broader filter: it already matches the narrower one's + * file, so dropping the narrower one selects nothing less. + * + * @param {string[]} filters + */ +export function dedupeFilters(filters) { + const unique = [...new Set(filters)].sort((a, b) => a.length - b.length || a.localeCompare(b)); + const kept = []; + for (const f of unique) { + if (!kept.some((k) => f.toLowerCase().includes(k.toLowerCase()))) kept.push(f); + } + return kept.sort(); +} + +function readFlag(argv, flag) { + const i = argv.indexOf(flag); + return i === -1 ? undefined : argv[i + 1]; +} + +function main(argv) { + const base = readFlag(argv, '--base'); + const out = readFlag(argv, '--out'); + const explain = argv.includes('--explain'); + if (!base || (!out && !explain)) { + console.error('usage: select-affected-tests.mjs --base (--out | --explain)'); + return 2; + } + const changed = changedFiles(REPO_ROOT, base); + console.log(`changed files against the merge base of ${base}: ${changed.length}`); + const verdict = selectAffectedTests({ changed }); + if (verdict.mode === 'full') { + console.log('FULL RUN — this change reaches something every test runs through:'); + for (const r of verdict.reasons) console.log(` - ${r}`); + console.log('mode=full'); + return 0; + } + console.log(`changed packages: ${verdict.changedPackages.join(', ') || '(none)'}`); + console.log(`affected packages (changed + dependents): ${verdict.affectedPackages.join(', ') || '(none)'}`); + if (verdict.heavyEdge.length > 0) { + console.log(`the heavy DOM setup imports affected ${verdict.heavyEdge.join(', ')}: its projects' test files are selected`); + } + console.log(`always-on test files (the package graph cannot place them): ${verdict.alwaysOn.length}`); + if (explain) for (const a of verdict.alwaysOn) console.log(` - ${a.file}: ${a.why}`); + console.log( + `selected ${verdict.selectedTestFileCount} of ${verdict.testFileCount} tracked test files through ${verdict.filters.length} filter(s)`, + ); + if (verdict.filters.length === 0) { + // The always-on set is never empty on this tree; an empty selection is a + // broken reader, and a broken reader must not turn into a green shard. + console.error('::error::the affected selection is EMPTY — refusing to report a run that tests nothing'); + return 2; + } + if (out) fs.writeFileSync(out, `${verdict.filters.join('\n')}\n`); + console.log('mode=affected'); + return 0; +} + +if (isEntrypoint(import.meta.url)) { + try { + process.exitCode = main(process.argv.slice(2)); + } catch (error) { + console.error(`::error::select-affected-tests could not decide: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 2; + } +}