From 3e9675e2059f5a352081e367a67292ab2aa1779f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 01:40:25 +0000 Subject: [PATCH] ci: delete the spec-main-shape-gate copy; the spec shape gate runs in objectstack Executes ruling B on objectstack-ai/objectstack#23238 (comment 6145029395), the objectui half: the spec shape gate moved to objectstack (`.github/workflows/spec-shape-gate.yml` there, hourly, read by its release gate), and objectui drops its copy now that the two gates agree. - delete `.github/workflows/spec-main-shape-gate.yml`, `scripts/spec-main-shape-gate.mjs` and its pin test; - `scripts/dependabot-merge-gate.mjs`: drop the `NOT_A_GATE` entry and the `REQUIRED_CONTEXTS` note for a context no workflow produces any more (the partition test refuses a classified name nothing produces), and the objectui#12240 describe block that pinned it; - `scripts/check-required-check-set.mjs`: the tombstone now says the workflow is gone, so the name must not come back to the ruleset; - `.github/workflows/repo-gates.yml`: header comment brought up to date; - `content/docs/guide/ci-cd-pipeline.md`: inventory row and section replaced by a short section naming the successor and the retirement registry, and why `turbo.json` keeps the injection marker in `globalDependencies`; - `scripts/__tests__/ci-cd-pipeline-doc.test.ts`: the two declared non-run commands of the deleted section removed. Claude-Session: https://claude.ai/code/session_016PVjjuVPte5XEJvaQx1E4t Co-authored-by: Claude --- .github/workflows/repo-gates.yml | 5 +- .github/workflows/spec-main-shape-gate.yml | 425 ----- content/docs/guide/ci-cd-pipeline.md | 160 +- scripts/__tests__/ci-cd-pipeline-doc.test.ts | 14 - .../__tests__/dependabot-merge-gate.test.ts | 47 - .../__tests__/spec-main-shape-gate.test.ts | 799 ---------- scripts/check-required-check-set.mjs | 14 +- scripts/dependabot-merge-gate.mjs | 6 - scripts/spec-main-shape-gate.mjs | 1387 ----------------- 9 files changed, 50 insertions(+), 2807 deletions(-) delete mode 100644 .github/workflows/spec-main-shape-gate.yml delete mode 100644 scripts/__tests__/spec-main-shape-gate.test.ts delete mode 100644 scripts/spec-main-shape-gate.mjs diff --git a/.github/workflows/repo-gates.yml b/.github/workflows/repo-gates.yml index 082855c9e5..f4c959cc77 100644 --- a/.github/workflows/repo-gates.yml +++ b/.github/workflows/repo-gates.yml @@ -21,8 +21,9 @@ name: Repo Gates # reads the live set; when this fold landed the ruleset named # `Governed Surface Queue Guard`, `Changeset Declaration` and # `Spec Main Shape Gate`, which is why those three workflows were NOT folded -- -# the last has since left the set by objectui#12240's ruling and keeps its own -# workflow for its hourly run on `main`), so the queue is unchanged. The one in-tree reader +# the last has since left the set by objectui#12240's ruling, and objectui#12352 +# deleted its workflow when the spec shape gate moved to objectstack), 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`. # diff --git a/.github/workflows/spec-main-shape-gate.yml b/.github/workflows/spec-main-shape-gate.yml deleted file mode 100644 index b02b42c866..0000000000 --- a/.github/workflows/spec-main-shape-gate.yml +++ /dev/null @@ -1,425 +0,0 @@ -name: Spec Main Shape Gate - -# Compile THIS repository against `@objectstack/spec` built from objectstack -# `main`, and name the objectui file and the objectstack commit when it does not -# compile (objectui#9860). -# -# ## The ruling this executes -# -# The maintainer chose option C on 2026-09-18: the ONLY shape gate for -# `@objectstack/spec`'s public surface is a real consumer compiling against -# objectstack's current `main`. objectui is the first consumer. It supersedes the -# 2026-09-06 ruling that put a declaration-text snapshot in the platform -# repository; that snapshot is being reverted there, and nothing replaces it -# except this job. ⛔ The direction is not re-adjudicated here. -# -# ⭐ WHAT A RED MUST SAY is the acceptance criterion, not decoration: which -# objectui file fails against which objectstack commit, so that the objectstack -# pull request which moved the shape is the one that answers. A red saying only -# "typecheck failed" does not satisfy the ruling. `scripts/spec-main-shape-gate.mjs` -# owns that half — it attributes every diagnostic to a repo-relative objectui path -# and pairs it with the objectstack sha, in GitHub annotations and in the run -# summary, and it refuses (exit 2, loudly) to report an unattributable failure as -# a shape break. -# -# ## Why NO path filter, and why that is the opposite of an oversight -# -# This gate's subject is not the pull request's diff. It is the drift between two -# repositories, and that drift moves when NEITHER a file here nor this workflow -# changes. A `paths:` filter would make the gate report on exactly the pull -# requests whose author already knows about the spec, and skip the ones that find -# out the hard way. Unfiltered also keeps it requirable: a check that does not -# report on every pull request cannot be in a required set (objectui#3523 — a -# required check that never reports does not fail the pull request, it leaves it -# pending until the ruleset's 60-minute timeout). -# -# ## ADVISORY, not required: the full battery on `main`, a signal on a PR -# -# objectui#12240 executes the maintainer's ruling B on objectui#12214's open -# question 1 (ACCEPT 6123370050), the two-speed doctrine of objectstack#22998 -# applied to this gate: the context leaves the merge queue's required set. It -# was the pull request's critical path, and most of its reds are objectstack -# drift rather than the pull request's own change (the measurement is on -# objectui#12214, not copied here). ⛔ NOTHING it verifies changed -- the -# bypass, the anti-false-green marker and the attribution below are as they -# were. What changed is who WAITS for the verdict: -# -# - `schedule`, hourly on `main`, is its place in the full battery. -# ⭐ A red on that run is a MAIN-RED for the `domain:devx` lane at objectui, -# under objectstack#22998's rule 3: it is that lane's first work, the -# lane's next pull request carries the fix or the pin bump, and no pull -# request of that lane arms the queue while the red stands. -# - `pull_request` posts the same verdict as an advisory check. A human reads -# it; nothing waits for it. -# - `merge_group` is NOT subscribed (objectui#12252), the other half of the -# same ruling: the context is not in the queue's required set, so a -# verdict on a queue build would be read by nobody while every merge group -# paid a runner for it. (Whether the ruleset says so is -# `scripts/check-required-check-set.mjs`'s reading, ⛔ not this comment's.) -# ⛔ Re-subscribe `merge_group` BEFORE re-enrolling the context, never -# after: a required context that never reports on a queue build stalls -# it at the ruleset's 60-minute timeout with nothing red to point at -# (objectui#3523 -- subscribe first, enrol second). `merge-queue-reporting` -# derives that floor from `REQUIRED_CONTEXTS`, and -# `scripts/__tests__/spec-main-shape-gate.test.ts` pins the absence, so -# re-enrolling edits both on purpose rather than by accident. -# -# ⚠️ Requiredness is repository SETTINGS (GitHub ruleset 11776024), which no -# file in this tree can set, so "advisory" is not a mechanism in this file. -# ⛔ Never add `continue-on-error` to make it look so: it would hide the verdict -# on the run and change nothing about who waits for it. The in-tree halves are -# `scripts/dependabot-merge-gate.mjs`, which classifies the name in -# `NOT_A_GATE` again (a Dependabot bump does not wait for it either), and -# `scripts/check-required-check-set.mjs`, which no longer watches it. Whether -# the ruleset still lists it is that script's reading, ⛔ not this comment's. -# -# ## The fetch shape: sparse, blobless, at a sha -# -# The pin discussion of 2026-09-18 excluded a full clone per run. Measured -# against this repository's CI on 2026-09-18 (three shapes, one box, curl's and -# git's own counters — the numbers are in objectui#9860's pull request, not -# copied here, because a figure in a comment is derived once and never again): -# the blobless + depth-1 + cone-sparse clone transfers the least by a wide -# margin, because a packed codeload tarball has no server-side path filter and -# still ships the whole repository. Each directory in the cone is there because -# a step below fails without it: -# - `packages/spec` is the subject. -# - `scripts`: the spec's own build script calls scripts from the objectstack -# repository root, so a cone without it fails in the build step. -# - `patches`: objectstack's root `pnpm-workspace.yaml` declares -# `patchedDependencies`, and pnpm reads and hashes EVERY patch file that map -# names on any install, filtered or not, so a cone without it fails in the -# install step before anything is built (objectui#11024). The cone holds the -# directory `pnpm patch-commit` writes to; a patch declared anywhere else -# 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 manual dispatch on a branch) 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 -# list. It does NOT cover the content of `node_modules`, which is the only thing -# this job changes — so a cached `type-check` can replay a verdict taken against -# a DIFFERENT spec, including the published one. The typecheck below therefore -# runs with `TURBO_FORCE=true` and no cache action, and -# `scripts/__tests__/spec-main-shape-gate.test.ts` pins that it keeps doing so. - -on: - pull_request: - branches: [main, develop] - 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 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 }} - # Superseded pull-request runs are cancelled; a run on `main` (the hourly - # `schedule`, or a dispatch) never is. A cancelled check is not `success`, and - # the scheduled run is the one place this gate's verdict is waited on, so a - # cancellation there is a missing reading rather than a saved runner minute. - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - -jobs: - shape-gate: - name: Spec Main Shape Gate - runs-on: ubuntu-latest - # Sized against measurements, not taste: the spec's own build and this - # repository's forced (uncached) build + typecheck each cost minutes, and the - # runner is slower than the box they were measured on. A ceiling this job can - # reach turns a verdict into `cancelled`, which reads as a red on a pull - # request or on `main` and reports nothing a reader can act on. - timeout-minutes: 45 - - steps: - - name: Checkout objectui - uses: actions/checkout@v7 - # No history is read by this gate: it compares two trees, not two commits - # of one. Nothing here diffs against a merge base. - - # ── The objectstack commit under test ──────────────────────────────── - # Resolved ONCE, into an output every later step and the report quote, so - # the sha in the annotation is provably the sha that was built. Resolving - # it twice would let a push between the two steps produce a report about a - # commit this job never compiled. - - name: Resolve objectstack main - id: upstream - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - commit_json="$RUNNER_TEMP/objectstack-main.json" - curl -sS --fail-with-body \ - -H "Authorization: Bearer $GH_TOKEN" \ - -H "Accept: application/vnd.github+json" \ - https://api.github.com/repos/objectstack-ai/objectstack/commits/main \ - -o "$commit_json" - sha=$(jq -r '.sha' "$commit_json") - # A gate that could not take its reading is a failure, never a pass. - if [ -z "$sha" ] || [ "$sha" = 'null' ]; then - echo "::error::could not resolve objectstack-ai/objectstack@main — no sha in the API response. This job has no subject; it is red rather than green." - exit 1 - fi - subject=$(jq -r '.commit.message' "$commit_json" | head -n 1) - authored=$(jq -r '.commit.committer.date' "$commit_json") - echo "sha=$sha" >> "$GITHUB_OUTPUT" - 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 }} - run: | - set -euo pipefail - # `--filter=blob:none` fetches file contents lazily, `--depth=1` drops - # history, and the cone below decides which blobs are ever fetched at - # all. `scripts` is in the cone because the spec's build calls scripts - # from the objectstack repository root; `patches` because the install - # reads every patch file the root `pnpm-workspace.yaml` declares in - # `patchedDependencies` (the header says why each one is needed). - git clone --depth=1 --filter=blob:none --no-checkout --single-branch \ - --branch main https://github.com/objectstack-ai/objectstack \ - "$RUNNER_TEMP/objectstack" - cd "$RUNNER_TEMP/objectstack" - git sparse-checkout init --cone - git sparse-checkout set packages/spec scripts patches - # Check out the resolved SHA, not `main`: `main` may have moved between - # the step above and this one, and the report names the sha. - git fetch --depth=1 origin "$UPSTREAM_SHA" - git checkout --detach "$UPSTREAM_SHA" - echo "objectstack HEAD: $(git rev-parse HEAD)" - - - name: Enable Corepack and download the pinned pnpm - run: bash scripts/ci-setup-pnpm.sh - - - name: Verify pnpm version - run: pnpm --version - - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: '22.x' - cache: 'pnpm' - - # ── 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. 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 - 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 - - # Replaces the installed spec in pnpm's virtual store — REMOVE-then-copy, - # because those files are hardlinks into the global pnpm store that - # `actions/setup-node` caches, and writing through one would poison that - # cache for every later job. The script proves the injection reached every - # workspace consumer before returning; nothing tracked by git is touched, - # so the pin and the lockfile are exactly as committed. - # - # It is handed the objectstack checkout the spec was BUILT in - # (objectui#10229): a declared dependency whose installed store sibling - # does not satisfy the packed manifest's range (zod, when objectstack - # raises its floor) is re-pointed at the copy that build resolved, so the - # declarations are read against the dependency they were emitted against. - # Each substitution is printed here and in the run summary. - - name: Inject the source-built spec into the install - env: - UPSTREAM_SHA: ${{ steps.upstream.outputs.sha }} - TARBALL: ${{ steps.pack.outputs.tarball }} - UPSTREAM_CHECKOUT: ${{ runner.temp }}/objectstack - run: node scripts/spec-main-shape-gate.mjs inject --tarball "$TARBALL" --sha "$UPSTREAM_SHA" --upstream-checkout "$UPSTREAM_CHECKOUT" - - # ── The compile ────────────────────────────────────────────────────── - # `pnpm type-check` is `turbo run type-check`, whose task dependsOn - # `^build`, so this builds every workspace package against the injected - # spec and then type-checks against those declarations. Both halves are the - # consumer compile this gate exists to be. - # - # The exit code is captured with NO PIPE in between: `node … | tail` reports - # the PIPE's status and `tail` essentially never fails, so a red and a green - # would read identically — the exact class of silent pass this repository - # has been bitten by before. - # - # ⛔ `--continue` IS THE READING, not a convenience. Without it turbo stops - # scheduling the moment one task fails, so the log this gate parses holds - # the diagnostics of the FIRST package that broke and nothing about the - # packages it never asked. That is a lower bound reported as a set — and - # the harm is not hypothetical while any single long-lived break exists in - # the graph: every run stops there, and every other package's drift against - # the spec stays invisible for as long as that one break is open. Measured - # on this repository, two packages broken on purpose, same command - # otherwise: without the flag turbo reported one of them and counted six - # tasks, with it both and eight. `--continue` still exits non-zero when a - # task failed, so the verdict above is unchanged — only its completeness is. - - name: Type-check objectui against it - id: compile - run: | - set +e - TURBO_FORCE=true pnpm type-check --continue > "$RUNNER_TEMP/typecheck.log" 2>&1 - code=$? - set -e - echo "status=$code" >> "$GITHUB_OUTPUT" - cat "$RUNNER_TEMP/typecheck.log" - exit 0 - - # ALWAYS: this step IS the delivery. It writes the summary, emits one - # annotation per diagnostic naming the objectstack commit, and carries the - # verdict — exit 1 for an attributed shape break, exit 2 for a failure it - # could not attribute to a file (which is not a pass and is not reported as - # a shape break either). - - name: Report which objectui file fails against which objectstack commit - if: always() - env: - UPSTREAM_SHA: ${{ steps.upstream.outputs.sha }} - UPSTREAM_COMMIT: ${{ steps.upstream.outputs.commit }} - COMPILE_STATUS: ${{ steps.compile.outputs.status }} - run: | - node scripts/spec-main-shape-gate.mjs report \ - --log "$RUNNER_TEMP/typecheck.log" \ - --sha "$UPSTREAM_SHA" \ - --commit "$UPSTREAM_COMMIT" \ - --status "${COMPILE_STATUS:-2}" diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index 5cc969e68c..8093708281 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -29,7 +29,6 @@ one has its own section below. | `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 | | `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**; manual; hourly `schedule` on `main` | No — **advisory** since [#12240](https://github.com/objectstack-ai/objectui/issues/12240): the maintainer's ruling took the context out of the required set, and `scripts/dependabot-merge-gate.mjs` classifies it `NOT_A_GATE`. Its verdict is unchanged — a shape break is a real red naming the objectui file and the objectstack commit ([#9860](https://github.com/objectstack-ai/objectui/issues/9860)) — and a red on the hourly `schedule` run on `main` is a main-red for the `domain:devx` lane | | `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` | @@ -593,11 +592,12 @@ job, and nothing was weakened in the fold: **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 +(**Changeset Declaration**), the Spec Main Shape Gate and `governed-surface-guard.yml` were not folded: their contexts were required when the fold landed, and a step inside a job is not a check run (the spec gate's has since left the set, by -[#12240](https://github.com/objectstack-ai/objectui/issues/12240), and keeps its own workflow for its -hourly run on `main`). The one in-tree +[#12240](https://github.com/objectstack-ai/objectui/issues/12240), and its workflow was deleted by +[#12352](https://github.com/objectstack-ai/objectui/issues/12352) when the gate +[moved to objectstack](#spec-shape-gate--moved-to-objectstack)). 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. @@ -2109,122 +2109,42 @@ Uses [Lychee](https://github.com/lycheeverse/lychee) with configuration from `ly excluded wholesale. Judging those routes is `check-doc-links.mjs`'s job, and duplicating its route-to-file mapping here would only create a second copy free to drift. -## Spec Main Shape Gate (`spec-main-shape-gate.yml`) - -**Trigger:** PR to `main`, `develop` — **no path filter**; 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 -against it. This is the maintainer's option C, ruled 2026-09-18 -([#9860](https://github.com/objectstack-ai/objectui/issues/9860)): under it, a real consumer's -compile is the **only** shape gate for that package's public surface, replacing the -declaration-text snapshot the platform repository is reverting. objectui is the first consumer. - -**What a red says, and why that is the point.** Not "typecheck failed" — the job names *which -objectui file* stopped compiling and *which objectstack commit* it was compiled against, as -GitHub annotations on the failing lines and as a table in the run summary, so the objectstack pull -request that moved the shape is the one that answers. `scripts/spec-main-shape-gate.mjs` owns that -half, and it refuses to report a failure it could not attribute to a file as a shape break at all: -that exits 2 and says so, rather than billing the wrong repository. - -**How it reaches the spec without moving the pin.** This repository consumes a *published* -`@objectstack/spec` through a caret range in many manifests and a resolution in `pnpm-lock.yaml`. -The job changes none of that — the pin-bump question is a separate, open one. After the ordinary -`pnpm install --frozen-lockfile`, it replaces the installed copy **inside pnpm's virtual store** -with the source-built one, which every workspace consumer already symlinks to, and then proves -every consumer landed on it before compiling. Nothing tracked by git is touched, and the -replacement is remove-then-copy: those files are hardlinks into pnpm's global store, so writing -*through* one would corrupt that store for every later job and for the cache `actions/setup-node` -saves. - -Two mechanisms that look like the obvious answer cannot do this job, and the script's header -carries the long version: - -- **`OBJECTSTACK_SPEC_DIST`** (`scripts/vite-objectstack-spec-dist.ts`) already resolves - `@objectstack/spec` at a locally built spec — but it emits Vite `resolve.alias` entries, and - `tsc` does not read a Vite config. A typecheck run under it compiles against the *installed* - spec: a green that means nothing. It remains the right hook for the console **build**. -- **A `pnpm.overrides` / `file:` / `link:` entry** moves `pnpm-workspace.yaml`, a `package.json` - or the lockfile. Every one of those is the pin. - -**The fetch is sparse, blobless and at a sha, never a full clone per run.** The cone holds -`packages/spec` *and* `scripts`, because the spec's own build script calls scripts from the -objectstack repository root. A packed codeload tarball was measured against it and rejected: the -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 manual dispatch on a branch) 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 -compile. turbo's hash covers this repository's sources, its lockfile and a declared env list — -not the *content* of `node_modules`, which is the only thing this job changes. Cached, it would -replay a verdict taken against a different spec, including the published one. - -`--continue` is what makes the diagnostic list a *set* rather than a lower bound. turbo stops -scheduling at the first failing task unless told otherwise, so without it the parsed log carries -the first broken package's diagnostics and says nothing at all about the packages turbo never -asked — while reporting that silence as the reading. The harm is not hypothetical: while one -long-lived break sits in the graph, every run stops at it and no other package's drift against -the spec is ever measured. The flag does not soften the verdict — turbo still exits non-zero -when a task failed — it only completes it. The ablation behind that claim, two packages broken -on purpose with the same command otherwise, is in this gate's own pull request rather than -copied here. - -**The attribution reads the log order turbo actually emits.** `tsc` prints paths relative to the -package directory, so `src/foo.ts(3,9)` names no file until something says which package. turbo -carries that in one of two carriers depending on where it runs: a `::` prefix on -every line locally, and on a GitHub Actions runner a `##[group]:` header with the -output emitted bare beneath it — with the *failing* task announced by a bare colourised header -and no group at all. Only the second ever reaches this job, and a prefix-only reading has nothing -to match in it. `scripts/__tests__/spec-main-shape-gate.test.ts` pins the bypass, the completeness -flag, the unfiltered trigger, the absence of a queue subscription and both attribution carriers, -each with the firing control that proves the assertion is not satisfied by something else. - -**How it became a required check, and how it left the set.** Requiredness is a GitHub ruleset — -repository settings — which `scripts/check-required-check-set.mjs` can read and nothing in this tree -can write. The workflow was built to be requirable (unfiltered on `pull_request`, subscribed to -`merge_group`, no `continue-on-error`), so the maintainer could enrol the context on its own without -stalling the queue. It waited for the day-one break to clear — its first run was red on one objectui -file over an objectstack commit — and was enrolled under -[#9969](https://github.com/objectstack-ai/objectui/issues/9969)'s ruling A. The in-tree half of that -action came second: the name moved from `NOT_A_GATE` to `REQUIRED_CONTEXTS` in -`scripts/dependabot-merge-gate.mjs`. - -[#12240](https://github.com/objectstack-ai/objectui/issues/12240) reversed the enrolment on the -maintainer's ruling B, the two-speed doctrine of -[objectstack#22998](https://github.com/objectstack-ai/objectstack/issues/22998) applied to this gate: -it was the pull request's critical path, and most of its reds are objectstack drift rather than the -pull request's own change. **Nothing it verifies changed**, and it still runs on every event above; -what changed is who waits for its verdict. - -- On `main`'s hourly `schedule` run it is part of the full battery, and **a red there is a main-red - for the `domain:devx` lane**: the lane's next pull request carries the fix or the pin bump, and no - pull request of that lane arms the queue while the red stands. -- On a pull request it posts the same verdict as an advisory check. Advisory is not a mechanism in - the workflow — requiredness is the ruleset's — so it carries no `continue-on-error`, which would - hide the verdict and change nothing about who waits for it. -- The name moved back to `NOT_A_GATE` in `scripts/dependabot-merge-gate.mjs`, so a Dependabot bump - does not wait for it either, and `scripts/check-required-check-set.mjs` no longer watches it. -- It does not run on queue builds. [#12252](https://github.com/objectstack-ai/objectui/issues/12252) - dropped the `merge_group` trigger once the context was out of the queue's required set: a verdict - on a merge group is read by nobody, and every merge group paid a runner for it. The way back is - two edits in this order — re-subscribe `merge_group`, then enrol the context — because a required - context that never reports on a queue build stalls it at the ruleset's 60-minute timeout. The - test file pins the absence, so re-enrolling edits both on purpose. - -Whether the context is enrolled today is not answered here; the patrol's reading answers it. +## Spec Shape Gate — moved to objectstack + +The gate that compiled this repository against `@objectstack/spec` built from objectstack `main` +no longer runs here. Under the maintainer's ruling B on +[objectstack#23238](https://github.com/objectstack-ai/objectstack/issues/23238), the shape gate +moved to the repository whose commits change the shape, and +[#12352](https://github.com/objectstack-ai/objectui/issues/12352) deleted objectui's copy — its +workflow, its script and its pin test — once the two gates had read the same diagnostics on +consecutive hourly runs (the readings are on that card, not restated here). The gate's history in +this repository — option C of [#9860](https://github.com/objectstack-ai/objectui/issues/9860), its +enrolment under [#9969](https://github.com/objectstack-ai/objectui/issues/9969) and its removal from +the required set under [#12240](https://github.com/objectstack-ai/objectui/issues/12240) — lives on +those cards and in git history. + +- **Where it runs now.** objectstack's `.github/workflows/spec-shape-gate.yml`, hourly on objectstack + `main`. It clones objectui `main`, injects the spec built from the objectstack commit into this + repository's install, and runs this repository's `pnpm type-check` with the turbo cache bypassed, + so a red still names the objectui file and the objectstack commit. objectstack's release gate + (`scripts/ci/scheduled-full-run.mjs` there) reads those runs, and a red is attributed to the + objectstack lane that moved the shape. Nothing in this repository waits for it: no workflow here + produces the Spec Main Shape Gate context any more, and `scripts/check-required-check-set.mjs` + keeps a tombstone saying the name must not come back to the ruleset. +- **Where an intentional retirement is registered.** An objectstack change that removes something + objectui still compiles against, on purpose, is registered in objectstack's + `scripts/ci/spec-shape-retirements.json`, beside the objectui card that removes the downstream + use. The gate reads a diagnostic matching a registered retirement as known rather than new; + that file's own header states the entry grammar and when an entry is deleted. +- **What stays here.** `turbo.json` still names + `node_modules/@objectstack/spec/.spec-main-shape-gate.json` in `globalDependencies`. objectstack's + injection writes that marker — the name is kept on purpose — into every copy of the spec it + replaces, so a tree holding an injected spec never shares a turbo hash with an installed one. That + matters when somebody reproduces the gate locally in a linked worktree, whose `.turbo/cache` is + shared with its siblings: a forced run skips cache reads but still writes, and without the marker + an ordinary `pnpm type-check` in a sibling could replay a verdict taken against the injected spec + ([#12114](https://github.com/objectstack-ai/objectui/issues/12114)). An installed tree carries no + marker, and an absent global dependency hashes as nothing. ## Release Workflows diff --git a/scripts/__tests__/ci-cd-pipeline-doc.test.ts b/scripts/__tests__/ci-cd-pipeline-doc.test.ts index d19fb00ebf..cbbd8cdeae 100644 --- a/scripts/__tests__/ci-cd-pipeline-doc.test.ts +++ b/scripts/__tests__/ci-cd-pipeline-doc.test.ts @@ -3565,20 +3565,6 @@ function inheritedDeclarations(): Map { * STALE rather than quietly widening the hole. */ const SWEEP_DECLARED_NON_RUN_COMMANDS = new Map([ - [ - 'spec-main-shape-gate.yml: scripts/dependabot-merge-gate.mjs', - 'Named as the registry that classifies this context: `REQUIRED_CONTEXTS` from ' + - 'objectui#9969 (the in-tree half of the enrolment), `NOT_A_GATE` since objectui#12240 ' + - '(the in-tree half of its removal from the required set). A declaration this section ' + - 'points at, not a step this workflow runs (objectui#9860, objectui#9969, objectui#12240).', - ], - [ - 'spec-main-shape-gate.yml: scripts/check-required-check-set.mjs', - 'Named as the one thing in this tree that can READ the ruleset where requiredness actually ' + - 'lives — the instrument that says whether this context is still enrolled, which the ' + - "section deliberately does not answer. A different workflow's instrument, cited to keep " + - 'the claim falsifiable (objectui#9860, objectui#9969, objectui#12240).', - ], [ 'changeset-guard.yml: scripts/check-changeset-presence.mjs', 'Introduced by the words "Deliberately not listed": the overwrite gate imports that ' + diff --git a/scripts/__tests__/dependabot-merge-gate.test.ts b/scripts/__tests__/dependabot-merge-gate.test.ts index ba3a9cd2a0..8dd6f36903 100644 --- a/scripts/__tests__/dependabot-merge-gate.test.ts +++ b/scripts/__tests__/dependabot-merge-gate.test.ts @@ -392,53 +392,6 @@ describe('the path-filtered blocking checks (optional bucket)', () => { }); }); -/** - * objectui#12240 — the maintainer's ruling B on objectui#12214's open question - * 1 (ACCEPT 6123370050). `Spec Main Shape Gate` leaves the required set and is - * advisory on a pull request, so it moved back from `REQUIRED_CONTEXTS` (where - * objectui#9969's ruling A had put it) to `NOT_A_GATE`. This block pins the - * MOVE by name, in both directions: an edit that parks the name back in - * `REQUIRED_CONTEXTS` without a ruling fails here, and so does one that drops - * it from every bucket. The control comes first: the same red on a context - * that IS required still refuses the head, so each green below is about this - * one name and not about a snapshot that stopped being read. - */ -describe('Spec Main Shape Gate is advisory, not waited for (objectui#12240)', () => { - const NAME = 'Spec Main Shape Gate'; - const withSpecGate = (conclusion: string | null, status = 'completed') => [ - ...allGreenSnapshot(), - { id: 95_325_290_000, name: NAME, status, conclusion }, - ]; - - it('is declared NOT_A_GATE, with a reason, and in neither other bucket', () => { - expect(Object.keys(NOT_A_GATE)).toContain(NAME); - expect(NOT_A_GATE[NAME as keyof typeof NOT_A_GATE]).toMatch(/objectui#12240/); - expect(REQUIRED_CONTEXTS).not.toContain(NAME); - expect(Object.keys(OPTIONAL_CONTEXTS)).not.toContain(NAME); - }); - - it('CONTROL: the same red on a REQUIRED context still refuses the head', () => { - const runs = withSpecGate('success').map((run) => - run.name === 'Type Check' ? { ...run, conclusion: 'failure' } : run, - ); - expect(evaluateGate({ checkRuns: runs }).verdict).toBe('red'); - }); - - it('does not refuse a head on which it reported failure', () => { - const result = evaluateGate({ checkRuns: withSpecGate('failure') }); - - expect(result.verdict).toBe('green'); - expect(result.failing).toEqual([]); - }); - - it('does not hold a head on which it is still running, or has not reported', () => { - expect(evaluateGate({ checkRuns: withSpecGate(null, 'in_progress') }).verdict).toBe('green'); - const absent = evaluateGate({ checkRuns: allGreenSnapshot().filter((run) => run.name !== NAME) }); - expect(absent.verdict).toBe('green'); - expect(absent.missing).toEqual([]); - }); -}); - describe('waitForGate: the deadline fails closed', () => { const fakeClock = () => { let nowMs = 0; diff --git a/scripts/__tests__/spec-main-shape-gate.test.ts b/scripts/__tests__/spec-main-shape-gate.test.ts deleted file mode 100644 index 251e2c8b27..0000000000 --- a/scripts/__tests__/spec-main-shape-gate.test.ts +++ /dev/null @@ -1,799 +0,0 @@ -import { afterEach, 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 { - parseDiagnostics, - renderSummary, - declaredTypesTargets, - satisfiesRange, - parseWorkspaceGlobs, - MARKER_FILE, - SPEC_PACKAGE_NAME, - TURBO_HASH_MARKER, -} from '../spec-main-shape-gate.mjs'; -import { pullRequestTrigger, readWorkflows, repoRoot, subscribesMergeGroup } from './workflow-checks.js'; - -/** - * objectui#9860 — the maintainer's option C: the ONLY shape gate for - * `@objectstack/spec`'s public surface is a real consumer compiling against - * objectstack's `main`, and objectui is that consumer. - * - * ⭐ The card's acceptance criterion is not "there is a job". It is that a RED - * names **which objectui file fails against which objectstack commit**, so the - * objectstack pull request that moved the shape is the one that answers. A gate - * that only goes red is not this card, so that property is asserted here on a - * fixture that carries a real diagnostic shape, in both directions. - * - * ## Why the workflow assertions read COMMENT-STRIPPED lines - * - * This workflow DOCUMENTS its own subject: its header discusses the turbo cache, - * `TURBO_FORCE`, the pipeline trap and the pin, in prose. A source-text - * assertion over the raw file would therefore be satisfied by the comment that - * explains the mechanism even if the mechanism itself were deleted — the pin - * would survive the very edit it exists to catch. `readWorkflows()` strips - * comment lines, and the control below proves the stripping is live rather than - * assumed. - */ -const WORKFLOW = 'spec-main-shape-gate.yml'; -const SCRIPT = 'scripts/spec-main-shape-gate.mjs'; - -const workflow = readWorkflows().find((candidate) => candidate.file === WORKFLOW); - -/** The workflow with every comment line removed — what the assertions judge. */ -function body(): string { - return (workflow as NonNullable).lines.join('\n'); -} - -describe('the gate exists and is wired the way a requirable check has to be', () => { - it('is a workflow in this repository', () => { - expect(workflow, `${WORKFLOW} is missing from .github/workflows/`).toBeTruthy(); - expect(fs.existsSync(path.join(repoRoot, SCRIPT))).toBe(true); - }); - - it('the comment stripper is live — the control every assertion below rests on', () => { - // A phrase that exists ONLY in the header prose. If it survives stripping, - // every "the workflow contains X" assertion below could be satisfied by - // documentation rather than by the workflow, which is this file's whole - // reason for reading `lines` instead of `text`. - expect( - (workflow as NonNullable).text, - 'the header no longer carries the phrase this control keys on; pick another comment-only phrase', - ).toContain('FALSE GREEN'); - expect(body()).not.toContain('FALSE GREEN'); - }); - - it('subscribes pull_request with NO path filter, so it reports on every pull request', () => { - const trigger = pullRequestTrigger(workflow as NonNullable); - expect(trigger.subscribes).toBe(true); - // objectui#3523: a check that does not report on every pull request cannot be - // required — it leaves the PR pending rather than failing it. The gate's - // subject is cross-repository drift, which moves without any file here - // changing, so there is nothing a path filter could honestly select on. - expect(trigger.filtered, 'a path filter would make this context unrequirable').toBe(false); - }); - - it('does not subscribe merge_group — re-subscribe merge_group before re-enrolling the context (objectui#12252)', () => { - // The #3523 ordering, on the way out. The context left the queue's required - // set (objectui#12240), so a queue build's verdict on this gate is read by - // nobody while every merge group pays a runner for it; the hourly run on - // `main` and the advisory run on a pull request are where it is read. - // - // This is the ruling's in-tree lock, not a ban on the trigger. The way back - // is two edits in this order — subscribe first (a pure addition), enrol - // second — and the second is the case the derived floor in - // `merge-queue-reporting.test.ts` refuses on its own: a name in - // `REQUIRED_CONTEXTS` whose workflow lacks the trigger. The reverse order is - // the state in which every queued pull request burns the ruleset's - // 60-minute timeout with nothing red to point at. - expect( - subscribesMergeGroup(workflow as NonNullable), - `${WORKFLOW} subscribes \`merge_group\` again, so every merge group runs a gate whose ` + - `verdict nothing waits for (objectui#12252). If the context is being RE-ENROLLED on a ` + - `ruling, subscribing first is the right order (objectui#3523): replace this pin with the ` + - `subscription it then owes, and enrol second — the in-tree half is \`REQUIRED_CONTEXTS\` ` + - `in scripts/dependabot-merge-gate.mjs. Otherwise drop the trigger.`, - ).toBe(false); - }); - - it('runs both halves of the gate script', () => { - expect(body()).toContain(`node ${SCRIPT} inject`); - expect(body()).toContain(`node ${SCRIPT} report`); - }); - - it('hands `inject` the objectstack checkout the spec was built in (objectui#10229)', () => { - // Without it, a dependency the source-built spec raised (zod) has no source - // but the published spec's store sibling, and the declarations are read - // against a dependency they were not emitted against. - expect(body()).toMatch( - /node scripts\/spec-main-shape-gate\.mjs inject [^\n]*--upstream-checkout "\$UPSTREAM_CHECKOUT"/, - ); - // The SAME directory the spec is built in, so the copy it names is the one - // that build resolved. - const checkout = '${{ runner.temp }}/objectstack'; - expect(body()).toContain(`UPSTREAM_CHECKOUT: ${checkout}`); - expect(body()).toMatch( - /working-directory: \$\{\{ runner\.temp \}\}\/objectstack\n\s*run: \|\n\s*set -euo pipefail\n\s*pnpm install --frozen-lockfile --filter @objectstack\/spec\.\.\./, - ); - }); -}); - -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 a manual dispatch on a branch) 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 - // the CONTENT of `node_modules`, which is the only thing this job changes. - // Cached, `type-check` replays a verdict taken against a different spec. - // Keyed on the CALL, not on the identifier: the header discusses - // `TURBO_FORCE` at length, and the stripped body is what runs. - expect(body()).toMatch(/TURBO_FORCE=true pnpm type-check/); - expect( - body(), - 'a turbo cache action here would restore the verdict this gate exists to re-take', - ).not.toMatch(/uses:\s*actions\/cache/); - }); - - it('captures the compiler exit code with no pipe in between', () => { - // `pnpm type-check | tail` reports the PIPE's status, and `tail` essentially - // never fails, so red and green would read identically. - expect(body()).toMatch( - /pnpm type-check --continue > "\$RUNNER_TEMP\/typecheck\.log" 2>&1\n\s*code=\$\?/, - ); - }); - - it('compiles EVERY package, so the diagnostic set is a set and not a lower bound', () => { - // turbo stops scheduling at the first failing task unless told otherwise, so - // without this flag the log holds the first broken package's diagnostics and - // silence about every package turbo never asked — reported as though it were - // the whole reading. While one long-lived break sits in the graph, every run - // stops there and nothing else is ever measured. - expect( - body(), - 'without --continue this gate reports the FIRST broken package and calls it the set', - ).toMatch(/pnpm type-check --continue/); - }); - - it('never moves the pin: no install flag or manifest edit that could', () => { - // The card fences the pin-bump question off as still open with the - // maintainer. The injection is post-install runner state; these are the - // spellings that would make it something else. - expect(body()).not.toContain('--no-frozen-lockfile'); - expect(body()).not.toMatch(/pnpm (add|link|update|dedupe)\b/); - expect(body()).not.toContain('pnpm-workspace.yaml'); - expect(body()).toContain('pnpm install --frozen-lockfile'); - }); -}); - -describe('the acceptance criterion: a red names the objectui FILE and the objectstack COMMIT', () => { - const sha = '96cf32b07579c0cafb6d9e58bb51930cef48e48d'; - // The shape a real break takes, captured from a real run of this gate against - // objectstack `main` — turbo prefix, package-relative path, TS code, message. - const redLog = - "@object-ui/core:build: src/utils/normalize-list-view.ts(205,3): error TS2353: Object literal " + - "may only specify known properties, and 'page' does not exist in type 'Record<\"list\" | " + - "\"detail\", string | null>'."; - - it('resolves the package-relative path a compiler prints into a path a reader can open', () => { - const { rows } = parseDiagnostics(redLog); - expect(rows).toHaveLength(1); - expect(rows[0].file).toBe('packages/core/src/utils/normalize-list-view.ts'); - expect(rows[0].line).toBe(205); - expect(rows[0].code).toBe('TS2353'); - }); - - it('attributes the GROUPED log a GitHub Actions runner actually produces', () => { - // ⚠️ The fixture above is turbo's STREAM order, which is what a local run - // emits. On an Actions runner turbo switches to GROUPED order: a - // `##[group]:` header, output emitted BARE beneath it, and - // the failing task announced by a colourised header with no group at all. - // That is the ONLY order this gate ever reads, and a prefix-only parser has - // nothing to match in it — measured on this gate's own runs, which reported - // `src/hooks/…` for a file that lives under `packages/react/`. - const ESC = String.fromCharCode(27); - const groupedLog = [ - '##[group]@object-ui/sdui-parser:type-check', - '> tsc --noEmit && tsc -p tsconfig.test.json', - '##[endgroup]', - `${ESC}[;31m@object-ui/react:type-check${ESC}[;0m`, - "src/hooks/__tests__/useNavigationOverlay.modeDefault.test.tsx(76,3): error TS2322: Type 'string' is not assignable to type 'undefined'.", - ].join('\n'); - const { rows } = parseDiagnostics(groupedLog); - expect(rows).toHaveLength(1); - expect(rows[0].file).toBe( - 'packages/react/src/hooks/__tests__/useNavigationOverlay.modeDefault.test.tsx', - ); - - // The firing control: the same diagnostic with its header removed must NOT - // resolve, or the assertion above is being satisfied by something other than - // the header it claims to read. - const headerless = parseDiagnostics(groupedLog.split('\n').slice(-1).join('\n')); - expect(headerless.rows[0].file).toBe( - 'src/hooks/__tests__/useNavigationOverlay.modeDefault.test.tsx', - ); - }); - - it('puts the file and the commit in the same report', () => { - const { rows, unmappedPackages } = parseDiagnostics(redLog); - const summary = renderSummary({ sha, rows, unmappedPackages, status: 1 }); - expect(summary).toContain('packages/core/src/utils/normalize-list-view.ts'); - expect(summary).toContain(sha); - }); - - it('refuses to report an unattributable failure as a shape break', () => { - // The ablation of the assertion above. A compile that failed somewhere the - // parser cannot see must NOT read as "objectstack moved the shape" — that - // sends the wrong repository a bill. It also must not read as a pass. - const summary = renderSummary({ sha, rows: [], unmappedPackages: [], status: 1 }); - expect(summary).toContain('no diagnostic could be attributed to a file'); - // Keyed on the CLAIM, not on a phrase: the refusal prose says the words - // "not a shape break", so a `not.toContain('shape break')` would be asserting - // that the explanation is absent rather than that the accusation is. - expect(summary).not.toMatch(/\d+ diagnostic\(s\) in \d+ objectui file\(s\)/); - expect(summary).not.toContain('| objectui file |'); - }); - - it('says so plainly when the consumer still compiles', () => { - const summary = renderSummary({ sha, rows: [], unmappedPackages: [], status: 0 }); - expect(summary).toContain('type-checks against'); - expect(summary).toContain(sha); - }); -}); - -describe('the injection refuses the inputs that would make a green meaningless', () => { - it('knows which files `tsc` will actually read out of an exports map', () => { - // A spec built with its declaration pass skipped packs, installs and - // resolves — and fails every consumer with TS2307, which reads like a - // hundred broken imports instead of one missing build step. - expect( - declaredTypesTargets({ - '.': { import: { types: './dist/index.d.mts', default: './dist/index.mjs' } }, - './ui': { browser: { require: { types: './dist/ui/index.d.ts', default: './x.js' } } }, - }), - ).toEqual(['./dist/index.d.mts', './dist/ui/index.d.ts']); - expect(declaredTypesTargets({ '.': './dist/index.js' })).toEqual([]); - }); -}); - -describe('the script re-derives its own behaviour', () => { - it('passes its offline self-test', () => { - // The CLI, not the imports: a self-test that only ran through this file's - // imports would not prove the entry point works. - const output = execFileSync('node', [path.join(repoRoot, SCRIPT), '--self-test'], { - encoding: 'utf8', - }); - expect(output).not.toContain('FAIL'); - expect(output).toMatch(/--self-test: \d+\/\d+ passed/); - }); -}); - -/** - * objectui#10229 — the gate injected the spec's FILES but not the dependency - * floor its manifest declares, so a spec built on zod 4.6 was read against the - * zod 4.4 the published spec's store entry held, and the gate reported its own - * injection as a shape break. - * - * These run the real CLI over a synthetic pnpm store. The script is COPIED into - * the fixture so that its own repo root — which `inject` derives from the - * script's location — is the fixture, never this checkout's install. - */ -describe('the injection reads the spec against the dependencies it was built with', () => { - const made: string[] = []; - afterEach(() => { - for (const dir of made.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); - }); - - function writePackage(dir: string, name: string, version: string): string { - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name, version })); - return dir; - } - - /** - * objectui's side: a store entry for the published spec with pnpm's RELATIVE - * sibling links, and one workspace consumer. objectstack's side: a checkout - * whose `packages/spec/node_modules` resolves the given versions. The tarball - * declares `dependencies`. - */ - /** Where objectui's store keeps each dependency an importer may link to. */ - const OBJECTUI_STORE_COPY: Record = { - zod: 'zod@4.4.3/node_modules/zod', - 'pg-connection-string': 'pg-connection-string@2.14.0/node_modules/pg-connection-string', - }; - - function fixture(opts: { - dependencies: Record; - upstream: Record; - /** objectui importers under `packages/`, each with the dependencies it declares. */ - importers?: Record>; - }) { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spec-gate-deps-')); - made.push(root); - const repo = path.join(root, 'objectui'); - fs.mkdirSync(path.join(repo, 'scripts'), { recursive: true }); - for (const file of ['spec-main-shape-gate.mjs', 'invoked-as.mjs']) { - fs.copyFileSync(path.join(repoRoot, 'scripts', file), path.join(repo, 'scripts', file)); - } - - const store = path.join(repo, 'node_modules', '.pnpm'); - writePackage(path.join(store, 'zod@4.4.3', 'node_modules', 'zod'), 'zod', '4.4.3'); - writePackage( - path.join(store, 'pg-connection-string@2.14.0', 'node_modules', 'pg-connection-string'), - 'pg-connection-string', - '2.14.0', - ); - const entry = path.join(store, '@objectstack+spec@17.4.0_zod@4.4.3', 'node_modules'); - const fixtureSpec = writePackage(path.join(entry, '@objectstack', 'spec'), '@objectstack/spec', '17.4.0'); - fs.symlinkSync('../../zod@4.4.3/node_modules/zod', path.join(entry, 'zod')); - fs.symlinkSync( - '../../pg-connection-string@2.14.0/node_modules/pg-connection-string', - path.join(entry, 'pg-connection-string'), - ); - const consumerModules = path.join(repo, 'packages', 'app', 'node_modules', '@objectstack'); - fs.mkdirSync(consumerModules, { recursive: true }); - fs.symlinkSync(fixtureSpec, path.join(consumerModules, 'spec')); - fs.writeFileSync( - path.join(repo, 'packages', 'app', 'package.json'), - JSON.stringify({ name: 'app', dependencies: { '@objectstack/spec': '^17.4.0' } }), - ); - fs.writeFileSync(path.join(repo, 'pnpm-workspace.yaml'), "packages:\n - 'packages/*'\n"); - fs.writeFileSync(path.join(repo, 'package.json'), JSON.stringify({ name: 'root', private: true })); - // objectui's own importers, linked the way pnpm links them: a RELATIVE - // symlink from the importer's node_modules into the virtual store. - for (const [importer, dependencies] of Object.entries(opts.importers ?? {})) { - const dir = path.join(repo, 'packages', importer); - fs.mkdirSync(path.join(dir, 'node_modules'), { recursive: true }); - fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: importer, dependencies })); - for (const name of Object.keys(dependencies)) { - fs.symlinkSync(`../../../node_modules/.pnpm/${OBJECTUI_STORE_COPY[name]}`, path.join(dir, 'node_modules', name)); - } - } - - const upstream = path.join(root, 'objectstack'); - writePackage(path.join(upstream, 'packages', 'spec'), '@objectstack/spec', '17.4.0'); - fs.mkdirSync(path.join(upstream, 'packages', 'spec', 'node_modules'), { recursive: true }); - for (const [name, version] of Object.entries(opts.upstream)) { - if (version === null) continue; - const copy = writePackage( - path.join(upstream, 'node_modules', '.pnpm', `${name}@${version}`, 'node_modules', name), - name, - version, - ); - fs.symlinkSync(copy, path.join(upstream, 'packages', 'spec', 'node_modules', name)); - } - - const pack = path.join(root, 'pack', 'package'); - fs.mkdirSync(path.join(pack, 'dist'), { recursive: true }); - fs.writeFileSync(path.join(pack, 'dist', 'index.d.ts'), 'export {};\n'); - fs.writeFileSync( - path.join(pack, 'package.json'), - JSON.stringify({ - name: '@objectstack/spec', - version: '17.4.0', - exports: { '.': { types: './dist/index.d.ts', default: './dist/index.js' } }, - dependencies: opts.dependencies, - }), - ); - const tarball = path.join(root, 'spec.tgz'); - execFileSync('tar', ['-czf', tarball, '-C', path.join(root, 'pack'), 'package']); - - const run = () => - spawnSync( - 'node', - [ - path.join(repo, 'scripts', 'spec-main-shape-gate.mjs'), - 'inject', - '--tarball', - tarball, - '--sha', - 'f'.repeat(40), - '--upstream-checkout', - upstream, - ], - { encoding: 'utf8', env: { ...process.env, GITHUB_STEP_SUMMARY: path.join(root, 'summary.md') } }, - ); - const summary = () => fs.readFileSync(path.join(root, 'summary.md'), 'utf8'); - return { repo, store, entry, installed: fixtureSpec, upstream, run, summary }; - } - - it('re-points an unsatisfied sibling at the copy the objectstack checkout resolved, and reports it', () => { - const f = fixture({ dependencies: { zod: '^4.6.1' }, upstream: { zod: '4.6.1' } }); - const upstreamZod = fs.realpathSync(path.join(f.upstream, 'packages', 'spec', 'node_modules', 'zod')); - - const result = f.run(); - expect(result.status, result.stderr).toBe(0); - - // On disk: the sibling now resolves to objectstack's zod 4.6.1 ... - expect(fs.realpathSync(path.join(f.entry, 'zod'))).toBe(upstreamZod); - // ... and the store copy it used to point at is intact: the link was - // replaced, nothing was deleted or written through it. - expect( - JSON.parse(fs.readFileSync(path.join(f.store, 'zod@4.4.3', 'node_modules', 'zod', 'package.json'), 'utf8')) - .version, - ).toBe('4.4.3'); - - // Reported once, with the range, the old link target and the new one, in the - // log and in the run summary. - const lines = result.stdout.split('\n').filter((line) => line.includes('substituted zod')); - expect(lines).toHaveLength(1); - expect(lines[0]).toContain('^4.6.1'); - expect(lines[0]).toContain('zod@4.4.3 (-> ../../zod@4.4.3/node_modules/zod)'); - expect(lines[0]).toContain(`now -> ${upstreamZod} (zod@4.6.1`); - expect(f.summary()).toContain('substituted zod'); - }); - - it('proves, of every consumer, which zod the injected spec resolves', () => { - // The "reached every consumer" proof, extended: the consumer is asked, not - // the plan. ⛔ Asserted on the named version and path, so a reading that - // merely printed "zod" would not pass. - const f = fixture({ dependencies: { zod: '^4.6.1' }, upstream: { zod: '4.6.1' } }); - const upstreamZod = fs.realpathSync(path.join(f.upstream, 'packages', 'spec', 'node_modules', 'zod')); - const result = f.run(); - expect(result.status, result.stderr).toBe(0); - expect(result.stdout).toContain(`resolves zod@4.6.1 (declared ^4.6.1) for 1 consumer(s) -> ${upstreamZod}`); - expect(result.stdout).not.toMatch(/resolves zod@4\.4\.3/); - expect(fs.existsSync(path.join(f.installed, MARKER_FILE))).toBe(true); - }); - - it('leaves a sibling that satisfies its range exactly as installed (positive control)', () => { - // The checkout offers copies that ALSO satisfy both ranges, so a pass that - // re-pointed every sibling regardless would be caught here. - const f = fixture({ - dependencies: { 'pg-connection-string': '^2.14.0', zod: '^4.4.0' }, - upstream: { 'pg-connection-string': '2.15.0', zod: '4.6.1' }, - }); - const result = f.run(); - expect(result.status, result.stderr).toBe(0); - expect(fs.readlinkSync(path.join(f.entry, 'zod'))).toBe('../../zod@4.4.3/node_modules/zod'); - expect(fs.readlinkSync(path.join(f.entry, 'pg-connection-string'))).toBe( - '../../pg-connection-string@2.14.0/node_modules/pg-connection-string', - ); - expect(result.stdout).not.toContain('substituted'); - expect(result.stdout).toContain('no dependency substitution'); - expect(f.summary()).toContain('no dependency substitution'); - expect(result.stdout).toContain('resolves zod@4.4.3 (declared ^4.4.0)'); - }); - - it('exits 2 naming the dependency when no copy satisfies the range, having written nothing', () => { - for (const upstreamZod of ['4.5.0', null]) { - const f = fixture({ dependencies: { zod: '^4.6.1' }, upstream: { zod: upstreamZod } }); - const result = f.run(); - expect(result.status, `${upstreamZod}: ${result.stdout}`).toBe(2); - expect(result.stderr).toContain('`zod: ^4.6.1`'); - expect(result.stderr).toContain('No copy satisfying the range is available'); - // Decided before the first write: the install is exactly as it was. - expect(fs.readlinkSync(path.join(f.entry, 'zod'))).toBe('../../zod@4.4.3/node_modules/zod'); - expect(fs.existsSync(path.join(f.installed, MARKER_FILE))).toBe(false); - } - }); - - it('exits 2 when it is not handed an objectstack checkout at all', () => { - const f = fixture({ dependencies: { zod: '^4.6.1' }, upstream: { zod: '4.6.1' } }); - const result = spawnSync( - 'node', - [path.join(f.repo, 'scripts', 'spec-main-shape-gate.mjs'), 'inject', '--tarball', 'x.tgz', '--sha', 'f'], - { encoding: 'utf8' }, - ); - expect(result.status).toBe(2); - expect(result.stderr).toContain('--upstream-checkout'); - }); - - it("re-points an objectui importer whose range admits the new copy, and reports it (objectui#10229 B)", () => { - const f = fixture({ - dependencies: { zod: '^4.6.1' }, - upstream: { zod: '4.6.1' }, - importers: { types: { zod: '^4.4.3' } }, - }); - const upstreamZod = fs.realpathSync(path.join(f.upstream, 'packages', 'spec', 'node_modules', 'zod')); - const result = f.run(); - expect(result.status, result.stderr).toBe(0); - - // On disk: the importer's link now resolves the same copy the spec reads, - // and objectui's own store copy is intact (a link was replaced). - const link = path.join(f.repo, 'packages', 'types', 'node_modules', 'zod'); - expect(fs.realpathSync(link)).toBe(upstreamZod); - expect( - JSON.parse(fs.readFileSync(path.join(f.store, 'zod@4.4.3', 'node_modules', 'zod', 'package.json'), 'utf8')) - .version, - ).toBe('4.4.3'); - - const line = - 're-pointed zod for objectui importer packages/types: declares ^4.4.3, which admits 4.6.1; ' + - 'was zod@4.4.3 (-> ../../../node_modules/.pnpm/zod@4.4.3/node_modules/zod), ' + - `now -> ${upstreamZod} (zod@4.6.1`; - expect(result.stdout).toContain(line); - expect(f.summary()).toContain(line); - // The proof, asked of the consumer AND the importer: one copy, named. - expect(result.stdout).toContain( - `one copy of zod@4.6.1 for 1 spec consumer(s) and 1 objectui importer(s) -> ${upstreamZod}`, - ); - expect(result.stdout).not.toContain('two copies'); - }); - - it('leaves an importer whose range does not admit the new copy, and says two copies stay', () => { - const f = fixture({ - dependencies: { zod: '^4.6.1' }, - upstream: { zod: '4.6.1' }, - importers: { types: { zod: '^4.4.3' }, legacy: { zod: '~4.4.0' } }, - }); - const upstreamZod = fs.realpathSync(path.join(f.upstream, 'packages', 'spec', 'node_modules', 'zod')); - const result = f.run(); - expect(result.status, result.stderr).toBe(0); - - expect(fs.readlinkSync(path.join(f.repo, 'packages', 'legacy', 'node_modules', 'zod'))).toBe( - '../../../node_modules/.pnpm/zod@4.4.3/node_modules/zod', - ); - const line = 'two copies of zod stay: packages/legacy declares ~4.4.0, which does not admit 4.6.1'; - expect(result.stdout).toContain(line); - expect(f.summary()).toContain(line); - expect(result.stdout).not.toContain('re-pointed zod for objectui importer packages/legacy'); - // The admitting importer in the same run is still re-pointed, and the - // proof counts it while naming the importer that keeps a second copy. - expect(fs.realpathSync(path.join(f.repo, 'packages', 'types', 'node_modules', 'zod'))).toBe(upstreamZod); - expect(result.stdout).toContain( - `one copy of zod@4.6.1 for 1 spec consumer(s) and 1 objectui importer(s) -> ${upstreamZod}; ` + - 'a second copy stays for packages/legacy', - ); - }); - - it('leaves an importer of a package the spec did NOT re-point untouched (positive control)', () => { - // The spec's pg-connection-string sibling satisfies its range, so the spec - // substitution does not re-point it -- and the checkout offers a copy the - // importer's range WOULD admit, so a pass that re-pointed every declared - // dependency regardless would be caught here. - const f = fixture({ - dependencies: { zod: '^4.6.1', 'pg-connection-string': '^2.14.0' }, - upstream: { zod: '4.6.1', 'pg-connection-string': '2.15.0' }, - importers: { db: { 'pg-connection-string': '^2.0.0' } }, - }); - const result = f.run(); - expect(result.status, result.stderr).toBe(0); - expect(fs.readlinkSync(path.join(f.repo, 'packages', 'db', 'node_modules', 'pg-connection-string'))).toBe( - '../../../node_modules/.pnpm/pg-connection-string@2.14.0/node_modules/pg-connection-string', - ); - expect(result.stdout).not.toMatch(/re-pointed pg-connection-string|two copies of pg-connection-string/); - }); - - it('exits 2 when an importer declares a range it cannot read, having written nothing', () => { - const f = fixture({ - dependencies: { zod: '^4.6.1' }, - upstream: { zod: '4.6.1' }, - importers: { odd: { zod: 'npm:zod@^4.4.3' } }, - }); - const result = f.run(); - expect(result.status, result.stdout).toBe(2); - expect(result.stderr).toContain("objectui importer packages/odd's declared `zod: npm:zod@^4.4.3`"); - expect(fs.readlinkSync(path.join(f.entry, 'zod'))).toBe('../../zod@4.4.3/node_modules/zod'); - expect(fs.existsSync(path.join(f.installed, MARKER_FILE))).toBe(false); - }); - - it("reads THIS repository's pnpm-workspace.yaml, so the gate can enumerate its importers", () => { - // The gate refuses (exit 2) a workspace file it cannot read. Pinned here so - // an unsupported spelling reds in this suite, not first inside a gate run. - const globs = parseWorkspaceGlobs(fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf8')); - expect(globs).toContain('packages/*'); - expect(() => parseWorkspaceGlobs("packages:\n - 'packages/**'\n")).toThrow(/not a/); - }); - - it('judges ranges narrowly, and refuses a spelling it does not read', () => { - expect(satisfiesRange('4.4.3', '^4.6.1')).toBe(false); - expect(satisfiesRange('4.6.1', '^4.6.1')).toBe(true); - expect(satisfiesRange('5.0.0', '^4.6.1')).toBe(false); - expect(() => satisfiesRange('4.6.1', 'workspace:*')).toThrow(/does not read/); - }); -}); - -/** - * objectui#12114 — a linked git worktree shares the main checkout's turbo - * cache, and `--force` skips cache reads but still WRITES. So a local - * reproduction of this gate recorded main-spec verdicts that a sibling - * worktree's ordinary, installed-spec `pnpm type-check` replayed as its own: a - * false green. The fix puts the injection inside turbo's hash, through - * `turbo.json`'s `globalDependencies`; the script header carries the why. - * - * The second case drives a REAL turbo (`--dry=json`, which computes the hashes - * and runs nothing) over a fixture that copies this repository's `turbo.json`, - * so it fails the day that file stops hashing the marker. - */ -describe('a local reproduction cannot leave a verdict an installed tree replays (objectui#12114)', () => { - const made: string[] = []; - afterEach(() => { - for (const dir of made.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); - }); - - const STORE_SPEC = 'node_modules/.pnpm/@objectstack+spec@17.4.0/node_modules/@objectstack/spec'; - - /** - * An installed tree, linked the way pnpm links it (RELATIVE symlinks into the - * virtual store), in a git repository that ignores `node_modules` as this one - * does: the file turbo has to hash is an ignored one. - */ - function installedTree({ rootLink }: { rootLink: boolean }) { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spec-gate-turbo-')); - made.push(root); - const repo = path.join(root, 'objectui'); - fs.mkdirSync(path.join(repo, 'scripts'), { recursive: true }); - for (const file of ['spec-main-shape-gate.mjs', 'invoked-as.mjs']) { - fs.copyFileSync(path.join(repoRoot, 'scripts', file), path.join(repo, 'scripts', file)); - } - fs.copyFileSync(path.join(repoRoot, 'turbo.json'), path.join(repo, 'turbo.json')); - - const store = path.join(repo, STORE_SPEC); - // What a reinstall does to an injected copy: the directory is replaced. - const install = () => { - fs.rmSync(store, { recursive: true, force: true }); - fs.mkdirSync(store, { recursive: true }); - fs.writeFileSync(path.join(store, 'package.json'), JSON.stringify({ name: SPEC_PACKAGE_NAME, version: '17.4.0' })); - }; - install(); - const devDependencies = rootLink ? { [SPEC_PACKAGE_NAME]: '^17.4.0' } : {}; - if (rootLink) { - fs.mkdirSync(path.join(repo, 'node_modules', '@objectstack'), { recursive: true }); - fs.symlinkSync(`../.pnpm/@objectstack+spec@17.4.0/node_modules/@objectstack/spec`, path.join(repo, 'node_modules', '@objectstack', 'spec')); - } - const app = path.join(repo, 'packages', 'app'); - fs.mkdirSync(path.join(app, 'node_modules', '@objectstack'), { recursive: true }); - fs.symlinkSync(`../../../../${STORE_SPEC}`, path.join(app, 'node_modules', '@objectstack', 'spec')); - fs.writeFileSync( - path.join(app, 'package.json'), - JSON.stringify({ name: 'app', scripts: { 'type-check': 'tsc --noEmit' }, dependencies: { [SPEC_PACKAGE_NAME]: '^17.4.0' } }), - ); - fs.writeFileSync( - path.join(repo, 'package.json'), - JSON.stringify({ name: 'root', private: true, packageManager: 'pnpm@10.31.0', devDependencies }), - ); - fs.writeFileSync(path.join(repo, 'pnpm-workspace.yaml'), "packages:\n - 'packages/*'\n"); - fs.writeFileSync(path.join(repo, '.gitignore'), 'node_modules\n.turbo\n'); - execFileSync('git', ['init', '-q'], { cwd: repo }); - - const upstream = path.join(root, 'objectstack'); - fs.mkdirSync(path.join(upstream, 'packages', 'spec'), { recursive: true }); - fs.writeFileSync(path.join(upstream, 'packages', 'spec', 'package.json'), JSON.stringify({ name: SPEC_PACKAGE_NAME })); - const pack = path.join(root, 'pack', 'package'); - fs.mkdirSync(path.join(pack, 'dist'), { recursive: true }); - fs.writeFileSync(path.join(pack, 'dist', 'index.d.ts'), 'export {};\n'); - fs.writeFileSync( - path.join(pack, 'package.json'), - JSON.stringify({ name: SPEC_PACKAGE_NAME, version: '17.4.0', exports: { '.': { types: './dist/index.d.ts' } } }), - ); - const tarball = path.join(root, 'spec.tgz'); - execFileSync('tar', ['-czf', tarball, '-C', path.join(root, 'pack'), 'package']); - - const inject = () => - spawnSync( - 'node', - [path.join(repo, 'scripts', 'spec-main-shape-gate.mjs'), 'inject', '--tarball', tarball, '--sha', 'f'.repeat(40), '--upstream-checkout', upstream], - { encoding: 'utf8' }, - ); - // The global files turbo hashed, and every task's hash. - const hashes = () => { - const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith('TURBO_'))); - const dry = JSON.parse( - execFileSync(path.join(repoRoot, 'node_modules', '.bin', 'turbo'), ['run', 'type-check', '--dry=json'], { - cwd: repo, - encoding: 'utf8', - env: { ...env, TURBO_TELEMETRY_DISABLED: '1' }, - // turbo warns that the fixture has no lockfile; a failure still throws with it. - stdio: ['ignore', 'pipe', 'pipe'], - }), - ); - const tasks: Record = {}; - for (const task of dry.tasks) tasks[task.taskId] = task.hash; - return { files: Object.keys(dry.globalCacheInputs.files), tasks }; - }; - return { install, inject, hashes }; - } - - it('turbo.json hashes the marker inject writes, by a literal path through the root link', () => { - expect(TURBO_HASH_MARKER).toBe(`node_modules/${SPEC_PACKAGE_NAME}/${MARKER_FILE}`); - const turbo = JSON.parse(fs.readFileSync(path.join(repoRoot, 'turbo.json'), 'utf8')); - expect(turbo.globalDependencies ?? [], 'without it an injected tree and an installed one hash alike').toContain( - TURBO_HASH_MARKER, - ); - // turbo does not expand a wildcard inside node_modules: a glob there would - // hash nothing, silently. - expect(TURBO_HASH_MARKER).not.toMatch(/[*?[{!]/); - // pnpm creates the root link the path goes through only because the root - // manifest declares the spec. - const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); - expect( - ['dependencies', 'devDependencies', 'optionalDependencies'].some((field) => manifest[field]?.[SPEC_PACKAGE_NAME]), - `the root manifest no longer declares ${SPEC_PACKAGE_NAME}, so ${TURBO_HASH_MARKER} never exists`, - ).toBe(true); - // The marker can never be committed. - expect(spawnSync('git', ['check-ignore', '-q', 'node_modules'], { cwd: repoRoot }).status).toBe(0); - }); - - it('real turbo: an injected tree hashes apart from an installed one, and back once the injection is undone', () => { - const tree = installedTree({ rootLink: true }); - const installed = tree.hashes(); - expect(installed.tasks).toHaveProperty(['app#type-check']); - expect(installed.files).not.toContain(TURBO_HASH_MARKER); - - const first = tree.inject(); - expect(first.status, first.stderr).toBe(0); - expect(first.stdout).toContain(`turbo hashes ${TURBO_HASH_MARKER}`); - const injected = tree.hashes(); - expect(injected.files).toContain(TURBO_HASH_MARKER); - for (const [task, hash] of Object.entries(installed.tasks)) { - expect(injected.tasks[task], `${task} hashes as installed after an injection`).not.toBe(hash); - } - - // A second injection at the same sha shares no entry with the first. - expect(tree.inject().status).toBe(0); - const again = tree.hashes(); - for (const [task, hash] of Object.entries(injected.tasks)) expect(again.tasks[task]).not.toBe(hash); - - // Undone the way a reinstall undoes it, the tree hashes as installed again: - // the marker cannot outlive the injection. - tree.install(); - expect(tree.hashes()).toEqual(installed); - }); - - it('the firing control: without the root link the same injection hashes as installed, and inject says so', () => { - // The marker inside the virtual store alone is invisible to turbo, so the - // hash above moved because of the root-linked path and nothing else. - const tree = installedTree({ rootLink: false }); - const installed = tree.hashes(); - const result = tree.inject(); - expect(result.status, result.stderr).toBe(0); - expect(result.stdout).toContain(`⚠️ ${TURBO_HASH_MARKER} is absent`); - expect(tree.hashes()).toEqual(installed); - }); -}); diff --git a/scripts/check-required-check-set.mjs b/scripts/check-required-check-set.mjs index 7637385368..bbedf5fd39 100644 --- a/scripts/check-required-check-set.mjs +++ b/scripts/check-required-check-set.mjs @@ -208,13 +208,13 @@ export const WATCHED_CONTEXTS = Object.freeze([ // ⛔ TOMBSTONE -- `Spec Main Shape Gate`, watched here from objectui#9969 // (ruling A, which enrolled it) to objectui#12240, which removed it: the // maintainer's ruling B on objectui#12214's open question 1 (ACCEPT - // 6123370050) takes the context out of the required set -- advisory on pull - // requests, hourly on `main` in the full battery, nothing it verifies - // changed (`.github/workflows/spec-main-shape-gate.yml`'s header). ⛔ Do not - // re-add it to agree with whatever the endpoint says: the ruleset edit is - // the maintainer's act, and this list names the set the ruling installs. - // Re-enrolling it is a ruling, and its in-tree half is moving the name back - // into `REQUIRED_CONTEXTS` in `scripts/dependabot-merge-gate.mjs`. + // 6123370050) took the context out of the required set. objectui#12352 then + // deleted the workflow that produced it, executing ruling B on + // objectstack-ai/objectstack#23238: the spec shape gate runs in objectstack + // now, and no workflow in this repository reports the context any more. + // ⛔ Do not re-add it, here or to the ruleset: a required context that + // nothing reports leaves every merge group pending until the ruleset's + // timeout, with nothing red to point at (objectui#3523). 'Governed Surface Queue Guard', ]); diff --git a/scripts/dependabot-merge-gate.mjs b/scripts/dependabot-merge-gate.mjs index 985d4f85c8..68610ab4ad 100644 --- a/scripts/dependabot-merge-gate.mjs +++ b/scripts/dependabot-merge-gate.mjs @@ -206,10 +206,6 @@ export const REQUIRED_CONTEXTS = Object.freeze([ 'Repo Gates', 'Changeset Declaration', 'Governed Surface Queue Guard', - // ⛔ `Spec Main Shape Gate` is NOT here since objectui#12240: it sat here from - // objectui#9969 (ruling A, the in-tree half of enrolling it) and moved to - // `NOT_A_GATE` below when the maintainer's ruling B took it out of the - // required set. The reason is written there. ]); /** @@ -305,8 +301,6 @@ export const NOT_A_GATE = Object.freeze({ 'Test (coverage shard 6/8)': COVERAGE_SHARD_NOT_A_GATE, 'Test (coverage shard 7/8)': COVERAGE_SHARD_NOT_A_GATE, 'Test (coverage shard 8/8)': COVERAGE_SHARD_NOT_A_GATE, - 'Spec Main Shape Gate': - "spec-main-shape-gate.yml is ADVISORY on a pull request by ruling (objectui#12240: the maintainer's ruling B on objectui#12214's open question 1, ACCEPT 6123370050). It compiles this repository against `@objectstack/spec` built from objectstack `main`, so most of its reds name an objectstack commit rather than anything the pull request changed (measured on objectui#12214, not copied here), and its place in the two-speed doctrine (objectstack#22998) is the hourly `schedule` run on `main`, where a red is a main-red for the `domain:devx` lane. It still reports on every pull request and its verdict is real -- nothing it verifies changed and it carries no `continue-on-error` -- so a red here is ignored because the lane is advisory, never because the check cannot fail. It sat in `REQUIRED_CONTEXTS` from objectui#9969 (ruling A) to objectui#12240, the in-tree half of the maintainer's enrolment; ⛔ moving it back is the in-tree half of a re-enrolment, a ruling and not a tidy-up, and it puts the workflow back inside the `merge_group` floor `merge-queue-reporting.test.ts` derives from that list.", 'Live E2E (informational)': 'live-e2e.yml is declared INFORMATIONAL and NON-REQUIRED in its own header, and ci-cd-pipeline.md says in as many words not to add it to required checks. It carries no `continue-on-error` since objectui#8084 — that flag left this check run red anyway and only inverted the workflow-run conclusion — so a red here is a real red, ignored because the lane is advisory and never because the check could not fail.', label: diff --git a/scripts/spec-main-shape-gate.mjs b/scripts/spec-main-shape-gate.mjs deleted file mode 100644 index b85652599e..0000000000 --- a/scripts/spec-main-shape-gate.mjs +++ /dev/null @@ -1,1387 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * spec-main-shape-gate -- compile THIS repository against `@objectstack/spec` - * built from objectstack `main`, and name the objectui file and the objectstack - * commit when it does not compile. - * - * node scripts/spec-main-shape-gate.mjs inject --tarball --sha --upstream-checkout - * node scripts/spec-main-shape-gate.mjs report --log --sha --status - * node scripts/spec-main-shape-gate.mjs --self-test # offline, no install needed - * - * Exit: 0 = the step did what it says - * 1 = the typecheck failed (report mode) -- the diagnostics are attributed - * 2 = the step COULD NOT BE PERFORMED (nothing to inject, a staged package - * that is not the spec, a failure that could not be attributed to a - * file). ⛔ Never a pass. A gate that cannot take its reading says so. - * - * ## What this is, and the ruling it executes (objectui#9860) - * - * The maintainer's ruling of 2026-09-18 chose option C: the ONLY shape gate for - * `@objectstack/spec`'s public surface is a real consumer compiling against - * objectstack's current `main`. objectui is the first consumer. The declaration- - * text snapshot that used to carry that job on the platform side is being - * reverted there; nothing replaces it except this. - * - * ⭐ THE ACCEPTANCE CRITERION IS THE MESSAGE, NOT THE VERDICT. A red that says - * only "typecheck failed" does not satisfy the ruling: option C's entire value is - * that the objectstack pull request which moved the shape can be identified from - * objectui's failure. So `report` pairs every diagnostic with the objectstack - * commit it was compiled against, and a failure it cannot attribute to a file is - * exit 2 with that said out loud -- never a bare red, and never a pass. - * - * ## Why the injection is into the VIRTUAL STORE, and not any of the obvious places - * - * This repository consumes a PUBLISHED `@objectstack/spec` (a caret range in 29 - * manifests, resolved by `pnpm-lock.yaml`). The job must typecheck against a - * BUILT-FROM-SOURCE one WITHOUT changing what a normal build resolves, and the - * pin-bump question is explicitly still open with the maintainer -- so the pin, - * the manifests and the lockfile are all off limits. - * - * Three mechanisms were considered and two are structurally unable to do it: - * - * - `OBJECTSTACK_SPEC_DIST` (`scripts/vite-objectstack-spec-dist.ts`) already - * resolves `@objectstack/spec` at a locally built spec, and it is the right - * hook for the console BUILD -- but it emits Vite `resolve.alias` entries. - * `tsc` does not read a Vite config, so a typecheck run under it compiles - * against the INSTALLED spec while the bundler compiles against the override: - * a green that means nothing. That module's own header states its scope; this - * one states why it cannot be reused here. - * - A `pnpm.overrides` / `file:` / `link:` entry moves `pnpm-workspace.yaml`, - * `package.json` or `pnpm-lock.yaml`. Every one of those is the pin. - * - Replacing the installed package IN THE VIRTUAL STORE moves no tracked file - * at all. It is post-install runner state, discarded with the runner. - * - * `tsc` reaches the spec through `/node_modules/@objectstack/spec`, - * which pnpm makes a symlink into `node_modules/.pnpm/@objectstack+spec@/`. - * Replacing the contents of that one directory moves every consumer at once -- - * which is also why the verification below asks the CONSUMERS where they landed - * rather than asking this function what it wrote. - * - * ⚠️ THE HARDLINK TRAP, measured on this repository's own install: the files in - * the virtual store are HARDLINKS into pnpm's global content-addressable store - * (`stat -c %h` on the installed spec's `package.json` reports a link count well - * above one). Writing INTO them writes through to the global store and corrupts - * `@objectstack/spec` for every other checkout on the machine -- and in CI, for - * whatever `actions/setup-node`'s pnpm cache saves afterwards. So the replacement - * is REMOVE-THEN-COPY, never copy-over: unlinking a hardlink is local, writing - * through one is not. - * - * ## Why the spec's declared DEPENDENCIES are re-pointed too (objectui#10229) - * - * The copy above replaces the spec's own files and nothing else. What those - * files IMPORT -- `zod` above all -- resolves through the SIBLING links of the - * same virtual-store entry (`.pnpm/@objectstack+spec@/node_modules/zod`), and - * those siblings were laid down for the PUBLISHED spec this repository pins. - * When objectstack `main` raises a dependency floor, the source-built - * declarations are then read against a dependency OLDER than the one they were - * emitted against -- a topology no real install produces, because an install of - * a release from that commit resolves the raised floor from the manifest. - * - * Measured, not hypothesised: declarations emitted on a newer zod spell a zod - * type with an arity the older zod does not declare. `skipLibCheck` hides the - * error that would name it, the type degrades to an error type, and objectui - * fails to compile for a reason that exists only inside this job -- reported - * under this gate's name as a shape break objectstack never made. The gate was - * reading its own injection. - * - * So after the copy, every entry of the PACKED manifest's `dependencies` is - * judged against the sibling the store entry actually holds: - * - * - a sibling that satisfies the declared range is left exactly as installed; - * - one that does not (or is absent) is REMOVED and replaced by a symlink to - * the copy the spec's own build resolved in the objectstack checkout - * (`--upstream-checkout`: the realpath of `packages/spec/node_modules/`), - * i.e. the dependency the declarations were emitted against. Remove-then- - * link, for the hardlink reason above: the sibling is a link to replace, - * never a directory to write into; - * - when no copy satisfying the range is available, that is exit 2 naming the - * dependency, decided BEFORE anything is written. Compiling against an - * unsatisfied dependency is a reading of this gate, not of the spec. - * - * `peerDependencies` are the consumer's to provide and are not touched. The - * consumer proof below then asks every consumer which copy of each declared - * dependency the injected spec resolves, and refuses one outside its range. - * - * ## ...and why objectui's OWN copies of a re-pointed dependency follow it - * - * Re-pointing the spec's sibling alone leaves TWO copies of that package in one - * program: the spec's declarations read the new one, while every objectui - * package that declares it still reads the old one. For zod that was measured - * on this gate's own flow at a commit that raised the floor: hundreds of - * diagnostics across dozens of files -- non-portable inferred types, one copy's - * `ZodType` refused where the other's is expected -- and every one of them an - * artifact of the two copies, with objectui's code correct. With one copy the - * same compile was clean. (objectui#10229's pull request carries the figures; - * none are restated here.) - * - * One copy is what an install of a release from that commit gives objectui: - * the new floor falls inside objectui's own declared range, and objectui's - * `check:lockfile-dedupe` keeps the lockfile deduped, so a pin bump installs - * the package once. So for every dependency the spec substitution re-pointed - * to version V -- and only those -- each objectui workspace importer that - * declares the same package is judged: - * - * - every range it declares admits V: its `node_modules/` link is removed - * and re-linked to the same V copy (remove-then-link, as above); - * - a range does NOT admit V: its copy stays, and the log and run summary say - * `two copies of stay: declares , which does not - * admit `. The diagnostics that follow are then genuine readings: a real - * install would carry the same two copies. - * - * The importers are enumerated from `pnpm-workspace.yaml`, the file pnpm itself - * reads, never from a hand-kept list. Like everything else here this is runner - * state and moves no tracked file. The consumer proof is extended to match: - * every spec consumer and every admitting importer must resolve ONE copy of - * each re-pointed package, and the log names which. - * - * Satisfaction is judged by `satisfiesRange`, a deliberately narrow reader in - * this file, for the reason `check-spec-range-floors.mjs` gives for its own: - * `semver` is not a dependency of this repository's root, and importing a - * hoisted transitive copy is a phantom dependency. It is not imported from that - * script because that module loads `typescript`, and `--self-test` here needs - * no install. Any spelling the reader does not know is exit 2, never a guess. - * - * ## Why the injected bytes come from `npm pack` and not from the source tree - * - * The subject of this gate is the spec's PUBLISHED surface. `npm pack` produces - * exactly the file set `packages/spec`'s `files` field would ship, so what - * objectui compiles against here is what a release from that commit would give - * it -- not a working tree that also carries sources, tooling and an installed - * `node_modules`. It also makes the failure mode of a half-built spec loud - * rather than subtle: a build that skipped declarations produces a tarball whose - * `exports` types targets are absent, and `inject` refuses it by name instead of - * letting every consumer fail with TS2307 for a reason nobody can see. - * - * ## ⛔ The turbo cache is a FALSE GREEN here, and the workflow must bypass it - * - * `turbo`'s `type-check` task is cached, and its hash covers this repository's - * sources, its lockfile and a declared env list -- it does NOT cover the CONTENT - * of `node_modules`. The injected spec's own files therefore change nothing - * turbo hashes: a second run after an injection could replay the verdict - * recorded BEFORE it, and a run at a new objectstack sha the verdict from the - * old one. Both are green answers to a question that was never asked. The - * workflow runs the typecheck with the cache bypassed for exactly this reason, - * and `scripts/__tests__/spec-main-shape-gate.test.ts` pins that it keeps doing - * so. The marker below closes both cases too; the bypass does not lean on it. - * - * ## ...and an injected tree must not leave a verdict an INSTALLED tree replays (objectui#12114) - * - * The reverse direction is local, and the bypass does not close it: `--force` - * (`TURBO_FORCE=true`) skips cache READS and still WRITES. A linked git worktree - * shares the main checkout's `.turbo/cache` (turbo logs "using shared worktree - * cache"), so a reproduction in one worktree recorded main-spec verdicts that a - * later ordinary `pnpm type-check` in a sibling -- same sources, installed spec - * -- replayed as its own. The measurement is on this issue's pull request. - * - * So the injection is part of turbo's hash: `turbo.json`'s `globalDependencies` - * names `TURBO_HASH_MARKER`, the `MARKER_FILE` `inject` writes into every - * replaced copy, reached through the root link to the spec. Measured on turbo - * 2.10: - * - * - Absent, a global dependency hashes as nothing: installed trees share - * entries with each other as before, and an injected tree hashes apart from - * all of them in both directions, with no flag to remember. The marker holds - * the sha and the injection time, so two injections never share one either. - * - The path is LITERAL because turbo does not expand a wildcard inside - * `node_modules`: a glob over the virtual store hashes nothing, silently. The - * root link exists because the root manifest declares the spec; the consumer - * proof below requires it to resolve to a marked copy, and `inject` says so - * out loud when there is no root link to hash. - * - The marker lives INSIDE the replaced copy, so it cannot outlive the - * injection: `pnpm install --force` or a fresh install replaces that - * directory, and the tree hashes as installed again. A plain `pnpm install` - * leaves the injection and its marker both in place. - * - * A local reproduction is therefore `inject`, then `pnpm type-check --continue` - * with no flag, then `pnpm install --frozen-lockfile --force` to undo it. The - * test file above pins the wiring and drives a real turbo over a fixture. - */ - -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { execFileSync } from 'node:child_process'; -import { createRequire } from 'node:module'; -import { fileURLToPath } from 'node:url'; - -import { isEntrypoint } from './invoked-as.mjs'; - -export const SPEC_PACKAGE_NAME = '@objectstack/spec'; - -/** The upstream this gate compiles against. Used only to build human links. */ -export const UPSTREAM_REPO = 'objectstack-ai/objectstack'; - -/** The marker `inject` leaves inside every replaced copy. */ -export const MARKER_FILE = '.spec-main-shape-gate.json'; - -/** - * That marker as `turbo.json`'s `globalDependencies` names it: a LITERAL path - * through the repository root's own link to the spec. See "...and an injected - * tree must not leave a verdict" in the header. - */ -export const TURBO_HASH_MARKER = ['node_modules', SPEC_PACKAGE_NAME, MARKER_FILE].join('/'); - -const repoRootDefault = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); - -/** - * The virtual-store directories holding an installed `@objectstack/spec`. - * - * pnpm encodes the peer-dependency resolution into the directory name - * (`@objectstack+spec@17.4.0_ai@...._zod@....`), and one install can materialise - * more than one of them. Enumerating by PREFIX rather than by a computed name is - * the difference between moving every consumer and moving the ones whose peer - * set happened to match a guess. - */ -export function findInstalledSpecDirs(repoRoot = repoRootDefault) { - const virtualStore = path.join(repoRoot, 'node_modules', '.pnpm'); - if (!fs.existsSync(virtualStore)) return []; - return fs - .readdirSync(virtualStore) - .filter((entry) => entry.startsWith('@objectstack+spec@')) - .map((entry) => path.join(virtualStore, entry, 'node_modules', ...SPEC_PACKAGE_NAME.split('/'))) - .filter((dir) => fs.existsSync(path.join(dir, 'package.json'))) - .sort(); -} - -/** - * Every workspace package whose own `node_modules` links to the spec, with the - * directory that link resolves to. - * - * This is the VERIFICATION population, and it is deliberately taken from the - * consumers rather than from the injection: "I replaced two directories" is a - * statement about this function's own behaviour, while "every consumer that can - * see the spec now sees the injected one" is the property the typecheck depends - * on. A consumer left pointing at an un-injected copy is the silent half-green - * this gate exists to make impossible. - */ -export function findSpecConsumers(repoRoot = repoRootDefault) { - const consumers = []; - for (const group of ['packages', 'apps', 'examples']) { - const groupDir = path.join(repoRoot, group); - if (!fs.existsSync(groupDir)) continue; - for (const pkg of fs.readdirSync(groupDir)) { - const link = path.join(groupDir, pkg, 'node_modules', ...SPEC_PACKAGE_NAME.split('/')); - if (!fs.existsSync(link)) continue; - consumers.push({ workspace: `${group}/${pkg}`, resolved: fs.realpathSync(link) }); - } - } - const rootLink = path.join(repoRoot, 'node_modules', ...SPEC_PACKAGE_NAME.split('/')); - if (fs.existsSync(rootLink)) { - consumers.push({ workspace: '.', resolved: fs.realpathSync(rootLink) }); - } - return consumers.sort((a, b) => a.workspace.localeCompare(b.workspace)); -} - -/** - * Every file an `exports` map names under a `types` condition, relative to the - * package root. - * - * `tsc` reads exactly these and nothing else, so they are what must exist in the - * staged package for a typecheck against it to mean anything. A spec built with - * declarations skipped still packs, still installs, and still resolves at - * runtime -- and fails every consumer with TS2307, which reads like a hundred - * broken imports rather than like one missing build step. - */ -export function declaredTypesTargets(exportsMap) { - const targets = new Set(); - const walk = (node) => { - if (typeof node === 'string') return; - if (Array.isArray(node)) return node.forEach(walk); - if (!node || typeof node !== 'object') return; - for (const [condition, value] of Object.entries(node)) { - if (condition === 'types' && typeof value === 'string') targets.add(value); - else walk(value); - } - }; - walk(exportsMap ?? {}); - return [...targets].sort(); -} - -/** `tar -xzf` into a fresh directory and return the extracted package root. */ -function extractTarball(tarball, intoDir) { - fs.mkdirSync(intoDir, { recursive: true }); - execFileSync('tar', ['-xzf', path.resolve(tarball), '-C', intoDir], { stdio: 'pipe' }); - const staged = path.join(intoDir, 'package'); - if (!fs.existsSync(staged)) { - fail( - `the tarball ${tarball} did not extract a \`package/\` directory. npm and pnpm both pack ` + - `into one; a tarball shaped otherwise is not a packed npm package.`, - ); - } - return staged; -} - -function fail(message) { - process.stderr.write(`spec-main-shape-gate: ${message}\n`); - process.exit(2); -} - -/* ── dependency ranges, deliberately narrow ───────────────────────────────── - * See "Why the spec's declared DEPENDENCIES are re-pointed too" in the header - * for why this is not the `semver` package. - */ - -/** `[major, minor, patch]` of a plain `X.Y.Z` release; anything else throws. */ -function parseRelease(version) { - const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(version).trim()); - if (!match) throw new Error(`"${version}" is not a plain X.Y.Z release, and this gate orders nothing else`); - return [Number(match[1]), Number(match[2]), Number(match[3])]; -} - -function compareReleases(left, right) { - for (let i = 0; i < 3; i += 1) { - if (left[i] !== right[i]) return left[i] < right[i] ? -1 : 1; - } - return 0; -} - -/** One comparator: an operator (none means `=`) and a full `X.Y.Z` bound. */ -const RANGE_COMPARATOR = /^(\^|~|>=|<=|>|<|=)?(\d+\.\d+\.\d+)$/; - -function comparatorHolds(version, operator, bound) { - const order = compareReleases(version, bound); - if (operator === '=') return order === 0; - if (operator === '>') return order > 0; - if (operator === '>=') return order >= 0; - if (operator === '<') return order < 0; - if (operator === '<=') return order <= 0; - // `~` admits patch releases; `^` admits everything up to the next change in - // the left-most non-zero part -- npm's reading of both. - const ceiling = - operator === '~' - ? [bound[0], bound[1] + 1, 0] - : bound[0] > 0 - ? [bound[0] + 1, 0, 0] - : bound[1] > 0 - ? [0, bound[1] + 1, 0] - : [0, 0, bound[2] + 1]; - return order >= 0 && compareReleases(version, ceiling) < 0; -} - -/** - * Does the plain release `version` satisfy the dependency range `range`? - * - * Reads `X.Y.Z`, `=`, `^`, `~`, `>`, `>=`, `<`, `<=` on full `X.Y.Z` bounds, - * space-separated comparator sets and `||` unions -- the spellings a published - * manifest's `dependencies` carries. Everything else THROWS (a prerelease, a - * partial or `x` version, a hyphen range, a `workspace:` or `npm:` protocol), - * and the caller turns that into exit 2 naming the dependency: the failure - * direction that matters is an unreadable range read as satisfied, which would - * leave this gate compiling against the wrong dependency without a word. - */ -export function satisfiesRange(version, range) { - const release = parseRelease(version); - const text = String(range ?? '').trim(); - if (text.length === 0) throw new Error('an empty range names nothing this gate can judge'); - // Every comparator is PARSED before any is judged, so an unreadable one - // throws even when an earlier alternative would already have decided. - const alternatives = text.split('||').map((alternative) => { - const comparators = alternative.trim().split(/\s+/).filter(Boolean); - if (comparators.length === 0) throw new Error(`range "${text}" has an empty alternative`); - return comparators.map((comparator) => { - const match = RANGE_COMPARATOR.exec(comparator); - if (!match) { - throw new Error(`range "${text}" contains "${comparator}", which this gate does not read`); - } - return { operator: match[1] ?? '=', bound: parseRelease(match[2]) }; - }); - }); - return alternatives.some((comparators) => - comparators.every(({ operator, bound }) => comparatorHolds(release, operator, bound)), - ); -} - -/* ── the spec's declared dependencies ─────────────────────────────────────── */ - -/** `{ name, version, realpath }` of the package installed at `dir`, or null. */ -function installedPackageAt(dir) { - if (!fs.existsSync(path.join(dir, 'package.json'))) return null; - const realpath = fs.realpathSync(dir); - const { name, version } = JSON.parse(fs.readFileSync(path.join(realpath, 'package.json'), 'utf8')); - return { name, version, realpath }; -} - -/** - * The link pnpm lays down for dependency `name` next to the spec, in the same - * virtual-store entry: `/node_modules/` beside - * `/node_modules/@objectstack/spec`. - */ -function storeSiblingPath(target, name) { - const entryModules = path.resolve(target, ...SPEC_PACKAGE_NAME.split('/').map(() => '..')); - return path.join(entryModules, ...name.split('/')); -} - -/** The virtual-store entry a spec copy lives in (`@objectstack+spec@_`). */ -function storeEntryName(target) { - return path.basename(path.resolve(target, ...SPEC_PACKAGE_NAME.split('/').map(() => '..'), '..')); -} - -/** Satisfaction with an unreadable range or version turned into exit 2 naming `name`. */ -function judged(name, range, version, whose) { - try { - return satisfiesRange(version, range); - } catch (error) { - return fail( - `cannot judge ${whose} \`${name}@${version}\` against the injected ${SPEC_PACKAGE_NAME}'s ` + - `declared \`${name}: ${range}\`: ${error.message}. Refused rather than guessed -- a range read ` + - `wrongly here decides which dependency the whole compile runs against.`, - ); - } -} - -/** - * Which store siblings must be re-pointed, and at what. Decided BEFORE anything - * is written, so a dependency that cannot be satisfied leaves the install as it - * was and says so. - */ -function planDependencySubstitutions({ dependencies, targets, upstreamCheckout }) { - const plan = []; - for (const [name, range] of Object.entries(dependencies ?? {}).sort(([a], [b]) => a.localeCompare(b))) { - for (const target of targets) { - const sibling = storeSiblingPath(target, name); - const held = installedPackageAt(sibling); - if (held && judged(name, range, held.version, 'the store sibling')) continue; - - const upstreamDir = path.join(upstreamCheckout, 'packages', 'spec', 'node_modules', ...name.split('/')); - const upstream = installedPackageAt(upstreamDir); - const heldText = held ? `\`${name}@${held.version}\`` : 'no copy at all'; - if (!upstream || upstream.name !== name || !judged(name, range, upstream.version, 'the objectstack checkout')) { - fail( - `the injected ${SPEC_PACKAGE_NAME} declares \`${name}: ${range}\`. The store entry ` + - `${storeEntryName(target)} holds ${heldText}, which does not satisfy it, ` + - `and the objectstack checkout has ` + - (upstream ? `\`${upstream.name}@${upstream.version}\`` : 'no copy') + - ` at ${upstreamDir}. No copy satisfying the range is available, so nothing was injected: a ` + - `compile now would read the spec's declarations against a dependency they were not built ` + - `against, and report this gate's own injection as a shape break.`, - ); - } - let linkedFrom = null; - try { - linkedFrom = fs.readlinkSync(sibling); - } catch { - // Absent, or a directory rather than a link: nothing to print as its target. - } - plan.push({ name, range, target, sibling, held, linkedFrom, upstream }); - } - } - return plan; -} - -/** - * Re-point one sibling. REMOVE, then link. ⛔ Never write into what is there: a - * link is replaced, and a directory -- which a pnpm store entry never holds - * here, but a hand-made one might -- is unlinked file by file, locally. Neither - * path writes through a hardlink into the global store. See this file's header. - */ -function relinkSibling({ sibling, upstream }) { - const stat = fs.lstatSync(sibling, { throwIfNoEntry: false }); - if (stat?.isSymbolicLink()) fs.unlinkSync(sibling); - else if (stat) fs.rmSync(sibling, { recursive: true, force: true }); - fs.mkdirSync(path.dirname(sibling), { recursive: true }); - fs.symlinkSync(upstream.realpath, sibling, 'dir'); -} - -/** The one line a substitution is reported with, in the log and the run summary. */ -function describeSubstitution({ name, range, target, held, linkedFrom, upstream }) { - const entry = storeEntryName(target); - const was = held ? `${name}@${held.version} (-> ${linkedFrom ?? held.realpath})` : 'absent'; - return ( - `substituted ${name} for ${SPEC_PACKAGE_NAME} in ${entry}: declared ${range}, the store held ${was}, ` + - `which does not satisfy it; now -> ${upstream.realpath} (${name}@${upstream.version}, the copy ` + - `the objectstack checkout's spec build resolved)` - ); -} - -/* ── objectui's own copies of a re-pointed dependency ───────────────────── */ - -/** The manifest fields whose ranges pnpm links into an importer's `node_modules`. */ -const IMPORTER_DEPENDENCY_FIELDS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']; - -/** - * The `packages:` globs of a `pnpm-workspace.yaml`, read narrowly: `- 'dir/*'` - * and `- 'dir'` entries only. Anything else -- a negation, a `**`, a glob inside - * a segment -- THROWS, because an importer the reader silently drops is one - * whose copy is never re-pointed, and the proof below cannot see it either. - */ -export function parseWorkspaceGlobs(text) { - const globs = []; - let inPackages = false; - for (const raw of String(text).split('\n')) { - const line = raw.replace(/\s+#.*$/, '').replace(/\s+$/, ''); - if (line.length === 0 || /^\s*#/.test(line)) continue; - if (/^\S/.test(line)) { - inPackages = /^packages:\s*$/.test(line); - continue; - } - if (!inPackages) continue; - const entry = /^\s+-\s+(['"]?)([^'"]+)\1$/.exec(line); - if (!entry || !/^[\w.@-]+(?:\/[\w.@-]+)*(?:\/\*)?$/.test(entry[2])) { - throw new Error(`pnpm-workspace.yaml line "${raw.trim()}" is not a \`- 'dir'\` or \`- 'dir/*'\` entry`); - } - globs.push(entry[2]); - } - if (globs.length === 0) throw new Error('pnpm-workspace.yaml declares no `packages:` entries'); - return globs; -} - -/** The root and every workspace directory `pnpm-workspace.yaml` names that holds a manifest. */ -function workspaceImporterDirs(repoRoot) { - let globs; - try { - globs = parseWorkspaceGlobs(fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf8')); - } catch (error) { - return fail( - `cannot enumerate this repository's workspace importers: ${error.message}. Refused rather than ` + - `guessed -- an importer left out keeps its own copy of a re-pointed dependency, and the ` + - `compile reads two copies without a word.`, - ); - } - const dirs = new Set(['.']); - for (const glob of globs) { - if (glob.endsWith('/*')) { - const parent = glob.slice(0, -2); - const parentDir = path.join(repoRoot, parent); - if (!fs.existsSync(parentDir)) continue; - for (const child of fs.readdirSync(parentDir)) dirs.add(path.posix.join(parent, child)); - } else { - dirs.add(glob); - } - } - return [...dirs].filter((dir) => fs.existsSync(path.join(repoRoot, dir, 'package.json'))).sort(); -} - -/** Does `importer`'s declared `range` for `name` admit `version`? Unreadable is exit 2. */ -function importerAdmits(name, range, version, importer) { - try { - return satisfiesRange(version, range); - } catch (error) { - return fail( - `cannot judge whether objectui importer ${importer}'s declared \`${name}: ${range}\` admits ` + - `${name}@${version}, the copy the injected ${SPEC_PACKAGE_NAME} was re-pointed to: ${error.message}. ` + - `Refused rather than guessed -- the answer decides whether this compile reads one copy or two.`, - ); - } -} - -/** - * For each package the spec substitution re-pointed, every objectui importer - * declaring it, judged against the version it was re-pointed to. Decided before - * the first write, like the spec's own plan. - */ -function planImporterRepoints({ repoRoot, plan }) { - const repointed = new Map(); - for (const step of plan) repointed.set(step.name, step.upstream); - if (repointed.size === 0) return { relink: [], twoCopies: [], importers: new Map() }; - - const relink = []; - const twoCopies = []; - const importers = new Map(); - const dirs = workspaceImporterDirs(repoRoot); - for (const [name, upstream] of [...repointed].sort(([a], [b]) => a.localeCompare(b))) { - const declaring = []; - for (const dir of dirs) { - const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, dir, 'package.json'), 'utf8')); - const ranges = [ - ...new Set( - IMPORTER_DEPENDENCY_FIELDS.map((field) => manifest[field]?.[name]).filter((range) => typeof range === 'string'), - ), - ]; - if (ranges.length === 0) continue; - const refusing = ranges.filter((range) => !importerAdmits(name, range, upstream.version, dir)); - const link = path.join(repoRoot, dir, 'node_modules', ...name.split('/')); - let linkedFrom = null; - try { - linkedFrom = fs.readlinkSync(link); - } catch { - // Absent, or a directory rather than a link: nothing to print as its target. - } - const entry = { name, importer: dir, ranges, refusing, link, linkedFrom, held: installedPackageAt(link), upstream }; - declaring.push(entry); - (refusing.length === 0 ? relink : twoCopies).push(entry); - } - importers.set(name, declaring); - } - return { relink, twoCopies, importers }; -} - -function describeImporterRepoint({ name, importer, ranges, held, linkedFrom, upstream }) { - const was = held ? `${name}@${held.version} (-> ${linkedFrom ?? held.realpath})` : 'absent'; - return ( - `re-pointed ${name} for objectui importer ${importer}: declares ${ranges.join(' and ')}, which admits ` + - `${upstream.version}; was ${was}, now -> ${upstream.realpath} (${name}@${upstream.version}, the copy the ` + - `spec substitution linked)` - ); -} - -function describeTwoCopies({ name, importer, refusing, upstream }) { - return `two copies of ${name} stay: ${importer} declares ${refusing.join(' and ')}, which does not admit ${upstream.version}`; -} - -/** - * Which copy of `name` code at `fromDir` resolves: the first - * `/` along Node's own lookup path for that directory. The - * package directory is found the way both Node and `tsc` walk `node_modules`, - * without going through an `exports` map that may not expose `package.json`. - */ -function resolvedDependency(fromDir, name) { - const lookup = createRequire(path.join(fromDir, 'package.json')).resolve.paths(name) ?? []; - for (const modulesDir of lookup) { - const found = installedPackageAt(path.join(modulesDir, ...name.split('/'))); - if (found) return found; - } - return null; -} - -/** - * Replace every installed copy of the spec with the staged one, re-point its - * store siblings at the dependencies it was built against where the installed - * ones do not satisfy its manifest, and PROVE both took. - * - * `upstreamCheckout` is the objectstack checkout the tarball was built and - * packed in: the spec build's own dependency resolution lives under its - * `packages/spec/node_modules`. - * - * Returns the reading so callers (and the self-test) can assert on it rather - * than on stdout. - */ -export function inject({ tarball, sha, upstreamCheckout, repoRoot = repoRootDefault, log = () => {} }) { - const upstreamSpecManifest = upstreamCheckout - ? path.join(upstreamCheckout, 'packages', 'spec', 'package.json') - : null; - let upstreamSpecName = null; - try { - upstreamSpecName = JSON.parse(fs.readFileSync(upstreamSpecManifest, 'utf8')).name; - } catch { - // Reported below with the path that was tried. - } - if (upstreamSpecName !== SPEC_PACKAGE_NAME) { - fail( - `--upstream-checkout must be the objectstack checkout the spec was built in, and ` + - `${upstreamSpecManifest ?? '(none given)'} is not \`${SPEC_PACKAGE_NAME}\`'s manifest. Without it ` + - `there is no source for the dependencies the spec's declarations were emitted against.`, - ); - } - - const targets = findInstalledSpecDirs(repoRoot); - if (targets.length === 0) { - fail( - `no installed \`${SPEC_PACKAGE_NAME}\` found under ${path.join(repoRoot, 'node_modules/.pnpm')}. ` + - `Nothing was injected, so a typecheck run after this point would compile against whatever ` + - `is installed and report a verdict about the PUBLISHED spec under this gate's name. ` + - `Run \`pnpm install --frozen-lockfile\` first, or fix the store layout this reader assumes.`, - ); - } - - const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'spec-main-shape-gate-')); - const staged = extractTarball(tarball, staging); - - const manifest = JSON.parse(fs.readFileSync(path.join(staged, 'package.json'), 'utf8')); - if (manifest.name !== SPEC_PACKAGE_NAME) { - fail( - `the staged package is \`${manifest.name}\`, not \`${SPEC_PACKAGE_NAME}\`. Refusing to write ` + - `it over the installed spec -- a mis-aimed tarball would produce a typecheck whose subject ` + - `is not the one this gate reports on.`, - ); - } - - const missingTypes = declaredTypesTargets(manifest.exports).filter( - (target) => !fs.existsSync(path.join(staged, target)), - ); - if (missingTypes.length > 0) { - fail( - `the staged \`${SPEC_PACKAGE_NAME}\` declares ${missingTypes.length} \`types\` target(s) its ` + - `tarball does not contain:\n` + - missingTypes.map((t) => ` - ${t}`).join('\n') + - `\n\nThis is what a spec built with its declaration pass skipped looks like. Injecting it ` + - `would fail every consumer with TS2307 and this gate would report that as a shape break at ` + - `${sha}. Build the spec with declarations and pack it again.`, - ); - } - - // Decided before the first write: a dependency no copy can satisfy is exit 2 - // with the install exactly as it was. See the header. - const plan = planDependencySubstitutions({ - dependencies: manifest.dependencies, - targets, - upstreamCheckout, - }); - // objectui's own copies of what that plan re-points, judged before any write - // too. See "...and why objectui's OWN copies" in the header. - const importerPlan = planImporterRepoints({ repoRoot, plan }); - - for (const target of targets) { - // REMOVE, then copy. ⛔ Never copy over: the files below are hardlinks into - // pnpm's global store, and writing through one corrupts the store for every - // other checkout on this machine. See this file's header. - fs.rmSync(target, { recursive: true, force: true }); - fs.mkdirSync(target, { recursive: true }); - fs.cpSync(staged, target, { recursive: true, dereference: true }); - fs.writeFileSync( - path.join(target, MARKER_FILE), - `${JSON.stringify( - { - upstream: UPSTREAM_REPO, - sha, - version: manifest.version, - injectedAt: new Date().toISOString(), - }, - null, - 2, - )}\n`, - ); - } - - fs.rmSync(staging, { recursive: true, force: true }); - - const substitutions = plan.map((step) => { - relinkSibling(step); - return describeSubstitution(step); - }); - for (const entry of importerPlan.relink) { - relinkSibling({ sibling: entry.link, upstream: entry.upstream }); - substitutions.push(describeImporterRepoint(entry)); - } - for (const entry of importerPlan.twoCopies) substitutions.push(describeTwoCopies(entry)); - - // The verification, asked of the consumers. See `findSpecConsumers`. - const consumers = findSpecConsumers(repoRoot); - if (consumers.length === 0) { - fail( - `the injection wrote ${targets.length} director(ies), and then no workspace package was ` + - `found linking to \`${SPEC_PACKAGE_NAME}\`. A reading of zero consumers is a broken reader ` + - `or an install that never happened -- not evidence that the injection reached everyone.`, - ); - } - const stranded = consumers.filter( - (consumer) => !fs.existsSync(path.join(consumer.resolved, MARKER_FILE)), - ); - if (stranded.length > 0) { - fail( - `these ${stranded.length} consumer(s) still resolve \`${SPEC_PACKAGE_NAME}\` to a copy this ` + - `run did not inject:\n` + - stranded.map((c) => ` - ${c.workspace} -> ${c.resolved}`).join('\n') + - `\n\nA typecheck now would mix the built-from-source spec with the published one, and the ` + - `green half would be indistinguishable from a real pass.`, - ); - } - - // Which copy of each declared dependency the injected spec resolves -- asked - // of every consumer's resolved spec, like the marker check above, and never - // of the plan: "I re-pointed zod" is this function's claim about itself. - const resolutions = []; - for (const consumer of consumers) { - for (const [name, range] of Object.entries(manifest.dependencies ?? {})) { - const found = resolvedDependency(consumer.resolved, name); - resolutions.push({ - workspace: consumer.workspace, - name, - range, - version: found?.version ?? null, - resolved: found?.realpath ?? null, - satisfied: found !== null && judged(name, range, found.version, 'the resolved'), - }); - } - } - const unsatisfied = resolutions.filter((resolution) => !resolution.satisfied); - if (unsatisfied.length > 0) { - fail( - `the injected \`${SPEC_PACKAGE_NAME}\` resolves ${unsatisfied.length} declared dependency ` + - `reading(s) outside the range its manifest declares:\n` + - unsatisfied - .map( - (r) => - ` - ${r.workspace}: ${r.name} ${r.range} -> ` + - (r.version ? `${r.name}@${r.version} (${r.resolved})` : 'nothing'), - ) - .join('\n') + - `\n\nA compile now would read the spec's declarations against a dependency they were not ` + - `built against, and report this gate's own injection as a shape break.`, - ); - } - - // ONE copy of each re-pointed package: every spec consumer and every objectui - // importer whose range admits it must resolve the same directory. Asked of - // each of them, never of the plan. - const oneCopy = []; - for (const [name, declaring] of importerPlan.importers) { - const expected = plan.find((step) => step.name === name).upstream.realpath; - const readers = [ - ...resolutions - .filter((r) => r.name === name) - .map((r) => ({ who: `spec consumer ${r.workspace}`, resolved: r.resolved, version: r.version })), - ...declaring - .filter((entry) => entry.refusing.length === 0) - .map((entry) => { - const found = resolvedDependency(path.join(repoRoot, entry.importer), name); - return { who: `objectui importer ${entry.importer}`, resolved: found?.realpath ?? null, version: found?.version ?? null }; - }), - ]; - const elsewhere = readers.filter((reader) => reader.resolved !== expected); - if (elsewhere.length > 0) { - fail( - `${elsewhere.length} reader(s) of the re-pointed \`${name}\` do not resolve the one copy at ` + - `${expected}:\n` + - elsewhere - .map((r) => ` - ${r.who} -> ` + (r.version ? `${name}@${r.version} (${r.resolved})` : 'nothing')) - .join('\n') + - `\n\nA compile now would read two copies of \`${name}\` in one program, and report the ` + - `artifacts of that as shape breaks.`, - ); - } - oneCopy.push({ - name, - version: readers[0]?.version ?? null, - resolved: expected, - consumers: readers.filter((r) => r.who.startsWith('spec consumer')).length, - importers: readers.length - readers.filter((r) => r.who.startsWith('spec consumer')).length, - twoCopies: declaring.filter((entry) => entry.refusing.length > 0).map((entry) => entry.importer), - }); - } - - log( - `spec-main-shape-gate: injected ${SPEC_PACKAGE_NAME}@${manifest.version} built from ` + - `${UPSTREAM_REPO}@${sha} into ${targets.length} store copy/copies; ` + - `${consumers.length} workspace consumer(s) verified on it.`, - ); - for (const target of targets) log(` store ${path.relative(repoRoot, target)}`); - for (const consumer of consumers) log(` consumer ${consumer.workspace}`); - for (const line of substitutions) log(`spec-main-shape-gate: ${line}`); - if (plan.length === 0) { - log( - `spec-main-shape-gate: no dependency substitution -- every declared dependency's store sibling ` + - `satisfies its range.`, - ); - } - const distinct = new Map(); - for (const r of resolutions) { - const key = JSON.stringify([r.name, r.resolved]); - distinct.set(key, { ...r, consumers: (distinct.get(key)?.consumers ?? 0) + 1 }); - } - for (const r of distinct.values()) { - log( - ` resolves ${r.name}@${r.version} (declared ${r.range}) for ${r.consumers} consumer(s) -> ${r.resolved}`, - ); - } - for (const c of oneCopy) { - log( - ` one copy of ${c.name}@${c.version} for ${c.consumers} spec consumer(s) and ${c.importers} objectui ` + - `importer(s) -> ${c.resolved}` + - (c.twoCopies.length > 0 ? `; a second copy stays for ${c.twoCopies.join(', ')}` : ''), - ); - } - - // The marker turbo hashes (objectui#12114). The root link, when there is one, - // already passed the consumer proof above, so this reads where turbo will look. - const hashMarker = fs.existsSync(path.join(repoRoot, TURBO_HASH_MARKER)) ? TURBO_HASH_MARKER : null; - log( - hashMarker - ? `spec-main-shape-gate: turbo hashes ${hashMarker}, so this tree's task hashes no longer match an ` + - `installed tree's: a type-check here neither replays an installed verdict nor leaves one for a sibling ` + - `worktree. \`pnpm install --frozen-lockfile --force\` undoes the injection and the marker together.` - : `spec-main-shape-gate: ⚠️ ${TURBO_HASH_MARKER} is absent -- no root link to the spec -- so turbo hashes ` + - `this tree like an installed one, and a type-check here leaves verdicts a sibling worktree's installed ` + - `type-check can replay.`, - ); - - return { targets, consumers, version: manifest.version, sha, substitutions, resolutions, oneCopy, hashMarker }; -} - -/** - * The turbo line prefix, and the workspace it names. - * - * turbo prefixes every line of a task's output with `:: `, and - * root tasks with `//:: `. Stripping it is not cosmetic: `tsc` prints - * paths relative to ITS OWN cwd, which is the package directory, so - * `src/foo.ts(3,9)` is ambiguous across forty packages until the prefix says - * which one. The prefix is the only thing that makes the acceptance criterion - * -- name the objectui FILE -- answerable at all. - * - * ⚠️ BUT THE PREFIX IS NOT THERE IN THE ONLY LOG THIS GATE EVER READS. turbo picks - * its log order from the environment: STREAM locally, where every line carries - * the prefix above, and GROUPED on a GitHub Actions runner, where each task gets - * a `##[group]:` header and its output is emitted BARE. The - * failing task is not even grouped -- it is announced by a colourised header - * line and then streams unprefixed. Measured on this gate's own runs: both - * reported `src/hooks/__tests__/...` for a file that lives under - * `packages/react/`, because the prefix-only reading had nothing to match, and - * `unmappedPackages` stayed EMPTY so the summary did not even warn. A path no - * reader can open, handed over as the answer to "which objectui file". - * - * So the workspace is tracked from BOTH carriers: the prefix when it is there, - * and otherwise the header of the task whose output is currently streaming. - */ -const TURBO_PREFIX = /^(?:\x1b\[[0-9;]*m)*([^\s:]+):([^\s:]+(?::[^\s:]+)*):\s?/; - -/** - * A turbo task header standing alone on its line -- `:`, with or - * without the `##[group]` that GitHub Actions wraps a collapsible section in. - * - * Deliberately anchored at BOTH ends: a header is the whole line. Anything with - * a space in it -- ` Tasks: 27 successful`, `> tsc --noEmit`, a diagnostic -- - * is not one, and the caller additionally refuses any name that is not a real - * workspace, so a stray `foo:bar` in somebody's output cannot silently become - * the package a later diagnostic is charged to. - */ -const TURBO_GROUP_HEADER = /^(?:##\[group\])?([^\s:]+):([^\s:]+(?::[^\s:]+)*)$/; - -/** GitHub Actions' end-of-section marker: whatever was streaming has stopped. */ -const GROUP_END = /^##\[endgroup\]\s*$/; - -/** A `tsc` diagnostic line, in the form every package here emits it. */ -const DIAGNOSTIC = /^(.+?)\((\d+),(\d+)\):\s+(error|warning)\s+(TS\d+):\s+(.*)$/; - -/** Strip ANSI so a colourised log parses the same as a plain one. */ -const ANSI = /\x1b\[[0-9;]*m/g; - -/** - * Map a turbo package name to its directory, by reading the workspace manifests. - * - * Derived, never tabulated: a hand-kept name->directory table is one package - * rename away from attributing a failure to the wrong file, which is worse than - * not attributing it at all. - */ -export function workspaceDirsByName(repoRoot = repoRootDefault) { - const byName = new Map([['//', '.']]); - for (const group of ['packages', 'apps', 'examples', 'docs']) { - const groupDir = path.join(repoRoot, group); - if (!fs.existsSync(groupDir)) continue; - const entries = fs.statSync(groupDir).isDirectory() ? fs.readdirSync(groupDir) : []; - for (const pkg of entries) { - const manifest = path.join(groupDir, pkg, 'package.json'); - if (!fs.existsSync(manifest)) continue; - try { - const { name } = JSON.parse(fs.readFileSync(manifest, 'utf8')); - if (name) byName.set(name, path.posix.join(group, pkg)); - } catch { - // A manifest that does not parse is not this gate's subject; the file it - // would have named simply stays unmapped and is reported unmapped. - } - } - } - return byName; -} - -/** - * Parse a captured `pnpm type-check` log into attributed diagnostics. - * - * Every row carries the objectui file as a REPO-RELATIVE path, because that is - * what a GitHub annotation needs and what a reader can open. - */ -export function parseDiagnostics(logText, repoRoot = repoRootDefault) { - const dirs = workspaceDirsByName(repoRoot); - const rows = []; - const unmappedPackages = new Set(); - - // The task whose output is streaming right now, in turbo's GROUPED log order. - // Null means "nothing is known to be streaming", which is the honest state - // outside a task's own section -- and an unattributed diagnostic is reported - // as unattributed rather than charged to whoever ran last. - let streaming = null; - - for (const rawLine of logText.split('\n')) { - const line = rawLine.replace(ANSI, ''); - - if (GROUP_END.test(line)) { - streaming = null; - continue; - } - const header = line.match(TURBO_GROUP_HEADER); - if (header) { - // Only a name this workspace actually has may become the attribution - // target; anything else clears it. The alternative -- trusting the shape - // -- attributes a file to a package that does not exist, which is worse - // than not attributing it. - streaming = dirs.has(header[1]) ? header[1] : null; - continue; - } - - const prefixed = line.match(TURBO_PREFIX); - const workspaceName = prefixed ? prefixed[1] : streaming; - const body = prefixed ? line.slice(prefixed[0].length) : line; - - const diagnostic = body.match(DIAGNOSTIC); - if (!diagnostic) continue; - - const [, rawFile, lineNo, column, severity, code, message] = diagnostic; - let file = rawFile.trim(); - if (path.isAbsolute(file)) { - file = path.relative(repoRoot, file); - } else if (workspaceName) { - const dir = dirs.get(workspaceName); - if (dir) file = dir === '.' ? file : path.posix.join(dir, file); - else unmappedPackages.add(workspaceName); - } - - rows.push({ - workspace: workspaceName ?? '(unprefixed)', - file: file.split(path.sep).join('/'), - line: Number(lineNo), - column: Number(column), - severity, - code, - message, - }); - } - - return { rows, unmappedPackages: [...unmappedPackages].sort() }; -} - -/** The markdown this gate puts in front of a human when it goes red. */ -export function renderSummary({ sha, rows, unmappedPackages, status, commit = null }) { - const short = sha.slice(0, 12); - const link = `https://github.com/${UPSTREAM_REPO}/commit/${sha}`; - const out = []; - - out.push(`### Spec Main Shape Gate — \`${UPSTREAM_REPO}@${short}\``); - out.push(''); - out.push(`Compiled this repository against \`${SPEC_PACKAGE_NAME}\` built from [${short}](${link}).`); - if (commit) out.push('', `> ${commit}`); - out.push(''); - - if (status === 0 && rows.length === 0) { - out.push(`✅ objectui type-checks against \`${SPEC_PACKAGE_NAME}\` at that commit.`); - return out.join('\n'); - } - - if (rows.length === 0) { - out.push( - `⛔ The typecheck failed (exit ${status}) and **no diagnostic could be attributed to a file**.`, - '', - `That is not a shape break this gate can hand to anybody: the whole point of compiling a ` + - `consumer is that the objectstack change which moved the shape is identifiable from the ` + - `objectui file that stopped compiling. Read the job log — the failure is upstream of the ` + - `compiler (an install, a build, the spec's own build) or the compiler's output shape moved.`, - ); - return out.join('\n'); - } - - const files = [...new Set(rows.map((row) => row.file))].sort(); - out.push( - `⛔ **${rows.length} diagnostic(s) in ${files.length} objectui file(s)** against ` + - `\`${UPSTREAM_REPO}@${short}\`.`, - '', - `The objectstack pull request that moved this shape is the one that answers. Each row below is ` + - `an objectui file that compiles against the pinned published spec and does not compile ` + - `against that commit.`, - '', - '| objectui file | line | code | message |', - '| --- | --- | --- | --- |', - ); - for (const row of rows.slice(0, 50)) { - out.push( - `| \`${row.file}\` | ${row.line} | ${row.code} | ${row.message.replace(/\|/g, '\\|')} |`, - ); - } - if (rows.length > 50) out.push(`| … | | | ${rows.length - 50} more, in the job log |`); - - out.push('', `**Files:** ${files.map((file) => `\`${file}\``).join(', ')}`); - out.push( - '', - `**Against:** \`${UPSTREAM_REPO}@${sha}\` — ${link}`, - ); - - if (unmappedPackages.length > 0) { - out.push( - '', - `⚠️ ${unmappedPackages.length} turbo package name(s) did not resolve to a directory ` + - `(${unmappedPackages.join(', ')}), so the paths they contributed are package-relative. ` + - `That is a reader problem in this gate, not a finding about the spec.`, - ); - } - - return out.join('\n'); -} - -/** GitHub annotations: one per diagnostic, each naming the objectstack commit. */ -function emitAnnotations(rows, sha) { - const short = sha.slice(0, 12); - for (const row of rows) { - const message = - `${row.code}: ${row.message} — compiled against ${SPEC_PACKAGE_NAME} built from ` + - `${UPSTREAM_REPO}@${short}`; - process.stdout.write( - `::error file=${row.file},line=${row.line},col=${row.column},` + - `title=spec@main shape break (${short})::${message.replace(/\r?\n/g, ' ')}\n`, - ); - } -} - -function report({ logPath, sha, status, commit }) { - const logText = fs.readFileSync(logPath, 'utf8'); - const { rows, unmappedPackages } = parseDiagnostics(logText); - const summary = renderSummary({ sha, rows, unmappedPackages, status, commit }); - - if (process.env.GITHUB_STEP_SUMMARY) { - fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`); - } - process.stdout.write(`${summary}\n`); - - if (status === 0 && rows.length === 0) return 0; - if (rows.length === 0) return 2; - emitAnnotations(rows, sha); - return 1; -} - -/* ─────────────────────────── self-test ───────────────────────────────────── - * Offline, and it exercises BOTH directions of every judgement that decides a - * verdict. A parser demonstrated only on input it parses is not demonstrated. - */ -function selfTest() { - /** ESC, built from its code point so no control byte is ever written into this file. */ - const ESC = String.fromCharCode(27); - const results = []; - const check = (name, actual, expected) => { - const ok = JSON.stringify(actual) === JSON.stringify(expected); - results.push({ name, ok, actual, expected }); - }; - - const sha = 'a'.repeat(40); - const fixture = [ - '@object-ui/core:type-check: > tsc --noEmit', - '@object-ui/core:type-check: src/registry.ts(12,5): error TS2339: Property nope does not exist.', - // The colour escape is BUILT, never typed: an editor that materialises an - // escape spelling into a raw control byte puts one in this file, and - // `pnpm check:control-bytes` is right to refuse it. - `${ESC}[32m@object-ui/plugin-grid:type-check:${ESC}[0m src/grid.tsx(4,1): error TS2724: no export.`, - '//:type-check:e2e: e2e/app.spec.ts(9,3): error TS2554: Expected 1 arguments.', - '@object-ui/core:type-check: this line is prose about (1,2): error TS9999 inside a sentence', - ].join('\n'); - - const parsed = parseDiagnostics(fixture); - check( - 'attributes a package-relative path to its workspace directory', - parsed.rows[0]?.file, - 'packages/core/src/registry.ts', - ); - check('parses a colourised prefix', parsed.rows[1]?.file, 'packages/plugin-grid/src/grid.tsx'); - check('maps the root task to the repo root', parsed.rows[2]?.file, 'e2e/app.spec.ts'); - check('carries the code through', parsed.rows[1]?.code, 'TS2724'); - - // The firing control for the "no diagnostics" verdict: the same parser over a - // green log must find NOTHING, or every assertion above is satisfied by noise. - const green = [ - '@object-ui/core:type-check: > tsc --noEmit', - '@object-ui/core:type-check: ', - ' Tasks: 81 successful, 81 total', - ].join('\n'); - check('a green log yields zero rows', parseDiagnostics(green).rows.length, 0); - - // The log order turbo ACTUALLY selects on a GitHub Actions runner: a - // `##[group]` header per task, output emitted BARE under it, and the failing - // task announced by a colourised header with no group at all. The fixture is - // the shape this gate's own runs produced. - const grouped = [ - '##[group]@object-ui/sdui-parser:type-check', - '> tsc --noEmit && tsc -p tsconfig.test.json', - '##[endgroup]', - `${ESC}[;31m@object-ui/react:type-check${ESC}[;0m`, - '> tsc --noEmit && tsc -p tsconfig.test.json', - "src/hooks/useNavigationOverlay.ts(76,3): error TS2322: Type 'string' is not assignable.", - ].join('\n'); - check( - 'attributes a BARE diagnostic to the task whose section it is streaming in', - parseDiagnostics(grouped).rows[0]?.file, - 'packages/react/src/hooks/useNavigationOverlay.ts', - ); - - // The firing control for the line above. Same diagnostic, same parser, with - // the header removed: the attribution must fall away, or the assertion above - // is satisfied by something other than the header it claims to read. - const headerless = [ - '##[endgroup]', - "src/hooks/useNavigationOverlay.ts(76,3): error TS2322: Type 'string' is not assignable.", - ].join('\n'); - check( - 'a diagnostic in no task section is NOT charged to a package', - parseDiagnostics(headerless).rows[0]?.file, - 'src/hooks/useNavigationOverlay.ts', - ); - - // A name that is not a workspace may not become an attribution target: a - // `foo:bar` line in somebody's output would otherwise redirect every later - // diagnostic into a directory that does not exist. - check( - 'a header-shaped line naming no workspace clears the attribution', - parseDiagnostics( - [ - '##[group]@object-ui/react:type-check', - 'totally:unrelated', - "src/hooks/useNavigationOverlay.ts(76,3): error TS2322: Type 'string' is not assignable.", - ].join('\n'), - ).rows[0]?.file, - 'src/hooks/useNavigationOverlay.ts', - ); - - // `renderSummary`, both directions. - check( - 'a green summary says so', - renderSummary({ sha, rows: [], unmappedPackages: [], status: 0 }).includes('type-checks against'), - true, - ); - check( - 'an unattributable failure refuses to read as a shape break', - renderSummary({ sha, rows: [], unmappedPackages: [], status: 1 }).includes( - 'no diagnostic could be attributed to a file', - ), - true, - ); - check( - 'a red summary names the file AND the commit', - (() => { - const text = renderSummary({ sha, rows: parsed.rows, unmappedPackages: [], status: 1 }); - return text.includes('packages/core/src/registry.ts') && text.includes(sha); - })(), - true, - ); - - // `declaredTypesTargets`, both directions. - check( - 'reads every `types` target out of a conditional exports map', - declaredTypesTargets({ - '.': { import: { types: './dist/index.d.mts', default: './dist/index.mjs' } }, - './ui': { require: { types: './dist/ui/index.d.ts', default: './dist/ui/index.js' } }, - }), - ['./dist/index.d.mts', './dist/ui/index.d.ts'], - ); - check('an exports map with no types target yields none', declaredTypesTargets({ '.': './x.js' }), []); - - // `findInstalledSpecDirs` over a synthetic store, both directions. - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'spec-gate-selftest-')); - const store = path.join(tmp, 'node_modules', '.pnpm'); - const one = path.join(store, '@objectstack+spec@17.4.0_ai@7.0.0_', 'node_modules', '@objectstack', 'spec'); - fs.mkdirSync(one, { recursive: true }); - fs.writeFileSync(path.join(one, 'package.json'), '{"name":"@objectstack/spec"}'); - fs.mkdirSync(path.join(store, '@objectstack+formula@17.4.0', 'node_modules', '@objectstack', 'formula'), { - recursive: true, - }); - check('finds a peer-suffixed store copy', findInstalledSpecDirs(tmp).length, 1); - check('does not claim a sibling @objectstack package', findInstalledSpecDirs(tmp)[0], one); - check('an empty tree finds none', findInstalledSpecDirs(path.join(tmp, 'nowhere')).length, 0); - fs.rmSync(tmp, { recursive: true, force: true }); - - // `satisfiesRange` decides which dependency the whole compile reads: both - // directions of every operator it claims, and the refusal of what it does not. - check('a caret range refuses an older minor', satisfiesRange('4.4.3', '^4.6.1'), false); - check('a caret range admits its floor and a later minor', [ - satisfiesRange('4.6.1', '^4.6.1'), - satisfiesRange('4.7.0', '^4.6.1'), - ], [true, true]); - check('a caret range refuses the next major', satisfiesRange('5.0.0', '^4.6.1'), false); - check('a caret range on 0.x stops at the next minor', [ - satisfiesRange('0.3.9', '^0.3.1'), - satisfiesRange('0.4.0', '^0.3.1'), - ], [true, false]); - check('a tilde range stops at the next minor', [ - satisfiesRange('2.14.9', '~2.14.0'), - satisfiesRange('2.15.0', '~2.14.0'), - ], [true, false]); - check('an exact version admits only itself', [ - satisfiesRange('2.14.0', '2.14.0'), - satisfiesRange('2.14.1', '2.14.0'), - ], [true, false]); - check('a comparator set is an AND, a union an OR', [ - satisfiesRange('17.9.0', '>=17.1.0 <18.0.0'), - satisfiesRange('18.0.0', '>=17.1.0 <18.0.0'), - satisfiesRange('3.0.0', '^1.0.0 || ^3.0.0'), - satisfiesRange('2.0.0', '^1.0.0 || ^3.0.0'), - ], [true, false, true, false]); - const refuses = (version, range) => { - try { - satisfiesRange(version, range); - return false; - } catch { - return true; - } - }; - // `parseWorkspaceGlobs` decides which importers the dedupe pass can see. - check( - 'reads the packages globs of a pnpm-workspace.yaml, and nothing after them', - parseWorkspaceGlobs("# c\npackages:\n - 'packages/*'\n - \"docs\" # tail\n - apps/*\nonlyBuiltDependencies:\n - esbuild\n"), - ['packages/*', 'docs', 'apps/*'], - ); - check('a glob the reader does not know throws rather than dropping importers', [ - (() => { try { parseWorkspaceGlobs("packages:\n - 'packages/**'\n"); return false; } catch { return true; } })(), - (() => { try { parseWorkspaceGlobs("packages:\n - '!packages/x'\n"); return false; } catch { return true; } })(), - (() => { try { parseWorkspaceGlobs('overrides: {}\n'); return false; } catch { return true; } })(), - ], [true, true, true]); - check('an unreadable spelling throws rather than guessing', [ - refuses('4.6.1', 'workspace:*'), - refuses('4.6.1', '4.x'), - refuses('4.6.1', '^4.6'), - refuses('4.7.0-beta.1', '^4.6.1'), - // Parsed before judged: the first alternative holding does not excuse the second. - refuses('4.6.1', '^4.6.1 || latest'), - ], [true, true, true, true, true]); - - for (const result of results) { - process.stdout.write(`${result.ok ? 'ok ' : 'FAIL'} ${result.name}\n`); - if (!result.ok) { - process.stdout.write(` expected ${JSON.stringify(result.expected)}\n`); - process.stdout.write(` actual ${JSON.stringify(result.actual)}\n`); - } - } - const failed = results.filter((result) => !result.ok).length; - process.stdout.write( - `spec-main-shape-gate --self-test: ${results.length - failed}/${results.length} passed\n`, - ); - return failed === 0 ? 0 : 1; -} - -function readFlag(argv, flag) { - const at = argv.indexOf(flag); - return at === -1 ? undefined : argv[at + 1]; -} - -function main(argv) { - if (argv.includes('--self-test')) return selfTest(); - - const mode = argv[0]; - const sha = readFlag(argv, '--sha'); - - if (mode === 'inject') { - const tarball = readFlag(argv, '--tarball'); - const upstreamCheckout = readFlag(argv, '--upstream-checkout'); - if (!tarball || !sha || !upstreamCheckout) { - fail('usage: inject --tarball --sha --upstream-checkout '); - } - const { substitutions } = inject({ - tarball, - sha, - upstreamCheckout, - log: (line) => process.stdout.write(`${line}\n`), - }); - if (process.env.GITHUB_STEP_SUMMARY) { - const lines = substitutions.length > 0 - ? substitutions.map((line) => `- ${line}`) - : ["- no dependency substitution: every declared dependency's store sibling satisfies its range."]; - fs.appendFileSync( - process.env.GITHUB_STEP_SUMMARY, - `### Spec Main Shape Gate — injection\n\n${lines.join('\n')}\n\n`, - ); - } - return 0; - } - - if (mode === 'report') { - const logPath = readFlag(argv, '--log'); - const status = Number(readFlag(argv, '--status') ?? '0'); - if (!logPath || !sha) fail('usage: report --log --sha --status '); - if (!fs.existsSync(logPath)) { - fail( - `the captured typecheck log ${logPath} does not exist. With no log there is no reading, ` + - `and an absent reading is never a pass.`, - ); - } - return report({ logPath, sha, status, commit: readFlag(argv, '--commit') }); - } - - fail(`unknown mode ${JSON.stringify(mode ?? '')}. Modes: inject, report, --self-test.`); - return 2; -} - -if (isEntrypoint(import.meta.url)) { - process.exit(main(process.argv.slice(2))); -}