From 076bb900c598b6474dbe08ef0277b3973684169c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 02:02:20 +0000 Subject: [PATCH 01/12] release(ui-merge): one fixed group, an in-workspace Console build, no objectui pin (objectstack#25711, WIP) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_0149BHSXfCdQywGaLdpRAtWe --- .changeset/config.json | 43 +- .objectui-sha | 1 - package.json | 20 +- packages/console/build-dist.sh | 146 + packages/console/package.json | 5 +- .../build-console-objectstack-root.test.ts | 234 - scripts/assert-console-spec-injection.mjs | 310 -- scripts/build-console.sh | 561 --- scripts/bump-objectui.selftest.sh | 548 --- scripts/bump-objectui.sh | 741 --- scripts/check-console-injection.mjs | 1365 ------ scripts/check-console-sha.mjs | 81 - scripts/check-prerelease-pin-watch.mjs | 1127 ----- scripts/console-spec-probes.mjs | 382 -- scripts/objectui-changeset-digest.mjs | 3963 ----------------- scripts/objectui-range.mjs | 798 ---- turbo.json | 5 + 17 files changed, 201 insertions(+), 10129 deletions(-) delete mode 100644 .objectui-sha create mode 100644 packages/console/build-dist.sh delete mode 100644 packages/spec/scripts/build-console-objectstack-root.test.ts delete mode 100644 scripts/assert-console-spec-injection.mjs delete mode 100755 scripts/build-console.sh delete mode 100755 scripts/bump-objectui.selftest.sh delete mode 100755 scripts/bump-objectui.sh delete mode 100644 scripts/check-console-injection.mjs delete mode 100644 scripts/check-console-sha.mjs delete mode 100644 scripts/check-prerelease-pin-watch.mjs delete mode 100644 scripts/console-spec-probes.mjs delete mode 100644 scripts/objectui-changeset-digest.mjs delete mode 100644 scripts/objectui-range.mjs diff --git a/.changeset/config.json b/.changeset/config.json index 4ea188acdd6..9493bed7d9d 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -80,13 +80,52 @@ "@objectstack/account", "create-objectstack", "@objectstack/connector-openapi", - "@objectstack/verify" + "@objectstack/verify", + "@object-ui/app-shell", + "@object-ui/auth", + "@object-ui/cli", + "@object-ui/collaboration", + "@object-ui/components", + "@object-ui/console", + "@object-ui/core", + "@object-ui/create-plugin", + "@object-ui/data-objectstack", + "@object-ui/fields", + "@object-ui/i18n", + "@object-ui/layout", + "@object-ui/mobile", + "@object-ui/permissions", + "@object-ui/plugin-ai", + "@object-ui/plugin-calendar", + "@object-ui/plugin-charts", + "@object-ui/plugin-chatbot", + "@object-ui/plugin-dashboard", + "@object-ui/plugin-designer", + "@object-ui/plugin-detail", + "@object-ui/plugin-editor", + "@object-ui/plugin-form", + "@object-ui/plugin-gantt", + "@object-ui/plugin-grid", + "@object-ui/plugin-kanban", + "@object-ui/plugin-list", + "@object-ui/plugin-map", + "@object-ui/plugin-markdown", + "@object-ui/plugin-report", + "@object-ui/plugin-timeline", + "@object-ui/plugin-tree", + "@object-ui/plugin-view", + "@object-ui/providers", + "@object-ui/react", + "@object-ui/react-runtime", + "@object-ui/runner", + "@object-ui/sdui-parser", + "@object-ui/types" ] ], "linked": [], "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": [], + "ignore": ["@object-ui/example-*", "@object-ui/site", "@object-ui/test-support"], "privatePackages": { "version": true, "tag": false } } diff --git a/.objectui-sha b/.objectui-sha deleted file mode 100644 index c92f3042b49..00000000000 --- a/.objectui-sha +++ /dev/null @@ -1 +0,0 @@ -2958f17e9c6add35636bb07aa9da4b1dcb1daa5f diff --git a/package.json b/package.json index e28703fbf82..c1347b60428 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,10 @@ "description": "ObjectStack Protocol & Specification - Monorepo for TypeScript Interfaces, JSON Schemas, and Convention Configurations", "scripts": { "build": "turbo run build --filter=!@objectstack/docs", - "dev": "node scripts/check-dev-prereqs.mjs && pnpm check:console-sha && pnpm --filter @objectstack/example-showcase dev", - "dev:showcase": "node scripts/check-dev-prereqs.mjs && pnpm check:console-sha && pnpm --filter @objectstack/example-showcase dev", - "dev:crm": "node scripts/check-dev-prereqs.mjs && pnpm check:console-sha && pnpm --filter @objectstack/example-crm dev", - "dev:todo": "node scripts/check-dev-prereqs.mjs && pnpm check:console-sha && pnpm --filter @objectstack/example-todo dev", + "dev": "node scripts/check-dev-prereqs.mjs && pnpm --filter @objectstack/example-showcase dev", + "dev:showcase": "node scripts/check-dev-prereqs.mjs && pnpm --filter @objectstack/example-showcase dev", + "dev:crm": "node scripts/check-dev-prereqs.mjs && pnpm --filter @objectstack/example-crm dev", + "dev:todo": "node scripts/check-dev-prereqs.mjs && pnpm --filter @objectstack/example-todo dev", "spec:rebuild": "turbo run build --filter=...@objectstack/spec", "test": "VITEST_MAX_WORKERS=$(node scripts/vitest-worker-cap.mjs) turbo run test test:repo --concurrency=50%", "test:smoke": "turbo run test:smoke", @@ -18,16 +18,13 @@ "prepare": "node scripts/setup-git-hooks.mjs", "check:merge-driver": "node scripts/git-env.mjs --self-test && node scripts/git-merge-regen.mjs --self-test && node scripts/check-regen-pending.mjs --self-test", "version": "node scripts/release-pending-publish.mjs annotate-shipped; changeset version && node scripts/sync-protocol-version.mjs && node scripts/sync-template-versions.mjs && node scripts/sync-docs-image-tags.mjs && node scripts/sync-release-index-currency.mjs && node scripts/check-skill-compatibility-version.mjs --fix", - "release": "pnpm run build && bash scripts/build-console.sh && bash scripts/release-publish.sh", + "release": "pnpm run build && bash scripts/release-publish.sh", "docs:dev": "pnpm --filter @objectstack/docs dev", "docs:build": "pnpm --filter @objectstack/docs build", "docs:start": "pnpm --filter @objectstack/docs start", "os": "node packages/cli/bin/run.js", - "objectui:build": "bash scripts/build-console.sh", + "console:build": "turbo run build --filter=@objectstack/console", "gen:sdui-lockstep": "node scripts/check-sdui-lockstep.mjs --update", - "objectui:bump": "bash scripts/bump-objectui.sh", - "objectui:refresh": "bash scripts/bump-objectui.sh && bash scripts/build-console.sh", - "objectui:clean": "rm -rf packages/console/dist .cache/objectui-*", "lint": "node --stack-size=4000 node_modules/eslint/bin/eslint.js . --no-inline-config", "i18n:extract": "tsx packages/cli/bin/run-dev.js i18n extract packages/platform-objects/scripts/i18n-extract.config.ts --locales=zh-CN,ja-JP,es-ES --fill=default --source-hashes --out=packages/platform-objects/src/apps/translations", "check:i18n": "node scripts/check-i18n-bundles.mjs --self-test && node scripts/check-i18n-bundles.mjs", @@ -147,12 +144,7 @@ "check:optional-error-sink": "node scripts/check-optional-error-sink-contract.mjs --self-test && node scripts/check-optional-error-sink-contract.mjs", "check:logger-receiver-detach": "node scripts/check-logger-receiver-detach.mjs --self-test && node scripts/check-logger-receiver-detach.mjs", "check:startup-registry-verdict": "node scripts/check-startup-registry-verdict.mjs --self-test && node scripts/check-startup-registry-verdict.mjs", - "check:console-sha": "node scripts/check-console-sha.mjs", - "check:console-injection": "node scripts/check-console-injection.mjs --self-test && node scripts/check-console-injection.mjs", "check:dev-prereqs": "node scripts/check-dev-prereqs.mjs --self-test && node scripts/check-dev-prereqs.mjs", - "check:objectui-changeset": "node scripts/objectui-changeset-digest.mjs --self-test && node scripts/objectui-range.mjs --self-test", - "check:objectui-bump": "bash scripts/bump-objectui.selftest.sh", - "check:prerelease-pins": "node scripts/check-prerelease-pin-watch.mjs --self-test && node scripts/check-prerelease-pin-watch.mjs", "check:empty-changeset": "node scripts/check-empty-changeset.mjs --self-test && node scripts/check-empty-changeset.mjs", "check:adr-0087-registration": "node scripts/check-adr-0087-registration.mjs --self-test && node scripts/check-adr-0087-registration.mjs", "check:changeset-gate-self-tests": "node scripts/check-empty-changeset.mjs --self-test && node scripts/check-adr-0087-registration.mjs --self-test && node scripts/check-changeset-no-major.mjs --self-test", diff --git a/packages/console/build-dist.sh b/packages/console/build-dist.sh new file mode 100644 index 00000000000..5287d2ab57d --- /dev/null +++ b/packages/console/build-dist.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# The build of @objectstack/console: a copy of this workspace's own +# @object-ui/console build. Nothing is cloned and nothing is pinned — the +# Console source lives in ui/apps/console, its @objectstack/* dependencies are +# `workspace:^`, and the root turbo.json makes `@objectstack/console#build` +# depend on `@object-ui/console#build`, so the dist copied here was built +# against this tree's spec and client by construction. Run it through turbo +# (`pnpm console:build`, or the root `pnpm build`), never directly: a direct +# run copies whatever ui/apps/console/dist holds, however old. +# +# What it ships: ui/apps/console/dist as-is, with the TRACKED repo-root +# sdui.manifest.json copied over `dist/sdui.manifest.json` (ADR-0080; the CLI's +# JSX gate reads it there, see packages/cli/src/utils/sdui-manifest.ts). The +# manifest copy is the one file in this package's dist that comes from outside +# the Console build, and `scripts/check-changeset-no-major.mjs` reads it out of +# this script (PACKED_COPY_BUILD_SCRIPTS) to grade what a manifest change ships. +# +# Usage: +# bash packages/console/build-dist.sh +# bash packages/console/build-dist.sh --check-single-zod +# run ONLY the single-zod canary (below) over an +# existing dist's assets/ and exit with its verdict; +# copies nothing, writes nothing + +set -euo pipefail + +# ── Single-zod canary (objectstack#21108) ──────────────────────────── +# The console bundle must carry exactly ONE zod instance. Two copies split +# zod's internals: a spec schema built by one instance and walked by the other +# (`z.toJSONSchema`) throws, so every spec-derived Studio form lost its fields — +# the New Package dialog could not create a package and the dashboard/report +# inspectors read "Spec schema unavailable". +# +# The console's own Vite build pins every `zod` import to one copy and fails on +# more than one zod package directory in the emitted chunks (objectui#11353). +# This is the belt to that brace, measured on what this package SHIPS rather +# than on how the console build is configured, so it still holds for a build +# that drops or weakens that guard — one workspace lockfile can still resolve +# two zod versions. +# +# What it counts: zod 4 stamps every instance with a version object literal +# (`version` in `zod/v4/core/versions`, read back as `_zod.version`), which the +# minifier emits as `{major:4,minor:6,patch:5}`. One instance = one literal, so +# the canary requires EXACTLY ONE occurrence across assets/*.js: +# - two DIFFERENT literals: two zod versions, the shipped defect; +# - one literal found twice: two instances of the same version — still two +# instances, which is what the crash keys on; +# - none at all: the matcher has gone blind (the console always bundles zod), +# and a negative check that cannot see its subject would pass forever. +ZOD_VERSION_LITERAL='\{[[:space:]]*major:[[:space:]]*[0-9]+,[[:space:]]*minor:[[:space:]]*[0-9]+,[[:space:]]*patch:[[:space:]]*[0-9]+[[:space:]]*,?[[:space:]]*\}' + +assert_single_zod() { + local assets="$1" + if [[ ! -d "$assets" ]]; then + echo "✗ Single-zod canary: ${assets} is not a directory." + return 1 + fi + local files=() + local f + for f in "$assets"/*.js; do + [[ -f "$f" ]] && files+=("$f") + done + if (( ${#files[@]} == 0 )); then + echo "✗ Single-zod canary: no .js files under ${assets} — nothing was measured." + return 1 + fi + local hits="" rc=0 + hits="$(grep -oHE "$ZOD_VERSION_LITERAL" "${files[@]}")" || rc=$? + if (( rc > 1 )); then + echo "✗ Single-zod canary: grep failed (exit ${rc}) reading ${assets}/*.js." + return 1 + fi + # One line per occurrence: :, the literal's whitespace and any + # trailing comma dropped so a re-formatted bundle reads the same version. + local occurrences + occurrences="$(printf '%s\n' "$hits" | sed -e '/^$/d' -e 's/[[:space:]]//g' -e 's/,}$/}/')" + local count=0 distinct=0 + if [[ -n "$occurrences" ]]; then + count="$(printf '%s\n' "$occurrences" | wc -l | tr -d ' ')" + distinct="$(printf '%s\n' "$occurrences" | sed 's/^.*:{/{/' | sort -u | wc -l | tr -d ' ')" + fi + if (( count == 0 )); then + echo "✗ Single-zod canary: none of the ${#files[@]} assets/*.js files carries a zod version" + echo " literal ({major:N,minor:N,patch:N}). The console always bundles zod, so zero means" + echo " this matcher can no longer see it — and a check that cannot see its subject cannot" + echo " fail. Fix the matcher (ZOD_VERSION_LITERAL in packages/console/build-dist.sh) before" + echo " trusting this bundle." + return 1 + fi + if (( count > 1 )); then + if (( distinct > 1 )); then + echo "✗ Single-zod canary: the console bundle carries ${distinct} different zod versions" + echo " (${count} version literals) and must carry exactly one:" + else + echo "✗ Single-zod canary: the console bundle carries ${count} copies of ONE zod version" + echo " literal — two instances of the same version are still two instances:" + fi + printf '%s\n' "$occurrences" | sed "s|^${assets%/}/| |" + echo " A schema built by one zod instance and walked by another breaks every spec-derived" + echo " Studio form. The workspace lockfile resolves zod twice for the console's closure:" + echo " dedupe it (one zod line across packages/spec and ui/apps/console), then rebuild." + return 1 + fi + echo "✓ Single-zod canary: exactly one zod version literal $(printf '%s' "$occurrences" | sed 's/^.*:{/{/') in ${occurrences%%:*}." + echo " (${#files[@]} assets/*.js files read)" + return 0 +} + +if [[ "${1:-}" == "--check-single-zod" ]]; then + if [[ -z "${2:-}" ]]; then + echo "usage: packages/console/build-dist.sh --check-single-zod " + exit 2 + fi + if assert_single_zod "$2"; then exit 0; fi + exit 1 +fi + +FRAMEWORK_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CONSOLE_DIST="${FRAMEWORK_ROOT}/ui/apps/console/dist" +TARGET="${FRAMEWORK_ROOT}/packages/console/dist" +MANIFEST_SRC="${FRAMEWORK_ROOT}/sdui.manifest.json" + +if [[ ! -f "${CONSOLE_DIST}/index.html" ]]; then + echo "✗ ${CONSOLE_DIST}/index.html is missing — @object-ui/console has not been built." + echo " Build through turbo, which builds it first: pnpm console:build" + exit 1 +fi +if [[ ! -f "$MANIFEST_SRC" ]]; then + echo "✗ ${MANIFEST_SRC} is missing — the console dist ships the tracked SDUI manifest." + echo " Regenerate it: node scripts/gen-sdui-manifest-node.mjs" + exit 1 +fi + +echo "→ Copying ${CONSOLE_DIST} → ${TARGET}" +rm -rf "$TARGET" +cp -R "$CONSOLE_DIST" "$TARGET" +cp "$MANIFEST_SRC" "${TARGET}/sdui.manifest.json" + +# Placed after the copy so it judges the bytes this package publishes. +if ! assert_single_zod "${TARGET}/assets"; then + rm -rf "$TARGET" + exit 1 +fi + +BYTES="$(du -sk "$TARGET" 2>/dev/null | awk '{print $1}')" +echo "✓ @objectstack/console dist ready (${BYTES} KB) from ui/apps/console, with the tracked sdui.manifest.json" diff --git a/packages/console/package.json b/packages/console/package.json index 5a48c5af370..de4f8fcc33b 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -1,7 +1,7 @@ { "name": "@objectstack/console", "version": "18.1.0", - "description": "Prebuilt Console SPA pinned to this framework release, installed as a dependency of @objectstack/cli. Source of truth: @object-ui/console (https://github.com/objectstack-ai/objectui).", + "description": "Prebuilt Console SPA built from this workspace's ui/apps/console (@object-ui/console) at the same version, installed as a dependency of @objectstack/cli.", "license": "Apache-2.0", "homepage": "https://github.com/objectstack-ai/objectstack/tree/main/packages/console", "repository": { @@ -24,6 +24,7 @@ "./package.json": "./package.json" }, "scripts": { - "prepublishOnly": "node -e \"const fs=require('fs');const p='dist/index.html';if(!fs.existsSync(p)){console.error('\\n✗ @objectstack/console: '+p+' is missing — run scripts/build-console.sh before publishing.\\n');process.exit(1)}\"" + "build": "bash build-dist.sh", + "prepublishOnly": "node -e \"const fs=require('fs');const p='dist/index.html';if(!fs.existsSync(p)){console.error('\\n✗ @objectstack/console: '+p+' is missing — run pnpm console:build at the repo root before publishing.\\n');process.exit(1)}\"" } } diff --git a/packages/spec/scripts/build-console-objectstack-root.test.ts b/packages/spec/scripts/build-console-objectstack-root.test.ts deleted file mode 100644 index 17bcef1aed4..00000000000 --- a/packages/spec/scripts/build-console-objectstack-root.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// Pins the `.objectstack-sha` branch of `scripts/build-console.sh` -// (objectstack#24306, ruling B on objectui#12955): which objectstack the console -// build's objectui install compiles against. -// -// ## The contract -// -// A pinned objectui that carries `.objectstack-sha` overrides every -// `@objectstack/*` package to `file:node_modules/.cache/objectstack/` and -// fills those directories with `scripts/objectstack-pin.mjs build`. With -// `OBJECTSTACK_ROOT` set, that script packs an installed and BUILT objectstack -// tree instead of building its own pin. build-console.sh must: -// -// 1. pass THIS framework tree as `OBJECTSTACK_ROOT`, after building the -// overridden packages here, and before objectui's frozen install; -// 2. leave a pin WITHOUT `.objectstack-sha` exactly as before (no closure -// build, no pin script), detected by the file and never by the sha; -// 3. refuse, naming the hook, a pin WITH `.objectstack-sha` whose pin script -// has no `OBJECTSTACK_ROOT` input, before any install; -// 4. build that closure WITH declarations whatever `OS_SKIP_DTS` the caller -// exported: the pin script packs whole packages and refuses one whose -// `types` entry is missing, and a typeless tree did exactly that (consumer -// pointer on objectstack#24306, measured again on this branch); -// 5. stop on the pin script's refusal, before the install; -// 6. first build objectui's OWN pin (the pin script with no OBJECTSTACK_ROOT -// and no OS_SKIP_DTS) and keep that cache, its stamp included, outside the -// one the stand-in replaces: once this tree stands in, objectui's install -// is this tree, so only that build can be the injection assertion's -// reference. A rerun reuses it while its stamp names the pin. -// -// ## How it runs: the real script, a fake world -// -// The script under test is the tracked file, copied byte for byte into a temp -// framework root (it derives its root from its own path) beside a temp objectui -// git repo. Everything it would spend minutes on is a shim: `pnpm` on PATH -// records its argv and the `OS_SKIP_DTS` it received, and exits 42 at -// `install`, so "the install was reached" is the exit code itself; the pin -// script is a few lines of JS that records `OBJECTSTACK_ROOT` and its cwd. All -// records go to ONE log, so the ORDER of closure build, pin script and install -// is asserted, not inferred. The guards above this branch (the two vite hooks, -// the client and spec dists) are satisfied by the fixtures, so the branch is -// reached with nothing else to build. -// -// Vacuity guards: the case that exports OS_SKIP_DTS=1 also asserts the INSTALL -// call still received it, so "the closure build ran without it" cannot pass -// because the variable never reached the script at all. - -import { describe, it, expect, afterAll } from 'vitest'; -import { execFileSync, spawnSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const HERE = path.dirname(fileURLToPath(import.meta.url)); -const SCRIPT = path.resolve(HERE, '..', '..', '..', 'scripts', 'build-console.sh'); - -const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'build-console-osroot-')); -afterAll(() => fs.rmSync(ROOT, { recursive: true, force: true })); - -const PNPM_SHIM = `#!/usr/bin/env bash -printf 'pnpm %s | OS_SKIP_DTS=%s\\n' "$*" "\${OS_SKIP_DTS-unset}" >> "$SHIM_LOG" -if [[ "\${1:-}" == "install" ]]; then exit 42; fi -exit 0 -`; - -/** A pin script that HAS the input: it records OBJECTSTACK_ROOT and leaves the - * cache the real one does, its stamp at the root, unless told to refuse. */ -const PIN_WITH_HOOK = `import fs from 'node:fs'; -const root = process.env.OBJECTSTACK_ROOT; -fs.appendFileSync(process.env.SHIM_LOG, 'pin ' + process.argv.slice(2).join(' ') + ' | OBJECTSTACK_ROOT=' + (root ?? 'unset') + ' | OS_SKIP_DTS=' + (process.env.OS_SKIP_DTS ?? 'unset') + ' | cwd=' + process.cwd() + '\\n'); -if (process.env.FAKE_PIN_EXIT) process.exit(Number(process.env.FAKE_PIN_EXIT)); -const cache = 'node_modules/.cache/objectstack'; -fs.rmSync(cache, { recursive: true, force: true }); -fs.mkdirSync(cache + '/spec', { recursive: true }); -const sha = fs.readFileSync('.objectstack-sha', 'utf8').trim(); -fs.writeFileSync(cache + '/.stamp.json', JSON.stringify(root ? { source: 'external', root } : { source: 'pin', sha })); -`; - -/** A pin script from before the input existed: it builds its own pin, always. */ -const PIN_WITHOUT_HOOK = `import fs from 'node:fs'; -fs.appendFileSync(process.env.SHIM_LOG, 'pin-without-hook\\n'); -`; - -function git(cwd: string, ...args: string[]): string { - return execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'commit.gpgsign=false', ...args], { - cwd, - encoding: 'utf8', - }).trim(); -} - -function write(file: string, text: string): void { - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, text); -} - -// The objectui side: one repo, one commit per pin shape. -const UI = path.join(ROOT, 'ui'); -fs.mkdirSync(UI); -git(UI, 'init', '-q'); -write(path.join(UI, 'apps/console/vite.config.ts'), '// OBJECTSTACK_CLIENT_DIST OBJECTSTACK_SPEC_DIST\n'); -write( - path.join(UI, 'package.json'), - JSON.stringify({ - name: 'fake-objectui', - pnpm: { - overrides: { - react: '19.2.8', - '@objectstack/spec': 'file:node_modules/.cache/objectstack/spec', - '@objectstack/client': 'file:node_modules/.cache/objectstack/client', - }, - }, - }), -); -git(UI, 'add', '-A'); -git(UI, 'commit', '-q', '-m', 'a pin from before .objectstack-sha'); -const OLD_PIN = git(UI, 'rev-parse', 'HEAD'); - -write(path.join(UI, '.objectstack-sha'), `${'a'.repeat(40)}\n`); -write(path.join(UI, 'scripts/objectstack-pin.mjs'), PIN_WITHOUT_HOOK); -git(UI, 'add', '-A'); -git(UI, 'commit', '-q', '-m', '.objectstack-sha, pin script without the input'); -const NO_HOOK_PIN = git(UI, 'rev-parse', 'HEAD'); - -write(path.join(UI, 'scripts/objectstack-pin.mjs'), PIN_WITH_HOOK); -git(UI, 'add', '-A'); -git(UI, 'commit', '-q', '-m', '.objectstack-sha, pin script with OBJECTSTACK_ROOT'); -const HOOK_PIN = git(UI, 'rev-parse', 'HEAD'); - -// The framework side: the real script, and dists that satisfy the guards above -// the branch so they build nothing. -const FW = path.join(ROOT, 'fw'); -fs.mkdirSync(path.join(FW, 'scripts'), { recursive: true }); -fs.copyFileSync(SCRIPT, path.join(FW, 'scripts', 'build-console.sh')); -write(path.join(FW, 'packages/client/dist/index.d.ts'), 'export {};\n'); -write(path.join(FW, 'packages/spec/dist/index.mjs'), 'export {};\n'); -write(path.join(FW, 'packages/spec/json-schema/openapi.json'), '{}\n'); - -const BIN = path.join(ROOT, 'bin'); -write(path.join(BIN, 'pnpm'), PNPM_SHIM); -fs.chmodSync(path.join(BIN, 'pnpm'), 0o755); - -interface Run { - status: number | null; - output: string; - log: string[]; - buildRoot: string; -} - -let runs = 0; -function buildConsole(pin: string, env: Record = {}): Run { - runs += 1; - const log = path.join(ROOT, `run-${runs}.log`); - fs.writeFileSync(log, ''); - fs.writeFileSync(path.join(FW, '.objectui-sha'), `${pin}\n`); - const base = { ...process.env }; - delete base.OS_SKIP_DTS; - delete base.OBJECTSTACK_ROOT; - const result = spawnSync('bash', [path.join(FW, 'scripts', 'build-console.sh')], { - encoding: 'utf8', - env: { ...base, PATH: `${BIN}${path.delimiter}${process.env.PATH ?? ''}`, OBJECTUI_ROOT: UI, SHIM_LOG: log, ...env }, - }); - return { - status: result.status, - output: `${result.stdout}${result.stderr}`, - log: fs.readFileSync(log, 'utf8').split('\n').filter(Boolean), - buildRoot: fs.realpathSync(path.join(FW, '.cache', `objectui-${pin.slice(0, 12)}`)), - }; -} - -const closureBuilds = (log: string[]) => log.filter((l) => l.startsWith('pnpm exec turbo run build')); -const pinBuilds = (log: string[]) => log.filter((l) => l.startsWith('pin build')); -const reference = (pin: string) => path.join(FW, '.cache', `objectui-${pin.slice(0, 12)}`, 'node_modules/.cache/objectstack-pin-reference'); -const installs = (log: string[]) => log.filter((l) => l.startsWith('pnpm install')); - -describe('build-console.sh: the .objectstack-sha branch', () => { - it('a pin without .objectstack-sha builds as before: no closure build, no pin script, straight to the install', () => { - const run = buildConsole(OLD_PIN, { OS_SKIP_DTS: '1' }); - expect(run.status, run.output).toBe(42); - expect(run.log.filter((l) => l.startsWith('pin'))).toEqual([]); - expect(closureBuilds(run.log)).toEqual([]); - expect(installs(run.log)).toHaveLength(1); - }); - - it('a pin with .objectstack-sha but no OBJECTSTACK_ROOT hook refuses, naming the hook, before any install', () => { - const run = buildConsole(NO_HOOK_PIN); - expect(run.status, run.output).toBe(1); - expect(run.output).toContain('OBJECTSTACK_ROOT hook'); - expect(run.log).toEqual([]); - }); - - it('a pin with the hook builds the overridden packages, passes THIS tree as OBJECTSTACK_ROOT, then installs', () => { - const run = buildConsole(HOOK_PIN); - expect(run.status, run.output).toBe(42); - expect(run.log).toEqual([ - 'pnpm exec turbo run build --filter=@objectstack/client --filter=@objectstack/spec --output-logs=new-only | OS_SKIP_DTS=unset', - `pin build | OBJECTSTACK_ROOT=unset | OS_SKIP_DTS=unset | cwd=${run.buildRoot}`, - `pin build | OBJECTSTACK_ROOT=${FW} | OS_SKIP_DTS=unset | cwd=${run.buildRoot}`, - 'pnpm install --frozen-lockfile --prefer-offline --config.production=false | OS_SKIP_DTS=unset', - ]); - // The pin's own cache survived the stand-in that replaced it: the reference. - expect(JSON.parse(fs.readFileSync(path.join(reference(HOOK_PIN), '.stamp.json'), 'utf8'))).toEqual({ source: 'pin', sha: 'a'.repeat(40) }); - expect(fs.existsSync(path.join(reference(HOOK_PIN), 'spec'))).toBe(true); - }); - - it("a rerun reuses the reference while its stamp names the pin, and rebuilds it when it does not", () => { - expect(pinBuilds(buildConsole(HOOK_PIN).log)).toEqual([expect.stringContaining(`OBJECTSTACK_ROOT=${FW}`)]); - fs.writeFileSync(path.join(reference(HOOK_PIN), '.stamp.json'), JSON.stringify({ source: 'pin', sha: 'b'.repeat(40) })); - expect(pinBuilds(buildConsole(HOOK_PIN).log)).toHaveLength(2); - }); - - it('an inherited OS_SKIP_DTS does not reach the closure build, and the script says so', () => { - fs.rmSync(reference(HOOK_PIN), { recursive: true, force: true }); - const run = buildConsole(HOOK_PIN, { OS_SKIP_DTS: '1' }); - expect(pinBuilds(run.log)[0]).toContain('OBJECTSTACK_ROOT=unset | OS_SKIP_DTS=unset'); - expect(run.status, run.output).toBe(42); - expect(closureBuilds(run.log)).toEqual([ - 'pnpm exec turbo run build --filter=@objectstack/client --filter=@objectstack/spec --output-logs=new-only | OS_SKIP_DTS=unset', - ]); - // Vacuity guard: the variable did reach the script — the install still sees it. - expect(installs(run.log)).toEqual([ - 'pnpm install --frozen-lockfile --prefer-offline --config.production=false | OS_SKIP_DTS=1', - ]); - expect(run.output).toContain('OS_SKIP_DTS is set'); - }); - - it("the pin script's refusal stops the build before the install", () => { - const run = buildConsole(HOOK_PIN, { FAKE_PIN_EXIT: '1' }); - expect(run.status, run.output).toBe(1); - expect(run.log.filter((l) => l.startsWith('pin build'))).toHaveLength(1); - expect(installs(run.log)).toEqual([]); - }); -}); diff --git a/scripts/assert-console-spec-injection.mjs b/scripts/assert-console-spec-injection.mjs deleted file mode 100644 index 26acfe988c3..00000000000 --- a/scripts/assert-console-spec-injection.mjs +++ /dev/null @@ -1,310 +0,0 @@ -#!/usr/bin/env node -// Assert that OBJECTSTACK_SPEC_DIST actually landed in the built console bundle. -// -// ## Why this is not a frozen literal like the client's BUNDLE_CANARY -// -// build-console.sh asserts the injected *client* with a fixed string -// ('import/jobs'). That works because the question is static: "is the client in -// here new enough to have the import-job API?". The spec question is not static. -// What must be true is "the bundle carries the surface the framework declares -// NOW", and any literal frozen today is carried by the published spec too within -// one release — after which the canary passes forever while proving nothing. A -// self-staling assertion is the exact silent-pass failure objectstack#8134 exists -// to end, so it must not be the fix for it. -// -// So both probes are DERIVED, on every run, from the two specs actually on disk: -// -// injected = this framework tree's packages/spec (what must be bundled) -// vendored = the @objectstack/spec objectui's own lockfile installed -// (what gets bundled when the injection is missing or broken); -// on a pin carrying `.objectstack-sha`, objectui's own build of -// that commit — see readPinnedReference for why only that one -// -// ## The test is two-sided, because one side is not enough -// -// Measured while building this check: asserting only "a string unique to the -// injected spec appears in the bundle" PASSES even with no injection at all. The -// console bundle already contains a second, transitive copy of this tree's spec, -// dragged in by the injected @objectstack/client — it lands in a different chunk -// from the console's own `@objectstack/spec` imports. A one-sided probe reads -// that copy and reports success while the designer still runs on the published -// schemas. So: -// -// FRESH WITNESS — text only the injected spec has; must be PRESENT. -// STALE DETECTOR — text only the vendored spec has; must be ABSENT. -// -// The stale detector is the one that actually catches this card's defect: it is -// positive evidence that the published spec is still in the bundle. The fresh -// witness alone cannot distinguish "injection worked" from "some other copy". -// -// ## objectui's own text is not evidence of either spec (objectstack#21709) -// -// The bundle also carries objectui's OWN code, and objectui's component -// registry writes `inputs` descriptions that mirror spec `.describe()` text. -// When the spec rewords one, the old text is published-only and objectui's -// literal still carries it, so a pure substring search reads objectui's own -// registry as "the published spec": object-gantt `markers` turned every -// merge-queue build red that way. So the third input, --objectui, is the tree -// the console was built from, and a probe its tracked source carries is never -// counted as present — nor stamped. Absent text stays evidence; see -// chooseProbes and readHostSourceBlob for the rule and why it cannot hide a -// real leak. -// -// ## Probe derivation lives in ./console-spec-probes.mjs -// -// Not here, because objectstack#9667 added a SECOND consumer: this script can -// only run where BOTH specs exist, which is inside a build. On a console-dist -// cache HIT there is no objectui build tree and therefore no vendored spec, so -// nothing could re-ask the question about the restored artifact. Two scripts, -// one derivation — see that module's header. -// -// ## This script also writes the provenance stamp -// -// On success it records the probes it chose into `/.objectstack-injection.json`, -// so `pnpm check:console-injection` can replay them against a restored dist that -// this script can no longer be run against. Written LAST, only once every -// assertion below is green — unlike the `.objectui-sha` stamp, which -// build-console.sh writes before its canary assert (the asymmetry ci.yml's -// split restore/save comment calls out). -// -// Usage: -// node scripts/assert-console-spec-injection.mjs \ -// --injected \ -// --vendored \ -// --objectui \ -// --assets -// -// Exit: 0 = injection proven (or no skew to prove) · 1 = injection failed -// 2 = inconclusive / cannot run (including a reference that is this tree) - -import fs from 'node:fs'; -import path from 'node:path'; - -import { - ProbeError, - chooseProbes, - readBundle, - readHostSourceBlob, - readSpecBlob, - writeStamp, -} from './console-spec-probes.mjs'; - -function fail(message) { - console.error(`✗ assert-console-spec-injection: ${message}`); - process.exit(2); -} - -function parseArgs(argv) { - const out = {}; - for (let i = 2; i < argv.length; i += 2) { - const key = argv[i]; - if (!key.startsWith('--')) fail(`unexpected argument \`${key}\``); - if (argv[i + 1] === undefined) fail(`\`${key}\` has no value`); - out[key.slice(2)] = argv[i + 1]; - } - for (const required of ['injected', 'vendored', 'objectui', 'assets']) { - if (!out[required]) fail(`--${required} is required`); - } - return out; -} - -const args = parseArgs(process.argv); - -/** - * The objectstack commit --vendored was built at, when objectui pins one; null otherwise. - * - * A pin carrying `.objectstack-sha` installs @objectstack/* by commit, and - * build-console.sh stands THIS tree in for that commit, so the spec objectui's - * install holds is this tree's own, packed. As --vendored it equals --injected: - * both pools are empty, the no-skew branch below passes, and the stamp records no - * witness for ANY bundle. What a broken pack-or-inject ships there is objectui's - * own build of the pinned commit, so that build is the only reference taken on - * such a pin. It is known by the stamp objectui's scripts/objectstack-pin.mjs - * writes at the root of the cache it packed into (`/.stamp.json` beside - * `/spec`): `source: 'pin'`, `sha` equal to the pin. A stand-in, a copy - * with no such stamp (the install's node_modules/@objectstack/spec) or a build of - * another commit is refused as cannot-judge, never read as "no skew". - */ -function readPinnedReference(objectuiDir, vendoredDir) { - const pinFile = path.join(objectuiDir, '.objectstack-sha'); - if (!fs.existsSync(pinFile)) return null; - const pin = fs.readFileSync(pinFile, 'utf8').trim(); - const stampFile = path.join(path.dirname(vendoredDir), '.stamp.json'); - let stamp = null; - try { - stamp = JSON.parse(fs.readFileSync(stampFile, 'utf8')); - } catch { - // No readable stamp is refused below, with the rest. - } - if (stamp?.source !== 'pin' || stamp?.sha !== pin) { - const found = stamp ? `source ${JSON.stringify(stamp.source)} at ${String(stamp.sha).slice(0, 12)}` : 'no readable stamp'; - fail( - `objectui pins objectstack ${pin.slice(0, 12)} (.objectstack-sha), so --vendored must be objectui's own\n` + - ` build of that commit, and \`${stampFile}\` says ${found}. On this pin objectui's install is\n` + - " this tree (the OBJECTSTACK_ROOT stand-in), and judged against itself this check would pass any\n" + - " bundle. build-console.sh keeps that build before standing this tree in; pass its spec.", - ); - } - return pin; -} - -const vendoredPin = readPinnedReference(path.resolve(args.objectui), path.resolve(args.vendored)); -const vendoredName = vendoredPin - ? `@objectstack/spec of objectui's own build of objectstack ${vendoredPin.slice(0, 12)}` - : 'PUBLISHED @objectstack/spec'; -const vendoredOnly = vendoredPin ? 'pin-only' : 'published-only'; - -const assetsDir = path.resolve(args.assets); -// The dist root is the assets dir's parent: build-console.sh passes -// `/assets`, so this is where the .objectui-sha stamp already lives. -const distDir = path.dirname(assetsDir); - -let bundle; -let injectedBlob; -let vendoredBlob; -let hostBlob; -try { - bundle = readBundle(assetsDir); - injectedBlob = readSpecBlob(path.resolve(args.injected), 'injected'); - vendoredBlob = readSpecBlob(path.resolve(args.vendored), 'vendored'); - hostBlob = readHostSourceBlob(path.resolve(args.objectui), 'objectui'); -} catch (error) { - if (!(error instanceof ProbeError)) throw error; - fail(error.message); -} - -// Chosen with the bundle in view (objectstack#20646): the witness is injected-only -// text this bundle carries, and the stale leg is judged over EVERY published-only -// description, not the one that sorts first — see chooseProbes for why the old -// alphabetical pick read text from entries the console never imports. And with -// objectui's own source in view (objectstack#21709): text that source carries is -// never counted as present, on either leg, and never stamped. -const { - freshWitness, - freshPresent, - freshCounts, - staleDetector, - stalePresent, - staleCounts, - freshHostCarried, - staleHostCarried, -} = chooseProbes({ injectedBlob, vendoredBlob, bundle, hostBlob }); - -/** One leg's descriptions that this bundle carries only as objectui's own text. */ -function hostCarriedNote(indent, kind, counts, carried) { - if (counts.hostCarried === 0) return []; - return [ - `${indent}${counts.hostCarried} ${kind} description(s) ARE in the bundle, and objectui's own`, - `${indent}source at the pin carries each of them too, so they are not evidence of either spec:`, - `${indent} "${carried[0]}"`, - ]; -} -const freshNote = (indent) => hostCarriedNote(indent, 'injected-only', freshCounts, freshHostCarried); -const staleNote = (indent) => hostCarriedNote(indent, 'published-only', staleCounts, staleHostCarried); - -/** Record what this build proved, for check:console-injection to replay. */ -function stamp(skew) { - try { - writeStamp(distDir, [ - { - name: '@objectstack/spec', - injectedFrom: path.resolve(args.injected), - vendoredFrom: path.resolve(args.vendored), - vendoredPin, - skew, - freshWitness, - staleDetector, - }, - ]); - } catch (error) { - // A dist we cannot stamp is still a dist this script just proved good. - // Failing here would turn a green build red over provenance bookkeeping; - // check:console-injection reports the missing stamp on its own terms. - console.warn(`⚠ could not write the injection stamp: ${error.message}`); - } -} - -// With a pinned reference this is the honest case, never the vacuous one: an -// independently built spec of the pinned commit declares the same descriptions as -// this tree's, so a bundle carrying either carries the same text. -if (freshCounts.pool === 0 && staleCounts.pool === 0) { - console.log(`✓ Injected and vendored @objectstack/spec declare the same descriptions${vendoredPin ? ` (vendored: ${vendoredName})` : ''}`); - console.log(' — no observable skew, so nothing for this check to assert.'); - stamp(false); - process.exit(0); -} - -// The stale leg with no probe left: every published-only description is in the -// bundle AND carried by objectui's own source, so not one of them can say which -// side put it there. That is a leg that cannot judge this bundle — not "no skew" -// (the pool is not empty) and not "absent" (the text is right there) — so it is -// inconclusive, never a pass: a bundle carrying the published spec would look -// exactly like this. -if (staleCounts.pool > 0 && staleDetector === null) { - console.error("✗ The staleness leg cannot judge this bundle: every published-only description"); - console.error(" it could look for is in the bundle, and objectui's own source at the pin carries"); - console.error(' each one too, so none can tell the published spec from objectui\'s own text.'); - console.error(' The injection is UNVERIFIED by this check.'); - console.error(` ${vendoredOnly} descriptions: ${staleCounts.pool}, all of them carried by objectui's source`); - console.error(` first of them: "${staleHostCarried[0]}"`); - process.exit(2); -} - -// Neither probe anywhere in the bundle means the spec is not in this build at -// all — the check cannot speak to an injection it cannot see. -if (freshPresent !== true && stalePresent !== true) { - console.error('✗ Neither spec appears in the built console — no @objectstack/spec'); - console.error(' content matched. The injection is UNVERIFIED by this check.'); - console.error(` injected-only descriptions in the bundle: ${freshCounts.inBundle} of ${freshCounts.pool}`); - console.error(` ${vendoredOnly} descriptions in the bundle: ${staleCounts.inBundle} of ${staleCounts.pool}`); - for (const line of [...freshNote(' '), ...staleNote(' ')]) console.error(line); - process.exit(2); -} - -if (stalePresent === true) { - console.error(`✗ Built console still carries the ${vendoredName}.`); - console.error(` The console resolved spec from objectui's ${vendoredPin ? 'own pin' : 'lockfile'}, so any authorable`); - console.error(` key this framework declared after ${vendoredPin ? 'that commit' : 'the last spec publish'} is unreachable`); - console.error(' in the Studio designer — the defect objectstack#8134 exists to end.'); - console.error(''); - console.error(` Text found in the bundle that ONLY the vendored spec has (${staleCounts.inBundle} of`); - console.error(` ${staleCounts.pool} ${vendoredOnly} descriptions), the first of them:`); - console.error(` "${staleDetector}"`); - if (staleCounts.hostCarried > 0) { - console.error(''); - console.error(` Not counted: ${staleCounts.hostCarried} more published-only description(s) the bundle`); - console.error(" carries that objectui's own source at the pin carries too."); - } - if (freshPresent === true) { - console.error(''); - console.error(' Note: text unique to this tree\'s spec is ALSO in the bundle —'); - console.error(' a second, transitive copy (via the injected @objectstack/client).'); - console.error(' That copy is not what the designer imports; both must not coexist.'); - } - process.exit(1); -} - -if (freshPresent !== true) { - console.error('✗ The published spec is gone from the bundle, but nothing unique to'); - console.error(" this tree's spec was found either — the build is in an unexpected"); - console.error(' state and the injection is UNVERIFIED.'); - console.error(` expected: "${freshWitness}"`); - for (const line of freshNote(' ')) console.error(line); - process.exit(2); -} - -console.log(`✓ Console bundle carries THIS tree's @objectstack/spec, and only it${vendoredPin ? ` (vendored: ${vendoredName})` : ''}.`); -console.log(` present (injected only): "${freshWitness}"`); -console.log(` — ${freshCounts.inBundle} of ${freshCounts.pool} injected-only descriptions are in the bundle`); -for (const line of freshNote(' ')) console.log(line); -if (staleDetector) { - console.log(` absent (vendored only): "${staleDetector}"`); - if (staleCounts.hostCarried === 0) { - console.log(` — and all ${staleCounts.pool} ${vendoredOnly} descriptions are absent`); - } else { - console.log(` — and ${staleCounts.pool - staleCounts.hostCarried} of ${staleCounts.pool} ${vendoredOnly} descriptions are absent;`); - for (const line of staleNote(' ')) console.log(line); - } -} -stamp(true); -process.exit(0); diff --git a/scripts/build-console.sh b/scripts/build-console.sh deleted file mode 100755 index 158773d617c..00000000000 --- a/scripts/build-console.sh +++ /dev/null @@ -1,561 +0,0 @@ -#!/usr/bin/env bash -# Build @object-ui/console at the SHA pinned in .objectui-sha and copy -# its dist/ into packages/console/ so @objectstack/console can publish -# a version-matched, prebuilt Console SPA alongside the framework. The -# tracked repo-root sdui.manifest.json ships in that dist too (see the -# block near the end). -# -# Resolution order for the objectui source tree: -# 1. $OBJECTUI_ROOT (if set and a git repo) — explicit override -# 2. ../objectui sibling checkout — local dev layout -# 3. Shallow clone into .cache/objectui at the SHA — CI / fresh machines -# -# In modes 1 and 2 the script does NOT mutate the developer's checkout — -# it creates a git worktree at the pinned SHA so the dev tree is left alone. -# Mode 3 fetches just the pinned commit. -# -# Always rebuilds the dist (no `if exists, skip` shortcut) so a stale -# tree can't mask a bad SHA in CI. -# -# Usage: -# scripts/build-console.sh -# scripts/build-console.sh --check-single-zod -# run ONLY the single-zod canary (below) over an -# existing dist's assets/ and exit with its verdict; -# builds nothing, writes nothing -# -# Env: -# OBJECTUI_ROOT override path to objectui checkout -# OBJECTUI_REPO_URL override clone URL (default: https://github.com/objectstack-ai/objectui.git) -# OBJECTUI_DEPS_BUILD_CMD override deps build (default: pnpm exec turbo run build --filter=@object-ui/console^...) -# OBJECTUI_BUILD_CMD override console build (default: pnpm --filter @object-ui/console run build) -# CONSOLE_BUNDLE_CANARY literal asserted in the built assets (default: import/jobs) - -set -euo pipefail - -# ── Single-zod canary (objectstack#21108) ──────────────────────────── -# The console bundle must carry exactly ONE zod instance. Two copies split -# zod's internals: a spec schema built by one instance and walked by the other -# (`z.toJSONSchema`) throws, so every spec-derived Studio form lost its fields — -# the New Package dialog could not create a package and the dashboard/report -# inspectors read "Spec schema unavailable". The pin `db11afd4967c` shipped that -# split: objectui's zod 4.4.3 in `vendor-zod` beside the injected spec's 4.6.1 -# in `vendor-objectstack`, each carrying its own version literal. -# -# objectui#11353 (objectui#11327) fixed it at the source: inside the console's -# Vite build it pins every `zod` import to the console's copy, asserts the -# console's zod satisfies the injected spec's declared range, and fails the -# build on more than one zod package directory in the emitted chunks. This is -# the belt to that brace, measured on what this script SHIPS rather than on how -# the pinned objectui build is configured, so it still holds for a future pin -# that drops or weakens that guard. -# -# What it counts: zod 4 stamps every instance with a version object literal -# (`version` in `zod/v4/core/versions`, read back as `_zod.version`), which the -# minifier emits as `{major:4,minor:6,patch:5}`. One instance = one literal, so -# the canary requires EXACTLY ONE occurrence across assets/*.js: -# - two DIFFERENT literals: two zod versions, the shipped defect; -# - one literal found twice: two instances of the same version — still two -# instances, which is what the crash keys on; -# - none at all: the matcher has gone blind (the console always bundles zod), -# and a negative check that cannot see its subject would pass forever. -ZOD_VERSION_LITERAL='\{[[:space:]]*major:[[:space:]]*[0-9]+,[[:space:]]*minor:[[:space:]]*[0-9]+,[[:space:]]*patch:[[:space:]]*[0-9]+[[:space:]]*,?[[:space:]]*\}' - -assert_single_zod() { - local assets="$1" - if [[ ! -d "$assets" ]]; then - echo "✗ Single-zod canary: ${assets} is not a directory." - return 1 - fi - local files=() - local f - for f in "$assets"/*.js; do - [[ -f "$f" ]] && files+=("$f") - done - if (( ${#files[@]} == 0 )); then - echo "✗ Single-zod canary: no .js files under ${assets} — nothing was measured." - return 1 - fi - local hits="" rc=0 - hits="$(grep -oHE "$ZOD_VERSION_LITERAL" "${files[@]}")" || rc=$? - if (( rc > 1 )); then - echo "✗ Single-zod canary: grep failed (exit ${rc}) reading ${assets}/*.js." - return 1 - fi - # One line per occurrence: :, the literal's whitespace and any - # trailing comma dropped so a re-formatted bundle reads the same version. - local occurrences - occurrences="$(printf '%s\n' "$hits" | sed -e '/^$/d' -e 's/[[:space:]]//g' -e 's/,}$/}/')" - local count=0 distinct=0 - if [[ -n "$occurrences" ]]; then - count="$(printf '%s\n' "$occurrences" | wc -l | tr -d ' ')" - distinct="$(printf '%s\n' "$occurrences" | sed 's/^.*:{/{/' | sort -u | wc -l | tr -d ' ')" - fi - if (( count == 0 )); then - echo "✗ Single-zod canary: none of the ${#files[@]} assets/*.js files carries a zod version" - echo " literal ({major:N,minor:N,patch:N}). The console always bundles zod, so zero means" - echo " this matcher can no longer see it — and a check that cannot see its subject cannot" - echo " fail. Fix the matcher (ZOD_VERSION_LITERAL in scripts/build-console.sh) before" - echo " trusting this bundle." - return 1 - fi - if (( count > 1 )); then - if (( distinct > 1 )); then - echo "✗ Single-zod canary: the console bundle carries ${distinct} different zod versions" - echo " (${count} version literals) and must carry exactly one:" - else - echo "✗ Single-zod canary: the console bundle carries ${count} copies of ONE zod version" - echo " literal — two instances of the same version are still two instances:" - fi - printf '%s\n' "$occurrences" | sed "s|^${assets%/}/| |" - echo " A schema built by one zod instance and walked by another breaks every spec-derived" - echo " Studio form. If OBJECTSTACK_SPEC_DIST was injected, the pinned objectui build did not" - echo " pin the spec's zod imports to the console's copy; otherwise objectui's own lockfile" - echo " resolves zod twice. Move .objectui-sha to an objectui commit whose console build" - echo " holds zod to one copy." - return 1 - fi - echo "✓ Single-zod canary: exactly one zod version literal $(printf '%s' "$occurrences" | sed 's/^.*:{/{/') in ${occurrences%%:*}." - echo " (${#files[@]} assets/*.js files read)" - return 0 -} - -if [[ "${1:-}" == "--check-single-zod" ]]; then - if [[ -z "${2:-}" ]]; then - echo "usage: scripts/build-console.sh --check-single-zod " - exit 2 - fi - if assert_single_zod "$2"; then exit 0; fi - exit 1 -fi - -FRAMEWORK_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SHA_FILE="${FRAMEWORK_ROOT}/.objectui-sha" - -if [[ ! -f "$SHA_FILE" ]]; then - echo "✗ ${SHA_FILE} is missing — cannot determine which objectui commit to build." - exit 1 -fi - -PINNED_SHA="$(tr -d '[:space:]' < "$SHA_FILE")" -if [[ -z "$PINNED_SHA" ]]; then - echo "✗ ${SHA_FILE} is empty." - exit 1 -fi - -REPO_URL="${OBJECTUI_REPO_URL:-https://github.com/objectstack-ai/objectui.git}" -# The console app itself must NOT build through turbo: turbo v2 runs tasks in -# strict env mode and strips undeclared vars, so OBJECTSTACK_CLIENT_DIST and -# OBJECTSTACK_SPEC_DIST (both exported below) never reach vite unless the pinned -# objectui SHA happens to declare them in turbo.json. Build the workspace deps -# through turbo (cacheable, env-independent), then invoke the console's own build -# script directly so the env survives. -DEPS_BUILD_CMD="${OBJECTUI_DEPS_BUILD_CMD:-pnpm exec turbo run build --filter=@object-ui/console^...}" -BUILD_CMD="${OBJECTUI_BUILD_CMD:-pnpm --filter @object-ui/console run build}" -# Post-build canary: a literal that only exists in an up-to-date bundled -# client. Guards against any future mechanism (turbo env stripping, a removed -# vite hook, chunking changes) silently shipping a stale client again. -BUNDLE_CANARY="${CONSOLE_BUNDLE_CANARY:-import/jobs}" - -# Resolve a source checkout of objectui. -SOURCE_ROOT="" -if [[ -n "${OBJECTUI_ROOT:-}" && -d "${OBJECTUI_ROOT}/.git" ]]; then - SOURCE_ROOT="$OBJECTUI_ROOT" -elif [[ -d "${FRAMEWORK_ROOT}/../objectui/.git" ]]; then - SOURCE_ROOT="$(cd "${FRAMEWORK_ROOT}/../objectui" && pwd)" -fi - -# Worktree path we'll build from. Always under framework so cleanup is local. -BUILD_ROOT="${FRAMEWORK_ROOT}/.cache/objectui-${PINNED_SHA:0:12}" -mkdir -p "${FRAMEWORK_ROOT}/.cache" - -if [[ -n "$SOURCE_ROOT" ]]; then - echo "→ Using objectui source at ${SOURCE_ROOT}" - # Fetch the pinned commit if missing locally, so dev laptops with a - # stale checkout still work. - if ! git -C "$SOURCE_ROOT" cat-file -e "${PINNED_SHA}^{commit}" 2>/dev/null; then - echo "→ Pinned commit ${PINNED_SHA:0:12} not present locally — fetching from origin..." - git -C "$SOURCE_ROOT" fetch --no-tags origin "$PINNED_SHA" || \ - git -C "$SOURCE_ROOT" fetch --no-tags origin - fi - - # Reuse worktree if it already points at the right commit; otherwise - # remove and recreate so we always build the pinned tree. - if [[ -d "$BUILD_ROOT" ]]; then - CURRENT="$(git -C "$BUILD_ROOT" rev-parse HEAD 2>/dev/null || echo '')" - if [[ "$CURRENT" != "$PINNED_SHA" ]]; then - git -C "$SOURCE_ROOT" worktree remove --force "$BUILD_ROOT" 2>/dev/null || rm -rf "$BUILD_ROOT" - fi - fi - if [[ ! -d "$BUILD_ROOT" ]]; then - # Prune stale worktree registrations before adding. Registrations live in - # the SHARED objectui/.git — nothing on the framework side touches them, - # so if a previous run's directory was removed without going through - # `worktree remove` (the force-remove fallback above hitting `rm -rf`, or - # an external `rm -rf .cache/objectui-*` cleanup), the registration - # survives as `prunable` and the next `worktree add` at this same path - # dies with exit 128 "already registered worktree". `prune` only drops - # registrations whose directory is already gone, so it's safe here - # unconditionally. - git -C "$SOURCE_ROOT" worktree prune - git -C "$SOURCE_ROOT" worktree add --detach "$BUILD_ROOT" "$PINNED_SHA" - fi -else - echo "→ No local objectui checkout — shallow-cloning ${REPO_URL} at ${PINNED_SHA:0:12}" - if [[ -d "$BUILD_ROOT/.git" ]]; then - CURRENT="$(git -C "$BUILD_ROOT" rev-parse HEAD 2>/dev/null || echo '')" - if [[ "$CURRENT" != "$PINNED_SHA" ]]; then - rm -rf "$BUILD_ROOT" - fi - fi - if [[ ! -d "$BUILD_ROOT/.git" ]]; then - rm -rf "$BUILD_ROOT" - mkdir -p "$BUILD_ROOT" - git -C "$BUILD_ROOT" init -q - git -C "$BUILD_ROOT" remote add origin "$REPO_URL" - git -C "$BUILD_ROOT" fetch --depth=1 origin "$PINNED_SHA" - git -C "$BUILD_ROOT" checkout --detach FETCH_HEAD - fi -fi - -# Verify HEAD matches the pin. -ACTUAL="$(git -C "$BUILD_ROOT" rev-parse HEAD)" -if [[ "$ACTUAL" != "$PINNED_SHA" ]]; then - echo "✗ Worktree HEAD ${ACTUAL:0:12} does not match pin ${PINNED_SHA:0:12}" - exit 1 -fi - -echo "→ Building @object-ui/console at ${PINNED_SHA:0:12}..." - -# ── Bundle THIS framework's client ─────────────────────────────────── -# The console SPA inlines @objectstack/client. Left to itself, the objectui -# build resolves the client from objectui's own lockfile — which lags the -# framework whenever a release adds new client APIs (the lockfile can't point -# at a client that isn't published yet). That shipped 11.5.0 with the new -# import UI bundled against client 11.2.0, so the console threw "does not -# support async import jobs" at runtime. Alias the build to the client in -# THIS tree instead: the bundled client then always matches the framework -# release being published, with no objectui pin-bump round-trip. -# objectui honors OBJECTSTACK_CLIENT_DIST in apps/console/vite.config.ts; -# fail hard if the pinned SHA predates that hook rather than silently drift. -CLIENT_PKG="${FRAMEWORK_ROOT}/packages/client" -if ! grep -q "OBJECTSTACK_CLIENT_DIST" "${BUILD_ROOT}/apps/console/vite.config.ts"; then - echo "✗ objectui@${PINNED_SHA:0:12} has no OBJECTSTACK_CLIENT_DIST hook in apps/console/vite.config.ts —" - echo " the bundled client would come from objectui's lockfile, not this framework." - echo " Bump .objectui-sha to a commit that includes the hook." - exit 1 -fi -# Build the client THROUGH TURBO, not by shelling into the package. turbo.json -# declares "build": { "dependsOn": ["^build"] }, and `cd packages/client && pnpm -# build` bypasses exactly that guarantee. On a cold tree packages/spec/dist and -# packages/core/dist do not exist yet, so the client's subpath imports -# (@objectstack/spec/data, @objectstack/core/logger, ...) resolve to nothing and -# the tsup DTS pass dies. `--filter=@objectstack/client` alone is enough — the -# dependency closure comes from ^build, not from a `...` filter suffix (measured: -# 32 build tasks in scope). -# -# Guard on the DECLARATION, not on dist/index.mjs: tsup writes the CJS/ESM -# bundles BEFORE the DTS pass, so a client build that died in DTS still leaves -# dist/index.mjs on disk. Keying the guard on it made a half-built client look -# complete, so the next run skipped the build and carried on with a client dist -# whose declarations were never generated. dist/index.d.ts is this package's -# declared type entrypoint (package.json "types" and exports["."].types) and -# only appears once the DTS pass has succeeded. -# -# Under OS_SKIP_DTS (see tsup.config.ts) there is legitimately no declaration, -# so this guard re-fires on every run — harmless, because the turbo build it -# guards is cached. -CLIENT_DTS="${CLIENT_PKG}/dist/index.d.ts" -if [[ ! -f "$CLIENT_DTS" ]]; then - echo "→ @objectstack/client dist absent or incomplete (no dist/index.d.ts) — building it and its deps first..." - (cd "$FRAMEWORK_ROOT" && pnpm exec turbo run build --filter=@objectstack/client) -fi -export OBJECTSTACK_CLIENT_DIST="$CLIENT_PKG" -echo "→ Console will bundle @objectstack/client from ${CLIENT_PKG}" - -# ── Bundle THIS framework's spec ───────────────────────────────────── -# The same class of skew as the client above, one level quieter. The console SPA -# inlines @objectstack/spec, and left to itself the objectui build resolves it -# from objectui's own lockfile under --frozen-lockfile — the last PUBLISHED spec, -# never this workspace. So an authorable key added to packages/spec after that -# publish is accepted and round-tripped by the server while the Studio designer -# rejects it as an unrecognized key and refuses to auto-save, and the -# framework-side card closes green because packages/spec's own pins all pass. -# Reaching the key took three ordered cross-repo steps: spec publishes, objectui -# refreshes its lockfile, this pin moves. Injecting this tree's spec collapses -# all three (objectstack#8134, hook added in objectui#4854). -# -# objectui honors OBJECTSTACK_SPEC_DIST in apps/console/vite.config.ts; fail hard -# if the pinned SHA predates that hook rather than silently drift — an unguarded -# injection would quietly rebuild the exact silent skew it exists to end. -SPEC_PKG="${FRAMEWORK_ROOT}/packages/spec" -if ! grep -q "OBJECTSTACK_SPEC_DIST" "${BUILD_ROOT}/apps/console/vite.config.ts"; then - echo "✗ objectui@${PINNED_SHA:0:12} has no OBJECTSTACK_SPEC_DIST hook in apps/console/vite.config.ts —" - echo " the bundled spec would come from objectui's lockfile, not this framework, so" - echo " any key this tree declares since the last spec publish would be unreachable" - echo " in the Studio designer." - echo " Bump .objectui-sha to a commit that includes the hook." - exit 1 -fi -# The hook resolves EVERY entry of the spec's exports map and refuses any whose -# target is missing, so the package must be built before it is injected. Two -# sentinels, because two different generators produce those targets: -# dist/index.mjs is tsup's, and json-schema/openapi.json is `gen:openapi`'s — the -# one export entry that does not live under dist/, is not committed, and is wiped -# by a later `gen:schema` run. A guard keyed on dist/ alone sails past a tree -# where that happened, and the hook then throws in the middle of the console build. -# -# Unlike the client's guard this deliberately does NOT key on a declaration file: -# the hook resolves the `import` condition only, so a spec whose DTS pass never -# ran (OS_SKIP_DTS, or a DTS crash) is still complete for the injection. -SPEC_ESM="${SPEC_PKG}/dist/index.mjs" -SPEC_OPENAPI="${SPEC_PKG}/json-schema/openapi.json" -if [[ ! -f "$SPEC_ESM" || ! -f "$SPEC_OPENAPI" ]]; then - echo "→ @objectstack/spec dist absent or incomplete — building it and its deps first..." - (cd "$FRAMEWORK_ROOT" && pnpm exec turbo run build --filter=@objectstack/spec) -fi -export OBJECTSTACK_SPEC_DIST="$SPEC_PKG" -echo "→ Console will bundle @objectstack/spec from ${SPEC_PKG}" - -# ── Install THIS framework's @objectstack/* too (objectui consumes it by commit) ── -# The two injections above answer "which spec and client does the BUNDLE carry". -# Ruling B on objectui#12955 (objectui#12957) moved the INSTALL off npm as well: a -# pinned objectui that carries `.objectstack-sha` overrides every @objectstack/* -# package it resolves to `file:node_modules/.cache/objectstack/` in its root -# package.json, and its `scripts/objectstack-pin.mjs build` fills those directories -# from a build of the objectstack commit `.objectstack-sha` names. Until something -# fills them, the frozen install below dies (measured at objectui 749ed760: exit -# 254, `ENOENT ... scandir node_modules/.cache/objectstack/client`); and the commit -# objectui would fill them from is its own pin, not this tree. -# -# That script's OBJECTSTACK_ROOT input packs the same packages from an installed -# and BUILT objectstack tree instead, with no lockfile change. Pointed at THIS tree, -# every @objectstack/* package objectui installs is this framework's, the same -# answer the injections give for the bundle. The injections stay: a pin without -# `.objectstack-sha` still installs from npm and needs them. -# -# Detected by the FILE, never by the sha: a pin without `.objectstack-sha` builds -# exactly as before. A pin with it but without the hook refuses, like the two -# vite.config.ts guards above, rather than letting objectui build its own pin. -OBJECTSTACK_PIN_SCRIPT="${BUILD_ROOT}/scripts/objectstack-pin.mjs" -# The spec the injection assertion at the end judges the bundle against: objectui's -# own install, on a pin without `.objectstack-sha` (the block below says what -# replaces it on a pin with one). -VENDORED_SPEC="${BUILD_ROOT}/node_modules/@objectstack/spec" -if [[ -f "${BUILD_ROOT}/.objectstack-sha" ]]; then - if [[ ! -f "$OBJECTSTACK_PIN_SCRIPT" ]] || ! grep -q "OBJECTSTACK_ROOT" "$OBJECTSTACK_PIN_SCRIPT"; then - echo "✗ objectui@${PINNED_SHA:0:12} carries .objectstack-sha but has no OBJECTSTACK_ROOT hook in" - echo " scripts/objectstack-pin.mjs — its install would resolve @objectstack/* from objectui's own" - echo " build of the objectstack commit it pins, not from this framework." - echo " Bump .objectui-sha to a commit that includes the hook." - exit 1 - fi - - # Build what the pin script packs, through turbo (see the client's note above): - # the packages objectui overrides, read from its root package.json at the pin - # (the same list the pin script reads), and their dependencies by `^build`. - # Unguarded on purpose: the pin script packs whatever dist/ is on disk, so a - # stale one would install without a word, while turbo judges freshness by - # content hash. Every workflow that runs this script has just built that - # closure, so there this is a cache hit; `new-only` keeps a hit from replaying - # thousands of cached log lines, and still prints every task that really runs. - # - # WITH declarations, whatever OS_SKIP_DTS the caller exported. The two guards - # above accept a typeless client and spec because the vite hooks read only the - # `import` condition; the pin script packs whole packages as they publish and - # refuses one whose `types` entry is missing. Measured on this branch with - # OS_SKIP_DTS=1 and a typeless tree: exit 1, "@objectstack/client is not built - # ... dist/index.d.ts, which is not there", and the rebuild that refusal - # prints inherits the same OS_SKIP_DTS (consumer pointer on objectstack#24306). - # turbo.json lists OS_SKIP_DTS in globalEnv, so this build hashes apart from a - # typeless one and cannot replay it. - OBJECTSTACK_PINNED_NAMES="$(node -e ' - const manifest = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); - const names = Object.keys(manifest.pnpm?.overrides ?? {}).filter((n) => n.startsWith("@objectstack/")); - process.stdout.write(names.sort().join(" ")); - ' "${BUILD_ROOT}/package.json")" - OBJECTSTACK_TURBO_FILTERS=() - for name in $OBJECTSTACK_PINNED_NAMES; do - OBJECTSTACK_TURBO_FILTERS+=("--filter=${name}") - done - if (( ${#OBJECTSTACK_TURBO_FILTERS[@]} > 0 )); then - echo "→ objectui@${PINNED_SHA:0:12} installs ${OBJECTSTACK_PINNED_NAMES} by commit — building them here..." - if [[ -n "${OS_SKIP_DTS:-}" ]]; then - echo " OS_SKIP_DTS is set; these packages are built WITHOUT it, declarations included," - echo " because objectui packs them as they publish." - fi - (cd "$FRAMEWORK_ROOT" && unset OS_SKIP_DTS && pnpm exec turbo run build "${OBJECTSTACK_TURBO_FILTERS[@]}" --output-logs=new-only) - fi - - # The REFERENCE for the injection assertion. Once this tree stands in below, - # objectui's install IS this tree, so its spec equals --injected and the - # assertion would pass any bundle with no witness. What a broken pack-or-inject - # ships here is objectui's own build of the commit its .objectstack-sha names, so - # build that first by objectui's own recipe (the pin script without - # OBJECTSTACK_ROOT, declarations included as it requires) and keep a copy, the - # pin's .stamp.json with it, outside the cache the stand-in replaces. The - # assertion refuses any other reference on such a pin. Reused while its stamp - # names this pin: the build of a fixed commit does not change. - OBJECTSTACK_PIN_SHA="$(tr -d '[:space:]' < "${BUILD_ROOT}/.objectstack-sha")" - PIN_REFERENCE="${BUILD_ROOT}/node_modules/.cache/objectstack-pin-reference" - REFERENCE_SHA="$(node -e ' - try { const s = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); if (s.source === "pin") process.stdout.write(String(s.sha)); } catch {} - ' "${PIN_REFERENCE}/.stamp.json")" - if [[ "$REFERENCE_SHA" != "$OBJECTSTACK_PIN_SHA" ]]; then - echo "→ Building objectui's own @objectstack/* at its .objectstack-sha ${OBJECTSTACK_PIN_SHA:0:12}, the reference the spec assertion judges this bundle against..." - (cd "$BUILD_ROOT" && env -u OBJECTSTACK_ROOT -u OS_SKIP_DTS node scripts/objectstack-pin.mjs build) - rm -rf "$PIN_REFERENCE" "${PIN_REFERENCE}.partial" - cp -R "${BUILD_ROOT}/node_modules/.cache/objectstack" "${PIN_REFERENCE}.partial" - mv "${PIN_REFERENCE}.partial" "$PIN_REFERENCE" - fi - VENDORED_SPEC="${PIN_REFERENCE}/spec" - - # The pin script refuses (exit 1, reason on stderr) on a missing entry point, - # and on two dependency mismatches between these packages and objectui's pin: - # a packed manifest whose dependency NAMES differ from what objectui's lockfile - # records (a frozen install would link the package without them and exit 0), - # and an @objectstack/* dependency objectui's root package.json does not - # override. This tree trips either one by itself, the first time it adds or - # drops a runtime dependency of one of these packages after the objectstack - # commit objectui's `.objectstack-sha` names, and the fix lives in objectui. - echo "→ Packing them into objectui's install from ${FRAMEWORK_ROOT} (OBJECTSTACK_ROOT)" - if ! (cd "$BUILD_ROOT" && OBJECTSTACK_ROOT="$FRAMEWORK_ROOT" node scripts/objectstack-pin.mjs build); then - echo "✗ objectui@${PINNED_SHA:0:12}'s scripts/objectstack-pin.mjs refused to stand this tree in for" - echo " the objectstack commit its .objectstack-sha names (the reason is above)." - echo " If the reason names a dependency (pnpm-lock.yaml does not record what the build" - echo " declares, or an @objectstack/* package the root package.json does not override): this" - echo " tree added or dropped a runtime dependency of a package objectui installs from it, after" - echo " the objectstack commit objectui pins. objectui has to learn it first, in objectui: move" - echo " its .objectstack-sha to an objectstack commit that carries the change" - echo " (scripts/bump-objectstack.sh there), commit the pnpm-lock.yaml (and any override) that" - echo " move needs, land it, then move .objectui-sha here to that objectui commit." - echo " ⛔ Not here: editing ${BUILD_ROOT}/pnpm-lock.yaml or making the install below non-frozen" - echo " builds a console against a dependency set no objectui commit records." - if [[ -n "${OS_SKIP_DTS:-}" ]]; then - echo " OS_SKIP_DTS is set in this environment. The build above ran without it; a rebuild by" - echo " hand, such as the one the pin script prints, inherits it and comes out typeless again:" - echo " run it as env -u OS_SKIP_DTS ." - fi - exit 1 - fi -fi - -pushd "$BUILD_ROOT" > /dev/null - -# objectui's root package.json may pin packages that aren't available on -# every mirror. Fall back to the public registry just for this install. -# -# The console build needs objectui's devDependencies, so production mode is -# forced OFF: an inherited `npm_config_production=true` or an .npmrc -# `production=true` would otherwise skip them (NODE_ENV=production alone does -# not, under pnpm 10). Spelled `--config.production=false`, never -# `--prod=false`: a global pnpm 11+/12 `@pnpm/exe` frontend parses the argv -# itself before handing off to the version objectui pins, rejects a value on its -# boolean `--prod` and exits 2 without installing anything. The pinned pnpm and -# that frontend both accept this spelling. -NPM_CONFIG_REGISTRY_OVERRIDE="${OBJECTUI_NPM_REGISTRY:-https://registry.npmjs.org}" -npm_config_registry="$NPM_CONFIG_REGISTRY_OVERRIDE" \ - pnpm install --frozen-lockfile --prefer-offline --config.production=false - -# Build the console's workspace deps via turbo, then the SPA itself directly -# (see DEPS_BUILD_CMD/BUILD_CMD above for why these are split). -eval "$DEPS_BUILD_CMD" -eval "$BUILD_CMD" - -popd > /dev/null - -CONSOLE_DIST="${BUILD_ROOT}/apps/console/dist" -if [[ ! -f "${CONSOLE_DIST}/index.html" ]]; then - echo "✗ Build did not produce ${CONSOLE_DIST}/index.html" - exit 1 -fi - -TARGET="${FRAMEWORK_ROOT}/packages/console/dist" -echo "→ Copying dist → ${TARGET}" -rm -rf "$TARGET" -mkdir -p "$(dirname "$TARGET")" -cp -R "$CONSOLE_DIST" "$TARGET" - -# Provenance stamp: record which objectui SHA this dist was built from, so -# `pnpm check:console-sha` and the CLI serve-time guard can detect drift when -# .objectui-sha later moves ahead of this gitignored, locally-built dist -# (which `turbo run build` does NOT rebuild). Travels inside dist/ so a -# cloud/objectos Docker overlay that replaces dist/ restamps it too. -echo "$PINNED_SHA" > "${TARGET}/.objectui-sha" - -# Assert the injected client actually landed in the bundle (see BUNDLE_CANARY). -if ! grep -rq "$BUNDLE_CANARY" "${TARGET}/assets"; then - echo "✗ Built console dist does not contain '${BUNDLE_CANARY}' — the bundled" - echo " @objectstack/client is stale (OBJECTSTACK_CLIENT_DIST injection failed)." - exit 1 -fi -echo "✓ Bundle canary '${BUNDLE_CANARY}' present — framework client is in the bundle." - -# Assert ONE zod instance in what ships (see the single-zod canary at the top). -# Placed after the bundle canary and, like it, after the SHA stamp: CI's split -# restore/save saves only when every step passed, so a failure here never -# seeds the dist cache. -if ! assert_single_zod "${TARGET}/assets"; then - exit 1 -fi - -# Assert the injected SPEC landed too. Deliberately NOT a frozen literal like -# BUNDLE_CANARY above: "does the bundle carry the surface the framework declares -# now" is a moving target, and any string pinned here would be carried by the -# published spec within one release — after which it passes forever while proving -# nothing, which is the same silent pass this injection exists to remove. The -# script derives its probes from the two specs on disk on every run, and tests -# BOTH directions: the console bundle also holds a second, transitive copy of -# this tree's spec (pulled in through the injected client above), which makes a -# one-sided "is the new text present" probe pass even with no injection at all. -# --objectui is the checkout just built: objectui's own registry mirrors spec -# descriptions, so text its tracked source carries is not evidence of either spec. -# On a pin that installs @objectstack/* from this tree (the OBJECTSTACK_ROOT block -# above), objectui's install is this tree's spec, packed, so --vendored is the -# reference that block kept instead: objectui's own build of its .objectstack-sha. -node "${FRAMEWORK_ROOT}/scripts/assert-console-spec-injection.mjs" \ - --injected "$SPEC_PKG" \ - --vendored "$VENDORED_SPEC" \ - --objectui "$BUILD_ROOT" \ - --assets "${TARGET}/assets" - -# ── Ship the TRACKED SDUI manifest (ADR-0080) ──────────────────────── -# One producer, one artefact: the repo-root sdui.manifest.json is written by -# scripts/gen-sdui-manifest-node.mjs from THIS built tree at the pin, and held -# against .objectui-sha (plus its sha256 record) by scripts/check-sdui-manifest.mjs -# in the required lint job. The console dist ships that file as-is. Measured -# before the switch: the browser dump this replaces (the retired -# scripts/gen-sdui-manifest.sh, a vite dev server plus Playwright chromium over -# this same tree) and the node output were `cmp`-identical, so the copy loses -# nothing and the console build gains no browser dependency. Nothing here -# regenerates the manifest: a pin that moved without regeneration is reported -# below, and lint is red on it until the regeneration lands. -MANIFEST_SRC="${FRAMEWORK_ROOT}/sdui.manifest.json" -MANIFEST_RECORD="${FRAMEWORK_ROOT}/scripts/sdui-manifest.record.json" -if [[ ! -f "$MANIFEST_SRC" ]]; then - echo "✗ ${MANIFEST_SRC} is missing — the console dist ships the tracked SDUI manifest." - echo " Regenerate it from the tree this run just built: node scripts/gen-sdui-manifest-node.mjs" - exit 1 -fi -cp "$MANIFEST_SRC" "${TARGET}/sdui.manifest.json" -RECORDED_SHA="$(node -e 'process.stdout.write(String(JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).objectuiSha ?? ""))' "$MANIFEST_RECORD" 2>/dev/null || true)" -if [[ "$RECORDED_SHA" == "$PINNED_SHA" ]]; then - echo "✓ Shipped the tracked sdui.manifest.json (recorded at objectui@${PINNED_SHA:0:12}, this pin)." -else - echo "⚠ Shipped the tracked sdui.manifest.json, but its record names objectui@${RECORDED_SHA:0:12}, not this pin ${PINNED_SHA:0:12}." - echo " Moved the pin? Regenerate from the tree this run just built, then re-run this build to ship it:" - echo " node scripts/gen-sdui-manifest-node.mjs" - echo " scripts/check-sdui-manifest.mjs keeps the required lint job red until the regeneration lands." -fi - -BYTES="$(du -sk "$TARGET" 2>/dev/null | awk '{print $1}')" -echo "✓ @objectstack/console dist ready (${BYTES} KB) from objectui@${PINNED_SHA:0:12}" - -# The reminder names the TRIGGER, not just the command (#5960): `pnpm objectui:refresh` -# runs bump-objectui.sh and then this script, so this is the last output an operator -# sees while moving the pin. bump-objectui.sh prints the same step; this repeats it -# because that one has scrolled past a whole console build by now. -echo "ℹ Moved the objectui pin? Regenerate the TRACKED SDUI manifest — the required lint gate:" -echo " node scripts/gen-sdui-manifest-node.mjs" -echo " It reads the tree this script just built (.cache/objectui-${PINNED_SHA:0:12}/apps/console) and" -echo " rewrites sdui.manifest.json + scripts/sdui-manifest.record.json; no browser, no npm install." diff --git a/scripts/bump-objectui.selftest.sh b/scripts/bump-objectui.selftest.sh deleted file mode 100755 index b689c89a7f6..00000000000 --- a/scripts/bump-objectui.selftest.sh +++ /dev/null @@ -1,548 +0,0 @@ -#!/usr/bin/env bash -# Self-test for scripts/bump-objectui.sh — the WRITE-ORDERING invariant (#10797). -# -# bash scripts/bump-objectui.selftest.sh -# -# THE INVARIANT UNDER TEST -# A bump that cannot read the objectui commit object REFUSES, and leaves -# `.objectui-sha` BYTE-IDENTICAL to what it was before the run. -# -# The bill this pins: the pin file used to be written BEFORE the run read the -# commit subject, so an unreadable commit object killed the script under `set -e` -# with `.objectui-sha` already rewritten — no changeset, no commit, and a bare -# `fatal: bad object` as the whole explanation. Re-running did not self-correct: -# the pin file now held the bad SHA, so the next run compared against it. -# -# WHY THE ASSERTION IS ON BYTES, NOT ON THE MESSAGE. Both the broken and the -# fixed script exit non-zero on this input — 128 from `set -e` versus 1 from the -# refusal — so "did it fail?" does not discriminate between them and a test that -# asked only that would have passed against the defect. What discriminates is the -# file: the broken ordering rewrites it, the fixed one does not. So every refusal -# case snapshots `.objectui-sha` and compares with `cmp` (byte-for-byte, and it -# needs no hashing tool), and the absent-pin case asserts the file was not -# CREATED. Verified by ablation, 2026-08-21: with the reads moved back after the -# write, cases 1 and 2 both go red on exactly that assertion. -# -# AND THE GUARD MUST NOT BE VACUOUS. `exit 1` at the top of bump-objectui.sh -# would satisfy every refusal case above, so cases 3 and 4 drive readable commits -# all the way through and assert the pin MOVES (and that an already-current pin -# is left alone). A guard that refuses everything fails them. -# -# CASE 5 PINS A SECOND, NARROWER REFUSAL (#14393): the range can WALK -# COMPLETELY (every commit and tree object present) and the bump still must -# refuse, because the changeset BLOB the digest needs to derive a bump level is -# unreadable at both revisions. This is not the #9408/#14178 shape above — the -# discriminator is `RANGE_OK`, settled by the commit/tree walk alone — so this -# case builds a real objectui changeset commit and deletes only its blob -# object, then asserts `--check-walkable` still exits 0 on the broken tree -# before asserting the bump itself refuses. This bump used to emit a degraded -# `patch` changeset here instead (silently, exit 0) — see the reproduction log -# in the #14393 PR body for the byte-for-byte before/after. -# -# Cases 1-4 stay offline, no node, no network: throwaway git repos under -# `mktemp -d`, and the script under test is exercised as a byte copy inside a -# throwaway FRAMEWORK_ROOT (the real one is derived from `${BASH_SOURCE[0]}/..`, -# so a copy is the only way to run it without writing into this repo's own -# `.objectui-sha`). Case 5 additionally needs node, offline still: it drives -# the real `objectui-changeset-digest.mjs` (a byte copy, like the script under -# test) through a throwaway objectui repo with a real changeset commit. -# -# dispatch-gates: no-path-population -- every path this file writes or reads lives inside a disposable checkout under mktemp -d (a throwaway objectui and a throwaway FRAMEWORK_ROOT, both destroyed by the EXIT trap below), so no quoted literal in this file names a path this repo tracks. Case 5's fixture changeset filename is assembled from CHANGESET_NAME by interpolation everywhere it is used, deliberately never spelled as one bare quoted token, so it does not read as a declared population here either. - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -UNDER_TEST="${SCRIPT_DIR}/bump-objectui.sh" - -if [[ ! -f "$UNDER_TEST" ]]; then - echo "✗ cannot find the script under test at ${UNDER_TEST}" >&2 - exit 1 -fi - -TMPROOT="$(mktemp -d)" -trap 'rm -rf "$TMPROOT"' EXIT - -PASSED=0 -FAILED=0 -CASE="" - -ok() { echo " ✓ $*"; PASSED=$((PASSED + 1)); } -bad() { echo " ✗ $*" >&2; FAILED=$((FAILED + 1)); } -case_begin() { CASE="$1"; echo " • ${CASE}"; } - -# case_5 additionally needs a copy of the digest script `bump-objectui.sh` -# calls, plus every first-party module that script imports, alongside the script -# under test — mirroring `objectui-changeset-digest.mjs`'s own self-test -# fixtures, which stage the same closure for the same reason. -# -# ⭐ The module list is DERIVED, not typed (#16421). It was two names here -# (`objectui-changeset-digest.mjs`, `invoked-as.mjs`) and the same two names in -# three other places; when the digest script gained one import, this file was the -# THIRD staging site to go red, and what a reader saw was not an -# `ERR_MODULE_NOT_FOUND` they could act on: the staged digest died as a -# subprocess, so `range_walkable` never returned one of its two verdicts and -# case 5 failed on the refusal's wording instead. (⚠️ `bump-objectui.sh` itself -# is NOT at fault and is not to be touched for this: it swallows no stderr, and -# its `WALK_RC` branch already separates a verdict from a no-answer and refuses -# to offer `--unshallow` for a crash. What WAS at fault was this file, and it -# is fixed below: `check_walkable` keeps the child's stderr and -# `walk_answered` forks on the same 2-or-3 criterion, so a probe that never -# answered is no longer reported as a verdict about the objectui range.) The -# derivation lives in `first-party-closure.mjs`, shared with the JS sites. -# -# ⚠️ THE BASENAME IS SPELLED ALONE AND THE DIRECTORY IS INTERPOLATED ONTO IT — -# the same discipline the declaration above states for CHANGESET_NAME, and for -# the same machine reason: `dispatch-gates` reads a quoted literal carrying a -# separator as a DECLARED WATCHED PATH, so writing the repo-relative path as one -# bare token here gives this gate a path population and CONTRADICTS the -# `no-path-population` marker above. Measured, not theorised — -# `check:pm-dispatch-gates`: "no family both DECLARES no path population and -# names paths anyway (contradicted: check:objectui-bump)". ⛔ Do not tidy this -# back into a single literal. -DIGEST_BASENAME='objectui-changeset-digest.mjs' -REPO_ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" -DIGEST_ENTRY="scripts/${DIGEST_BASENAME}" -# The same script, absolute, for the two cases that run it from THIS checkout -# rather than from a sandbox — those need no staging at all. -DIGEST_SCRIPT="${REPO_ROOT_DIR}/${DIGEST_ENTRY}" - -# Case 5's fixture changeset BASENAME — interpolated into a changeset path -# everywhere it is used, never spelled as one bare path literal: that path -# lives only inside a disposable objectui checkout under mktemp -d and names -# no file this repo tracks, so a literal quoted token would be misread as a -# declared population by check-declared-population-live. -CHANGESET_NAME='widget-refresh' - -# --- fixtures ---------------------------------------------------------------- - -# A throwaway objectui with two commits and an `origin/main` that matches HEAD, -# so the #10495 reachability report answers "on origin/main" instead of "UNKNOWN" -# and cannot be mistaken for the thing being tested here. -new_objectui() { - local d="$1" - mkdir -p "$d" - git -C "$d" init -q - git -C "$d" symbolic-ref HEAD refs/heads/main - git -C "$d" config user.email 'selftest@example.invalid' - git -C "$d" config user.name 'selftest' - git -C "$d" config commit.gpgsign false - # No commit-graph and no auto-gc: both can answer for an object that is no - # longer in the store, which would quietly defuse the fixture below. - git -C "$d" config gc.auto 0 - git -C "$d" config core.commitGraph false - git -C "$d" config fetch.writeCommitGraph false - git -C "$d" commit -q --allow-empty -m 'feat(console): the previous pin' - git -C "$d" commit -q --allow-empty -m 'feat(console): the commit being pinned' - git -C "$d" update-ref refs/remotes/origin/main HEAD - git -C "$d" rev-parse HEAD -} - -# A throwaway FRAMEWORK_ROOT holding a byte copy of the script under test. -new_framework() { - local d="$1" pin="${2-}" - mkdir -p "${d}/scripts" "${d}/.changeset" - cp "$UNDER_TEST" "${d}/scripts/bump-objectui.sh" - if [[ -n "$pin" ]]; then printf '%s\n' "$pin" > "${d}/.objectui-sha"; fi -} - -# Same, plus a byte copy of the digest script `bump-objectui.sh` shells out to -# and every first-party module that script imports — needed only by cases that -# do NOT pass `--no-changeset` and so actually reach the changeset section. -# -# ⛔ The closure is written to a file and its exit code read on its own line: a -# `node ... | while read` pipeline would hand this function the exit code of -# `while`, so a derivation that died would stage NOTHING and look like it worked, -# which is the failure shape this whole change exists to remove. -new_framework_with_digest() { - local d="$1" pin="${2-}" - new_framework "$d" "$pin" - local list="${d}/.closure.txt" rel - # ⚠️ Each path is emitted with its OWN trailing newline, and the loop below - # still reads an unterminated last line. Both halves, because the first time - # this was written with `join('\n')` the final path — `first-party-closure.mjs` - # itself — was silently dropped by `read`, and the sandbox then failed with the - # SAME ERR_MODULE_NOT_FOUND this derivation exists to prevent, from a list that - # had named the file correctly. - node --input-type=module -e " - import { firstPartyModuleClosure } from '${REPO_ROOT_DIR}/scripts/first-party-closure.mjs'; - for (const rel of firstPartyModuleClosure('${DIGEST_ENTRY}', { root: '${REPO_ROOT_DIR}' })) console.log(rel); - " > "$list" 2>"${list}.err" - local ec=$? - if [[ $ec -ne 0 ]]; then - printf 'bump-objectui.selftest: could not derive the digest closure (exit %s)\n' "$ec" >&2 - cat "${list}.err" >&2 - return "$ec" - fi - local staged_count=0 - while read -r rel || [[ -n "$rel" ]]; do - [[ -z "$rel" ]] && continue - mkdir -p "${d}/$(dirname "$rel")" - cp "${REPO_ROOT_DIR}/${rel}" "${d}/${rel}" - staged_count=$((staged_count + 1)) - done < "$list" - # A derivation that came back short stages a sandbox that looks runnable and - # is not, so the count is ASSERTED here rather than discovered three cases - # later as "the objectui range does not walk". - if [[ "$staged_count" -lt 2 ]]; then - printf 'bump-objectui.selftest: the digest closure staged only %s file(s) — expected the script and its imports\n' "$staged_count" >&2 - return 1 - fi - rm -f "$list" "${list}.err" -} - -# A throwaway objectui repo with a REAL changeset commit — commit A (the -# previous pin), commit B adding `.changeset/.md` declaring a real -# package/level, so the range A..B WALKS COMPLETELY and the digest has a real -# blob to read (#14393). Prints "OLD_SHA NEW_SHA" on stdout. -new_objectui_with_changeset() { - local d="$1" - mkdir -p "$d" - git -C "$d" init -q - git -C "$d" symbolic-ref HEAD refs/heads/main - git -C "$d" config user.email 'selftest@example.invalid' - git -C "$d" config user.name 'selftest' - git -C "$d" config commit.gpgsign false - git -C "$d" config gc.auto 0 - git -C "$d" config core.commitGraph false - git -C "$d" config fetch.writeCommitGraph false - git -C "$d" commit -q --allow-empty -m 'feat(console): the previous pin' - local old_sha - old_sha="$(git -C "$d" rev-parse HEAD)" - mkdir -p "${d}/.changeset" - cat > "${d}/.changeset/${CHANGESET_NAME}.md" <<'EOF' ---- -"@object-ui/core": minor ---- - -Refresh the widget palette. -EOF - git -C "$d" add ".changeset/${CHANGESET_NAME}.md" - git -C "$d" commit -q -m 'feat(core): refresh the widget palette' - local new_sha - new_sha="$(git -C "$d" rev-parse HEAD)" - git -C "$d" update-ref refs/remotes/origin/main HEAD - printf '%s %s\n' "$old_sha" "$new_sha" -} - -# Delete the changeset blob added at `sha` under `path` from the object store, -# then PROVE it is gone — same discipline as `break_commit_object`, one layer -# further in (a blob, not a commit object). `--check-walkable` walks commits -# and trees, never blobs, so this leaves the range walk green (asserted by the -# caller) while making `readAt` fail at BOTH `to` and the commit that added it -# — `to` and `sha` are the SAME commit in this fixture (the changeset is read -# at the tip that added it, before any release consumes it), so a single -# deleted blob object answers both reads identically. -break_changeset_blob() { - local d="$1" sha="$2" path="$3" - local blob - blob="$(git -C "$d" rev-parse "${sha}:${path}")" - local loose="${d}/.git/objects/${blob:0:2}/${blob:2}" - if [[ ! -f "$loose" ]]; then - bad "fixture: expected a loose blob object at ${loose}, found none (packed already?)" - return 1 - fi - rm -f "$loose" - if git -C "$d" cat-file -e "$blob" 2>/dev/null; then - bad "fixture: blob ${blob:0:12} is still readable after deleting it" - return 1 - fi - printf '%s\n' "$blob" - return 0 -} - -# Delete a commit object from the store, then PROVE it is gone. A fixture that -# silently failed to break anything is the one way this whole file could go -# green over nothing, so the proof is an assertion, not a comment. -break_commit_object() { - local d="$1" sha="$2" - local loose="${d}/.git/objects/${sha:0:2}/${sha:2}" - if [[ ! -f "$loose" ]]; then - bad "fixture: expected a loose object at ${loose}, found none (packed already?)" - return 1 - fi - rm -f "$loose" - if git -C "$d" cat-file -e "${sha}^{commit}" 2>/dev/null; then - bad "fixture: commit object ${sha:0:12} is still readable after deleting it" - return 1 - fi - if git -C "$d" log -1 --format=%s "$sha" >/dev/null 2>&1; then - bad "fixture: 'git log' still reads ${sha:0:12} after deleting the object" - return 1 - fi - # The card's own measurement, re-asserted here because it is what makes the - # DEFAULT path (no argument) able to reach the bug at all: `rev-parse HEAD` - # resolves the ref without reading the object, and exits 0. - local resolved rc=0 - resolved="$(git -C "$d" rev-parse HEAD 2>/dev/null)" || rc=$? - if [[ "$rc" -ne 0 || "$resolved" != "$sha" ]]; then - bad "fixture: 'git rev-parse HEAD' no longer resolves to ${sha:0:12} (rc=${rc})" - return 1 - fi - return 0 -} - -# Run the copy under test. No pipe anywhere near the exit code: redirect to a -# file, THEN read `$?`. -LOG="" -EC=0 -run_bump() { - local fw="$1" oui="$2"; shift 2 - LOG="${TMPROOT}/run-$$-${RANDOM}.log" - EC=0 - OBJECTUI_ROOT="$oui" bash "${fw}/scripts/bump-objectui.sh" "$@" > "$LOG" 2>&1 || EC=$? -} - -log_has() { grep -qF -- "$1" "$LOG"; } - -# --- the walkability probe, and the ONE criterion for reading its exit ------- -# -# THE PROBE'S STDERR IS THE ONLY DIAGNOSTIC IT EMITS. `--check-walkable` -# derives nothing and prints nothing on stdout — the digest's own self-test -# asserts that emptiness — so everything it has to say arrives on stderr: a -# real verdict's explanation, and equally a crash's `ERR_MODULE_NOT_FOUND`. -# Both call sites below used to send stderr to the bit bucket along with -# stdout, which threw the only clue away before anyone could read it. -# -# ⭐ AND A NON-ZERO EXIT IS NOT AUTOMATICALLY A VERDICT ABOUT THE RANGE. -# `bump-objectui.sh` already forks on exactly this, and the criterion is taken -# from there rather than re-invented, so the two cannot drift into two -# different ideas of what "the range does not walk" means: 2 and 3 are the -# probe's two VERDICTS (2 = an endpoint is missing, 3 = the endpoints are here -# but the history stops inside the range); any other non-zero exit means it -# never reached one — no node, a missing module, a syntax error, a killed -# process. Reporting that as "the range does not walk" is a confident, wrong -# diagnosis pointing at another subsystem, and it has cost a round already -# (#16421): a new import made the digest die, the wording sent that dev to -# investigate shallow clones and `fetch --unshallow`, and the real cause was a -# copy manifest three directories away. Their report calls it the longest part -# of the round. -WALK_ERR="" -check_walkable() { - local oui="$1" from="$2" to="$3" rc=0 - WALK_ERR="${TMPROOT}/walk-$$-${RANDOM}.err" - node "$DIGEST_SCRIPT" --objectui-root "$oui" --from "$from" --to "$to" \ - --check-walkable >/dev/null 2>"$WALK_ERR" || rc=$? - return "$rc" -} - -# True for the probe's two verdicts, false for every "it never answered" exit. -walk_answered() { [[ "$1" -eq 2 || "$1" -eq 3 ]]; } - -# Replay the child's stderr BEFORE the `bad` that reports it, so the failure -# carries its evidence instead of only pointing somewhere. -walk_replay_stderr() { - echo " ↳ the walkability probe's own stderr:" >&2 - if [[ -s "$WALK_ERR" ]]; then - sed 's#^# | #' "$WALK_ERR" >&2 - else - echo " | (nothing — the probe wrote no stderr at all)" >&2 - fi -} - -# --- case 1: unreadable commit object ⇒ refusal, pin file untouched ---------- -case_1() { - case_begin 'unreadable commit object ⇒ refuses, .objectui-sha byte-identical' - local fw="${TMPROOT}/c1/fw" oui="${TMPROOT}/c1/objectui" - local new_sha old_sha - new_sha="$(new_objectui "$oui")" - old_sha="$(git -C "$oui" rev-parse 'HEAD~1')" - new_framework "$fw" "$old_sha" - break_commit_object "$oui" "$new_sha" || return 0 - - cp "${fw}/.objectui-sha" "${TMPROOT}/c1.before" - run_bump "$fw" "$oui" --no-commit --no-changeset - - if [[ "$EC" -eq 0 ]]; then - bad "expected a non-zero exit, got 0 — the bump did not refuse" - else - ok "refused (exit ${EC})" - fi - # THE discriminating assertion. - if cmp -s "${TMPROOT}/c1.before" "${fw}/.objectui-sha"; then - ok ".objectui-sha is byte-identical to before the run" - else - bad ".objectui-sha CHANGED — half-applied state: $(cat "${fw}/.objectui-sha")" - fi - if log_has 'REFUSING to bump'; then - ok 'the refusal names itself rather than leaving git'"'"'s fatal as the whole message' - else - bad "no 'REFUSING to bump' in the output; got: $(head -3 "$LOG" | tr '\n' '|')" - fi - if log_has 'NOTHING WAS WRITTEN'; then - ok 'the message states that nothing was written' - else - bad "the refusal does not tell the operator the tree is clean" - fi - if [[ ! -e "${fw}/.changeset/console-${new_sha:0:12}.md" ]]; then - ok 'no changeset was emitted' - else - bad 'a changeset was emitted for a commit that cannot be read' - fi -} - -# --- case 2: same, with no pin file at all (initial pin) --------------------- -# Byte-identity of a file that does not exist is "it still does not exist". The -# broken ordering CREATES it here, which is the same defect wearing the -# first-ever-bump disguise. -case_2() { - case_begin 'unreadable commit object + no existing pin file ⇒ the file is not created' - local fw="${TMPROOT}/c2/fw" oui="${TMPROOT}/c2/objectui" - local new_sha - new_sha="$(new_objectui "$oui")" - new_framework "$fw" - break_commit_object "$oui" "$new_sha" || return 0 - - if [[ -e "${fw}/.objectui-sha" ]]; then - bad 'fixture: the pin file should not exist yet' - return 0 - fi - run_bump "$fw" "$oui" --no-commit --no-changeset - - if [[ "$EC" -ne 0 ]]; then ok "refused (exit ${EC})"; else bad 'expected a non-zero exit, got 0'; fi - if [[ ! -e "${fw}/.objectui-sha" ]]; then - ok '.objectui-sha was not created' - else - bad ".objectui-sha was CREATED holding $(cat "${fw}/.objectui-sha")" - fi -} - -# --- case 3: a readable commit still bumps (the guard is not vacuous) ------- -case_3() { - case_begin 'readable commit ⇒ the bump proceeds and the pin moves' - local fw="${TMPROOT}/c3/fw" oui="${TMPROOT}/c3/objectui" - local new_sha old_sha - new_sha="$(new_objectui "$oui")" - old_sha="$(git -C "$oui" rev-parse 'HEAD~1')" - new_framework "$fw" "$old_sha" - - run_bump "$fw" "$oui" --no-commit --no-changeset - - if [[ "$EC" -eq 0 ]]; then ok 'exited 0'; else bad "expected exit 0, got ${EC}: $(tail -5 "$LOG" | tr '\n' '|')"; fi - local written - written="$(tr -d '[:space:]' < "${fw}/.objectui-sha" 2>/dev/null || true)" - if [[ "$written" == "$new_sha" ]]; then - ok ".objectui-sha now holds ${new_sha:0:12}" - else - bad ".objectui-sha holds '${written}', expected ${new_sha}" - fi - if log_has '(on origin/main)'; then - ok 'the #10495 reachability report still runs and answers "on origin/main"' - else - bad "the reachability report did not answer: $(head -5 "$LOG" | tr '\n' '|')" - fi -} - -# --- case 4: an already-current pin is left alone --------------------------- -case_4() { - case_begin 'readable commit already pinned ⇒ nothing to do, pin file untouched' - local fw="${TMPROOT}/c4/fw" oui="${TMPROOT}/c4/objectui" - local new_sha - new_sha="$(new_objectui "$oui")" - new_framework "$fw" "$new_sha" - cp "${fw}/.objectui-sha" "${TMPROOT}/c4.before" - - run_bump "$fw" "$oui" --no-commit --no-changeset - - if [[ "$EC" -eq 0 ]]; then ok 'exited 0'; else bad "expected exit 0, got ${EC}"; fi - if log_has 'nothing to do'; then ok 'reported "nothing to do"'; else bad 'did not report "nothing to do"'; fi - if cmp -s "${TMPROOT}/c4.before" "${fw}/.objectui-sha"; then - ok '.objectui-sha is byte-identical to before the run' - else - bad '.objectui-sha was rewritten on a no-op bump' - fi -} - -# --- case 5: range WALKS, changeset blob unreadable at both revisions ------- -# (#14393.) Discriminator from case 1/2: the COMMIT object is fully readable -# and the range walks completely — only the changeset BLOB the digest needs to -# derive a level is gone. Discriminator from the initial-pin degrade (which -# this file does not otherwise exercise): there IS a previous SHA and the -# range DOES walk, so a level was derivable in principle and the derivation -# failed rather than never having an input. -case_5() { - case_begin 'range walks, changeset blob unreadable at both revisions ⇒ refuses, .objectui-sha byte-identical' - local fw="${TMPROOT}/c5/fw" oui="${TMPROOT}/c5/objectui" - local shas old_sha new_sha - shas="$(new_objectui_with_changeset "$oui")" - old_sha="${shas%% *}" - new_sha="${shas##* }" - new_framework_with_digest "$fw" "$old_sha" - - local walk_rc=0 - check_walkable "$oui" "$old_sha" "$new_sha" || walk_rc=$? - if [[ "$walk_rc" -ne 0 ]]; then - walk_replay_stderr - if walk_answered "$walk_rc"; then - bad "fixture: the range does not walk BEFORE breaking the blob (rc=${walk_rc}) — not this card's state" - else - bad "fixture: the walkability probe never ANSWERED before the blob was broken (exit ${walk_rc}; its verdicts are 2 and 3) — this says nothing about the objectui range; read the probe's stderr above" - fi - return 0 - fi - - break_changeset_blob "$oui" "$new_sha" ".changeset/${CHANGESET_NAME}.md" >/dev/null || return 0 - - # Re-assert walkability AFTER breaking the blob — the whole point of this - # case is that the commit/tree walk stays green while the blob read fails. - walk_rc=0 - check_walkable "$oui" "$old_sha" "$new_sha" || walk_rc=$? - if [[ "$walk_rc" -eq 0 ]]; then - ok 'fixture: --check-walkable still exits 0 after the blob is deleted (walk is commits/trees, not blobs)' - else - walk_replay_stderr - if walk_answered "$walk_rc"; then - bad "fixture: --check-walkable now exits ${walk_rc} — the blob deletion broke the WALK, not just the blob read" - else - bad "fixture: the walkability probe never ANSWERED after the blob deletion (exit ${walk_rc}; its verdicts are 2 and 3) — the WALK is UNMEASURED here, not broken; read the probe's stderr above" - fi - return 0 - fi - - cp "${fw}/.objectui-sha" "${TMPROOT}/c5.before" - run_bump "$fw" "$oui" --no-commit "$new_sha" - - if [[ "$EC" -eq 0 ]]; then - bad "expected a non-zero exit, got 0 — the bump did not refuse" - else - ok "refused (exit ${EC})" - fi - if cmp -s "${TMPROOT}/c5.before" "${fw}/.objectui-sha"; then - ok ".objectui-sha is byte-identical to before the run" - else - bad ".objectui-sha CHANGED — half-applied state: $(cat "${fw}/.objectui-sha")" - fi - if log_has 'REFUSING to bump'; then - ok 'the refusal names itself' - else - bad "no 'REFUSING to bump' in the output; got: $(tail -10 "$LOG" | tr '\n' '|')" - fi - if log_has 'walks'; then - ok 'the refusal says the range WALKS (the discriminator from the unwalkable-range refusal)' - else - bad "the refusal does not say the range walks: $(tail -10 "$LOG" | tr '\n' '|')" - fi - if log_has 'NOTHING WAS WRITTEN'; then - ok 'the message states that nothing was written' - else - bad "the refusal does not tell the operator the tree is clean" - fi - if [[ ! -e "${fw}/.changeset/console-${new_sha:0:12}.md" ]]; then - ok 'no changeset was emitted' - else - bad 'a changeset was emitted for a range whose derivation failed' - fi -} - -echo "bump-objectui.sh self-test — write-ordering invariant (#10797)" -case_1 -case_2 -case_3 -case_4 -case_5 - -echo -if [[ "$FAILED" -gt 0 ]]; then - echo "✗ bump-objectui self-test FAILED — ${FAILED} assertion(s) failed, ${PASSED} passed." >&2 - exit 1 -fi -echo "✓ bump-objectui self-test PASSED — ${PASSED} assertions across 5 cases." diff --git a/scripts/bump-objectui.sh b/scripts/bump-objectui.sh deleted file mode 100755 index aa467d85c82..00000000000 --- a/scripts/bump-objectui.sh +++ /dev/null @@ -1,741 +0,0 @@ -#!/usr/bin/env bash -# Bump the objectui SHA the framework workspace pins against. -# -# Usage: -# scripts/bump-objectui.sh # bump to current HEAD of ../objectui -# scripts/bump-objectui.sh # bump to an explicit SHA (or ref) -# scripts/bump-objectui.sh --no-commit # update files only, don't commit -# scripts/bump-objectui.sh --no-changeset # skip the @objectstack/console changeset -# -# After the bump — the second half of the pin-update procedure (#5960, #12924): -# pnpm objectui:build # builds objectui at the NEW pin into .cache/objectui-/ -# node scripts/gen-sdui-manifest-node.mjs # reads that built tree; no arguments -# -# That call rewrites the two TRACKED files, and nothing else writes them: the -# repo-root `sdui.manifest.json` and its provenance record -# `scripts/sdui-manifest.record.json`. The required lint job reds the bump PR until -# it has run — `scripts/check-sdui-manifest.mjs` asserts the record's pin equals -# `.objectui-sha`. No browser and no npm install: it enumerates the registry out of -# the pin's BUILT tree, the one producer (ruling 丙 on #17735), and the console build -# ships that same tracked file in its dist. This script prints the reminder on its -# way out; `pnpm objectui:refresh` (this script, then the build) leaves the tree the -# generator needs. -# -# The pin must name a commit that is on objectui MAIN. This script WARNS — it -# does not refuse — when the revision being pinned is not reachable from the -# objectui checkout's `origin/main`, and names the branch(es) it IS on. It never -# fetches for you: `origin/main` is only as fresh as your last fetch and this -# script stays usable offline. The release cut re-asks the same question against -# a fresh full clone and REFUSES to cut (#9450). Three answers, never two — a -# checkout that cannot answer the question says so rather than guessing (#10495). -# -# It DOES refuse — before writing anything — when the commit object cannot be -# read in the objectui checkout at all. There is nothing meaningful to pin, and a -# failed run must leave no half-applied state: `.objectui-sha` is byte-identical -# to what it was before the run (#10797). -# -# It also refuses — before writing anything, same invariant — when the OLD..NEW -# range cannot be walked in the objectui checkout and a deepen does not repair -# it. The changeset's bump LEVEL comes from what objectui declared over that -# range; with the range unreadable there is nothing to declare, and the degraded -# entry this used to emit carried the default level (`patch`) into published -# CHANGELOG text as if it were one (#14178). Deepen the checkout and re-run, or -# pass `--no-changeset` to move the pin while claiming nothing about the range. -# -# It ALSO refuses — same invariant, one input further in — when the range -# WALKS COMPLETELY but deriving the changeset from it still fails: the digest -# reads git BLOBS the walk above never touches, and a changeset blob unreadable -# at both `to` and the commit that added it throws. A level was derivable in -# principle there, so publishing the default level would be a guess wearing a -# declaration's clothes — the same #14178 shape, one state further in (#14393). -# The initial pin is unaffected: it has no previous SHA and no range to have -# walked, so it keeps its degraded (tip-subject-only) entry. -# -# Env: -# CONSOLE_BUMP=major|minor|patch # force the changeset bump type (default: auto — -# # the HIGHEST level objectui itself declared in the -# # changesets added over the range; see #4731) -# CONSOLE_CHANGES_MAX= # cap the rendered list (default 100). A cap that -# # fires says so, with the real count — never silently. -# OBJECTUI_NO_DEEPEN=1 # do NOT run 'git fetch --unshallow' on the objectui -# # checkout when the pin range cannot be walked in it — -# # whether an endpoint is absent or the history stops -# # inside the range (#9408 / #14178, one remedy for both). -# # Default is to deepen: measured on objectui the fetch -# # costs ~6s and ~4MB and turns a 110-commit walk into -# # the true 191 (#9408). Set this offline, or when the -# # checkout must not be touched — the bump then REFUSES -# # and says why, rather than deriving a weaker record. -# -# Assumes sibling layout: -# ~/work/objectui -# ~/work/objectstack ← run from here -# --help ends here -# -# ^ SENTINEL, not prose — `--help` prints from the shebang down to the line above -# and stops there, so the terminator travels with the text it terminates. Add or -# remove header lines freely; no line number tracks this block any more (#6425). -# Spell it exactly: the --help branch below refuses to run without it. Everything -# from here down is internal rationale and is NOT user-facing help. -# -# objectui ships @object-ui/console as a static SPA. The framework -# release pipeline reads .objectui-sha, clones objectui at that commit, -# builds @object-ui/console, and copies dist/ into -# packages/console/ so @objectstack/console publishes a frozen, -# version-matched build alongside the rest of the framework. -# -# The frontend is a version-locked package too, but a SHA bump alone left no -# trace in the release history — @objectstack/console's CHANGELOG stayed empty -# across frontend-only updates. So this bump also emits a changeset summarizing -# the objectui commit range, routing the frontend delta through the SAME -# changesets pipeline as the backend: it lands in @objectstack/console's -# CHANGELOG and rolls up into the platform version + the curated release notes. -# -# WHAT GOES IN THE LIST — DECLARED, NOT GUESSED (#4731) -# The list used to be a GUESS off the commit subject (`grep -iE '^- (feat|fix)'` -# + `head -40`), and the bump level another (`grep -ciE '^feat'`). Both were -# measured wrong on one real range: every `refactor(...)!` — the BREAKING class, -# the one that must never vanish from a release record — was structurally unable -# to appear, `head -40` truncated in silence, and `fix(ci)` commits that release -# nothing were pulled in. objectui already DECLARES which commits ship: every -# releasing PR carries a `.changeset/*.md`, and an empty frontmatter block is -# changesets' own "release-nothing". So `objectui-changeset-digest.mjs` reads the -# changesets added over the range — package names decide inclusion, the declared -# level decides the bump. Nothing is inferred from a subject line. -# -# SELF-TEST: `scripts/bump-objectui.selftest.sh` (`pnpm check:objectui-bump`, -# run unconditionally by the lint job). It drives this file's real bytes over -# throwaway git repos and pins the write-ordering invariant of #10797 — an -# unreadable commit object refuses and leaves `.objectui-sha` byte-identical — -# plus two readable-commit cases, so a guard that refused everything would fail -# it, plus a case pinning the #14393 refusal above: a range that WALKS -# COMPLETELY but whose changeset blob is unreadable at both revisions also -# refuses with the pin untouched. The first four cases stay offline, no node, -# ~1s; the #14393 case needs node (it drives the real digest derivation through -# a throwaway objectui with a real changeset commit) and a copy of -# `objectui-changeset-digest.mjs` + `invoked-as.mjs` alongside the script under -# test. Reordering anything between the reads above the first mutation and that -# mutation is what this file exists to catch. - -set -euo pipefail - -FRAMEWORK_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -OBJECTUI_ROOT="${OBJECTUI_ROOT:-$(cd "${FRAMEWORK_ROOT}/../objectui" 2>/dev/null && pwd || true)}" - -NO_COMMIT=0 -NO_CHANGESET=0 -EXPLICIT_SHA="" -for arg in "$@"; do - case "$arg" in - --no-commit) NO_COMMIT=1 ;; - --no-changeset) NO_CHANGESET=1 ;; - -h|--help) - # The header block above IS the help text, and the `# --help ends here` - # sentinel is what ends it — no line range, so growing the header can no - # longer truncate the help (#6425; #5960 grew it and PR #6421 had to move a - # hand-kept `2,26p`). The leading `2` addresses the shebang, whose position - # is fixed by execve rather than by the header's content, so it cannot drift. - # - # A missing sentinel EXITS 1 rather than running on to EOF: a truncated help - # and a complete one both exit 0 and both print something, which is precisely - # why the old coupling could fail in silence — same lesson as the `head -40` - # this script used to truncate its changeset list with (#4731). Guarded here, - # not at startup: a deleted comment must never stop an actual pin bump. - if ! grep -qxF '# --help ends here' "$0"; then - echo "✗ ${0##*/}: the '# --help ends here' sentinel is missing — cannot tell" >&2 - echo " where the help text ends. Restore it at the end of the header block." >&2 - exit 1 - fi - sed -n '2,/^# --help ends here$/p' "$0" \ - | grep -vxF '# --help ends here' \ - | sed 's/^# \{0,1\}//' - exit 0 - ;; - *) EXPLICIT_SHA="$arg" ;; - esac -done - -# `-e`, not `-d`: in a git WORKTREE `.git` is a regular file holding a `gitdir:` -# pointer, so a `-d` test rejected every linked worktree — and AGENTS.md requires -# one per task, so this rejected the mandated workflow and only ever worked from a -# primary clone. -if [[ -z "${OBJECTUI_ROOT}" || ! -e "${OBJECTUI_ROOT}/.git" ]]; then - if [[ -n "${OBJECTUI_ROOT}" ]]; then - echo "✗ ${OBJECTUI_ROOT} is not a git checkout (no .git)" - else - echo "✗ Cannot find objectui checkout at ${FRAMEWORK_ROOT}/../objectui" - fi - echo " Override with: OBJECTUI_ROOT=/path/to/objectui scripts/bump-objectui.sh" - exit 1 -fi - -if [[ -n "$EXPLICIT_SHA" ]]; then - NEW_SHA="$(git -C "$OBJECTUI_ROOT" rev-parse "$EXPLICIT_SHA^{commit}")" -else - NEW_SHA="$(git -C "$OBJECTUI_ROOT" rev-parse HEAD)" -fi - -# --- READ THE COMMIT BEFORE TOUCHING ANYTHING (#10797) ----------------------- -# Everything this bump reads OUT of the objectui commit is read here, ahead of -# the first mutation of `.objectui-sha`. The ordering is the whole point. The pin -# write used to come first and `git log -1 --format=%s` second, so an unreadable -# commit object killed the run under `set -e` with the pin file ALREADY -# REWRITTEN: no changeset, no commit, a bare `fatal: bad object` as the entire -# explanation, and a working tree the operator had to clean up by hand. Nor did -# re-running self-correct — the pin file now held the bad SHA, so the next run -# compared against it. Measured, git 2.43.0, on a throwaway objectui whose HEAD -# commit object was deleted: `SCRIPT EXIT=128`, `.objectui-sha` modified. The -# invariant restored here is "a failed run leaves no half-applied state". -# -# THE GUARD IS THE READ ITSELF, not a probe standing in for it. `cat-file -e` -# answers "is the object present", which is one failure short of the question -# that matters: a present-but-unreadable object (corrupt zlib, truncated pack) -# passes it and still kills `git log`. Performing the real read means anything -# that would have failed later has already failed HERE, with the tree untouched. -# One implementation of the rule, and it is the rule — the same reason the range -# walk is asked inside the digest rather than copied into this shell (#9408). -# -# AND IT REFUSES RATHER THAN WARNS. The #10495 warning-not-gate ruling below is -# about a pin that is not on `origin/main` — a real commit you can still -# meaningfully pin, where `origin/main` may simply be stale and the judgement is -# the operator's. This is a pin whose object cannot be read AT ALL: there is -# nothing to pin, nothing for the operator to weigh, and neither the changeset -# entry nor the commit message can be derived from it. Warning here would only -# reinstate the half-applied write. (#10797 triage ruling, 2026-08-21.) -# -# Reachable from the DEFAULT path, not just from an explicit argument. Measured, -# git 2.43.0: `git rev-parse HEAD` exits **0** and prints the sha even when that -# commit object is missing from the object store — it resolves the ref, it does -# not read the object. A partial clone with the object not fetched, or an -# interrupted object store, gets here with no argument at all. -# -# git's own `fatal:` is left on stderr deliberately: it names WHICH failure this -# was, and the block below is the explanation it was missing — not a replacement -# for it. -SHORT="${NEW_SHA:0:12}" -if ! SUBJECT_LINE="$(git -C "$OBJECTUI_ROOT" log -1 --format=%s "$NEW_SHA")"; then - { - echo "✗ REFUSING to bump: the objectui commit object ${SHORT} cannot be read in" - echo " ${OBJECTUI_ROOT}." - echo " 'git log -1 --format=%s ${SHORT}' fails there, so this bump can derive neither" - echo " the @objectstack/console changeset nor the commit message from it — there is" - echo " nothing meaningful to pin." - echo " A ref can name a commit whose object is missing: 'git rev-parse' resolves the" - echo " ref WITHOUT reading the object, so a plain HEAD bump reaches this too, not just" - echo " an explicit argument." - echo " NOTHING WAS WRITTEN — .objectui-sha is untouched and still holds the old pin." - echo " Fetch or repair the objectui object store, then re-run this bump:" - echo " git -C ${OBJECTUI_ROOT} fetch origin" - echo " git -C ${OBJECTUI_ROOT} cat-file -e ${SHORT}^{commit} && echo present" - } >&2 - exit 1 -fi - -# --- Is the revision we are about to pin actually ON objectui main? (#10495) -- -# `rev-parse HEAD` answers "what is checked out", never "is it on main". Bump -# with a feature branch checked out — or pass a branch name — and the pin names -# a revision that is not on main, and this script used to report success. -# Measured on a fresh `--no-tags` clone of objectui, 2026-08-21: 941 remote -# branches, 118 branch tips not reachable from `main`, 291 commits present and -# not on main. Any of those is one `rev-parse HEAD` away from being the pin. -# -# A WARNING, NOT A GATE — deliberately, and the split is with #9450 / PR #10494: -# `.github/workflows/cut-rc.yml` now runs this same predicate against a fresh -# full clone and refuses to cut. That is the chokepoint that must fail closed. -# Here it must not: `origin/main` in a local checkout is only as fresh as the -# last fetch, so hard-failing would reject a legitimately-just-merged commit, -# and this script is deliberately usable offline and on a machine that cannot -# run the full procedure (same rationale as `print_sdui_next_step`). So: say it -# loudly, name the branch it IS on, and let the operator decide. Never fetch on -# the operator's behalf — unlike the `--unshallow` deepen below, which repairs -# an input this script is about to DERIVE from, a fetch here would only change -# the answer to a question the operator is being asked to judge. -# -# THREE ANSWERS, NOT TWO. `merge-base --is-ancestor` exits 0 (ancestor), 1 (not -# an ancestor) and **128 on an absent object** — an error, not a verdict. -# Measured, git 2.43.0: -# git merge-base --is-ancestor origin/main -# fatal: Not a valid commit name -> exit 128 -# Reading 128 as "not an ancestor" invents a false alarm; reading anything that -# is not 1 as "fine" reinstates exactly the silent pass this check exists to -# remove. A checkout with no `origin/main` cannot answer the question at all. -# So the exits are split three ways, and the third says what it could not -# determine and why — it never borrows the wording of either verdict. -# -# The absent-object case is reachable from the DEFAULT path, not just from an -# explicit argument. Measured, git 2.43.0: `git rev-parse HEAD` exits **0** and -# prints the sha even when that commit object is missing from the object store — -# it resolves the ref, it does not read the object. So a resolved NEW_SHA is not -# proof the object is present; that question is settled ABOVE, before anything is -# written, as a refusal rather than as a report (#10797). -REACH_TAG="" # parenthetical for the one line that is printed anyway -REACH_RECALL="" # tail recall, so a warning cannot scroll out of the run - -# Render at most 10 ref names. A cap that fires SAYS SO, with the real count — -# a capped list that reads as complete is this file's other standing lesson -# (CONSOLE_CHANGES_MAX, #4731). -reach_ref_list() { - local n="$#" - if (( n == 0 )); then printf 'none'; return 0; fi - local out - out="$(printf '%s, ' "${@:1:10}")" - out="${out%, }" - if (( n > 10 )); then out="${out} … (+$(( n - 10 )) more; ${n} total)"; fi - printf '%s' "$out" -} - -report_objectui_reachability() { - local sha="$1" - local short="${sha:0:12}" - local rc=0 - - # Q1 — can this checkout answer the question AT ALL? Asked first because the - # later questions are meaningless without it: with no origin/main, - # `--is-ancestor` would report "the pin left main", which is this check's own - # overclaim wearing a new message. - if ! git -C "$OBJECTUI_ROOT" rev-parse --verify --quiet origin/main >/dev/null 2>&1; then - REACH_TAG=" (reachability UNKNOWN)" - REACH_RECALL=" — ⚠️ pin reachability UNKNOWN, see above" - { - echo - echo "⚠️ COULD NOT DETERMINE whether ${short} is on objectui main." - echo " ${OBJECTUI_ROOT} has no 'origin/main' ref, so the question cannot be asked" - echo " in this checkout at all." - echo " This is NOT 'the pin is fine' and NOT 'the pin left main'. It is unanswered," - echo " and the pin is being written anyway." - echo " To make it answerable here: git -C ${OBJECTUI_ROOT} fetch origin main" - echo " (not run for you — this script stays usable offline.)" - echo " The release cut re-asks it against a fresh full clone and fails closed (#9450)." - echo - } >&2 - return 0 - fi - - # Q2 — whether the object can be READ is deliberately NOT asked here. It is - # settled up front, by the read this bump actually needs (`git log -1 - # --format=%s`), and an unreadable object REFUSES there with the working tree - # untouched (#10797) — so `--is-ancestor` below cannot meet an absent object on - # any path that reaches this function. Asking it a second time with a weaker - # probe (`cat-file -e` tests presence, not readability) would be a second - # implementation of one rule, free to drift from the thing it guards. If that - # ordering is ever broken, the rc-is-not-1 branch below is the backstop: it - # reports 128 as the error it is and never as a verdict. - - # Q3 — the verdict. 0 / 1 / anything else, distinguished on purpose. - git -C "$OBJECTUI_ROOT" merge-base --is-ancestor "$sha" origin/main 2>/dev/null || rc=$? - - if [[ "$rc" -eq 0 ]]; then - # Reachable. No banner — but the line that prints anyway says so, so that - # "checked, and fine" is stated rather than inferred from silence. - REACH_TAG=" (on origin/main)" - return 0 - fi - - if [[ "$rc" -ne 1 ]]; then - REACH_TAG=" (reachability UNKNOWN)" - REACH_RECALL=" — ⚠️ pin reachability UNKNOWN, see above" - { - echo - echo "⚠️ COULD NOT DETERMINE whether ${short} is on objectui main." - echo " 'git merge-base --is-ancestor ${short} origin/main' exited ${rc}; only 0" - echo " (is an ancestor) and 1 (is not) are verdicts. Anything else is an error," - echo " and is being reported as one rather than folded into either answer." - echo " This is NOT 'the pin is fine' and NOT 'the pin left main'. It is unanswered." - echo - } >&2 - return 0 - fi - - # rc == 1: a real verdict, and the one this card exists for. - REACH_TAG=" (NOT on origin/main)" - REACH_RECALL=" — ⚠️ pin is NOT on objectui origin/main, see above" - - # Enumerated only on this branch: with ~941 branches a --contains walk is not - # free, and the healthy path must not pay for it. - # - # READ LOOP, NOT `mapfile` — THE BASH 3.2 FLOOR. `mapfile`/`readarray` are - # bash 4 builtins, and `/usr/bin/env bash` is bash 3.2.57 on macOS (Apple has - # not shipped bash 4+ for licensing reasons). This script is run BY HAND, BY - # AN OPERATOR, on a laptop — the pin-bump procedure in - # `docs/releases-maintenance.md` has no CI path — so a bash-4 builtin here - # does not fail on a fringe host, it fails on the ordinary one. And it fails - # on exactly the branch that must not fail: `mapfile` is only ever reached - # once the verdict is "NOT on origin/main", i.e. the #10495 warning is the - # single thing this shell cannot deliver. Measured with `mapfile` disabled: - # `mapfile: command not found`, then `set -e` kills the run at status - # 127 BEFORE the pin is written, and the operator is handed a bare builtin - # error where the warning should have been — an error whose obvious remedy - # (edit `.objectui-sha` by hand) walks around every guard in this file. - # CI runs bash 5, so neither the defect nor this repair is observable in a - # normal CI run; the digest self-test pins it with the builtin disabled - # (`enable -n mapfile readarray` via `BASH_ENV`) plus a static scan. - # Keep this loop bash-3.2-clean: no `mapfile`, no `readarray`, no - # `declare -A`, no `${x^^}`/`${x,,}`. The `if` (rather than `[[ … ]] &&`) is - # load-bearing under `set -e` — but NOT on an empty ref list: an all-empty - # read leaves the loop body unexecuted and the `while` exits 0 either way - # (measured, bash 5.2.21). The real trap is a NON-EMPTY read whose LAST - # line fails the `[[ -n … ]]` test, and only once that loop is the LAST - # command of a function: the `&&`-list's false status becomes the loop's - # exit status, which becomes the function's return, which `set -e` then - # kills the caller on. Measured over all four combinations (empty vs. - # non-empty-with-failing-last-line × loop-is-fn's-last-command vs. not): - # only that one combination dies (exit 1); the other three exit 0. - local -a local_refs=() remote_refs=() - local ref_line='' - while IFS= read -r ref_line; do - if [[ -n "$ref_line" ]]; then local_refs+=("$ref_line"); fi - done < <( - git -C "$OBJECTUI_ROOT" for-each-ref --contains "$sha" --format='%(refname:short)' refs/heads 2>/dev/null || true - ) - while IFS= read -r ref_line; do - if [[ -n "$ref_line" ]]; then remote_refs+=("$ref_line"); fi - done < <( - git -C "$OBJECTUI_ROOT" for-each-ref --contains "$sha" --format='%(refname:short)' refs/remotes 2>/dev/null || true - ) - - local main_tip - main_tip="$(git -C "$OBJECTUI_ROOT" log -1 --format='%h, committed %cr' origin/main 2>/dev/null || echo 'unknown')" - - { - echo - echo "⚠️ objectui pin ${short} is NOT reachable from origin/main in" - echo " ${OBJECTUI_ROOT} — it names a revision that is not on objectui main." - echo " on local branches : $(reach_ref_list ${local_refs[@]+"${local_refs[@]}"})" - echo " on remote branches: $(reach_ref_list ${remote_refs[@]+"${remote_refs[@]}"})" - # Which of the two situations this is, said out loud, so the operator does - # not have to go and look: a branch that never merged and a commit that was - # never pushed take different remedies. - if (( ${#remote_refs[@]} > 0 )); then - echo " → It IS pushed, but only onto branch(es) that have not merged into main." - elif (( ${#local_refs[@]} > 0 )); then - echo " → It is on a LOCAL branch only — this commit has not been pushed to objectui." - else - echo " → No branch in this checkout contains it (detached HEAD on a discarded commit?)." - fi - echo " Or your origin/main is simply STALE: it points at ${main_tip}." - echo " Refresh it with: git -C ${OBJECTUI_ROOT} fetch origin main" - echo " (not run for you — this script stays usable offline, and the answer to" - echo " this question is yours to judge.)" - echo " Pinning it anyway. Downstream: the release cut re-asks this against a fresh" - echo " full clone and REFUSES to cut (#9450), so a bad pin surfaces at RC time at" - echo " the earliest; until then the committed SDUI manifest regenerated at this pin" - echo " describes a registry that is not on main." - echo - } >&2 - return 0 -} - -report_objectui_reachability "$NEW_SHA" - -OLD_SHA="$(cat "${FRAMEWORK_ROOT}/.objectui-sha" 2>/dev/null | tr -d '[:space:]' || echo '')" - -if [[ "$OLD_SHA" == "$NEW_SHA" ]]; then - echo "→ Already at ${NEW_SHA:0:12}${REACH_TAG}, nothing to do." - exit 0 -fi - -# --- CAN THE OLD..NEW RANGE BE WALKED? Asked BEFORE the first mutation -------- -# (#9408 for the question, #14178 for where it is asked and what a "no" costs.) -# -# THE TEST IS WALK COMPLETENESS, NOT OBJECT PRESENCE (#9408). It used to be -# `git cat-file -e OLD_SHA` — "does the OLD endpoint exist" — which is a -# different question, and the gap between them is measured: on the bump that -# landed `.changeset/console-82a94170c405.md` that test PASSED against a history -# truncated at commit 110 of 191, so the guard reported a walkable range, the -# degraded path never fired, and the digest exited 0 on a record crediting 36 of -# its 119 entries to one commit that adds exactly one. A truncated history is -# worse than an absent endpoint precisely because it ANSWERS: git shows its -# oldest visible commit as parentless, diffs it against the empty tree, and that -# one commit absorbs a whole batch. -# -# The question is asked IN THE DIGEST (`--check-walkable`) so there is one -# implementation of the rule rather than a shell copy that can drift from the -# thing it guards — see `findRangeTruncation` and `findAbsentEndpoints`. Exit -# 2 = an endpoint is missing, 3 = the endpoints are here but the history stops -# inside the range. -# -# ONE FAILURE, TWO CAUSES, ONE REMEDY SHAPE (#14178). 2 used to skip the deepen -# entirely: the digest's endpoint guard returned before `findRangeTruncation` -# ran, so a shallow clone whose boundary sits AFTER the old pin — what -# `git clone --depth N` produces — never reached the fetch that would have -# repaired it. Both causes now take the same path: deepen, then RE-ASK. -# -# AND A "no" AFTER THAT REFUSES. Measured on a shallow checkout, same range and -# same command before and after `git fetch --unshallow`: the degraded record -# carried `patch` where the range declared `minor`, 15 declared-breaking entries -# were invisible in it, the ADR-0087 disposition prompt never fired (it keys on a -# declared-breaking changeset, and the degraded one declares none), and the run -# exited 0 having written and offered to commit it. That level publishes into -# @objectstack/console's CHANGELOG and the curated release notes, where it reads -# as a declaration. No record beats a wrong one (triage ruling, 2026-09-01), so -# the unwalkable range is now a REFUSAL rather than a fourth kind of artifact. -# -# ASKED HERE, above the pin write, for the #10797 invariant: a run that refuses -# must leave `.objectui-sha` byte-identical. The deepen this block may perform is -# additive to the OBJECTUI checkout (it adds objects and drops `.git/shallow`; it -# moves no branch and touches no working tree) and writes nothing in the -# framework tree, so nothing here can leave half-applied state behind. -RANGE_OK=0 -if [[ "$NO_CHANGESET" -eq 0 && "$OLD_SHA" != "" ]]; then - range_walkable() { - node "${FRAMEWORK_ROOT}/scripts/objectui-changeset-digest.mjs" \ - --objectui-root "$OBJECTUI_ROOT" --from "$1" --to "$2" --check-walkable - } - - WALK_RC=0 - range_walkable "$OLD_SHA" "$NEW_SHA" || WALK_RC=$? - DEEPEN_NOTE="" - if [[ "$WALK_RC" -ne 0 ]]; then - if [[ "$WALK_RC" -ne 2 && "$WALK_RC" -ne 3 ]]; then - # The probe did not ANSWER. 2 and 3 are its two verdicts; anything else - # means it never reached one (no node, a syntax error, a killed process). - # Deepening would be a remedy applied to a diagnosis nobody made, so it is - # not attempted — and the refusal below says which of the two this was. - DEEPEN_NOTE=" no deepen was attempted: the probe never returned a verdict, so there is nothing to repair yet." - elif [[ "${OBJECTUI_NO_DEEPEN:-0}" == "1" ]]; then - echo "→ the objectui pin range cannot be walked here; OBJECTUI_NO_DEEPEN=1, not deepening." >&2 - DEEPEN_NOTE=" OBJECTUI_NO_DEEPEN=1 was set for this run, so no deepen was attempted." - elif [[ "$(git -C "$OBJECTUI_ROOT" rev-parse --is-shallow-repository 2>/dev/null)" != "true" ]]; then - # Not shallow, yet the range does not walk: an object that was never - # fetched, a graft, a `git replace`, or unrelated histories. `--unshallow` - # cannot repair those and errors out on a complete repository, so do not - # pretend it might. - echo "→ the objectui pin range cannot be walked here, and the clone is NOT shallow" >&2 - echo " (an unfetched object, a graft, git replace, or unrelated histories) —" >&2 - echo " 'fetch --unshallow' cannot repair that." >&2 - DEEPEN_NOTE=" the clone is NOT shallow, so 'fetch --unshallow' is not the repair here." - else - # REPAIR THE INPUT BEFORE DERIVING FROM IT. A console changeset becomes - # published CHANGELOG text, so a bad record is permanent — while the - # correct history is one fetch away and cheap: measured on objectui, - # `fetch --unshallow` costs ~6s and ~4MB and takes the walk from 110 - # commits to the true 191. The fetch is ADDITIVE by construction, which is - # what makes doing it on the operator's checkout defensible rather than - # presumptuous. Announced before and after, and skippable with - # OBJECTUI_NO_DEEPEN=1 for an offline run. - # - # RE-CHECK, never trust the fetch's exit code. Measured: `git fetch - # --unshallow` in a checkout with no remote configured exits 0 and changes - # nothing at all, so a status-only test would have declared the range - # walkable on a still-broken tree — this card's failure shape, one layer - # further in. - echo "→ objectui is a shallow clone and the pin range cannot be walked in it — deepening…" - DEEPEN_RC=0 - git -C "$OBJECTUI_ROOT" fetch --unshallow || DEEPEN_RC=$? - if [[ "$DEEPEN_RC" -eq 0 ]]; then - WALK_RC=0 - range_walkable "$OLD_SHA" "$NEW_SHA" || WALK_RC=$? - if [[ "$WALK_RC" -eq 0 ]]; then - echo "✓ deepened — the range walks completely now." - else - DEEPEN_NOTE=" 'git fetch --unshallow' exited 0, but the RE-CHECK still refuses — the re-check decides, never the fetch's status." - fi - else - echo "✗ 'git fetch --unshallow' failed (exit ${DEEPEN_RC})." >&2 - DEEPEN_NOTE=" 'git fetch --unshallow' failed (exit ${DEEPEN_RC}), so the input was not repaired." - fi - fi - fi - - if [[ "$WALK_RC" -eq 0 ]]; then - RANGE_OK=1 - else - case "$WALK_RC" in - 2) WALK_CAUSE="an endpoint of the range is not present as a commit object in that checkout" ;; - 3) WALK_CAUSE="both endpoints are present but the history STOPS INSIDE the range (objectstack#9408)" ;; - *) WALK_CAUSE="the walkability probe itself did not answer (exit ${WALK_RC}); its verdicts are 2 and 3" ;; - esac - { - echo "✗ REFUSING to bump: the objectui range ${OLD_SHA:0:12}..${NEW_SHA:0:12} cannot be walked in" - echo " ${OBJECTUI_ROOT} — ${WALK_CAUSE}." - echo " (the digest's own diagnostic is above.)" - # An `[[ … ]] && echo` here would be a top-level AND-list whose status is - # the test's — false when there is no note — and `set -e` kills the run on - # exactly that, mid-refusal. Same trap the #12071 loop below documents. - if [[ -n "$DEEPEN_NOTE" ]]; then echo "$DEEPEN_NOTE"; fi - echo " The @objectstack/console changeset takes BOTH its list and its bump level from the" - echo " changesets objectui declared over this range. Neither can be derived here. This bump" - echo " used to emit a degraded entry carrying the default level instead — measured on a" - echo " shallow checkout: \`patch\` for a range that declared \`minor\`, every declared-breaking" - echo " entry invisible, the ADR-0087 disposition prompt silently skipped, exit 0" - echo " (objectstack#14178). That level publishes into @objectstack/console's CHANGELOG and" - echo " the release notes, where it reads as a declaration — so this refuses instead." - echo " NOTHING WAS WRITTEN — .objectui-sha is untouched and still holds the old pin." - echo " Fix it, then re-run this bump:" - echo " git -C ${OBJECTUI_ROOT} fetch --unshallow # a shallow clone (the usual case)" - echo " git -C ${OBJECTUI_ROOT} fetch origin # an object that was never fetched" - echo " Or move the pin without a release record at all: scripts/bump-objectui.sh --no-changeset" - echo " (the pin moves, nothing is derived, and nothing is claimed about the range)." - } >&2 - exit 1 - fi -fi - -# --- Derive the @objectstack/console changeset BEFORE the pin write ---------- -# `RANGE_OK` was settled above, before the pin write — walkability is a -# PRECONDITION of this whole section, and an unwalkable range already refused -# (#14178). Reaching here means one of exactly two things: the range walks -# (derive), or this is the initial pin with no previous SHA to walk from -# (degrade after the pin write, below). There is no third input state to test. -# -# THE DERIVATION ITSELF CAN STILL FAIL even though the range WALKS COMPLETELY: -# `objectui-changeset-digest.mjs` reads git BLOBS (`readAt`) that the commit/tree -# walk above never touches, and the reachable failure is a changeset blob that -# cannot be read at EITHER `to` or the commit that added it — its own -# both-reads-failed diagnostic, then a thrown exception, non-zero exit. -# -# ASKED HERE, above the pin write, for the SAME #10797 invariant the range walk -# above already keeps: a run that refuses must leave `.objectui-sha` -# byte-identical. Deriving AFTER the pin write (as this bump used to) refused -# the level correctly but left the pin already moved — the half-applied write -# #10797 exists to prevent, one input further in (#14393). -# -# WRITTEN STRAIGHT INTO `CS_FILE`, not a temp path moved into place afterward: -# the digest CLI only calls `writeFileSync(out, …)` once the WHOLE digest -# object has built successfully — a `readAt` failure throws INSIDE that build, -# before the write is ever reached, so a failing derivation leaves `CS_FILE` -# unwritten on its own. There is no partial-write case left for a temp file to -# guard against. -CS_FILE="" -DIGEST_OK=0 -BUMP="" -if [[ "$NO_CHANGESET" -eq 0 ]]; then - CS_FILE="${FRAMEWORK_ROOT}/.changeset/console-${SHORT}.md" - if [[ "$RANGE_OK" -eq 1 ]]; then - # The digest reads objectui's OWN declarations (.changeset/*.md added over - # the range) — inclusion and level both come from there, nothing is guessed - # off a commit subject. It writes the whole changeset file and echoes the - # resolved bump level. - if BUMP="$(node "${FRAMEWORK_ROOT}/scripts/objectui-changeset-digest.mjs" \ - --objectui-root "$OBJECTUI_ROOT" \ - --framework-root "$FRAMEWORK_ROOT" \ - --from "$OLD_SHA" --to "$NEW_SHA" \ - --max "${CONSOLE_CHANGES_MAX:-100}" \ - --bump-override "${CONSOLE_BUMP:-}" \ - --out "$CS_FILE")"; then - DIGEST_OK=1 - else - # `RANGE_OK=1` implies `OLD_SHA != ""` (the walk-check above only - # runs when there IS a previous SHA), so reaching here is always THIS - # card's state: the range walked, so a level WAS derivable in principle, - # and the derivation failed reading a changeset blob. Declaring `patch` - # here used to be a guess wearing a declaration's clothes, published into - # @objectstack/console's CHANGELOG and the curated release notes. No - # record beats a wrong one (#14178 triage ruling, restated for this state - # #14393) — refuse instead, same as the unwalkable-range case above and - # for the same reason. - { - echo "✗ REFUSING to bump: the objectui range ${OLD_SHA:0:12}...${NEW_SHA:0:12} walks" - echo " completely in ${OBJECTUI_ROOT}, but deriving the @objectstack/console changeset" - echo " from it failed — its diagnostic is in this run's output, above." - echo " A level was derivable in principle here; the derivation just failed reading a" - echo " changeset blob. This bump used to emit a degraded entry carrying the default" - echo " level instead (\`patch\`) as if it were a declaration nobody made, publishing" - echo " into @objectstack/console's CHANGELOG and the curated release notes. No record" - echo " beats a wrong one (objectstack#14178 triage ruling), so this refuses instead." - echo " NOTHING WAS WRITTEN — .objectui-sha is untouched and still holds the old pin." - echo " Repair the objectui object store and re-run this bump, or move the pin without" - echo " a release record at all: scripts/bump-objectui.sh --no-changeset" - echo " (the pin moves, nothing is derived, and nothing is claimed about the range)." - } >&2 - exit 1 - fi - fi -fi - -# NOT "the first mutation" any more — on the SUCCESS path the digest above -# already wrote `CS_FILE` (`--out`), so the changeset file is the first -# working-tree mutation and this pin write is the second. That is fine: the -# digest only calls `writeFileSync(out, …)` once the WHOLE digest has built -# successfully, so a refusal never leaves `CS_FILE` behind for this write to -# follow — either both land, in this order, or neither does. -# -# THE INVARIANT THAT MUST HOLD HERE (#10797): no read of objectui — git or the -# digest — happens BELOW this point. Everything that can fail on such a read -# (the commit read above, the range walk, the changeset derivation) already -# ran and already refused above. Keep it that way: a new `git -C -# "$OBJECTUI_ROOT" …` or `node .../objectui-changeset-digest.mjs` call added -# after this point reopens exactly the half-applied write this ordering exists -# to prevent — a mutation already made (the changeset, or this pin) sitting -# next to a read that still might fail. -echo "$NEW_SHA" > "${FRAMEWORK_ROOT}/.objectui-sha" -echo "→ objectui pin: ${OLD_SHA:0:12} → ${NEW_SHA:0:12}${REACH_TAG}" - -# --- Emit the degraded @objectstack/console changeset for the initial pin ---- -if [[ -n "$CS_FILE" && "$DIGEST_OK" -eq 0 ]]; then - # Reaching here with NO_CHANGESET=0 and DIGEST_OK=0 means RANGE_OK was 0 above - # — the ONLY input state left, now that the walkable-range/digest-failed - # state refuses above instead of degrading (#14393). RANGE_OK is 0 here only - # when OLD_SHA == "" (the initial pin: no previous SHA, no range, - # nothing guessed about a walk) — a genuinely different fact from state 2, - # and it keeps its degraded (tip-subject-only) entry, labelled as such - # (#4731) so the reader can tell this list from a derived one. - BUMP="${CONSOLE_BUMP:-patch}" - RANGE_LABEL="(initial pin) → ${NEW_SHA:0:12}" - WHY="this is the initial pin, so there is no previous SHA to walk from" - cat > "$CS_FILE" < objectui BUILT at the new pin, in -# .cache/objectui-/ -# node scripts/gen-sdui-manifest-node.mjs -> sdui.manifest.json (repo root) and -# scripts/sdui-manifest.record.json -# — both TRACKED, and gated per PR -# -# `scripts/check-sdui-manifest.mjs` in the required lint job reds while the record's -# pin trails `.objectui-sha`, so only the regeneration turns the bump PR green. It is -# the one producer (ruling 丙 on #17735): it reads the pin's BUILT tree — never an npm -# install, whose version string names a tarball built from an EARLIER commit — and the -# console build ships the same tracked file in packages/console/dist/. -# -# Deliberately a REMINDER, not a hard gate: a machine that cannot build objectui -# must still be able to move the pin, and hard-failing here would be CI cost wearing -# a local disguise. Nothing is lost by that, because the bump PR itself reds in lint -# until the regeneration lands. Printed on BOTH exits below: --no-commit still moved -# the pin. -print_sdui_next_step() { - echo - echo "→ NEXT STEP — regenerate the committed SDUI manifest (required lint gate):" - echo " pnpm objectui:build # skip if objectui:refresh already built this pin" - echo " node scripts/gen-sdui-manifest-node.mjs # reads .cache/objectui-/apps/console" - echo " It rewrites the tracked sdui.manifest.json and scripts/sdui-manifest.record.json;" - echo " scripts/check-sdui-manifest.mjs reds this bump PR until it does. No browser needed." - echo " Procedure: docs/releases-maintenance.md → 'After the pin moves'." -} - -if [[ "$NO_COMMIT" -eq 1 ]]; then - echo "→ --no-commit: leaving files unstaged${REACH_RECALL}." - print_sdui_next_step - exit 0 -fi - -git -C "$FRAMEWORK_ROOT" add .objectui-sha -[[ -n "$CS_FILE" ]] && git -C "$FRAMEWORK_ROOT" add "$CS_FILE" -git -C "$FRAMEWORK_ROOT" commit -m "chore: bump objectui to ${SHORT} - -${SUBJECT_LINE} - -objectui@${NEW_SHA}" -- .objectui-sha ${CS_FILE:+"$CS_FILE"} -echo "✓ Committed${REACH_RECALL}. Push with: git push" -print_sdui_next_step diff --git a/scripts/check-console-injection.mjs b/scripts/check-console-injection.mjs deleted file mode 100644 index f3a793c3f5c..00000000000 --- a/scripts/check-console-injection.mjs +++ /dev/null @@ -1,1365 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * check:console-injection — re-ask the spec-injection question about a console - * dist that was RESTORED FROM CACHE rather than built. - * - * ## The hole this closes (objectstack#9667) - * - * The vendored Console SPA is cached under a key each workflow spells in its - * own restore step: ci.yml's `console-pin` job and release.yml's publish job. - * The key is deliberately not restated here, because a copy in a comment is one - * that nothing holds in step (scripts/check-ci-filter-parity.mjs holds ci.yml's - * spellings to its `console` filter). The two keys no longer match - * (objectstack#20765): release.yml hashes the pin and the build script, and - * ci.yml hashes those plus the probe scripts and the spec's ENTRY LAYOUT - * (package.json, tsup.config.ts). Neither includes the spec's CONTENT, so a dist - * built while spec was at state X is restored and reused after spec moves on, - * and because - * scripts/assert-console-spec-injection.mjs runs INSIDE build-console.sh, a - * cache hit skips the entire build step and therefore skips the assertion too. - * The injection fixed resolution; the cache could still serve a console whose - * bundled spec is not the one this build proved. - * - * Adding the whole of packages/spec to the cache key was considered and - * REJECTED: it busts the key on every spec change and forces a full cold console rebuild (~20 min, - * measured) on a repo doing ~18 merges a day. The cache's economics — including - * ci.yml's deliberate split restore/save, which exists so a failed build never - * poisons the entry — are kept exactly as they are. Only the silent half is - * removed: this gate runs on every console-job run, cache hit or miss. - * - * ## Why it replays a STAMP instead of re-deriving probes - * - * assert-console-spec-injection.mjs derives its two probes from the two specs on - * disk — this tree's packages/spec, and the published @objectstack/spec that - * objectui's own lockfile installs. On a cache hit the second one DOES NOT - * EXIST: no objectui clone, no node_modules, nothing to compare against. So that - * script cannot simply be re-run here, and a version of it that fetched the - * published spec would put a network round-trip on every PR. - * - * Instead the build stamps the probes it chose into - * `/.objectstack-injection.json`, and this gate replays them against the - * restored bundle. Cost: one node process reading files already on disk. - * - * The objectui tree is just as absent here, and the replay does not need it: - * the build never chooses — so never stamps — a probe objectui's own source - * carries (objectstack#21709, see chooseProbes). A stamped detector is text no - * part of a good build writes, so finding it in the restored bundle still means - * the published spec, and the replay reaches the build's answer by substring - * alone. Battery 14 of the self-test holds the two to one answer. - * - * ## The stamped probes are checked for EXPIRY, not trusted forever - * - * A frozen probe is exactly the failure objectstack#8134 was filed about: #7804's - * `describe()` text ended up carried by the published 17.0.0 too, so grepping for - * it matched before AND after and proved nothing. A stale detector is only - * evidence while it still tells the two specs apart, so this gate re-checks that - * the stamped detector is STILL ABSENT from this tree's spec. Once the published - * spec catches up, the stamp is expired and says so instead of passing. - * - * ## Why the rest of packages/spec is NOT in ci.yml's console filter (objectstack#9710) - * - * That filter lists the pin, the build and probe scripts and this gate's own - * sources, and since objectstack#20765 also the spec's ENTRY LAYOUT - * (package.json's exports map and tsup.config.ts). ci.yml's dist key hashes - * each of those build inputs too, so a head that moves the entry layout MISSES - * the cache, rebuilds, and assert-console-spec-injection.mjs judges it; a - * cache hit could not have, as the paragraphs below explain. The rest of - * packages/spec is its CONTENT, which is in neither the filter nor the key, so - * a content-only spec PR never schedules Console Pin Gate and never reaches - * this check. Adding the content is the obvious next thought; it was measured and - * DECLINED, and the reason is not cost, which is why it is recorded here rather - * than left on a card: the job it would schedule is vacuous, not expensive. - * - * Count what this gate can fail on. Of its six failure verdicts, FIVE are pure - * functions of the RESTORED DIST and its stamp — a missing dist, unreadable - * assets or a malformed stamp, a missing stamp, the published-only detector - * present in the bundle, the stamp's own fresh witness missing from it. A - * content-only spec diff cannot move any of those: it does not move the cache - * key (see the top of this header for what each key hashes), and entries under - * a key are IMMUTABLE, so all five replay what the last - * console-filtered run already saw. Exactly ONE verdict reads this tree, the - * expiry re-check, and it needs packages/spec/dist because readSpecBlob resolves - * the package's exports map. So the restore-only job proposed there — no - * install, no turbo build — would start the gate 15 more times per 100 commits - * (6/100 today, 21/100 with packages/spec added, measured over real first-parent - * history) and skip the only tree-sensitive assertion on every one of them. - * - * Paying for the build instead does not rescue it, because the headline scenario - * is one this gate deliberately does not test. With the dist and stamp held - * fixed and only the tree varying: spec unbuilt now FAILS under --require-stamp - * (objectstack#10428; it used to pass with the expiry check silently skipped), - * spec unchanged PASSES, spec MOVED FORWARD PASSES, and only a spec that has - * caught up to the published text FAILS. "Spec moved forward since the dist was - * built" is precisely what a spec trigger would be bought for, and PR - * objectstack#9706 already ruled it "not a failure — the ruled cache design - * accepts lag". The lag is the trade-off objectstack#9667 accepted when it - * rejected the cache-key option, not a defect a trigger change can catch. What - * remains has a low ceiling: only 5 of those 15 commits add any `describe()` - * text under packages/spec — the only text the probes read — and expiry is a - * tree STATE, not an event, so once it is true today's 6/100 console runs still - * catch it. Widening the filter buys latency, not coverage. - * - * The exit proposed for whoever asks a third time was to derive the expiry probe - * from packages/spec SOURCE text — `describe()` arguments are "plain string - * literals present in both" — making that one assertion BUILDLESS and reopening - * the light-job question on different terms. That was PRICED under - * objectstack#10428 and DECLINED. The premise is 98% true and the missing 2% is - * the wrong 2%: of 2993 `.describe()` probe candidates in a built spec, 44 do not - * appear anywhere in `src/**` as literal text (59, or 2.0%, against the narrower - * `.zod.ts` source subset the package actually publishes), by two irreducible - * bundler transforms: - * - * - 25/44 quote-and-escape normalisation. Source writes `'…definition\'s…'`; - * esbuild re-emits `"…definition's…"`. Same characters, different bytes, so a - * literal substring search over source text misses it. - * - 19/44 constant-folded concatenation. Source splits a long description as - * `'… declares ' + '`_packageId`.'` (`packages/spec/src/api/protocol.zod.ts` - * is one, lines 341-342 as measured); - * the bundler folds it to one literal that exists in no source file. - * - * A missed probe here reads as "not expired" — a SILENT PASS, the same failure - * class this gate exists to end, so the 2% lands on exactly the side that cannot - * be tolerated. The reverse channel is worse-behaved still: 260 of 3161 source - * candidates (8.2%) are description text the built package does not ship, and a - * detector matching one of those would report EXPIRED on a healthy tree. - * Symmetric src-vs-src derivation would cancel both, but that is a redesign of - * what assert-console-spec-injection.mjs stamps — and it still cannot make the - * BUNDLE side buildless, which is where the dist dependency actually lives. - * So the dependency stays and is made MANDATORY instead: see --require-stamp - * below. Full working — the paths-filter replay under both picomatch versions, - * the per-commit attribution — is on objectstack#9710's ruling comment. - * - * ## Failure response: FAIL, deliberately, rather than rebuild - * - * GitHub Actions cache keys are IMMUTABLE. A gate that reacted to a bad restore - * by rebuilding could not evict the offending entry, so the next run would - * restore it and rebuild again — a ~20 min rebuild on every run until the pin - * moves, which is strictly worse than the cost that got the cache-key option - * rejected. Failing once, with the eviction command spelled out, is the cheaper - * and more honest response. Every failure below names its remedy. - * - * ## What --require-stamp requires (objectstack#10428) - * - * The flag's job is to refuse a VACUOUS pass: a caller passing it has asserted - * that this run's green means something. That covered the dist side only — a - * missing dist, an unstamped dist — and left the tree side open, so a run with - * packages/spec unbuilt skipped the expiry re-check, printed an `ℹ`, and exited - * 0. Five of six verdicts here are pure functions of the restored dist and its - * stamp; the expiry re-check is the only one that reads the checkout. A run that - * skips it cannot fail on anything about the PR it is guarding. - * - * ci.yml gets a readable spec today only by STEP ADJACENCY — the Console Pin - * Gate happens to run `turbo run build --filter=@objectstack/client...` first, - * and @objectstack/spec is in that closure. Nothing enforced the ordering, so - * reordering the steps or calling this from a job without that build turned the - * assertion off silently. --require-stamp now covers the tree side too, which - * makes the ordering a contract instead of a coincidence and needs no new flag - * at the call site — a flag a new job could forget is the same failure again. - * The bare invocation is unchanged: a checkout with no built spec still gets the - * advisory notice, because there the five bundle assertions genuinely do stand - * on their own. - * - * Exit codes: - * 0 verified; or no dist to verify; or an unstamped dist without --require-stamp - * 1 the restored dist is not one this repo can vouch for (see the message); or, - * under --require-stamp, a run that could not make an assertion it promised - * 2 cannot run (unreadable assets, malformed stamp) - */ - -import { spawnSync } from 'node:child_process'; -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { - ProbeError, - STAMP_BASENAME, - chooseProbes, - describeCandidates, - pickProbe, - readBundle, - readSpecBlob, - readStamp, - uniqueCandidates, - writeStamp, -} from './console-spec-probes.mjs'; -import { isEntrypoint } from './invoked-as.mjs'; - -// ── The self-test's own battery roster and floor (#13489) ────────────────── -// -// `failures.length === 0` used to be this self-test's ONLY success condition, so -// "every case held" and "the cases never ran" printed the same line. Closed the -// way PR #13487 validated on check-doc-authoring: what is pinned is the -// registered NAMES, not a number. Every section opens with `battery('')`, -// every assertion is attributed to the battery most recently opened, and the -// floor requires the OPENED set to equal the DECLARED set with each battery at -// or above its own count. -// -// ⛔ A pinned TOTAL is not the repair: a battery dropping from 9 cases to 3 -// keeps a total "right" the moment a sibling grows. -// -// The counts are a FLOOR, not an equality — adding cases is ordinary work and -// must not red. A battery BELOW its floor means cases stopped running; the -// remedy is to find what stopped registering. -const SELF_TEST_BATTERIES = Object.freeze({ - '1. Clean pass: fresh witness in the bundle, stale detector nowhere.': 1, - '2. THE DEFECT: the restored bundle carries the published spec.': 1, - '3. Partial restore: the stamp\'s own witness is missing from the assets.': 1, - '4. Probe expiry: this tree\'s spec now carries the stale detector too.': 3, - '7. No dist at all: nothing to verify, unless one was required.': 2, - '7b. THE SECOND VACUITY PATH (objectstack#10428): an unbuilt spec means the': 4, - '7c. THE THIRD VACUITY PATH (objectstack#10595): a well-formed stamp whose': 4, - '7d. The producer cannot emit that stamp in the first place. writeStamp is': 2, - '8. A build that found no skew records it, and this gate says so honestly.': 3, - '12. ROUND TRIP against the real assert script: whatever it stamps, this gate': 2, - '13. THE BLIND SPOT (objectstack#20646): the build-time derivation must choose': 6, - "14. OBJECTUI'S OWN TEXT IS NOT EVIDENCE: a probe the console's own source carries": 23, - "15. A PIN CARRYING .objectstack-sha: the reference is objectui's own build of it": 17, - '16. EVERY DISTINGUISHING LITERAL IS LONG OR ESCAPED (objectstack#24663).': 16, -}); - -// DELETING an entry silences that battery's floor exactly as effectively as -// zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 14; - -// The key an assertion is filed under when no battery is open. It is not a -// declared battery, so it reds by the same set difference rather than silently -// inflating whichever battery happened to run last. -const UNATTRIBUTED_BATTERY = '(no battery open)'; - -const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); - -/** - * How an operator clears a cached dist this gate refused. - * - * ⛔ The `gh cache delete` line is MAINTAINER-ONLY — it needs repo write scope, - * and it deletes an entry every later run of that workflow under the same key - * would restore. It is named anyway because ruling out a remedy an operator - * cannot discover is how a red becomes noise; a contributor who cannot run it - * should hand this message to a maintainer rather than guess. - * - * The key comes from the CI step (`CONSOLE_DIST_CACHE_KEY`). Without it (a local - * run) the line names WHERE each workflow spells its key instead of restating - * one: ci.yml's and release.yml's keys differ (objectstack#20765), and a copy - * here would be a third spelling nothing holds in step. - */ -function remedy(cacheKey) { - return [ - ' How to clear this:', - '', - ' • Locally — rebuild the console at the pinned SHA:', - '', - ' pnpm objectui:build', - '', - ' • In CI — the restored artifact is a CACHE ENTRY, not anything in this PR.', - " Nothing in the diff can fix it; the entry has to go. ⛔ MAINTAINER-ONLY:", - '', - ` gh cache delete "${cacheKey || 'KEY'}"`, - ...(cacheKey - ? [] - : [ - '', - " KEY is the key the failing job's restore step printed. ci.yml's", - " `console-pin` job and release.yml's publish job each spell their own.", - ]), - '', - ' then re-run the Console Pin Gate job. The next run misses, rebuilds,', - ' and re-stamps.', - ]; -} - -/** - * The one evaluation path. `main()` and `--self-test` both go through here, so - * the self-test exercises the real logic rather than a parallel imitation. - * - * Returns `{ code, out, err }` instead of printing, so the self-test can assert - * on verdicts without capturing stdout. - */ -export function evaluate({ distDir, specDir, requireStamp = false, cacheKey = '' }) { - const out = []; - const err = []; - const rel = (p) => path.relative(ROOT, p) || p; - - const index = path.join(distDir, 'index.html'); - if (!fs.existsSync(index)) { - // Published installs and package-only CI legitimately have no dist. Same - // policy as check:console-sha — but --require-stamp callers have already - // asserted a dist is present and a missing one means the restore broke. - if (requireStamp) { - err.push(`✗ No console dist at ${rel(distDir)} — nothing to verify, but a dist was required.`); - return { code: 1, out, err }; - } - out.push(`ℹ No console dist at ${rel(distDir)} — skipping injection check. Build it with: pnpm objectui:build`); - return { code: 0, out, err }; - } - - let stamp; - let bundle; - try { - stamp = readStamp(distDir); - bundle = readBundle(path.join(distDir, 'assets')); - } catch (error) { - if (!(error instanceof ProbeError)) throw error; - err.push(`✗ check:console-injection cannot run: ${error.message}`); - return { code: 2, out, err }; - } - - if (!stamp) { - // A dist built by a build-console.sh that predates the stamp, assembled by - // hand, or — the case that matters — written by a run that never reached the - // assertion. Unprovable either way. - const lines = [ - `⚠ Console dist at ${rel(distDir)} carries no ${STAMP_BASENAME} stamp,`, - ' so whether it bundles THIS tree\'s @objectstack/spec cannot be verified.', - ]; - if (!requireStamp) { - lines.push(' Rebuild once to enable the guard: pnpm objectui:build'); - out.push(...lines); - return { code: 0, out, err }; - } - err.push( - `✗ Console dist at ${rel(distDir)} carries no ${STAMP_BASENAME} stamp.`, - ' This job consumes a dist that may have been RESTORED FROM CACHE, and an', - ' unstamped dist is one no build ever proved the spec injection for —', - ' including a dist saved by a run that failed before the assertion.', - '', - ...remedy(cacheKey), - ); - return { code: 1, out, err }; - } - - // A stamp whose `packages` array is EMPTY (objectstack#10595). Well-formed — - // readStamp's Array.isArray shape check accepts `[]` — and therefore not a - // code-2 "cannot read"; it is readable and says nothing, which is a different - // failure. Measured: all three of this gate's substantive verdicts (the - // published-only detector in the bundle, the stamp's own fresh witness - // missing from it, and probe expiry) live inside the loop below, so an empty - // array silences every one of them. A dist literally carrying the PUBLISHED - // spec — the objectstack#8134 defect this gate exists to end — passes green. - // - // That is strictly MORE vacuous than the state objectstack#10428 refused one - // input over: an unbuilt spec skips only the expiry re-check and leaves the - // two bundle assertions standing. Refusing the lesser vacuity while tolerating - // the greater one is incoherent, so this refuses on the same terms. - // - // Scoped to `stamp.packages.length === 0`, NOT to the `asserted === 0` notice - // below, which a legitimate no-skew stamp also reaches: a no-skew entry is a - // POSITIVE record that the build looked and found nothing to tell the two - // specs apart, whereas an empty array is no record at all. Keying on - // `asserted` would fail the no-skew runs objectstack#10428 deliberately kept - // passing. Advisory when bare, for the same reason every other refusal here - // is: exit 1 under the flag, matching the no-dist and no-stamp verdicts. - if (stamp.packages.length === 0 && requireStamp) { - err.push( - `✗ Console dist at ${rel(distDir)} carries a stamp with an EMPTY \`packages\` array.`, - '', - ' The stamp is well-formed and asserts nothing. Every verdict that actually', - " interrogates the dist — the published-only detector, the stamp's own fresh", - ' witness, probe expiry — is derived per package entry, so with no entries', - ' this run would report green without making a single assertion about the', - ' artifact it is guarding. A dist carrying the PUBLISHED spec would pass.', - '', - ' Nothing this repo builds writes such a stamp: assert-console-spec-injection.mjs', - ' records one hard-coded @objectstack/spec entry on both of its stamping paths.', - ' So this dist was hand-assembled, truncated in transit, or partially restored.', - '', - ...remedy(cacheKey), - ); - return { code: 1, out, err }; - } - - // The tree's own spec, for the expiry re-check. Absent when spec is not built - // — a real state for a bare checkout, and not a reason to fail on its own: the - // bundle assertions below stand without it. Under --require-stamp it IS a - // reason to fail, but only once we know a probe was actually skipped; that is - // counted in the loop and answered after it (see `expiryDeferred`). - let treeBlob = null; - let treeBlobNote = ''; - try { - treeBlob = readSpecBlob(specDir, 'this tree\'s'); - } catch (error) { - if (!(error instanceof ProbeError)) throw error; - treeBlobNote = error.message; - } - - let asserted = 0; - let expiryDeferred = 0; - for (const entry of stamp.packages) { - const name = entry?.name || ''; - - // vendoredPin: the build judged against objectui's own build of that commit. - const against = entry?.vendoredPin - ? `objectui's own build of objectstack ${String(entry.vendoredPin).slice(0, 12)}` - : 'published spec'; - - if (!entry?.skew) { - out.push(`ℹ ${name}: the build recorded no observable skew between the injected and`); - out.push(` ${against}, so there is no probe that could tell them apart.`); - continue; - } - - const { freshWitness, staleDetector } = entry; - - if (staleDetector && bundle.includes(staleDetector)) { - err.push( - `✗ The console dist carries ${entry.vendoredPin ? `the ${name} of ${against}` : `the PUBLISHED ${name}`}, not this tree's.`, - '', - ' This dist was RESTORED FROM CACHE or built without the injection. Any', - ' authorable key the framework declared after the last spec publish is', - ' unreachable in the Studio designer — the defect objectstack#8134 exists', - ' to end, reaching main one layer out through the cache.', - '', - ' Text in the bundle that only the PUBLISHED spec has:', - ` "${staleDetector}"`, - '', - ...remedy(cacheKey), - ); - return { code: 1, out, err }; - } - - if (freshWitness && !bundle.includes(freshWitness)) { - err.push( - `✗ The console dist does not carry the ${name} content its own stamp records.`, - '', - ' The stamp was written beside this dist by the build that produced it, so', - ' the witness below MUST be in these assets. It is not — the artifact and', - ' its stamp disagree, which means a partial cache restore or a dist that', - ' was modified after it was proved.', - '', - ' Expected in the bundle (from the stamp):', - ` "${freshWitness}"`, - '', - ...remedy(cacheKey), - ); - return { code: 1, out, err }; - } - - // Expiry. A stale detector is evidence only while it still separates the two - // specs; once this tree's spec also contains it, a bundle that "passes" is - // proving nothing at all. - // - // Count the probes this run could NOT re-check, rather than reading - // `asserted` afterwards: an entry can have skew and still carry no stale - // detector, and such an entry has no expiry question to defer. Only a probe - // that exists and went unexamined is a skipped assertion. - if (staleDetector && !treeBlob) expiryDeferred += 1; - - if (staleDetector && treeBlob && treeBlob.includes(staleDetector)) { - err.push( - `✗ The stamped staleness probe for ${name} has EXPIRED.`, - '', - ' The probe is text that used to exist ONLY in the published spec. This', - " tree's spec now contains it too, so a bundle carrying it is no longer", - ' distinguishable from one carrying the injected spec. The check would', - ' pass from here on while proving nothing — the silent pass this gate', - ' exists to prevent, so it reports the expiry instead.', - '', - ' Expired probe:', - ` "${staleDetector}"`, - '', - ' The dist itself is not known to be bad. It is UNVERIFIABLE, and a fresh', - ' build re-derives a probe that discriminates again.', - '', - ...remedy(cacheKey), - ); - return { code: 1, out, err }; - } - - asserted += 1; - out.push(`✓ ${name}: the dist carries this tree's copy, and not the published one.`); - if (freshWitness) out.push(` present (injected only): "${freshWitness}"`); - if (staleDetector) out.push(` absent (published only): "${staleDetector}"`); - } - - // A probe that was never re-checked is an assertion this run did not make. - // Advisory by default — a bare checkout legitimately has no built spec, and - // the five bundle assertions above still stand. Fatal under --require-stamp, - // for the reason that flag exists: five of this gate's six verdicts are pure - // functions of the restored dist and its stamp, and the expiry re-check is the - // ONLY one that reads this tree. Skipping it leaves a run that cannot fail on - // anything about the checkout it is guarding, which is the vacuous pass - // --require-stamp already refuses one layer in (objectstack#10428). - // - // Exit 1, not 2, deliberately: every other --require-stamp refusal here (no - // dist, no stamp) is a 1, and they are the same kind of statement — the run - // could not prove what this caller promised was provable. 2 stays reserved for - // a tree this script cannot read at all. - if (expiryDeferred > 0) { - if (requireStamp) { - err.push( - `✗ ${expiryDeferred === 1 ? 'A stamped staleness probe was' : `${expiryDeferred} stamped staleness probes were`} never re-checked for expiry.`, - '', - ` ${treeBlobNote}`, - '', - ' This gate has six failure verdicts and five of them read only the restored', - ' dist and its stamp. The expiry re-check is the one that reads THIS TREE, so', - ' a run without it cannot fail on anything about the checkout it guards — it', - ' would report green while asserting nothing about this PR.', - '', - ' --require-stamp callers have asserted that this run is one whose verdict', - ' means something, so the skip is a failure here rather than the notice a', - ' bare checkout gets.', - '', - ' How to clear this: build the spec before this step.', - '', - ' pnpm --filter @objectstack/spec build', - '', - ' In ci.yml the Console Pin Gate already does, via', - ' `turbo run build --filter=@objectstack/client...` (@objectstack/spec is in', - ' that closure). If you are seeing this there, the steps were reordered or', - ' this check moved to a job that does not build the closure.', - ); - return { code: 1, out, err }; - } - out.push(`ℹ Probe expiry not re-checked: ${treeBlobNote}`); - out.push(' (build the spec — `pnpm --filter @objectstack/spec build` — to enable it)'); - } - - if (asserted === 0) { - out.push('ℹ Nothing assertable in this stamp; the dist was not contradicted.'); - } - - return { code: 0, out, err }; -} - -// ── self-test ──────────────────────────────────────────────────────────────── - -function tmpdir(name) { - const dir = fs.mkdtempSync(path.join(fs.realpathSync(process.env.RUNNER_TEMP || '/tmp'), `${name}-`)); - return dir; -} - -/** A minimal package whose exports map resolves to one built ESM file. */ -function makeSpecPkg(dir, descriptions) { - fs.mkdirSync(path.join(dir, 'dist'), { recursive: true }); - fs.writeFileSync( - path.join(dir, 'package.json'), - JSON.stringify({ - name: '@objectstack/spec', - exports: { '.': { import: { types: './dist/index.d.mts', default: './dist/index.mjs' } } }, - }), - ); - const body = descriptions.map((d) => `z.string().describe(${JSON.stringify(d)})`).join(';\n'); - fs.writeFileSync(path.join(dir, 'dist', 'index.mjs'), `${body}\n`); - return dir; -} - -/** - * A spec package that has never been built: the manifest and its exports map are - * there, `dist/` is not. This is what a bare checkout looks like, and what the - * Console Pin Gate would look like if its build step were reordered away. - */ -function makeUnbuiltSpecPkg(dir) { - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync( - path.join(dir, 'package.json'), - JSON.stringify({ - name: '@objectstack/spec', - exports: { '.': { import: { types: './dist/index.d.mts', default: './dist/index.mjs' } } }, - }), - ); - return dir; -} - -/** A minimal console dist: index.html, one JS asset, optionally a stamp. */ -function makeDist(dir, assetText, stamp) { - fs.mkdirSync(path.join(dir, 'assets'), { recursive: true }); - fs.writeFileSync(path.join(dir, 'index.html'), ''); - fs.writeFileSync(path.join(dir, 'assets', 'app-abc123.js'), assetText); - if (stamp !== undefined) { - fs.writeFileSync(path.join(dir, STAMP_BASENAME), typeof stamp === 'string' ? stamp : JSON.stringify(stamp)); - } - return dir; -} - -/** - * A minimal objectui build tree: a git checkout whose TRACKED files are the - * given ones. The assert script reads objectui's own source with `git ls-files` - * (never a directory walk, which would also read node_modules and built dists), - * so the fixture has to be tracked to be seen; an index entry is enough. - */ -function makeHostTree(dir, files) { - for (const [rel, text] of Object.entries(files)) { - fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); - fs.writeFileSync(path.join(dir, rel), text); - } - for (const argv of [['init', '-q'], ['add', '-A']]) { - const run = spawnSync('git', ['-C', dir, ...argv], { encoding: 'utf8' }); - if (run.status !== 0) throw new Error(`fixture: git ${argv.join(' ')} failed in ${dir}: ${run.stderr || run.error}`); - } - return dir; -} - -const FRESH = 'Authorable key this tree declares and the registry reads'; -const STALE = 'Published description that only the vendored spec carries'; - -function stampFor({ skew = true, freshWitness = FRESH, staleDetector = STALE } = {}) { - return { - stampVersion: 1, - generatedBy: 'scripts/assert-console-spec-injection.mjs', - packages: [{ name: '@objectstack/spec', injectedFrom: '/x/packages/spec', skew, freshWitness, staleDetector }], - }; -} - -// Returned by `selfTest()` only after its verdict is printed. The dispatch -// refuses anything else: a `return` that leaves the function above that line -// prints nothing and still exits 0 — a self-test that never finished, reported -// as one that passed (#13798). -const SELF_TEST_VERDICT = 'check-console-injection self-test reached its verdict'; - -function selfTest() { - // The battery ledger this self-test's floor is evaluated against (#13489). - // `battery()` opens a battery; every assertion below is attributed to the one - // most recently opened, so a section that stops running stops registering and - // names ITSELF at the floor rather than going quiet. - const batterySeen = new Map(); - let openBattery = null; - const battery = (name) => { - openBattery = name; - }; - const registerCase = () => { - const b = openBattery ?? UNATTRIBUTED_BATTERY; - batterySeen.set(b, (batterySeen.get(b) ?? 0) + 1); - }; - - const failures = []; - let checked = 0; - const root = tmpdir('check-console-injection'); - - const expect = (label, actual, wanted) => { - registerCase(); - checked += 1; - if (actual !== wanted) failures.push(`${label}: expected ${wanted}, got ${actual}`); - }; - - const specDir = makeSpecPkg(path.join(root, 'spec-ahead'), [FRESH, 'A shared description both specs carry always']); - - // 1. Clean pass: fresh witness in the bundle, stale detector nowhere. - battery('1. Clean pass: fresh witness in the bundle, stale detector nowhere.'); - { - const dist = makeDist(path.join(root, 'ok'), `console(${JSON.stringify(FRESH)})`, stampFor()); - expect('clean pass', evaluate({ distDir: dist, specDir }).code, 0); - } - - // 2. THE DEFECT: the restored bundle carries the published spec. - // - // The fixture carries BOTH probes on purpose. A bundle holding only the - // published text also trips the missing-fresh-witness branch below, so a - // fixture like that proves nothing about THIS branch — asserting the exit code - // alone passed with the stale-detector check deleted, which is how this case - // was found to be vacuous. Both strings present is also the real shape: the - // console bundle holds a second, transitive copy of this tree's spec via the - // injected client, which is exactly why the check is two-sided. - battery('2. THE DEFECT: the restored bundle carries the published spec.'); - { - const dist = makeDist( - path.join(root, 'stale'), - `console(${JSON.stringify(FRESH)});console(${JSON.stringify(STALE)})`, - stampFor(), - ); - const r = evaluate({ distDir: dist, specDir, cacheKey: 'Linux-console-dist-deadbeef' }); - expect('published spec in bundle fails', r.code, 1); - const text = r.err.join('\n'); - checked += 1; - // Branch-unique wording, not the shared remedy block: every failure path - // prints remedy(), so keying on it cannot tell the branches apart. - if (!text.includes('carries the PUBLISHED')) { - failures.push('published-spec failure must say the dist carries the published spec'); - } - checked += 1; - if (!text.includes('gh cache delete "Linux-console-dist-deadbeef"')) { - failures.push('published-spec failure must name the exact cache key to delete'); - } - } - - // 3. Partial restore: the stamp's own witness is missing from the assets. - battery('3. Partial restore: the stamp\'s own witness is missing from the assets.'); - { - const dist = makeDist(path.join(root, 'partial'), 'console("unrelated bundle text")', stampFor()); - const r = evaluate({ distDir: dist, specDir }); - expect('missing fresh witness fails', r.code, 1); - checked += 1; - if (!r.err.join('\n').includes('does not carry the @objectstack/spec content its own stamp records')) { - failures.push('missing-witness failure must name the stamp/artifact disagreement'); - } - } - - // 4. Probe expiry: this tree's spec now carries the stale detector too. - battery('4. Probe expiry: this tree\'s spec now carries the stale detector too.'); - { - const caught = makeSpecPkg(path.join(root, 'spec-caught-up'), [FRESH, STALE]); - const dist = makeDist(path.join(root, 'expired'), `console(${JSON.stringify(FRESH)})`, stampFor()); - const r = evaluate({ distDir: dist, specDir: caught }); - expect('expired probe fails', r.code, 1); - checked += 1; - if (!r.err.join('\n').includes('EXPIRED')) failures.push('expiry failure must say the probe expired'); - } - - // 5/6. Unstamped dist: advisory by default, fatal under --require-stamp. - { - const dist = makeDist(path.join(root, 'unstamped'), `console(${JSON.stringify(FRESH)})`, undefined); - expect('unstamped is advisory', evaluate({ distDir: dist, specDir }).code, 0); - expect('unstamped is fatal when required', evaluate({ distDir: dist, specDir, requireStamp: true }).code, 1); - } - - // 7. No dist at all: nothing to verify, unless one was required. - battery('7. No dist at all: nothing to verify, unless one was required.'); - { - const dist = path.join(root, 'absent'); - expect('no dist passes', evaluate({ distDir: dist, specDir }).code, 0); - expect('no dist fails when required', evaluate({ distDir: dist, specDir, requireStamp: true }).code, 1); - } - - // 7b. THE SECOND VACUITY PATH (objectstack#10428): an unbuilt spec means the - // expiry re-check — the only one of six verdicts that reads this tree — - // never ran. Advisory on a bare checkout, fatal under --require-stamp. - // - // Asserted on the REJECT side on purpose. "No error" proves nothing here: - // before the fix this fixture exited 0 with an `ℹ`, which is precisely a - // green that asserts nothing, so only a red and its branch-unique wording - // can tell the fixed script from the broken one. - battery('7b. THE SECOND VACUITY PATH (objectstack#10428): an unbuilt spec means the'); - { - const unbuilt = makeUnbuiltSpecPkg(path.join(root, 'spec-unbuilt')); - const dist = makeDist(path.join(root, 'unbuilt-tree'), `console(${JSON.stringify(FRESH)})`, stampFor()); - - expect('unbuilt spec is advisory by default', evaluate({ distDir: dist, specDir: unbuilt }).code, 0); - - const r = evaluate({ distDir: dist, specDir: unbuilt, requireStamp: true }); - expect('unbuilt spec is fatal under --require-stamp', r.code, 1); - const text = r.err.join('\n'); - checked += 1; - if (!text.includes('never re-checked for expiry')) { - failures.push('skipped-expiry failure must say the probe was never re-checked'); - } - checked += 1; - if (!text.includes('pnpm --filter @objectstack/spec build')) { - failures.push('skipped-expiry failure must name the build that clears it'); - } - - // POSITIVE CONTROL. The normal CI ordering — spec built before the check — - // must still pass under the same flag. Without this the case above would be - // satisfied by a script that fails --require-stamp unconditionally. - expect( - 'positive control: built spec still passes under --require-stamp', - evaluate({ distDir: dist, specDir, requireStamp: true }).code, - 0, - ); - - // PRECISION. The refusal is scoped to a probe that actually went unexamined, - // not to "the spec is unbuilt". A stamp recording no skew has no expiry - // question to skip, so an unbuilt tree is still a clean pass for it — - // otherwise this would fail runs over an assertion nobody was owed. - expect( - 'no-skew stamp does not fail on an unbuilt spec', - evaluate({ - distDir: makeDist(path.join(root, 'noskew-unbuilt'), 'console("anything")', stampFor({ skew: false, freshWitness: null, staleDetector: null })), - specDir: unbuilt, - requireStamp: true, - }).code, - 0, - ); - } - - // 7c. THE THIRD VACUITY PATH (objectstack#10595): a well-formed stamp whose - // `packages` array is EMPTY. readStamp's Array.isArray check accepts `[]`, - // and every substantive verdict is derived per entry, so the gate asserts - // nothing at all — strictly more vacuous than 7b, which still ran the two - // bundle assertions. - battery('7c. THE THIRD VACUITY PATH (objectstack#10595): a well-formed stamp whose'); - { - const empty = { stampVersion: 1, generatedBy: 'scripts/assert-console-spec-injection.mjs', packages: [] }; - const dist = makeDist(path.join(root, 'empty-stamp'), `console(${JSON.stringify(FRESH)})`, empty); - - expect('empty packages is advisory by default', evaluate({ distDir: dist, specDir }).code, 0); - - const r = evaluate({ distDir: dist, specDir, requireStamp: true, cacheKey: 'Linux-console-dist-cafe01' }); - expect('empty packages is fatal under --require-stamp', r.code, 1); - const text = r.err.join('\n'); - checked += 1; - // Branch-unique wording: every refusal prints remedy(), so keying on that - // cannot tell this branch from the no-stamp one it sits next to. - if (!text.includes('EMPTY `packages` array')) { - failures.push('empty-packages failure must name the empty packages array'); - } - checked += 1; - if (!text.includes('gh cache delete "Linux-console-dist-cafe01"')) { - failures.push('empty-packages failure must name the exact cache key to delete'); - } - - // THE REJECT SIDE, ASSERTED POSITIVELY. The point is not the exit code on a - // benign fixture — it is that an empty stamp silences the verdict this whole - // gate exists for. This dist carries the PUBLISHED spec (objectstack#8134's - // defect); before this fix it exited 0 under --require-stamp. - const poisoned = makeDist( - path.join(root, 'empty-stamp-poisoned'), - `console(${JSON.stringify(FRESH)});console(${JSON.stringify(STALE)})`, - empty, - ); - expect( - 'a PUBLISHED-spec bundle under an empty stamp no longer passes --require-stamp', - evaluate({ distDir: poisoned, specDir, requireStamp: true }).code, - 1, - ); - - // PRECISION, and the reason this keys on `packages.length` and not on the - // `asserted === 0` notice further down. A no-skew entry ALSO leaves - // `asserted` at 0 and reaches that same notice, but it is a positive record - // that the build looked and found nothing to tell the specs apart — an - // assertion nobody was owed, which objectstack#10428 deliberately kept - // passing. Keying on `asserted` would silently fail every no-skew run. - expect( - 'precision: a no-skew stamp still passes --require-stamp on a built spec', - evaluate({ - distDir: makeDist(path.join(root, 'noskew-required'), 'console("anything")', stampFor({ skew: false, freshWitness: null, staleDetector: null })), - specDir, - requireStamp: true, - }).code, - 0, - ); - } - - // 7d. The producer cannot emit that stamp in the first place. writeStamp is - // the one call site every producer passes, and the entries array is meant - // to GROW (objectstack#9659) — the day it is derived rather than literal, - // an empty result becomes producible. Refused at the write. - battery('7d. The producer cannot emit that stamp in the first place. writeStamp is'); - { - const dir = fs.mkdtempSync(path.join(root, 'writestamp-')); - checked += 1; - let threw = null; - try { - writeStamp(dir, []); - } catch (error) { - threw = error; - } - if (!(threw instanceof ProbeError)) { - failures.push(`writeStamp([]) must throw ProbeError, got ${threw === null ? 'no throw' : threw.constructor.name}`); - } - expect('writeStamp([]) writes no stamp at all', fs.existsSync(path.join(dir, STAMP_BASENAME)), false); - - // Positive control: the shape the real producer writes still goes through. - writeStamp(dir, [{ name: '@objectstack/spec', skew: true, freshWitness: FRESH, staleDetector: STALE }]); - expect('writeStamp writes a populated stamp', fs.existsSync(path.join(dir, STAMP_BASENAME)), true); - } - - // 8. A build that found no skew records it, and this gate says so honestly. - battery('8. A build that found no skew records it, and this gate says so honestly.'); - { - const dist = makeDist( - path.join(root, 'noskew'), - 'console("anything")', - stampFor({ skew: false, freshWitness: null, staleDetector: null }), - ); - const r = evaluate({ distDir: dist, specDir }); - expect('no-skew stamp passes', r.code, 0); - checked += 1; - if (!r.out.join('\n').includes('no observable skew')) failures.push('no-skew stamp must say so'); - } - - // 9/10. A stamp that cannot be trusted is inconclusive, never a quiet pass. - { - const bad = makeDist(path.join(root, 'malformed'), 'console("x")', '{not json'); - expect('malformed stamp is inconclusive', evaluate({ distDir: bad, specDir }).code, 2); - const old = makeDist(path.join(root, 'oldversion'), 'console("x")', { stampVersion: 0, packages: [] }); - expect('wrong stampVersion is inconclusive', evaluate({ distDir: old, specDir }).code, 2); - } - - // 11. Substring safety, the property the whole probe scheme rests on: a - // reworded description makes the OLD text a PREFIX of the new one, and a - // set-difference check would call it unique. pickProbe must not. - { - checked += 1; - const oldText = 'The label shown above the field in forms'; - const newText = `${oldText} and in the record detail header`; - const reworded = pickProbe(describeCandidates(`x.describe(${JSON.stringify(oldText)})`), `y.describe(${JSON.stringify(newText)})`); - if (reworded !== null) { - failures.push(`pickProbe returned a prefix of a reworded string as unique: ${JSON.stringify(reworded)}`); - } - checked += 1; - const genuine = pickProbe(describeCandidates(`x.describe(${JSON.stringify(FRESH)})`), `y.describe(${JSON.stringify(newText)})`); - if (genuine !== FRESH) failures.push('pickProbe failed to find a genuinely unique candidate'); - } - - // 12. ROUND TRIP against the real assert script: whatever it stamps, this gate - // must accept. This is the drift the shared module exists to prevent, and - // the only assertion here that proves the two halves still agree. - // objectui's build tree, which the assert script reads for objectui's own - // source (battery 14 says why). This one carries no spec text at all. - const plainHost = makeHostTree(path.join(root, 'host-plain'), { - 'packages/plugin/src/index.tsx': 'export const inputs = [{ name: "title", description: "Unrelated host text" }];\n', - }); - - battery('12. ROUND TRIP against the real assert script: whatever it stamps, this gate'); - { - const injected = makeSpecPkg(path.join(root, 'rt-injected'), [FRESH, 'Shared text in both specs for the round trip']); - const vendored = makeSpecPkg(path.join(root, 'rt-vendored'), [STALE, 'Shared text in both specs for the round trip']); - const dist = makeDist(path.join(root, 'rt-dist'), `console(${JSON.stringify(FRESH)})`, undefined); - const assert = path.join(ROOT, 'scripts', 'assert-console-spec-injection.mjs'); - const run = spawnSync( - process.execPath, - [assert, '--injected', injected, '--vendored', vendored, '--objectui', plainHost, '--assets', path.join(dist, 'assets')], - { encoding: 'utf8' }, - ); - expect('assert script passes on a good fixture', run.status, 0); - checked += 1; - if (!fs.existsSync(path.join(dist, STAMP_BASENAME))) { - failures.push('assert script did not write the injection stamp'); - } else { - expect('round trip: this gate accepts the stamp the assert script wrote', evaluate({ distDir: dist, specDir: injected }).code, 0); - } - } - - // 13. THE BLIND SPOT (objectstack#20646): the build-time derivation must choose - // its probes with the bundle in view. A spec package publishes entries a - // console never imports, and the alphabetically first unique description - // can sit in one of them — on the fresh side that read a WORKING injection - // as "neither spec appears", on the stale side it let a console built from - // the PUBLISHED spec pass. Each fixture below puts the first unique - // candidate where the bundle does not carry it, and runs the real assert - // script against it. - battery('13. THE BLIND SPOT (objectstack#20646): the build-time derivation must choose'); - { - const assert = path.join(ROOT, 'scripts', 'assert-console-spec-injection.mjs'); - const runAssert = (injected, vendored, dist) => - spawnSync( - process.execPath, - [assert, '--injected', injected, '--vendored', vendored, '--objectui', plainHost, '--assets', path.join(dist, 'assets')], - { encoding: 'utf8' }, - ); - const SHARED = 'Shared text in both specs for the blind-spot fixtures'; - // Both sort before FRESH and STALE, so the old first-candidate pick chose them. - const UNBUNDLED_FRESH = 'A description only an injected entry the console never imports carries'; - const UNBUNDLED_STALE = 'A description only a published entry the console never imports carries'; - - // Fresh side: the first injected-only candidate is not in the bundle, a later - // one is. The injection worked, so the check must pass on the one it carries. - const freshInjected = makeSpecPkg(path.join(root, 'bs-fresh-injected'), [UNBUNDLED_FRESH, FRESH, SHARED]); - const freshVendored = makeSpecPkg(path.join(root, 'bs-fresh-vendored'), [STALE, SHARED]); - const freshDist = makeDist(path.join(root, 'bs-fresh-dist'), `console(${JSON.stringify(FRESH)})`, undefined); - const fresh = runAssert(freshInjected, freshVendored, freshDist); - expect('a witness the bundle carries verifies, whatever sorts first', fresh.status, 0); - const freshStamp = fs.existsSync(path.join(freshDist, STAMP_BASENAME)) ? readStamp(freshDist) : null; - expect('the stamp records the witness the bundle carries', freshStamp?.packages?.[0]?.freshWitness, FRESH); - expect('and this gate replays that stamp green', evaluate({ distDir: freshDist, specDir: freshInjected }).code, 0); - - // Stale side: the first published-only candidate is not in the bundle, a - // later one IS. The console carries the published spec, so the check must - // fail — the old single pick read the absent one and passed. - const staleInjected = makeSpecPkg(path.join(root, 'bs-stale-injected'), [FRESH, SHARED]); - const staleVendored = makeSpecPkg(path.join(root, 'bs-stale-vendored'), [UNBUNDLED_STALE, STALE, SHARED]); - const staleDist = makeDist( - path.join(root, 'bs-stale-dist'), - `console(${JSON.stringify(FRESH)});console(${JSON.stringify(STALE)})`, - undefined, - ); - const stale = runAssert(staleInjected, staleVendored, staleDist); - expect('any published-only description in the bundle fails the build', stale.status, 1); - checked += 1; - if (!stale.stderr.includes('still carries the PUBLISHED') || !stale.stderr.includes(STALE)) { - failures.push('the stale-side failure must say the published spec is bundled and name the text it found'); - } - expect('a failing build writes no stamp', fs.existsSync(path.join(staleDist, STAMP_BASENAME)), false); - - // Neither: the bundle carries no unique text from either spec. Still exit 2 — - // choosing from the bundle must never turn "unverified" into a pass. - const neitherDist = makeDist(path.join(root, 'bs-neither-dist'), `console(${JSON.stringify(SHARED)})`, undefined); - const neither = runAssert(freshInjected, freshVendored, neitherDist); - expect('neither spec in the bundle stays inconclusive', neither.status, 2); - checked += 1; - if (!neither.stderr.includes('Neither spec appears')) { - failures.push('the neither-found verdict must still say that neither spec appears'); - } - } - - // 14. OBJECTUI'S OWN TEXT IS NOT EVIDENCE (objectstack#21709). The bundle - // carries objectui's code too, and objectui's registry `inputs` mirror spec - // descriptions — as a prefix it extends (object-gantt `markers`, which - // turned every merge-queue build red) or verbatim. Once the spec rewords - // one, the old text is published-only and objectui's literal still holds - // it, so a substring search reads objectui's own registry as "the published - // spec". Every fixture here runs the real assert script and replays what it - // stamped through evaluate(), so the two halves are held to one answer. - // - // The mirrored texts sort FIRST on purpose: an unfiltered pick would choose - // them, as the detector of a failing build or as the stamped detector of a - // passing one — and a stamped detector objectui's own code puts in every - // build would fail every cache-hit replay of a good dist. - battery("14. OBJECTUI'S OWN TEXT IS NOT EVIDENCE: a probe the console's own source carries"); - { - const assert = path.join(ROOT, 'scripts', 'assert-console-spec-injection.mjs'); - const runAssert = (injected, vendored, host, dist) => - spawnSync( - process.execPath, - [assert, '--injected', injected, '--vendored', vendored, '--objectui', host, '--assets', path.join(dist, 'assets')], - { encoding: 'utf8' }, - ); - const stampOf = (dist) => (fs.existsSync(path.join(dist, STAMP_BASENAME)) ? readStamp(dist) : null); - const SHARED = 'Shared text in both specs for the collision fixtures'; - // Published-only once the spec reworded them; objectui still writes both. - const MIRRORED = 'A marker line drawn like the Today marker, as the spec used to say'; - const MIRRORED_EXACT = "An exact registry description objectui's registry copied from the spec"; - const OBJECTUI_MARKERS = `${MIRRORED}. The renderer's own clause continues the sentence.`; - // objectui's source, as written — the apostrophes escaped inside single - // quotes, which is not how the bundler emits them — plus a TEST file quoting - // the published-only STALE text, the way objectui's parity tests do. - const quoteSingle = (text) => `'${text.replace(/'/g, "\\'")}'`; - const host = makeHostTree(path.join(root, 'cl-host'), { - 'packages/plugin-gantt/src/index.tsx': - `export const inputs = [\n` + - ` { name: 'markers', description: ${quoteSingle(OBJECTUI_MARKERS)} },\n` + - ` { name: 'criticalPath', description: ${quoteSingle(MIRRORED_EXACT)} },\n` + - `];\n`, - 'packages/plugin-gantt/src/__tests__/inputs-parity.test.ts': `expect(describe).toBe(${JSON.stringify(STALE)});\n`, - }); - // What a bundler emits for that registration. - const objectuiChunk = `register({inputs:[{name:"markers",description:${JSON.stringify(OBJECTUI_MARKERS)}},{name:"criticalPath",description:${JSON.stringify(MIRRORED_EXACT)}}]})`; - const injected = makeSpecPkg(path.join(root, 'cl-injected'), [FRESH, SHARED]); - const vendored = makeSpecPkg(path.join(root, 'cl-vendored'), [MIRRORED, MIRRORED_EXACT, STALE, SHARED]); - - // PIN 1 — this tree's spec plus objectui's literal beginning with (and one - // equal to) a published-only description PASSES, and stamps a detector - // objectui does not write. - const good = makeDist(path.join(root, 'cl-good'), `console(${JSON.stringify(FRESH)});${objectuiChunk}`, undefined); - const goodRun = runAssert(injected, vendored, host, good); - expect('pin 1: injected spec + objectui literals beginning with / equal to published-only text passes', goodRun.status, 0); - expect('pin 1: the pass says the host-carried text is not evidence', goodRun.stdout.includes('not evidence of either spec'), true); - const goodStamp = stampOf(good); - expect('pin 1: the passing build writes its stamp', goodStamp !== null, true); - expect('pin 1: the stamp records the FILTERED detector, never objectui\'s text', goodStamp?.packages?.[0]?.staleDetector, STALE); - expect('pin 1: the stamp records the witness the bundle carries', goodStamp?.packages?.[0]?.freshWitness, FRESH); - expect('pin 3: the replay agrees with the build on bundle 1', evaluate({ distDir: good, specDir: injected, requireStamp: true }).code, 0); - - // PIN 2 — a bundle carrying the PUBLISHED spec itself still FAILS, through a - // published-only description objectui does not write. STALE is quoted by - // objectui's TEST file only, so this case also pins that tests are not read - // as objectui's source: were they, STALE would be excused too and the - // verdict would be "cannot judge" (exit 2), not this exit 1. - const publishedJs = fs.readFileSync(path.join(vendored, 'dist', 'index.mjs'), 'utf8'); - const leaked = makeDist( - path.join(root, 'cl-leaked'), - `${publishedJs};console(${JSON.stringify(FRESH)});${objectuiChunk}`, - undefined, - ); - const leakedRun = runAssert(injected, vendored, host, leaked); - expect('pin 2: the published spec itself in the bundle still fails', leakedRun.status, 1); - expect('pin 2: the failure names a detector objectui does not write', leakedRun.stderr.includes(`"${STALE}"`), true); - expect('pin 2: objectui\'s mirrored text is never named as the detector', leakedRun.stderr.includes(`"${MIRRORED}"`), false); - expect('pin 2: a failing build writes no stamp', stampOf(leaked), null); - // The replay half: the stamp a good build wrote, beside these assets — a cache - // entry whose dist no longer matches its proof. (No stamp to copy is pin 1's - // failure, already registered above; the wording assertion below fails too.) - if (goodStamp) fs.copyFileSync(path.join(good, STAMP_BASENAME), path.join(leaked, STAMP_BASENAME)); - const leakedReplay = evaluate({ distDir: leaked, specDir: injected, requireStamp: true }); - expect('pin 3: the replay agrees with the build on bundle 2', leakedReplay.code, 1); - expect('pin 3: and says the dist carries the published spec', leakedReplay.err.join('\n').includes('carries the PUBLISHED'), true); - - // The leg with no probe left: the ONLY published-only description is in the - // bundle and objectui writes it. Not "no skew" and not "absent", so it is - // inconclusive, never a pass — the published spec would look exactly so. - const onlyMirrored = makeSpecPkg(path.join(root, 'cl-vendored-mirrored'), [MIRRORED, SHARED]); - const blind = makeDist(path.join(root, 'cl-blind'), `console(${JSON.stringify(FRESH)});${objectuiChunk}`, undefined); - const blindRun = runAssert(injected, onlyMirrored, host, blind); - expect('a stale leg whose every candidate is host-carried and bundled is inconclusive', blindRun.status, 2); - expect('and says the leg cannot judge the bundle', blindRun.stderr.includes('cannot judge this bundle'), true); - expect('and writes no stamp', stampOf(blind), null); - - // ABSENCE stays evidence: the same host-carried text, NOT in the bundle - // (objectui's chunk tree-shaken away), is absent from every source, so it is - // still a detector — this rule excuses presence, never absence. - const shaken = makeDist(path.join(root, 'cl-shaken'), `console(${JSON.stringify(FRESH)})`, undefined); - const shakenRun = runAssert(injected, onlyMirrored, host, shaken); - expect('host-carried text absent from the bundle still verifies as absent', shakenRun.status, 0); - expect('and is a legitimate stamped detector', stampOf(shaken)?.packages?.[0]?.staleDetector, MIRRORED); - expect('and the replay agrees', evaluate({ distDir: shaken, specDir: injected, requireStamp: true }).code, 0); - - // THE FRESH SIDE, same rule: an injected-only description objectui already - // writes is no witness. The bundle below holds objectui's literal and no spec - // at all — a pass before this rule, "neither spec appears" now. - const FRESH_MIRRORED = 'A key this tree declares in the words objectui already wrote'; - const freshHost = makeHostTree(path.join(root, 'cl-host-fresh'), { - 'packages/plugin/src/index.tsx': `export const d = ${quoteSingle(FRESH_MIRRORED)};\n`, - }); - const injectedMirror = makeSpecPkg(path.join(root, 'cl-injected-mirror'), [FRESH_MIRRORED, FRESH, SHARED]); - const vendoredPlain = makeSpecPkg(path.join(root, 'cl-vendored-plain'), [STALE, SHARED]); - const hollow = makeDist(path.join(root, 'cl-hollow'), `register(${JSON.stringify(FRESH_MIRRORED)})`, undefined); - const hollowRun = runAssert(injectedMirror, vendoredPlain, freshHost, hollow); - expect('a witness objectui writes itself does not prove the injection', hollowRun.status, 2); - expect('and the verdict is that neither spec appears', hollowRun.stderr.includes('Neither spec appears'), true); - - // CANNOT RUN: the objectui tree must be a checkout whose tracked files git - // can list — a directory walk would read node_modules (the published spec - // itself) as objectui's own source. - const notGit = fs.mkdtempSync(path.join(root, 'cl-not-git-')); - fs.writeFileSync(path.join(notGit, 'index.tsx'), `export const d = ${quoteSingle(MIRRORED)};\n`); - const notGitRun = runAssert(injected, vendored, notGit, makeDist(path.join(root, 'cl-not-git-dist'), `console(${JSON.stringify(FRESH)})`, undefined)); - expect('an objectui tree git cannot list is inconclusive', notGitRun.status, 2); - expect('and says it is not a git checkout', notGitRun.stderr.includes('not a git checkout'), true); - - // The shared module refuses to choose probes blind to objectui's source. - let blindChoice = null; - try { - chooseProbes({ injectedBlob: '', vendoredBlob: '', bundle: '' }); - } catch (error) { - blindChoice = error; - } - expect('chooseProbes without hostBlob throws a TypeError', blindChoice instanceof TypeError, true); - } - - // 15. A PIN CARRYING .objectstack-sha installs @objectstack/* by commit, and the - // build stands THIS tree in for it, so objectui's install is this tree: as - // the reference it equalled --injected, and the build-time check passed a - // bundle carrying the PINNED spec with no witness. The reference must be - // objectui's own build of that commit, known by the pin script's cache - // stamp (`/.stamp.json` beside `/spec`). - battery("15. A PIN CARRYING .objectstack-sha: the reference is objectui's own build of it"); - { - const assert = path.join(ROOT, 'scripts', 'assert-console-spec-injection.mjs'); - const runAssert = (vendored, dist) => - spawnSync( - process.execPath, - [assert, '--injected', injected, '--vendored', vendored, '--objectui', host, '--assets', path.join(dist, 'assets')], - { encoding: 'utf8' }, - ); - const stampOf = (dist) => (fs.existsSync(path.join(dist, STAMP_BASENAME)) ? readStamp(dist).packages[0] : null); - const PIN = 'a'.repeat(40); - const SHARED = 'Shared text in both specs for the pinned-reference fixtures'; - const PINNED_ONLY = 'A description the pinned commit carries and this tree has since reworded'; - const host = makeHostTree(path.join(root, 'pr-host'), { - '.objectstack-sha': `${PIN}\n`, - 'packages/plugin/src/index.tsx': 'export const d = "Unrelated host text";\n', - }); - const injected = makeSpecPkg(path.join(root, 'pr-injected'), [FRESH, SHARED]); - const cache = (dir, stamp, descriptions) => { - const spec = makeSpecPkg(path.join(dir, 'spec'), descriptions); - fs.writeFileSync(path.join(dir, '.stamp.json'), JSON.stringify(stamp)); - return spec; - }; - const pinnedBundle = `console(${JSON.stringify(PINNED_ONLY)});console(${JSON.stringify(SHARED)})`; - - // THE DEFECT, refused: no reference that is not objectui's own build of the pin. - const notThePin = { - 'the stand-in (this tree, packed)': cache(path.join(root, 'pr-standin'), { source: 'external', sha: 'b'.repeat(40) }, [FRESH, SHARED]), - "the install's own copy (no stamp)": makeSpecPkg(path.join(root, 'pr-install', '@objectstack', 'spec'), [FRESH, SHARED]), - 'a build of another commit': cache(path.join(root, 'pr-other'), { source: 'pin', sha: 'c'.repeat(40) }, [PINNED_ONLY, SHARED]), - }; - for (const [label, vendored] of Object.entries(notThePin)) { - const dist = makeDist(fs.mkdtempSync(path.join(root, 'pr-refused-')), pinnedBundle, undefined); - const run = runAssert(vendored, dist); - expect(`${label} as the reference is refused as cannot-judge`, run.status, 2); - expect(`${label}: the refusal names the pin`, run.stderr.includes(`pins objectstack ${PIN.slice(0, 12)}`), true); - expect(`${label}: no stamp is written`, stampOf(dist), null); - } - - // GREEN: this tree's bundle, judged against objectui's own build of the pin. - const reference = cache(path.join(root, 'pr-reference'), { source: 'pin', sha: PIN }, [PINNED_ONLY, SHARED]); - const good = makeDist(path.join(root, 'pr-good'), `console(${JSON.stringify(FRESH)});console(${JSON.stringify(SHARED)})`, undefined); - expect('this tree\'s bundle against the pinned build verifies', runAssert(reference, good).status, 0); - const goodStamp = stampOf(good); - expect('the stamp carries a real witness, not null', goodStamp?.freshWitness, FRESH); - expect('the stamp carries the pinned-only detector', goodStamp?.staleDetector, PINNED_ONLY); - expect('the stamp names the commit it was judged against', goodStamp?.vendoredPin, PIN); - - // RED: a bundle carrying the pinned spec instead fails the build, and the - // good stamp replayed over it (a cache entry its proof no longer matches). - const bad = makeDist(path.join(root, 'pr-bad'), pinnedBundle, undefined); - expect('a bundle carrying the pinned commit\'s spec fails the build', runAssert(reference, bad).status, 1); - if (goodStamp) fs.copyFileSync(path.join(good, STAMP_BASENAME), path.join(bad, STAMP_BASENAME)); - expect('and the replay fails it too', evaluate({ distDir: bad, specDir: injected, requireStamp: true }).code, 1); - - // HONEST NO SKEW: the pinned commit declares this tree's descriptions, so a - // bundle carrying either carries the same text. The stamp says against what. - const same = cache(path.join(root, 'pr-same'), { source: 'pin', sha: PIN }, [FRESH, SHARED]); - const sameDist = makeDist(path.join(root, 'pr-same-dist'), `console(${JSON.stringify(FRESH)})`, undefined); - expect('a pinned build declaring the same descriptions passes as no skew', runAssert(same, sameDist).status, 0); - const sameReplay = evaluate({ distDir: sameDist, specDir: injected, requireStamp: true }); - expect('and its replay names the commit it was judged against', sameReplay.out.join('\n').includes(`objectstack ${PIN.slice(0, 12)}`), true); - } - - // 16. EVERY DISTINGUISHING LITERAL IS LONG OR ESCAPED (objectstack#24663). The - // shape of the 12dc7b47 pin: the reference and this tree differ only in a - // describe literal over 160 characters whose em dash the dist writes as a - // `—` escape. The whole-literal rule skipped it, both pools came out - // empty, the build stamped `freshWitness: null`, and the assertion passed - // the control bundle. The runs between escapes are the candidates now. - battery('16. EVERY DISTINGUISHING LITERAL IS LONG OR ESCAPED (objectstack#24663).'); - { - const assert = path.join(ROOT, 'scripts', 'assert-console-spec-injection.mjs'); - const PIN = 'd'.repeat(40); - const host = makeHostTree(path.join(root, 'le-host'), { - '.objectstack-sha': `${PIN}\n`, - 'packages/plugin/src/index.tsx': 'export const d = "Unrelated host text";\n', - }); - const runAssert = (injected, vendored, dist) => - spawnSync( - process.execPath, - [assert, '--injected', injected, '--vendored', vendored, '--objectui', host, '--assets', path.join(dist, 'assets')], - { encoding: 'utf8' }, - ); - const stampOf = (dist) => (fs.existsSync(path.join(dist, STAMP_BASENAME)) ? readStamp(dist).packages[0] : null); - // A spec dist written as a bundler writes it: each description a JS string - // literal, `—` spelled as the six-character escape. - const writeDist = (dir, literals) => { - makeSpecPkg(dir, []); - fs.writeFileSync(path.join(dir, 'dist', 'index.mjs'), `${literals.map((l) => `z.string().describe("${l}")`).join(';\n')}\n`); - return dir; - }; - const ESC = '\\u2014'; - const HEAD = 'Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user'; - const FRESH_RUN = ' system flows keep system privileges; after a pause the run resumes as the trigger user until a human decision'; - const STALE_RUN = ' the run keeps that identity across every pause and resume of a long-running flow, whatever its trigger'; - const LONG_SHARED = - 'A shared description over one hundred and sixty characters long that both specs carry byte for byte, so no run cut out of it can tell them apart in either direction'; - const SHARED = 'Shared text in both specs for the long-literal fixtures'; - const injectedLiteral = `${HEAD} ${ESC}${FRESH_RUN}`; - const pinnedLiteral = `${HEAD} ${ESC}${STALE_RUN}`; - const injected = writeDist(path.join(root, 'le-injected'), [injectedLiteral, LONG_SHARED, SHARED]); - const reference = path.join(root, 'le-cache', 'spec'); - writeDist(reference, [pinnedLiteral, LONG_SHARED, SHARED]); - fs.writeFileSync(path.join(root, 'le-cache', '.stamp.json'), JSON.stringify({ source: 'pin', sha: PIN })); - - // The fixture has the pin's shape: no distinguishing literal the whole-literal - // rule accepts — each is over 160 characters AND carries an escape. - expect('the injected literal is over 160 characters', injectedLiteral.length > 160, true); - expect('the pinned literal is over 160 characters', pinnedLiteral.length > 160, true); - expect('the long shared literal has no escape and is over 160 characters', LONG_SHARED.length > 160 && !LONG_SHARED.includes('\\'), true); - - // The candidate source itself: runs between escapes, never the whole literal. - const injectedBlob = readSpecBlob(injected, 'injected'); - const pinnedBlob = readSpecBlob(reference, 'vendored'); - const candidates = describeCandidates(injectedBlob); - expect('the run after the escape is a candidate', candidates.includes(FRESH_RUN), true); - expect('the escaped literal is never a candidate whole', candidates.includes(injectedLiteral), false); - expect('a 32-to-160 plain literal is still taken whole', candidates.includes(SHARED), true); - expect('the unchanged run before the escape is not unique', uniqueCandidates(candidates, pinnedBlob).includes(HEAD), false); - expect('a long literal both specs carry yields nothing unique', uniqueCandidates(candidates, pinnedBlob).includes(LONG_SHARED), false); - expect( - 'a quote cuts a run, since a bundler may re-quote it', - describeCandidates(`x.describe("${'q'.repeat(40)}\\"${'r'.repeat(40)}${ESC}")`).join('|'), - `${'q'.repeat(40)}|${'r'.repeat(40)}`, - ); - - // GREEN: this tree's bundle, where the bundler emitted the dash raw. - const good = makeDist( - path.join(root, 'le-good'), - `console(${JSON.stringify(`${HEAD} —${FRESH_RUN}`)});console(${JSON.stringify(SHARED)})`, - undefined, - ); - const goodRun = runAssert(injected, reference, good); - expect('this tree\'s bundle against the pinned build verifies', goodRun.status, 0); - const goodStamp = stampOf(good); - expect('the stamp carries a non-null witness: the changed run', goodStamp?.freshWitness, FRESH_RUN); - expect('the stamp carries the pinned run as its detector', goodStamp?.staleDetector, STALE_RUN); - expect('and records skew', goodStamp?.skew, true); - - // RED: the control bundle — objectui's own build of the pin, escape kept. - const control = makeDist( - path.join(root, 'le-control'), - `console("${pinnedLiteral}");console(${JSON.stringify(SHARED)})`, - undefined, - ); - const controlRun = runAssert(injected, reference, control); - expect('the control bundle fails the build', controlRun.status, 1); - expect('the failure names the pinned run it found', controlRun.stderr.includes(STALE_RUN), true); - if (goodStamp) fs.copyFileSync(path.join(good, STAMP_BASENAME), path.join(control, STAMP_BASENAME)); - expect('and the good stamp replayed over it fails too', evaluate({ distDir: control, specDir: injected, requireStamp: true }).code, 1); - } - - fs.rmSync(root, { recursive: true, force: true }); - - // ── The floor: every declared battery RAN, and ran its cases (#13489) ─── - // - // Evaluated after every battery has had its chance and BEFORE the verdict, so - // the success line below can only be printed by a run in which the set of - // batteries that registered assertions EQUALS the set declared. A set - // difference names WHICH battery stopped; a count says only that something did. - const floorFailure = (message) => { - failures.push(message); - }; - const declaredBatteries = Object.keys(SELF_TEST_BATTERIES); - let floorBreached = false; - if (declaredBatteries.length < SELF_TEST_BATTERY_FLOOR) { - floorBreached = true; - floorFailure( - `SELF_TEST_BATTERIES declares ${declaredBatteries.length} batteries, below the pinned ` + - `${SELF_TEST_BATTERY_FLOOR} — a battery deleted from the roster takes its own floor with it.`, - ); - } - for (const [name, count] of batterySeen) { - if (declaredBatteries.includes(name)) continue; - floorBreached = true; - floorFailure( - `self-test battery "${name}" registered ${count} case(s) but is not declared in ` + - 'SELF_TEST_BATTERIES — an assertion attributed to no declared battery is one nothing floors.', - ); - } - for (const name of declaredBatteries) { - const count = batterySeen.get(name) ?? 0; - if (count >= SELF_TEST_BATTERIES[name]) continue; - floorBreached = true; - floorFailure( - count === 0 - ? `self-test battery "${name}" DID NOT RUN — 0 cases registered, ${SELF_TEST_BATTERIES[name]} pinned. ` + - 'The verdict below would have claimed those cases hold.' - : `self-test battery "${name}" registered ${count} case(s), below its pinned floor of ` + - `${SELF_TEST_BATTERIES[name]} — cases that used to run no longer do.`, - ); - } - if (floorBreached) { - floorFailure( - 'A battery at or below its floor means cases STOPPED RUNNING — the battery is the bug, not the ' + - 'number. Find what stopped registering (an early return, a deleted block, a guard that now ' + - 'skips) and restore it.', - ); - } - - if (failures.length > 0) { - console.error(`✗ check-console-injection --self-test -- ${failures.length} failure(s)\n`); - for (const f of failures) console.error(` ${f}`); - process.exit(1); - } - console.log(`✓ check-console-injection --self-test: ${checked} assertions over real fixture trees (real evaluate() path)`); - - return SELF_TEST_VERDICT; -} - -// ── entry point ────────────────────────────────────────────────────────────── - -// Guarded so `evaluate` is genuinely importable. Without this, `import`ing this -// module RUNS the CLI — it would evaluate the default dist path and call -// process.exit() before the importer's first line, which is what the assert -// script's top-level-argv shape does and the reason probe derivation was moved -// into a side-effect-free module. Measured here: the first attempt to drive -// evaluate() from a script exited 0 with "no console dist" and never reached the -// caller's code. -const invokedDirectly = - isEntrypoint(import.meta.url); - -if (!invokedDirectly) { - // imported as a module — expose evaluate() and do nothing else -} else if (process.argv.includes('--self-test')) { - if (selfTest() !== SELF_TEST_VERDICT) { - console.error( - '\n✗ check-console-injection self-test: selfTest() returned without reaching its verdict,\n' - + 'so no success line was printed. Exiting 0 here would report a self-test\n' - + 'that never finished as a self-test that passed.\n', - ); - process.exit(1); - } -} else { - const argOf = (flag, fallback) => { - const i = process.argv.indexOf(flag); - return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : fallback; - }; - const result = evaluate({ - distDir: path.resolve(argOf('--dist', path.join(ROOT, 'packages', 'console', 'dist'))), - specDir: path.resolve(argOf('--spec', path.join(ROOT, 'packages', 'spec'))), - requireStamp: process.argv.includes('--require-stamp'), - cacheKey: process.env.CONSOLE_DIST_CACHE_KEY || '', - }); - for (const line of result.out) console.log(line); - if (result.err.length > 0) console.error(`\n${result.err.join('\n')}\n`); - process.exit(result.code); -} diff --git a/scripts/check-console-sha.mjs b/scripts/check-console-sha.mjs deleted file mode 100644 index f5ae11c289e..00000000000 --- a/scripts/check-console-sha.mjs +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * check:console-sha — guard against @objectstack/console version drift. - * - * The Console SPA in packages/console/dist is a gitignored, locally-built - * artifact. It is (re)built ONLY by scripts/build-console.sh — via - * `pnpm objectui:build` / `objectui:refresh` / `release` / CI — and NOT by - * `turbo run build`. So pulling a branch that bumps the committed - * `.objectui-sha` pin updates the pin while leaving a stale dist in place, - * silently serving a console built from a different objectui commit. - * - * build-console.sh stamps the SHA it built from into - * packages/console/dist/.objectui-sha. This script compares that stamp - * against the pin and fails loudly on drift so the fix is obvious. - * - * Remediation is `pnpm objectui:build` (rebuild at the *pinned* SHA), NOT - * `objectui:refresh` — after a pull the pin is already correct; refresh - * would re-bump it to your local ../objectui HEAD. - * - * Exit codes: - * 0 in sync; or no dist built yet (nothing to serve — CLI degrades on its - * own); or dist present but unstamped (unverifiable → warns, non-fatal) - * 1 drift: dist was built from a different objectui SHA than the pin - */ -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const PIN_FILE = path.join(ROOT, '.objectui-sha'); -const DIST = path.join(ROOT, 'packages', 'console', 'dist'); -const STAMP_FILE = path.join(DIST, '.objectui-sha'); -const INDEX = path.join(DIST, 'index.html'); - -const read = (p) => fs.readFileSync(p, 'utf-8').trim(); -const short = (s) => (s ? s.slice(0, 12) : ''); -const rel = (p) => path.relative(ROOT, p); - -if (!fs.existsSync(PIN_FILE)) { - console.error(`✗ ${rel(PIN_FILE)} is missing — cannot determine the pinned objectui commit.`); - process.exit(1); -} -const pin = read(PIN_FILE); - -// No console built yet: not a drift condition. The CLI already degrades -// gracefully (serves the API, warns the dist is absent), and package-only / -// CI builds legitimately have no dist. Nothing to verify. -if (!fs.existsSync(INDEX)) { - console.log(`ℹ No console dist at ${rel(DIST)} — skipping SHA check. Build it with: pnpm objectui:build`); - process.exit(0); -} - -// Dist present but unstamped: built by an older build-console.sh (pre-guard) -// or assembled by hand. Can't prove drift, but can't prove freshness either. -if (!fs.existsSync(STAMP_FILE)) { - console.warn( - `⚠ Console dist has no objectui-SHA stamp — cannot verify it matches the pin (objectui@${short(pin)}).\n` + - ` Rebuild once to enable the drift guard: pnpm objectui:build`, - ); - process.exit(0); -} - -const stamp = read(STAMP_FILE); -if (stamp === pin) { - console.log(`✓ Console dist matches the objectui pin (objectui@${short(pin)}).`); - process.exit(0); -} - -console.error( - `\n✗ Console version drift detected.\n\n` + - ` pinned (.objectui-sha): objectui@${short(pin)}\n` + - ` built (console/dist/.objectui-sha): objectui@${short(stamp)}\n\n` + - ` packages/console/dist is a gitignored local artifact that 'turbo run build' does NOT rebuild.\n` + - ` The objectui pin moved ahead of your locally-built console, so a stale Console SPA would be served.\n\n` + - ` Rebuild the console at the pinned SHA:\n\n` + - ` pnpm objectui:build\n\n` + - ` (Use 'pnpm objectui:refresh' only when you intend to move the pin to your local ../objectui HEAD.)\n`, -); -process.exit(1); diff --git a/scripts/check-prerelease-pin-watch.mjs b/scripts/check-prerelease-pin-watch.mjs deleted file mode 100644 index 1ed369ce7c2..00000000000 --- a/scripts/check-prerelease-pin-watch.mjs +++ /dev/null @@ -1,1127 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -// -// check:prerelease-pins — has upstream shipped a STABLE release that retires one -// of our prerelease `overrides` pins? (#5024) -// -// node scripts/check-prerelease-pin-watch.mjs # the probe (needs network) -// node scripts/check-prerelease-pin-watch.mjs --verbose # one line per watched pin -// node scripts/check-prerelease-pin-watch.mjs --json -// node scripts/check-prerelease-pin-watch.mjs --strict # an unreadable registry exits 1 -// node scripts/check-prerelease-pin-watch.mjs --self-test # offline; proves all three states -// -// WHY THIS EXISTS (#5024) -// ---------------------- -// `pnpm-workspace.yaml` pins the whole better-auth family to `1.7.0-rc.2` -// (scim deliberately one rc behind, at `1.7.0-rc.1`), and the comment above -// those pins promises: -// -// revert to a stable `^1.7.x` line the moment one ships. -// -// Two issues were gated on exactly that event — #3002 (revert the family to a -// stable `^1.7.x`, done: the family is on `^1.7.1` and only `@better-auth/scim` -// is still pinned to a prerelease) and #3653 (the SCIM migration, which #3653 -// explicitly defers until `@better-auth/scim` ships a non-rc release — it has, -// and it ships the rc.2 rewrite, so #3653 is now the live card). Neither had a -// PRODUCER: -// nothing anywhere watched npm, so redeeming the promise depended on somebody -// remembering to look. That is the repo's standard `declared != enforced` shape, -// applied to a promise in a comment — and every extra day on a prerelease is -// another day the auth surface runs on a version carrying no stability -// commitment. -// -// This is the producer. It is a WATCHER, not a fixer: it never edits a pin. Its -// entire job is to make the trigger condition of those cards arrive as an -// automatic signal within a day of the release, instead of as a memory. -// -// THE WATCH LIST IS DERIVED, NOT DECLARED -// --------------------------------------- -// The packages watched are not a hardcoded list — they are read out of -// `pnpm-workspace.yaml` `overrides`: every pin whose TARGET is a prerelease -// version. So the probe polices the very declaration it is derived from, and it -// cannot drift from it: -// -// * a new prerelease pin is watched the day it lands, with no edit here; -// * a pin reverted to a stable range leaves the watch list by itself; -// * when the last prerelease pin is gone the probe reports "nothing to watch" -// and exits 0 — it retires itself rather than needing to be deleted. -// -// A hardcoded list of three package names would have gone stale against the -// eleven pins that are actually in the file, in whichever direction the file -// moved first. -// -// THE CRITERION IS SEMVER, NOT THE `latest` TAG -// --------------------------------------------- -// A version triggers when it has NO prerelease segment and is at or above the -// pinned target's base version — NOT when npm's `latest` tag moves. The two are -// genuinely different facts today: as of 2026-08-05 better-auth's `latest` is -// `1.6.26` (the old line) while `rc` is `1.7.0-rc.4`, so upstream can publish a -// stable `1.7.0` and leave `latest` on 1.6 for days. A `latest`-watching probe -// would sleep straight through the event it exists to catch. `dist-tags` are -// printed in the report as context and never consulted for the verdict. -// -// Two shapes of hit are reported apart, because the remedy differs: -// -// in-line a stable release inside the pinned line (`1.7.x`) — the exact -// trigger those cards wrote down. Mechanical: move the pin. -// later a stable release only in a HIGHER line (`1.8.0`+) while the -// pinned line never stabilized. Also gets us off the prerelease, -// but crossing a minor is a decision, not a bump. Reported as its -// own case so it cannot hide: without it, upstream stabilizing -// 1.8 and abandoning 1.7 would leave this probe silent forever — -// #5024's defect one line over. -// -// NETWORK FAILURE IS NOT A GREEN — AND NOT A RED EITHER (the trade-off) -// --------------------------------------------------------------------- -// Three verdicts, three exit codes, because "npm was unreachable" and "no -// stable release yet" are different facts and a probe that blurs them is the -// thing it was built to replace: -// -// 0 WAITING every watched pin was read; no stable release yet. Quiet. -// 1 AVAILABLE a stable release exists. Loud; names the follow-up card. -// 2 UNKNOWN at least one registry read failed; no hit among the rest. -// -// Exit 2 is deliberately NOT exit 1. Unlike a blocking release gate that reads a -// remote, where an unreadable remote must block because the alternative is -// shipping the thing the gate exists to catch (`check:objectui-pin-fresh` was -// this repo's example until #10134 deleted it) — this is an unattended nightly, -// and its failure mode is asymmetric: a transient npm 5xx that turns the nightly -// red teaches everyone to skim it, and a nightly nobody reads is precisely the -// "no producer" state #5024 is about. Missing one night costs at most a day, -// because the next run re-probes from scratch and the event it watches for is -// permanent — a published version is never unpublished back into silence. -// So the workflow surfaces exit 2 as a `::warning::` plus a step-summary line -// (visible, non-blocking) and keeps red meaning exactly one thing: a stable -// release is out. `--strict` promotes UNKNOWN to exit 1 for whoever needs the -// harder line (e.g. if the registry becomes persistently unreadable and the -// warnings are being missed) — the knob exists so escalating is a flag, not a -// rewrite. -// -// (Behind an HTTPS proxy, node's `fetch` ignores HTTPS_PROXY unless node runs -// with NODE_USE_ENV_PROXY=1. GitHub Actions needs no such thing.) -// -// WHERE IT RUNS -// ------------- -// `.github/workflows/prerelease-pin-watch.yml`, nightly at 06:00 UTC, plus -// `--self-test` on any PR touching the probe, the workflow, or the pins -// ("a change to the guard runs the guard"). It is NOT wired into `lint.yml`: -// the probe needs the network, and no PR gate in this repo may depend on a -// third-party registry being up. -import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'; -import { spawnSync } from 'node:child_process'; -import { tmpdir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { isEntrypoint } from './invoked-as.mjs'; - -// ── The self-test's own battery roster and floor (#13489) ────────────────── -// -// `failures.length === 0` used to be this self-test's ONLY success condition, so -// "every case held" and "the cases never ran" printed the same line. Closed the -// way PR #13487 validated on check-doc-authoring: what is pinned is the -// registered NAMES, not a number. Every section opens with `battery('')`, -// every assertion is attributed to the battery most recently opened, and the -// floor requires the OPENED set to equal the DECLARED set with each battery at -// or above its own count. -// -// ⛔ A pinned TOTAL is not the repair: a battery dropping from 9 cases to 3 -// keeps a total "right" the moment a sibling grows. -// -// The counts are a FLOOR, not an equality — adding cases is ordinary work and -// must not red. A battery BELOW its floor means cases stopped running; the -// remedy is to find what stopped registering. -const SELF_TEST_BATTERIES = Object.freeze({ - '1. the watch list is derived from the pins': 6, - '1b. the ledger rule: a note points at the pin, it does not restate it': 5, - '2. the three registry states': 7, - '3. the criterion is semver, NOT the `latest` tag': 3, - '4. a stable release only in a LATER line is its own case': 3, - '5. the reports say what they must': 8, - '6. a hit alongside an unreadable pin stays a hit, and says it may be': 2, - '7. end-to-end through the real CLI, exit codes included': 16, -}); - -// DELETING an entry silences that battery's floor exactly as effectively as -// zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 8; - -// The key an assertion is filed under when no battery is open. It is not a -// declared battery, so it reds by the same set difference rather than silently -// inflating whichever battery happened to run last. -const UNATTRIBUTED_BATTERY = '(no battery open)'; - -// This gate's whole population is ONE repo-root file, and the derivation -// already reaches it — through the trigger key, not through a hint. Read from -// the source rather than assumed: `prerelease-pin-watch.yml` declares -// pnpm-workspace.yaml in its `paths:` filter, so a card that edits the pins -// gets this gate named with `CI trigger in prerelease-pin-watch.yml` as its -// provenance. What it carries no hint for is a bare repo-root FILENAME, which -// `hintCovers` refuses as too generic; the subtree-spelling escape would be a -// second, weaker route to a lead the workflow already delivers, and a second -// spelling of one population is what drifts. Nothing else in the tree moves -// this gate: it reads the `overrides` block and npm, and nothing more. -// -// dispatch-gates: no-path-population -- population is pnpm-workspace.yaml alone, and prerelease-pin-watch.yml already names it in `paths:` — the CI-trigger key reaches it, so a hint would be a second spelling of one population -const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const DEFAULT_WORKSPACE = join(REPO_ROOT, 'pnpm-workspace.yaml'); -const DEFAULT_REGISTRY = 'https://registry.npmjs.org'; -const USER_AGENT = 'objectstack-check-prerelease-pin-watch'; - -/** A registry read failed. Never a green verdict, never a red one — see the header. */ -export class RegistryUnreadable extends Error {} - -// --------------------------------------------------------------------------- -// The follow-up ledger — what a hit must TELL you to do. -// -// A probe that only says "a stable release exists" hands its reader a research -// task at the exact moment they have the least context (a nightly, out of -// band). Each watched package therefore declares the issues carrying its -// action list, so the signal arrives pointing at the work. -// -// Unmapped is legal and is itself reported: a prerelease pin with no recorded -// revert plan is the #5024 shape again, and saying so is cheaper than -// discovering it later. -// -// THE RULE FOR A NOTE: carry the ACTION, point at the PIN for the MEASUREMENT. -// ----------------------------------------------------------------------------- -// A note's job is to tell its reader what to DO. It must not restate a fact -// that is measured and written down somewhere else -- above all not a COUNT. -// The SCIM model set is measured in the `@better-auth/scim` block of -// `pnpm-workspace.yaml`; this note used to carry its own typed copy of that -// measurement ("all six new models present"), which is a second copy with -// nothing holding it to the first. -// -// It rotted exactly the way a second copy rots. The measurement was re-taken -// and corrected in the pin comment (#11372, #11764) and the copy here went on -// printing the old number on every nightly run -- to the single reader this -// probe exists to inform, at the moment they have the least context. #11761 -// deleted the copy rather than correcting it a second time: a correction would -// have left a third copy waiting to rot. -// -// Deriving the number here instead was considered and measured as impractical: -// the count lives in a `#` comment, which `readOverrides` skips by design, and -// that block states three different counts (the core set, what -// `managedConnections` adds, and the total). A regex over wrapped English prose -// would have to GUESS which one a note meant -- a tolerant reader of an -// unspecified producer, which is the shape this repo removes rather than adds. -// So the note points, and the pin comment stays the single source. -// -// `restatedModelCounts()` mechanizes the COUNT half of this rule -- the half -// that has demonstrably rotted -- and the self-test proves it can still fail. -// --------------------------------------------------------------------------- - -/** @type {Array<{ match: RegExp, issues: string[], note: string }>} */ -const FOLLOW_UPS = [ - { - // Held one prerelease BEHIND the rest of the family on purpose, and its - // stable release triggers a migration rather than a bump — rc.2 replaced the - // whole SCIM model set, so #3653 defers the work to the stable release. - match: /^@better-auth\/scim$/, - issues: ['#3653'], - note: - 'SCIM is a MIGRATION, not a bump (#3653): rc.2 replaced the model set and moved ' + - 'connections from runtime rows to boot config, and the STABLE 1.7 releases ship that ' + - 'same rewrite. That rewrite is measured against the published tarball and written ' + - 'down ONCE, in the `@better-auth/scim` block of pnpm-workspace.yaml — read it there ' + - 'for which models the stable line ships, what it dropped, and what `managedConnections` ' + - 'adds. Do the migration against the stable models — do not "align" this pin with the ' + - 'family first. #3002 moved the REST of the family to stable ^1.7.1 and left this pin ' + - 'behind deliberately, so #3653 is the only card left.', - }, - { - match: /^(better-auth|@better-auth\/.+)$/, - issues: ['#3653'], - note: - 'The family moves together (mixing a 1.7 plugin with 1.6 core throws during init and ' + - "500s every auth endpoint). plugin-auth's own declarations must move in the SAME PR — " + - '`check:override-consistency` holds them to it. The family is on stable `^1.7.1` since ' + - '#3002, so a family member showing up here again means a NEW prerelease pin was added; ' + - 'read the pin\'s own comment in pnpm-workspace.yaml for why, and file a card for it.', - }, -]; - -/** @returns {{ issues: string[], note: string } | null} */ -export function followUpFor(pkg) { - return FOLLOW_UPS.find((f) => f.match.test(pkg)) ?? null; -} - -/** - * The ledger rule above, mechanized for the half that rots: a note may not - * state a COUNT of upstream models. Version numbers and issue refs are stripped - * first — `1.7.1` and `#3653` are identifiers, not counts of anything, and a - * guard that reddens on them would be turned off rather than obeyed. - * - * @returns {string[]} every offending fragment; empty means the note obeys. - */ -export function restatedModelCounts(note) { - const prose = String(note) - .replace(/#\d+/g, '') - .replace(/\brc\.\d+\b/gi, '') - .replace(/\b\d+(?:\.\d+)+(?:-[0-9A-Za-z.]+)?\b/g, ''); - const CARDINAL = - '(?:\\d+|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)'; - const re = new RegExp( - `\\b${CARDINAL}\\b[^.]{0,40}?\\bmodels?\\b|\\bmodels?\\b[^.]{0,40}?\\b${CARDINAL}\\b`, - 'gi', - ); - return prose.match(re) ?? []; -} - -// --------------------------------------------------------------------------- -// Reading the pins -// --------------------------------------------------------------------------- - -/** - * Minimal `overrides:` reader — the same block-scanning approach as - * `check-override-consistency.mjs` (this file only ever uses `key: value` - * scalars, so a YAML dependency is avoided and the probe stays runnable with - * no `pnpm install`). - * - * @returns {Array<{ name: string, selector: string | null, target: string }>} - */ -export function readOverrides(text) { - const overrides = []; - let inOverrides = false; - for (const raw of text.split(/\r?\n/)) { - const line = raw.replace(/\s+$/, ''); - if (!line.trim() || /^\s*#/.test(line)) continue; - if (/^overrides\s*:\s*$/.test(line)) { - inOverrides = true; - continue; - } - if (!inOverrides) continue; - if (/^\S/.test(line)) { - inOverrides = false; - continue; - } - const m = /^\s+["']?([^"':]+?)["']?\s*:\s*["']?([^"'#]+?)["']?\s*(#.*)?$/.exec(line); - if (!m) continue; - const key = m[1].trim(); - const target = m[2].trim(); - // Split `pkg@selector` on the LAST `@` so scoped names survive. - const at = key.lastIndexOf('@'); - if (at > 0) overrides.push({ name: key.slice(0, at), selector: key.slice(at + 1), target }); - else overrides.push({ name: key, selector: null, target }); - } - return overrides; -} - -// --------------------------------------------------------------------------- -// Semver, the two questions this probe actually asks -// --------------------------------------------------------------------------- - -const VERSION_RE = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/; - -/** - * @returns {{ major: number, minor: number, patch: number, prerelease: string | null } | null} - * `null` when the string is not a single exact version (a RANGE like `^8.5.10` - * or `>=0.28.1` is not a prerelease pin and must not be watched). - */ -export function parseVersion(v) { - const m = VERSION_RE.exec(String(v).trim()); - if (!m) return null; - return { - major: Number(m[1]), - minor: Number(m[2]), - patch: Number(m[3]), - prerelease: m[4] ?? null, - }; -} - -/** Numeric release-triple comparison. Prerelease segments are NOT ordered here: the - * probe only ever asks "is this stable, and is it at or above the base", and the - * base is always a `.0`-patch release triple. */ -function compareRelease(a, b) { - return a.major - b.major || a.minor - b.minor || a.patch - b.patch; -} - -/** - * The pins worth watching: every override whose TARGET is an exact prerelease - * version. Everything else — stable exact pins, ranges, selectors — is not a - * promise waiting to be redeemed. - * - * @returns {Array<{ name: string, target: string, base: string, line: string, followUp: object|null }>} - */ -export function buildWatchList(overrides) { - const seen = new Map(); - for (const o of overrides) { - const v = parseVersion(o.target); - if (!v || !v.prerelease) continue; - // The base release the prerelease is a candidate FOR: `1.7.0-rc.2` -> `1.7.0`. - const base = `${v.major}.${v.minor}.${v.patch}`; - const entry = { - name: o.name, - target: o.target, - base, - line: `${v.major}.${v.minor}`, - followUp: followUpFor(o.name), - }; - // The same package can be pinned by more than one selector; the LOWEST base - // is the one whose promise is outstanding first. - const prev = seen.get(o.name); - if (!prev || compareRelease(parseVersion(base), parseVersion(prev.base)) < 0) { - seen.set(o.name, entry); - } - } - return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name)); -} - -// --------------------------------------------------------------------------- -// Reading the registry -// --------------------------------------------------------------------------- - -/** - * The abbreviated packument (`application/vnd.npm.install-v1+json`) — orders of - * magnitude smaller than the full document and it still carries every published - * version key plus `dist-tags`. - */ -export async function readPackument(name, { registry = DEFAULT_REGISTRY } = {}) { - const url = `${registry.replace(/\/$/, '')}/${name.replace('/', '%2F')}`; - let res; - try { - res = await fetch(url, { - headers: { - Accept: 'application/vnd.npm.install-v1+json', - 'User-Agent': USER_AGENT, - }, - }); - } catch (err) { - throw new RegistryUnreadable(`GET ${url} — ${err.message}`); - } - if (!res.ok) throw new RegistryUnreadable(`GET ${url} → HTTP ${res.status}`); - let body; - try { - body = await res.json(); - } catch (err) { - throw new RegistryUnreadable(`GET ${url} → response was not JSON (${err.message})`); - } - if (!body || typeof body !== 'object' || typeof body.versions !== 'object') { - throw new RegistryUnreadable(`GET ${url} → no 'versions' map in the response`); - } - return { versions: Object.keys(body.versions), distTags: body['dist-tags'] ?? {} }; -} - -// --------------------------------------------------------------------------- -// The verdict -// --------------------------------------------------------------------------- - -/** - * Judge one watched pin against what the registry published. - * - * @param {{ name: string, base: string, line: string }} entry - * @param {{ versions: string[], distTags: object } | { error: string }} read - */ -export function judge(entry, read) { - if (read && read.error) { - return { ...entry, verdict: 'unknown', error: read.error, stableInLine: [], stableLater: [] }; - } - const base = parseVersion(entry.base); - const stableInLine = []; - const stableLater = []; - for (const raw of read.versions) { - const v = parseVersion(raw); - if (!v || v.prerelease) continue; // prerelease -> not the trigger, by definition - if (compareRelease(v, base) < 0) continue; // below the pin: reverting there undoes the fix - if (v.major === base.major && v.minor === base.minor) stableInLine.push(raw); - else stableLater.push(raw); - } - const byRelease = (a, b) => compareRelease(parseVersion(a), parseVersion(b)); - stableInLine.sort(byRelease); - stableLater.sort(byRelease); - return { - ...entry, - verdict: stableInLine.length || stableLater.length ? 'available' : 'waiting', - trigger: stableInLine.length ? 'in-line' : stableLater.length ? 'later' : null, - stableInLine, - stableLater, - distTags: read.distTags ?? {}, - error: null, - }; -} - -/** - * @param {Array} judged - * @returns {{ verdict: 'available'|'unknown'|'waiting'|'nothing-to-watch', packages: object[], issues: string[] }} - */ -export function evaluate(judged) { - const hits = judged.filter((j) => j.verdict === 'available'); - const unknown = judged.filter((j) => j.verdict === 'unknown'); - const issues = [ - ...new Set(hits.flatMap((h) => h.followUp?.issues ?? [])), - ].sort(); - let verdict; - if (!judged.length) verdict = 'nothing-to-watch'; - else if (hits.length) verdict = 'available'; - else if (unknown.length) verdict = 'unknown'; - else verdict = 'waiting'; - return { verdict, packages: judged, hits, unknown, issues }; -} - -// --------------------------------------------------------------------------- -// Rendering -// --------------------------------------------------------------------------- - -export function render(result, { verbose = false } = {}) { - const out = []; - const { verdict, packages, hits, unknown } = result; - - if (verdict === 'nothing-to-watch') { - out.push( - '✓ No prerelease pin left in pnpm-workspace.yaml `overrides` — nothing to watch.', - '', - ' This probe derives its watch list from the pins themselves (#5024), so an empty', - ' list is the SUCCESS state, not a misconfiguration: every prerelease promise in the', - ' file has been redeemed. When the last pin went stable this probe retired itself.', - ); - return out.join('\n'); - } - - if (verdict === 'available') { - out.push( - `⛔ A STABLE release is available for ${hits.length} prerelease pin(s) — the trigger ` + - `condition of ${result.issues.join(' / ') || 'the follow-up work'} has ARRIVED.`, - '', - ); - for (const h of hits) { - out.push(` ${h.name}`); - out.push(` pinned: ${h.target} (prerelease; watched line ${h.line}.x)`); - if (h.stableInLine.length) { - out.push( - ` STABLE in the pinned line: ${h.stableInLine.join(', ')}` + - ` → revert the pin to \`^${h.line}.x\`.`, - ); - } - if (h.stableLater.length) { - out.push( - ` STABLE only in a LATER line: ${h.stableLater.join(', ')}`, - h.stableInLine.length - ? ' (also available above the pinned line.)' - : ` → the pinned ${h.line} line never stabilized. Getting off the prerelease means ` + - `crossing a minor, which is a DECISION, not a bump — read the action list first.`, - ); - } - if (h.followUp) { - out.push(` action list: ${h.followUp.issues.join(', ')}`); - out.push(` ${h.followUp.note}`); - } else { - out.push( - ' ⚠ NO follow-up issue is declared for this pin. Whoever added it recorded no', - ' revert plan, which is the #5024 defect shape again — file the follow-up issue', - ' and add it to FOLLOW_UPS in this script.', - ); - } - // Context only. The verdict above was decided by semver, never by a tag. - const tags = Object.entries(h.distTags ?? {}) - .filter(([t]) => ['latest', 'rc', 'beta', 'next'].includes(t)) - .map(([t, v]) => `${t}=${v}`); - if (tags.length) out.push(` dist-tags (context only, NOT the criterion): ${tags.join(' ')}`); - out.push(''); - } - out.push( - ' Why this is loud: the pins in `pnpm-workspace.yaml` carry a promise in their comment —', - ' "revert to a stable `^1.7.x` line the moment one ships" — and until this probe existed', - ' nothing watched for that moment: redeeming it depended on somebody remembering to check', - ' npm (#5024). Every extra day on a prerelease is another day the auth surface runs on a', - ' version with no stability commitment behind it.', - '', - ' This probe does NOT move any pin. Do the work on the issues above; the red goes away', - ' when the pins do.', - ); - if (unknown.length) { - out.push( - '', - ` ⚠ ${unknown.length} further pin(s) could not be read, so this list may be INCOMPLETE:`, - ...unknown.map((u) => ` ${u.name} — ${u.error}`), - ); - } - return out.join('\n'); - } - - if (verdict === 'unknown') { - out.push( - `⚠ Prerelease pin watch is INCONCLUSIVE — ${unknown.length} of ${packages.length} ` + - `registry read(s) failed.`, - '', - ...unknown.map((u) => ` ${u.name} — ${u.error}`), - '', - ' This is NOT "no stable release yet" and it is NOT reported as one. It is also not a', - ' red: the next run re-probes from scratch and a published version is never unpublished', - ' back into silence, so a missed night costs at most a day — while a nightly that goes', - ' red on a transient registry error is a nightly everyone learns to skim, which is the', - ' "nobody is watching" state this probe exists to end (#5024).', - '', - ' Persistently inconclusive? Run with `--strict` (exit 1) to make it block.', - ' Behind an HTTPS proxy, node ignores HTTPS_PROXY unless run with NODE_USE_ENV_PROXY=1.', - ); - const readable = packages.filter((p) => p.verdict === 'waiting'); - if (readable.length) { - out.push( - '', - ` The ${readable.length} pin(s) that WERE readable have no stable release yet:`, - ...readable.map((p) => ` ${p.name} — pinned ${p.target}, no stable ${p.line}.x`), - ); - } - return out.join('\n'); - } - - // WAITING — the quiet path. One line, by design: a nightly that prints a - // report every night trains its reader to skip the night it matters. - out.push( - `✓ No stable release yet for ${packages.length} prerelease pin(s) ` + - `(${[...new Set(packages.map((p) => `${p.line}.x`))].join(', ')}) — nothing to do.`, - ); - if (verbose) { - for (const p of packages) { - const tags = p.distTags?.latest ? ` [latest=${p.distTags.latest}]` : ''; - out.push(` ${p.name.padEnd(32)} pinned ${p.target} → no stable ${p.line}.x${tags}`); - } - } - return out.join('\n'); -} - -// --------------------------------------------------------------------------- -// CLI -// --------------------------------------------------------------------------- - -// Set by `selfTest()` only after its verdict is printed, and read at the -// dispatch: a `return` that leaves the function above that line prints nothing -// and still exits 0 — a self-test that never finished, reported as one that -// passed (#13798). The self-test's own exit code stays load-bearing, so the -// handshake is a flag rather than a returned sentinel. -let selfTestReachedVerdict = false; - -async function main(argv) { - const has = (f) => argv.includes(f); - const val = (f, d) => { - const i = argv.indexOf(f); - return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : d; - }; - - if (has('-h') || has('--help')) { - console.log( - readFileSync(fileURLToPath(import.meta.url), 'utf8') - .split('\n') - .filter((l) => l.startsWith('//')) - .map((l) => l.slice(3)) - .join('\n'), - ); - return 0; - } - if (has('--self-test')) { - const selfTestCode = selfTest(); - if (!selfTestReachedVerdict) { - console.error( - '\n✗ check-prerelease-pin-watch self-test: selfTest() returned without reaching its verdict,\n' - + 'so no success line was printed. Exiting 0 here would report a self-test\n' - + 'that never finished as a self-test that passed.\n', - ); - process.exit(1); - } - return selfTestCode; - } - - const strict = has('--strict'); - const asJson = has('--json'); - const verbose = has('--verbose'); - const registry = val('--registry', DEFAULT_REGISTRY); - const workspace = val('--workspace', DEFAULT_WORKSPACE); - // Self-test only: registry responses come from a JSON file instead of the - // network, so the real CLI (exit codes included) is what gets exercised. - // Every fixture run SAYS SO — it can never be mistaken for a real verdict. - const fixture = val('--fixture', ''); - - const watch = buildWatchList(readOverrides(readFileSync(workspace, 'utf8'))); - - const reads = fixture ? JSON.parse(readFileSync(fixture, 'utf8')) : null; - const judged = []; - for (const entry of watch) { - if (reads) { - const r = reads[entry.name]; - if (!r) judged.push(judge(entry, { error: `no fixture entry for ${entry.name}` })); - else if (r.__throw) judged.push(judge(entry, { error: r.__throw })); - else judged.push(judge(entry, { versions: r.versions ?? [], distTags: r.distTags ?? {} })); - continue; - } - try { - judged.push(judge(entry, await readPackument(entry.name, { registry }))); - } catch (err) { - if (!(err instanceof RegistryUnreadable)) throw err; - judged.push(judge(entry, { error: err.message })); - } - } - - const result = evaluate(judged); - - if (fixture) { - console.error( - `⚠ REGISTRY READ FROM FIXTURE ${fixture} — self-test output, not a real verdict.`, - ); - } - - if (asJson) console.log(JSON.stringify({ ...result, registry, strict }, null, 2)); - else console.log(render(result, { verbose })); - - if (result.verdict === 'available') { - console.error( - `\n::error::A stable release retires a prerelease pin — act on ` + - `${result.issues.join(' / ') || 'the follow-up issues'} (#5024 probe).`, - ); - return 1; - } - if (result.verdict === 'unknown') { - console.error( - `\n::warning::Prerelease pin watch could not read ${result.unknown.length} package(s) — ` + - `INCONCLUSIVE, not "no release yet". Re-runs tomorrow; --strict to make it block (#5024).`, - ); - return strict ? 1 : 2; - } - return 0; -} - -// --------------------------------------------------------------------------- -// Self-test — the repo idiom for a scripts/ gate: drive the real code and the -// real CLI (exit codes included) over fabricated registry responses. Fully -// offline, so it is safe as a PR gate where the probe itself is not. -// --------------------------------------------------------------------------- - -function selfTest() { - // The battery ledger this self-test's floor is evaluated against (#13489). - // `battery()` opens a battery; every assertion below is attributed to the one - // most recently opened, so a section that stops running stops registering and - // names ITSELF at the floor rather than going quiet. - const seen = new Map(); - let openBattery = null; - const battery = (name) => { - openBattery = name; - }; - const registerCase = () => { - const b = openBattery ?? UNATTRIBUTED_BATTERY; - seen.set(b, (seen.get(b) ?? 0) + 1); - }; - const failures = []; - const check = (name, cond, detail = '') => { - registerCase(); - if (cond) console.log(` ✓ ${name}`); - else { - failures.push(`${name}${detail ? ` — ${detail}` : ''}`); - console.log(` ✗ ${name}${detail ? ` — ${detail}` : ''}`); - } - }; - - console.log('check-prerelease-pin-watch --self-test'); - - // --- 1. the watch list is derived from the pins --------------------------- - battery('1. the watch list is derived from the pins'); - const YAML = [ - 'packages:', - ' - packages/*', - 'overrides:', - " esbuild: '>=0.28.1'", - " 'minimatch@<10.2.3': '10.2.3'", - " # a comment line that must not parse", - " 'better-auth@<1.7.0-rc.2': '1.7.0-rc.2'", - " '@better-auth/core@<1.7.0-rc.2': '1.7.0-rc.2'", - " '@better-auth/scim@<1.7.0-rc.1': '1.7.0-rc.1'", - " 'postcss@<8.5.10': '^8.5.10'", - " svelte: '^5.55.7'", - 'onlyBuiltDependencies:', - ' - esbuild', - ].join('\n'); - - const watch = buildWatchList(readOverrides(YAML)); - check( - 'only the prerelease pins are watched (stable exacts and ranges are not)', - watch.map((w) => w.name).join(',') === '@better-auth/core,@better-auth/scim,better-auth', - watch.map((w) => w.name).join(','), - ); - check( - 'an exact STABLE pin is not watched (minimatch 10.2.3)', - !watch.some((w) => w.name === 'minimatch'), - ); - check( - 'a RANGE target is not watched (^8.5.10, >=0.28.1)', - !watch.some((w) => w.name === 'postcss' || w.name === 'esbuild'), - ); - check( - 'the watched line comes from the pin, not from a constant', - watch.find((w) => w.name === 'better-auth')?.line === '1.7' && - watch.find((w) => w.name === 'better-auth')?.base === '1.7.0', - ); - check( - 'scim carries the migration card (#3653) and names it a MIGRATION', - watch.find((w) => w.name === '@better-auth/scim').followUp.issues.join() === '#3653' && - watch.find((w) => w.name === '@better-auth/scim').followUp.note.includes('MIGRATION'), - ); - check( - 'the rest of the family carries a follow-up card too', - watch.find((w) => w.name === 'better-auth').followUp.issues.join() === '#3653', - ); - - // --- 1b. the ledger rule: a note points at the pin, it does not restate it - - // Two correction rounds found one copy each and left the other (#11372 fixed - // the pin comment, #11761 the note). What forbids a third copy is this check, - // not the memory of those rounds. - battery('1b. the ledger rule: a note points at the pin, it does not restate it'); - const restated = FOLLOW_UPS.flatMap((f) => - restatedModelCounts(f.note).map((m) => `${f.match.source} -> "${m.trim()}"`), - ); - check( - 'no follow-up note restates a model COUNT (the pin comment is the single source)', - restated.length === 0, - restated.join(' | '), - ); - check( - 'the scim note routes its reader to that single source BY NAME', - followUpFor('@better-auth/scim').note.includes('pnpm-workspace.yaml'), - followUpFor('@better-auth/scim').note, - ); - // Positive control. A guard that cannot fail is not a guard, and the zero - // above is only worth reading next to this one: the exact wording #11761 - // deleted, kept here verbatim so the check can never quietly become a no-op. - check( - 'the count guard FIRES on the exact wording this ledger used to print', - restatedModelCounts( - 'the STABLE 1.7 releases ship that same rewrite (measured on the 1.7.1 tarball: no ' + - 'scimProvider model, no generate-token endpoint, all six new models present).', - ).length > 0, - ); - // Negative control: a version is an identifier, not a count. A guard that - // reddened on `1.7.1` next to `models` would be switched off, not obeyed. - check( - 'the count guard does NOT fire on version numbers or issue refs', - restatedModelCounts('the models changed in 1.7.1, again in rc.2, tracked by #3653') - .length === 0, - ); - check( - 'a prerelease pin with no declared follow-up is watched anyway (unmapped is legal)', - buildWatchList(readOverrides("overrides:\n 'nobody@<2.0.0-rc.1': '2.0.0-rc.1'\n")).length === 1, - ); - - // --- 2. the three registry states ---------------------------------------- - battery('2. the three registry states'); - const entry = watch.find((w) => w.name === 'better-auth'); - - const onlyPre = judge(entry, { - versions: ['1.6.26', '1.7.0-beta.10', '1.7.0-rc.2', '1.7.0-rc.4'], - distTags: { latest: '1.6.26', rc: '1.7.0-rc.4' }, - }); - check('only prereleases in the line → WAITING', onlyPre.verdict === 'waiting', onlyPre.verdict); - check( - 'a newer prerelease (rc.4 vs the pinned rc.2) does NOT trigger', - onlyPre.stableInLine.length === 0, - ); - - const stable = judge(entry, { - versions: ['1.6.26', '1.7.0-rc.4', '1.7.0', '1.7.1'], - distTags: { latest: '1.6.26', rc: '1.7.0-rc.4' }, - }); - check('a stable release in the line → AVAILABLE', stable.verdict === 'available', stable.verdict); - check('and it is classified in-line', stable.trigger === 'in-line', String(stable.trigger)); - check( - 'every stable version in the line is listed, in order', - stable.stableInLine.join(',') === '1.7.0,1.7.1', - stable.stableInLine.join(','), - ); - - const unreadable = judge(entry, { error: 'getaddrinfo ENOTFOUND registry.npmjs.org' }); - check('a failed read → UNKNOWN', unreadable.verdict === 'unknown', unreadable.verdict); - check( - 'UNKNOWN never reports stable versions it did not see', - unreadable.stableInLine.length === 0 && unreadable.stableLater.length === 0, - ); - - // --- 3. the criterion is semver, NOT the `latest` tag -------------------- - // The live shape on 2026-08-05: `latest` sits on the OLD line while the new - // line is published. A `latest`-watching probe sleeps through this. - battery('3. the criterion is semver, NOT the `latest` tag'); - const latestStillOld = judge(entry, { - versions: ['1.6.26', '1.7.0'], - distTags: { latest: '1.6.26', rc: '1.7.0-rc.4' }, - }); - check( - 'a stable 1.7.0 triggers even while dist-tag `latest` is still 1.6.26', - latestStillOld.verdict === 'available' && latestStillOld.trigger === 'in-line', - JSON.stringify({ v: latestStillOld.verdict, t: latestStillOld.trigger }), - ); - check( - 'a stable release BELOW the pinned base never triggers (reverting there undoes the fix)', - judge(entry, { versions: ['1.6.26', '1.5.9'], distTags: {} }).verdict === 'waiting', - ); - check( - 'build metadata does not make a version a prerelease', - parseVersion('1.7.0+build.5')?.prerelease === null, - ); - - // --- 4. a stable release only in a LATER line is its own case ----------- - battery('4. a stable release only in a LATER line is its own case'); - const later = judge(entry, { versions: ['1.7.0-rc.4', '1.8.0', '2.0.0'], distTags: {} }); - check('stable only above the pinned line → AVAILABLE', later.verdict === 'available'); - check('…classified `later`, not `in-line`', later.trigger === 'later', String(later.trigger)); - const laterText = render(evaluate([later])); - check( - 'and the report says crossing a minor is a DECISION', - laterText.includes('DECISION') && laterText.includes('never stabilized'), - laterText, - ); - - // --- 5. the reports say what they must ---------------------------------- - battery('5. the reports say what they must'); - const hitText = render(evaluate([judge(entry, { versions: ['1.7.0'], distTags: {} })])); - check('the AVAILABLE report names its follow-up card', hitText.includes('#3653'), hitText); - check( - 'the AVAILABLE report names the remedy (revert to ^1.7.x)', - hitText.includes('^1.7.x'), - hitText, - ); - // Printed only when the packument carried tags, so it is asserted on the - // `latest`-still-old case — the report that shows `latest=1.6.26` next to a - // stable 1.7.0 is exactly the one that must disclaim the tag. - const latestStillOldText = render(evaluate([latestStillOld])); - check( - 'the AVAILABLE report prints dist-tags as context and states they are NOT the criterion', - latestStillOldText.includes('latest=1.6.26') && - latestStillOldText.includes('NOT the criterion'), - latestStillOldText, - ); - const scimHit = render( - evaluate([judge(watch.find((w) => w.name === '@better-auth/scim'), { versions: ['1.7.0'], distTags: {} })]), - ); - check('a scim hit names #3653 and calls it a migration', scimHit.includes('#3653') && scimHit.includes('MIGRATION'), scimHit); - const unmappedHit = render( - evaluate([ - judge(buildWatchList(readOverrides("overrides:\n 'nobody@<2.0.0-rc.1': '2.0.0-rc.1'\n"))[0], { - versions: ['2.0.0'], - distTags: {}, - }), - ]), - ); - check( - 'an unmapped pin hit says so instead of pointing nowhere', - unmappedHit.includes('NO follow-up issue is declared'), - unmappedHit, - ); - const waitingText = render(evaluate([onlyPre])); - check( - 'the WAITING report is ONE line (zero noise when nothing shipped)', - waitingText.trim().split('\n').length === 1, - waitingText, - ); - check( - 'the UNKNOWN report refuses to read as "no release yet"', - render(evaluate([unreadable])).includes('INCONCLUSIVE'), - ); - check( - 'an empty watch list is the SUCCESS state and says why', - render(evaluate([])).includes('nothing to watch') && - render(evaluate([])).includes('retired itself'), - ); - - // --- 6. a hit alongside an unreadable pin stays a hit, and says it may be - // incomplete (the itemization lesson from check:objectui-pin-fresh) -- - battery('6. a hit alongside an unreadable pin stays a hit, and says it may be'); - const mixed = evaluate([judge(entry, { versions: ['1.7.0'], distTags: {} }), unreadable]); - check('a hit is not diluted by a sibling read failure', mixed.verdict === 'available'); - check( - 'and the report admits the hit list may be incomplete', - render(mixed).includes('INCOMPLETE'), - render(mixed), - ); - - // --- 7. end-to-end through the real CLI, exit codes included ------------- - battery('7. end-to-end through the real CLI, exit codes included'); - const tmp = mkdtempSync(join(tmpdir(), 'prerelease-pin-watch-selftest-')); - try { - const cli = fileURLToPath(import.meta.url); - const file = (name, obj) => { - const p = join(tmp, name); - writeFileSync(p, typeof obj === 'string' ? obj : JSON.stringify(obj)); - return p; - }; - const ws = file('pnpm-workspace.yaml', YAML); - const run = (args) => { - const r = spawnSync('node', [cli, ...args], { encoding: 'utf8' }); - return { code: r.status ?? 1, stdout: r.stdout || '', out: `${r.stdout || ''}${r.stderr || ''}` }; - }; - - const pre = { versions: ['1.6.26', '1.7.0-rc.2', '1.7.0-rc.4'], distTags: { latest: '1.6.26' } }; - const waitingRun = run([ - '--workspace', - ws, - '--fixture', - file('waiting.json', { - 'better-auth': pre, - '@better-auth/core': pre, - '@better-auth/scim': pre, - }), - ]); - check('CLI exits 0 while only prereleases exist', waitingRun.code === 0, `code ${waitingRun.code}`); - check( - 'the quiet run emits no ::error:: and no ::warning::', - !waitingRun.out.includes('::error::') && !waitingRun.out.includes('::warning::'), - waitingRun.out, - ); - check('a fixture run announces itself as a fixture run', waitingRun.out.includes('FIXTURE')); - - const hitRun = run([ - '--workspace', - ws, - '--fixture', - file('hit.json', { - 'better-auth': { versions: ['1.7.0'], distTags: { latest: '1.6.26' } }, - '@better-auth/core': pre, - '@better-auth/scim': pre, - }), - ]); - check('CLI exits 1 when a stable release exists', hitRun.code === 1, `code ${hitRun.code}`); - check( - 'the hit run emits ::error:: naming the follow-up issue', - hitRun.out.includes('::error::') && hitRun.out.includes('#3653'), - hitRun.out, - ); - - const netFixture = file('net.json', { - 'better-auth': { __throw: 'getaddrinfo ENOTFOUND registry.npmjs.org' }, - '@better-auth/core': { __throw: 'HTTP 503' }, - '@better-auth/scim': pre, - }); - const netRun = run(['--workspace', ws, '--fixture', netFixture]); - check('CLI exits 2 (not 0, not 1) when reads fail', netRun.code === 2, `code ${netRun.code}`); - check( - 'a read failure never renders as "no stable release yet"', - netRun.out.includes('INCONCLUSIVE') && !netRun.out.includes('nothing to do'), - netRun.out, - ); - check('the failure reason is printed verbatim', netRun.out.includes('ENOTFOUND'), netRun.out); - check('and it warns rather than passing silently', netRun.out.includes('::warning::')); - - const strictRun = run(['--workspace', ws, '--strict', '--fixture', netFixture]); - check('--strict promotes an unreadable registry to exit 1', strictRun.code === 1, `code ${strictRun.code}`); - - const emptyRun = run([ - '--workspace', - file('empty-workspace.yaml', "overrides:\n 'minimatch@<10.2.3': '10.2.3'\n"), - '--fixture', - file('empty.json', {}), - ]); - check('CLI exits 0 with no prerelease pins left', emptyRun.code === 0, `code ${emptyRun.code}`); - check( - '…and says that is the success state, not a misconfiguration', - emptyRun.out.includes('nothing to watch'), - emptyRun.out, - ); - - const jsonRun = run([ - '--workspace', - ws, - '--json', - '--fixture', - file('hit2.json', { - 'better-auth': { versions: ['1.7.0', '1.7.1'], distTags: {} }, - '@better-auth/core': pre, - '@better-auth/scim': { versions: ['1.7.0'], distTags: {} }, - }), - ]); - const parsed = JSON.parse(jsonRun.stdout); - check( - 'JSON carries the verdict, the per-package rows and the issue list', - parsed.verdict === 'available' && - parsed.packages.length === 3 && - parsed.issues.join(',') === '#3653', - jsonRun.stdout, - ); - check( - 'JSON reports each package separately (per-package conclusions)', - parsed.packages.filter((p) => p.verdict === 'available').length === 2 && - parsed.packages.filter((p) => p.verdict === 'waiting').length === 1, - jsonRun.stdout, - ); - - // The REAL file must still parse — a self-test that only ever reads its - // own fixture would pass while the file it polices had moved out from - // under it. Since #3653 redeemed the last prerelease pin (the scim - // override now targets stable 1.7.1 exactly), the probe's RETIRED state — - // an empty watch list — is the expected reading. Assert it over a - // successfully parsed, non-empty overrides block, so a parser break - // cannot masquerade as retirement. - const realOverrides = readOverrides(readFileSync(DEFAULT_WORKSPACE, 'utf8')); - check( - `the repo's own pnpm-workspace.yaml overrides still parse (${Object.keys(realOverrides).length} entr(y/ies))`, - Object.keys(realOverrides).length > 0, - 'readOverrides returned nothing — the parser broke or the file moved; this is NOT retirement', - ); - const realWatch = buildWatchList(realOverrides); - check( - `the repo's own watch list is EMPTY — the probe's retired state (#3653; found ${realWatch.length} pin(s))`, - realWatch.length === 0, - 'a prerelease pin re-appeared in pnpm-workspace.yaml — the probe is live again: flip this ' - + 'check back to the non-empty expectations it replaced (git log this file), and restore ' - + 'the per-pin base/line shape assertion beside it', - ); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - - // ── The floor: every declared battery RAN, and ran its cases (#13489) ─── - // - // Evaluated after every battery has had its chance and BEFORE the verdict, so - // the success line below can only be printed by a run in which the set of - // batteries that registered assertions EQUALS the set declared. A set - // difference names WHICH battery stopped; a count says only that something did. - const floorFailure = (message) => { - failures.push(message); - }; - const declaredBatteries = Object.keys(SELF_TEST_BATTERIES); - let floorBreached = false; - if (declaredBatteries.length < SELF_TEST_BATTERY_FLOOR) { - floorBreached = true; - floorFailure( - `SELF_TEST_BATTERIES declares ${declaredBatteries.length} batteries, below the pinned ` + - `${SELF_TEST_BATTERY_FLOOR} — a battery deleted from the roster takes its own floor with it.`, - ); - } - for (const [name, count] of seen) { - if (declaredBatteries.includes(name)) continue; - floorBreached = true; - floorFailure( - `self-test battery "${name}" registered ${count} case(s) but is not declared in ` + - 'SELF_TEST_BATTERIES — an assertion attributed to no declared battery is one nothing floors.', - ); - } - for (const name of declaredBatteries) { - const count = seen.get(name) ?? 0; - if (count >= SELF_TEST_BATTERIES[name]) continue; - floorBreached = true; - floorFailure( - count === 0 - ? `self-test battery "${name}" DID NOT RUN — 0 cases registered, ${SELF_TEST_BATTERIES[name]} pinned. ` + - 'The verdict below would have claimed those cases hold.' - : `self-test battery "${name}" registered ${count} case(s), below its pinned floor of ` + - `${SELF_TEST_BATTERIES[name]} — cases that used to run no longer do.`, - ); - } - if (floorBreached) { - floorFailure( - 'A battery at or below its floor means cases STOPPED RUNNING — the battery is the bug, not the ' + - 'number. Find what stopped registering (an early return, a deleted block, a guard that now ' + - 'skips) and restore it.', - ); - } - if (failures.length) { - console.error(`\n⛔ check-prerelease-pin-watch --self-test: ${failures.length} failure(s)`); - for (const f of failures) console.error(` - ${f}`); - return 1; - } - console.log('✓ check-prerelease-pin-watch --self-test: all checks passed'); - selfTestReachedVerdict = true; - return 0; -} - -if (isEntrypoint(import.meta.url)) { - main(process.argv.slice(2)).then( - (code) => process.exit(code), - (err) => { - console.error(`✗ check:prerelease-pins — ${err.message}`); - process.exit(1); - }, - ); -} diff --git a/scripts/console-spec-probes.mjs b/scripts/console-spec-probes.mjs deleted file mode 100644 index 2c1f5364d85..00000000000 --- a/scripts/console-spec-probes.mjs +++ /dev/null @@ -1,382 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Shared probe derivation for the vendored-Console spec-injection guards. - * - * TWO scripts ask "does this console bundle carry THIS tree's @objectstack/spec - * or the published one", at two different moments: - * - * scripts/assert-console-spec-injection.mjs — right after a build, with BOTH - * specs and objectui's build tree on disk. Derives the probes and STAMPS - * them into the dist. - * scripts/check-console-injection.mjs — on every console-job run, - * including a cache HIT, where the objectui build tree does not exist and - * the published spec is therefore unavailable. Replays the stamped probes. - * - * They must derive probes IDENTICALLY: the second script re-asserts strings the - * first one chose, so two implementations that drift would silently stop - * agreeing about what a probe even is. Hence one module, imported by both, - * rather than a copy in each — the copy is what rots. - * - * Nothing here exits the process; callers own exit codes. A module that called - * process.exit() could not be unit-tested, and check-console-injection's - * --self-test drives these functions directly. - */ - -import { spawnSync } from 'node:child_process'; -import fs from 'node:fs'; -import path from 'node:path'; - -/** Basename of the provenance stamp written into the console dist. */ -export const STAMP_BASENAME = '.objectstack-injection.json'; - -/** Shape version of that stamp. Bump only on an incompatible field change. */ -export const STAMP_VERSION = 1; - -/** Export conditions a browser/ESM bundler picks, in preference order. - * `types` is deliberately absent — it sits first in each condition object and - * would resolve every subpath at a `.d.mts` file. */ -const IMPORT_CONDITIONS = ['import', 'module', 'browser', 'default']; - -/** Raised for every "cannot run" condition, so callers map it to their own - * inconclusive exit code instead of inheriting one from this module. */ -export class ProbeError extends Error {} - -const bad = (message) => { - throw new ProbeError(message); -}; - -export function pickImportTarget(value) { - if (typeof value === 'string') return value; - if (value === null || typeof value !== 'object') return null; - if (Array.isArray(value)) { - for (const candidate of value) { - const hit = pickImportTarget(candidate); - if (hit) return hit; - } - return null; - } - for (const condition of IMPORT_CONDITIONS) { - if (!Object.hasOwn(value, condition)) continue; - const hit = pickImportTarget(value[condition]); - if (hit) return hit; - } - return null; -} - -/** Every JS file a package's exports map resolves to, concatenated once. */ -export function readSpecBlob(packageDir, label) { - const manifestPath = path.join(packageDir, 'package.json'); - if (!fs.existsSync(manifestPath)) bad(`${label} spec has no package.json at \`${manifestPath}\``); - let exportsMap; - try { - exportsMap = JSON.parse(fs.readFileSync(manifestPath, 'utf8')).exports; - } catch (error) { - bad(`${label} \`${manifestPath}\` is not readable JSON (${error.message})`); - } - if (!exportsMap || typeof exportsMap !== 'object') bad(`${label} spec declares no exports map`); - - const chunks = []; - for (const value of Object.values(exportsMap)) { - const target = pickImportTarget(value); - if (!target || !/\.(js|mjs|cjs)$/.test(target)) continue; - const absolute = path.resolve(packageDir, target); - if (!fs.existsSync(absolute)) continue; - chunks.push(fs.readFileSync(absolute, 'utf8')); - } - if (chunks.length === 0) bad(`${label} spec at \`${packageDir}\` has no built JavaScript to compare`); - return chunks.join('\n'); -} - -/** - * Candidate probe strings: Zod `.describe()` arguments. - * - * They are prose written by spec authors, which makes them stable across a - * bundler (plain string literals, preserved by minification) and specific enough - * that a match is not a coincidence — the property objectstack#8134's own - * measurement relied on, and the reason a bare key name like `object` is - * unusable here (`optionsFrom.object` false-positives). - */ -export function describeCandidates(blob) { - const found = new Set(); - const pattern = /\.describe\(\s*(["'])((?:\\.|(?!\1)[^\\])*)\1\s*\)/g; - for (const match of blob.matchAll(pattern)) { - const text = match[2]; - // Long enough to be unique, short enough to survive intact, and free of - // escapes and line breaks so a literal search means what it says. - if (text.length >= 32 && text.length <= 160 && !/[\\\r\n]/.test(text)) { - found.add(text); - continue; - } - for (const segment of literalSegments(text)) found.add(segment); - } - return [...found].sort(); -} - -/** - * The second candidate source: the plain runs inside a describe literal the - * whole-literal rule above skips — one longer than 160 characters, or one - * carrying an escape. - * - * That rule is right about the WHOLE literal: an escape is spelled one way in a - * spec's dist and may be spelled another in a bundle (`—` emitted as the - * raw dash, or the reverse), so the literal as a whole is no probe. The text - * BETWEEN two escapes is plain characters in both spellings. Skipping it cost - * real coverage (objectstack#24663): on a pin whose reference differs from this - * tree only in such literals both pools were empty, the build stamped - * `freshWitness: null`, and the assertion passed a console carrying the - * reference's spec — at objectui 12dc7b47 against objectstack f4755a68a797 the - * one changed describe is the flow `runAs` text, over 160 characters with its em - * dash escaped. - * - * So a skipped literal is cut at every escape sequence and at every quote - * character — a bundler re-quotes a string to whichever quote needs fewer - * escapes, which can put a backslash before a quote that had none — and each run - * of 32 characters or more is a candidate. A run is NOT cut to 160: that bound - * was about a whole literal surviving, and a run is plain text the bundler copies - * byte for byte. Cutting it into windows would also turn one changed run into - * windows the other spec still contains, which pickProbe's substring rule drops. - * - * A literal the rule above accepts is still taken whole, so every candidate it - * produced before is still produced; this only adds candidates it skipped. - */ -function literalSegments(text) { - return text - .split(/\\(?:u\{[0-9a-fA-F]+\}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|[\s\S])|["'`\r\n]/) - .filter((segment) => segment.length >= 32); -} - -/** - * First candidate present in `mine` and absent from `theirs`, as raw text. - * - * SUBSTRING, not set difference. Descriptions are routinely reworded by - * appending a clause, which makes the old text a PREFIX of the new one — three - * of the first candidates measured for objectstack#8134 were exactly that, and a - * set-difference check called them unique when a substring search matched both. - */ -export function pickProbe(candidates, theirs) { - for (const candidate of candidates) { - if (!theirs.includes(candidate)) return candidate; - } - return null; -} - -/** Every candidate `theirs` does not contain, by the same substring rule as pickProbe. */ -export function uniqueCandidates(candidates, theirs) { - return candidates.filter((candidate) => !theirs.includes(candidate)); -} - -/** Tracked files whose string literals a console bundle can carry. */ -const HOST_SOURCE_FILE = /\.(?:[cm]?[jt]sx?)$/; - -/** Tracked test files: source no console bundle carries. */ -const HOST_TEST_FILE = /(?:^|\/)__tests__\/|\.(?:test|spec)\.[cm]?[jt]sx?$/; - -/** - * objectui's OWN source at the pin, as one blob: every tracked code file of the - * checkout the console was built from, test files excluded. - * - * A console bundle carries text from two places, the specs and objectui's own - * code, and objectui's component registry writes `inputs` descriptions that - * mirror spec `.describe()` text — verbatim, or as a prefix it then extends. - * Measured at objectui ab1879721595 over its non-test code: 18 literals equal a - * spec description and 18 more begin with one. When the spec rewords such a - * description, the old text becomes published-only while objectui's literal - * still carries it, and a substring search finds "the published spec" in a - * bundle built from this tree's spec (objectstack#21709: object-gantt `markers`). - * Probe text found here is therefore not evidence of either spec — see - * chooseProbes for how it is judged. - * - * TRACKED files, from `git ls-files`, because the build tree also holds - * objectui's node_modules (the published spec itself) and its built dists: - * reading those as "objectui's own source" would excuse exactly the text the - * stale leg exists to find. Tests are excluded because no bundle carries them, - * and objectui's parity tests quote spec descriptions on purpose — two of the 38 - * published-only descriptions measured at that pin sit in one — so counting - * them would only take evidence away from a real leak. - * - * The one normalisation is a quote's backslash: objectui writes - * `'…chart\'s range…'` where the bundler emits `"…chart's range…"`, and probe - * text never contains a backslash (describeCandidates drops those). Any other - * spelling difference leaves the text unmatched here, which keeps it EVIDENCE — - * the loud direction, never a silent pass. - */ -export function readHostSourceBlob(hostDir, label) { - if (!fs.existsSync(hostDir)) bad(`${label} source tree \`${hostDir}\` does not exist`); - const listed = spawnSync('git', ['-C', hostDir, 'ls-files', '-z'], { - encoding: 'utf8', - maxBuffer: 256 * 1024 * 1024, - }); - if (listed.error || listed.status !== 0) { - const reason = listed.error ? listed.error.message : (listed.stderr || '').trim() || `git exited ${listed.status}`; - bad(`${label} source tree \`${hostDir}\` is not a git checkout whose tracked files can be listed (${reason})`); - } - const chunks = []; - for (const file of listed.stdout.split('\0')) { - if (!file || !HOST_SOURCE_FILE.test(file) || HOST_TEST_FILE.test(file)) continue; - const absolute = path.join(hostDir, file); - if (!fs.existsSync(absolute)) continue; - chunks.push(fs.readFileSync(absolute, 'utf8')); - } - if (chunks.length === 0) bad(`${label} source tree \`${hostDir}\` tracks no source files`); - return chunks.join('\n').replace(/\\(['"`])/g, '$1'); -} - -/** - * The two probes for ONE built console bundle, chosen with the bundle in view. - * - * ## The blind spot this closes (objectstack#20646) - * - * Both blobs are every JS file the package's exports map resolves to, but a - * console bundles only the entries it imports. Taking the alphabetically first - * unique candidate therefore picked text the bundle could never carry, on both - * legs, and nothing noticed until the witness landed in one: - * - * - FRESH: the new `@objectstack/spec/migrations` entry took the change-manifest - * descriptions off the root. "A public export added or removed by one - * release." stayed the first injected-only candidate, now carried only by an - * entry the console never imports, so a working injection read as "neither - * spec appears" (exit 2) — while 102 of the 142 injected-only descriptions - * were in that very bundle (measured on fbec216e2d against objectui - * dd3f7e1be356 and its published @objectstack/spec 17.4.0). - * - STALE: the first published-only candidate was text from the published - * `./cloud` entry, which the console never imports either, so the detector - * was absent by construction: a console built from the PUBLISHED spec passed - * this leg too. - * - * ## What is chosen instead - * - * - The fresh witness is the first injected-only candidate the bundle DOES - * carry. When it carries none, the first candidate is still returned with - * `freshPresent: false` — "neither spec appears" stays exit 2 at the caller. - * - The stale leg is judged over EVERY published-only candidate, not one: it is - * present when ANY of them is in the bundle, and the detector returned is the - * first one found. That is strictly stronger than the single pick — a bundle - * the old leg flagged is still flagged — and it no longer depends on which - * entry happens to sort first. With none present, the first candidate is - * returned, exactly the one pickProbe chose, so the stamp this feeds keeps - * its shape and its replay (check-console-injection) keeps its meaning. - * - * `freshPresent` / `stalePresent` are `null` when that side has no unique - * candidate at all — no skew on that side — matching the caller's old tri-state. - * - * ## Text objectui's own source carries is not evidence (objectstack#21709) - * - * A candidate is in the bundle either because a spec put it there or because - * objectui's own code did — readHostSourceBlob says why the second is routine. - * `hostBlob` is that code, and a candidate it carries is judged by what its - * presence can and cannot show: - * - * - PRESENT in the bundle and carried by `hostBlob`: proves nothing about - * either spec. It is counted (`counts.hostCarried`) and never decides a - * verdict, and it is never returned as a probe — the stamp replays the - * probes returned here, and a stamped detector that objectui's own text puts - * in every build would fail every cache-hit replay of a good dist. - * - ABSENT from the bundle: still evidence, whoever else carries it. The text - * is not there, from any source. - * - * So `inBundle` counts EVIDENCE only, and a bundle carrying the published spec - * still fails: every published-only description objectui does not write is - * still a detector. One state is new, and the caller must refuse it: a side - * whose unique candidates are ALL present and host-carried has no probe left - * (`freshWitness` / `staleDetector` null) while its pool is non-empty — a - * leg that cannot judge this bundle, which is not the same thing as no skew. - */ -export function chooseProbes({ injectedBlob, vendoredBlob, bundle, hostBlob }) { - if (typeof hostBlob !== 'string') { - throw new TypeError('chooseProbes needs hostBlob: objectui\'s own source, from readHostSourceBlob'); - } - const judge = (pool) => { - const inBundle = pool.filter((candidate) => bundle.includes(candidate)); - const hostCarried = new Set(inBundle.filter((candidate) => hostBlob.includes(candidate))); - const evidence = inBundle.filter((candidate) => !hostCarried.has(candidate)); - const usable = pool.filter((candidate) => !hostCarried.has(candidate)); - return { - probe: evidence[0] ?? usable[0] ?? null, - present: pool.length === 0 ? null : evidence.length > 0, - counts: { pool: pool.length, inBundle: evidence.length, hostCarried: hostCarried.size }, - hostCarried: [...hostCarried], - }; - }; - const fresh = judge(uniqueCandidates(describeCandidates(injectedBlob), vendoredBlob)); - const stale = judge(uniqueCandidates(describeCandidates(vendoredBlob), injectedBlob)); - return { - freshWitness: fresh.probe, - freshPresent: fresh.present, - freshCounts: fresh.counts, - freshHostCarried: fresh.hostCarried, - staleDetector: stale.probe, - stalePresent: stale.present, - staleCounts: stale.counts, - staleHostCarried: stale.hostCarried, - }; -} - -/** Concatenated JavaScript of a built console `assets/` directory. */ -export function readBundle(assetsDir) { - if (!fs.existsSync(assetsDir)) bad(`assets dir \`${assetsDir}\` does not exist`); - const chunks = []; - for (const entry of fs.readdirSync(assetsDir, { withFileTypes: true })) { - if (entry.isFile() && /\.(js|mjs|cjs)$/.test(entry.name)) { - chunks.push(fs.readFileSync(path.join(assetsDir, entry.name), 'utf8')); - } - } - if (chunks.length === 0) bad(`no JavaScript assets under \`${assetsDir}\``); - return chunks.join('\n'); -} - -/** - * Write the provenance stamp beside a console dist. - * - * `entries` is an ARRAY, not a `spec` field: objectstack#9659 records that 4 of 6 - * `@objectstack/*` packages in the console build tree still resolve from - * objectui's lockfile, and any of them gaining an injection later belongs in the - * same staleness question. Appending an entry must not need a shape change. - */ -export function writeStamp(distDir, entries) { - // A stamp with no entries asserts NOTHING about the dist it sits beside, and - // check:console-injection's three substantive verdicts — published-only - // detector present, stamped fresh witness missing, probe expired — all live - // inside its loop over this array. Writing an empty one would produce a dist - // that satisfies `--require-stamp` while the gate makes no assertion at all. - // - // Unrepresentable here rather than only detected there: this is the single - // call site every producer must pass, and the array shape above exists to be - // GROWN (objectstack#9659), so the day entries are derived from a package list - // instead of a literal, a filter that matches nothing becomes producible. - // Refusing at the write keeps that from ever reaching a dist. - // - // The caller downgrades this throw to a warning and writes no stamp, which - // lands the build in the missing-stamp state the gate already refuses under - // `--require-stamp` — an unguarded state converted into a guarded one. - if (!Array.isArray(entries) || entries.length === 0) { - bad(`refusing to write an empty ${STAMP_BASENAME}: a stamp with no packages asserts nothing`); - } - const stamp = { - stampVersion: STAMP_VERSION, - generatedBy: 'scripts/assert-console-spec-injection.mjs', - packages: entries, - }; - fs.writeFileSync(path.join(distDir, STAMP_BASENAME), `${JSON.stringify(stamp, null, 2)}\n`); -} - -/** Read and shape-check a stamp. Returns null when the dist carries none. */ -export function readStamp(distDir) { - const file = path.join(distDir, STAMP_BASENAME); - if (!fs.existsSync(file)) return null; - let parsed; - try { - parsed = JSON.parse(fs.readFileSync(file, 'utf8')); - } catch (error) { - bad(`\`${file}\` is not readable JSON (${error.message})`); - } - if (!parsed || typeof parsed !== 'object') bad(`\`${file}\` is not a JSON object`); - if (parsed.stampVersion !== STAMP_VERSION) { - bad( - `\`${file}\` is stampVersion ${JSON.stringify(parsed.stampVersion)}, expected ${STAMP_VERSION} — ` + - 'rebuild the console so the stamp matches this checkout', - ); - } - if (!Array.isArray(parsed.packages)) bad(`\`${file}\` has no \`packages\` array`); - return parsed; -} diff --git a/scripts/objectui-changeset-digest.mjs b/scripts/objectui-changeset-digest.mjs deleted file mode 100644 index 404172eb4a0..00000000000 --- a/scripts/objectui-changeset-digest.mjs +++ /dev/null @@ -1,3963 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -// -// objectui-changeset-digest — build the `@objectstack/console` changeset body -// for an objectui pin bump FROM WHAT OBJECTUI DECLARED, not from commit titles. -// -// node scripts/objectui-changeset-digest.mjs --from --to \ -// [--objectui-root ] [--framework-root ] \ -// [--out ] [--bump-override minor] [--max N] [--json] -// node scripts/objectui-changeset-digest.mjs --from --to --check-walkable -// node scripts/objectui-changeset-digest.mjs --self-test -// -// Prints the resolved bump level (`major` | `minor` | `patch`) on stdout; the -// human-readable accounting goes to stderr. With `--out` the full changeset -// file (frontmatter included) is written there, otherwise it goes to stdout -// after the bump line is emitted to stderr instead. -// -// EXIT CODES. 2 and 3 both mean "the range is not walkable in this checkout", -// and they SPLIT the two causes so a caller can name the right remedy: 2 = an -// endpoint is missing as a commit object (fetch it, or this is the initial pin), -// 3 = both endpoints are here but the history STOPS INSIDE the range — see -// #9408 and `findRangeTruncation`. `--check-walkable` derives nothing and only -// answers the precondition; the deriving path writes no artifact on either. -// -// WHAT A CALLER OWES ON 2 AND 3 IS THE SAME (#14178): repair the input and ask -// again — never derive something weaker. Both causes are one `git fetch` apart -// from a complete answer, and the re-ask (not the fetch's status) settles it. -// -// WHY AN ABSENT ENDPOINT IS THE SAME FAILURE AS A TRUNCATED WALK (#14178) -// ----------------------------------------------------------------------- -// #9408 taught this file to detect the truncated walk and taught the bump to -// DEEPEN instead of degrading. But the endpoint guard returned one step earlier: -// on a shallow clone whose boundary sits AFTER `from`, the `from` object is -// absent outright, the guard returned 2, and the deepen was never considered. -// `bump-objectui.sh` then wrote its degraded record with the DEFAULT level. -// -// Measured on a throwaway objectui (5 commits, 4 releasing changesets, one of -// them author-annotated breaking), cloned `--depth 2` so `from` is absent, same -// range and same command before and after `git fetch --unshallow`: -// -// shallow, deepen allowed : bump `patch`, 1-line degraded list, exit 0 -// unshallowed : bump `minor`, 4 releasing, 1 breaking, ADR-0087 -// placeholder emitted -// -// The degraded entry does label itself degraded, so nothing lies — but the LEVEL -// is a default nobody declared, it publishes into @objectstack/console's -// CHANGELOG, every declared-breaking entry is invisible in it, and the ADR-0087 -// disposition prompt never fires because the degraded changeset declares nothing -// breaking. The same shape #4731 named: a record that reads like a declaration. -// -// So an absent endpoint is reported exactly like a truncated walk — as a -// PREREQUISITE the caller must repair — and the caller deepens and re-asks on -// both. When the re-ask still fails, the bump REFUSES rather than emitting a -// level nobody declared (triage ruling, 2026-09-01): no record beats a wrong -// one, and the refusal removes the degraded-unwalkable path instead of adding a -// fourth state to it. -// -// WHY THIS EXISTS (#4731) -// ----------------------- -// `bump-objectui.sh` used to GUESS which objectui commits belonged in the -// platform release record by matching conventional-commit types on the subject -// line — `grep -iE '^- (feat|fix)'`, then `head -40`, then `grep -ciE '^feat'` -// for the bump level. Every part of that guess was measured wrong on one real -// range (`7d9734d5e321..785b8a5d432c`, 53 non-merge commits): -// -// * It dropped ALL 9 `refactor(...)` commits, 6 of them BREAKING (`!`) — -// including `refactor(layout)!: delete PageNodeRenderer` and the burn-ledger -// batches (objectui#3220 / #3224) — plus `chore(deps): lockstep the -// @objectstack family onto 17.0.0-rc.1`. Breaking changes are the single -// class that must never vanish from a release record, and the type filter -// made them the only class structurally unable to appear. -// * `head -40` truncated in silence. A truncated list and a complete list are -// indistinguishable in the artifact — which is the exact silence this -// changeset exists to prevent (#3340). -// * It pulled IN commits that ship nothing: two `fix(ci)` commits -// (objectui#3198 / #3186) matched the type filter while releasing no -// package code at all. -// -// The question the script actually needs answered is "does this commit ship in -// the frontend release?" — and objectui already DECLARES the answer. Every -// releasing PR carries a `.changeset/*.md`; an empty frontmatter block is -// changesets' own spelling of "release-nothing". So we read the changesets -// added over the range: package names say whether it releases, and the declared -// level (major/minor/patch) IS the bump — nothing is inferred from a subject -// line any more. Declaration over inference, per AGENTS.md Prime Directive #12. -// -// WHY "BREAKING" IS BROADER THAN THE DECLARED LEVEL (#6099) -// -------------------------------------------------------- -// #4731 got breaking changes back INTO the list. #6099 is the other half: they -// get in, but they could not be RECOGNISED. The criterion used to be `level === -// 'major'`, and objectui never declares `major` inside a launch window — the -// same convention this repo's `check-changeset-no-major.mjs` enforces ships a -// breaking change as `minor` plus a prose annotation the author writes. Measured -// on the range `f5bc4c78be76..f995a452d2ca`: 64 releasing changesets, 14 `minor` -// / 50 `patch` / **0 `major`**. So the count was structurally pinned at 0 and the -// `⚠️` hint could never fire, while unmarked breaking migrations flowed into -// `@objectstack/console`'s CHANGELOG input layer. -// -// The criterion is therefore "declared level `major` OR the AUTHOR annotated the -// change as breaking in the changeset body" — see `hasBreakingAnnotation`. Note -// what it still refuses to read: the commit SUBJECT. A conventional-commit `!` -// is the tool's classification of a commit, not the author's statement about the -// release, and re-admitting subject parsing is precisely the inference #4731 -// removed. Reading the body keeps this inside "declaration": it is the author's -// own prose, in the same file that already declares the level. It also measures -// strictly better than `!` would — over that same range `!` marks 2 commits, -// while the authors' own annotations mark 4, including `app.homePageId` being -// retired (a `feat(deps):` subject with no `!`). -// -// This criterion changes the ANNOTATION and the COUNT only. The collected set is -// untouched: nothing is admitted or dropped because of it. -// -// WHY THE FALLBACK IS QUIET, AND WHY IT STILL SAYS SO (#6175) -// ---------------------------------------------------------- -// `readAt` reads each changeset at `to` and falls back to the commit that ADDED -// it, because a changeset consumed by a release INSIDE the range is gone at `to` -// (the reason `collectAddedChangesets` walks the log instead of diffing the -// endpoints). The fallback works, and the artifact it produces is complete. -// -// It used to ANNOUNCE ITSELF AS A FAILURE anyway. `execFileSync` sends the -// child's stderr to ours unless `stdio` says otherwise, so the first `git show` -// printed git's own `fatal: path … does not exist in …` before `catch` could run -// — one line per changeset, then one line saying everything succeeded. Measured -// on the real pin bump `f995a452d2ca..7dfbeb704e1e` (#6159 / PR #6173): 9 -// `fatal:` lines, 9 complete entries, exit 0. -// -// That is the MIRROR of the rule this file already enforces. #4731 wrote "a -// degraded list and a complete one must never look alike"; here a COMPLETE list -// looked like a failure. The two failure modes cost the same, because the next -// reader's first instinct is "the digest is broken, write the changeset by hand -// with `--no-changeset`" — and that hand-written path really does drop all 9 -// releasing entries. Noise that points at the wrong remedy is not merely noise. -// -// So the first attempt CAPTURES its stderr instead of inheriting it, and the -// fallback is reported once, as a fact, beside the accounting line -// (`absentAtTo`). Two things this deliberately does not do: -// -// * It does not go silent. Silence would trade a misleading line for no line, -// and "this range crossed a release" is worth exactly one sentence — the -// same reason the cap and the degraded list announce themselves (#4731). -// * It does not mute FAILURE. When BOTH reads fail nothing was read and the -// entry would vanish, so both captured diagnostics are re-emitted, named by -// attempt, and the error still propagates. Quiet is earned by the fallback -// that worked; it is never extended to the one that did not. -// -// WHY THE UNDECLARED COMMITS ARE NAMED, NOT ONLY COUNTED (#6174) -// -------------------------------------------------------------- -// "Read what objectui declared" is the right criterion, and this changes none -// of it. What it fixes is a REPORTING gap on the criterion's own shadow: a -// commit that declared nothing is correctly kept out of the releasing list, and -// used to leave the artifact as one digit inside `omitted: N commits carrying -// no changeset`. Which commits those were, the record did not say. -// -// Measured on the real pin bump `f995a452d2ca..7dfbeb704e1e` (#6159 / PR -// #6173), the 20 undeclared commits are 1 release commit, 3 dependabot bumps, -// 15 docs/ci/scripts commits — and `0e50440e8`, `fix(form): bind `previous` for -// field rules and stop resubmitting read-only fields` (objectui#3518): 26 files -// across 5 packages and all ten locale packs, a user-visible form behaviour -// change. Inside the number 20 it is indistinguishable from `chore(deps-dev): -// Bump postcss from 8.5.25 to 8.5.26`. It shipped in the console build and is -// in no CHANGELOG anywhere — not even objectui's, because there was no -// changeset to compile. A count cannot be read; a subject can. -// -// Three boundaries this deliberately keeps: -// -// * It does not touch the CRITERION. `noChangesetCommits` is rendered exactly -// as `classifyRange` produced it — unfiltered, unreordered. Nothing enters -// or leaves the releasing list, no count moves, and the accounting line is -// byte-for-byte what it was. Presentation only, the same separation -// `objectui-range.mjs` already keeps; the rows are spelled in that script's -// `--all` vocabulary (`- _(no changeset)_ …`) so the two artifacts read as -// one, which is what "wire up the capability that already exists" means. -// * It does not CLASSIFY them. `chore: release packages` and the form fix are -// both listed, flatly. Deciding which undeclared commit "matters" means -// inferring from a subject line — the exact move #4731 removed, and it -// would fail the same way, since the commit worth naming here carries a -// `fix(form)` subject no more distinctive than the `fix(ci)` beside it. The -// reader judges; the tool only stops hiding. -// * It is NOT the stderr fallback line (#6175). That one reports how the tool -// READ A FILE, which is not a fact about the release, so it stays on stderr -// beside the accounting. This one reports WHICH COMMITS entered the build -// with nothing declared for them — which is precisely what a release record -// is for. Adjacent facts, different readers, different destinations. -// -// The gate that would stop this at the source is objectui#3387 (a PR touching -// `packages/*/src/**` with no changeset fails). Until it lands, this is the only -// place the class is visible at all; after it lands, this list goes quiet on its -// own, because there will be nothing to name. -// -// WHY THE ARTIFACT CARRIES AN UNANSWERED ADR-0087 MARKER (#6494) -// -------------------------------------------------------------- -// #6099 made this script DECLARE breaking changes (`**BREAKING**`), and #6148's -// gate requires every declared-breaking changeset to state an ADR-0087 -// disposition. The first pin bump after #6289 hit exactly that (PR #6476), was -// patched by hand — and a hand-patched marker in a GENERATED file survives only -// until the next bump rewrites it. The recurrence condition is objectui's -// standing practice, not an edge case: its release window bans `major`, so -// breaking changes arrive as `minor` plus a body annotation, which is precisely -// what this script now recognises. -// -// So the artifact carries the QUESTION. `` is a marker -// the gate parses, refuses, and reports as unanswered — the operator answers it -// at bump time, in writing, every time. The generator never picks a disposition: -// #6148's whole design is that the gate does not answer for you, and a generator -// that answers for you is the same hole with a different author. The argument in -// full, including the two rejected alternatives, is at `ADR_0087_SCAFFOLD`. - -import { execFileSync, spawnSync } from 'node:child_process'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import { gitFreeEnv } from './git-env.mjs'; -import { isEntrypoint } from './invoked-as.mjs'; -// #16421 — the `fw-gate` sandbox below copies `check-adr-0087-registration.mjs` -// in and runs it. Its staging manifest is DERIVED from that gate's module graph, -// by the same module the gate's own fixture uses, so the two cannot disagree. -import { firstPartyModuleClosure, stageFirstPartyClosure } from './first-party-closure.mjs'; - -// ── The self-test's own battery roster and floor (#13489) ────────────────── -// -// `failures.length === 0` used to be this self-test's ONLY success condition, so -// "every case held" and "the cases never ran" printed the same line. Closed the -// way PR #13487 validated on check-doc-authoring: what is pinned is the -// registered NAMES, not a number. Every section opens with `battery('')`, -// every assertion is attributed to the battery most recently opened, and the -// floor requires the OPENED set to equal the DECLARED set with each battery at -// or above its own count. -// -// ⛔ A pinned TOTAL is not the repair: a battery dropping from 9 cases to 3 -// keeps a total "right" the moment a sibling grows. -// -// The counts are a FLOOR, not an equality — adding cases is ordinary work and -// must not red. A battery BELOW its floor means cases stopped running; the -// remedy is to find what stopped registering. -const SELF_TEST_BATTERIES = Object.freeze({ - 'and one in RC pre-mode': 15, - '#6099: the `minor` + prose-annotation family': 17, - '#23112: a DECORATED annotation — a pictograph before the word': 16, - '#23118: a bold LEAD LABEL holding a `(breaking …)` parenthetical': 16, - 'end-to-end through the shell driver': 5, - 'an unwalkable range: REFUSE, loudly (#14178)': 1, - 'the INITIAL pin still degrades, and says so': 1, - '#6175: a range whose `to` endpoint is a RELEASE COMMIT': 8, - '#6174: the undeclared commits are NAMED, not only counted': 12, - '#6494: the ADR-0087 disposition scaffold': 5, - '#6494 THE ROUND TRIP, through the REAL gate': 4, - '#7004: the frontmatter shapes the old entry anchor hid': 7, - '#7044: WHERE the fence is allowed to start': 7, - '#9408: WALK COMPLETENESS, not object presence': 12, - '#9408 through the shell driver: REFUSE, and say WHICH failure': 6, - '#14178: an ABSENT endpoint is the same failure, one step earlier': 9, - '#10495: is the revision being PINNED actually on objectui main?': 16, - 'R7: the bash 3.2 floor': 5, -}); - -// DELETING an entry silences that battery's floor exactly as effectively as -// zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 18; - -// The key an assertion is filed under when no battery is open. It is not a -// declared battery, so it reds by the same set difference rather than silently -// inflating whichever battery happened to run last. -const UNATTRIBUTED_BATTERY = '(no battery open)'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = resolve(__dirname, '..'); - -/** - * Stage the shell driver and everything it needs into a throwaway framework - * root, so `bump-objectui.sh` can be run there for real. (#16421) - * - * Seven of this file's self-test cases build such a root, and all seven used to - * carry the SAME hand-written three-name manifest — `bump-objectui.sh`, this - * script, `invoked-as.mjs`. One function now, for the reason the other staging - * site in this file just learned the hard way: a manifest is a rule enforced by - * remembering, and the first time this script gained an import, CI went red with - * an `ERR_MODULE_NOT_FOUND` naming neither the import nor the list. - * - * ⭐ `bump-objectui.sh` stays NAMED, and that is not an oversight. It is a shell - * script: no `import` statement reaches it, so no module walk can find it. What - * is derived is exactly the part a walk can answer — this script's own - * first-party JS closure — and what is named is the part it cannot. - * - * @param {string} root — the throwaway framework root. - * @returns {string[]} every repo-relative path written, for a caller to assert on. - */ -function stageBumpDriver(root) { - const write = (rel, text) => { - mkdirSync(dirname(join(root, rel)), { recursive: true }); - writeFileSync(join(root, rel), text); - }; - write('scripts/bump-objectui.sh', readFileSync(join(__dirname, 'bump-objectui.sh'), 'utf8')); - return [ - 'scripts/bump-objectui.sh', - ...stageFirstPartyClosure('scripts/objectui-changeset-digest.mjs', { root: REPO_ROOT, write }), - ]; -} - -/** - * Default cap on EACH rendered list. The releasing entries (#4731) and the - * undeclared commits (#6174) are capped INDEPENDENTLY, so a long release can - * never squeeze the other list down to nothing — one budget shared between them - * would make the second list's length depend on the first's, which no reader - * could infer. A cap that fires ANNOUNCES itself, with the real total (#4731). - */ -export const DEFAULT_MAX_ENTRIES = 100; - -const LEVEL_RANK = { patch: 1, minor: 2, major: 3 }; - -/** - * @param {string} cwd - * @param {string[]} args - * @param {{ captureStderr?: boolean }} [options] `captureStderr` routes the - * child's stderr into the thrown error instead of ours. Leave it OFF by - * default: an unset `stdio` inherits our stderr (`execFileSync`'s documented - * behaviour), which is what a call whose failure is a real failure should do. - * Turn it on only where a failure is EXPECTED and retried — `readAt` (#6175). - */ -function git(cwd, args, { captureStderr = false } = {}) { - return execFileSync('git', ['-C', cwd, ...args], { - // LOCAL-ONLY, every caller (#16644). This helper serves `rev-parse`, `log`, `show`, - // `cat-file`, `merge-base`, `update-ref`, `init`, `add` and `commit` -- all against - // the repository `-C cwd` names, which is the objectui checkout on a real run and a - // mkdtemp fixture throughout the self-test. ⛔ No caller fetches, clones or pushes - // through it, so no transport configuration is lost; the two `clone`s in this file - // are spawned separately and labelled where they stand. - env: gitFreeEnv(), - encoding: 'utf8', - maxBuffer: 64 * 1024 * 1024, - ...(captureStderr ? { stdio: ['ignore', 'pipe', 'pipe'] } : {}), - }); -} - -/** - * Parse a changeset file: the frontmatter package→level map, the first - * paragraph of the summary, and the FULL body after the frontmatter. - * - * An EMPTY frontmatter block (`---\n---`) is changesets' own "release-nothing" - * marker — the file exists so the repo's own gate is satisfied, but it releases - * no package. That is precisely the signal the old type filter could not see. - * - * `summary` stays the first paragraph — that is what an entry line renders. But - * a breaking annotation is not reliably in the first paragraph (#6099: one of - * the two live specimens puts it in the last), so `body` carries the whole text - * for `hasBreakingAnnotation` to scan. - * - * The entry regex is deliberately the SAME shape the three changeset GATES use - * (`check-changeset-no-major.mjs`, `check-empty-changeset.mjs`, - * `check-adr-0087-registration.mjs`), and `check-empty-changeset.mjs`'s - * self-test asserts all four are byte-identical (#7004). This file is the fourth - * carrier and the one #7004's report did not name; it reads objectui's - * changesets rather than this repo's, so its stake is the release RECORD, not a - * gate: a bump entry the regex cannot see makes the changeset read - * "release-nothing" and drops the commit from the digest entirely — which is - * #4731's harm exactly, arrived at through the parser instead of a type filter. - * - * WHERE THE FENCE MAY START (#7044). The entry regex was aligned in #7004; the - * OTHER row of the same dialect table was not. This parser required the opening - * fence on line 1 (`lines[0]?.trim() === '---'`) long after #6923 taught the - * three gates to skip leading blank lines first — so a changeset opening with a - * single blank line declared, here, nothing at all. Measured with - * `@changesets/parse@0.4.3` on 2026-08-10, the version this repo resolves: - * - * input | @changesets/parse | this file (before) - * ---------------------------------------|-------------------|------------------- - * `---\n"@object-ui/layout": major\n---` | major | major - * one leading blank line, then the same | major | NOTHING - * two leading blank lines | major | NOTHING - * a leading whitespace-only line | major | NOTHING - * - * changesets honours the bump; this file dropped the commit out of the digest - * and, worse, handed the raw frontmatter text back as the `summary`. So it now - * carries the same preamble the three gates carry, byte for byte — - * `check-empty-changeset.mjs`'s self-test asserts that agreement across all four - * on this row too, which is what stops the fourth carrier drifting again. - * - * @param {string} text - * @returns {{ packages: Record, summary: string, body: string }} - */ -export function parseChangeset(text) { - const lines = text.split(/\r?\n/); - /** @type {Record} */ - const packages = {}; - let i = 0; - while (i < lines.length && lines[i].trim() === '') i++; // tolerate leading blank lines - if (lines[i]?.trim() === '---') { - i++; - for (; i < lines.length; i++) { - if (lines[i].trim() === '---') { - i++; - break; - } - if (/^\s*#/.test(lines[i])) continue; // a whole-line YAML comment declares nothing - // "": | '': | : - // with an optionally quoted bump value and an optional trailing ` # comment`. - const m = /^\s*["']?([^"':]+)["']?\s*:\s*["']?([A-Za-z]+)["']?(?:\s+#.*)?\s*$/.exec(lines[i]); - if (m && LEVEL_RANK[m[2].toLowerCase()]) packages[m[1].trim()] = m[2].toLowerCase(); - } - } - while (i < lines.length && !lines[i].trim()) i++; - const body = lines.slice(i).join('\n'); - const firstParagraph = []; - for (; i < lines.length; i++) { - if (!lines[i].trim()) break; - firstParagraph.push(lines[i].trim()); - } - return { - packages, - summary: firstParagraph.join(' ').replace(/\s+/g, ' ').trim(), - body: body.trimEnd(), - }; -} - -/** Highest declared level in a package→level map, or null when release-nothing. */ -export function highestLevel(packages) { - let best = null; - for (const level of Object.values(packages)) { - if (!best || LEVEL_RANK[level] > LEVEL_RANK[best]) best = level; - } - return best; -} - -/** One-line entry text, truncated AUDIBLY (never silently) at `limit` chars. */ -export function clampSummary(summary, limit = 180) { - if (summary.length <= limit) return summary; - return `${summary.slice(0, limit - 1).trimEnd()}…`; -} - -/** - * An emphasis run whose FIRST word is "breaking": `**BREAKING**`, - * `**BREAKING (v17)**`, `__Breaking change__`. - * - * Scanned anywhere in the body, because bolding a word is a deliberate act — - * nobody emphasises "breaking" by accident. Requiring it to OPEN the run is what - * keeps an emphasised ordinary sentence that happens to contain the word (real - * shape: `**Removed — … is technically breaking for anyone who wrote one**`) - * from matching, and the bounded, `*`/`_`-free tail keeps a run from swallowing - * a whole paragraph. - */ -const BREAKING_EMPHASIS_LABEL = /(\*\*|__)[ \t]*breaking\b[^*_\n]{0,48}\1/i; - -/** - * A DECORATION an author may put in front of the word (#23112): a run of - * pictographic symbols, each with its optional variation selector — `⚠️` is - * U+26A0 WARNING SIGN + U+FE0F, and `⚠` without the selector is the same sign. - * It sits AFTER the heading or bullet mark, on either side of the emphasis mark - * (`## ⚠️ BREAKING`, `⚠️ **BREAKING`, `**⚠️ Breaking`), and admits nothing else: - * no letter, no digit, no ASCII punctuation, so the line-start anchor below is - * exactly as strict as it was for every line that carries no pictograph. - * - * Measured over objectui's whole changeset history (every blob under - * `.changeset/` on every head of a full clone, objectui `main` at `5595aabca30b`: - * 5678 blobs, 704 block-head lines carrying the word), the ONLY decoration that - * occurs is `⚠️`, in four placements on 51 blobs, and 47 of those were read as - * not breaking — objectui#12242's `## ⚠️ BREAKING, priced as minor …` among them. - * - * Why a CLASS rather than the one sign the corpus shows, and why not "any - * non-word run": ASCII punctuation in front of the word is a PROSE shape in that - * same corpus — `(breaking …)`, `"Breaking for TypeScript readers" below`, - * `— breaking for a top-level logo` — and a code span (`` `breaking` ``) names - * an identifier, so admitting it would trade this miss for false positives. A - * pictograph in front of the word at the head of a block is never prose; closing - * the class keeps the next author's `🚨 BREAKING` from being the same miss again. - */ -const DECORATION = String.raw`(?:\p{Extended_Pictographic}[\u{FE0E}\u{FE0F}]?[ \t]*)*`; - -/** The same label, but required to OPEN the text — used to avoid restating it. */ -const OPENS_WITH_BREAKING_LABEL = new RegExp( - String.raw`^[ \t]*${DECORATION}(?:\*\*|__)[ \t]*${DECORATION}breaking\b`, - 'iu', -); - -/** - * A block (paragraph, heading or top-level bullet) that OPENS with the word - * "breaking" — `## Breaking note …`, `Breaking for anyone reading \`node.ui\` …`, - * `BREAKING CHANGE: …` (the conventional-commit footer needs no separate rule; - * it opens a block by construction) — optionally behind a `DECORATION` - * (`## ⚠️ BREAKING, priced as minor …`). - */ -const BREAKING_BLOCK_OPENER = new RegExp( - String.raw`^[ \t]*(?:#{1,6}[ \t]+)?(?:[-*+][ \t]+)?${DECORATION}(?:\*\*|__|\*|_)?[ \t]*${DECORATION}breaking\b`, - 'iu', -); - -/** - * A bold LEAD LABEL that carries the annotation in a parenthetical (#23118): - * ``**`@object-ui/layout` (breaking, declared `minor` per the version policy).**``, - * `**Narrowed (breaking).**`, `- **Tolerant face (breaking for invalid documents).**`. - * The word is not first, so the opener above cannot see it, and the label names - * no word first either, so neither can `BREAKING_EMPHASIS_LABEL`. - * - * A SECOND POSITION, beside the opener and not inside it, with all three parts - * required: - * - * * the line OPENS with `**`, after optional indentation and an optional - * bullet — the label is the line's first token; - * * the `(` sits INSIDE that first bold span: nothing before it may close the - * span, and the span closes on the same line; - * * `breaking` is the parenthetical's FIRST word, so `(non-breaking)` and - * `(not breaking)` stay what they say. - * - * So a `(breaking …)` anywhere else is prose — mid-sentence, after a label that - * already closed (`**Behaviour change** (breaking …`), in a bold span that is not - * the line's first token — and so is a label with no parenthetical. - * - * Judged on EVERY line, not only at a block start: a bullet opens a line without - * opening a block (`- **Strict authoring face.** …` then `- **Tolerant face - * (breaking for invalid documents).** …`), and a wrap column cannot manufacture - * a closed bold span holding the parenthetical the way it can land a bare word - * first on a line. - * - * Measured over objectui's changeset history (every `.changeset/*.md` blob on - * every head and every pull-request head of a clone, objectui `main` at - * `45ae6b89e845`: 6449 blobs), the label opens 41 lines in 39 blobs (18 paths), - * none of them read as breaking before, and each is a lead label. It never sits - * behind `__`, a heading mark, an ordered-list mark or a pictograph, and its - * span never crosses a line, so none of those is admitted. - * - * `markBreaking` STAMPS such an entry. The label names a package or a topic, not - * the class, so it is not a label `OPENS_WITH_BREAKING_LABEL` keeps verbatim, and - * the uniform marker is what keeps the count note's "Each is marked" true. None - * of those 39 blobs opens its summary with the label, so the stamp lands on an - * ordinary first paragraph there. - */ -const BREAKING_LEAD_LABEL = /^[ \t]*(?:[-*+][ \t]+)?\*\*(?:(?!\*\*)[^\n])*?\([ \t]*breaking\b(?:(?!\*\*)[^\n])*\*\*/i; - -/** - * Did the AUTHOR annotate this change as breaking, in the changeset body? - * - * THE PRECISION RULE IS POSITIONAL, NOT LEXICAL. "breaking" is an ordinary - * English word that appears in changeset prose all the time; what makes an - * occurrence a *declaration* is where the author put it — in an emphasis label, - * at the head of a block, or as the first word of a parenthetical inside the - * bold lead label that opens a line (`BREAKING_LEAD_LABEL`, #23118). It is never - * enough that the word merely occurs. - * - * Measured over `f5bc4c78be76..f995a452d2ca` (65 changesets, 5 mention the word): - * - * FLAGGED `042e09d77` `**BREAKING (v17)** — field widgets receive …` (label) - * FLAGGED `6e794a19e` `Breaking for anyone reading \`node.ui\` …` (block, LAST paragraph) - * FLAGGED `8ec406728` `## Breaking note — read before tracking …` (heading) - * FLAGGED `d22ae31ce` `Breaking semantics, in FROM → TO form:` (block) - * NOT `9e9e9a92f` `… is technically breaking for anyone who wrote one, but only in - * the sense that TypeScript now reports what was already true` - * - * Two negatives fall out of the positional anchor with no lexical guard, which - * is why there is none: a hedged mid-sentence mention is not at a block head, - * and a negated form ("non-breaking", "not breaking", "no breaking changes" — - * `d22ae31ce` carries the second) does not START with the word, so the anchor - * rejects it. Same for words that merely CONTAIN it ("groundbreaking"). - * - * Deliberately line-INSENSITIVE: block-initial, never line-initial. Changeset - * prose is hard-wrapped, so any sentence containing "breaking" can land it first - * on a line by accident — but only the author's own paragraphing puts it first - * in a block. - * - * @param {string} body full changeset text after the frontmatter - */ -export function hasBreakingAnnotation(body) { - if (!body) return false; - if (BREAKING_EMPHASIS_LABEL.test(body)) return true; - const lines = body.split(/\r?\n/); - let atBlockStart = true; - for (const line of lines) { - if (!line.trim()) { - atBlockStart = true; - continue; - } - // #23118: line-initial, unlike the opener below — see `BREAKING_LEAD_LABEL`. - if (BREAKING_LEAD_LABEL.test(line)) return true; - // A heading opens a block whether or not a blank line precedes it. - if (atBlockStart || /^[ \t]*#{1,6}[ \t]+/.test(line)) { - if (BREAKING_BLOCK_OPENER.test(line)) return true; - } - atBlockStart = false; - } - return false; -} - -/** - * The uniform marker the digest stamps on a breaking entry, so the compiled - * release record shows the class without a human having to rescue it by hand - * (#6110 did exactly that rescue, which is what #6099 records). - * - * Uniform, and deliberately WITHOUT a version tag: the digest cannot derive one, - * and inventing "(v17)" would be the script asserting something no input says. - * Where the author wrote their own richer label it survives verbatim instead — - * `markBreaking` never restates a summary that already opens with one. - */ -export const BREAKING_MARKER = '**BREAKING**'; - -/** Prefix an entry's text with `BREAKING_MARKER` unless it already carries one. */ -export function markBreaking(text) { - if (OPENS_WITH_BREAKING_LABEL.test(text)) return text; - return `${BREAKING_MARKER} — ${text}`; -} - -/** - * The ADR-0087 disposition SCAFFOLD — a question, never an answer (#6494). - * - * `scripts/check-adr-0087-registration.mjs` (#6148) requires every changeset - * that DECLARES a breaking change to carry exactly one `adr-0087:` disposition - * marker. From #6099 onward this script declares one as soon as an upstream - * entry is annotated — and that is objectui's ORDINARY shape, not an exotic - * one: its release window bans `major` (`scripts/check-changeset-no-major.mjs`), - * so a breaking change ships as `minor` plus the author's own body annotation, - * which is exactly what `hasBreakingAnnotation` above finds. Measured on the - * range PR #6476 bumped: 24 changesets, 2 of them annotated-breaking. So the - * gate's red is the COMMON case on any substantive pin bump, and a marker - * patched in by hand lives exactly until the next bump regenerates the file. - * - * Hence a placeholder that the gate STILL JUDGES: - * - * * `TODO` parses as neither `registered ...` nor `not-required (...) ...`, - * so the gate reports `unparseable disposition: "TODO …"` and stays RED. - * Present-but-unanswered reads differently from absent, and it lands the - * gate's own fix-it block listing the four accepted forms. - * * It is ONE marker. Two would produce a different red ("N markers -- exactly - * one disposition is expected") that names the tooling instead of the - * question, so nothing else in the emitted body may spell `adr-0087:`. - * * It says NOTHING about which disposition applies. Writing `not-required - * (...)` here — even the category that is usually right — would make this - * script perform the judgement #6148 exists to extract from a human, and it - * would then be written unread on every bump forever. That gate's whole - * design is that it never answers for you; a generator that answers for you - * is the same hole with a different author. - * - * An HTML comment for the reason the gate's own header gives: a changeset body - * is copied VERBATIM into CHANGELOG.md, so the marker must render as nothing for - * end users while staying fully visible in the diff, in `git grep` and in the - * gate's log. - */ -export const ADR_0087_SCAFFOLD = - ''; - -/** - * Will `check-adr-0087-registration.mjs` judge THIS ARTIFACT as declaring a - * breaking change? Read the artifact we actually rendered, not our belief about - * it — the scaffold has to appear on exactly the files that gate will judge. - * - * That gate's `breakingDeclaration()` unions three signals, and this mirrors the - * two a generated console changeset can ever raise: - * - * 1. a `major` bump in the frontmatter — reachable here in RC pre-mode and via - * `CONSOLE_BUMP=major`, and it arms the gate ON ITS OWN, with no marker - * anywhere in the body. - * 2. `**BREAKING` (or a `BREAKING CHANGE:` line) in the body. - * 3. a conventional-commit `!` on the summary line — UNREACHABLE, deliberately - * not mirrored: the first line of the emitted file is this script's own - * fixed sentence (`Console (objectui) refreshed to …`) and the gate's - * pattern `^[a-z]+(\([^)]*\))?!:` is case-sensitive, so nothing this - * generator writes can start it. - * - * Why the RENDERED body rather than the `breaking` count: the two nearly agree, - * and the gap runs one way. `breaking > 0` always renders `**BREAKING**` (the ⚠️ - * note restates the marker by construction), so the count implies the signal; - * the converse can fail. An entry whose own summary carries an emphasis run too - * long for `BREAKING_EMPHASIS_LABEL`'s 48-character tail is NOT counted breaking - * here, yet its text reaches the rendered line where the gate's looser - * `/\*\*BREAKING/` matches it. Reading the artifact closes that by construction - * instead of by agreement between two predicates. - * - * Why the gate's own function is not imported: it would put the release-critical - * bump path one rename away from failing, and the claim being pinned is about - * ANOTHER script's verdict -- only that script's own run settles it. (The gate's - * former top-level CLI dispatch, which made any import execute the gate, was - * entry-guarded in #6566; the coupling reason stands on its own.) The agreement - * is pinned in the self-test instead, which runs the real gate as a child - * process over a real temp repository. - * - * @param {{ bump: string, body: string }} artifact - */ -export function artifactDeclaresBreaking({ bump, body }) { - if (bump === 'major') return true; - return /\*\*BREAKING/i.test(body) || /^\s*BREAKING[ -]CHANGE/mi.test(body); -} - -/** - * Does the `from..to` walk COMPLETE in this checkout, or does the history stop - * INSIDE the range? (#9408) - * - * `bump-objectui.sh` used to answer this with `git cat-file -e OLD_SHA` — "does - * the OLD endpoint exist as an object". That is a different question, and the - * gap between the two is measured, not theoretical: on the pin bump that landed - * `.changeset/console-82a94170c405.md`, `cat-file -e` PASSED, the guard reported - * a walkable range, and this script exited 0 having derived a complete-LOOKING - * release record from a history truncated at commit 110 of 191. - * - * THE MECHANISM — a truncated history does not merely stop early, it stops with - * a LIE. The oldest commit git can still see is presented as PARENTLESS, so git - * diffs it against the empty tree and reports every `.changeset/*.md` in its - * TREE as `A`dded by it. `collectAddedChangesets`'s `seen` dedup then hands that - * one commit exactly the paths no newer commit claimed, and a whole batch of - * upstream releases is credited to a single "sink" commit that really added one. - * Reproduced byte-for-byte on both containers that shipped a bad record: 36 of - * 119 entries credited to `183d09b78`, and 12 to `75444e38a`. Each of those - * adds exactly ONE changeset in a complete clone. - * - * WHY THE TEST IS "PARENTLESS INSIDE THE RANGE", NOT "IS THE CLONE SHALLOW". - * The sink IS the parentless commit, so this tests the HARM directly rather than - * one cause of it: a `--depth` clone, a hand-written `.git/shallow`, a graft, a - * `git replace`, and a range spanning unrelated histories all arrive here, and - * none of them has to be named. It is also strictly more PRECISE than asking - * about the clone — a shallow clone whose boundary sits AT OR BEFORE `from` - * walks the range completely, and refusing that would be a false positive on a - * checkout that can answer the question perfectly well. In an untruncated - * history `from..to` cannot contain a parentless commit at all: every commit in - * it has a parent, `from` itself at the far end. So a hit here is always real. - * - * WHY NOT THE ARITHMETIC CHECK #9408 SKETCHED — "no commit may be credited with - * more added changesets than `git show --name-only` attributes to it". Measured - * against both bad runs it fires on NEITHER (36 credited vs 72 attributed; 12 vs - * 47), and it cannot fire on any history: `git show --name-only` on the sink - * runs the very same empty-tree diff that produced the fault, and the credited - * set is that attributed set minus the `seen` dedup — a SUBSET, by construction. - * The comparison is a tautology with no false positives because it has no - * positives at all. Pinned as such in the self-test, so it cannot be restored as - * a "cheap backstop" later. - * - * @returns {{ truncated: boolean, sinks: string[], shallow: boolean }} `sinks` - * are the parentless commits inside the range, newest first — the commits the - * empty-tree diff would have made absorb a batch. - */ -export function findRangeTruncation(objectuiRoot, from, to) { - const sinks = git(objectuiRoot, ['rev-list', '--parents', `${from}..${to}`]) - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - // " …" — exactly one field means no parent at all. - .filter((line) => !/\s/.test(line)); - let shallow = false; - try { - shallow = git(objectuiRoot, ['rev-parse', '--is-shallow-repository']).trim() === 'true'; - } catch { - // An old git without `--is-shallow-repository` still gets the real verdict - // above; this only enriches the diagnostic, so it must never be the thing - // that decides. Same reason the test is the parentless commit, not the flag. - } - return { truncated: sinks.length > 0, sinks, shallow }; -} - -/** The operator-facing account of a truncated range: what, where, and the fix. */ -export function describeTruncation({ objectuiRoot, from, to, truncation }) { - const { sinks, shallow } = truncation; - const n = sinks.length; - return [ - `✗ objectui-changeset-digest: the history of ${objectuiRoot} STOPS INSIDE ` + - `${from.slice(0, 12)}..${to.slice(0, 12)} — the range cannot be walked completely.`, - ` ${n} commit${n === 1 ? '' : 's'} in this range ${n === 1 ? 'has' : 'have'} no parent ` + - `here${shallow ? ' (this is a shallow clone)' : ''}: ${sinks.map((s) => s.slice(0, 12)).join(', ')}`, - ` Deriving anyway would credit every changeset in ${n === 1 ? "that commit's" : "those commits'"} ` + - `TREE to ${n === 1 ? 'it' : 'them'} — git diffs a parentless commit against the empty tree — `, - ` and the artifact would look complete while naming the wrong commits (objectstack#9408).`, - ` Fix it: git -C ${objectuiRoot} fetch --unshallow`, - ].join('\n'); -} - -/** - * Are both endpoints of `from..to` present as commit OBJECTS in this checkout? - * (#14178) - * - * The other half of the walkability precondition, and the one that used to be - * answered one step too early. `findRangeTruncation` asks whether the walk - * BETWEEN the endpoints completes; this asks whether the endpoints are here to - * walk between at all. A shallow clone whose boundary sits AFTER `from` fails - * THIS test, never the other one — `rev-list from..to` cannot even start — and - * that is the case a `--depth` clone produces by default. - * - * Reported rather than thrown, and reported per endpoint, because the two are - * not the same fact: an absent `from` on the pin bump is the ordinary shallow - * checkout (deepen it), while an absent `to` is a revision this checkout has - * never seen (fetch it). The caller picks the remedy from the answer, and the - * RE-CHECK after that remedy — never the fetch's exit status — decides whether - * it worked (the C17 lesson, which measured `fetch --unshallow` exiting 0 while - * changing nothing). - * - * `git()` inherits our stderr by default, so the probe captures it instead: git - * prints its own `fatal: Not a valid object name …` for a failure this function - * exists to REPORT, and an unexplained `fatal:` above a full explanation is the - * #6175 shape (a handled condition that reads like a crash). The captured text - * is re-emitted inside the refusal by `describeAbsentEndpoints`, where it names - * WHICH failure this was — absent, corrupt, or an unresolvable rev. - * - * @returns {{ absent: boolean, endpoints: Array<{ role: string, rev: string, diagnostic: string }>, shallow: boolean }} - */ -export function findAbsentEndpoints(objectuiRoot, from, to) { - /** @type {Array<{ role: string, rev: string, diagnostic: string }>} */ - const endpoints = []; - for (const [role, rev] of [ - ['from', from], - ['to', to], - ]) { - try { - git(objectuiRoot, ['cat-file', '-e', `${rev}^{commit}`], { captureStderr: true }); - } catch (err) { - endpoints.push({ role, rev, diagnostic: gitDiagnostic(err) }); - } - } - let shallow = false; - try { - shallow = git(objectuiRoot, ['rev-parse', '--is-shallow-repository']).trim() === 'true'; - } catch { - // Enriches the diagnostic only — the verdict above never depends on it, the - // same separation `findRangeTruncation` keeps. - } - return { absent: endpoints.length > 0, endpoints, shallow }; -} - -/** - * The operator-facing account of an absent endpoint: what, where, and the fix. - * - * Deliberately shaped like `describeTruncation`: the two are one class of - * failure with two causes, and a caller that treats them alike must be able to - * read them alike. `PREREQUISITE NOT MET` is the repo's vocabulary for "the - * measurement never happened" — the one verdict that must never be mistaken for - * either a pass or a finding. - */ -export function describeAbsentEndpoints({ objectuiRoot, from, to, absence }) { - const { endpoints, shallow } = absence; - const roles = endpoints.map((e) => `${e.role}=${e.rev.slice(0, 12)}`).join(', '); - const lines = [ - `✗ objectui-changeset-digest: PREREQUISITE NOT MET — ${endpoints.length === 1 ? 'an endpoint' : 'both endpoints'} of ` + - `${from.slice(0, 12)}..${to.slice(0, 12)} ${endpoints.length === 1 ? 'is' : 'are'} not present as a commit ` + - `object in ${objectuiRoot}.`, - ` missing here${shallow ? ' (this is a shallow clone)' : ''}: ${roles}`, - ` Nothing can be derived from a range one of whose ends is absent, and a`, - ` degraded record derived anyway would carry a bump level nobody declared`, - ` (objectstack#14178). This is the SAME class as a range that stops inside`, - ` itself (objectstack#9408): repair the input, then ask again.`, - ` Fix it: git -C ${objectuiRoot} fetch --unshallow (a shallow clone)`, - ` or: git -C ${objectuiRoot} fetch origin (an object never fetched)`, - ]; - for (const e of endpoints) { - lines.push(` git on ${e.role} (${e.rev.slice(0, 12)}):`); - lines.push(e.diagnostic); - } - return lines.join('\n'); -} - -/** - * Collect the `.changeset/*.md` files ADDED over `from..to`, newest first. - * - * Walks the log rather than diffing the two endpoints: a changeset consumed by - * a release INSIDE the range is gone at `to`, and an endpoint diff would drop - * it — the released frontend change would vanish from the release record, the - * very failure this whole mechanism exists to prevent. - * - * `commits` is every non-merge commit in the range (newest first) and - * `commitsWithChangesetShas` the subset that added one, so a caller can NAME - * the commits it is leaving out instead of only counting them (#4843). - * - * @returns {{ entries: Array<{ path: string, sha: string, subject: string }>, commits: Array<{ sha: string, subject: string }>, totalCommits: number, commitsWithChangeset: number, commitsWithChangesetShas: Set }} - */ -export function collectAddedChangesets(objectuiRoot, from, to) { - const commits = git(objectuiRoot, ['log', '--no-merges', '--format=%H%x09%s', `${from}..${to}`]) - .split('\n') - .filter(Boolean) - .map((line) => { - const tab = line.indexOf('\t'); - return { sha: line.slice(0, tab), subject: line.slice(tab + 1) }; - }); - const totalCommits = commits.length; - - const raw = git(objectuiRoot, [ - 'log', - '--no-merges', - '--diff-filter=A', - '--name-only', - '--format=%x01%H%x02%s', - `${from}..${to}`, - '--', - '.changeset/', - ]); - - /** @type {Array<{ path: string, sha: string, subject: string }>} */ - const entries = []; - const seen = new Set(); - const commitsWithChangeset = new Set(); - let sha = ''; - let subject = ''; - for (const line of raw.split('\n')) { - if (line.startsWith('\u0001')) { - const sep = line.indexOf('\u0002'); - sha = line.slice(1, sep); - subject = line.slice(sep + 1); - continue; - } - const path = line.trim(); - if (!path.startsWith('.changeset/') || !path.endsWith('.md')) continue; - if (path.endsWith('/README.md')) continue; - if (seen.has(path)) continue; - seen.add(path); - commitsWithChangeset.add(sha); - entries.push({ path, sha, subject }); - } - return { - entries, - commits, - totalCommits, - commitsWithChangeset: commitsWithChangeset.size, - commitsWithChangesetShas: commitsWithChangeset, - }; -} - -/** A failed `execFileSync` rendered as the child's own diagnostic, indented. */ -function gitDiagnostic(err) { - const captured = typeof err?.stderr === 'string' ? err.stderr : ''; - const text = captured.trim() || err?.message || String(err); - return text - .split('\n') - .map((line) => ` ${line}`) - .join('\n'); -} - -/** - * Read a changeset's content at `to`, falling back to the commit that added it. - * - * `fellBack` is the caller's only handle on "this range crossed a release". - * git's own `fatal:` used to be that handle by accident — which made a complete - * result read as a failed one, the defect #6175 records; see the header note. - * - * Exported for the self-test: the both-reads-failed branch cannot be reached - * through `classifyRange`, whose `sha` always comes from `--diff-filter=A` and - * therefore always has the path. An error path no test can enter is an error - * path that quietly rots into silence, which is the one outcome #6175 forbids. - * - * @returns {{ text: string, fellBack: boolean }} - */ -export function readAt(objectuiRoot, to, sha, path) { - try { - return { - text: git(objectuiRoot, ['show', `${to}:${path}`], { captureStderr: true }), - fellBack: false, - }; - } catch (atTo) { - try { - return { - text: git(objectuiRoot, ['show', `${sha}:${path}`], { captureStderr: true }), - fellBack: true, - }; - } catch (atSha) { - // BOTH reads failed: nothing was read, and this entry is about to be lost - // from the release record. Say so with everything git told us, twice over. - console.error( - `✗ objectui-changeset-digest: cannot read ${path} — neither at \`to\` nor at the commit that added it.`, - ); - console.error(` at \`to\` (${to}):`); - console.error(gitDiagnostic(atTo)); - console.error(` at the commit that added it (${sha}):`); - console.error(gitDiagnostic(atSha)); - throw atSha; - } - } -} - -/** - * Is the framework repo in changesets pre-release (RC) mode? - * - * Mirrors `scripts/check-changeset-no-major.mjs`: outside an RC window a - * `major` on any package promotes the WHOLE lockstep group, and that gate - * rejects it. Inside pre-mode a `major` only ever yields `X.0.0-.N`, which - * is what an RC window is for — so the level passes through untouched. - * - * WHAT OBJECTUI ACTUALLY DECLARES (corrected #6099). This comment used to assert - * that "objectui marks its breaking symbol renames `major` routinely", and the - * `downgradedMajor` path below was written as the ROUTINE case that assertion - * implies. Measured, the opposite holds: over `f5bc4c78be76..f995a452d2ca`, - * **0 of 64** releasing changesets declared `major` — including both commits - * whose subject carries a conventional-commit `!`. objectui observes the same - * launch-window convention this repo does, and ships a breaking change as - * `minor` plus an annotation the author writes in the changeset body. - * - * So `downgradedMajor` is a SAFETY NET for a level not observed in practice, not - * the common path — it stays because it is still the right answer if a `major` - * ever does arrive (an RC window closing upstream would do it), but nothing may - * be built on the premise that it fires. In particular the breaking COUNT must - * not be derived from it: that was the #6099 defect, and `hasBreakingAnnotation` - * is what actually carries the class now. - */ -export function inPreMode(frameworkRoot) { - try { - const pre = JSON.parse(readFileSync(join(frameworkRoot, '.changeset', 'pre.json'), 'utf8')); - return pre?.mode === 'pre'; - } catch { - return false; - } -} - -/** - * THE criterion: which objectui changes over `from..to` actually ship in the - * frontend release, as objectui DECLARED it — plus a full account of what is - * being left out and why. - * - * This is the single shared implementation. `bump-objectui.sh` (via - * `buildDigest` below, #4731) and `scripts/objectui-range.mjs` (#4843) both go - * through it, so the platform release record and the release page's Console - * section can never disagree about what "a releasing frontend change" means. - * Two copies of this rule would drift, and the first thing they would drift on - * is the class that already went missing once: breaking `refactor(...)!`. - * - * Nothing here reads a commit type. Grouping output BY type is presentation and - * belongs to the caller; it must never become a filter again. - * - * `absentAtTo` counts the changesets that were gone at `to` and had to be read - * from the commit that added them — the readable form of what used to arrive as - * a screenful of git `fatal:` lines (#6175). - * - * @returns {{ releasing: Array, releaseNothingEntries: Array, noChangesetCommits: Array, releaseNothing: number, noChangeset: number, changesetsAdded: number, absentAtTo: number, totalCommits: number }} - */ -export function classifyRange({ objectuiRoot, from, to }) { - // #9408: the precondition, asserted in the SINGLE SHARED implementation so no - // consumer can derive from a truncated walk. `objectui-range.mjs` catches this - // and dies with its own message; the digest CLI checks first and returns 2. - // Placing it here is what makes "never fabricate" structural rather than a - // habit each caller has to remember — the same argument that put the releasing - // criterion itself in this function. - // #14178: the OTHER half of the same precondition, asked in the same place and - // for the same reason. An absent endpoint reaches `rev-list` as a bare git - // `fatal:` — a failure, but an unnamed one, and the caller cannot tell it from - // a broken checkout. Named here, both consumers get the same account. - const absence = findAbsentEndpoints(objectuiRoot, from, to); - if (absence.absent) { - throw new Error(describeAbsentEndpoints({ objectuiRoot, from, to, absence })); - } - const truncation = findRangeTruncation(objectuiRoot, from, to); - if (truncation.truncated) { - throw new Error(describeTruncation({ objectuiRoot, from, to, truncation })); - } - const { entries, commits, totalCommits, commitsWithChangesetShas } = collectAddedChangesets( - objectuiRoot, - from, - to, - ); - - const releasing = []; - const releaseNothingEntries = []; - let absentAtTo = 0; - for (const entry of entries) { - const read = readAt(objectuiRoot, to, entry.sha, entry.path); - if (read.fellBack) absentAtTo++; - const { packages, summary, body } = parseChangeset(read.text); - const level = highestLevel(packages); - if (!level) { - releaseNothingEntries.push({ ...entry, summary: summary || entry.subject }); - continue; - } - // The breaking verdict lives HERE, in the single shared implementation, for - // the same reason the releasing verdict does: two copies would drift, and - // the first thing they would drift on is this exact class (#4731 / #6099). - const annotated = hasBreakingAnnotation(body); - releasing.push({ - ...entry, - level, - packages: Object.keys(packages), - summary: summary || entry.subject, - breaking: level === 'major' || annotated, - breakingSource: level === 'major' ? 'declared-major' : annotated ? 'annotation' : null, - }); - } - - // Breaking first, then by declared level, then in the log's own (newest-first) - // order. The class the old filter could not represent at all now leads. - const order = { major: 0, minor: 1, patch: 2 }; - releasing.sort((a, b) => order[a.level] - order[b.level]); - - const noChangesetCommits = commits.filter((c) => !commitsWithChangesetShas.has(c.sha)); - - return { - releasing, - releaseNothingEntries, - noChangesetCommits, - releaseNothing: releaseNothingEntries.length, - noChangeset: noChangesetCommits.length, - changesetsAdded: entries.length, - absentAtTo, - totalCommits, - }; -} - -/** - * Build the digest for a range. - * - * @returns {{ bump: string, declaredLevel: string|null, breaking: number, breakingByLevel: number, breakingByAnnotation: number, releasing: Array, releaseNothing: number, noChangeset: number, noChangesetCommits: Array, changesetsAdded: number, absentAtTo: number, totalCommits: number, downgradedMajor: boolean, adr0087Scaffold: boolean, body: string }} - */ -export function buildDigest({ - objectuiRoot, - frameworkRoot = REPO_ROOT, - from, - to, - max = DEFAULT_MAX_ENTRIES, - bumpOverride = '', -}) { - const { - releasing, - releaseNothing, - noChangeset, - noChangesetCommits, - changesetsAdded, - absentAtTo, - totalCommits, - } = classifyRange({ - objectuiRoot, - from, - to, - }); - - const declaredLevel = releasing.length - ? releasing.reduce( - (best, r) => (LEVEL_RANK[r.level] > LEVEL_RANK[best] ? r.level : best), - 'patch', - ) - : null; - // #6099: declared `major` OR the author's own breaking annotation. Level alone - // pinned this at 0 for every launch-window range objectui has ever shipped. - const breakingEntries = releasing.filter((r) => r.breaking); - const breaking = breakingEntries.length; - const breakingByLevel = breakingEntries.filter((r) => r.breakingSource === 'declared-major').length; - const breakingByAnnotation = breaking - breakingByLevel; - - // Ruling A (maintainer, 2026-10-21, objectstack#25105): the platform's version - // line follows the METADATA PROTOCOL alone, and the console never changes it, - // so the refresh changeset is `patch` whatever objectui declared over the - // range — in RC pre-mode too, which is why `inPreMode` no longer exempts a - // declared `major` here. `declaredLevel` is still derived and reported (the - // entry lines carry it), and `CONSOLE_BUMP` (`bumpOverride`) still wins, so an - // operator who knows better says so out loud. `downgradedMajor` keeps its name - // and its meaning — a declared `major` recorded lower — and `recordedBelow` is - // the wider fact: ANY declared level recorded below itself, `minor` included. - const bump = bumpOverride || 'patch'; - const recordedBelow = !bumpOverride && declaredLevel !== null && LEVEL_RANK[declaredLevel] > LEVEL_RANK[bump]; - const downgradedMajor = recordedBelow && declaredLevel === 'major'; - - const shown = releasing.slice(0, Math.max(0, max)); - const hidden = releasing.length - shown.length; - - const lines = []; - for (const r of shown) { - // Mark AFTER clamping, so the marker can never be the thing truncation eats - // — a breaking entry silently losing its marker is the #6099 failure again, - // one layer down. The clamp budget stays spent on the author's own text. - const text = r.breaking ? markBreaking(clampSummary(r.summary)) : clampSummary(r.summary); - lines.push(`- **${r.level}** — ${text} (objectui \`${r.sha.slice(0, 9)}\`)`); - } - if (hidden > 0) { - // A cap that fires SAYS SO, with the real count. Silent truncation and no - // truncation are indistinguishable in the artifact (#4731). - lines.push( - `- …and ${hidden} more releasing changeset${hidden === 1 ? '' : 's'} in this range (list capped at ${max}; see the objectui range below).`, - ); - } - if (!lines.length) { - lines.push( - '- _No releasing changeset in this range — every objectui commit here declared release-nothing._', - ); - } - - const omitted = []; - if (releaseNothing > 0) { - omitted.push( - `${releaseNothing} release-nothing changeset${releaseNothing === 1 ? '' : 's'}`, - ); - } - if (noChangeset > 0) { - omitted.push(`${noChangeset} commit${noChangeset === 1 ? '' : 's'} carrying no changeset`); - } - - const accounting = - `Derived from the changesets objectui declared over the range — ` + - `${releasing.length} releasing of ${changesetsAdded} changeset${changesetsAdded === 1 ? '' : 's'} added ` + - `across ${totalCommits} non-merge commit${totalCommits === 1 ? '' : 's'}` + - (omitted.length ? `; omitted: ${omitted.join(', ')} (they ship no package code).` : '.'); - - const notes = []; - if (breaking > 0) { - // The hint must NAME its source. Saying "declare a **major** bump" when the - // source is a body annotation would be false on every launch-window range, - // which is every range objectui has shipped so far (#6099). - const byLevel = `${breakingByLevel} by a declared \`major\` level`; - const byAnnotation = `${breakingByAnnotation} by the author's own breaking annotation in the changeset body`; - const source = - breakingByLevel && breakingByAnnotation - ? `${byLevel}, ${byAnnotation}` - : breakingByLevel - ? byLevel - : `${byAnnotation} — objectui declares no \`major\` inside a launch window ` + - `(\`scripts/check-changeset-no-major.mjs\`)`; - notes.push( - `⚠️ ${breaking} of these ${breaking === 1 ? 'carries' : 'carry'} a breaking change: ${source}. ` + - `Each is marked ${BREAKING_MARKER} in the list above — read them before compiling the release record` + - (recordedBelow - ? `. The declared \`${declaredLevel}\` is recorded here as \`${bump}\`: the platform's version line follows ` + - `the metadata protocol, which the console never changes (\`scripts/check-changeset-no-major.mjs\`) — ` + - `the breaking entries are listed above, unabridged, and keep their marker.` - : '.'), - ); - } - - // --- #6174: NAME the commits that declared nothing, don't only count them --- - // Rendered straight from `classifyRange`'s own `noChangesetCommits`, in its - // order, with no predicate of any kind: presentation, never a criterion (see - // the header note). Zero-suppressed, and the silence is safe because this - // block is never the SOLE record of the fact — whenever the count is non-zero - // the accounting line above already carries it, so an absent block can only - // mean "every commit in this range declared something", never "the section - // was not written" (the same reasoning that lets #6175's fallback sentence - // stay quiet on a range that crossed no release). - const undeclared = []; - if (noChangesetCommits.length > 0) { - const n = noChangesetCommits.length; - undeclared.push( - `**In this console build, declared nowhere** — objectui merged ${n} ` + - `commit${n === 1 ? '' : 's'} in this range with no \`.changeset/*.md\`. ` + - `The code is inside the pin above and ships here, but nothing upstream declared ` + - `${n === 1 ? 'it' : 'them'}, so ${n === 1 ? 'it appears' : 'they appear'} in no objectui ` + - `CHANGELOG and in no entry above. Listed by subject rather than counted, because a ` + - `count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); ` + - `the upstream gate that would prevent this is objectui#3387.`, - '', - ); - const shownUndeclared = noChangesetCommits.slice(0, Math.max(0, max)); - for (const c of shownUndeclared) { - undeclared.push( - `- _(no changeset)_ ${clampSummary(c.subject)} (objectui \`${c.sha.slice(0, 9)}\`)`, - ); - } - const hiddenUndeclared = n - shownUndeclared.length; - if (hiddenUndeclared > 0) { - // Same rule as the releasing cap, and the reason it is worth repeating: - // this list's whole purpose is that a number hides which commits it is - // made of, so truncating it back into a number in silence would restore - // the exact defect. The marker carries the REAL TOTAL and the command - // that produces the rest (#4731: a degraded list and a complete one must - // never look alike). - undeclared.push( - `- …and ${hiddenUndeclared} more commit${hiddenUndeclared === 1 ? '' : 's'} with no ` + - `changeset — this list is capped at ${max}, the range has ${n} in total. ` + - `Run \`node scripts/objectui-range.mjs --from ${from.slice(0, 12)} ` + - `--to ${to.slice(0, 12)} --all\` for the complete list.`, - ); - } - } - - const rendered = [ - accounting, - '', - ...lines, - ...(notes.length ? ['', ...notes] : []), - ...(undeclared.length ? ['', ...undeclared] : []), - ].join('\n'); - - // --- #6494: the artifact carries the ADR-0087 QUESTION, never its answer --- - // A changeset that declares breaking must carry a disposition marker (#6148), - // and THIS changeset is generated: a marker written by hand survives exactly - // until the next pin bump rewrites the file, which is how PR #6476's answer - // was already scheduled to disappear. So the generator emits the question and - // leaves it deliberately unanswered — see ADR_0087_SCAFFOLD. - // - // Zero-suppressed against the gate's own subject: on an artifact the gate does - // not judge, an unanswered marker would sit there unjudged forever and teach - // the next reader that this marker is decoration. It appears exactly where it - // is enforced. - const adr0087Scaffold = artifactDeclaresBreaking({ bump, body: rendered }); - const body = adr0087Scaffold ? `${rendered}\n\n${ADR_0087_SCAFFOLD}` : rendered; - - return { - bump, - declaredLevel, - breaking, - breakingByLevel, - breakingByAnnotation, - releasing, - releaseNothing, - noChangeset, - noChangesetCommits, - changesetsAdded, - absentAtTo, - totalCommits, - downgradedMajor, - recordedBelow, - adr0087Scaffold, - body, - }; -} - -// --------------------------------------------------------------------------- -// CLI -// --------------------------------------------------------------------------- - -// Set by `selfTest()` only after its verdict is printed, and read at the -// dispatch: a `return` that leaves the function above that line prints nothing -// and still exits 0 — a self-test that never finished, reported as one that -// passed (#13798). The self-test's own exit code stays load-bearing, so the -// handshake is a flag rather than a returned sentinel. -let selfTestReachedVerdict = false; - -function main(argv) { - const has = (f) => argv.includes(f); - const val = (f, d) => { - const i = argv.indexOf(f); - return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : d; - }; - - if (has('-h') || has('--help')) { - console.log( - readFileSync(fileURLToPath(import.meta.url), 'utf8') - .split('\n') - .filter((l) => l.startsWith('//')) - .map((l) => l.slice(3)) - .join('\n'), - ); - return 0; - } - - if (has('--self-test')) { - const selfTestCode = selfTest(); - if (!selfTestReachedVerdict) { - console.error( - '\n✗ objectui-changeset-digest self-test: selfTest() returned without reaching its verdict,\n' - + 'so no success line was printed. Exiting 0 here would report a self-test\n' - + 'that never finished as a self-test that passed.\n', - ); - process.exit(1); - } - return selfTestCode; - } - - const objectuiRoot = val('--objectui-root', join(REPO_ROOT, '..', 'objectui')); - const frameworkRoot = val('--framework-root', REPO_ROOT); - const from = val('--from'); - const to = val('--to'); - const out = val('--out'); - const max = Number(val('--max', String(DEFAULT_MAX_ENTRIES))); - const bumpOverride = val('--bump-override', ''); - - if (!from || !to) { - console.error('✗ objectui-changeset-digest: --from --to are required.'); - return 1; - } - if (!existsSync(join(objectuiRoot, '.git'))) { - console.error(`✗ objectui-changeset-digest: no objectui checkout at ${objectuiRoot}`); - return 2; - } - // #14178: an absent endpoint is a PREREQUISITE the caller must repair — the - // same class as the truncated walk below, reported the same way and answered - // with the same remedy. It used to be a one-line "cannot walk" with no cause, - // no remedy and no distinction between "shallow, deepen it" and "this checkout - // has never seen that revision", which is what let the caller treat it as - // grounds to DEGRADE rather than to fetch and ask again. - const absence = findAbsentEndpoints(objectuiRoot, from, to); - if (absence.absent) { - console.error(describeAbsentEndpoints({ objectuiRoot, from, to, absence })); - return 2; - } - - // #9408: the endpoints resolve — but does the WALK BETWEEN THEM? A truncated - // history answers `cat-file -e` perfectly and still yields a complete-looking - // record from a walk that stopped early. Asked HERE, before anything is - // derived, so the caller degrades instead of publishing a wrong attribution. - const truncation = findRangeTruncation(objectuiRoot, from, to); - if (truncation.truncated) { - console.error(describeTruncation({ objectuiRoot, from, to, truncation })); - // Under `--check-walkable` the caller wants to know WHICH failure it is, so - // it can deepen rather than degrade; on the deriving path the documented - // "not walkable" contract (2) is what every existing caller reads. - return has('--check-walkable') ? 3 : 2; - } - if (has('--check-walkable')) { - console.error( - `✓ objectui-changeset-digest: ${from.slice(0, 12)}..${to.slice(0, 12)} walks completely ` + - `in ${objectuiRoot}.`, - ); - return 0; - } - - const digest = buildDigest({ objectuiRoot, frameworkRoot, from, to, max, bumpOverride }); - - if (has('--json')) { - console.log(JSON.stringify(digest, null, 2)); - return 0; - } - - const short = to.slice(0, 12); - const rangeLabel = `${from.slice(0, 12)}...${to.slice(0, 12)}`; - // The declaration line every changeset carries (ruling A): the console refresh - // changes nothing a metadata author writes, so its kind is `none` — the - // release-notes grouping files it under 其他修复, and the level gate reads it - // as the `patch` it is. Written by the generator because a generated file is - // regenerated: a line a human added would last exactly one pin bump. - const file = - `---\n"@objectstack/console": ${digest.bump}\n---\n\n` + - `Console (objectui) refreshed to \`${short}\`. Frontend changes in this range:\n\n` + - `${digest.body}\n\n` + - `Metadata-protocol: none (the console refresh changes no key, value or type a metadata author writes)\n\n` + - `objectui range: \`${rangeLabel}\`\n`; - - console.error( - `→ ${digest.releasing.length} releasing changeset(s), ` + - `${digest.breaking} breaking (${digest.breakingByLevel} declared major, ` + - `${digest.breakingByAnnotation} annotated), ` + - `${digest.releaseNothing} release-nothing, ${digest.noChangeset} commit(s) without a changeset` + - (digest.recordedBelow - ? ` — objectui declared \`${digest.declaredLevel}\`, recorded as \`${digest.bump}\` (the console never changes the metadata protocol)` - : ''), - ); - - // #6175: the one sentence that replaces the screenful of git `fatal:` lines. - // It lands HERE, beside the accounting line on stderr, and NOT in the - // changeset body: the body records what the range released, and how the tool - // had to read a file is not a fact about the release. The reader who needs it - // is the operator watching this run — the same reader the `fatal:` lines used - // to mislead. Printed only when it fires, so a range that crossed no release - // gains no new line (silence here means "nothing to explain", never "nothing - // happened" — the absence itself is not load-bearing). - if (digest.absentAtTo > 0) { - console.error( - `→ ${digest.absentAtTo} of ${digest.changesetsAdded} changeset(s) added in this range ` + - `no longer exist at ${short} — a release consumes the changesets it ships; ` + - `each was read from the commit that added it, so the account above is complete.`, - ); - } - - // #6494: the one line the operator running the bump must act on. It goes to - // stderr beside the accounting, which is where a `BUMP="$(…)"` caller lets it - // through — the shell driver captures stdout only. Saying it here is not the - // enforcement (the gate is); it is what stops the operator discovering the red - // one push later, and it names the marker so the fix is a search away. - if (digest.adr0087Scaffold) { - console.error( - `→ this changeset DECLARES a breaking change, so it carries an UNANSWERED ADR-0087 ` + - `disposition placeholder. Replace the \`adr-0087: TODO\` marker in ` + - `${out ?? 'the changeset'} with the real disposition before you push: ` + - `\`Check Changeset\` rejects the placeholder on purpose, and the generator cannot ` + - `answer it for you (objectstack#6494).`, - ); - } - - if (out) { - mkdirSync(dirname(out), { recursive: true }); - writeFileSync(out, file); - console.log(digest.bump); - } else { - console.log(file); - } - return 0; -} - -// --------------------------------------------------------------------------- -// Self-test (#4731) — the repo idiom for a `scripts/` gate: build throwaway git -// repos, run the real code over them, assert the artifact. -// --------------------------------------------------------------------------- - -function selfTest() { - // The battery ledger this self-test's floor is evaluated against (#13489). - // `battery()` opens a battery; every assertion below is attributed to the one - // most recently opened, so a section that stops running stops registering and - // names ITSELF at the floor rather than going quiet. - const seen = new Map(); - let openBattery = null; - const battery = (name) => { - openBattery = name; - }; - const registerCase = () => { - const b = openBattery ?? UNATTRIBUTED_BATTERY; - seen.set(b, (seen.get(b) ?? 0) + 1); - }; - const failures = []; - const check = (name, cond, detail = '') => { - registerCase(); - if (cond) { - console.log(` ✓ ${name}`); - } else { - failures.push(`${name}${detail ? ` — ${detail}` : ''}`); - console.log(` ✗ ${name}${detail ? ` — ${detail}` : ''}`); - } - }; - - const tmp = mkdtempSync(join(tmpdir(), 'objectui-digest-selftest-')); - try { - // --- a throwaway "objectui" whose commits mirror the #4731 range shapes --- - const ui = join(tmp, 'objectui'); - mkdirSync(join(ui, '.changeset'), { recursive: true }); - const g = (...args) => git(ui, args); - g('init', '-q', '-b', 'main'); - g('config', 'user.email', 'selftest@objectstack.ai'); - g('config', 'user.name', 'self test'); - g('config', 'commit.gpgsign', 'false'); - - const commit = (subject, files) => { - for (const [path, content] of Object.entries(files)) { - mkdirSync(dirname(join(ui, path)), { recursive: true }); - writeFileSync(join(ui, path), content); - } - g('add', '-A'); - g('commit', '-q', '-m', subject); - return g('rev-parse', 'HEAD').trim(); - }; - - const base = commit('chore: base', { 'README.md': 'base\n' }); - - commit('feat(grid): aggregate single-call mode for bulk actions', { - '.changeset/bulk-action-aggregate.md': - '---\n"@object-ui/plugin-grid": minor\n---\n\nGrid bulk actions gain an aggregate single-call mode.\n', - 'src/grid.ts': 'a\n', - }); - // The class the old `feat|fix` type filter could not represent AT ALL. - const breakingSha = commit( - 'refactor(layout)!: delete PageNodeRenderer, the unregistered page-node renderer (#3225)', - { - '.changeset/remove-dead-layout-page-node-renderer.md': - '---\n"@object-ui/layout": major\n---\n\nRemove `PageNodeRenderer`, the dead page-node renderer (objectui#3223).\n', - 'src/layout.ts': 'b\n', - }, - ); - commit('chore(deps): lockstep the @objectstack family onto 17.0.0-rc.1 (#3189)', { - '.changeset/objectstack-family-rc1-lockstep.md': - '---\n"@object-ui/app-shell": minor\n"@object-ui/core": minor\n---\n\nBring the whole `@objectstack` family to `17.0.0-rc.1`.\n', - 'package.json': '{}\n', - }); - // Matches `fix|feat` on the subject but declares release-nothing. - commit('fix(ci): hand the cross-repo token to github-script (#3186)', { - '.changeset/fix-cross-repo-closer-require.md': - '---\n---\n\nfix(ci): hand the cross-repo token to github-script\n\nRelease-nothing: touches a workflow only.\n', - '.github/workflows/x.yml': 'on: push\n', - }); - // Matches the type filter and carries no changeset at all. - commit('fix(ci): never render a budget FAIL for a run that measured nothing (#3198)', { - '.github/workflows/y.yml': 'on: push\n', - }); - const head = g('rev-parse', 'HEAD').trim(); - // A real objectui checkout has a remote-tracking main, and since #10495 the - // bump asks whether the revision it is pinning is reachable from it. Without - // this ref every drive below would take the "cannot answer" path and bury - // the assertions it is here to make under reachability warnings — a fixture - // that is unrealistic in exactly the dimension a later reader would trust. - g('update-ref', 'refs/remotes/origin/main', head); - - // --- a "framework" root without pre.json (launch window, no RC) --- - const fwPlain = join(tmp, 'fw-plain'); - mkdirSync(join(fwPlain, '.changeset'), { recursive: true }); - // --- and one in RC pre-mode --- - battery('and one in RC pre-mode'); - const fwPre = join(tmp, 'fw-pre'); - mkdirSync(join(fwPre, '.changeset'), { recursive: true }); - writeFileSync(join(fwPre, '.changeset', 'pre.json'), '{"mode":"pre","tag":"rc"}\n'); - - const digest = buildDigest({ - objectuiRoot: ui, - frameworkRoot: fwPre, - from: base, - to: head, - }); - - console.log('objectui-changeset-digest --self-test'); - check( - 'releasing list is derived from changesets, not commit types (3 of 4 added)', - digest.releasing.length === 3, - `got ${digest.releasing.length}`, - ); - check( - 'a breaking `refactor(...)!` commit IS represented', - digest.body.includes('PageNodeRenderer') && digest.body.includes(breakingSha.slice(0, 9)), - ); - check( - 'a non-feat/fix `chore(deps)` commit that releases IS represented', - digest.body.includes('Bring the whole'), - ); - check( - 'a `fix(ci)` commit with an EMPTY frontmatter changeset is NOT represented', - !digest.body.includes('cross-repo token'), - ); - check( - 'a `fix(ci)` commit with no changeset at all is NOT represented as a releasing entry', - // #6174 repaired the PROXY, never the claim. This check has always meant - // "the criterion did not admit it", and tested that by looking for the - // subject ANYWHERE in the body — which stopped expressing it the moment - // the body began naming undeclared commits on purpose. It now says what it - // always meant, at both layers: the classified set, and the rendered - // releasing lines. The other half — that it IS named, under the undeclared - // block — is pinned in the #6174 group below. - !digest.releasing.some((r) => r.subject.includes('budget FAIL')) && - !digest.body - .split('\n') - .some((l) => /^- \*\*(?:major|minor|patch)\*\* —/.test(l) && l.includes('budget FAIL')), - ); - check('release-nothing changesets are counted, not dropped in silence', digest.releaseNothing === 1); - check( - 'commits without a changeset are counted, not dropped in silence', - digest.noChangeset === 1, - `got ${digest.noChangeset}`, - ); - check('the accounting states the omissions', digest.body.includes('omitted:')); - check('the declared level drives the bump (major declared)', digest.declaredLevel === 'major'); - check( - 'breaking count is surfaced in the body, NAMING its source', - digest.body.includes('⚠️ 1 of these carries a breaking change: 1 by a declared `major` level'), - digest.body, - ); - check( - 'a declared-major entry is marked in the list too', - digest.body.includes('- **major** — **BREAKING** — Remove `PageNodeRenderer`'), - digest.body, - ); - check( - 'a declared major records as patch, in RC pre-mode too — the console never moves the version line (ruling A)', - digest.bump === 'patch' && digest.declaredLevel === 'major' && digest.downgradedMajor === true && digest.recordedBelow === true, - `bump=${digest.bump} declared=${digest.declaredLevel}`, - ); - - const plain = buildDigest({ - objectuiRoot: ui, - frameworkRoot: fwPlain, - from: base, - to: head, - }); - check( - 'outside pre-mode a declared major records as patch — AUDIBLY, naming the rule and the declared level', - plain.bump === 'patch' && plain.downgradedMajor && plain.body.includes('metadata protocol') && plain.body.includes('The declared `major` is recorded here as `patch`'), - plain.body, - ); - - const overridden = buildDigest({ - objectuiRoot: ui, - frameworkRoot: fwPre, - from: base, - to: head, - bumpOverride: 'patch', - }); - check('CONSOLE_BUMP still overrides the derived level', overridden.bump === 'patch'); - - const capped = buildDigest({ - objectuiRoot: ui, - frameworkRoot: fwPre, - from: base, - to: head, - max: 1, - }); - check( - 'a cap that fires ANNOUNCES itself with the real count', - capped.body.includes('…and 2 more releasing changesets'), - capped.body, - ); - - // --- #6099: the `minor` + prose-annotation family ---------------------- - // This is the form objectui ACTUALLY ships breaking changes in (0 of 64 - // declared `major` over the live range), so it needs its own repo: the - // #4731 fixtures above pin exact counts and must not shift under it. - // - // Both recognised shapes are here, because the two live specimens differ in - // WHERE the author put the annotation — one leads with it, one buries it in - // the last paragraph — and a criterion that reads only the first paragraph - // (which is all `summary` is) catches the first and misses the second. - battery('#6099: the `minor` + prose-annotation family'); - const ui2 = join(tmp, 'objectui-annotated'); - mkdirSync(join(ui2, '.changeset'), { recursive: true }); - const g2 = (...args) => git(ui2, args); - g2('init', '-q', '-b', 'main'); - g2('config', 'user.email', 'selftest@objectstack.ai'); - g2('config', 'user.name', 'self test'); - g2('config', 'commit.gpgsign', 'false'); - const commit2 = (subject, files) => { - for (const [path, content] of Object.entries(files)) { - mkdirSync(dirname(join(ui2, path)), { recursive: true }); - writeFileSync(join(ui2, path), content); - } - g2('add', '-A'); - g2('commit', '-q', '-m', subject); - return g2('rev-parse', 'HEAD').trim(); - }; - - const base2 = commit2('chore: base', { 'README.md': 'base\n' }); - - // (A) RECOGNISED — the annotation LEADS, as an emphasis label the author - // spelled with their own version tag. Mirrors objectui `042e09d77`. - commit2('refactor(fields)!: converge the widget metadata carrier to `field` (#3233)', { - '.changeset/field-widget-single-metadata-carrier.md': - '---\n"@object-ui/fields": minor\n---\n\n' + - '**BREAKING (v17)** — field widgets receive their metadata on ONE key, `field`.\n' + - '`schema` is removed from the widget contract (objectui#3233).\n\n' + - '## What changed\n\n' + - '`schema` was a second carrier for what `field` already means.\n', - 'src/fields.ts': 'a\n', - }); - // (B) RECOGNISED — the annotation is in the LAST paragraph, opening a block - // in plain prose. Mirrors objectui `6e794a19e`, the specimen whose entry - // #6110 had to rescue by hand. - commit2('fix(app-shell)!: converge flow node geometry to spec `FlowNode.position` (#3172)', { - '.changeset/flow-node-geometry-is-spec-position.md': - '---\n"@object-ui/app-shell": minor\n---\n\n' + - "The flow designer writes node geometry as the spec's `FlowNode.position`, not\n" + - 'its own `ui: { x, y }` (objectui#3172).\n\n' + - 'FROM: dragging, adding-at-a-point and insert-on-edge each wrote `node.ui`.\n' + - 'TO: all three write `position: { x, y }`.\n\n' + - 'Breaking for anyone reading `node.ui` off a flow draft: after the first edit\n' + - 'the key is gone and the coordinates live under `node.position`.\n', - 'src/flow.ts': 'b\n', - }); - // (C) RECOGNISED — the annotation is a HEADING. Mirrors objectui `8ec406728`, - // which the conventional-commit `!` convention does NOT mark. - commit2('fix(app-shell): entitlement context reads `error.details.*` only (#3329)', { - '.changeset/entitlement-error-context-reads-details.md': - '---\n"@object-ui/app-shell": minor\n---\n\n' + - 'The environment entitlement dialog now reads its context from\n' + - '`error.details.*` — the single declared location (objectui#3329).\n\n' + - '## Breaking note — read before tracking objectui `main` directly\n\n' + - 'This is a wire-shape change with no consumer-side fallback.\n', - 'src/entitlement.ts': 'c\n', - }); - // (D) NOT RECOGNISED — the word appears MID-SENTENCE inside an emphasised - // paragraph, hedged and then discounted. Mirrors objectui `9e9e9a92f`, - // the real near-miss: an ordinary entry that merely discusses breakage. - commit2('fix(types): DrillDownConfig declares only keys a renderer reads (#3354)', { - '.changeset/drilldown-config-declared-equals-delivered.md': - '---\n"@object-ui/types": minor\n---\n\n' + - '`DrillDownConfig` now declares only keys a renderer reads (objectui#3354).\n\n' + - '**Removed — two keys no renderer has ever read.** Removing a declared key from\n' + - 'a published interface is technically breaking for anyone who wrote one, but\n' + - 'only in the sense that TypeScript now reports what was already true at\n' + - 'runtime: the key did nothing.\n', - 'src/types.ts': 'd\n', - }); - // (E) NOT RECOGNISED — negated forms, and a word that merely CONTAINS it. - // "Fixes that are not breaking…" is verbatim objectui `d22ae31ce`. - commit2('chore(deps): follow-up cleanups (#3287)', { - '.changeset/rc2-followup-cleanups.md': - '---\n"@object-ui/core": patch\n---\n\n' + - 'Fixes that are not breaking, but were only found because rc.2 stopped being\n' + - 'lenient — each had been passing vacuously.\n\n' + - 'Non-breaking cleanup throughout; this groundbreaking refactor changes no\n' + - 'contract.\n', - 'src/core.ts': 'e\n', - }); - // (F) NOT RECOGNISED — hard wrapping lands the word first on a LINE, still - // mid-sentence. This is why the anchor is block-initial, not line-initial: - // an author's paragraphing is a choice, a wrap column is not. - commit2('fix(fields): the record picker sends the authored option value (#3336)', { - '.changeset/record-picker-authored-option-value.md': - '---\n"@object-ui/fields": patch\n---\n\n' + - 'The record picker sends the AUTHORED value of a picked `select` option.\n\n' + - 'Radix `Select` speaks strings, so a numeric option value used to arrive as\n' + - 'its stringified form. Nothing about the change is\n' + - 'breaking for existing authors — the stored wire shape is unchanged.\n', - 'src/picker.ts': 'f\n', - }); - const head2 = g2('rev-parse', 'HEAD').trim(); - - const annotated = buildDigest({ - objectuiRoot: ui2, - frameworkRoot: fwPlain, - from: base2, - to: head2, - }); - - check( - '#6099 the whole family is collected, breaking or not (6 releasing)', - annotated.releasing.length === 6, - `got ${annotated.releasing.length}`, - ); - check( - '#6099 no `major` is declared anywhere — the form objectui actually ships', - annotated.declaredLevel === 'minor' && annotated.breakingByLevel === 0, - `declaredLevel=${annotated.declaredLevel} byLevel=${annotated.breakingByLevel}`, - ); - check( - '#6099 a `minor` whose FIRST paragraph carries the annotation is recognised', - annotated.releasing.some( - (r) => r.path.includes('field-widget') && r.breaking && r.breakingSource === 'annotation', - ), - ); - check( - '#6099 a `minor` whose LAST paragraph carries the annotation is recognised', - annotated.releasing.some( - (r) => r.path.includes('flow-node') && r.breaking && r.breakingSource === 'annotation', - ), - ); - check( - '#6099 a `minor` annotated by a HEADING is recognised', - annotated.releasing.some((r) => r.path.includes('entitlement') && r.breaking), - ); - check( - '#6099 a hedged MID-SENTENCE "technically breaking" is NOT flagged', - annotated.releasing.some((r) => r.path.includes('drilldown') && !r.breaking), - ); - check( - '#6099 "not breaking" / "Non-breaking" / "groundbreaking" are NOT flagged', - annotated.releasing.some((r) => r.path.includes('rc2-followup') && !r.breaking), - ); - check( - '#6099 a wrapped line STARTING with the word mid-sentence is NOT flagged', - annotated.releasing.some((r) => r.path.includes('record-picker') && !r.breaking), - ); - check( - '#6099 exactly the three annotated entries are flagged, no more', - annotated.breaking === 3 && annotated.breakingByAnnotation === 3, - `breaking=${annotated.breaking} byAnnotation=${annotated.breakingByAnnotation}`, - ); - check( - "#6099 the author's own label survives verbatim — never restated", - // The `r.breaking` clause is load-bearing: the `(v17)` label is the - // AUTHOR's text, so the two body clauses alone would pass whether or not - // this entry is flagged at all — green for an empty reason. - annotated.releasing.find((r) => r.path.includes('field-widget'))?.breaking === true && - annotated.body.includes('- **minor** — **BREAKING (v17)** — field widgets receive') && - !annotated.body.includes('**BREAKING** — **BREAKING'), - annotated.body, - ); - check( - '#6099 markBreaking pins both directions directly (renderer, not criterion)', - markBreaking('**BREAKING (v17)** — x') === '**BREAKING (v17)** — x' && - markBreaking('__Breaking change__ y') === '__Breaking change__ y' && - markBreaking('An ordinary summary.') === '**BREAKING** — An ordinary summary.', - ); - check( - '#6099 hasBreakingAnnotation pins both directions directly (criterion)', - hasBreakingAnnotation('## Breaking note\n\nx') && - hasBreakingAnnotation('a\n\nBreaking for anyone reading `node.ui`.') && - hasBreakingAnnotation('**BREAKING (v17)** — x') && - !hasBreakingAnnotation('is technically breaking for anyone who wrote one') && - !hasBreakingAnnotation('Non-breaking cleanup; a groundbreaking refactor.') && - !hasBreakingAnnotation('wrapped so the word lands first on a line:\nbreaking for authors'), - ); - check( - '#6099 an entry annotated further down GETS the marker on its line (#6110 by hand)', - annotated.body.includes( - '- **minor** — **BREAKING** — The flow designer writes node geometry', - ), - annotated.body, - ); - check( - '#6099 an ordinary entry is NOT marked', - annotated.body.includes('- **minor** — `DrillDownConfig` now declares') && - annotated.body.includes('- **patch** — The record picker sends'), - annotated.body, - ); - check( - '#6099 the ⚠️ hint fires on a range with NO declared major — the whole point', - annotated.body.includes('⚠️ 3 of these carry a breaking change') && - annotated.body.includes("by the author's own breaking annotation in the changeset body"), - annotated.body, - ); - check( - '#6099 the hint no longer claims a **major** bump it cannot have', - !annotated.body.includes('declare a **major**'), - annotated.body, - ); - check( - '#6099 no major was downgraded — there was none to downgrade — and the declared `minor` is recorded as `patch` (ruling A), said in the body', - annotated.bump === 'patch' && annotated.downgradedMajor === false && annotated.recordedBelow === true && annotated.declaredLevel === 'minor' && - annotated.body.includes('The declared `minor` is recorded here as `patch`'), - `bump=${annotated.bump} downgraded=${annotated.downgradedMajor} recordedBelow=${annotated.recordedBelow}`, - ); - - // --- #23112: a DECORATED annotation — a pictograph before the word ------- - // objectui#12242's changeset opens its annotation `## ⚠️ BREAKING, priced as - // minor …`, and the opener used to admit nothing between the heading mark and - // the word, so the digest read it as not breaking. Every positive below is a - // placement measured in objectui's own changeset history (see `DECORATION`); - // every negative is a line the decoration must NOT turn into a declaration. - battery('#23112: a DECORATED annotation — a pictograph before the word'); - // Verbatim heading of `.changeset/12242-types-field-metadata-family.md` at - // objectui `1fb1ced36243`, with its paragraphs trimmed. - const heading12242 = "## ⚠️ BREAKING, priced as minor under the fixed group's version policy"; - const body12242 = - 'The exported field metadata types stop declaring the members `FieldSchema` refuses by name ' + - '(objectui#12242).\n\n' + - '- `BaseFieldMetadata`: FROM `filterable?: boolean` → TO refused.\n\n' + - `${heading12242}\n\n` + - 'TypeScript that writes a retired member no longer compiles.\n'; - check( - "#23112 objectui#12242's changeset body reads breaking (`## ⚠️ BREAKING, …`)", - body12242.includes('## \u26A0\uFE0F BREAKING') && hasBreakingAnnotation(body12242), - ); - check( - '#23112 the plain-heading control still reads breaking (`## BREAKING, …`)', - hasBreakingAnnotation(body12242.replace('## \u26A0\uFE0F BREAKING', '## BREAKING')), - ); - check( - '#23112 a decorated PROSE sentence containing "breaking" does not read breaking', - !hasBreakingAnnotation('⚠️ Upgrade note: nothing in this change is breaking for an existing author.') && - !hasBreakingAnnotation('## ⚠️ Read before upgrading — this is not breaking') && - !hasBreakingAnnotation('⚠️ Non-breaking cleanup; a groundbreaking refactor.'), - ); - check( - '#23112 the sign BEFORE the emphasis mark reads breaking (`⚠️ **BREAKING (…)**`)', - // Wrapped as objectui `10719-retire-phase35-validation-types.md` wraps it, so - // the label's run crosses a line and `BREAKING_EMPHASIS_LABEL` cannot see it: - // only the opener can answer. - hasBreakingAnnotation( - "a\n\n⚠️ **BREAKING (scored `minor` per this repository's version-alignment\n" + - 'convention) — x.**', - ), - ); - check( - '#23112 the sign INSIDE the emphasis mark reads breaking (`**⚠️ Breaking …**`)', - hasBreakingAnnotation('a\n\n**⚠️ Breaking for consumers that read `result.data`.** `validateSchema` x'), - ); - check( - '#23112 the sign opening a plain paragraph reads breaking (`⚠️ Breaking in places, …`)', - hasBreakingAnnotation("a\n\n⚠️ Breaking in places, marked `minor` under this repo's rule."), - ); - check( - '#23112 the sign without its variation selector reads breaking (`## ⚠ BREAKING`)', - hasBreakingAnnotation('a\n\n## \u26A0 BREAKING'), - ); - check( - '#23112 the rule is the pictograph CLASS, not the one sign (`- 🚨 BREAKING:` behind a bullet)', - hasBreakingAnnotation('a\n\n- 🚨 BREAKING: `onReorder` is retired.'), - ); - check( - '#23112 ASCII punctuation is NOT decoration — a code span and a quotation stay prose', - !hasBreakingAnnotation('a\n\n`breaking` joins `ChangeEntry` as an optional boolean.') && - !hasBreakingAnnotation('a\n\n"Breaking for TypeScript readers", below, lists the members.'), - ); - check( - "#23112 markBreaking keeps an author's DECORATED label verbatim — never restated", - markBreaking('⚠️ **BREAKING (scored `minor`)** — x') === '⚠️ **BREAKING (scored `minor`)** — x' && - markBreaking('**⚠️ Breaking for consumers.** y') === '**⚠️ Breaking for consumers.** y' && - markBreaking('⚠️ An ordinary summary.') === '**BREAKING** — ⚠️ An ordinary summary.', - ); - - // Through the real digest, in a repo of its own so the #6099 counts above - // stay pinned. Commit (A) is the 12242 shape, (B) opens its SUMMARY with a - // decorated label (objectui `10719-retire-phase35-validation-types.md`), (C) - // is decorated prose. - const uiDeco = join(tmp, 'objectui-decorated'); - mkdirSync(join(uiDeco, '.changeset'), { recursive: true }); - const gDeco = (...args) => git(uiDeco, args); - gDeco('init', '-q', '-b', 'main'); - gDeco('config', 'user.email', 'selftest@objectstack.ai'); - gDeco('config', 'user.name', 'self test'); - gDeco('config', 'commit.gpgsign', 'false'); - const commitDeco = (subject, files) => { - for (const [path, content] of Object.entries(files)) { - mkdirSync(dirname(join(uiDeco, path)), { recursive: true }); - writeFileSync(join(uiDeco, path), content); - } - gDeco('add', '-A'); - gDeco('commit', '-q', '-m', subject); - return gDeco('rev-parse', 'HEAD').trim(); - }; - const baseDeco = commitDeco('chore: base', { 'README.md': 'base\n' }); - const shaA = commitDeco('fix(types): field metadata family (#12242)', { - '.changeset/12242-types-field-metadata-family.md': - "---\n'@object-ui/types': minor\n---\n\n" + body12242, - 'src/types.ts': 'a\n', - }); - commitDeco('fix(types): retire the phase-3.5 validation types (#10719)', { - '.changeset/10719-retire-phase35-validation-types.md': - "---\n'@object-ui/types': minor\n---\n\n" + - "⚠️ **BREAKING (scored `minor` per this repository's version-alignment\n" + - 'convention) — `@object-ui/types` no longer exports the eight "Phase 3.5"\n' + - 'validation types (objectui#10719).**\n', - 'src/core.ts': 'b\n', - }); - commitDeco('fix(fields): a picker note (#3336)', { - '.changeset/picker-note.md': - "---\n'@object-ui/fields': patch\n---\n\n" + - '⚠️ Upgrade note: nothing in this change is breaking for an existing author.\n', - 'src/fields.ts': 'c\n', - }); - const headDeco = gDeco('rev-parse', 'HEAD').trim(); - const decorated = buildDigest({ objectuiRoot: uiDeco, frameworkRoot: fwPlain, from: baseDeco, to: headDeco }); - check( - '#23112 exactly the two decorated annotations are counted, the decorated prose is not', - decorated.releasing.length === 3 && decorated.breaking === 2 && decorated.breakingByAnnotation === 2, - `releasing=${decorated.releasing.length} breaking=${decorated.breaking} byAnnotation=${decorated.breakingByAnnotation}`, - ); - check( - '#23112 the count line reads 2, by annotation', - decorated.body.includes("⚠️ 2 of these carry a breaking change: 2 by the author's own breaking annotation"), - decorated.body, - ); - check( - '#23112 the heading-annotated entry GETS the marker on its line', - decorated.body.includes('- **minor** — **BREAKING** — The exported field metadata types stop declaring'), - decorated.body, - ); - check( - "#23112 a summary opening with a decorated label renders it verbatim, never restated", - // The `breaking` clause is load-bearing: an UNFLAGGED entry renders this - // same line, so the text clauses alone would pass for an empty reason. - decorated.releasing.find((r) => r.path.includes('10719'))?.breaking === true && - decorated.body.includes( - "- **minor** — ⚠️ **BREAKING (scored `minor` per this repository's version-alignment convention)", - ) && - !decorated.body.includes('**BREAKING** — ⚠️ **BREAKING'), - decorated.body, - ); - check( - '#23112 the decorated prose entry is NOT marked', - decorated.body.includes('- **patch** — ⚠️ Upgrade note: nothing in this change is breaking'), - decorated.body, - ); - // The card's second consequence: a range whose ONLY breaking entry is the - // decorated heading used to raise no count at all, and so no ADR-0087 - // disposition prompt. - const onlyA = buildDigest({ objectuiRoot: uiDeco, frameworkRoot: fwPlain, from: baseDeco, to: shaA }); - check( - '#23112 a range whose ONLY breaking entry is the decorated heading still raises the ADR-0087 question', - onlyA.breaking === 1 && onlyA.adr0087Scaffold === true && onlyA.body.includes(ADR_0087_SCAFFOLD), - `breaking=${onlyA.breaking} scaffold=${onlyA.adr0087Scaffold}`, - ); - - // --- #23118: a bold LEAD LABEL holding a `(breaking …)` parenthetical ----- - // objectui `8a55f0ccd`'s `.changeset/12059-sidebar-no-reorder.md` annotates its - // change in a per-package lead label, and the word is neither first in the - // block nor first in the emphasis run, so the digest read it as not breaking. - // Every positive below is a shape measured in objectui's own changeset - // history (see `BREAKING_LEAD_LABEL`); every negative is a `(breaking …)` - // the rule must leave as prose, most of them verbatim from that same history. - battery('#23118: a bold LEAD LABEL holding a `(breaking …)` parenthetical'); - const label12059 = '**`@object-ui/layout` (breaking, declared `minor` per the version policy).**'; - // `.changeset/12059-sidebar-no-reorder.md` at objectui `8a55f0ccd`, paragraphs trimmed. - const body12059 = - "The console sidebar no longer lets a user drag the app's menu into a private order; the " + - 'Pinned section is now the one place a user orders entries (objectui#12059).\n\n' + - `${label12059} \`NavigationRenderer\` retires \`enableReorder\` and \`onReorder\`.\n\n` + - "**`@object-ui/app-shell`.** The sidebar no longer reads or writes the per-user menu order.\n"; - check( - "#23118 the card's example reads breaking (``**`@object-ui/layout` (breaking, …).**``)", - body12059.includes(`\n\n${label12059} `) && hasBreakingAnnotation(body12059), - ); - check( - '#23118 the rule is LINE-initial: a bulleted label that opens no block reads breaking', - // objectui `6152-object-form-unmirrored-members.md`: the second bullet of a list. - hasBreakingAnnotation( - 'What an author sees change:\n\n' + - '- **Strict authoring face.** A document using any of these keys is no longer refused.\n' + - '- **Tolerant face (breaking for invalid documents).** A value of the wrong type is refused.', - ), - ); - check( - '#23118 the label alone on its line, with more after the parenthetical, and in any case', - hasBreakingAnnotation('a\n\n**Narrowed (breaking).**\n\n`object-grid` x') && - hasBreakingAnnotation("a\n\n**Narrowed (breaking), `@object-ui/types`.** `@objectstack/spec`'s x") && - hasBreakingAnnotation('a\n\n**`@object-ui/i18n` (BREAKING, declared `minor`).** `console.nav` x'), - ); - check( - '#23118 a prose `(breaking …)` outside a lead label does not read breaking', - // objectui `11717-dashboard-widget-chart-measure-arity.md` (mid-sentence) and - // `6152-unmirrored-round4.md` (a wrapped line that opens with the parenthetical). - !hasBreakingAnnotation( - 'a\n\nThis narrows the accepted documents (breaking for a document of either shape). The fix: x', - ) && - !hasBreakingAnnotation( - 'a\n\nA wrong-typed value at either key is now refused at its path, where it used to be kept\n' + - 'unexamined\n(breaking for invalid documents).', - ), - ); - check( - '#23118 a lead label WITHOUT the parenthetical does not read breaking', - !hasBreakingAnnotation('a\n\n**`@object-ui/app-shell`.** The sidebar no longer reads x') && - !hasBreakingAnnotation('a\n\n- **Strict authoring face.** A document using any of these keys x'), - ); - check( - '#23118 a parenthetical AFTER the label has closed is prose (`**…** (breaking …`)', - // objectui `5896-feeditem-single-constructor.md`, verbatim. - !hasBreakingAnnotation( - 'a\n\n**Behaviour change on the console record page** (breaking in the objectui sense,\n' + - "shipped `minor` — objectui's `major` tracks `@objectstack`):", - ), - ); - check( - "#23118 a bold span that is NOT the line's first token does not read breaking", - !hasBreakingAnnotation('a\n\nSee **`@object-ui/layout` (breaking).** below.') && - !hasBreakingAnnotation('a\n\n**Removed.** **`@object-ui/layout` (breaking).** x'), - ); - check( - "#23118 `breaking` must be the parenthetical's FIRST word (`(non-breaking)`, `(not breaking …)`)", - !hasBreakingAnnotation('a\n\n**`@object-ui/layout` (non-breaking).** x') && - !hasBreakingAnnotation('a\n\n**Cleanup (not breaking for any author).** x'), - ); - check( - '#23118 an UNBOLDED label is prose: plain text and a code span stay unread', - // objectui `11168-element-family-inputs.md` and `11674-shortcuts-dialog-wired-only.md`. - !hasBreakingAnnotation( - 'a\n\nNarrowed (breaking for an author who wrote them, which the spec already\nrefuses):', - ) && - !hasBreakingAnnotation( - "a\n\n`@object-ui/i18n` (BREAKING; `minor` under this repository's release model): x", - ), - ); - check( - '#23118 markBreaking STAMPS a summary that opens with a lead label — it is not a breaking label', - markBreaking('**`@object-ui/types` (breaking, hence `minor`).** x') === - '**BREAKING** — **`@object-ui/types` (breaking, hence `minor`).** x' && - markBreaking('**BREAKING (v17)** — x') === '**BREAKING (v17)** — x', - ); - - // Through the real digest, in a repo of its own so the counts above stay - // pinned. (A) is the 12059 shape, (B) the bulleted label, (C) opens its - // SUMMARY with a lead label (no measured specimen; it pins the renderer - // through the real path), (D)-(F) are the prose shapes the rule must leave. - const uiLead = join(tmp, 'objectui-lead-label'); - mkdirSync(join(uiLead, '.changeset'), { recursive: true }); - const gLead = (...args) => git(uiLead, args); - gLead('init', '-q', '-b', 'main'); - gLead('config', 'user.email', 'selftest@objectstack.ai'); - gLead('config', 'user.name', 'self test'); - gLead('config', 'commit.gpgsign', 'false'); - const commitLead = (subject, files) => { - for (const [path, content] of Object.entries(files)) { - mkdirSync(dirname(join(uiLead, path)), { recursive: true }); - writeFileSync(join(uiLead, path), content); - } - gLead('add', '-A'); - gLead('commit', '-q', '-m', subject); - return gLead('rev-parse', 'HEAD').trim(); - }; - const baseLead = commitLead('chore: base', { 'README.md': 'base\n' }); - const shaLeadA = commitLead('feat(layout): the sidebar menu is not user-reorderable (#12059)', { - '.changeset/12059-sidebar-no-reorder.md': "---\n'@object-ui/layout': minor\n---\n\n" + body12059, - 'src/layout.ts': 'a\n', - }); - commitLead('feat(types): the object-form mirror declares its members (#6152)', { - '.changeset/6152-object-form-unmirrored-members.md': - "---\n'@object-ui/types': minor\n---\n\n" + - 'The `object-form` zod mirror declares the members its TypeScript twin declared.\n\n' + - '- **Strict authoring face.** A document using any of these keys is no longer refused.\n' + - '- **Tolerant face (breaking for invalid documents).** A value of the wrong type is refused.\n', - 'src/form.ts': 'b\n', - }); - commitLead('feat(types): a list view retires its legacy chart axes (#6152)', { - '.changeset/lead-label-summary.md': - "---\n'@object-ui/types': minor\n---\n\n" + - '**`@object-ui/types` (breaking for a writer of a retired key, hence `minor`).** The legacy\n' + - 'chart axes are refused at the key.\n', - 'src/chart.ts': 'c\n', - }); - commitLead('feat(dashboard): a widget measure arity (#11717)', { - '.changeset/11717-dashboard-widget-chart-measure-arity.md': - "---\n'@object-ui/plugin-dashboard': patch\n---\n\n" + - 'A chart widget refuses a measure it cannot draw.\n\n' + - 'This narrows the accepted documents (breaking for a document of either shape). The fix: x\n', - 'src/dashboard.ts': 'd\n', - }); - commitLead('refactor(app-shell): one feed item constructor (#5896)', { - '.changeset/5896-feeditem-single-constructor.md': - "---\n'@object-ui/app-shell': patch\n---\n\n" + - 'The record feed builds every item through one constructor.\n\n' + - '**Behaviour change on the console record page** (breaking in the objectui sense,\n' + - "shipped `minor` — objectui's `major` tracks `@objectstack`):\n", - 'src/feed.ts': 'e\n', - }); - commitLead('feat(app-shell): the sidebar keeps no private order', { - '.changeset/app-shell-no-private-order.md': - "---\n'@object-ui/app-shell': patch\n---\n\n" + - '**`@object-ui/app-shell`.** The sidebar no longer reads or writes the per-user menu order.\n', - 'src/sidebar.ts': 'f\n', - }); - const headLead = gLead('rev-parse', 'HEAD').trim(); - const lead = buildDigest({ objectuiRoot: uiLead, frameworkRoot: fwPlain, from: baseLead, to: headLead }); - const leadFlag = (frag) => lead.releasing.find((r) => r.path.includes(frag))?.breaking; - check( - '#23118 exactly the three lead-label annotations are counted, the three prose shapes are not', - lead.releasing.length === 6 && - lead.breaking === 3 && - lead.breakingByAnnotation === 3 && - leadFlag('12059') === true && - leadFlag('6152-object-form') === true && - leadFlag('lead-label-summary') === true, - `releasing=${lead.releasing.length} breaking=${lead.breaking} byAnnotation=${lead.breakingByAnnotation}`, - ); - check( - '#23118 the count line reads 3, by annotation', - lead.body.includes("⚠️ 3 of these carry a breaking change: 3 by the author's own breaking annotation"), - lead.body, - ); - check( - '#23118 the 12059-shaped entry GETS the marker on its line', - lead.body.includes("- **minor** — **BREAKING** — The console sidebar no longer lets a user drag the app's menu"), - lead.body, - ); - check( - '#23118 a summary opening with a lead label is stamped ONCE, the label kept verbatim after it', - lead.body.includes( - '- **minor** — **BREAKING** — **`@object-ui/types` (breaking for a writer of a retired key, hence `minor`).** The legacy', - ) && !lead.body.includes('**BREAKING** — **BREAKING'), - lead.body, - ); - check( - '#23118 the three prose entries are NOT marked', - lead.body.includes('- **patch** — A chart widget refuses a measure it cannot draw.') && - lead.body.includes('- **patch** — The record feed builds every item through one constructor.') && - lead.body.includes('- **patch** — **`@object-ui/app-shell`.** The sidebar no longer reads'), - lead.body, - ); - // The family card's second consequence: a range whose ONLY breaking entry is - // a lead label used to raise no count, and so no ADR-0087 disposition prompt. - const onlyLead = buildDigest({ objectuiRoot: uiLead, frameworkRoot: fwPlain, from: baseLead, to: shaLeadA }); - check( - '#23118 a range whose ONLY breaking entry is a lead label still raises the ADR-0087 question', - onlyLead.breaking === 1 && onlyLead.adr0087Scaffold === true && onlyLead.body.includes(ADR_0087_SCAFFOLD), - `breaking=${onlyLead.breaking} scaffold=${onlyLead.adr0087Scaffold}`, - ); - - // --- end-to-end through the shell driver ------------------------------- - battery('end-to-end through the shell driver'); - const fwRun = join(tmp, 'fw-run'); - mkdirSync(join(fwRun, 'scripts'), { recursive: true }); - mkdirSync(join(fwRun, '.changeset'), { recursive: true }); - writeFileSync(join(fwRun, '.changeset', 'pre.json'), '{"mode":"pre","tag":"rc"}\n'); - writeFileSync(join(fwRun, '.objectui-sha'), `${base}\n`); - stageBumpDriver(fwRun); - const bumpStdout = execFileSync( - 'bash', - [join(fwRun, 'scripts', 'bump-objectui.sh'), '--no-commit', head], - { - encoding: 'utf8', - // #16644: `gitFreeEnv()` as the BASE -- `bump-objectui.sh` spawns `git` one frame - // down against the fixture, where an inherited GIT_DIR outranks its `cwd`. - env: { ...gitFreeEnv(), OBJECTUI_ROOT: ui }, - }, - ); - // #5960: the pin bump is what makes the committed `sdui.manifest.json` stale, - // and no workflow regenerates it — so the procedure's second half lives in - // this script's output. Prose alone is deletable in silence; pinning it here - // is what makes "the procedure gained a line" a fact a gate can lose. The - // step it names is the one producer, which reads the pin's BUILT tree (ruling - // 丙 on #17735). Asserted on the `--no-commit` path on purpose: that path moved - // the pin too, and it is the one that returns early. - check( - '#5960 a bump prints the manifest-regeneration step (gen-sdui-manifest-node.mjs over the built tree)', - bumpStdout.includes('node scripts/gen-sdui-manifest-node.mjs') - && bumpStdout.includes('pnpm objectui:build') - && bumpStdout.includes('NEXT STEP'), - bumpStdout, - ); - const written = join(fwRun, '.changeset', `console-${head.slice(0, 12)}.md`); - const body = existsSync(written) ? readFileSync(written, 'utf8') : ''; - check('bump-objectui.sh writes the digest-derived changeset', body.length > 0); - check( - 'the emitted changeset declares the derived bump — `patch`, whatever objectui declared (ruling A)', - body.startsWith('---\n"@objectstack/console": patch\n---'), - body.slice(0, 60), - ); - check( - 'the emitted changeset carries its `Metadata-protocol: none` line, at the start of a line, for the gate and the release notes', - /^Metadata-protocol: none\b/m.test(body), - body, - ); - check( - 'the emitted changeset carries the breaking entry and not the ci noise', - body.includes('PageNodeRenderer') && !body.includes('cross-repo token'), - ); - check('the pin file is updated', readFileSync(join(fwRun, '.objectui-sha'), 'utf8').trim() === head); - - // --- an unwalkable range: REFUSE, loudly (#14178) ----------------------- - // This case used to assert the degraded artifact. The artifact is gone: its - // bump level was the `patch` DEFAULT, indistinguishable in the published - // CHANGELOG from a level objectui declared. What the case still asserts is - // the half that mattered — the failure is loud, named, and takes the - // operator to a remedy — plus the #10797 invariant the refusal now owes. - battery('an unwalkable range: REFUSE, loudly (#14178)'); - const fwDegraded = join(tmp, 'fw-unwalkable'); - mkdirSync(join(fwDegraded, 'scripts'), { recursive: true }); - mkdirSync(join(fwDegraded, '.changeset'), { recursive: true }); - writeFileSync(join(fwDegraded, '.objectui-sha'), `${'0'.repeat(40)}\n`); - stageBumpDriver(fwDegraded); - const unwalkableRun = spawnSync( - 'bash', - [join(fwDegraded, 'scripts', 'bump-objectui.sh'), '--no-commit', head], - // #16644: `gitFreeEnv()` as the BASE -- `bump-objectui.sh` spawns `git` one frame - // down against the fixture, where an inherited GIT_DIR outranks its `cwd`. - { encoding: 'utf8', env: { ...gitFreeEnv(), OBJECTUI_ROOT: ui, GIT_TERMINAL_PROMPT: '0' } }, - ); - const unwalkableCs = join(fwDegraded, '.changeset', `console-${head.slice(0, 12)}.md`); - check( - 'an unwalkable range REFUSES loudly — non-zero, no artifact, pin untouched', - unwalkableRun.status !== 0 && - !existsSync(unwalkableCs) && - readFileSync(join(fwDegraded, '.objectui-sha'), 'utf8') === `${'0'.repeat(40)}\n` && - unwalkableRun.stderr.includes('REFUSING to bump') && - unwalkableRun.stderr.includes('cannot be walked'), - `status=${unwalkableRun.status} wrote=${existsSync(unwalkableCs)}\n${unwalkableRun.stderr}`, - ); - - // --- the INITIAL pin still degrades, and says so ------------------------ - // The one degraded artifact #14178 kept, so it needs the coverage the - // removed ones had: there is no previous SHA, hence no range, hence no - // remedy to name and nothing being guessed about a walk. A refusal here - // would make the first-ever pin impossible to write. - battery('the INITIAL pin still degrades, and says so'); - const fwInitial = join(tmp, 'fw-initial-pin'); - mkdirSync(join(fwInitial, 'scripts'), { recursive: true }); - mkdirSync(join(fwInitial, '.changeset'), { recursive: true }); - stageBumpDriver(fwInitial); - const initialRun = spawnSync( - 'bash', - [join(fwInitial, 'scripts', 'bump-objectui.sh'), '--no-commit', head], - // #16644: `gitFreeEnv()` as the BASE -- `bump-objectui.sh` spawns `git` one frame - // down against the fixture, where an inherited GIT_DIR outranks its `cwd`. - { encoding: 'utf8', env: { ...gitFreeEnv(), OBJECTUI_ROOT: ui, GIT_TERMINAL_PROMPT: '0' } }, - ); - const initialCs = join(fwInitial, '.changeset', `console-${head.slice(0, 12)}.md`); - const initialBody = existsSync(initialCs) ? readFileSync(initialCs, 'utf8') : ''; - check( - 'the INITIAL pin (no .objectui-sha at all) still writes a DEGRADED entry that says why', - initialRun.status === 0 && - initialBody.includes('**Degraded list**') && - initialBody.includes('this is the initial pin') && - initialBody.includes('NOT a\ncomplete account') && - !initialBody.includes('could not be walked'), - `status=${initialRun.status}\n${initialBody}\n${initialRun.stderr}`, - ); - - // --- #6175: a range whose `to` endpoint is a RELEASE COMMIT ------------- - // The shape that made a COMPLETE result look like a failure. A release - // consumes the changesets it ships, so every changeset added in the range - // is gone at `to` and every read falls back. Its own repo on purpose: the - // #4731 / #6099 fixtures above pin exact counts and must not shift under it. - battery('#6175: a range whose `to` endpoint is a RELEASE COMMIT'); - const ui3 = join(tmp, 'objectui-released'); - mkdirSync(join(ui3, '.changeset'), { recursive: true }); - const g3 = (...args) => git(ui3, args); - g3('init', '-q', '-b', 'main'); - g3('config', 'user.email', 'selftest@objectstack.ai'); - g3('config', 'user.name', 'self test'); - g3('config', 'commit.gpgsign', 'false'); - const commit3 = (subject, files, removals = []) => { - for (const [path, content] of Object.entries(files)) { - mkdirSync(dirname(join(ui3, path)), { recursive: true }); - writeFileSync(join(ui3, path), content); - } - for (const path of removals) rmSync(join(ui3, path), { force: true }); - g3('add', '-A'); - g3('commit', '-q', '-m', subject); - return g3('rev-parse', 'HEAD').trim(); - }; - - const base3 = commit3('chore: base', { 'README.md': 'base\n' }); - commit3('feat(grid): column pinning survives a layout reload (#3401)', { - '.changeset/grid-column-pinning-survives-reload.md': - '---\n"@object-ui/plugin-grid": minor\n---\n\nGrid column pinning survives a layout reload.\n', - 'src/grid.ts': 'a\n', - }); - commit3('fix(fields): the lookup picker keeps the authored value (#3402)', { - '.changeset/lookup-picker-keeps-authored-value.md': - '---\n"@object-ui/fields": patch\n---\n\nThe lookup picker keeps the authored value.\n', - 'src/fields.ts': 'b\n', - }); - const release3 = commit3( - 'chore: release packages (#3403)', - { 'package.json': '{"version":"17.1.0"}\n' }, - [ - '.changeset/grid-column-pinning-survives-reload.md', - '.changeset/lookup-picker-keeps-authored-value.md', - ], - ); - - const released = buildDigest({ - objectuiRoot: ui3, - frameworkRoot: fwPlain, - from: base3, - to: release3, - }); - check( - '#6175 a range crossing a release still collects every changeset', - released.releasing.length === 2 && released.changesetsAdded === 2, - `releasing=${released.releasing.length} added=${released.changesetsAdded}`, - ); - check( - '#6175 the fallback is COUNTED, not merely survived', - released.absentAtTo === 2, - `got ${released.absentAtTo}`, - ); - - // The CLI runs as a CHILD so its real stderr can be read: that stream is - // where git's `fatal:` used to land, and no in-process assertion can see it. - const selfPath = fileURLToPath(import.meta.url); - const runCli = (root, from, to, label) => - spawnSync( - process.execPath, - [ - selfPath, - '--objectui-root', root, - '--framework-root', fwPlain, - '--from', from, - '--to', to, - '--out', join(tmp, `cli-${label}.md`), - ], - { encoding: 'utf8' }, - ); - - const fellBack = runCli(ui3, base3, release3, 'released'); - check( - '#6175 the fallback no longer leaks git `fatal:` onto stderr', - fellBack.status === 0 && !fellBack.stderr.includes('fatal:'), - fellBack.stderr, - ); - check( - '#6175 the fallback SAYS SO — once, with the real count', - /^→ 2 of 2 changeset\(s\) added in this range no longer exist at [0-9a-f]{12} —/m.test( - fellBack.stderr, - ) && fellBack.stderr.split('no longer exist at').length === 2, - fellBack.stderr, - ); - const quietArtifact = readFileSync(join(tmp, 'cli-released.md'), 'utf8'); - check( - '#6175 the artifact stays COMPLETE — the quiet read is the whole read', - quietArtifact.includes('Grid column pinning survives') && - quietArtifact.includes('lookup picker keeps the authored value'), - quietArtifact, - ); - - const noFallback = runCli(ui, base, head, 'plain'); - check( - '#6175 a range that crossed no release gains NO new line', - noFallback.status === 0 && - !noFallback.stderr.includes('no longer exist at') && - !noFallback.stderr.includes('fatal:'), - noFallback.stderr, - ); - - // Both reads failing is a REAL failure — nothing was read and the entry - // would vanish from the record. Quiet is earned by the fallback that - // worked; it is never extended to this. Driven through the exported - // `readAt` because `classifyRange` cannot reach the branch: its `sha` - // comes from `--diff-filter=A` and therefore always has the path. - const probe = join(tmp, 'probe-both-reads-fail.mjs'); - writeFileSync( - probe, - `import { readAt } from ${JSON.stringify(pathToFileURL(selfPath).href)};\n` + - `readAt(${JSON.stringify(ui3)}, ${JSON.stringify(release3)}, ${JSON.stringify(base3)}, ` + - `'.changeset/never-existed.md');\n`, - ); - const bothFail = spawnSync(process.execPath, [probe], { encoding: 'utf8' }); - check( - '#6175 when BOTH reads fail the failure is LOUD, naming both attempts', - bothFail.status !== 0 && - bothFail.stderr.includes('cannot read .changeset/never-existed.md') && - bothFail.stderr.includes(release3) && - bothFail.stderr.includes(base3), - bothFail.stderr, - ); - check( - "#6175 both attempts' git diagnostics are re-emitted, not swallowed", - (bothFail.stderr.match(/fatal:/g) ?? []).length >= 2, - bothFail.stderr, - ); - - // --- #6174: the undeclared commits are NAMED, not only counted ----------- - // Its own repos, for the reason the two groups above have theirs: the #4731 - // / #6099 / #6175 fixtures pin exact counts and must not shift underneath. - // The row count here is deliberately > 1 so "the list agrees with the count" - // is a real claim, and the cap can be made to fire without touching `max` - // anywhere else. - battery('#6174: the undeclared commits are NAMED, not only counted'); - const SUBJECT_3518 = - 'fix(form): bind `previous` for field rules and stop resubmitting read-only fields (#3518)'; - - const ui4 = join(tmp, 'objectui-undeclared'); - mkdirSync(join(ui4, '.changeset'), { recursive: true }); - const g4 = (...args) => git(ui4, args); - g4('init', '-q', '-b', 'main'); - g4('config', 'user.email', 'selftest@objectstack.ai'); - g4('config', 'user.name', 'self test'); - g4('config', 'commit.gpgsign', 'false'); - const commit4 = (subject, files) => { - for (const [path, content] of Object.entries(files)) { - mkdirSync(dirname(join(ui4, path)), { recursive: true }); - writeFileSync(join(ui4, path), content); - } - g4('add', '-A'); - g4('commit', '-q', '-m', subject); - return g4('rev-parse', 'HEAD').trim(); - }; - - const base4 = commit4('chore: base', { 'README.md': 'base\n' }); - commit4('feat(grid): row virtualization for long lists (#3600)', { - '.changeset/grid-row-virtualization.md': - '---\n"@object-ui/plugin-grid": patch\n---\n\nThe grid virtualizes long lists.\n', - 'src/grid.ts': 'a\n', - }); - // The live specimen this issue is named after: a real, user-visible change - // that declared nothing. Committed FIRST of the three so the newest-first - // order puts it LAST — which is what lets the cap below hide it. - const undeclared3518 = commit4(SUBJECT_3518, { - 'packages/plugin-form/src/rules.ts': 'previous\n', - 'packages/components/src/form.tsx': 'readonly\n', - }); - const undeclaredDeps = commit4('chore(deps-dev): Bump postcss from 8.5.25 to 8.5.26 (#3519)', { - 'package.json': '{"devDependencies":{"postcss":"8.5.26"}}\n', - }); - const undeclaredDocs = commit4('docs(guide): retarget two dead examples/ links (#3509)', { - 'docs/guide.md': 'links\n', - }); - const head4 = g4('rev-parse', 'HEAD').trim(); - - const named = buildDigest({ - objectuiRoot: ui4, - frameworkRoot: fwPlain, - from: base4, - to: head4, - }); - const namedRows = named.body - .split('\n') - .filter((l) => l.startsWith('- _(no changeset)_')); - - check( - '#6174 every commit with no changeset is NAMED by subject, carrying its sha', - namedRows.length === 3 && - named.body.includes( - `- _(no changeset)_ ${SUBJECT_3518} (objectui \`${undeclared3518.slice(0, 9)}\`)`, - ) && - named.body.includes( - `- _(no changeset)_ chore(deps-dev): Bump postcss from 8.5.25 to 8.5.26 (#3519) (objectui \`${undeclaredDeps.slice(0, 9)}\`)`, - ) && - named.body.includes( - `- _(no changeset)_ docs(guide): retarget two dead examples/ links (#3509) (objectui \`${undeclaredDocs.slice(0, 9)}\`)`, - ), - named.body, - ); - check( - '#6174 the rendered rows AGREE with the count — no list/number drift', - namedRows.length === named.noChangeset && named.noChangesetCommits.length === namedRows.length, - `rows=${namedRows.length} noChangeset=${named.noChangeset}`, - ); - check( - '#6174 the block says WHAT it is — shipped in this build, declared nowhere', - named.body.includes( - '**In this console build, declared nowhere** — objectui merged 3 commits in this range with no `.changeset/*.md`.', - ) && named.body.includes('objectui#3387'), - named.body, - ); - check( - '#6174 naming is ADDITIVE — the releasing list is unchanged and still leads', - named.releasing.length === 1 && - named.body.includes('- **patch** — The grid virtualizes long lists.') && - named.body.indexOf('- _(no changeset)_') > named.body.indexOf('- **patch** —'), - named.body, - ); - check( - '#6174 the accounting line is untouched — a section was added, no count moved', - named.body.includes( - 'Derived from the changesets objectui declared over the range — 1 releasing of 1 changeset added across 4 non-merge commits; omitted: 3 commits carrying no changeset (they ship no package code).', - ), - named.body, - ); - - const named2 = buildDigest({ - objectuiRoot: ui4, - frameworkRoot: fwPlain, - from: base4, - to: head4, - max: 2, - }); - const named2Rows = named2.body.split('\n').filter((l) => l.startsWith('- _(no changeset)_')); - check( - '#6174 a capped undeclared list SAYS SO, with the real total and the way to the rest', - named2Rows.length === 2 && - named2.body.includes( - '- …and 1 more commit with no changeset — this list is capped at 2, the range has 3 in total.', - ) && - named2.body.includes('--all'), - named2.body, - ); - check( - '#6174 truncation CAN hide the one commit worth naming — which is why it is loud', - // Not a restatement of the check above: it measures the stake. The - // specimen is the oldest of the three, so a cap eats it first, and a - // SILENT cap would restore exactly the defect this issue is about — a - // number where objectui#3518 used to be. - !named2.body.includes(SUBJECT_3518) && named2.body.includes('the range has 3 in total'), - named2.body, - ); - check( - '#6174 the two caps are INDEPENDENT — truncating the releasing list empties nothing else', - capped.body.includes('…and 2 more releasing changesets') && - capped.body.includes( - '- _(no changeset)_ fix(ci): never render a budget FAIL for a run that measured nothing (#3198)', - ), - capped.body, - ); - check( - '#6174 the commit the old body could only COUNT is now in the record by name', - digest.body.includes( - '- _(no changeset)_ fix(ci): never render a budget FAIL for a run that measured nothing (#3198)', - ), - digest.body, - ); - check( - '#6174 only the no-changeset class is named — a release-nothing changeset DID declare', - // The count clause is the load-bearing one: it goes red both if the block - // disappears (1 → 0) and if release-nothing entries are swept in (1 → 2). - // The substring clause alone would be green for an empty reason. - digest.body.split('\n').filter((l) => l.startsWith('- _(no changeset)_')).length === 1 && - !digest.body.includes('- _(no changeset)_ fix(ci): hand the cross-repo token'), - digest.body, - ); - check( - '#6174 body names the commits, #6175 keeps the read-fallback on stderr — adjacent, not merged', - quietArtifact.includes('- _(no changeset)_ chore: release packages (#3403)') && - !quietArtifact.includes('no longer exist at') && - fellBack.stderr.includes('no longer exist at') && - !fellBack.stderr.includes('_(no changeset)_'), - quietArtifact, - ); - - // NEGATIVE POLARITY, declared as such: this one cannot go red when the - // feature is deleted, because deleting it also produces no line. It pins a - // DIFFERENT regression — a block that fires on an empty list — so it stays, - // with the two positive guards that keep it from passing vacuously: the - // fixture must really be the zero case AND must really have produced a - // digest. Without them "no new line" would also be satisfied by an empty - // range, or by a `body` that failed to build at all. - const ui5 = join(tmp, 'objectui-all-declared'); - mkdirSync(join(ui5, '.changeset'), { recursive: true }); - const g5 = (...args) => git(ui5, args); - g5('init', '-q', '-b', 'main'); - g5('config', 'user.email', 'selftest@objectstack.ai'); - g5('config', 'user.name', 'self test'); - g5('config', 'commit.gpgsign', 'false'); - const commit5 = (subject, files) => { - for (const [path, content] of Object.entries(files)) { - mkdirSync(dirname(join(ui5, path)), { recursive: true }); - writeFileSync(join(ui5, path), content); - } - g5('add', '-A'); - g5('commit', '-q', '-m', subject); - return g5('rev-parse', 'HEAD').trim(); - }; - const base5 = commit5('chore: base', { 'README.md': 'base\n' }); - commit5('feat(core): every commit in this range declares (#3700)', { - '.changeset/core-declares.md': - '---\n"@object-ui/core": minor\n---\n\nA declared change.\n', - 'src/core.ts': 'a\n', - }); - commit5('fix(fields): and so does this one (#3701)', { - '.changeset/fields-declares.md': - '---\n"@object-ui/fields": patch\n---\n\nAnother declared change.\n', - 'src/fields.ts': 'b\n', - }); - const head5 = g5('rev-parse', 'HEAD').trim(); - - const allDeclared = buildDigest({ - objectuiRoot: ui5, - frameworkRoot: fwPlain, - from: base5, - to: head5, - }); - check( - '#6174 a range where every commit declared gains NO new line (negative polarity)', - allDeclared.noChangeset === 0 && - allDeclared.releasing.length === 2 && - !allDeclared.body.includes('_(no changeset)_') && - !allDeclared.body.includes('declared nowhere'), - allDeclared.body, - ); - - // --- #6494: the ADR-0087 disposition scaffold --------------------------- - // The gate's own marker pattern (`scripts/check-adr-0087-registration.mjs`, - // line 416 as measured), - // copied rather than imported: that module's CLI dispatch is top-level, so - // importing it would RUN the gate. The copy is belt-and-braces — the REAL - // gate binary judges a real artifact in the round trip below, and that, not - // this regex, is the authority on what the marker means. - battery('#6494: the ADR-0087 disposition scaffold'); - const markersIn = (text) => - [...text.matchAll(//g)].map((m) => - m[1].replace(/\s+/g, ' ').trim(), - ); - - // A range with NOTHING breaking in it, forced to `major` the way - // `CONSOLE_BUMP=major` does. The frontmatter alone is a breaking declaration - // to the gate, so this is the artifact a `breaking > 0` proxy would miss. - const majorOverride = buildDigest({ - objectuiRoot: ui5, - frameworkRoot: fwPlain, - from: base5, - to: head5, - bumpOverride: 'major', - }); - - check( - '#6494 an artifact that DECLARES breaking carries EXACTLY ONE adr-0087 marker', - annotated.adr0087Scaffold === true && - markersIn(annotated.body).length === 1 && - annotated.body.includes(ADR_0087_SCAFFOLD) && - digest.adr0087Scaffold === true && - markersIn(digest.body).length === 1, - annotated.body, - ); - check( - '#6494 the marker is a PLACEHOLDER — it names no disposition at all', - // Both legal forms must MISS it, or the generator would have answered the - // question on the human's behalf — the one thing #6148 forbids. - (markersIn(annotated.body)[0] ?? '').startsWith('TODO') && - !/^registered\b/i.test(markersIn(annotated.body)[0] ?? '') && - !/^not-required\b/i.test(markersIn(annotated.body)[0] ?? ''), - markersIn(annotated.body)[0], - ); - check( - '#6494 a `major` FRONTMATTER alone arms the gate — the scaffold follows it there', - // The gate declares breaking on the frontmatter ALONE, with no marker in - // the body, so a `breaking > 0` proxy would have missed this artifact. - majorOverride.bump === 'major' && - majorOverride.breaking === 0 && - !/\*\*BREAKING/i.test(majorOverride.body) && - majorOverride.adr0087Scaffold === true && - markersIn(majorOverride.body).length === 1, - majorOverride.body, - ); - check( - '#6494 a NON-breaking artifact carries no marker at all (negative polarity)', - // Declared as negative polarity: deleting the emission also produces no - // marker, so this cannot go red under that ablation. It pins a DIFFERENT - // regression — a marker on an artifact the gate never judges, which would - // sit unanswered forever and teach the next reader to ignore it. The three - // positive guards keep it from passing for the vacuous reason (an empty or - // unbuilt body would satisfy "no marker" just as well). - allDeclared.releasing.length === 2 && - allDeclared.breaking === 0 && - allDeclared.bump === 'patch' && - allDeclared.adr0087Scaffold === false && - markersIn(allDeclared.body).length === 0, - allDeclared.body, - ); - check( - '#6494 the flag, the rendered marker and the gate criterion agree on EVERY fixture', - [digest, plain, overridden, capped, annotated, named, named2, released, allDeclared, majorOverride].every( - (d) => - d.adr0087Scaffold === (markersIn(d.body).length === 1) && - d.adr0087Scaffold === (d.bump === 'major' || /\*\*BREAKING/i.test(d.body)), - ), - [digest, plain, overridden, capped, annotated, named, named2, released, allDeclared, majorOverride] - .map((d) => `${d.bump}/${d.adr0087Scaffold}/${markersIn(d.body).length}`) - .join(' '), - ); - - // --- #6494 THE ROUND TRIP, through the REAL gate ------------------------ - // The claim "the gate recognises this as present-but-unanswered" is about - // ANOTHER script, so nothing short of that script's own verdict settles it. - // A throwaway repo carrying the gate's required inputs (#4690: it refuses to - // report a verdict without them) plus a COPY of the gate, so its REPO_ROOT - // resolves here — the same idiom the bump-objectui.sh run above uses. - battery('#6494 THE ROUND TRIP, through the REAL gate'); - const gateRepo = join(tmp, 'fw-gate'); - mkdirSync(gateRepo, { recursive: true }); - const gg = (...args) => git(gateRepo, args); - const gw = (rel, text) => { - mkdirSync(dirname(join(gateRepo, rel)), { recursive: true }); - writeFileSync(join(gateRepo, rel), text); - }; - gg('init', '-q', '-b', 'main'); - gg('config', 'user.email', 'selftest@objectstack.ai'); - gg('config', 'user.name', 'self test'); - gg('config', 'commit.gpgsign', 'false'); - const LEDGER_ENTRY = (id) => - `export const X = {\n semantic: [\n {\n id: '${id}',\n surface: 's',\n },\n ],\n};\n`; - gw('packages/spec/src/migrations/registry.ts', LEDGER_ENTRY('old-entry-one')); - gw('packages/spec/src/conversions/registry.ts', LEDGER_ENTRY('a-conversion')); - // The gate's parser-rot witness is GENERATED at the rev it judges (#22485): it - // runs `packages/spec/scripts/build-spec-changes.ts --out` from a `git archive` - // of that rev, under the `tsx` its own checkout's `node_modules` supplies. The - // committed `spec-changes.json` this fixture used to carry is gone with the real - // one. So the sandbox carries a stand-in generator (the shape the gate's own - // self-test stages: it EVALUATES the fixture registry, which is what the real - // generator does to the real one) and the package manifest the archive takes, - // and borrows this checkout's `node_modules`, untracked. - gw( - 'packages/spec/scripts/build-spec-changes.ts', - "import { writeFileSync } from 'node:fs';\n" - + "import { X } from '../src/migrations/registry';\n" - + "const out = process.argv[process.argv.indexOf('--out') + 1];\n" - + 'writeFileSync(out, JSON.stringify({ perMajor: [{ to: 17, migrated: ' - + 'X.semantic.map((e: { id: string }) => ({ migrationId: e.id })) }] }));\n', - ); - gw('packages/spec/package.json', JSON.stringify({ name: '@objectstack/spec', version: '1.0.0' })); - gw('.git/info/exclude', 'node_modules\n'); - symlinkSync(join(__dirname, '..', 'node_modules'), join(gateRepo, 'node_modules')); - // ADR-0087 itself, an input the gate refuses to report a verdict without - // since #8299: it pins its `CATEGORIES` vocabulary against the categories the - // record documents, in BOTH directions, so a category with no written - // description cannot be claimed. Copied from this repo rather than written out - // here — the same idiom as the gate copy above, and the only spelling that - // cannot drift when the vocabulary next changes. - gw( - 'docs/adr/0087-metadata-protocol-upgrade-contract.md', - readFileSync(join(__dirname, '..', 'docs', 'adr', '0087-metadata-protocol-upgrade-contract.md'), 'utf8'), - ); - // One declared-breaking changeset in STOCK (committed at base, so it is not - // in the judged diff) — the gate's convention-rot assertion needs its - // breaking detector to match something. - gw('.changeset/stock-breaking.md', '---\n"@objectstack/spec": major\n---\n\nstock\n\n**BREAKING** something\n'); - // EVERY first-party module that gate imports, TRANSITIVELY, travels with the - // copy, or it dies on ERR_MODULE_NOT_FOUND — and the two cases below then - // read as "the ROUND TRIP assertion broke" when nothing about the round trip - // moved. - // - // ⭐ It was a hand MANIFEST, and the manifest is what broke (#16421). That - // gate gained one import — the fleet's one clause-② declaration reader, then - // living inside ten thousand lines of sweep with a nine-module closure — and - // the author updated the gate's OWN I1/I2 staging site in the same edit and - // not this one. CI went red HERE, on a gate about objectui changesets, with - // an error naming neither the new import nor this list. Both sites now DERIVE - // the closure from the same edges Node resolves, through - // `first-party-closure.mjs`; neither holds an opinion about the graph any - // more, and adding an import over there costs nothing here by construction. - // - // ⚠️ THE ENTRY IS READ HERE, BY NAME, and that line is not redundant with the - // walk below — it is this family's REGISTRATION. `dispatch-gates` derives - // "which gate does this family run a copy of?" from an anchored - // `readFileSync` whose target resolves to a tracked path; a loop variable or - // a read that happens inside another module is deliberately NOT followed. So - // the first spelling of this fix staged the gate correctly and made the - // dependency invisible, and `check:pm-dispatch-gates` said so: "the staged - // gate reaches the family that runs a copy of it (no key)". ⛔ Do not fold - // this read into the walk to save a line — the walk stages the DEPENDENCIES, - // this read stages the GATE, and each is load-bearing for a different reader. - const gateEntry = 'scripts/check-adr-0087-registration.mjs'; - gw(gateEntry, readFileSync(join(__dirname, 'check-adr-0087-registration.mjs'), 'utf8')); - const gateDeps = firstPartyModuleClosure(gateEntry, { root: join(__dirname, '..') }).filter((rel) => rel !== gateEntry); - for (const rel of gateDeps) gw(rel, readFileSync(join(__dirname, '..', rel), 'utf8')); - check( - '#6494 the staged gate carries its whole first-party closure — DERIVED, not a hand manifest', - gateDeps.includes('scripts/pm/clause2-line.mjs') && gateDeps.length >= 2, - `${gateDeps.length} dependenc(ies): ${gateDeps.join(', ')}`, - ); - gg('add', '-A'); - gg('commit', '-q', '-m', 'base'); - const gateBase = gg('rev-parse', 'HEAD').trim(); - - // The range is the #6099 fixture: `minor` + the author's own annotation, - // objectui's ORDINARY breaking shape and the one that reopens this every bump. - const gateCs = join(gateRepo, '.changeset', `console-${head2.slice(0, 12)}.md`); - const generated = spawnSync( - process.execPath, - [selfPath, '--objectui-root', ui2, '--framework-root', gateRepo, '--from', base2, '--to', head2, '--out', gateCs], - { encoding: 'utf8' }, - ); - gg('add', '-A'); - gg('commit', '-q', '-m', 'chore(console): bump objectui pin'); - const runGate = () => - spawnSync(process.execPath, [join(gateRepo, 'scripts', 'check-adr-0087-registration.mjs'), '--base', gateBase], { - encoding: 'utf8', - cwd: gateRepo, - }); - - const gateRed = runGate(); - check( - '#6494 ROUND TRIP (red): the REAL gate reads the marker and refuses it as UNANSWERED', - generated.status === 0 && - gateRed.status !== 0 && - // The EXACT verdict, not merely "it is red": present-but-unparseable is a - // different fact from absent, and only the first one proves the scaffold - // reached the gate at all. - /but unparseable disposition: "TODO/.test(gateRed.stderr) && - gateRed.stderr.includes(`console-${head2.slice(0, 12)}.md`) && - gateRed.stderr.includes(''), - `generator=${generated.status} gate=${gateRed.status}\n${gateRed.stderr}`, - ); - check( - '#6494 the operator is told at BUMP time, not one push later', - generated.stderr.includes('UNANSWERED ADR-0087') && generated.stderr.includes('adr-0087: TODO'), - generated.stderr, - ); - - // The other half. Same file, same range, same generator — only the human's - // answer is added, and the gate's verdict flips. A red that no answer can - // clear would be a broken gate, not a scaffold. - // - // The replacement is CHECKED before it is committed, and the commit allows an - // empty tree. Found while reverse-verifying this group: with the emission - // ablated there is no placeholder to replace, so the write was a no-op, `git - // commit` exited 1 on a clean tree and threw — the whole self-test died with - // a raw `execFileSync` stack instead of naming a failed check. An ablation is - // exactly when the next reader needs a NAME, not a stack trace. - const answered = readFileSync(gateCs, 'utf8').replace( - ADR_0087_SCAFFOLD, - '', - ); - check( - '#6494 the answer replaces the placeholder IN PLACE — one marker before, one after', - answered !== readFileSync(gateCs, 'utf8') && - !answered.includes('adr-0087: TODO') && - markersIn(answered).length === 1, - answered.slice(-400), - ); - writeFileSync(gateCs, answered); - gg('add', '-A'); - gg('commit', '-q', '--allow-empty', '-m', 'answer the ADR-0087 question'); - const gateGreen = runGate(); - check( - '#6494 ROUND TRIP (green): a real disposition written in that same place clears it', - gateGreen.status === 0 && - gateGreen.stdout.includes( - '✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.', - ), - `status=${gateGreen.status}\n${gateGreen.stdout}\n${gateGreen.stderr}`, - ); - // ---- #7004: the frontmatter shapes the old entry anchor hid ------------ - // - // This file is the FOURTH carrier of the family's entry regex and the one - // #7004's report did not name. Its consequence is neither a false green nor - // a false red but a silent DROP: an entry the regex cannot see makes the - // changeset read `release-nothing`, so the commit leaves the digest — the - // #4731 harm, reached through the parser instead of through a type filter. - // Worst, as ever, for breaking changes, which is the one class that must - // never vanish from a release record. - // - // Predicted direction on reverse verification: restoring the old anchor - // (`([A-Za-z]+)\s*$`) turns C1-C5 red (packages goes `{}`) and C6 red in the - // other direction (a phantom package named `# note`). - battery('#7004: the frontmatter shapes the old entry anchor hid'); - const pkgsOf = (block) => JSON.stringify(parseChangeset(`---\n${block}\n---\n\nsummary\n`).packages); - check('#7004 C1 a trailing YAML comment still declares its package', pkgsOf('"@object-ui/layout": major # keep') === '{"@object-ui/layout":"major"}', pkgsOf('"@object-ui/layout": major # keep')); - check('#7004 C2 a trailing comment after a tab', pkgsOf('"@object-ui/layout": minor\t# keep') === '{"@object-ui/layout":"minor"}', pkgsOf('"@object-ui/layout": minor\t# keep')); - check('#7004 C3 a double-quoted bump value', pkgsOf('"@object-ui/layout": "major"') === '{"@object-ui/layout":"major"}', pkgsOf('"@object-ui/layout": "major"')); - check('#7004 C4 a single-quoted bump value with a comment', pkgsOf('"@object-ui/layout": \'minor\' # keep') === '{"@object-ui/layout":"minor"}', pkgsOf('"@object-ui/layout": \'minor\' # keep')); - check( - '#7004 C5 a commented entry beside an uncommented one — BOTH survive', - pkgsOf('"@object-ui/a": major # keep\n"@object-ui/b": patch') === '{"@object-ui/a":"major","@object-ui/b":"patch"}', - pkgsOf('"@object-ui/a": major # keep\n"@object-ui/b": patch'), - ); - check( - '#7004 C6 a whole-line comment containing a colon is NOT a package (it used to parse as one named `# note`)', - pkgsOf('# note: major\n"@object-ui/real": patch') === '{"@object-ui/real":"patch"}', - pkgsOf('# note: major\n"@object-ui/real": patch'), - ); - check( - '#7004 C7 control — the same shape with an unknown bump word still declares nothing, so C1-C6 are about the entry regex and not about LEVEL_RANK being bypassed', - pkgsOf('"@object-ui/layout": enormous # keep') === '{}', - pkgsOf('"@object-ui/layout": enormous # keep'), - ); - - // ---- #7044: WHERE the fence is allowed to start ------------------------ - // - // The other row of the same dialect table. #6923 taught the three gates to - // skip leading blank lines before the fence; this fourth carrier kept - // requiring it on line 1, and #7004's alignment pass did not touch the row. - // Same consequence as the block above and by the same mechanism — a silent - // DROP — but reached without any entry being malformed at all: the entries - // are perfect, the parser simply never enters the block. - // - // Measured with @changesets/parse@0.4.3 (the version this repo resolves) on - // 2026-08-10: every D-fixture below is read by changesets as a real `major`. - // So a miss here is a breaking change vanishing from the release record — - // #4731's stated "single class that must never vanish". - // - // Predicted direction on reverse verification: restoring `lines[0]?.trim() - // === '---'` (with `i = 1`) turns D1-D4 red with `packages` going `{}`, and - // turns D5 red in the other direction — the raw frontmatter comes back as - // the summary text. D6/D7 are the controls and stay green in both worlds, - // which is what makes D1-D5 statements about the preamble rather than about - // a fixture that parses as nothing either way. - battery('#7044: WHERE the fence is allowed to start'); - const CS_MAJOR = '---\n"@object-ui/layout": major\n---\n\nDrop PageNodeRenderer.\n'; - const parsedPkgs = (text) => JSON.stringify(parseChangeset(text).packages); - check('#7044 D1 one leading blank line before the fence', parsedPkgs('\n' + CS_MAJOR) === '{"@object-ui/layout":"major"}', parsedPkgs('\n' + CS_MAJOR)); - check('#7044 D2 two leading blank lines', parsedPkgs('\n\n' + CS_MAJOR) === '{"@object-ui/layout":"major"}', parsedPkgs('\n\n' + CS_MAJOR)); - check('#7044 D3 a leading WHITESPACE-ONLY line (the preamble trims, it does not test for empty)', parsedPkgs(' \n' + CS_MAJOR) === '{"@object-ui/layout":"major"}', parsedPkgs(' \n' + CS_MAJOR)); - check( - '#7044 D4 a leading blank line with CRLF endings — the two dialect rows compose', - parsedPkgs('\r\n---\r\n"@object-ui/layout": major # keep\r\n---\r\n\r\nbody\r\n') === '{"@object-ui/layout":"major"}', - parsedPkgs('\r\n---\r\n"@object-ui/layout": major # keep\r\n---\r\n\r\nbody\r\n'), - ); - check( - '#7044 D5 the SUMMARY is the body, not the frontmatter — the old anchor handed back the raw fence text', - parseChangeset('\n' + CS_MAJOR).summary === 'Drop PageNodeRenderer.', - JSON.stringify(parseChangeset('\n' + CS_MAJOR).summary), - ); - check( - '#7044 D6 control — the identical text WITHOUT the leading blank line parses the same, so D1-D5 are about the blank line and nothing else', - parsedPkgs(CS_MAJOR) === '{"@object-ui/layout":"major"}' && parseChangeset(CS_MAJOR).summary === 'Drop PageNodeRenderer.', - `${parsedPkgs(CS_MAJOR)} / ${JSON.stringify(parseChangeset(CS_MAJOR).summary)}`, - ); - check( - '#7044 D7 control — skipping blanks did NOT make the parser fence-less: a leading blank line over an UNFENCED file still declares nothing, and its body survives whole', - parsedPkgs('\n"@object-ui/layout": major\n') === '{}' && parseChangeset('\n"@object-ui/layout": major\n').body === '"@object-ui/layout": major', - `${parsedPkgs('\n"@object-ui/layout": major\n')} / ${JSON.stringify(parseChangeset('\n"@object-ui/layout": major\n').body)}`, - ); - // ---- #9408: WALK COMPLETENESS, not object presence --------------------- - // - // The guard this group pins used to ask `git cat-file -e OLD_SHA` — "is the - // OLD endpoint present as an object". A truncated history answers that - // perfectly and then LIES about everything else: git shows its oldest - // visible commit as parentless, diffs it against the empty tree, and reports - // every `.changeset/*.md` in that commit's TREE as added by it. The dedup in - // `collectAddedChangesets` hands it whatever no newer commit claimed, so one - // commit absorbs a batch and the artifact looks complete. - // - // ONE REPO, TWO WORLDS. The truncation is synthesized by writing `.git/ - // shallow` (the reproduction #9408 used, and the shape the live containers - // were in), so the complete and truncated fixtures are the SAME commits and - // differ in exactly one file. That is what makes each assertion below a - // statement about the graft rather than about two repos that differ somehow. - // - // BOTH DIRECTIONS ARE PINNED, deliberately: a guard that refuses everything - // passes any refusal test. So the complete tree must still produce the full - // walk (C1), and — the sharper control — a tree that IS shallow with its - // boundary AT `from`, hence outside the range, must also walk completely - // (C7). C7 is the false positive that `--is-shallow-repository` alone would - // produce, and the reason the invariant is positional rather than a flag. - battery('#9408: WALK COMPLETENESS, not object presence'); - const ui6 = join(tmp, 'objectui-truncated'); - mkdirSync(join(ui6, '.changeset'), { recursive: true }); - const g6 = (...args) => git(ui6, args); - g6('init', '-q', '-b', 'main'); - g6('config', 'user.email', 'selftest@objectstack.ai'); - g6('config', 'user.name', 'self test'); - g6('config', 'commit.gpgsign', 'false'); - const commit6 = (subject, files) => { - for (const [path, content] of Object.entries(files)) { - mkdirSync(dirname(join(ui6, path)), { recursive: true }); - writeFileSync(join(ui6, path), content); - } - g6('add', '-A'); - g6('commit', '-q', '-m', subject); - return g6('rev-parse', 'HEAD').trim(); - }; - const cs6 = (n) => ({ - [`.changeset/entry-${n}.md`]: - `---\n"@object-ui/core": patch\n---\n\nUpstream change number ${n}.\n`, - [`src/f${n}.ts`]: `${n}\n`, - }); - commit6('chore: root', { 'README.md': 'root\n' }); - // `from` is deliberately NOT the root commit: grafting AT it (C7 below) has - // to be a real truncation of real history, or that control proves nothing. - const c6from = commit6('feat(core): the pinned starting point (#4001)', cs6(1)); - const c6two = commit6('feat(core): second (#4002)', cs6(2)); - const c6sink = commit6('feat(core): third — the future sink (#4003)', cs6(3)); - const c6four = commit6('feat(core): fourth (#4004)', cs6(4)); - const c6to = commit6('feat(core): fifth (#4005)', cs6(5)); - // As above (#10495): keep this fixture's truncation assertions independent - // of the reachability report by giving it the ref a real checkout has. - g6('update-ref', 'refs/remotes/origin/main', c6to); - const shallowFile = join(ui6, '.git', 'shallow'); - - const whole = buildDigest({ - objectuiRoot: ui6, - frameworkRoot: fwPlain, - from: c6from, - to: c6to, - }); - check( - '#9408 C1 a COMPLETE history walks the WHOLE range (the control a refuse-everything guard fails)', - whole.totalCommits === 4 && whole.changesetsAdded === 4 && whole.releasing.length === 4, - `commits=${whole.totalCommits} added=${whole.changesetsAdded} releasing=${whole.releasing.length}`, - ); - const creditedTo = (digestLike, sha) => - digestLike.releasing.filter((r) => r.sha === sha).length; - check( - '#9408 C2 every entry is credited to the commit that really added it — no sink', - [c6from, c6two, c6sink, c6four, c6to].every((sha) => creditedTo(whole, sha) <= 1) && - creditedTo(whole, c6sink) === 1, - whole.releasing.map((r) => `${r.path}@${r.sha.slice(0, 7)}`).join(' '), - ); - - // Synthesize the boundary INSIDE the range — #9408's own reproduction. - writeFileSync(shallowFile, `${c6sink}\n`); - - check( - '#9408 C3 the OLD guard is blind: `cat-file -e OLD_SHA` still PASSES on the truncated tree', - // The whole defect in one line. This is why the test had to change at all: - // object presence is answerable from a history that cannot be walked. - (() => { - try { - git(ui6, ['cat-file', '-e', `${c6from}^{commit}`]); - return true; - } catch { - return false; - } - })() && git(ui6, ['rev-parse', '--is-shallow-repository']).trim() === 'true', - ); - const truncatedRaw = collectAddedChangesets(ui6, c6from, c6to); - const sinkCredited = truncatedRaw.entries.filter((e) => e.sha === c6sink).length; - check( - '#9408 C4 the HARM is real and measured here, not asserted: the boundary commit absorbs a batch', - // In the complete world this commit adds exactly one changeset (C2). Here - // it is handed three — itself plus the two the truncated tail would have - // claimed. The same shape as the 36-of-119 and 12-of-121 live records. - sinkCredited === 3 && truncatedRaw.totalCommits === 3, - `sinkCredited=${sinkCredited} totalCommits=${truncatedRaw.totalCommits}`, - ); - check( - '#9408 C5 the count silently SHRINKS — a truncated walk and a complete one report alike', - whole.totalCommits === 4 && truncatedRaw.totalCommits === 3, - ); - const trunc = findRangeTruncation(ui6, c6from, c6to); - check( - '#9408 C6 findRangeTruncation NAMES the sink, and knows the clone is shallow', - trunc.truncated === true && - trunc.sinks.length === 1 && - trunc.sinks[0] === c6sink && - trunc.shallow === true, - JSON.stringify(trunc), - ); - - // THE FALSE-POSITIVE CONTROL. Same repo, still shallow, boundary moved to - // `from` — which is EXCLUDED from `from..to`, so the walk over the range is - // complete and refusing it would be wrong. A guard keyed on - // `--is-shallow-repository` fires here; the positional invariant does not. - writeFileSync(shallowFile, `${c6from}\n`); - const boundaryAtFrom = findRangeTruncation(ui6, c6from, c6to); - const stillWhole = buildDigest({ - objectuiRoot: ui6, - frameworkRoot: fwPlain, - from: c6from, - to: c6to, - }); - check( - '#9408 C7 a SHALLOW clone whose boundary is outside the range is NOT refused, and derives identically', - boundaryAtFrom.truncated === false && - boundaryAtFrom.shallow === true && - stillWhole.body === whole.body && - stillWhole.totalCommits === 4, - `truncated=${boundaryAtFrom.truncated} shallow=${boundaryAtFrom.shallow} commits=${stillWhole.totalCommits}`, - ); - - // Back to the harmful boundary for the refusal assertions. - writeFileSync(shallowFile, `${c6sink}\n`); - let classifyThrew = ''; - try { - classifyRange({ objectuiRoot: ui6, from: c6from, to: c6to }); - } catch (err) { - classifyThrew = String(err?.message ?? err); - } - check( - '#9408 C8 the SHARED implementation refuses — so objectui-range.mjs cannot derive from it either', - classifyThrew.includes('STOPS INSIDE') && classifyThrew.includes(c6sink.slice(0, 12)), - classifyThrew, - ); - - const walkProbe = (extraArgs) => - spawnSync( - process.execPath, - [selfPath, '--objectui-root', ui6, '--framework-root', fwPlain, '--from', c6from, '--to', c6to, ...extraArgs], - { encoding: 'utf8' }, - ); - const checkWalkable = walkProbe(['--check-walkable']); - check( - '#9408 C9 --check-walkable exits 3 (truncated) and names the remedy, deriving nothing', - checkWalkable.status === 3 && - checkWalkable.stderr.includes('fetch --unshallow') && - checkWalkable.stdout === '', - `status=${checkWalkable.status} stdout=${JSON.stringify(checkWalkable.stdout)}\n${checkWalkable.stderr}`, - ); - const truncOut = join(tmp, 'cli-truncated.md'); - const derivingRefused = walkProbe(['--out', truncOut]); - check( - '#9408 C10 the DERIVING path refuses with the documented 2 and writes NO artifact at all', - // Writing a wrong artifact is the failure; exiting non-zero without one is - // the fix. The file's absence is the load-bearing half. - derivingRefused.status === 2 && !existsSync(truncOut), - `status=${derivingRefused.status} wrote=${existsSync(truncOut)}`, - ); - const walkableProbe = spawnSync( - process.execPath, - [selfPath, '--objectui-root', ui, '--framework-root', fwPlain, '--from', base, '--to', head, '--check-walkable'], - { encoding: 'utf8' }, - ); - check( - '#9408 C11 --check-walkable exits 0 on a complete tree — it is a test, not a refusal', - walkableProbe.status === 0 && walkableProbe.stderr.includes('walks completely'), - `status=${walkableProbe.status}\n${walkableProbe.stderr}`, - ); - - // THE REJECTED ALTERNATIVE, pinned so it cannot be reintroduced as a "cheap - // backstop". #9408 proposed asserting that no commit is credited with more - // added changesets than `git show --name-only` attributes to it. It cannot - // fire: `git show` on the sink runs the SAME empty-tree diff, so the - // credited set is that attributed set minus the dedup — a subset, always. - // Measured on the two live bad runs it caught neither (36 credited vs 72 - // attributed; 12 vs 47), and it catches nothing here either. - const attributedToSink = git(ui6, [ - 'show', '--name-only', '--format=', '--diff-filter=A', c6sink, '--', '.changeset/', - ]) - .split('\n') - .map((l) => l.trim()) - .filter((l) => l.endsWith('.md')).length; - check( - '#9408 C12 the arithmetic check #9408 sketched is a TAUTOLOGY — it does not fire on this very fault', - sinkCredited === 3 && attributedToSink === 3 && !(sinkCredited > attributedToSink), - `credited=${sinkCredited} attributed=${attributedToSink}`, - ); - - // ---- #9408 through the shell driver: REFUSE, and say WHICH failure ------ - // - // These four used to pin the DEGRADED artifact this input produced. #14178 - // removed that path: the artifact carried a bump level nobody declared - // (`patch`) into published CHANGELOG text, and no record beats a wrong one. - // What survives unchanged is everything that made the old artifact readable - // — WHICH failure this was, and the remedy — now said in a refusal. - battery('#9408 through the shell driver: REFUSE, and say WHICH failure'); - const fwTrunc = join(tmp, 'fw-truncated'); - mkdirSync(join(fwTrunc, 'scripts'), { recursive: true }); - mkdirSync(join(fwTrunc, '.changeset'), { recursive: true }); - writeFileSync(join(fwTrunc, '.objectui-sha'), `${c6from}\n`); - stageBumpDriver(fwTrunc); - const truncPinBefore = readFileSync(join(fwTrunc, '.objectui-sha'), 'utf8'); - // OBJECTUI_NO_DEEPEN=1 on purpose: a self-test must never reach the network, - // and this run is also the opt-out's only coverage. - const truncBump = spawnSync('bash', [join(fwTrunc, 'scripts', 'bump-objectui.sh'), '--no-commit', c6to], { - encoding: 'utf8', - env: { ...gitFreeEnv(), OBJECTUI_ROOT: ui6, OBJECTUI_NO_DEEPEN: '1' }, // #16644: see above - }); - const truncCsPath = join(fwTrunc, '.changeset', `console-${c6to.slice(0, 12)}.md`); - check( - '#9408/#14178 C13 a truncated range REFUSES instead of shipping any record — no changeset file at all', - truncBump.status !== 0 && !existsSync(truncCsPath), - `status=${truncBump.status} wrote=${existsSync(truncCsPath)}\n${truncBump.stderr}`, - ); - check( - '#9408/#14178 C14 the refusal says WHICH failure it was, and names the fix', - // A refusal must be distinguishable from every other refusal: this one is - // a truncated walk, not an absent endpoint, and only one remedy applies. - truncBump.stderr.includes('REFUSING to bump') && - truncBump.stderr.includes('STOPS INSIDE') && - truncBump.stderr.includes('fetch --unshallow') && - truncBump.stderr.includes('objectstack#9408'), - truncBump.stderr, - ); - check( - '#9408/#10797/#14178 C15 the refused run left NO half-applied state — .objectui-sha is byte-identical', - // The refusal is asked ABOVE the pin write for exactly this reason. It is - // the same invariant #10797 pinned for an unreadable commit object, now - // owed by a second refusal — and the one thing a "just exit 1" fix would - // have broken silently. - readFileSync(join(fwTrunc, '.objectui-sha'), 'utf8') === truncPinBefore, - `before=${JSON.stringify(truncPinBefore)} after=${JSON.stringify(readFileSync(join(fwTrunc, '.objectui-sha'), 'utf8'))}`, - ); - check( - '#9408 C16 the OPT-OUT is reported, not silent', - truncBump.stderr.includes('OBJECTUI_NO_DEEPEN=1'), - truncBump.stderr, - ); - - // A deepen that does not actually REPAIR the range must still degrade. The - // re-check is what decides, never the fetch's exit code — and that is not a - // hypothetical distinction: measured here, `git fetch --unshallow` in a - // repository with NO remote configured exits **0** and changes nothing at - // all. Trusting the remedy's status would have handed a truncated walk to - // the digest with RANGE_OK=1, which is this whole card's failure shape - // arriving one layer further in. Offline by construction, so the self-test - // never reaches the network. - const fwTrunc2 = join(tmp, 'fw-truncated-deepen-noop'); - mkdirSync(join(fwTrunc2, 'scripts'), { recursive: true }); - mkdirSync(join(fwTrunc2, '.changeset'), { recursive: true }); - writeFileSync(join(fwTrunc2, '.objectui-sha'), `${c6from}\n`); - stageBumpDriver(fwTrunc2); - const noopDeepen = spawnSync('bash', [join(fwTrunc2, 'scripts', 'bump-objectui.sh'), '--no-commit', c6to], { - encoding: 'utf8', - env: { ...gitFreeEnv(), OBJECTUI_ROOT: ui6, GIT_TERMINAL_PROMPT: '0' }, // #16644: see above - }); - const noopDeepenCsPath = join(fwTrunc2, '.changeset', `console-${c6to.slice(0, 12)}.md`); - check( - '#9408/#14178 C17 a deepen that exits 0 WITHOUT repairing the range still refuses — the re-check decides, not the fetch status', - noopDeepen.status !== 0 && - noopDeepen.stdout.includes('deepening') && - !noopDeepen.stdout.includes('the range walks completely now') && - noopDeepen.stderr.includes('REFUSING to bump') && - noopDeepen.stderr.includes('STOPS INSIDE') && - !existsSync(noopDeepenCsPath), - `status=${noopDeepen.status}\nSTDOUT:${noopDeepen.stdout}\nSTDERR:${noopDeepen.stderr}\nwrote=${existsSync(noopDeepenCsPath)}`, - ); - check( - '#9408 C18 the truncated tree is STILL truncated afterwards — the no-op fetch is pinned as a no-op', - // The positive guard on C17: without it, C17 would also pass if the fetch - // had silently repaired the repo and the degradation came from elsewhere. - findRangeTruncation(ui6, c6from, c6to).truncated === true, - ); - - // Leave the fixture complete, so nothing added after this group inherits a - // truncated tree by accident. - rmSync(shallowFile, { force: true }); - - // ---- #14178: an ABSENT endpoint is the same failure, one step earlier --- - // - // #9408's group above synthesizes its truncation by writing `.git/shallow`, - // which keeps both endpoints present as objects. That is the SECOND of the - // two ways a shallow checkout breaks a pin range, and the rarer one. The - // FIRST is what `git clone --depth N` produces every time: the boundary sits - // AFTER the old pin, so the `from` object is not in the store at all — and - // that case returned from the endpoint guard before `findRangeTruncation` - // ever ran, so the deepen the script advertises was never reached. - // - // THE FIXTURE IS A REAL SHALLOW CLONE, not a hand-written `.git/shallow`, - // because the repair is the thing under test: `fetch --unshallow` needs a - // remote to deepen FROM. It is a `file://` remote, so this stays offline by - // construction — a self-test must never reach the network. - // - // BOTH DIRECTIONS, as always: the refusal cases sit beside a run that - // deepens and derives the COMPLETE record, asserted equal to the record the - // same range yields in a full clone. A guard that refused everything would - // pass every refusal case here and fail that one. - battery('#14178: an ABSENT endpoint is the same failure, one step earlier'); - const uiUp = join(tmp, 'objectui-upstream'); - mkdirSync(join(uiUp, '.changeset'), { recursive: true }); - const gUp = (...args) => git(uiUp, args); - gUp('init', '-q', '-b', 'main'); - gUp('config', 'user.email', 'selftest@objectstack.ai'); - gUp('config', 'user.name', 'self test'); - gUp('config', 'commit.gpgsign', 'false'); - let upN = 0; - const commitUp = (subject, level, breaking = false) => { - upN += 1; - writeFileSync(join(uiUp, `src-${upN}.ts`), `${upN}\n`); - writeFileSync( - join(uiUp, '.changeset', `entry-${upN}.md`), - `---\n"@object-ui/core": ${level}\n---\n\n${subject}\n` + - (breaking ? '\n**BREAKING** the old prop is gone.\n' : ''), - ); - gUp('add', '-A'); - gUp('commit', '-q', '-m', subject); - return gUp('rev-parse', 'HEAD').trim(); - }; - // The LEVEL is the point of this fixture: the range declares `minor`, while - // the degraded path this card removed emitted the `patch` default. A range - // that declared `patch` would make every assertion below vacuous. - const upFrom = commitUp('feat(core): the pinned starting point (#5001)', 'patch'); - commitUp('fix(core): a patch change (#5002)', 'patch'); - commitUp('feat(grid): a MINOR change nobody would call patch (#5003)', 'minor'); - commitUp('refactor(layout): drop PageNodeRenderer (#5004)', 'minor', true); - const upTo = commitUp('feat(core): the tip (#5005)', 'patch'); - - const cloneUi = (name, depth) => { - const dir = join(tmp, name); - const args = ['clone', '-q']; - if (depth) args.push('--depth', String(depth)); - args.push(pathToFileURL(uiUp).href, dir); - // LOCAL-ONLY despite being a `clone` (#16644): the source is a `file://` URL under - // this battery's own mkdtemp root, so the transport settings this container carries - // in GIT_CONFIG_* / GIT_SSL_* are not in play -- while an inherited GIT_DIR would - // still decide where the clone lands. - execFileSync('git', args, { encoding: 'utf8', env: gitFreeEnv() }); - // A real checkout has this ref, and #10495's reachability report keys on - // it — without it these cases would bury their assertions under warnings. - git(dir, ['update-ref', 'refs/remotes/origin/main', upTo]); - return dir; - }; - // `--depth 2` leaves the tip and its parent: `to` is present, `from` is not. - const uiShallow = cloneUi('objectui-depth2', 2); - const uiFull = cloneUi('objectui-full', 0); - - const fromPresent = (root, rev) => { - try { - git(root, ['cat-file', '-e', `${rev}^{commit}`], { captureStderr: true }); - return true; - } catch { - return false; - } - }; - let truncationCouldAnswer = true; - try { - findRangeTruncation(uiShallow, upFrom, upTo); - } catch { - truncationCouldAnswer = false; - } - check( - '#14178 S1 the fixture is the OTHER shallow shape: `from` is ABSENT, and #9408’s test cannot even answer here', - // Why the absent test must run FIRST: `rev-list from..to` needs `from`. - // Ordering the two the other way round would answer this input with a git - // `fatal:` from inside the truncation probe — a failure, but an unnamed - // one, and the caller could not tell it from a broken checkout. - !fromPresent(uiShallow, upFrom) && - fromPresent(uiShallow, upTo) && - git(uiShallow, ['rev-parse', '--is-shallow-repository']).trim() === 'true' && - truncationCouldAnswer === false, - `fromPresent=${fromPresent(uiShallow, upFrom)} toPresent=${fromPresent(uiShallow, upTo)} truncationAnswered=${truncationCouldAnswer}`, - ); - const absence = findAbsentEndpoints(uiShallow, upFrom, upTo); - const absenceFull = findAbsentEndpoints(uiFull, upFrom, upTo); - check( - '#14178 S2 findAbsentEndpoints NAMES which endpoint is missing, knows the clone is shallow — and clears the full clone', - absence.absent === true && - absence.endpoints.length === 1 && - absence.endpoints[0].role === 'from' && - absence.endpoints[0].rev === upFrom && - absence.endpoints[0].diagnostic.includes('Not a valid object name') && - absence.shallow === true && - absenceFull.absent === false, - `${JSON.stringify({ absent: absence.absent, roles: absence.endpoints.map((e) => e.role), shallow: absence.shallow, full: absenceFull.absent })}`, - ); - const absentProbe = (extraArgs, root = uiShallow) => - spawnSync( - process.execPath, - [selfPath, '--objectui-root', root, '--framework-root', fwPlain, '--from', upFrom, '--to', upTo, ...extraArgs], - { encoding: 'utf8' }, - ); - const absentWalkable = absentProbe(['--check-walkable']); - check( - '#14178 S3 --check-walkable exits 2 on an absent endpoint, reports PREREQUISITE NOT MET and names the remedy, deriving nothing', - absentWalkable.status === 2 && - absentWalkable.stderr.includes('PREREQUISITE NOT MET') && - absentWalkable.stderr.includes('fetch --unshallow') && - absentWalkable.stdout === '', - `status=${absentWalkable.status} stdout=${JSON.stringify(absentWalkable.stdout)}\n${absentWalkable.stderr}`, - ); - const absentOut = join(tmp, 'cli-absent-endpoint.md'); - const absentDeriving = absentProbe(['--out', absentOut]); - check( - '#14178 S4 the DERIVING path refuses with 2 and writes NO artifact at all', - absentDeriving.status === 2 && !existsSync(absentOut), - `status=${absentDeriving.status} wrote=${existsSync(absentOut)}`, - ); - let absentClassifyThrew = ''; - try { - classifyRange({ objectuiRoot: uiShallow, from: upFrom, to: upTo }); - } catch (err) { - absentClassifyThrew = String(err?.message ?? err); - } - check( - '#14178 S5 the SHARED implementation refuses — so objectui-range.mjs cannot derive from it either', - absentClassifyThrew.includes('PREREQUISITE NOT MET') && - absentClassifyThrew.includes(upFrom.slice(0, 12)), - absentClassifyThrew, - ); - - // S6 — THE CARD'S REPRODUCTION, end to end through the shell driver. - const mkFwFor = (name, pinSha) => { - const dir = join(tmp, name); - mkdirSync(join(dir, 'scripts'), { recursive: true }); - mkdirSync(join(dir, '.changeset'), { recursive: true }); - writeFileSync(join(dir, '.objectui-sha'), `${pinSha}\n`); - stageBumpDriver(dir); - return dir; - }; - const runShellBump = (fwDir, uiRoot, extraEnv = {}) => - spawnSync('bash', [join(fwDir, 'scripts', 'bump-objectui.sh'), '--no-commit', upTo], { - encoding: 'utf8', - env: { ...gitFreeEnv(), OBJECTUI_ROOT: uiRoot, GIT_TERMINAL_PROMPT: '0', ...extraEnv }, // #16644: see above - }); - const csName = `console-${upTo.slice(0, 12)}.md`; - - const fwFull = mkFwFor('fw-absent-control-full', upFrom); - const fullRun = runShellBump(fwFull, uiFull); - const fullCs = existsSync(join(fwFull, '.changeset', csName)) - ? readFileSync(join(fwFull, '.changeset', csName), 'utf8') - : ''; - const fwDeepen = mkFwFor('fw-absent-deepen', upFrom); - const deepenRun = runShellBump(fwDeepen, uiShallow); - const deepenCs = existsSync(join(fwDeepen, '.changeset', csName)) - ? readFileSync(join(fwDeepen, '.changeset', csName), 'utf8') - : ''; - check( - '#14178 S6 an absent endpoint now DEEPENS and re-asks — the shallow checkout derives the same COMPLETE record as a full clone: the breaking entry, the ADR-0087 prompt, no degraded list', - // The measured before/after, in one assertion. Before this card the same - // input wrote a one-line degraded entry, exited 0, and never printed the - // ADR-0087 prompt (which keys on a declared-breaking changeset, and the - // degraded one declared none). The level is `patch` on both sides now - // (ruling A: the console never moves the version line), so the - // discriminator is the RECORD — the entries and the prompt — not the bump. - deepenRun.status === 0 && - deepenRun.stdout.includes('deepening') && - deepenRun.stdout.includes('the range walks completely now') && - deepenCs.includes('"@objectstack/console": patch') && - deepenCs.includes('**BREAKING**') && - deepenCs.includes('adr-0087: TODO') && - !deepenCs.includes('Degraded list') && - fullRun.status === 0 && - deepenCs === fullCs, - `deepenStatus=${deepenRun.status} fullStatus=${fullRun.status} identical=${deepenCs === fullCs}\nSTDOUT:${deepenRun.stdout}\nSTDERR:${deepenRun.stderr}\n${deepenCs}`, - ); - check( - '#14178 S6b the deepen really repaired the CHECKOUT — the fixture is no longer shallow and `from` is present', - // The positive guard on S6: without it S6 would also pass if the record - // had come from somewhere other than the repaired history. - git(uiShallow, ['rev-parse', '--is-shallow-repository']).trim() === 'false' && - fromPresent(uiShallow, upFrom), - ); - - // S7 — the same input with the opt-out set. Its own clone: S6 deepened the - // one above, and a fixture that quietly stopped reproducing the defect is - // how this whole group could go green over nothing. - const uiShallow2 = cloneUi('objectui-depth2-nodeepen', 2); - const fwNoDeepen = mkFwFor('fw-absent-no-deepen', upFrom); - const pinBefore = readFileSync(join(fwNoDeepen, '.objectui-sha'), 'utf8'); - const noDeepenRun = runShellBump(fwNoDeepen, uiShallow2, { OBJECTUI_NO_DEEPEN: '1' }); - check( - '#14178 S7 absent endpoint + OBJECTUI_NO_DEEPEN=1 ⇒ REFUSAL: non-zero, no changeset, and the pin file byte-identical', - noDeepenRun.status !== 0 && - !existsSync(join(fwNoDeepen, '.changeset', csName)) && - readFileSync(join(fwNoDeepen, '.objectui-sha'), 'utf8') === pinBefore && - noDeepenRun.stderr.includes('REFUSING to bump') && - noDeepenRun.stderr.includes('NOTHING WAS WRITTEN') && - noDeepenRun.stderr.includes('OBJECTUI_NO_DEEPEN=1 was set'), - `status=${noDeepenRun.status} wrote=${existsSync(join(fwNoDeepen, '.changeset', csName))}\n${noDeepenRun.stderr}`, - ); - - // S8 — a deepen that CANNOT repair the range: the old pin names a commit - // that exists nowhere, in a clone that is not shallow at all. `--unshallow` - // is not the remedy here and the refusal must not claim it is. - const bogusPin = '0'.repeat(40); - const fwBogus = mkFwFor('fw-absent-bogus-pin', bogusPin); - const bogusRun = runShellBump(fwBogus, uiFull); - check( - '#14178 S8 an absent endpoint a deepen cannot repair still refuses — and says the clone is NOT shallow rather than prescribing --unshallow as the fix', - bogusRun.status !== 0 && - !existsSync(join(fwBogus, '.changeset', csName)) && - readFileSync(join(fwBogus, '.objectui-sha'), 'utf8') === `${bogusPin}\n` && - bogusRun.stderr.includes('REFUSING to bump') && - bogusRun.stderr.includes('NOT shallow'), - `status=${bogusRun.status}\n${bogusRun.stderr}`, - ); - - // ---- #10495: is the revision being PINNED actually on objectui main? ---- - // `bump-objectui.sh` pins `git rev-parse HEAD` of the operator's checkout. - // That answers "what is checked out", never "is it on main". Measured on a - // fresh --no-tags clone of objectui, 2026-08-21: 941 remote branches, 118 - // branch tips not reachable from main, 291 commits present and not on main. - // The release cut re-asks the question and fails closed (#9450 / PR #10494), - // but only at RC time — so in between a non-main pin merges and the - // committed SDUI manifest is regenerated against it. - // - // The producer half is a WARNING, not a gate: the script is deliberately - // usable offline and `origin/main` is only as fresh as the last fetch. So - // every case below asserts what it SAYS, and that it still exits 0 and - // still writes the pin. Three answers are pinned, never two — "cannot - // answer" must not borrow the wording of either verdict. - battery('#10495: is the revision being PINNED actually on objectui main?'); - const mkFramework = (name, pinSha) => { - const dir = join(tmp, name); - mkdirSync(join(dir, 'scripts'), { recursive: true }); - mkdirSync(join(dir, '.changeset'), { recursive: true }); - writeFileSync(join(dir, '.objectui-sha'), `${pinSha}\n`); - stageBumpDriver(dir); - return dir; - }; - // Offline by construction — a self-test must never reach the network. - // `extraEnv` exists for R7 below, which re-runs one of these cases under a - // shell with the bash 4 builtins taken away. - const runBump = (fwDir, uiRoot, args, extraEnv = {}) => - spawnSync('bash', [join(fwDir, 'scripts', 'bump-objectui.sh'), ...args], { - encoding: 'utf8', - env: { - // #16644: `gitFreeEnv()` rather than `process.env` as the BASE. This child is - // `bash`, not `git`, but `bump-objectui.sh` spawns `git` one frame down against - // the fixture repositories below, and an inherited GIT_DIR outranks their `cwd` - // there exactly as it would here. The deliberate keys are re-applied ON TOP. - ...gitFreeEnv(), - OBJECTUI_ROOT: uiRoot, - OBJECTUI_NO_DEEPEN: '1', - GIT_TERMINAL_PROMPT: '0', - ...extraEnv, - }, - }); - const mkUi = (name) => { - const dir = join(tmp, name); - mkdirSync(join(dir, '.changeset'), { recursive: true }); - const gg = (...args) => git(dir, args); - gg('init', '-q', '-b', 'main'); - gg('config', 'user.email', 'selftest@objectstack.ai'); - gg('config', 'user.name', 'self test'); - gg('config', 'commit.gpgsign', 'false'); - let n = 0; - const c = (subject) => { - n += 1; - writeFileSync(join(dir, `src${n}.ts`), `${n}\n`); - writeFileSync( - join(dir, '.changeset', `entry-${n}.md`), - `---\n"@object-ui/core": patch\n---\n\n${subject}\n`, - ); - gg('add', '-A'); - gg('commit', '-q', '-m', subject); - return gg('rev-parse', 'HEAD').trim(); - }; - return { dir, gg, c }; - }; - - const uiR = mkUi('objectui-reach'); - const rBase = uiR.c('chore: base'); - const rMain = uiR.c('feat(core): the commit main really has'); - uiR.gg('update-ref', 'refs/remotes/origin/main', rMain); - uiR.gg('checkout', '-q', '-b', 'feature/pushed-never-merged'); - const rPushed = uiR.c('feat(core): on a pushed branch that never merged'); - uiR.gg('update-ref', 'refs/remotes/origin/feature/pushed-never-merged', rPushed); - uiR.gg('checkout', '-q', 'main'); - uiR.gg('checkout', '-q', '-b', 'wip/local-only'); - const rLocal = uiR.c('feat(core): never pushed anywhere'); - uiR.gg('checkout', '-q', 'main'); - - // R1 — ON main. The control: a check that warns about everything proves - // nothing, and a bump on the healthy path must stay quiet. - const r1 = runBump(mkFramework('fw-reach-main', rBase), uiR.dir, ['--no-commit', rMain]); - check( - '#10495 R1 a pin that IS on main raises no warning of any kind', - r1.status === 0 && - !r1.stderr.includes('COULD NOT DETERMINE') && - !r1.stderr.includes('is NOT reachable from origin/main'), - `status=${r1.status}\nSTDERR:${r1.stderr}`, - ); - check( - '#10495 R1b … and the line that prints anyway STATES the answer, so "checked and fine" is not inferred from silence', - r1.stdout.includes('(on origin/main)'), - r1.stdout, - ); - - // R2 — pushed, but on a branch that never merged. The 118-branch-tip case. - const fwPushed = mkFramework('fw-reach-pushed', rBase); - const r2 = runBump(fwPushed, uiR.dir, ['--no-commit', rPushed]); - check( - '#10495 R2 a pin that is NOT on main warns, and NAMES the branch it is on', - r2.stderr.includes('is NOT reachable from origin/main') && - r2.stderr.includes('origin/feature/pushed-never-merged'), - r2.stderr, - ); - check( - '#10495 R2b the warning separates "wrong branch" from "not pushed yet" — the operator does not have to go and look', - r2.stderr.includes('It IS pushed, but only onto branch(es) that have not merged') && - !r2.stderr.includes('has not been pushed to objectui'), - r2.stderr, - ); - check( - '#10495 R2c a warning is not a refusal: exit 0, the pin is written, and the summary line carries the verdict', - r2.status === 0 && - readFileSync(join(fwPushed, '.objectui-sha'), 'utf8').trim() === rPushed && - r2.stdout.includes('(NOT on origin/main)'), - `status=${r2.status}\nSTDOUT:${r2.stdout}`, - ); - check( - '#10495 R2d the warning is recalled on the LAST line of the run, so it cannot scroll away', - r2.stdout.includes('⚠️ pin is NOT on objectui origin/main, see above'), - r2.stdout, - ); - - // R3 — the card's own shape, driven through the FRONT DOOR: no argument at - // all, `git rev-parse HEAD`, with a feature branch checked out. - uiR.gg('checkout', '-q', 'wip/local-only'); - const r3 = runBump(mkFramework('fw-reach-local', rBase), uiR.dir, ['--no-commit']); - uiR.gg('checkout', '-q', 'main'); - check( - '#10495 R3 a plain `bump-objectui.sh` with a feature branch checked out warns — the defect, through the default path', - r3.status === 0 && - r3.stderr.includes('is NOT reachable from origin/main') && - r3.stderr.includes(rLocal.slice(0, 12)), - `status=${r3.status}\nSTDERR:${r3.stderr}`, - ); - check( - '#10495 R3b a LOCAL-only commit is reported as never pushed, not as an unmerged branch', - r3.stderr.includes('wip/local-only') && - r3.stderr.includes('on remote branches: none') && - r3.stderr.includes('has not been pushed to objectui') && - !r3.stderr.includes('It IS pushed'), - r3.stderr, - ); - - // R4 — STATE 3, cause 1: the checkout cannot answer the question at all. - // Must not be dressed as either verdict. - const uiNo = mkUi('objectui-no-origin-main'); - const noBase = uiNo.c('chore: base'); - const noHead = uiNo.c('feat(core): tip'); - const r4 = runBump(mkFramework('fw-reach-no-origin', noBase), uiNo.dir, ['--no-commit', noHead]); - check( - '#10495 R4 no origin/main → says it COULD NOT DETERMINE, and names that as the reason', - r4.status === 0 && - r4.stderr.includes('COULD NOT DETERMINE') && - r4.stderr.includes("has no 'origin/main' ref"), - `status=${r4.status}\nSTDERR:${r4.stderr}`, - ); - check( - '#10495 R4b unanswered is NOT a false alarm and NOT a pass — it borrows neither verdict', - !r4.stderr.includes('is NOT reachable from origin/main') && - !r4.stdout.includes('(on origin/main)') && - r4.stdout.includes('(reachability UNKNOWN)'), - `STDOUT:${r4.stdout}\nSTDERR:${r4.stderr}`, - ); - - // R5 — the object is absent. Two git behaviours make this reachable and make - // 128 the thing that must not be read as "no"; both are MEASURED here rather - // than asserted in prose, so a future git that changes either one fails this - // test instead of silently flipping the branch taken. - // - // R5c CHANGED VERDICT under #10797 (triage ruling, 2026-08-21). It used to - // pin "reported as UNDETERMINED" — the same three-answer treatment R4 gets. - // That was #10495's reading, and #10495's warning-not-gate ruling is about a - // pin that is NOT ON MAIN: a real commit you can still meaningfully pin, - // where origin/main may just be stale and the judgement is the operator's. - // A pin whose object cannot be READ is a different question with no - // judgement in it — there is nothing to pin, and neither the changeset nor - // the commit message can be derived from it — so it REFUSES, before the - // first mutation of `.objectui-sha`. Reporting it was what let the pin file - // be rewritten and the run then die on `git log` (#10797). The case is - // rewritten rather than deleted: the fixture still pins the exact shape, - // only the verdict it demands moved. - const uiMiss = mkUi('objectui-missing-object'); - const missBase = uiMiss.c('chore: base'); - uiMiss.gg('update-ref', 'refs/remotes/origin/main', missBase); - const missHead = uiMiss.c('feat(core): tip whose object we are about to delete'); - rmSync(join(uiMiss.dir, '.git', 'objects', missHead.slice(0, 2), missHead.slice(2)), { - force: true, - }); - const revParseMissing = spawnSync('git', ['-C', uiMiss.dir, 'rev-parse', 'HEAD'], { - encoding: 'utf8', - env: gitFreeEnv(), // LOCAL-ONLY (#16644): the fixture `-C` names, never a hook's repo - }); - check( - '#10495 R5a `git rev-parse HEAD` exits 0 for a commit whose OBJECT is gone — so NEW_SHA arriving is no proof of presence', - revParseMissing.status === 0 && revParseMissing.stdout.trim() === missHead, - `status=${revParseMissing.status} stdout=${JSON.stringify(revParseMissing.stdout)}`, - ); - const isAncestorMissing = spawnSync( - 'git', - ['-C', uiMiss.dir, 'merge-base', '--is-ancestor', missHead, 'origin/main'], - // LOCAL-ONLY (#16644): `origin/main` here is a ref this fixture wrote with - // `update-ref`, not a remote to reach. - { encoding: 'utf8', env: gitFreeEnv() }, - ); - check( - '#10495 R5b `merge-base --is-ancestor` exits 128 on an absent object — an ERROR, not the "no" that 1 means', - isAncestorMissing.status === 128, - `status=${isAncestorMissing.status} stderr=${isAncestorMissing.stderr}`, - ); - const missFw = mkFramework('fw-reach-missing-object', missBase); - const missPinBefore = readFileSync(join(missFw, '.objectui-sha'), 'utf8'); - const r5 = runBump(missFw, uiMiss.dir, ['--no-commit']); - check( - '#10495/#10797 R5c an unreadable object REFUSES — it is not a verdict to report, there is nothing to pin', - r5.status !== 0 && - r5.stderr.includes('REFUSING to bump') && - !r5.stderr.includes('is NOT reachable from origin/main'), - `status=${r5.status}\nSTDERR:${r5.stderr}`, - ); - check( - '#10797 R5d the refused run left NO half-applied state — .objectui-sha is byte-identical', - readFileSync(join(missFw, '.objectui-sha'), 'utf8') === missPinBefore && - r5.stderr.includes('NOTHING WAS WRITTEN'), - `before=${JSON.stringify(missPinBefore)} after=${JSON.stringify( - readFileSync(join(missFw, '.objectui-sha'), 'utf8'), - )}`, - ); - check( - '#10797 R5e the refusal names the remedy, so git\'s bare `fatal:` is not the whole explanation', - r5.stderr.includes('fetch origin') && r5.stderr.includes('cat-file -e'), - r5.stderr, - ); - - // R6 — the branch list is CAPPED, and a cap that fires says so with the real - // count. A capped list that reads as complete is the same defect class as - // the `head -40` this file's changeset list used to truncate with (#4731). - const uiMany = mkUi('objectui-many-branches'); - const manyBase = uiMany.c('chore: base'); - const manyMain = uiMany.c('feat(core): main tip'); - uiMany.gg('update-ref', 'refs/remotes/origin/main', manyMain); - uiMany.gg('checkout', '-q', '-b', 'feature/many-0'); - const manySha = uiMany.c('feat(core): a commit 13 local branches contain'); - for (let i = 1; i <= 12; i += 1) uiMany.gg('branch', `dup/branch-${i}`, manySha); - uiMany.gg('checkout', '-q', 'main'); - const r6 = runBump(mkFramework('fw-reach-many', manyBase), uiMany.dir, ['--no-commit', manySha]); - check( - '#10495 R6 a capped branch list SAYS it is capped, with the real count', - r6.stderr.includes('(+3 more; 13 total)'), - r6.stderr, - ); - - // --- R7: the bash 3.2 floor -------------------------------------------- - // - // `bump-objectui.sh` is run BY HAND by an operator (docs/releases- - // maintenance.md), and `/usr/bin/env bash` is bash 3.2.57 on macOS. Every - // case above ran on whatever bash the host has — bash 5 in CI — so the - // whole R1..R6 block is blind to a bash-4-only construct: it went green on - // a script that could not complete a single one of these runs on a Mac. - // Measured: with `mapfile` unavailable, R2/R2b/R2c/R2d/R3/R3b/R6 all fail, - // two of them with `status=127`, and the operator run dies at 127 with - // `mapfile: command not found` as its ONLY output — the #10495 warning, - // which is reached on no other branch, cannot print at all. - // - // Both halves are needed and they catch different things: the static scan - // sees parse-level constructs that `enable -n` cannot simulate, and the - // simulated run proves the real code path completes without the builtins. - battery('R7: the bash 3.2 floor'); - const bash4Constructs = new RegExp( - [ - 'exec\\s+\\{[A-Za-z_]', // bash 4.1 fd auto-allocation - '\\$\\{[A-Za-z_][A-Za-z0-9_]*(\\[[^\\]]*\\])?(\\^|,)', // bash 4.0 ${x^^} / ${x,,} - '(declare|local|typeset)\\s+-[A-Za-z]*A\\s', // bash 4.0 associative arrays - '(mapfile|readarray)\\s', // bash 4.0 builtins — the one this card is about - '&>>', // bash 4.0 append-both redirection - 'EPOCH(SECONDS|REALTIME)', // bash 5.0 variables - ].join('|'), - ); - const bumpSrc = readFileSync(join(__dirname, 'bump-objectui.sh'), 'utf8'); - // Comments are exempt: this file has to NAME the constructs it refuses in - // order to explain why, and the simulated run below covers what actually - // executes. - const bash4Hits = bumpSrc - .split('\n') - .map((line, i) => [i + 1, line]) - .filter(([, line]) => !/^\s*#/.test(line)) - .filter(([, line]) => bash4Constructs.test(line)) - .map(([n, line]) => `${n}: ${line.trim()}`); - check( - '#12071 R7 bump-objectui.sh names no bash 4+/5 construct (mapfile, ${x^^}, declare -A, &>>, EPOCH*)', - bash4Hits.length === 0, - bash4Hits.join('\n'), - ); - - // Bash 3.2's world imposed on this host: `enable -n` really does make - // `mapfile`/`readarray` "command not found" — the same status 127 macOS - // produces — and BASH_ENV is sourced by every non-interactive bash, so the - // script under test inherits it through `spawnSync`. - const noBash4 = join(tmp, 'no-bash4-builtins.sh'); - writeFileSync(noBash4, 'enable -n mapfile readarray 2>/dev/null\n'); - - // The instrument must not be vacuous. If BASH_ENV were ever ignored (a - // posix-mode bash, a future harness change), R7b would pass by proving - // nothing, so the disabling is measured on a probe FIRST, both ways. - // - // ⭐ #12254 — that probe used to be `mapfile` ITSELF, which made this leg a - // GUARANTEED FAIL on the one platform it exists to model. On stock macOS - // `/bin/bash` IS 3.2, so `mapfile` is missing from the plain run too: the - // leg reported `plain=` empty exactly like `sim.out=`, "the simulation - // removed something" could not be satisfied by any correct harness, and - // `check:objectui-changeset` sat red on every macOS seat while CI - // (ubuntu-latest, bash 5) stayed green — so nothing would ever catch it. - // A control written to stop a vacuous pass had become a platform- - // conditional failure, which is the SAME error family one level up: a - // check whose own precondition was never measured across the population of - // hosts it runs on. - // - // The fix is to stop hardcoding a belief about another platform's builtin - // table and ASK THE HOST. `probeBuiltin` is chosen at runtime from bash - // 2.x-era builtins that (a) this shell reports as a builtin and (b) have no - // external `/usr/bin` twin to fall through to — so `enable -n` on one - // produces the very same 127 "command not found" that a missing bash-4 - // builtin produces on 3.2, and the leg measures the HARNESS instead of the - // platform. Deliberately not a skip: a host that offers no qualifying - // builtin fails this leg loudly, because a control that quietly does - // nothing is the exact shape R7a exists to prevent. - const probeCandidates = ['shopt', 'caller', 'compgen', 'hash', 'umask']; - const pickProbe = spawnSync( - 'bash', - [ - '-c', - 'for b in "$@"; do ' + - '[ "$(type -t "$b" 2>/dev/null)" = builtin ] || continue; ' + - '[ -n "$(type -P "$b" 2>/dev/null)" ] && continue; ' + - 'printf %s "$b"; exit 0; done; exit 1', - 'pick-probe-builtin', - ...probeCandidates, - ], - { encoding: 'utf8' }, - ); - const probeBuiltin = pickProbe.stdout.trim(); - const builtinProbe = join(tmp, 'builtin-probe.sh'); - writeFileSync(builtinProbe, `${probeBuiltin} >/dev/null\nprintf 'PROBE-STATUS=%s\\n' "$?"\n`); - const noProbeBuiltin = join(tmp, 'no-probe-builtin.sh'); - writeFileSync(noProbeBuiltin, `enable -n ${probeBuiltin} 2>/dev/null\n`); - const probePlain = spawnSync('bash', [builtinProbe], { encoding: 'utf8' }); - const probeSim = spawnSync('bash', [builtinProbe], { - encoding: 'utf8', - env: { ...process.env, BASH_ENV: noProbeBuiltin }, - }); - const probeStatus = (r) => (r.stdout.match(/PROBE-STATUS=(\d+)/) ?? [])[1]; - check( - '#12071/#12254 R7a the BASH_ENV harness really removes a builtin THIS host has (else R7b proves nothing)', - probeBuiltin !== '' && - probeStatus(probePlain) !== undefined && - probeStatus(probePlain) !== '127' && - probeStatus(probeSim) === '127' && - probeSim.stderr.includes(probeBuiltin), - `builtin=${probeBuiltin || ``} ` + - `plain=${probePlain.stdout.trim()} sim=${probeSim.stdout.trim()} sim.err=${probeSim.stderr.trim()}`, - ); - - // …and separately, the state R7b actually depends on: in the shell R7b - // runs, `mapfile` is unavailable. This holds on BOTH hosts and for - // DIFFERENT reasons — `enable -n` removed it on bash 5, it was never there - // on bash 3.2 — which is why it is its own leg rather than folded into the - // harness leg above: neither leg alone is unconditional, and together they - // leave no host on which R7b can pass vacuously. The detail line names - // which way this host got there, so a macOS operator reading a GREEN run - // still learns that their `/bin/bash` is the genuine 3.2 article. - const mapfileProbe = join(tmp, 'mapfile-probe.sh'); - writeFileSync(mapfileProbe, 'mapfile -t x < <(printf "a\\n") && echo MAPFILE-WORKS\n'); - const mapfilePlain = spawnSync('bash', [mapfileProbe], { encoding: 'utf8' }); - const mapfileSim = spawnSync('bash', [mapfileProbe], { - encoding: 'utf8', - env: { ...process.env, BASH_ENV: noBash4 }, - }); - const hostHasMapfile = mapfilePlain.stdout.includes('MAPFILE-WORKS'); - check( - '#12071/#12254 R7a2 … and in the R7b shell mapfile is gone, however this host got there', - !mapfileSim.stdout.includes('MAPFILE-WORKS') && mapfileSim.stderr.includes('mapfile'), - `host-has-mapfile=${hostHasMapfile} (${ - hostHasMapfile ? 'bash 4+, so enable -n has to remove it' : 'host bash is already 3.2-era, nothing to remove' - }) sim.out=${mapfileSim.stdout.trim()} sim.err=${mapfileSim.stderr.trim()}`, - ); - - // The R2 shape again — pushed, never merged — through a shell that has no - // bash 4 builtins. This is the operator's macOS run, and the assertion is - // the WARNING, not merely a zero exit: the defect's signature was a run - // that produced the builtin error INSTEAD of the warning. - const fwNoB4 = mkFramework('fw-reach-nobash4', rBase); - const r7 = runBump(fwNoB4, uiR.dir, ['--no-commit', rPushed], { BASH_ENV: noBash4 }); - check( - '#12071 R7b on a shell without bash 4 builtins the #10495 warning still fires, in full', - r7.stderr.includes('is NOT reachable from origin/main') && - r7.stderr.includes('origin/feature/pushed-never-merged') && - r7.stderr.includes('It IS pushed, but only onto branch(es) that have not merged'), - `status=${r7.status}\nSTDERR:${r7.stderr}`, - ); - check( - '#12071 R7c … and the run completes as a warning, not a 127: exit 0, pin written, verdict on stdout', - r7.status === 0 && - !r7.stderr.includes('command not found') && - readFileSync(join(fwNoB4, '.objectui-sha'), 'utf8').trim() === rPushed && - r7.stdout.includes('(NOT on origin/main)'), - `status=${r7.status}\nSTDOUT:${r7.stdout}\nSTDERR:${r7.stderr}`, - ); - - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - - // ── The floor: every declared battery RAN, and ran its cases (#13489) ─── - // - // Evaluated after every battery has had its chance and BEFORE the verdict, so - // the success line below can only be printed by a run in which the set of - // batteries that registered assertions EQUALS the set declared. A set - // difference names WHICH battery stopped; a count says only that something did. - const floorFailure = (message) => { - failures.push(message); - }; - const declaredBatteries = Object.keys(SELF_TEST_BATTERIES); - let floorBreached = false; - if (declaredBatteries.length < SELF_TEST_BATTERY_FLOOR) { - floorBreached = true; - floorFailure( - `SELF_TEST_BATTERIES declares ${declaredBatteries.length} batteries, below the pinned ` + - `${SELF_TEST_BATTERY_FLOOR} — a battery deleted from the roster takes its own floor with it.`, - ); - } - for (const [name, count] of seen) { - if (declaredBatteries.includes(name)) continue; - floorBreached = true; - floorFailure( - `self-test battery "${name}" registered ${count} case(s) but is not declared in ` + - 'SELF_TEST_BATTERIES — an assertion attributed to no declared battery is one nothing floors.', - ); - } - for (const name of declaredBatteries) { - const count = seen.get(name) ?? 0; - if (count >= SELF_TEST_BATTERIES[name]) continue; - floorBreached = true; - floorFailure( - count === 0 - ? `self-test battery "${name}" DID NOT RUN — 0 cases registered, ${SELF_TEST_BATTERIES[name]} pinned. ` + - 'The verdict below would have claimed those cases hold.' - : `self-test battery "${name}" registered ${count} case(s), below its pinned floor of ` + - `${SELF_TEST_BATTERIES[name]} — cases that used to run no longer do.`, - ); - } - if (floorBreached) { - floorFailure( - 'A battery at or below its floor means cases STOPPED RUNNING — the battery is the bug, not the ' + - 'number. Find what stopped registering (an early return, a deleted block, a guard that now ' + - 'skips) and restore it.', - ); - } - if (failures.length) { - console.error(`\n⛔ objectui-changeset-digest --self-test: ${failures.length} failure(s)`); - for (const f of failures) console.error(` - ${f}`); - return 1; - } - console.log('✓ objectui-changeset-digest --self-test: all checks passed'); - selfTestReachedVerdict = true; - return 0; -} - -if (isEntrypoint(import.meta.url)) { - process.exit(main(process.argv.slice(2))); -} diff --git a/scripts/objectui-range.mjs b/scripts/objectui-range.mjs deleted file mode 100644 index 3b1dede8fd5..00000000000 --- a/scripts/objectui-range.mjs +++ /dev/null @@ -1,798 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -// -// objectui-range — summarize the frontend (objectui) delta bundled between two -// framework revisions, ready to paste into a release page's Console section. -// -// The platform is one version-locked train and the Console UI is frozen into -// @objectstack/console at the objectui commit pinned in .objectui-sha. To learn -// "what did the frontend change between platform version A and B", you diff the -// pinned SHA at two framework revisions and read what objectui DECLARED over -// that range. This script does exactly that — the aggregation layer described in -// docs/releases-maintenance.md. -// -// Usage: -// node scripts/objectui-range.mjs [new-rev] -// old-rev / new-rev are FRAMEWORK git refs (tags, branches, SHAs). -// new-rev defaults to the working-tree .objectui-sha (the current pin). -// node scripts/objectui-range.mjs --from --to -// skip the framework lookup; use explicit objectui SHAs. -// … --json emit structured JSON instead of markdown -// … --all also itemize what ships nothing (release-nothing changesets and -// commits carrying no changeset), not just count them -// … --self-test run the built-in assertions over throwaway git repos -// -// Env: -// OBJECTUI_ROOT=/path/to/objectui (default: ../objectui, like bump-objectui.sh) -// -// WHAT COUNTS AS A FRONTEND CHANGE HERE (#4843) -// --------------------------------------------- -// The releasing entries come from `classifyRange()` in -// scripts/objectui-changeset-digest.mjs — the SAME function `bump-objectui.sh` -// uses to write the pin changeset (#4731). One criterion, two consumers: the -// platform release record and this release-page section can no longer disagree -// about which frontend changes shipped. -// -// This script used to guess instead, keeping only `feat` + `fix` subject lines -// unless you passed `--all`. Measured on the real range -// `7d9734d5e321..785b8a5d432c` (53 non-merge commits, objectui), that guess: -// -// * DROPPED 13 commits that actually released, 6 of them BREAKING (`!`): -// `refactor(layout)!: delete PageNodeRenderer` and burn-ledger batches -// 2/4/5/6/7 — plus `chore(deps): lockstep the @objectstack family onto -// 17.0.0-rc.1`. Breaking changes were the single class structurally unable -// to appear, in the artifact that leads with breaking changes. -// * PULLED IN 5 commits that release nothing in objectui, two of them -// `fix(ci)` (one with no changeset at all, one whose changeset has an empty -// frontmatter — changesets' own spelling of "release-nothing"). -// -// The output is a release page's body, so a reader cannot tell a filtered list -// from a complete one. Hence: the accounting footer is UNCONDITIONAL — every -// excluded item is counted out loud in the artifact itself, and `--all` names -// them. Grouping into headings is presentation and must never become a filter -// again. Declaration over inference, per AGENTS.md Prime Directive #12. -// -// WHICH HEADING AN ENTRY LANDS UNDER (#6294) -// ------------------------------------------ -// The section table used to key entirely on the DECLARED LEVEL, `major` → -// "Breaking changes". objectui does not declare `major` in a release window — it -// ships a breaking change as `minor` plus a prose annotation the author writes, -// and `scripts/check-changeset-no-major.mjs` holds that line deliberately. So -// that heading was structurally unable to render, and the breaking entries were -// presented to upgraders under "Features". Measured on the same real range -// #6099 was measured on, `f5bc4c78be76..f995a452d2ca`: 64 releasing changesets, -// 0 declared `major`, `### Breaking changes` absent, and all four -// author-annotated breaking entries (`042e09d77` / `6e794a19e` / `8ec406728` / -// `d22ae31ce`) under `### Features`. -// -// The leading section therefore keys on `r.breaking` — the verdict -// `classifyRange` already computes for every releasing entry ("declared `major` -// OR the author's own breaking annotation", #6099 / PR #6289). This file -// CONSUMES that verdict and does not restate it: a second copy would drift, and -// the first thing it would drift on is this exact class. That is why #6289 put -// the criterion in the shared implementation rather than in `buildDigest`. The -// remaining sections still key on the declared level. -import { execFileSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; -import { dirname, join } from 'node:path'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { classifyRange, clampSummary } from './objectui-changeset-digest.mjs'; -import { gitFreeEnv } from './git-env.mjs'; -import { isEntrypoint } from './invoked-as.mjs'; - -const FRAMEWORK_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); - -const argv = process.argv.slice(2); -const has = (f) => argv.includes(f); -const val = (f) => { - const i = argv.indexOf(f); - return i >= 0 ? argv[i + 1] : undefined; -}; -const positional = argv.filter( - (a, i) => !a.startsWith('--') && argv[i - 1] !== '--from' && argv[i - 1] !== '--to', -); - -const JSON_OUT = has('--json'); -const SHOW_EXCLUDED = has('--all'); - -/** - * Usage text, read back out of this file's own `//` lines so the two cannot - * drift. - * - * It is a FUNCTION rather than a top-level `if` because this module exports - * bindings. As a bare top-level statement the test read the IMPORTER's argv: - * an importer that happened to carry `-h` or `--help` got this header written - * to its stdout and then `process.exit(0)` — its process ended mid-import - * carrying a SUCCESS status, which no caller reading the status alone can - * tell apart from a clean import. - * - * BOUNDED to the header, not every column-0 `//` line in the file (#11952). - * A bare `.filter(startsWith('//'))` has no notion of "the header": it took - * EVERY column-0 `//` line anywhere in the file, so an ordinary implementation - * comment written at column 0 later in the file — a helper's one-line - * rationale, a self-test section banner — silently became part of the usage - * text a reader pastes. The header IS a contiguous prefix of this file (right - * after the shebang), so this walks lines in order, skips the shebang before - * the block starts, and STOPS at the first line that is not a `//` line once - * the block has started — a `takeWhile`, not a `filter`. That makes "add a - * column-0 comment anywhere later" structurally unable to reach `--help` - * again, rather than merely detectable after the fact. - */ -function printHelp() { - const lines = readFileSync(fileURLToPath(import.meta.url), 'utf8').split('\n'); - const start = lines.findIndex((l) => l.startsWith('//')); - const header = []; - for (let i = start; i >= 0 && i < lines.length && lines[i].startsWith('//'); i++) { - header.push(lines[i].slice(3)); - } - console.log(header.join('\n')); - return 0; -} - -function die(msg) { - console.error(`✗ objectui-range: ${msg}`); - process.exit(1); -} - -// LOCAL-ONLY, every caller (#16644): `show`, `log` and `rev-parse` against the -// repository `-C cwd` names -- the objectui checkout on a real run, a mkdtemp fixture in -// the self-test. ⛔ Nothing here fetches, clones or pushes, so the blanket strip takes no -// transport configuration away; an inherited GIT_DIR would otherwise outrank `-C`. -function git(cwd, args) { - return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8', env: gitFreeEnv() }).trim(); -} - -// Resolve the objectui SHA pinned at a given framework rev (or the working tree). -function pinAt(root, rev) { - if (rev === undefined) { - const p = join(root, '.objectui-sha'); - if (!existsSync(p)) die('.objectui-sha not found in the working tree'); - return readFileSync(p, 'utf8').trim(); - } - try { - return git(root, ['show', `${rev}:.objectui-sha`]).trim(); - } catch { - return die(`cannot read .objectui-sha at framework rev '${rev}'`); - } -} - -const CC = /^(feat|fix|refactor|perf|docs|test|chore|build|ci|style|revert)(?:\(([^)]+)\))?(!)?:\s*(.+)$/; - -/** Conventional-commit scope, when the subject has one. PRESENTATION ONLY. */ -function scopeOf(subject) { - const m = subject.match(CC); - return m ? m[2] || '' : ''; -} - -/** - * The release-page headings, in page order. - * - * The LEADING group is keyed on the shared breaking verdict `r.breaking`, not on - * a declared level (#6294 — see the header block). The rest key on the level - * objectui declared. - * - * TOTALITY: a `major` entry always satisfies `r.breaking` — `classifyRange` - * computes `breaking = level === 'major' || annotated` — so the level table only - * has to cover what the breaking group leaves behind, `minor` and `patch`. - * `renderRange` still prints anything this table cannot place, under a heading - * that says so: grouping is presentation and must never become a filter (#4843). - */ -const BREAKING_HEADING = 'Breaking changes'; -const LEVEL_SECTIONS = [ - ['minor', 'Features'], - ['patch', 'Fixes'], -]; -const UNPLACED_HEADING = 'Other changes'; - -/** - * Render the Console section for a classified range. - * - * Every count that could hide something is in the returned markdown, always — - * including the zeros. A footer that only appears when something was dropped - * still leaves the reader unable to tell "nothing dropped" from "footer not - * implemented". - */ -export function renderRange({ fromSha, toSha, classified, showExcluded = false }) { - const { releasing, releaseNothingEntries, noChangesetCommits, changesetsAdded, totalCommits } = - classified; - - const areaCounts = {}; - for (const r of releasing) { - const scope = scopeOf(r.subject); - if (scope) areaCounts[scope] = (areaCounts[scope] || 0) + 1; - } - const topAreas = Object.entries(areaCounts) - .sort((a, b) => b[1] - a[1]) - .slice(0, 10); - - const line = (r) => { - const scope = scopeOf(r.subject) || r.packages[0] || ''; - return `- ${scope ? `**${scope}** — ` : ''}${clampSummary(r.summary)} (objectui \`${r.sha.slice(0, 9)}\`)`; - }; - - const out = []; - out.push( - ``, - ); - if (topAreas.length) { - out.push('', `_Largest areas: ${topAreas.map(([a, n]) => `${a} (${n})`).join(', ')}_`); - } - - // Breaking FIRST, keyed on the shared verdict — declared `major` OR the - // author's own annotation — then the remainder by declared level (#6294). - const rest = releasing.filter((r) => !r.breaking); - const groups = [ - [BREAKING_HEADING, releasing.filter((r) => r.breaking)], - ...LEVEL_SECTIONS.map(([level, heading]) => [heading, rest.filter((r) => r.level === level)]), - ]; - // An entry no heading above claims is still printed, never dropped (#4843). - const keyed = new Set(LEVEL_SECTIONS.map(([level]) => level)); - const unplaced = rest.filter((r) => !keyed.has(r.level)); - if (unplaced.length) groups.push([UNPLACED_HEADING, unplaced]); - - for (const [heading, group] of groups) { - if (group.length) out.push('', `### ${heading}`, ...group.map(line)); - } - - if (!releasing.length) { - out.push( - '', - '_No releasing changeset in this range — every objectui commit here ships nothing._', - ); - } - - // --- the accounting. UNCONDITIONAL: a release page reader cannot otherwise - // tell a filtered list from a complete one (#4843 / #3340). --- - const excluded = [ - `${releaseNothingEntries.length} release-nothing changeset${releaseNothingEntries.length === 1 ? '' : 's'}`, - `${noChangesetCommits.length} commit${noChangesetCommits.length === 1 ? '' : 's'} carrying no changeset`, - ]; - out.push( - '', - `_Derived from the changesets objectui declared over this range — ` + - `${releasing.length} releasing of ${changesetsAdded} added across ${totalCommits} non-merge commit(s). ` + - `Excluded as shipping no package code: ${excluded.join(', ')}` + - `${showExcluded ? '' : ' (run with `--all` to list them)'}._`, - ); - - if (showExcluded) { - const rows = [ - ...releaseNothingEntries.map( - (e) => `- _(release-nothing)_ ${e.subject} (objectui \`${e.sha.slice(0, 9)}\`)`, - ), - ...noChangesetCommits.map( - (c) => `- _(no changeset)_ ${c.subject} (objectui \`${c.sha.slice(0, 9)}\`)`, - ), - ]; - out.push('', '### Excluded — ships no package code'); - out.push(...(rows.length ? rows : ['- _(nothing excluded in this range)_'])); - } - - return { markdown: out.join('\n'), topAreas }; -} - -function main() { - let fromSha = val('--from'); - let toSha = val('--to'); - if (!fromSha || !toSha) { - if (positional.length < 1) { - die('need [new-rev], or --from --to . See --help/header.'); - } - fromSha = pinAt(FRAMEWORK_ROOT, positional[0]); - toSha = pinAt(FRAMEWORK_ROOT, positional[1]); // undefined → working-tree pin - } - - const objectuiRoot = process.env.OBJECTUI_ROOT || join(FRAMEWORK_ROOT, '..', 'objectui'); - - if (!existsSync(join(objectuiRoot, '.git'))) { - die( - `no objectui checkout at ${objectuiRoot}.\n` + - ` Clone it as a sibling, or set OBJECTUI_ROOT=/path/to/objectui.\n` + - ` Range to inspect once available: ${fromSha.slice(0, 12)}..${toSha.slice(0, 12)}`, - ); - } - - if (fromSha === toSha) { - console.log( - `objectui unchanged (${fromSha.slice(0, 12)}) — no frontend delta in this range.`, - ); - return 0; - } - - let classified; - try { - classified = classifyRange({ objectuiRoot, from: fromSha, to: toSha }); - } catch { - die( - `cannot walk ${fromSha.slice(0, 12)}..${toSha.slice(0, 12)} in ${objectuiRoot}.\n` + - ` Fetch it: git -C ${objectuiRoot} fetch --all`, - ); - } - - const { markdown, topAreas } = renderRange({ - fromSha, - toSha, - classified, - showExcluded: SHOW_EXCLUDED, - }); - - if (JSON_OUT) { - console.log( - JSON.stringify( - { - from: fromSha, - to: toSha, - criterion: 'declared-changeset', - count: classified.releasing.length, - totalCommits: classified.totalCommits, - changesetsAdded: classified.changesetsAdded, - releaseNothing: classified.releaseNothing, - noChangeset: classified.noChangeset, - topAreas, - releasing: classified.releasing, - releaseNothingEntries: classified.releaseNothingEntries, - noChangesetCommits: classified.noChangesetCommits, - }, - null, - 2, - ), - ); - return 0; - } - - console.log(markdown); - return 0; -} - -// --------------------------------------------------------------------------- -// Self-test (#4843) — the repo idiom for a `scripts/` gate: build a throwaway -// git repo carrying the exact shapes measured on the real range, run the real -// code over it, assert the ARTIFACT (the markdown a maintainer pastes). -// --------------------------------------------------------------------------- - -// Set by `selfTest()` only after its verdict is printed, and read at the -// dispatch: a `return` that leaves the function above that line prints nothing -// and still exits 0 — a self-test that never finished, reported as one that -// passed (#13798). The self-test's own exit code stays load-bearing, so the -// handshake is a flag rather than a returned sentinel. -let selfTestReachedVerdict = false; - -// ── The self-test's own battery roster and floor (#13489) ────────────────── -// -// `failures.length === 0` used to be this self-test's ONLY success condition, so -// "every case held" and "the cases never ran" printed the same line. Closed the -// way PR #13487 validated on check-doc-authoring: what is pinned is the -// registered NAMES, not a number. Every section opens with `battery('')`, -// every assertion is attributed to the battery most recently opened, and the -// floor requires the OPENED set to equal the DECLARED set with each battery at -// or above its own count. -// -// ⛔ A pinned TOTAL is not the repair: a battery dropping from 9 cases to 3 -// keeps a total "right" the moment a sibling grows. -// -// The counts are a FLOOR, not an equality — adding cases is ordinary work and -// must not red. A battery BELOW its floor means cases stopped running; the -// remedy is to find what stopped registering. -const SELF_TEST_BATTERIES = Object.freeze({ - 'the class that went missing: it must be IN the artifact, and lead': 4, - '#6294: the leading section is keyed on the SHARED breaking verdict': 6, - 'what ships nothing is excluded, and SAYS SO in the artifact': 5, - 'a range that excludes NOTHING still says so (zeros are load-bearing)': 1, - '`--all` names the excluded entries instead of only counting them': 1, - 'end-to-end through the real CLI': 4, - '#11952: `--help`\'s self-read is bound to the header, not every': 2, -}); - -// DELETING an entry silences that battery's floor exactly as effectively as -// zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 7; - -// The key an assertion is filed under when no battery is open. It is not a -// declared battery, so it reds by the same set difference rather than silently -// inflating whichever battery happened to run last. -const UNATTRIBUTED_BATTERY = '(no battery open)'; - -function selfTest() { - // The battery ledger this self-test's floor is evaluated against (#13489). - // `battery()` opens a battery; every assertion below is attributed to the one - // most recently opened, so a section that stops running stops registering and - // names ITSELF at the floor rather than going quiet. - const seen = new Map(); - let openBattery = null; - const battery = (name) => { - openBattery = name; - }; - const registerCase = () => { - const b = openBattery ?? UNATTRIBUTED_BATTERY; - seen.set(b, (seen.get(b) ?? 0) + 1); - }; - const failures = []; - const check = (name, cond, detail = '') => { - registerCase(); - if (cond) { - console.log(` ✓ ${name}`); - } else { - failures.push(`${name}${detail ? ` — ${detail}` : ''}`); - console.log(` ✗ ${name}${detail ? ` — ${detail}` : ''}`); - } - }; - - const tmp = mkdtempSync(join(tmpdir(), 'objectui-range-selftest-')); - try { - const ui = join(tmp, 'objectui'); - mkdirSync(join(ui, '.changeset'), { recursive: true }); - const g = (...args) => git(ui, args); - g('init', '-q', '-b', 'main'); - g('config', 'user.email', 'selftest@objectstack.ai'); - g('config', 'user.name', 'self test'); - g('config', 'commit.gpgsign', 'false'); - - const commit = (subject, files) => { - for (const [path, content] of Object.entries(files)) { - mkdirSync(dirname(join(ui, path)), { recursive: true }); - writeFileSync(join(ui, path), content); - } - g('add', '-A'); - g('commit', '-q', '-m', subject); - return g('rev-parse', 'HEAD').trim(); - }; - - const base = commit('chore: base', { 'README.md': 'base\n' }); - - // Shapes 1-5 measured on 7d9734d5e321..785b8a5d432c; shapes 6-7 on - // f5bc4c78be76..f995a452d2ca, the range #6099/#6294 were measured on. - // 1. an ordinary releasing `feat` — the only shape the old filter got right - commit('feat(grid): aggregate single-call mode for bulk actions (#3201)', { - '.changeset/bulk-action-aggregate.md': - '---\n"@object-ui/plugin-grid": minor\n---\n\nGrid bulk actions gain an aggregate single-call mode.\n', - 'src/grid.ts': 'a\n', - }); - // 2. a BREAKING `refactor(...)!` — structurally invisible to a feat|fix filter - const breakingSha = commit( - 'refactor(layout)!: delete PageNodeRenderer, the unregistered page-node renderer (#3225)', - { - '.changeset/remove-dead-layout-page-node-renderer.md': - '---\n"@object-ui/layout": major\n---\n\nRemove `PageNodeRenderer`, the dead page-node renderer (objectui#3223).\n', - 'src/layout.ts': 'b\n', - }, - ); - // 3. a releasing `chore(deps)` — also invisible to a feat|fix filter - commit('chore(deps): lockstep the @objectstack family onto 17.0.0-rc.1 (#3189)', { - '.changeset/objectstack-family-rc1-lockstep.md': - '---\n"@object-ui/app-shell": minor\n"@object-ui/core": minor\n---\n\nBring the whole `@objectstack` family to `17.0.0-rc.1`.\n', - 'package.json': '{}\n', - }); - // 4. matches `fix` on the subject, declares release-nothing (empty frontmatter) - commit('fix(ci): hand the cross-repo token to github-script (#3186)', { - '.changeset/fix-cross-repo-closer-require.md': - '---\n---\n\nfix(ci): hand the cross-repo token to github-script\n\nRelease-nothing: touches a workflow only.\n', - '.github/workflows/x.yml': 'on: push\n', - }); - // 5. matches `fix` on the subject, carries no changeset at all - commit('fix(ci): never render a budget FAIL for a run that measured nothing (#3198)', { - '.github/workflows/y.yml': 'on: push\n', - }); - // 6. THE RELEASE-WINDOW SHAPE (#6294), after `042e09d77`: breaking, but - // declared `minor` with the author's own `**BREAKING (v17)**` label — - // the only spelling objectui actually uses, and the one a level-keyed - // section table could not represent at all. - const annotatedSha = commit( - 'feat(field): field widgets receive the record, not the raw value (#3244)', - { - '.changeset/field-widget-record-arg.md': - '---\n"@object-ui/plugin-field": minor\n---\n\nField widgets receive the whole record, not the raw value.\n\n' + - '**BREAKING (v17)** — a widget that read the raw value must read `record[field]`.\n', - 'src/field.ts': 'c\n', - }, - ); - // 7. the NEAR MISS, after `9e9e9a92f`: a `patch` whose prose merely mentions - // "breaking" mid-sentence. Not annotated, so it must stay under Fixes. - commit('fix(tree): stop re-sorting children on every keystroke (#3251)', { - '.changeset/tree-sort-stability.md': - '---\n"@object-ui/plugin-tree": patch\n---\n\nStop re-sorting tree children on every keystroke.\n\n' + - 'Reordering here is technically breaking for anyone who relied on the churn, but only in the sense that the order was never specified.\n', - 'src/tree.ts': 'd\n', - }); - const head = g('rev-parse', 'HEAD').trim(); - - /** The lines under `### `, up to the next `###` (or the end). */ - const sectionOf = (md, heading) => { - const lines = md.split('\n'); - const start = lines.indexOf(`### ${heading}`); - if (start < 0) return null; - const after = lines.slice(start + 1); - const end = after.findIndex((l) => l.startsWith('### ')); - return (end < 0 ? after : after.slice(0, end)).join('\n'); - }; - - console.log('objectui-range --self-test'); - - const classified = classifyRange({ objectuiRoot: ui, from: base, to: head }); - const { markdown } = renderRange({ fromSha: base, toSha: head, classified }); - - // --- the class that went missing: it must be IN the artifact, and lead --- - battery('the class that went missing: it must be IN the artifact, and lead'); - check( - 'a BREAKING `refactor(...)!` commit IS in the pasteable list', - markdown.includes('PageNodeRenderer') && markdown.includes(breakingSha.slice(0, 9)), - markdown, - ); - // Every conjunct is an EXISTENCE check on a heading this fixture really - // renders (`indexOf(...) > -1` first, so a missing heading fails rather than - // ordering vacuously against -1). All three sections are populated here: - // shape 2 or 6 → Breaking, shapes 1/3 → Features, shape 7 → Fixes. - check( - 'the three sections render in page order: Breaking changes, Features, Fixes', - markdown.indexOf('### Breaking changes') > -1 && - markdown.indexOf('### Breaking changes') < markdown.indexOf('### Features') && - markdown.indexOf('### Features') < markdown.indexOf('### Fixes'), - markdown, - ); - check( - 'a releasing non-feat/fix `chore(deps)` commit IS in the list', - markdown.includes('Bring the whole'), - markdown, - ); - check( - 'commit type never filters: all 5 releasing changesets are listed', - classified.releasing.length === 5, - `got ${classified.releasing.length}`, - ); - - // --- #6294: the leading section is keyed on the SHARED breaking verdict --- - // objectui never declares `major` in a release window, so a level-keyed - // table left `### Breaking changes` structurally unrenderable and filed the - // author-annotated breaking entries under "Features". - battery('#6294: the leading section is keyed on the SHARED breaking verdict'); - check( - 'a `minor` entry the AUTHOR annotated breaking is under Breaking changes', - (sectionOf(markdown, 'Breaking changes') ?? '').includes('not the raw value'), - markdown, - ); - check( - 'that annotated entry is NOT left under Features', - !(sectionOf(markdown, 'Features') ?? '').includes('not the raw value'), - markdown, - ); - check( - 'sectioning consumes the verdict, it does not restate the level: still `minor`/annotation', - classified.releasing.find((r) => r.sha === annotatedSha)?.level === 'minor' && - classified.releasing.find((r) => r.sha === annotatedSha)?.breakingSource === 'annotation', - JSON.stringify(classified.releasing.find((r) => r.sha === annotatedSha)), - ); - check( - 'a `patch` merely MENTIONING "breaking" mid-sentence stays under Fixes', - (sectionOf(markdown, 'Fixes') ?? '').includes('re-sorting tree children') && - !(sectionOf(markdown, 'Breaking changes') ?? '').includes('re-sorting tree children'), - markdown, - ); - check( - 'grouping never filters: every releasing entry appears exactly once', - classified.releasing.every( - (r) => markdown.split(`(objectui \`${r.sha.slice(0, 9)}\`)`).length === 2, - ), - markdown, - ); - - // The ordering guard above is carried in this fixture by BOTH breaking - // sources at once. Pin it a second time on a range whose ONLY breaking entry - // is annotation-sourced — the real release-window shape, and the one a - // level-keyed table cannot render at all. This is the check that goes red if - // the section ever reverts to keying on `r.level === 'major'`. - const annotationOnly = renderRange({ - fromSha: base, - toSha: head, - classified: { - ...classified, - releasing: classified.releasing.filter((r) => r.breakingSource !== 'declared-major'), - }, - }); - check( - 'with NO declared `major` in range, Breaking changes still renders and still leads', - annotationOnly.markdown.indexOf('### Breaking changes') > -1 && - annotationOnly.markdown.indexOf('### Breaking changes') < - annotationOnly.markdown.indexOf('### Features'), - annotationOnly.markdown, - ); - - // --- what ships nothing is excluded, and SAYS SO in the artifact --- - battery('what ships nothing is excluded, and SAYS SO in the artifact'); - check( - 'a `fix(ci)` with an EMPTY frontmatter changeset is NOT listed', - !markdown.includes('cross-repo token'), - markdown, - ); - check( - 'a `fix(ci)` with no changeset at all is NOT listed', - !markdown.includes('budget FAIL'), - markdown, - ); - check( - 'the release-nothing count is spoken IN THE MARKDOWN, not just in JSON', - classified.releaseNothing === 1 && markdown.includes('1 release-nothing changeset'), - markdown, - ); - check( - 'the no-changeset commit count is spoken IN THE MARKDOWN', - classified.noChangeset === 1 && markdown.includes('1 commit carrying no changeset'), - markdown, - ); - check( - 'the accounting footer is unconditional and states the totals', - markdown.includes('5 releasing of 6 added across 7 non-merge commit(s)'), - markdown, - ); - - // --- a range that excludes NOTHING still says so (zeros are load-bearing) --- - battery('a range that excludes NOTHING still says so (zeros are load-bearing)'); - const clean = renderRange({ - fromSha: base, - toSha: head, - classified: { - ...classified, - releaseNothingEntries: [], - noChangesetCommits: [], - releaseNothing: 0, - noChangeset: 0, - }, - }); - check( - 'a range excluding nothing STILL prints the accounting (0, not silence)', - clean.markdown.includes('0 release-nothing changesets') && - clean.markdown.includes('0 commits carrying no changeset'), - clean.markdown, - ); - - // --- `--all` names the excluded entries instead of only counting them --- - battery('`--all` names the excluded entries instead of only counting them'); - const all = renderRange({ fromSha: base, toSha: head, classified, showExcluded: true }); - check( - '`--all` itemizes the excluded commits by subject', - all.markdown.includes('### Excluded — ships no package code') && - all.markdown.includes('cross-repo token') && - all.markdown.includes('budget FAIL'), - all.markdown, - ); - - // --- end-to-end through the real CLI ------------------------------------ - battery('end-to-end through the real CLI'); - const cli = fileURLToPath(import.meta.url); - const run = (args) => - execFileSync('node', [cli, ...args], { - encoding: 'utf8', - // #16644: `gitFreeEnv()` as the BASE. The child re-enters this file against the - // fixture at `ui`; its own helper strips too, and this closes the frame above it. - env: { ...gitFreeEnv(), OBJECTUI_ROOT: ui }, - }); - const cliMd = run(['--from', base, '--to', head]); - check( - 'the CLI markdown carries the breaking entry and not the ci noise', - cliMd.includes('PageNodeRenderer') && !cliMd.includes('cross-repo token'), - cliMd, - ); - const cliJson = JSON.parse(run(['--from', base, '--to', head, '--json'])); - check( - 'the CLI JSON reports the declared criterion and the excluded counts', - cliJson.criterion === 'declared-changeset' && - cliJson.count === 5 && - cliJson.releaseNothing === 1 && - cliJson.noChangeset === 1, - JSON.stringify(cliJson).slice(0, 200), - ); - check( - 'every listed entry carries the level objectui declared', - cliJson.releasing.every((r) => ['major', 'minor', 'patch'].includes(r.level)) && - cliJson.releasing.filter((r) => r.level === 'major').length === 1, - JSON.stringify(cliJson.releasing.map((r) => r.level)), - ); - check( - 'the CLI JSON carries both breaking sources, so a consumer can tell them apart', - cliJson.releasing.filter((r) => r.breakingSource === 'declared-major').length === 1 && - cliJson.releasing.filter((r) => r.breakingSource === 'annotation').length === 1 && - cliJson.releasing.filter((r) => r.breaking).length === 2, - JSON.stringify(cliJson.releasing.map((r) => r.breakingSource)), - ); - - // --- #11952: `--help`'s self-read is bound to the header, not every - // column-0 `//` line in the file. Pin the acceptance criterion literally: - // the header is still there, and none of the six measured stray lines — - // an internal helper's rationale (pinAt, mid-`main`) and this very - // self-test's own section banner — leak into the usage text. - battery('#11952: `--help`\'s self-read is bound to the header, not every'); - const helpOut = run(['--help']); - check( - '--help still carries the real header (Usage/Env sections intact)', - helpOut.includes('Usage:') && - helpOut.includes('node scripts/objectui-range.mjs [new-rev]') && - helpOut.includes('OBJECTUI_ROOT=/path/to/objectui'), - helpOut, - ); - const strayLines = [ - 'Resolve the objectui SHA pinned at a given framework rev', - 'Self-test (#4843) — the repo idiom for a `scripts/` gate', - 'git repo carrying the exact shapes measured on the real range', - 'code over it, assert the ARTIFACT (the markdown a maintainer pastes)', - ]; - check( - '--help does NOT leak mid-file implementation comments (#11952)', - strayLines.every((s) => !helpOut.includes(s)), - helpOut, - ); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - - // ── The floor: every declared battery RAN, and ran its cases (#13489) ─── - // - // Evaluated after every battery has had its chance and BEFORE the verdict, so - // the success line below can only be printed by a run in which the set of - // batteries that registered assertions EQUALS the set declared. A set - // difference names WHICH battery stopped; a count says only that something did. - const floorFailure = (message) => { - failures.push(message); - }; - const declaredBatteries = Object.keys(SELF_TEST_BATTERIES); - let floorBreached = false; - if (declaredBatteries.length < SELF_TEST_BATTERY_FLOOR) { - floorBreached = true; - floorFailure( - `SELF_TEST_BATTERIES declares ${declaredBatteries.length} batteries, below the pinned ` + - `${SELF_TEST_BATTERY_FLOOR} — a battery deleted from the roster takes its own floor with it.`, - ); - } - for (const [name, count] of seen) { - if (declaredBatteries.includes(name)) continue; - floorBreached = true; - floorFailure( - `self-test battery "${name}" registered ${count} case(s) but is not declared in ` + - 'SELF_TEST_BATTERIES — an assertion attributed to no declared battery is one nothing floors.', - ); - } - for (const name of declaredBatteries) { - const count = seen.get(name) ?? 0; - if (count >= SELF_TEST_BATTERIES[name]) continue; - floorBreached = true; - floorFailure( - count === 0 - ? `self-test battery "${name}" DID NOT RUN — 0 cases registered, ${SELF_TEST_BATTERIES[name]} pinned. ` + - 'The verdict below would have claimed those cases hold.' - : `self-test battery "${name}" registered ${count} case(s), below its pinned floor of ` + - `${SELF_TEST_BATTERIES[name]} — cases that used to run no longer do.`, - ); - } - if (floorBreached) { - floorFailure( - 'A battery at or below its floor means cases STOPPED RUNNING — the battery is the bug, not the ' + - 'number. Find what stopped registering (an early return, a deleted block, a guard that now ' + - 'skips) and restore it.', - ); - } - if (failures.length) { - console.error(`\n⛔ objectui-range --self-test: ${failures.length} failure(s)`); - for (const f of failures) console.error(` - ${f}`); - return 1; - } - console.log('✓ objectui-range --self-test: all checks passed'); - selfTestReachedVerdict = true; - return 0; -} - -if (isEntrypoint(import.meta.url)) { - if (has('-h') || has('--help')) process.exit(printHelp()); - if (has('--self-test')) { - const selfTestCode = selfTest(); - if (!selfTestReachedVerdict) { - console.error( - '\n✗ objectui-range self-test: selfTest() returned without reaching its verdict,\n' - + 'so no success line was printed. Exiting 0 here would report a self-test\n' - + 'that never finished as a self-test that passed.\n', - ); - process.exit(1); - } - process.exit(selfTestCode); - } - process.exit(main()); -} diff --git a/turbo.json b/turbo.json index 9fe13f8f537..fe5792620b4 100644 --- a/turbo.json +++ b/turbo.json @@ -77,6 +77,11 @@ "outputs": ["dist/**"], "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/skills/**"] }, + "@objectstack/console#build": { + "dependsOn": ["^build", "@object-ui/console#build"], + "outputs": ["dist/**"], + "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/sdui.manifest.json"] + }, "@objectstack/spec#gen:migration-registry": { "dependsOn": [], "outputs": ["src/migrations/registry.ts"], From b35ec3585c5ca8cd448657ae74dd5e4ae92a63d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 02:07:34 +0000 Subject: [PATCH 02/12] release(ui-merge): workflows, root scripts and the CLI remedy follow the in-workspace Console build (objectstack#25711, WIP) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_0149BHSXfCdQywGaLdpRAtWe --- .github/workflows/cut-rc.yml | 195 +++--------------- .github/workflows/lint.yml | 25 --- .github/workflows/release.yml | 114 ++-------- .github/workflows/showcase-smoke.yml | 18 +- package.json | 4 +- .../cli/src/utils/console.not-built.test.ts | 6 +- packages/cli/src/utils/console.ts | 11 +- packages/cli/src/utils/sdui-manifest.ts | 6 +- packages/cli/test/console-resolve.test.ts | 10 +- packages/console/package.json | 2 +- scripts/check-changeset-no-major.mjs | 69 ++++--- turbo.json | 4 +- 12 files changed, 110 insertions(+), 354 deletions(-) diff --git a/.github/workflows/cut-rc.yml b/.github/workflows/cut-rc.yml index 78a7d549a44..8b7c02cce7c 100644 --- a/.github/workflows/cut-rc.yml +++ b/.github/workflows/cut-rc.yml @@ -29,32 +29,15 @@ name: Cut RC # objectstack `main` sha it checked out and does every downstream step against # that one frozen value. main may keep moving freely for the whole run. # -# THE objectui PIN IS AN INPUT HERE, NEVER A DECISION (#10134) -# ------------------------------------------------------------ -# Maintainer ruling, 2026-08-20, verbatim and untranslated: -# -# > objectui 每次 pin 的时候,对应的 changeset 应该带过来,但是发版本的时候不需要去 -# > 扫描 objectui 仓库的最新版 -# > -# > 更新到哪个版本是 objectstack 的 issue 自己决定的 -# -# So this lane READS `.objectui-sha` and builds against it. It does not resolve -# objectui `main`, does not compare the pin to anything in another repo, and does -# not move the pin. The pin moves only on a manual bump PR whose target revision -# was decided in an objectstack issue (#10129 is the pattern). -# -# ⛔ Do not re-add a currency check here or anywhere on a release path. It is not -# that liveness is expensive — it is that "the pin is behind objectui main" is -# not a defect. #3340's actual invariant is "everything published is covered by -# the changeset record", and `scripts/bump-objectui.sh` + -# `scripts/objectui-changeset-digest.mjs` deliver it at BUMP time by carrying -# objectui's own changesets across OLD..NEW. That carry-over is the mechanism; -# scanning objectui mid-release was a second, redundant attempt at the same goal -# that additionally reached into another repo to second-guess a decision. -# -# CONSEQUENCE, STATED PLAINLY: an rc ships whatever console the committed pin -# names. If the pin is old, the rc's frontend is old, and nothing here will warn -# about it. That is the design, not an oversight — the pin is a decision. +# THE CONSOLE IS PART OF THE SNAPSHOT (objectstack#25711) +# --------------------------------------------------------- +# There is no objectui pin to read. The Console source is `ui/apps/console` in +# this repository (@object-ui/console, ruling D1 「同一个版本号组」): the snapshot +# above freezes it together with the spec it builds against, `pnpm run build` +# builds it and `@objectstack/console` copies its dist, and the 39 published +# @object-ui/* packages are members of the one Changesets `fixed` group, so the +# version pass below numbers them with everything else. Nothing here reaches +# into another repository, and nothing moves a pin, because neither exists. # # SCOPE — rc PRERELEASES ONLY # --------------------------- @@ -245,90 +228,6 @@ jobs: echo "objectstack snapshot: ${SNAPSHOT_SHA}" echo "main currently carries @objectstack/cli@${CURRENT}; this run must compute ${REQUESTED}." - # ──────────────────────────────────────────────────────────────────────── - # THE objectui HALF — READ FROM THE COMMITTED PIN, NOT RESOLVED (#10134). - # - # `.objectui-sha` is the whole input. Everything downstream reads - # $OBJECTUI_SHA, which is that file and nothing else, so objectui may merge - # freely for the rest of the run and this cut neither notices nor cares — - # not because a snapshot froze a moving target, but because the target was - # never objectui `main`. See "THE objectui PIN IS AN INPUT HERE" up top. - # ──────────────────────────────────────────────────────────────────────── - - name: Read the committed objectui pin - run: | - # Shape-checked, not trusted: `.objectui-sha` is hand-edited by - # bump-objectui.sh and by humans, and a truncated or empty file would - # otherwise surface much later as an unhelpful clone/build failure. - OBJECTUI_SHA="$(tr -d '[:space:]' < .objectui-sha)" - if ! printf '%s' "$OBJECTUI_SHA" | grep -qE '^[0-9a-f]{40}$'; then - echo "::error::.objectui-sha does not contain a 40-hex commit id (got '${OBJECTUI_SHA}'). Refusing to cut against an unreadable frontend revision." - exit 1 - fi - echo "OBJECTUI_SHA=${OBJECTUI_SHA}" >> "$GITHUB_ENV" - echo "objectui pin (committed, NOT resolved): ${OBJECTUI_SHA}. This cut ships the console at that revision, however old it is — moving the pin is a separate, deliberate PR." - - - name: Clone objectui at full depth, at the pin - run: | - OBJECTUI_ROOT="${RUNNER_TEMP}/objectui" - # FULL clone, not shallow, and still a measured requirement rather than - # caution. Two reasons, both surviving #10134: - # - the check below asks whether the PIN is reachable from objectui - # main; a tip-only shallow clone cannot answer that and would answer - # "no" for every pin older than the tip. Necessary, not sufficient: - # a full clone carries every branch, main's and otherwise, so it - # makes the question answerable without answering it; - # - build-console.sh builds from THIS clone (it honours - # $OBJECTUI_ROOT) by adding a worktree at the pin, which needs the - # pin's tree present. - # The #4731 lesson — a degraded changeset record and a complete one look - # alike, so never let history quietly go missing — now applies on the - # bump PR, which is where objectui-changeset-digest.mjs walks OLD..NEW. - git clone --no-tags https://github.com/objectstack-ai/objectui.git "$OBJECTUI_ROOT" - echo "OBJECTUI_ROOT=${OBJECTUI_ROOT}" >> "$GITHUB_ENV" - - # THE PIN MUST BE ON objectui MAIN — WHICH IS NOT WHAT OBJECT PRESENCE - # ANSWERS (#9450). This was one `cat-file -e`, and the sentence it - # printed on failure — "not reachable from main (unmerged branch, or - # main was rewritten)" — named a case the test cannot see. Measured on - # a fresh `--no-tags` clone of objectui, 2026-08-21: 291 commits across - # 118 branch tips are present and NOT reachable from main, and - # `cat-file -e` says yes to every one of them — including the "branch - # that never merged" the message claimed to catch. The clone being FULL - # is what OPENS that gap rather than closing it: `git clone` fetches - # every branch head, so the more complete the clone, the more non-main - # revisions it can vouch for. - # - # Nothing upstream closes it either — bump-objectui.sh pins - # `git rev-parse HEAD` of a local objectui checkout without asking which - # branch that is. "The operator happened to be on main" is the whole of - # the protection, so ask the question here rather than assume it. - # - # Three questions, three exits, in this order because the later ones - # cannot be asked until the earlier ones hold: `merge-base --is-ancestor` - # exits 128 on an absent object — an error, not a verdict — and with no - # origin/main it would report "the pin left main", which is this block's - # own overclaim wearing a new message. - if ! git -C "$OBJECTUI_ROOT" rev-parse --verify --quiet origin/main >/dev/null; then - echo "::error::the objectui clone has no origin/main ref, so \"is the pin on main\" cannot be answered in it. Refusing to cut rather than assuming the answer." - exit 1 - fi - if ! git -C "$OBJECTUI_ROOT" cat-file -e "${OBJECTUI_SHA}^{commit}" 2>/dev/null; then - echo "::error::the committed pin ${OBJECTUI_SHA} is not present in a fresh full clone of objectui at all — no branch carries it. It was never pushed, its branch was deleted, or main was rewritten. Fix .objectui-sha in its own PR; do not cut against it." - exit 1 - fi - if ! git -C "$OBJECTUI_ROOT" merge-base --is-ancestor "$OBJECTUI_SHA" origin/main; then - echo "::error::the committed pin ${OBJECTUI_SHA} exists in objectui but is NOT reachable from objectui main — it names a revision on a branch that never merged. Cutting against it would publish @objectstack/console built from code that is not on main. Fix .objectui-sha in its own PR; do not cut against it." - exit 1 - fi - echo "objectui pin ${OBJECTUI_SHA:0:12}: present in the clone, and reachable from objectui main." - - # Detach the clone AT THE PIN so nothing downstream can accidentally - # read a working tree that is objectui main. build-console.sh builds - # from its own worktree at the pin regardless; this makes the intent - # true of the clone itself rather than incidental. - git -C "$OBJECTUI_ROOT" checkout --detach --quiet "$OBJECTUI_SHA" - echo "objectui clone detached at ${OBJECTUI_SHA:0:12}." - # ──────────────────────────────────────────────────────────────────────── # Toolchain. Mirrors release.yml's publish job step for step. # ──────────────────────────────────────────────────────────────────────── @@ -373,43 +272,14 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - # A fresh workspace must build local dists BEFORE the console build: the - # console's vite alias table (objectui#4103) resolves @objectstack/client to - # this tree's packages/client/dist, whose DTS needs @objectstack/core built, - # and build-console.sh's own fallback is a bare `pnpm build` inside - # packages/client that tsup cannot finish on its own. @objectstack/spec is - # named explicitly because generated-artifact reads need it even where the - # client closure would already have pulled it in. Same step, same reason, as - # ci.yml's Console Pin Gate and showcase-smoke.yml. - - name: Build the @objectstack/client and @objectstack/spec closures - run: pnpm exec turbo run build --filter=@objectstack/client... --filter=@objectstack/spec... --concurrency=4 - - # ──────────────────────────────────────────────────────────────────────── - # ⛔ NO PIN BUMP HAPPENS HERE (#10134). - # - # This lane used to run `bash scripts/bump-objectui.sh "$OBJECTUI_SHA"` and - # then assert `.objectui-sha` equalled the sha it had just written — an - # assertion that was trivially true, and that existed only because the sha - # came from outside the repo. Both are gone: the pin is committed input, so - # there is nothing to bump to and nothing to reconcile. - # - # The `@objectstack/console` changeset for a pin range is emitted by the - # manual bump PR, which is the only place the pin moves. A cut therefore - # publishes a console the release record already describes, or it publishes - # the same console as last time — never a console nobody wrote a record for. - # ──────────────────────────────────────────────────────────────────────── - - - name: Build the vendored Console SPA at the committed pin - run: pnpm objectui:build - - - name: Verify the Console dist stamp matches the pin - run: pnpm check:console-sha - - # No SDUI manifest step follows. The console dist built above already - # carries the TRACKED repo-root sdui.manifest.json (build-console.sh copies - # it), whose freshness against the pin the required lint job holds on every - # PR; the ADR-0082 D4 declaration-parity ratchet that used to run here, and - # the Playwright browser it needed, are retired. + # The whole workspace, both scopes: @object-ui/console builds against this + # snapshot's @objectstack/* (`workspace:^`), and `@objectstack/console#build` + # (root turbo.json) copies its dist with the TRACKED repo-root + # sdui.manifest.json and runs the single-zod canary on what it ships + # (packages/console/build-dist.sh). `pnpm run release` below builds again, + # as a turbo cache hit. + - name: Build the workspace (both scopes, the Console included) + run: pnpm run build # ──────────────────────────────────────────────────────────────────────── # PRE-VERSION GATES. Every one of these reads `.changeset/*`, so all of them @@ -422,18 +292,14 @@ jobs: # honest base here is $SNAPSHOT_SHA, and the diff they judge is this run's # OWN contribution. # - # ⚠️ Since #10134 that contribution is EMPTY by design — the lane no longer - # bumps the pin, so it emits no @objectstack/console changeset of its own, - # and these three assert exactly that. They are kept rather than deleted for + # ⚠️ That contribution is EMPTY by design — the lane writes no changeset of + # its own, and these three assert exactly that. They are kept rather than deleted for # two reasons: "this cut introduces nothing before versioning" is a real # invariant to hold (it is what makes the allowlist assertion below a # statement about `pnpm run version` alone), and each still runs its # `--self-test`, so a broken gate is caught here rather than on the PR that - # broke it. The risk they used to cover on this lane — an objectui `major` - # promoting all ~70 packages, or a digest bug emitting empty frontmatter and - # stalling the release silently and greenly (#4898) — now lands on the - # manual bump PR, which is where the digest runs and where these same gates - # judge a non-empty diff. + # broke it. Every changeset this cut versions, @object-ui/* included, was + # judged by these same gates on the PR that wrote it. # # $SNAPSHOT_SHA is a pinned sha, which #6129 warns against — but that warning # is about pr-automation.yml's FROZEN `base.sha`, a value that goes stale as @@ -487,8 +353,8 @@ jobs: # THE SINGLE VERSION COMMIT. # # Squashed onto $SNAPSHOT_SHA so main receives exactly one commit for the - # cut: the pin bump and the version output are one atomic unit, and there is - # never a main commit carrying a bumped pin but an unversioned tree. + # cut: the version output is one atomic unit, and there is never a main + # commit carrying half of it. # # WHAT MAY BE IN IT — re-measured under @changesets/cli v3 on the # 17.0.0 -> 17.1.0-rc.0 train (a full local `pnpm run version` in a throwaway @@ -585,9 +451,8 @@ jobs: REQUESTED: ${{ inputs.version }} DRY_RUN: ${{ inputs.dry_run }} run: | - # The committer identity. It used to be set by the pin-bump step, which - # #10134 deleted; `actions/checkout` does not set one, so `git commit` - # below would fail with "empty ident name" without this. + # The committer identity: `actions/checkout` does not set one, so + # `git commit` below would fail with "empty ident name" without this. git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' @@ -687,7 +552,6 @@ jobs: '*package.json' \ '*CHANGELOG.md' \ .changeset \ - .objectui-sha \ packages/spec/src/kernel/protocol-version.ts \ "${TEMPLATE_SURFACES[@]}" \ "${DOCS_SURFACES[@]}" \ @@ -708,7 +572,7 @@ jobs: # can accept a path its declaration does not name, and none needs # regex-escaping of the paths to stay exact. BAD="$(printf '%s\n' "$STAGED" \ - | grep -vE '(^|/)package\.json$|(^|/)CHANGELOG\.md$|^\.changeset/|^\.objectui-sha$|^packages/spec/src/kernel/protocol-version\.ts$' \ + | grep -vE '(^|/)package\.json$|(^|/)CHANGELOG\.md$|^\.changeset/|^packages/spec/src/kernel/protocol-version\.ts$' \ | grep -vxF -f "$TEMPLATE_LIST" \ | grep -vxF -f "$SURFACE_LIST" \ | grep -vxF -f "$INDEX_LIST" \ @@ -734,7 +598,6 @@ jobs: # which would land verbatim in the commit body. git commit -q \ -m "chore(release): ${REQUESTED}" \ - -m "objectui pin (unchanged by this cut): ${OBJECTUI_SHA}" \ -m "cut from ${SNAPSHOT_SHA} by ${GITHUB_ACTOR} via cut-rc (#7447)" COMMIT_SHA="$(git rev-parse HEAD)" @@ -748,7 +611,6 @@ jobs: echo "| | |" echo "|---|---|" echo "| objectstack snapshot | \`${SNAPSHOT_SHA}\` |" - echo "| objectui pin (committed) | \`${OBJECTUI_SHA}\` |" echo "| version commit | \`${COMMIT_SHA}\` |" echo "| files in commit | ${COUNT} |" echo "| dry run | ${DRY_RUN} |" @@ -775,7 +637,6 @@ jobs: echo "# cut-rc dry run — ${REQUESTED}" echo echo "- objectstack snapshot: ${SNAPSHOT_SHA}" - echo "- objectui pin: ${OBJECTUI_SHA} (committed, unchanged)" echo "- version commit: $(git rev-parse HEAD)" echo echo "Nothing was pushed and nothing was published." @@ -842,7 +703,7 @@ jobs: git fetch origin main if ! git rebase origin/main; then git rebase --abort || true - echo "::error::the version commit conflicts with main. Nothing has been published. A commit landing during the cut touched the release file surface (package.json / CHANGELOG.md / .changeset / .objectui-sha) — resolve it and re-dispatch." + echo "::error::the version commit conflicts with main. Nothing has been published. A commit landing during the cut touched the release file surface (package.json / CHANGELOG.md / .changeset) — resolve it and re-dispatch." exit 1 fi @@ -922,7 +783,7 @@ jobs: run: bash scripts/release-spec-changes.sh --verify # ──────────────────────────────────────────────────────────────────────── - # PUBLISH. `pnpm run release` = build + build-console + release-publish.sh, + # PUBLISH. `pnpm run release` = build (both scopes) + release-publish.sh, # which is `changeset publish` followed by ONE atomic `git push origin --tags` # (#2191: the changesets action's concurrent per-tag pushes raced GitHub's # ref backend and lost ~half the tags). diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3b2ca5d6b16..a8d951d16a8 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -3064,31 +3064,6 @@ jobs: continue-on-error: true run: pnpm check:startup-registry-verdict - # objectui pin-changeset guard (#4731). A frontend-only refresh reaches the - # platform release record ONLY through the changeset `bump-objectui.sh` - # writes, and that list used to be guessed off objectui's commit subjects - # (`feat|fix` + a silent `head -40`). On one real range the guess dropped - # all 13 releasing non-feat/fix commits — every breaking `refactor(...)!` - # among them — and pulled in 5 that release nothing. The list and the bump - # level now come from what objectui DECLARED (`.changeset/*.md` added over - # the range; empty frontmatter = release-nothing). This self-test builds - # throwaway git repos carrying those exact shapes, runs the real scripts - # over them, and asserts the emitted changeset — including that a cap which - # fires announces itself and that an unwalkable range degrades loudly. - # #4843 folded `scripts/objectui-range.mjs` — the release page's Console - # section — onto that same `classifyRange()` criterion, so both self-tests - # run here: one criterion, two consumers, no room to drift apart. - - name: objectui pin-changeset digest guard - id: objectui-pin-changeset-digest-guard - continue-on-error: true - run: pnpm check:objectui-changeset - - # Rationale: `scripts/bump-objectui.selftest.sh` header (#10797). - - name: objectui pin write-ordering guard - id: objectui-pin-write-ordering-guard - continue-on-error: true - run: pnpm check:objectui-bump - # Changeset-family gate self-tests (#6509). The SELF-TEST halves only — # the real scans stay in pr-automation.yml's `changeset-check`, and the # split is the whole point of this step. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4a17088b7bb..65a0cf83830 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1835,109 +1835,25 @@ jobs: run: node scripts/check-changeset-fixed.mjs # ────────────────────────────────────────────────────────────────────── - # ⛔ TOMBSTONE — the #3340 pin-currency gate that used to sit here is gone - # (2026-08-20 ruling, #10134). Do not re-add one. + # ⛔ TOMBSTONE — no objectui pin is read, built or checked here, and none + # is re-added (objectstack#25711, D1 ruling 「同一个版本号组」). # - # It compared `.objectui-sha` against objectui `main` at publish time and - # refused a release whose pin lagged. The ruling removed the question, not - # just the job: WHICH objectui revision this repo pins is a decision taken - # in an objectstack issue, never derived from another repo's HEAD, so - # "the pin is behind main" is not a defect a release lane may diagnose. - # - # R4's lesson (#6170 — "a gate required on a PR the publishing lane can - # skip is not a gate") is untouched and does not resurrect this one: - # #3340's actual invariant is "everything published is covered by the - # changeset record", and that is carried by `scripts/bump-objectui.sh` + - # `scripts/objectui-changeset-digest.mjs` at BUMP time — on the manual pin - # PR, which is now the only way the pin ever moves. Publishing an old - # console is therefore a published decision, not an unnoticed gap. + # The Console is a workspace package now: `ui/apps/console` + # (@object-ui/console) builds against this tree's @objectstack/* through + # `workspace:^`, and `@objectstack/console#build` (root turbo.json) copies + # that dist with the tracked sdui.manifest.json, so the `pnpm run build` + # below builds it with everything else. The pin-currency gate, the + # vendored-dist cache keyed on `.objectui-sha`, its SHA stamp check and + # its spec-injection check are gone with the pin: each answered "was the + # Console built from the objectui commit, and against the spec, this + # release names", and both are true by construction when the Console + # source and the spec are one tree at one commit. The single-zod canary + # survives inside packages/console/build-dist.sh, on the bytes it ships. # ────────────────────────────────────────────────────────────────────── - name: Build run: pnpm run build - - name: Read the spec's declared zod range (Console dist key input) - id: spec-zod - # Fails rather than keying on an empty or `undefined` range: a key that - # silently stopped carrying the range would restore across a zod move. - run: | - node -e ' - const range = require("./packages/spec/package.json").dependencies?.zod; - if (typeof range !== "string" || range.trim() === "") { - console.error("packages/spec/package.json declares no dependencies.zod, so the Console dist key cannot carry it."); - process.exit(1); - } - console.log("range=" + range.trim()); - ' >> "$GITHUB_OUTPUT" - - # This key hashes the pin and the build script, and carries the spec's - # DECLARED zod range (the step above). ci.yml's Console Pin Gate (#4290) - # hashes a SUPERSET of the files: those two, the probe scripts and the - # spec's entry layout (#20765). So the two workflows no longer share dist - # entries, and each one builds its own on a miss. Keeping this key narrower - # is deliberate. - # Following ci.yml would rebuild the console on every Version Packages - # merge, and publishing a cached console whose spec content lags is the - # existing cache design. - # - # The zod range is the one spec input the cached dist is NOT allowed to - # lag (#21108). The console bundles a single zod instance, the console's - # own copy, and its build refuses a console zod outside the injected spec's - # declared `zod` range (objectui#11353). A dist restored after that range - # moved would publish a console the build never judged against it. The - # range is read out of packages/spec/package.json, not hashed with it: the - # file's `version` moves on every Version Packages merge, and hashing the - # whole file would turn every publish into a cold console rebuild. - # - # Split restore/save, matching ci.yml, and not the combined actions/cache - # action: the combined form's post-step saves even when the job FAILED, - # and build-console.sh writes the SHA stamp BEFORE it asserts the bundle - # canary — so a canary failure here would seed this shared, repo-scoped - # key with a stamped-but-broken dist that later runs restore and sail - # through. See ci.yml's split-restore note for the full rationale. - - name: Restore vendored Console dist (keyed on the objectui pin) - id: console-dist-cache - uses: actions/cache/restore@v6 - with: - path: packages/console/dist - key: ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh') }}-zod-${{ steps.spec-zod.outputs.range }} - - - name: Build vendored @objectstack/console SPA - # Clones objectstack-ai/objectui at the SHA pinned in .objectui-sha, - # builds @object-ui/console, and copies dist/ into - # packages/console/dist/. Must run before publish so the prepublishOnly - # guard in @objectstack/console passes. - if: steps.console-dist-cache.outputs.cache-hit != 'true' - run: bash scripts/build-console.sh - - - name: Verify Console dist stamp matches pin - run: pnpm check:console-sha - - # The spec-injection half of the same question, spelled exactly as - # ci.yml's Console Pin Gate spells it — both consumers of one cache key - # apply one check. check:console-sha answers only "which objectui SHA", - # and scripts/assert-console-spec-injection.mjs runs INSIDE - # build-console.sh, which the `if: cache-hit != 'true'` above skips — so - # on a cache hit nothing had asked whether the dist this job is about to - # PUBLISH bundles this tree's @objectstack/spec rather than the published - # tarball. --require-stamp because the vacuity check:console-sha tolerates - # (no dist, or a dist no build ever stamped) is wrong on a publish lane. - - name: Verify the restored Console dist bundles this tree's spec - env: - CONSOLE_DIST_CACHE_KEY: ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh') }}-zod-${{ steps.spec-zod.outputs.range }} - run: pnpm check:console-injection --require-stamp - - # Reached only with every console assertion above green: Actions steps - # default to `success()`, and nothing between the restore and here carries - # `if: always()` or `continue-on-error`. Saving only on a miss keeps an - # immutable entry from being rewritten on every release run. - - name: Save vendored Console dist - if: steps.console-dist-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v6 - with: - path: packages/console/dist - key: ${{ runner.os }}-console-dist-${{ hashFiles('.objectui-sha', 'scripts/build-console.sh') }}-zod-${{ steps.spec-zod.outputs.range }} - - name: Downstream backward-compat smoke (live hotcrm) # Pre-publish gate (#2035): the about-to-publish @objectstack/spec must # not break a real third-party consumer pinned to a published release. @@ -2031,8 +1947,8 @@ jobs: run: bash scripts/release-spec-changes.sh --verify # ────────────────────────────────────────────────────────────────────── - # The publish itself. `pnpm run release` = build + build-console + - # scripts/release-publish.sh, which is `changeset publish` followed by ONE + # The publish itself. `pnpm run release` = build (both scopes; the turbo + # cache makes it the build above) + scripts/release-publish.sh, which is `changeset publish` followed by ONE # atomic `git push origin --tags` (#2191: the action's concurrent per-tag # pushes raced GitHub's ref backend and lost ~half the tags). # diff --git a/.github/workflows/showcase-smoke.yml b/.github/workflows/showcase-smoke.yml index c7b77c2f8b9..d86e9adc66a 100644 --- a/.github/workflows/showcase-smoke.yml +++ b/.github/workflows/showcase-smoke.yml @@ -55,17 +55,13 @@ jobs: # built `dist/`. Build the whole dependency closure (excluding the # showcase's own app build, which serve --dev doesn't need). - run: pnpm turbo run build --filter=@objectstack/example-showcase^... - # The console SPA served at /_console is vendored by build-console.sh - # (shallow-clones objectui@.objectui-sha, builds @object-ui/console, copies - # dist → packages/console/dist). Without it serve --dev warns "Console dist - # not found" and the app shell never renders. - - name: Vendor the pinned Console SPA - run: bash scripts/build-console.sh - # Self-test the drift guard: a fresh build-console.sh must stamp the dist - # with the pinned SHA so `pnpm check:console-sha` reports in-sync. Catches - # a regression where the stamp step is dropped or the pin/stamp diverge. - - name: Verify Console SHA stamp matches pin - run: pnpm check:console-sha + # The console SPA served at /_console is @objectstack/console's dist: a + # copy of the workspace's own ui/apps/console build, which `build` does not + # produce (packages/console/build-dist.sh, run by the `build:dist` task). + # Without it serve --dev warns "Console dist not found" and the app shell + # never renders. + - name: Build the Console SPA from ui/apps/console + run: pnpm console:build - name: Install Playwright Chromium working-directory: examples/app-showcase run: pnpm exec playwright install --with-deps chromium diff --git a/package.json b/package.json index c1347b60428..1bea32bcd72 100644 --- a/package.json +++ b/package.json @@ -18,12 +18,12 @@ "prepare": "node scripts/setup-git-hooks.mjs", "check:merge-driver": "node scripts/git-env.mjs --self-test && node scripts/git-merge-regen.mjs --self-test && node scripts/check-regen-pending.mjs --self-test", "version": "node scripts/release-pending-publish.mjs annotate-shipped; changeset version && node scripts/sync-protocol-version.mjs && node scripts/sync-template-versions.mjs && node scripts/sync-docs-image-tags.mjs && node scripts/sync-release-index-currency.mjs && node scripts/check-skill-compatibility-version.mjs --fix", - "release": "pnpm run build && bash scripts/release-publish.sh", + "release": "pnpm run build && pnpm run console:build && bash scripts/release-publish.sh", "docs:dev": "pnpm --filter @objectstack/docs dev", "docs:build": "pnpm --filter @objectstack/docs build", "docs:start": "pnpm --filter @objectstack/docs start", "os": "node packages/cli/bin/run.js", - "console:build": "turbo run build --filter=@objectstack/console", + "console:build": "turbo run build:dist --filter=@objectstack/console", "gen:sdui-lockstep": "node scripts/check-sdui-lockstep.mjs --update", "lint": "node --stack-size=4000 node_modules/eslint/bin/eslint.js . --no-inline-config", "i18n:extract": "tsx packages/cli/bin/run-dev.js i18n extract packages/platform-objects/scripts/i18n-extract.config.ts --locales=zh-CN,ja-JP,es-ES --fill=default --source-hashes --out=packages/platform-objects/src/apps/translations", diff --git a/packages/cli/src/utils/console.not-built.test.ts b/packages/cli/src/utils/console.not-built.test.ts index 5608497b321..f4841024be3 100644 --- a/packages/cli/src/utils/console.not-built.test.ts +++ b/packages/cli/src/utils/console.not-built.test.ts @@ -47,7 +47,7 @@ const NOT_INSTALLED_HEADLINE = 'The ObjectStack Console is not installed, so this server has no Console to serve at /_console/.'; /** The framework repo's remedy. */ -const BUILD_REMEDY = 'pnpm objectui:build'; +const BUILD_REMEDY = 'pnpm console:build'; /** The prefix the terminal warning carries and the HTTP answer does not. */ const WARNING_PREFIX = ' ⚠ '; @@ -84,7 +84,7 @@ function frameworkTree(): { root: string; consoleDir: string } { writeJson(path.join(root, 'package.json'), { name: 'framework-root', private: true, - scripts: { build: 'turbo run build', 'objectui:build': 'bash scripts/build-console.sh' }, + scripts: { build: 'turbo run build', 'console:build': 'turbo run build:dist --filter=@objectstack/console' }, }); return { root, consoleDir: writeUnbuiltConsole(path.join(root, 'packages', 'console')) }; } @@ -124,7 +124,7 @@ describe('a framework checkout with no built Console', () => { request = await serve(createConsoleNotBuiltPlugin(consoleDir)); }); - it.each(CONSOLE_REQUESTS)('GET %s answers 503 naming `pnpm objectui:build`', async (p) => { + it.each(CONSOLE_REQUESTS)('GET %s answers 503 naming `pnpm console:build`', async (p) => { const res = await request(p); expect(res.status).toBe(503); expect(res.headers.get('content-type')).toMatch(/^text\/plain/); diff --git a/packages/cli/src/utils/console.ts b/packages/cli/src/utils/console.ts index 4615747c624..b101ffe1d7c 100644 --- a/packages/cli/src/utils/console.ts +++ b/packages/cli/src/utils/console.ts @@ -307,10 +307,10 @@ export function hasConsoleDist(consolePath: string): boolean { // ─── "No dist" remedy — named for where the server runs ───────────── /** The framework repo's root script that builds `packages/console/dist`. */ -const CONSOLE_BUILD_SCRIPT = 'objectui:build'; +const CONSOLE_BUILD_SCRIPT = 'console:build'; /** - * The directory whose `package.json` declares `objectui:build` for this + * The directory whose `package.json` declares `console:build` for this * console package, or `null`. * * Only the NEAREST `package.json` above `consolePath` is asked. In the @@ -320,8 +320,7 @@ const CONSOLE_BUILD_SCRIPT = 'objectui:build'; * intermediate directories carry none — so a project that merely sits inside * a framework checkout is never told to run a script that does not build the * console it actually resolved. The signal is the script itself rather than - * the repo's layout or its `.objectui-sha` pin, so the remedy is named only - * where it exists. + * the repo's layout, so the remedy is named only where it exists. */ function findConsoleBuildRoot(consolePath: string): string | null { let dir = path.dirname(consolePath); @@ -351,7 +350,7 @@ function findConsoleBuildRoot(consolePath: string): string | null { * Three places reach it, and each gets the remedy that works there: * * - the framework repo, where `packages/console/dist` is a gitignored build - * that `pnpm build` never produces — `pnpm objectui:build`, run at the + * that `pnpm build` never produces — `pnpm console:build`, run at the * root it was found under (it is a root script, so it does not resolve * from the example directory `pnpm dev` runs in); * - anywhere else, where the Console arrives prebuilt as @@ -384,7 +383,7 @@ function describeConsoleDistMissing(consolePath: string | null, hostPaths: boole return ( `Console dist not found at ${path.relative(buildRoot, dist)} — \`pnpm build\` does not produce it. ` + `Build it with \`pnpm ${CONSOLE_BUILD_SCRIPT}\` in ${hostPaths ? buildRoot : 'the root of the ObjectStack checkout this server runs from'} ` + - `(uses a ../objectui checkout if present, else clones objectui at the pinned commit — needs network).` + `(builds ui/apps/console, then copies its dist into packages/console).` ); } return `Console dist not found${hostPaths ? ` at ${dist}` : ''} — ${reinstall}`; diff --git a/packages/cli/src/utils/sdui-manifest.ts b/packages/cli/src/utils/sdui-manifest.ts index 3d12708f32d..1f0e659ad43 100644 --- a/packages/cli/src/utils/sdui-manifest.ts +++ b/packages/cli/src/utils/sdui-manifest.ts @@ -54,8 +54,8 @@ * ## The console leg is reached through `package.json` (#19922) * * The second place looked is the copy `@objectstack/console` ships in its - * `dist/` — objectui's public-tier registry at the pinned commit, copied in by - * `scripts/build-console.sh`. This leg used to ask for that file by its own + * `dist/` — the tracked repo-root `sdui.manifest.json`, copied in by + * `packages/console/build-dist.sh`. This leg used to ask for that file by its own * subpath, which the console's `exports` map does not publish (it publishes * `./package.json` alone): the resolve threw `ERR_PACKAGE_PATH_NOT_EXPORTED`, a * `catch` swallowed it, and a project with no manifest of its own was checked @@ -105,7 +105,7 @@ export const CONSOLE_SDUI_MANIFEST = '@objectstack/console/dist/sdui.manifest.js /** The one subpath the console's `exports` map publishes. */ const CONSOLE_PACKAGE_JSON = '@objectstack/console/package.json'; -/** Where `scripts/build-console.sh` puts the manifest, relative to the console package root. */ +/** Where `packages/console/build-dist.sh` puts the manifest, relative to the console package root. */ const CONSOLE_MANIFEST_IN_PACKAGE = 'dist/sdui.manifest.json'; /** diff --git a/packages/cli/test/console-resolve.test.ts b/packages/cli/test/console-resolve.test.ts index 5218f22d72b..be25dfa10f6 100644 --- a/packages/cli/test/console-resolve.test.ts +++ b/packages/cli/test/console-resolve.test.ts @@ -227,7 +227,7 @@ describe('detectConsoleShaDrift', () => { describe('formatConsoleDistMissingWarning — the remedy named for where the server runs', () => { /** The framework repo's remedy, and the one the warning used to name everywhere. */ - const BUILD_REMEDY = 'pnpm objectui:build'; + const BUILD_REMEDY = 'pnpm console:build'; const RETIRED_REMEDY = '@object-ui/console'; function writeJson(file: string, value: unknown): void { @@ -256,14 +256,14 @@ describe('formatConsoleDistMissingWarning — the remedy named for where the ser name: 'framework-root', private: true, scripts: withScript - ? { build: 'turbo run build', 'objectui:build': 'bash scripts/build-console.sh' } + ? { build: 'turbo run build', 'console:build': 'turbo run build:dist --filter=@objectstack/console' } : { build: 'turbo run build' }, }); const consoleDir = writeUnbuiltConsole(path.join(root, 'packages', 'console')); return { root, consoleDir }; } - it('names `pnpm objectui:build` and the root to run it in, inside the framework repo', () => { + it('names `pnpm console:build` and the root to run it in, inside the framework repo', () => { const { root, consoleDir } = makeFrameworkTree(true); const message = formatConsoleDistMissingWarning(consoleDir); @@ -307,12 +307,12 @@ describe('formatConsoleDistMissingWarning — the remedy named for where the ser it('stops at the consuming project\'s own manifest, even inside a framework checkout', () => { // A project created inside a directory whose manifest declares the script: - // the console it resolved is ITS install, which `objectui:build` does not + // the console it resolved is ITS install, which `console:build` does not // build, so the install remedy is the true one. const outer = tmpRoot('outer'); writeJson(path.join(outer, 'package.json'), { name: 'framework-root', - scripts: { 'objectui:build': 'bash scripts/build-console.sh' }, + scripts: { 'console:build': 'turbo run build:dist --filter=@objectstack/console' }, }); const project = path.join(outer, 'scratch', 'my-app'); const consoleDir = writeUnbuiltConsole( diff --git a/packages/console/package.json b/packages/console/package.json index de4f8fcc33b..c410980761f 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -24,7 +24,7 @@ "./package.json": "./package.json" }, "scripts": { - "build": "bash build-dist.sh", + "build:dist": "bash build-dist.sh", "prepublishOnly": "node -e \"const fs=require('fs');const p='dist/index.html';if(!fs.existsSync(p)){console.error('\\n✗ @objectstack/console: '+p+' is missing — run pnpm console:build at the repo root before publishing.\\n');process.exit(1)}\"" } } diff --git a/scripts/check-changeset-no-major.mjs b/scripts/check-changeset-no-major.mjs index 0eb10f518d3..a0a874da057 100644 --- a/scripts/check-changeset-no-major.mjs +++ b/scripts/check-changeset-no-major.mjs @@ -1348,10 +1348,10 @@ export function publishedSourceOwners(path, manifestOf) { // ── LEG 3: a repo file a package's build COPIES into what it packs (#15204) ─ // // Legs 1 and 2 both read a path UNDER the package. `@objectstack/console` has -// neither: its tracked files are a manifest, a README, a CHANGELOG and a -// `.gitignore`, and everything it publishes is `dist/`, which -// `scripts/build-console.sh` writes. One file in that `dist` comes from THIS -// repository rather than from the objectui build — the repo-root +// neither: its tracked files are a manifest, a README, a CHANGELOG, a +// `.gitignore` and its build script, and everything it publishes is `dist/`, +// which `packages/console/build-dist.sh` writes. One file in that `dist` comes +// from outside the `ui/apps/console` build it copies — the repo-root // `sdui.manifest.json`, copied byte for byte to `dist/sdui.manifest.json`, and // read at runtime by `@objectstack/cli` (the JSX gate of `os validate` / // `compile` / `lint`, and the html-page save gate `serve` registers). So a PR @@ -1372,24 +1372,20 @@ export function publishedSourceOwners(path, manifestOf) { // ⚠️ Residual, stated rather than implied: the reader understands the plain // shell the script is written in — `NAME="value"` assignments, `$NAME` / // `${NAME}` expansion, and `cp [flags] SRC DEST` — and nothing else. A copy -// whose source does not resolve to one fixed repo path (the objectui build's -// own `dist`, under a cache dir named by the pin) is not a tracked file and is -// skipped. A script that is THERE but yields no copy at all is reported in -// `unreadable` rather than read as "copies nothing", and the self-test drives -// this repository's real `build-console.sh` so a respelling of the copy reds in -// lint instead of silently retiring the leg. -// -// ⚠️ And what it does NOT add: `.objectui-sha`. The pin decides which objectui -// tree the console `dist` is BUILT from, but the build does not copy it, and a -// pin bump that moves an accept set moves the manifest too — which is the -// reading this leg makes. -export const PACKED_COPY_BUILD_SCRIPTS = Object.freeze(['scripts/build-console.sh']); +// whose source is a build output rather than a tracked file (the Console +// build's own `ui/apps/console/dist`) names no path a diff can carry, so it +// matches nothing. A script that is THERE but yields no copy at all is reported +// in `unreadable` rather than read as "copies nothing", and the self-test drives +// this repository's real build script so a respelling of the copy reds in lint +// instead of silently retiring the leg. +export const PACKED_COPY_BUILD_SCRIPTS = Object.freeze(['packages/console/build-dist.sh']); // The value a build script assigns to the variable that names the repository -// root: its own directory's parent, which is the repo root because every script -// in `PACKED_COPY_BUILD_SCRIPTS` lives in `scripts/`. Read, not named, so the -// variable's NAME is the script's business. -const SCRIPT_PARENT_DIR_VALUE = /^\$\(cd "\$\(dirname "\$\{BASH_SOURCE\[0\]\}"\)\/\.\." && pwd\)$/; +// root: its own directory climbed by as many `..` as the script's path is deep +// below the root (`scripts/x.sh` one, `packages/console/x.sh` two). Read, not +// named, so the variable's NAME is the script's business; a climb of any other +// depth names some other directory and resolves nothing. +const SCRIPT_ROOT_CLIMB_VALUE = /^\$\(cd "\$\(dirname "\$\{BASH_SOURCE\[0\]\}"\)((?:\/\.\.)+)" && pwd\)$/; const REPO_ROOT_MARK = 'repo-root::'; /** @@ -1403,9 +1399,15 @@ const REPO_ROOT_MARK = 'repo-root::'; * the copy is skipped rather than guessed at. * * @param {string} text a shell script's content + * @param {number} [depth] how many directories below the repo root the script + * lives (`scripts/x.sh` is 1), i.e. the `..` climb that names the root * @returns {{ source: string, destination: string }[]} */ -export function packedCopiesIn(text) { +export function packedCopiesIn(text, depth = 1) { + const namesRoot = (value) => { + const climb = SCRIPT_ROOT_CLIMB_VALUE.exec(value); + return climb !== null && climb[1].length / 3 === depth; + }; const vars = new Map(); const unquote = (word) => (word.length >= 2 && word.startsWith('"') && word.endsWith('"') ? word.slice(1, -1) : word); const expand = (value) => @@ -1422,7 +1424,7 @@ export function packedCopiesIn(text) { const assignment = /^([A-Za-z_][A-Za-z0-9_]*)="(.*)"$/.exec(line); if (assignment) { const [, name, value] = assignment; - vars.set(name, SCRIPT_PARENT_DIR_VALUE.test(value) ? REPO_ROOT_MARK : expand(value)); + vars.set(name, namesRoot(value) ? REPO_ROOT_MARK : expand(value)); continue; } const copy = /^cp(?:\s+-[A-Za-z]+)*\s+("[^"]*"|[^\s"]+)\s+("[^"]*"|[^\s"]+)$/.exec(line); @@ -1558,7 +1560,7 @@ export function packagesTouched({ cwd, from, head }) { for (const script of PACKED_COPY_BUILD_SCRIPTS) { const text = showOrNull(head, script, cwd); if (text === null) continue; - const copies = packedCopiesIn(text); + const copies = packedCopiesIn(text, script.split('/').length - 1); if (copies.length === 0) unreadable.add(script); for (const { source, destination } of copies) copiedTo.set(source, [...(copiedTo.get(source) ?? []), destination]); } @@ -4810,7 +4812,7 @@ function selfTest() { // // `@objectstack/console` has no `src/**` and no `bin`; the one tracked file // its `dist` ships is the repo-root `sdui.manifest.json`, copied there by - // `scripts/build-console.sh`. PR #22812 moved only that file (and spec + // its build script. PR #22812 moved only that file (and spec // citations), graded the console `minor` under a true `Clause-②: yes // (widening)`, and was refused because no leg named the console. battery('THE COPY: a repo file a package build copies into what it packs (#15204)'); @@ -4820,6 +4822,8 @@ function selfTest() { const yesWidening = { value: 'yes', arm: 'widening', protocol: 'added', payload: true, readings: ['declaration line: `Clause-②: yes (widening)`', 'metadata-protocol line: `Metadata-protocol: added`'] }; const declaredNo = { value: 'no', arm: null, protocol: 'none', payload: true, readings: ['declaration line: `Clause-②: no`', 'metadata-protocol line: `Metadata-protocol: none`'] }; const ROOT_LINE = 'FRAMEWORK_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"'; + const BUILD_SCRIPT = PACKED_COPY_BUILD_SCRIPTS[0]; + const BUILD_ROOT_LINE = ROOT_LINE.replace('/..', '/..'.repeat(BUILD_SCRIPT.split('/').length - 1)); const BUILD = [ '#!/usr/bin/env bash', ROOT_LINE, @@ -4846,6 +4850,11 @@ function selfTest() { pairs(BUILD.replace(ROOT_LINE, 'FRAMEWORK_ROOT="/somewhere"')) === '[]', 'control: with no variable holding the script\'s own parent dir there is no repo root to resolve against, so nothing is claimed', ); + assert( + JSON.stringify(packedCopiesIn(BUILD.replace(ROOT_LINE, BUILD_ROOT_LINE), BUILD_SCRIPT.split('/').length - 1)) === MANIFEST_PAIR && + JSON.stringify(packedCopiesIn(BUILD.replace(ROOT_LINE, BUILD_ROOT_LINE), 1)) === '[]', + `the climb names the root only at the script's own depth: ${BUILD_SCRIPT} climbs ${BUILD_SCRIPT.split('/').length - 1}, and the same climb read for a script one level deep names a subdirectory, so nothing is claimed`, + ); assert( pairs(`${ROOT_LINE}\nT="\${FRAMEWORK_ROOT}/packages/a/dist"\nS="\${FRAMEWORK_ROOT}/x.json"\nS="\${FRAMEWORK_ROOT}/y.json"\ncp "$S" "$T/x.json"\n`) === '[{"source":"y.json","destination":"packages/a/dist/x.json"}]', @@ -4856,11 +4865,11 @@ function selfTest() { 'control: a source that keeps an unexpanded `$` is not one fixed path and is skipped rather than guessed at', ); - // ⭐ THE PIN on the real script. The leg reads `build-console.sh`, so the + // ⭐ THE PIN on the real script. The leg reads the console's build script, so the // day it stops copying the manifest (or respells the copy so the reader // cannot see it) this row reds in lint instead of the leg retiring quietly. const realBuild = readFileSync(resolve(REPO_ROOT, PACKED_COPY_BUILD_SCRIPTS[0]), 'utf8'); - const realPairs = packedCopiesIn(realBuild); + const realPairs = packedCopiesIn(realBuild, PACKED_COPY_BUILD_SCRIPTS[0].split('/').length - 1); assert( realPairs.some((p) => p.source === 'sdui.manifest.json' && p.destination === 'packages/console/dist/sdui.manifest.json'), `this repository's ${PACKED_COPY_BUILD_SCRIPTS[0]} copies the repo-root sdui.manifest.json into packages/console/dist/ as read by this leg — got ${JSON.stringify(realPairs)}`, @@ -4882,10 +4891,10 @@ function selfTest() { const consoleJson = JSON.stringify({ name: CONSOLE, version: '0.0.0', files: ['dist', 'README.md', 'CHANGELOG.md'] }); const specJson = JSON.stringify({ name: SPEC, version: '0.0.0', files: ['dist'] }); const changeset = (entries) => `---\n${entries.map(([p, b]) => `"${p}": ${b}`).join('\n')}\n---\n\nbody\n`; - const copyRepo = ({ head, build = BUILD }) => + const copyRepo = ({ head, build = BUILD.replace(ROOT_LINE, BUILD_ROOT_LINE) }) => makeRepo( { - 'scripts/build-console.sh': build, + [BUILD_SCRIPT]: build, 'packages/console/package.json': consoleJson, 'packages/spec/package.json': specJson, 'packages/spec/src/ui/x.ts': '/** cites objectui@aaaa */\nexport const x = 1;\n', @@ -4917,7 +4926,7 @@ function selfTest() { // FIRING control: the same diff with the build script gone is the // pre-leg reading — refused. So the green above is this leg, not a lane // that moved. - const noLeg = levelOf(copyRepo({ head: { ...PR22812, 'scripts/build-console.sh': null } }), yesWidening); + const noLeg = levelOf(copyRepo({ head: { ...PR22812, [BUILD_SCRIPT]: null } }), yesWidening); assert( !noLeg.touched.packages.includes(CONSOLE) && noLeg.result.verdict === 'enforce' && noLeg.exitCode === 1, `FIRING control: without the copy to read, the console is unseen and the same PR is refused, exactly as CI job 114433350057 was — got ${JSON.stringify(noLeg.touched)} / ${noLeg.result.verdict}`, @@ -4962,7 +4971,7 @@ function selfTest() { // "copies nothing"; and an unparseable destination manifest is unreadable. const blind = levelOf(copyRepo({ head: manifestOnly, build: '#!/usr/bin/env bash\ninstall -m 644 sdui.manifest.json packages/console/dist/\n' }), yesWidening); assert( - blind.touched.unreadable.includes('scripts/build-console.sh') && renderLevel(blind.result).stdout.join('\n').includes('scripts/build-console.sh'), + blind.touched.unreadable.includes(BUILD_SCRIPT) && renderLevel(blind.result).stdout.join('\n').includes(BUILD_SCRIPT), `a build script the reader yields no copy from is REPORTED and printed, never silent — got ${JSON.stringify(blind.touched)}`, ); const broken = levelOf(copyRepo({ head: { ...manifestOnly, 'packages/console/package.json': '{ "name": \n' } }), yesWidening); diff --git a/turbo.json b/turbo.json index fe5792620b4..2f127913b70 100644 --- a/turbo.json +++ b/turbo.json @@ -77,8 +77,8 @@ "outputs": ["dist/**"], "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/skills/**"] }, - "@objectstack/console#build": { - "dependsOn": ["^build", "@object-ui/console#build"], + "@objectstack/console#build:dist": { + "dependsOn": ["@object-ui/console#build"], "outputs": ["dist/**"], "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/sdui.manifest.json"] }, From 5c49f1bc62cbb88aa334e473c9e7d7a5361e4f51 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 02:17:59 +0000 Subject: [PATCH 03/12] release(ui-merge): gate self-tests stop reading the deleted pin scripts as live specimens (objectstack#25711, WIP) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_0149BHSXfCdQywGaLdpRAtWe --- scripts/check-empty-changeset.mjs | 9 ++-- scripts/pm/dispatch-gates.mjs | 7 ++-- scripts/pm/dispatch-gates.self-test.mjs | 56 +++++++++++++++---------- 3 files changed, 43 insertions(+), 29 deletions(-) diff --git a/scripts/check-empty-changeset.mjs b/scripts/check-empty-changeset.mjs index c0df1afc923..d3b22da3a81 100644 --- a/scripts/check-empty-changeset.mjs +++ b/scripts/check-empty-changeset.mjs @@ -694,7 +694,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'The second consumer: where THIS SELF-TEST runs (#6509)': 12, 'Parser unit rows': 8, 'THE FIX (#7004): comments and quoted bump values': 12, - 'The family reads one block one way (#7004)': 25, + 'The family reads one block one way (#7004)': 20, // the objectui digest's five cases left with it (objectstack#25711) 'Missing input is a failure, never a pass (#4690)': 1, }); @@ -2085,12 +2085,11 @@ function selfTest() { 'scripts/check-changeset-no-major.mjs', 'scripts/check-empty-changeset.mjs', 'scripts/check-adr-0087-registration.mjs', - 'scripts/objectui-changeset-digest.mjs', ]; const literals = new Map(); for (const rel of FAMILY) { const path = join(REPO_ROOT, rel); - assert(existsSync(path), `family: ${rel} must exist — it is one of the four readers of a changeset frontmatter block`); + assert(existsSync(path), `family: ${rel} must exist — it is one of the family's readers of a changeset frontmatter block`); const src = existsSync(path) ? readFileSync(path, 'utf8') : ''; const found = src.match(/\/\^\\s\*\["'\]\?.*?\/\.exec\(/g) ?? []; // Anti-vacuous-green (#6983): an extraction that stops matching yields @@ -2102,13 +2101,13 @@ function selfTest() { const distinct = new Set(literals.values()); assert( distinct.size === 1, - `family: all four changeset frontmatter parsers must use a byte-identical entry regex — found ${distinct.size} distinct spellings: ${JSON.stringify([...literals])}`, + `family: every changeset frontmatter parser in the family must use a byte-identical entry regex — found ${distinct.size} distinct spellings: ${JSON.stringify([...literals])}`, ); // And the shared spelling must actually be the comment-aware one, so this // block cannot go green on four identically-STALE copies. assert( [...distinct][0]?.includes('(?:\\s+#.*)?'), - 'family: the shared entry regex must carry the `(?:\\s+#.*)?` trailing-comment arm — four identical copies of the OLD anchor would satisfy the agreement check above while re-opening #7004', + 'family: the shared entry regex must carry the `(?:\\s+#.*)?` trailing-comment arm — identical copies of the OLD anchor would satisfy the agreement check above while re-opening #7004', ); for (const rel of FAMILY) { const src = existsSync(join(REPO_ROOT, rel)) ? readFileSync(join(REPO_ROOT, rel), 'utf8') : ''; diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index b2836327f78..94d06ca8917 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -12662,9 +12662,10 @@ function discoverFamiliesPass(tree) { // is refused for a self-test because a module's population describes the // gate's WORK, which a `--self-test` invocation does not perform. A file // the self-test OPENS is the opposite case — the self-test is the run, and - // that read is the run's own input. The live specimen is this card's: - // `scripts/objectui-changeset-digest.mjs` stages its copy inside - // `--self-test`, and skipping self-tests here would close nothing. + // that read is the run's own input. The card's specimen was + // `scripts/objectui-changeset-digest.mjs`, which staged its copy inside + // `--self-test` (gone with the objectui pin, objectstack#25711); skipping + // self-tests here would have closed nothing. for (const target of readProgramTargetsInSource(f, source, (t) => trackedSet.has(t))) { if (entry.reads.includes(target)) continue; entry.reads.push(target); diff --git a/scripts/pm/dispatch-gates.self-test.mjs b/scripts/pm/dispatch-gates.self-test.mjs index 61b7c6843ee..4cd47188947 100644 --- a/scripts/pm/dispatch-gates.self-test.mjs +++ b/scripts/pm/dispatch-gates.self-test.mjs @@ -2799,18 +2799,21 @@ export function selfTest({ tier = 'full' } = {}) { ); // The card's sharpest specimen, both ends. DEPARTURE alone would stay green // on a mask that emptied the file, so the ARRIVAL half names a live shell - // script whose hint is spelled in CODE and must survive. - const liveBumpSrc = readFileSync(nodePath.join(ROOT, 'scripts/bump-objectui.sh'), 'utf8'); - t( - 'LIVE: the file the card measured no longer offers the path its `#` comments merely NAME', - !extractWatchHints(liveBumpSrc, 'scripts/bump-objectui.sh', { tree: hintTree }).includes( - 'docs/releases-maintenance.md', + // script whose hint is spelled in CODE and must survive. The file the card + // measured (`scripts/bump-objectui.sh`) left the tree with the objectui pin + // (objectstack#25711); `with-fleet.sh` is the same shape — its header NAMES + // the relay's op list in `#` comments, and no line of its code reads it. + const liveCommentSrc = readFileSync(nodePath.join(ROOT, 'scripts/pm/with-fleet.sh'), 'utf8'); + t( + 'LIVE: a shell script no longer offers the path its `#` comments merely NAME', + !extractWatchHints(liveCommentSrc, 'scripts/pm/with-fleet.sh', { tree: hintTree }).includes( + 'scripts/pm/fleet-write/ops.mjs', ), ); t( '…non-vacuously: that path IS spelled in the file, inside a `#` comment, and the unmasked control still reads it', - extractWatchHints(liveBumpSrc, 'scripts/bump-objectui.sh.mjs', { tree: hintTree }).includes( - 'docs/releases-maintenance.md', + extractWatchHints(liveCommentSrc, 'scripts/pm/with-fleet.sh.mjs', { tree: hintTree }).includes( + 'scripts/pm/fleet-write/ops.mjs', ), ); // ⭐ LIVE RECOVERY (#16744): the site the card named, both ends. The `.mjs` @@ -8441,22 +8444,33 @@ export function selfTest({ tier = 'full' } = {}) { readEdges.length > 0, ); - // The card's own specimen, end to end and by name. ⛔ Not "some family - // matches": the miss was THIS family scoring `silent` for THIS path. - const digestEntry = liveDiscovery.byCheck.get('check:objectui-changeset'); - const STAGED_GATE = 'scripts/check-adr-0087-registration.mjs'; + // The card's own specimen, end to end. The card's family — + // `check:objectui-changeset`, whose digest staged a copy of the ADR-0087 gate + // — left the tree with the objectui pin (objectstack#25711), so the specimen + // is now every live read edge the family's hints do NOT cover: each must + // reach its family through the program-text key, named by the reader. ⛔ Not + // "some edge exists": the miss was a family scoring `silent` for a path only + // this key can supply, so the case reads exactly those paths, and fails when + // there are none left to read. + const keyOnlyReads = readEdges.filter(([check, r]) => { + const entry = liveDiscovery.byCheck.get(check); + return ( + entry.readEdge?.get(r) === 'program-text' && + !(entry.hints ?? []).some((h) => hintCovers(h, r)) && + !(entry.files ?? []).some((f) => hintCovers(f, r)) + ); + }); t( - `the staged gate reaches the family that runs a copy of it (${coveringKey(digestEntry, STAGED_GATE)?.via ?? 'no key'})`, - coveringKey(digestEntry, STAGED_GATE)?.key === STAGED_GATE && - coveringKey(digestEntry, STAGED_GATE)?.via === 'program text read by scripts/objectui-changeset-digest.mjs', + `the live tree has read edges no watch hint covers, which is why the key is needed (${keyOnlyReads.length})`, + keyOnlyReads.length > 0, ); - // …and green for the RIGHT reason. `extractWatchHints` masks self-tests, so - // the staging literal is not a hint and cannot supply this lead — the case - // above would otherwise pass on a key it is not testing. + const unkeyed = keyOnlyReads.filter(([check, r, by]) => { + const key = coveringKey(liveDiscovery.byCheck.get(check), r); + return key?.key !== r || key?.via !== `program text read by ${by}`; + }); t( - 'and no watch hint of that family covers it, which is why the key was needed', - !(digestEntry.hints ?? []).some((h) => hintCovers(h, STAGED_GATE)) && - !(digestEntry.files ?? []).some((f) => hintCovers(f, STAGED_GATE)), + `each of them reaches its family through the program-text key, naming the reader (${unkeyed.map(([c, r]) => `${c} <- ${r}`).join(' · ') || 'all keyed'})`, + unkeyed.length === 0, ); // Reconstruction: `entry.reads` is what the scan says over the family's own From bb5243481f1429487320d071df825427aa3e8bd9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 02:54:19 +0000 Subject: [PATCH 04/12] release(ui-merge): gates, release notes and docs stop naming the deleted pin pieces (objectstack#25711, WIP) - check-pnpm-filter-targets: the foreign-scope live assertion is tree-aware; without ui/ no committed filter names @object-ui/ (build-console.sh left). - pnpm-filter-targets: the @object-ui/ foreign entry states its new cause and when it retires (the PR that lands ui/). - collect-release-notes: section 3 reads a pinless ref instead of failing on the missing .objectui-sha. - bare-root-worklist: the two check-console-injection UNJUDGED rows are gone with the gate. - check-type-check-coverage: the @objectstack/console EXEMPT note names build-dist.sh. - dispatch-gates self-test: the prerelease-pin-watch literal specimen leaves. - cut-rc builds the Console before the version commit; release.yml and cut-rc comments name console:build; packages/console README describes the in-workspace build. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_0149BHSXfCdQywGaLdpRAtWe --- .github/workflows/cut-rc.yml | 23 +++++++++------- .github/workflows/release.yml | 14 +++++----- packages/console/README.md | 36 ++++++++++++------------- scripts/check-pnpm-filter-targets.mjs | 12 ++++++++- scripts/check-type-check-coverage.mjs | 2 +- scripts/collect-release-notes.sh | 18 ++++++++++--- scripts/pm/bare-root-worklist.mjs | 22 --------------- scripts/pm/dispatch-gates.self-test.mjs | 13 +++++---- scripts/pnpm-filter-targets.mjs | 21 ++++++++------- 9 files changed, 86 insertions(+), 75 deletions(-) diff --git a/.github/workflows/cut-rc.yml b/.github/workflows/cut-rc.yml index 8b7c02cce7c..a398c4a3c00 100644 --- a/.github/workflows/cut-rc.yml +++ b/.github/workflows/cut-rc.yml @@ -33,8 +33,9 @@ name: Cut RC # --------------------------------------------------------- # There is no objectui pin to read. The Console source is `ui/apps/console` in # this repository (@object-ui/console, ruling D1 「同一个版本号组」): the snapshot -# above freezes it together with the spec it builds against, `pnpm run build` -# builds it and `@objectstack/console` copies its dist, and the 39 published +# above freezes it together with the spec it builds against, `pnpm run +# console:build` builds it and copies its dist into `@objectstack/console` +# (packages/console/build-dist.sh), and the 39 published # @object-ui/* packages are members of the one Changesets `fixed` group, so the # version pass below numbers them with everything else. Nothing here reaches # into another repository, and nothing moves a pin, because neither exists. @@ -272,14 +273,18 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - # The whole workspace, both scopes: @object-ui/console builds against this - # snapshot's @objectstack/* (`workspace:^`), and `@objectstack/console#build` - # (root turbo.json) copies its dist with the TRACKED repo-root - # sdui.manifest.json and runs the single-zod canary on what it ships - # (packages/console/build-dist.sh). `pnpm run release` below builds again, - # as a turbo cache hit. + # The whole workspace, both scopes, then the Console: @object-ui/console + # builds against this snapshot's @objectstack/* (`workspace:^`), and + # `@objectstack/console#build:dist` (root turbo.json) copies its dist with + # the TRACKED repo-root sdui.manifest.json and runs the single-zod canary on + # what it ships (packages/console/build-dist.sh). Built HERE, before the + # version commit, so a Console that cannot build stops the cut before + # anything is written; `pnpm run release` below builds both again, as turbo + # cache hits. - name: Build the workspace (both scopes, the Console included) - run: pnpm run build + run: | + pnpm run build + pnpm run console:build # ──────────────────────────────────────────────────────────────────────── # PRE-VERSION GATES. Every one of these reads `.changeset/*`, so all of them diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 65a0cf83830..35dc1b22620 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1840,10 +1840,11 @@ jobs: # # The Console is a workspace package now: `ui/apps/console` # (@object-ui/console) builds against this tree's @objectstack/* through - # `workspace:^`, and `@objectstack/console#build` (root turbo.json) copies - # that dist with the tracked sdui.manifest.json, so the `pnpm run build` - # below builds it with everything else. The pin-currency gate, the - # vendored-dist cache keyed on `.objectui-sha`, its SHA stamp check and + # `workspace:^`, and `@objectstack/console#build:dist` (root turbo.json) + # copies that dist with the tracked sdui.manifest.json; `pnpm run release` + # runs it (`pnpm run console:build`) after the build below. The + # pin-currency gate, the vendored-dist cache keyed on `.objectui-sha`, + # its SHA stamp check and # its spec-injection check are gone with the pin: each answered "was the # Console built from the objectui commit, and against the spec, this # release names", and both are true by construction when the Console @@ -1948,8 +1949,9 @@ jobs: # ────────────────────────────────────────────────────────────────────── # The publish itself. `pnpm run release` = build (both scopes; the turbo - # cache makes it the build above) + scripts/release-publish.sh, which is `changeset publish` followed by ONE - # atomic `git push origin --tags` (#2191: the action's concurrent per-tag + # cache makes it the build above) + console:build (the Console dist into + # packages/console) + scripts/release-publish.sh, which is `changeset + # publish` followed by ONE atomic `git push origin --tags` (#2191: the action's concurrent per-tag # pushes raced GitHub's ref backend and lost ~half the tags). # # changesets/action is NOT used here, and that is deliberate: handed a diff --git a/packages/console/README.md b/packages/console/README.md index f055c414dc5..d7797521b11 100644 --- a/packages/console/README.md +++ b/packages/console/README.md @@ -3,9 +3,9 @@ **Prebuilt Console SPA, version-locked to the framework release that ships it.** This package contains nothing but a prebuilt `dist/` directory: the static -assets of the ObjectStack runtime Console, baked at the commit of -[`objectstack-ai/objectui`](https://github.com/objectstack-ai/objectui) -recorded in [`.objectui-sha`](../../.objectui-sha) of this framework release. +assets of the ObjectStack runtime Console, built from +[`ui/apps/console`](../../ui/apps/console) (`@object-ui/console`) in the same +commit of this repository as the framework release that ships it. You never install it directly. `@objectstack/cli` declares it as a dependency, and both ship at the same version from the Changesets `fixed` @@ -24,8 +24,8 @@ in sync. | | `@object-ui/console` | `@objectstack/console` | |---|---|---| -| Repo | [`objectstack-ai/objectui`](https://github.com/objectstack-ai/objectui) | [`objectstack-ai/objectstack`](https://github.com/objectstack-ai/objectstack) | -| Role | Standalone Console SPA on its own release cadence | Prebuilt SPA frozen at the SHA this framework release was tested against | +| Source | `ui/apps/console` in this repository | a copy of that build's `dist/` | +| Role | The Console SPA, versioned with the framework in the one `fixed` group | Prebuilt SPA shipped with the framework release it was built with | | Use | Cloud overlays, advanced users, anyone consuming Console directly | What every `@objectstack/cli` install gets by default | The framework CLI's `resolveConsolePath()` (in @@ -46,20 +46,20 @@ objectui edits — not a package resolution. Cloud and objectos Docker images overlay their own Console build into `@objectstack/console`'s `dist/` — not into any `@object-ui/console` directory. -## Updating +## Building -1. Run `scripts/bump-objectui.sh` (or `scripts/bump-objectui.sh `) at - the repo root to update `.objectui-sha`. Prefer `pnpm objectui:refresh`, - which bumps *and* rebuilds in one step. -2. Opening the PR runs CI's **Console Pin Gate**, which clones objectui at - the new SHA, builds `@object-ui/console` against this tree's - `@objectstack/client`, and runs `pnpm check:console-sha`. A pin that - cannot build fails the PR rather than the release. -3. CI runs `scripts/build-console.sh` again before publish, which clones - objectui at the pinned SHA, builds `@object-ui/console`, and copies - `dist/` into this package. -4. `pnpm publish` ships it at the same version as every other package in - the Changesets `fixed` group. +1. `pnpm console:build` at the repo root runs turbo's + `@objectstack/console#build:dist`, which first builds `@object-ui/console` + (and its closure) against this tree's `@objectstack/*` through + `workspace:^`, then runs [`build-dist.sh`](./build-dist.sh): it copies + `ui/apps/console/dist` here, puts the tracked repo-root + `sdui.manifest.json` over `dist/sdui.manifest.json`, and fails unless the + bundle carries exactly one zod instance. +2. `pnpm run release` runs it after the workspace build and before + `changeset publish`, so the publish ships it at the same version as every + other package in the Changesets `fixed` group. + +There is no pin to bump: a Console change is an ordinary change under `ui/`. The `dist/` directory is **not** committed — it's a CI publish artifact only. diff --git a/scripts/check-pnpm-filter-targets.mjs b/scripts/check-pnpm-filter-targets.mjs index 5ddd697c96f..a3e2bdaeb02 100644 --- a/scripts/check-pnpm-filter-targets.mjs +++ b/scripts/check-pnpm-filter-targets.mjs @@ -485,7 +485,17 @@ export function selfTest() { ok(`the live tree yields occurrences (found ${live.occurrences.length})`, live.occurrences.length > 50); ok(`and judges a real share of them (judged ${live.judged.length})`, live.judged.length > 30); ok(`and the checked-in tree is clean (dead: ${live.dead.map((d) => d.value).join(', ') || 'none'})`, live.dead.length === 0); - ok('and the foreign scope is present and unjudged, not silently missing', (live.unjudgedByKind.foreign ?? 0) > 0); + // The foreign scope's live population is objectui's own scripts under `ui/`. Its + // first one, scripts/build-console.sh, left with the objectui pin (objectstack#25711), + // so a tree without `ui/` names no `@object-ui/` filter at all and the zero there is the + // reading, not a blind scan; a tree with `ui/` must still show them unjudged. + const holdsUi = existsSync(join(root, 'ui', 'package.json')); + ok( + holdsUi + ? 'and the foreign scope is present and unjudged, not silently missing' + : 'and, with no ui/ in this tree, no committed filter names the foreign scope', + holdsUi ? (live.unjudgedByKind.foreign ?? 0) > 0 : (live.unjudgedByKind.foreign ?? 0) === 0, + ); ok( 'the self-exemption is exactly the three files that own the rule — never a fourth', RULE_OWNING_FILES.length === 3 diff --git a/scripts/check-type-check-coverage.mjs b/scripts/check-type-check-coverage.mjs index c1d3a3e17de..07cc8d01cf3 100644 --- a/scripts/check-type-check-coverage.mjs +++ b/scripts/check-type-check-coverage.mjs @@ -853,7 +853,7 @@ const DEBT = { // nature changes, the RECONCILED direction forces this entry out. const EXEMPT = { '@objectstack/console': - 'Published objectui build artifact -- package.json/README/CHANGELOG plus a dist/ pulled in by `pnpm objectui:refresh`. No TypeScript sources, no tsconfig; the sources are type-checked in the objectui repo.', + 'Published Console build artifact -- package.json/README/CHANGELOG plus a dist/ copied from ui/apps/console by its own build:dist (build-dist.sh, plain bash; objectstack#25711). No TypeScript sources, no tsconfig; the sources are type-checked as @object-ui/console under ui/.', '@objectstack/skills': 'Published skills catalog -- package.json/README/CHANGELOG plus a dist/skills tree copied from the repository root skills/** by its own build (scripts/sync-catalog.mjs, plain node). No TypeScript sources, no tsconfig; the catalog is markdown and JSON, judged by the skill gates over skills/**.', }; diff --git a/scripts/collect-release-notes.sh b/scripts/collect-release-notes.sh index e6944036aa7..937dd5f2aef 100755 --- a/scripts/collect-release-notes.sh +++ b/scripts/collect-release-notes.sh @@ -9,7 +9,8 @@ # 2. The changesets consumed by the release (full text — the best prose for # items that have one). # 3. The objectui (Console UI) commit range, derived from the .objectui-sha -# pin at each ref. +# pin at each ref. After objectstack#25711 no ref carries a pin (the +# Console is built from ui/), and the section says so instead. # 4. The cloud (control plane) commits inside the release's time window. # Cloud is not pinned by the framework (it tracks it via link: deps and # versions independently), so the window between the two refs' commit @@ -428,14 +429,23 @@ fi echo "## 3. Console UI (objectui) — pin range" echo +# The pin is gone from every ref after objectstack#25711: the Console is built +# from this tree's ui/, so its commits are already in section 1. A range across +# that boundary starts at the last pin and ends at the subtree import, and only +# the objectui repository holds the commits between the two. prev_sha=$(git show "${PREV_REF}:.objectui-sha" 2>/dev/null || true) -new_sha=$(git show "${NEW_REF}:.objectui-sha" 2>/dev/null || cat .objectui-sha) +new_sha=$(git show "${NEW_REF}:.objectui-sha" 2>/dev/null || true) echo "- previous pin: \`${prev_sha:-}\`" -echo "- this release: \`${new_sha}\`" +echo "- this release: \`${new_sha:-}\`" echo -if [[ -z "$prev_sha" ]]; then +if [[ -z "$new_sha" ]]; then + echo "_No objectui pin at ${NEW_REF}: the Console is built from ui/ in this tree (objectstack#25711), so its commits are in section 1._" + if [[ -n "$prev_sha" ]]; then + echo "_The last pin was \`${prev_sha}\`: the objectui commits from it up to the ui/ subtree import are not in section 1; read them in objectstack-ai/objectui._" + fi +elif [[ -z "$prev_sha" ]]; then echo "_No .objectui-sha at ${PREV_REF}; cannot compute the range._" elif [[ "$prev_sha" == "$new_sha" ]]; then echo "_Console pin unchanged — no objectui delta in this release._" diff --git a/scripts/pm/bare-root-worklist.mjs b/scripts/pm/bare-root-worklist.mjs index 4f6af7069d4..08e8a1dbe1c 100644 --- a/scripts/pm/bare-root-worklist.mjs +++ b/scripts/pm/bare-root-worklist.mjs @@ -1546,28 +1546,6 @@ export const UNJUDGED = new Map([ + 'at :189-:193 from the `for (const root of ROOTS)` at :462, for every `.md` file under it ' + 'minus the entry\'s own `exclude` list', }], - ['scripts/check-console-injection.mjs distDir packages', { - population: false, - base: '91f65c4ea', - why: 'the word is a `join()` path COMPONENT at check-console-injection.mjs:940 — the gate ' - + 'assembles the console dist directory from four bare single-segment words, so the extractor ' - + 'sees the top-level one and nothing else. It never walks that root: `evaluate` at :247 opens ' - + 'the index file at :252, the stamp beside it at :268, and every JavaScript asset directly ' - + 'inside that directory\'s assets child at :269 — `readBundle` in console-spec-probes.mjs, ' - + 'non-recursive. Surfaced by the #17057 property widening because the component sits inside ' - + 'the `distDir` property of the argv object, which the recogniser now reads', - }], - ['scripts/check-console-injection.mjs specDir packages', { - population: false, - base: '91f65c4ea', - why: 'the same shape one line down, at check-console-injection.mjs:941: a `join()` component of ' - + 'the spec package directory, assembled from bare words. The gate reads that package\'s ' - + 'manifest and the built JavaScript its `exports` map points at — `readSpecBlob` in ' - + 'console-spec-probes.mjs, reached at :348 — never an arbitrary file under the top-level ' - + 'root. ⚠️ Both of this gate\'s real populations are themselves invisible to the derivation, ' - + 'for the DIFFERENT reason that they are assembled rather than spelled; that is a fact this ' - + 'note records and not a verdict on what, if anything, should be declared for them', - }], ['scripts/check-doc-frontmatter.mjs ROOTS docs', { population: false, base: '91f65c4ea', diff --git a/scripts/pm/dispatch-gates.self-test.mjs b/scripts/pm/dispatch-gates.self-test.mjs index 4cd47188947..51f15a42c4f 100644 --- a/scripts/pm/dispatch-gates.self-test.mjs +++ b/scripts/pm/dispatch-gates.self-test.mjs @@ -658,8 +658,10 @@ export function selfTest({ tier = 'full' } = {}) { // sources: `pr-automation.yml` (the `--base` gates), `ci.yml` (the shard // attestation), `engine-split-metric.yml` (`--days 90`), // `required-set-patrol.yml` (a complete argv behind a continuation and a - // redirection), `prerelease-pin-watch.yml` (`--verbose 2>&1`). The live half - // at the end of the block re-reads them from the workflows themselves. + // redirection), `prerelease-pin-watch.yml` (`--verbose 2>&1`; that workflow + // left with the objectui pin, objectstack#25711, and its row stays as a + // fixture of the shape). The live half at the end of the block re-reads the + // others from the workflows themselves. const keyWf = [ 'jobs:', ' gates:', @@ -1002,10 +1004,11 @@ export function selfTest({ tier = 'full' } = {}) { 'scripts/check-adr-0087-registration.mjs', 'scripts/check-changeset-no-major.mjs'] .every((script) => valueBearing.some((i) => i.script === script)), ); + // The card named three; the third, `scripts/check-prerelease-pin-watch.mjs + // --verbose`, left with the objectui pin it watched (objectstack#25711). t( - '⭐ …and the three the card called value-bearing that are really LITERAL render as runnable commands instead', - ['scripts/check-engine-split-ratio.mjs --days 90', 'scripts/check-required-contexts.mjs --verify-required-set', - 'scripts/check-prerelease-pin-watch.mjs --verbose'] + '⭐ …and the two the card called value-bearing that are really LITERAL render as runnable commands instead', + ['scripts/check-engine-split-ratio.mjs --days 90', 'scripts/check-required-contexts.mjs --verify-required-set'] .every((key) => direct.some((i) => i.check === key && i.argvVariables.length === 0)), ); t( diff --git a/scripts/pnpm-filter-targets.mjs b/scripts/pnpm-filter-targets.mjs index 9805893866a..2db591c3efc 100644 --- a/scripts/pnpm-filter-targets.mjs +++ b/scripts/pnpm-filter-targets.mjs @@ -104,17 +104,21 @@ const HERE = dirname(fileURLToPath(import.meta.url)); * * Declared, never inferred: a new foreign scope has to be added here * deliberately, which is the difference between a documented exemption and a - * silent hole. `scripts/build-console.sh` runs its `@object-ui/console` filters - * inside `$BUILD_ROOT` -- a checkout of `objectstack-ai/objectui` at the SHA - * pinned in `.objectui-sha` -- never against this workspace, so resolving those - * against this tree would be asking the wrong root. + * silent hole. The `@object-ui/` entry's first cause is gone: it was + * `scripts/build-console.sh`, which ran its `@object-ui/console` filters inside a + * checkout of `objectstack-ai/objectui` at the SHA pinned in `.objectui-sha`, and + * both left with the pin (objectstack#25711). It stays declared until the objectui + * subtree lands under `ui/` (sub-issue 5 of objectstack#25677): before that a + * filter naming an `@object-ui/` package matches nothing in this tree by design. + * ⛔ Retire the entry in the PR that lands `ui/`, where every such name becomes a + * workspace package this gate can judge. */ export const FOREIGN_SCOPES = [ { scope: '@object-ui/', reason: - 'the objectui repo (objectstack-ai/objectui); scripts/build-console.sh runs these ' - + 'filters inside that checkout, not this workspace', + 'the objectui tree, which this workspace holds under ui/ only once the subtree import ' + + 'of objectstack#25677 lands', }, ]; @@ -451,9 +455,8 @@ export function extractFilters(text) { * THIS workspace naming a package that does not exist. * * The `cd` bail-out is the load-bearing one: a command that changes directory - * may be filtering against another root entirely (`scripts/build-console.sh` - * does exactly that, in the objectui checkout), and this function has no way to - * know which. Unknown root, no verdict. + * may be filtering against another root entirely, and this function has no way + * to know which. Unknown root, no verdict. * * @param {string} command * @param {string} cwd From f3340ffb2ca0c21099ca8aeb6a1db35b593f9afb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 03:25:19 +0000 Subject: [PATCH 05/12] release(ui-merge): the objectui-side half as a patch for the cutover window, and the changeset (objectstack#25711, WIP) scripts/ui-import/4a-ui-side.patch removes objectui's pin on objectstack, the dist-injection Vite probes, objectui's own release lane and its own Changesets config inside ui/. ui/ does not exist on main until the subtree PR of sub-issue 5, so it ships as a patch with its apply and verify commands in the README. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_0149BHSXfCdQywGaLdpRAtWe --- .../25711-console-built-in-workspace.md | 15 + scripts/ui-import/4a-ui-side.patch | 1704 +++++++++++++++++ scripts/ui-import/README.md | 120 ++ 3 files changed, 1839 insertions(+) create mode 100644 .changeset/25711-console-built-in-workspace.md create mode 100644 scripts/ui-import/4a-ui-side.patch create mode 100644 scripts/ui-import/README.md diff --git a/.changeset/25711-console-built-in-workspace.md b/.changeset/25711-console-built-in-workspace.md new file mode 100644 index 00000000000..e54b5919d07 --- /dev/null +++ b/.changeset/25711-console-built-in-workspace.md @@ -0,0 +1,15 @@ +--- +'@objectstack/console': patch +'@objectstack/cli': patch +--- + +build(console): `@objectstack/console` is built in this workspace from `ui/apps/console`, and the Console no longer arrives through a pinned objectui commit + +Clause-②: no +Metadata-protocol: none + +- **The package.** `@objectstack/console` ships the same `dist/` it shipped at 18.1.0: the built Console (`index.html`, `assets/`, the manifests it served). Two files leave the tarball: `dist/.objectui-sha`, which named the objectui commit the Console was built from, and `dist/.objectstack-injection.json`, which recorded the spec and client dists injected into that build. Both described a pin that no longer exists: the Console is built from the `ui/` tree of the same commit as the framework, so its version is the framework's version. +- **The CLI.** When a framework checkout has no built Console, `objectstack serve` / `objectstack dev` and the 503 at `/_console/` now name `pnpm console:build`, which builds `ui/apps/console` and copies its dist into `packages/console`. The remedy used to be `pnpm objectui:build`, which cloned objectui at the pinned commit. +- **One version.** The `@object-ui/*` packages join the `@objectstack/*` fixed group, so every public package of both scopes publishes at one version. + +What to do: nothing as a consumer; install `@objectstack/console` as before. In a framework checkout, run `pnpm console:build` where you ran `pnpm objectui:build`. diff --git a/scripts/ui-import/4a-ui-side.patch b/scripts/ui-import/4a-ui-side.patch new file mode 100644 index 00000000000..4daaa6f7bfa --- /dev/null +++ b/scripts/ui-import/4a-ui-side.patch @@ -0,0 +1,1704 @@ +diff --git a/.gitignore b/.gitignore +index fbb270c19d..3298ccde0c 100644 +--- a/.gitignore ++++ b/.gitignore +@@ -105,11 +105,6 @@ playwright-report + + .objectstack + +-# objectui#13008: `.objectstack-pin/NAME/` tracks one file, the packed package.json +-# of the pinned @objectstack/* build; `pnpm objectstack:build` links the rest beside it. +-/.objectstack-pin/*/* +-!/.objectstack-pin/*/package.json +- + .playwright-mcp + # Live e2e auth state (contains a session token) + artifacts + e2e/live/.auth/ +diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md +index b4f5092b75..7f96e932cd 100644 +--- a/CONTRIBUTING.md ++++ b/CONTRIBUTING.md +@@ -625,11 +625,11 @@ The release process is automated: + **The release PR is identified by its head branch, `changeset-release/main`, never + by its title.** The [Changesets action](https://github.com/changesets/action) + derives that branch from the base branch, so it is a stable convention that holds +-across repositories. The title is not: it is the `title:` input this repository +-passes to the action in +-[`.github/workflows/changeset-release.yml`](.github/workflows/changeset-release.yml), +-it is repository-local configuration, and it differs from repository to +-repository. To locate the current release PR — its number and its title included ++across repositories. The title is not: it is the `title:` input the objectstack ++monorepo's `.github/workflows/release.yml` passes to the action (objectui's own ++`changeset-release.yml` retired when the two scopes joined one release, ++objectstack#25711), it is repository-local configuration, and it differs from ++repository to repository. To locate the current release PR — its number and its title included + — search the head branch: + + ```text +diff --git a/QUICK_REFERENCE.md b/QUICK_REFERENCE.md +index 097d88086c..f518f6d690 100644 +--- a/QUICK_REFERENCE.md ++++ b/QUICK_REFERENCE.md +@@ -126,7 +126,8 @@ this block in the same commit, so a release can no longer leave the block a vers + behind (objectui#5394 — that had happened once per release, three times). + + - **Version:** 18.1.0 (the version every `@object-ui/*` manifest carries — they are one +- `fixed` group in `.changeset/config.json`, so a release moves all of them together) ++ `fixed` group with every `@objectstack/*` package in the monorepo's ++ `../.changeset/config.json`, so a release moves all of them together) + - **Spec:** `@objectstack/spec` ^18.0.0-next.1 (declared by the root `package.json` and by + `apps/console/package.json`) + - **Client:** `@objectstack/client` ^18.0.0-next.1 (declared by `apps/console/package.json` +diff --git a/apps/console/vite.config.ts b/apps/console/vite.config.ts +index 6800efa897..de0b876d06 100644 +--- a/apps/console/vite.config.ts ++++ b/apps/console/vite.config.ts +@@ -17,12 +17,7 @@ import { execFileSync } from 'node:child_process'; + // `native` becomes the default loader (objectui#3384). + import { viteCryptoStub } from '../../scripts/vite-crypto-stub.ts'; + import { viteMaplibreWorker } from '../../scripts/vite-maplibre-worker.ts'; +-import { resolveClientDistInjection, widenVendorChunkTestForClient } from '../../scripts/vite-objectstack-client-dist.ts'; +-import { +- assertSingleZodInstance, +- formatConditionReport, +- resolveSpecDistInjection, +-} from '../../scripts/vite-objectstack-spec-dist.ts'; ++import { assertSingleZodInstance } from '../../scripts/vite-single-zod.ts'; + import { viteIneffectiveDynamicImports } from '../../scripts/vite-ineffective-dynamic-imports.ts'; + import { frameworkPackagesDir, workspaceChunkTests } from '../../scripts/vite-objectstack-workspace.ts'; + import { viteDeclaredLazyViews } from '../../scripts/vite-declared-lazy-views.ts'; +@@ -85,26 +80,20 @@ function preloadCriticalChunks(): Plugin { + * closure walk has proven it can see the very chunk the linter used to hide in. + * + * That makes both module-id tests SUBJECTS of the check, not decoration, and +- * neither may be hardcoded here. `OBJECTSTACK_SPEC_DIST` rewrites all 18 +- * `@objectstack/spec` specifiers to absolute paths in the overriding tree — +- * `/…/objectstack/packages/spec/dist/…/index.mjs`, ids with no +- * `@objectstack` segment at all — so a private `/@objectstack[\\/+]spec/` +- * matched zero modules, this counter-probe correctly refused a verdict, and it +- * took every injected build down with it (objectui#5388; measured from the +- * consumer side in objectstack#10136, where it blocked the framework's Console +- * Pin Gate). The spec test is therefore a PARAMETER, fed from the same +- * `resolveSpecDistInjection` output the `vendor-objectstack` group already +- * reads: one producer, both consumers, no second opinion about where the spec +- * lives. ++ * neither may be hardcoded here. A spec reached through a path with no ++ * `@objectstack` segment — the monorepo's own `packages/spec/dist/…` — made a ++ * private `/@objectstack[\\/+]spec/` match zero modules, so this counter-probe ++ * correctly refused a verdict and took the build down with it (objectui#5388). ++ * The spec test is therefore a PARAMETER, fed from the same ++ * `workspaceChunkTests` producer the `vendor-objectstack` group reads: one ++ * producer, both consumers, no second opinion about where the spec lives. + * +- * `LINT` stays literal — nothing injects `@objectstack/lint` today — but it gets +- * its own counter-probe for the same reason, because its failure mode is the +- * worse one. The spec test going blind fails LOUD (this build stops). The lint +- * test going blind fails SILENT: the assertion below is negative, so zero +- * matches reads exactly like a clean bundle and the guard is green forever. +- * objectstack#9659 proposes injecting four more `@objectstack/*` packages the +- * same way and lint is a plausible member; if it ever joins, this build says so +- * instead of quietly ceasing to guard anything. ++ * `LINT` gets its own counter-probe for the same reason, because its failure ++ * mode is the worse one. The spec test going blind fails LOUD (this build ++ * stops). The lint test going blind fails SILENT: the assertion below is ++ * negative, so zero matches reads exactly like a clean bundle and the guard is ++ * green forever; if the linter's module ids ever change shape, this build says ++ * so instead of quietly ceasing to guard anything. + */ + function assertLazyLinterStaysLazy(specTest: RegExp): Plugin { + const LINT = WORKSPACE_CHUNK_TESTS.lint; +@@ -144,9 +133,6 @@ function assertLazyLinterStaysLazy(specTest: RegExp): Plugin { + `from the app entry, so it must be in the eager closure — its absence means ` + + `this walk is reading the graph wrongly, not that the bundle improved. ` + + `Fix the walk before trusting the linter assertion below. ` + +- `If \`OBJECTSTACK_SPEC_DIST\` is set, check that this test carries the ` + +- `override's location: an injected spec resolves outside node_modules and the ` + +- `baseline test cannot see it (objectui#5388). ` + + `(spec test: ${specTest}; ` + + `entry chunks: ${[...chunks.values()].filter((c) => c.isEntry).map((c) => c.fileName).join(', ') || 'NONE'}; ` + + `eager chunks: ${eager.size}/${chunks.size})`, +@@ -168,10 +154,8 @@ function assertLazyLinterStaysLazy(specTest: RegExp): Plugin { + `this bundle and the assertion below can no longer fail. Two ways to get here. ` + + `Either the lazy import in app-shell's \`capabilityLint.ts\` is gone, in which case ` + + `retire this plugin deliberately rather than leaving a guard with no subject; or ` + +- `the linter's module ids changed shape — e.g. \`@objectstack/lint\` joined the ` + +- `\`OBJECTSTACK_SPEC_DIST\`-style injection proposed in objectstack#9659, which ` + +- `rewrites specifiers to absolute paths with no \`@objectstack\` segment. In that ` + +- `case give this test the injection's location, exactly as the spec test above is ` + ++ `the linter's module ids changed shape (a path with no \`@objectstack\` segment). ` + ++ `In that case give this test the new location, exactly as the spec test above is ` + + `already parameterised (objectui#5388). (chunks: ${chunks.size})`, + ); + } +@@ -327,10 +311,8 @@ function emitEagerClosureReport(reportFileName = 'eager-closure.json'): Plugin { + `Fix the import rather than skipping the member here: a bare specifier the browser ` + + `cannot load is a broken bundle, not a measurement gap, and dropping it from the ` + + `walk would make this report under-count — the one direction the counter-probes ` + +- `above exist to refuse. The known source is an out-of-tree override whose own ` + +- `dependencies do not resolve from where it lives: check \`OBJECTSTACK_CLIENT_DIST\` ` + +- `and \`OBJECTSTACK_SPEC_DIST\`, and vite's own "could not be resolved" warnings ` + +- `earlier in this build.`, ++ `above exist to refuse. Read vite's own "could not be resolved" warnings earlier ` + ++ `in this build.`, + ); + } + +@@ -671,31 +653,6 @@ const workspaceAliases: Record = { + '@object-ui/plugin-designer': path.resolve(import.meta.dirname, '../../packages/plugin-designer/src'), + }; + +-// Opt-in override of the installed `@objectstack/client`. The published client +-// (11.2.0) predates the async import-job API (`data.createImportJob` et al.), +-// so to exercise the full background-import + undo flow through the real +-// console before that client ships, point OBJECTSTACK_CLIENT_DIST at a locally +-// built client (its dist entry or package dir). Inert when unset — production +-// and CI builds use the installed client unchanged. +-// +-// No longer a bare string alias: the value is VALIDATED before it is aliased — +-// the path must exist, it must sit inside a `@objectstack/client` package +-// (directory, `dist/`, or an entry file all resolve to the same package), and +-// that package's own declared `dependencies` must resolve from where it lives. +-// Without that last check an out-of-tree override produced a fully written +-// bundle whose bare specifiers no browser can load, and nothing in the build +-// output named this variable — objectui#6094, the client twin of the spec +-// hook's objectui#5391. See the module for the measurement and for why the +-// check is re-stated there rather than shared with the spec hook. +-const clientDistInjection = resolveClientDistInjection(process.env.OBJECTSTACK_CLIENT_DIST); +-if (clientDistInjection) workspaceAliases['@objectstack/client'] = clientDistInjection.aliasTarget; +- +-// Extra dirs the dev server may read the override from — it lives outside the +-// workspace root, so Vite's default `server.fs.allow` would 403 it (blank page). +-// Unchanged values: the containing package (…/dist/index.mjs → …/) and its +-// parent, now computed beside the validation that vouches for the path. +-const clientFsAllow: string[] = clientDistInjection ? clientDistInjection.fsAllow : []; +- + // Deps pre-bundled for the dev server. Build-time pre-bundling was removed in + // Vite 5.1, so this list is read by `pnpm dev` only, never by `vite build` — + // which is why no build or E2E job can see a bad entry here. +@@ -707,7 +664,7 @@ const clientFsAllow: string[] = clientDistInjection ? clientDistInjection.fsAllo + // package this app does not depend on is skipped with a warning and pre-bundles + // nothing: `maplibre-gl` was one, and needs no entry of its own, because the + // `react-map-gl/maplibre` pre-bundle already carries it. The console-config +-// cases in `scripts/__tests__/vite-objectstack-spec-dist.test.ts` resolve every ++// case in `scripts/__tests__/console-optimize-deps.test.ts` resolves every + // entry the way the dev server does. + const OPTIMIZE_DEPS_INCLUDE = [ + '@objectstack/spec', +@@ -763,81 +720,6 @@ const VENDOR_OBJECTSTACK_TEST = WORKSPACE_CHUNK_TESTS.vendor; + // plus the monorepo's workspace path (see `WORKSPACE_CHUNK_TESTS` above). + const SPEC_MODULE_TEST = WORKSPACE_CHUNK_TESTS.spec; + +-// Opt-in override of the installed `@objectstack/spec` — the spec twin of +-// OBJECTSTACK_CLIENT_DIST above, so a framework build can bundle the console +-// against its OWN spec instead of the last published one (objectui#4854, ruled +-// on objectstack#8134). Point it at a built spec package (its directory, its +-// `dist/`, or an entry file inside it). +-// +-// It is NOT a copy of the client line: `@objectstack/spec` publishes an 18-entry +-// exports map that redirects every subpath into `dist/`, and a Vite string alias +-// does not consult exports maps. So the injection is derived from the OVERRIDE's +-// own exports map, one alias per entry — see the module for the derivation and +-// for why every failure mode throws instead of falling back. +-// +-// Inert when unset: `null` here leaves the alias table, the pre-bundle list, the +-// vendor chunk test and the dev server's fs allow-list at their baseline values. +-// +-// The injected spec also arrives with its OWN install tree, so left alone its +-// bare `zod` import resolves to the framework's zod and the bundle carries two +-// instances — at the console pin objectui#11327 was reported against, the +-// Studio's spec-derived forms crashed in `z.toJSONSchema` over them. The +-// injection therefore pins every `zod` import to ONE +-// copy: the one `CONSOLE_ZOD_ANCHOR` resolves. That package is the anchor +-// because it is where the console walks spec schemas with its own zod (the +-// metadata-admin `*-schema.ts` modules) and it declares `zod` itself; the +-// injection refuses the build when that copy is outside the range the +-// injected spec declares. +-const CONSOLE_ZOD_ANCHOR = path.resolve(import.meta.dirname, '../../packages/app-shell'); +-const specDistInjection = resolveSpecDistInjection(process.env.OBJECTSTACK_SPEC_DIST, { +- vendorChunkTest: VENDOR_OBJECTSTACK_TEST, +- specModuleTest: SPEC_MODULE_TEST, +- consoleZodFrom: CONSOLE_ZOD_ANCHOR, +-}); +-if (specDistInjection) Object.assign(workspaceAliases, specDistInjection.aliases); +- +-// objectui#9408 — say which condition arm every entry came from, out loud. +-// +-// The resolver used to walk its OWN preference array instead of the exports +-// map's key order, so the five `browser`-first entries silently resolved to the +-// Node arm and the arm added to keep a browser build off `require('fs')` was +-// unreachable. Nothing in the build said so. The fix makes the pick correct; the +-// table makes it CHECKABLE — a wrong arm is now one grep in the build log +-// instead of an unattributable bundler error at some later pin bump. +-// +-// Printed only when the override is live, so a normal build stays quiet, and to +-// stderr so it cannot land in anything that parses stdout. +-if (specDistInjection) { +- for (const line of formatConditionReport(specDistInjection)) console.error(line); +-} +- +-const specFsAllow: string[] = specDistInjection ? specDistInjection.fsAllow : []; +- +-// Pre-bundling an ALIASED, out-of-workspace dep is opt-in through this list and +-// nothing else: Vite's pre-alias plugin only registers such a resolution as a +-// dep when `optimizeDeps.include` names the specifier. Keeping the four spec +-// entries while the override is live would therefore park the injected spec in +-// `node_modules/.vite`, whose cache key does not move when the framework +-// rebuilds its spec in place — a stale pre-bundle serving yesterday's schema is +-// the same silent skew this hook exists to end. Dropping them costs a colder +-// dev start and nothing else; `vite build` never reads this list. +-const optimizeDepsInclude = specDistInjection +- ? OPTIMIZE_DEPS_INCLUDE.filter((specifier) => !Object.hasOwn(specDistInjection.aliases, specifier)) +- : OPTIMIZE_DEPS_INCLUDE; +- +-// An injected spec resolves OUTSIDE node_modules, so the baseline test above +-// stops matching it and the biggest vendor surface in the bundle (spec is +-// imported by 29 packages here) would scatter into its importers' chunks. The +-// injected build should differ from a released one in spec CONTENT, not in +-// chunk layout, so the override's location joins the group's test. The same +-// holds for an injected CLIENT, and there the layout is not cosmetic: left out +-// of this group it lands in `framework`, closing a framework ↔ data-adapter +-// import cycle that kills the console at boot (see the helper's docblock). +-const vendorObjectstackTest = widenVendorChunkTestForClient( +- specDistInjection ? specDistInjection.vendorChunkTest : VENDOR_OBJECTSTACK_TEST, +- clientDistInjection, +-); +- + // A lucide icon module, captured by its file name (`circle-arrow-up`). Read by + // the `vendor-icons-shell` group below. + const LUCIDE_ICON_MODULE = /[\\/]node_modules[\\/]lucide-react[\\/]dist[\\/]esm[\\/]icons[\\/]([a-z0-9-]+)\.mjs$/; +@@ -851,16 +733,6 @@ const LAZY_ONLY_INITIAL_ICONS: ReadonlySet = new Set([ + 'webhook', + ]); + +-// The chunk grouping is not the only consumer of "where does the spec live". +-// `assertLazyLinterStaysLazy`'s counter-probe asks the same question about the +-// emitted module ids, and it used to answer it from a private regex that the +-// injection made unmatchable — so every build with the override set died on a +-// counter-probe that was right about what it saw (objectui#5388). It reads the +-// override's location here, from the same producer, for the same reason. +-const specModuleTest = specDistInjection +- ? specDistInjection.specModuleTest +- : SPEC_MODULE_TEST; +- + // https://vitejs.dev/config/ + export default defineConfig({ + base: basePath, +@@ -879,15 +751,11 @@ export default defineConfig({ + // Fails the build if the lazily-imported linter is folded back into an + // eagerly-loaded chunk. Runs on CI/Vercel too — it costs microseconds and + // the regression it catches is invisible in every other signal. +- assertLazyLinterStaysLazy(specModuleTest), +- // Under OBJECTSTACK_SPEC_DIST: resolve every bare `zod` import — the injected +- // spec's included — to the console's one copy (objectui#11327). Registered +- // only while the override is live, like every other injection surface. +- ...(specDistInjection ? [specDistInjection.singleZodPlugin] : []), +- // …and in EVERY build, injected or not: fail when the emitted chunks carry +- // more than one zod instance, or none at all (the counter-probe). Measures +- // the outcome rather than trusting the redirect above, because the first +- // mechanism tried for this (`resolve.dedupe`, see below) was a silent no-op. ++ assertLazyLinterStaysLazy(SPEC_MODULE_TEST), ++ // In EVERY build: fail when the emitted chunks carry more than one zod ++ // instance, or none at all (the counter-probe), objectui#11327. Measures the ++ // outcome rather than trusting a mechanism, because the first mechanism ++ // tried for this (`resolve.dedupe`, see below) was a silent no-op. + assertSingleZodInstance(), + // The same refusal one directory over: fails the build if the + // `@object-ui/types/zod` validators rejoin the eager closure, AND if they +@@ -977,14 +845,13 @@ export default defineConfig({ + // ⛔ `zod` is deliberately NOT listed. Dedupe resolves a listed package from + // the Vite root, and this root (`apps/console`) declares no `zod`: the + // lookup finds nothing and Vite falls back to each importer's own copy +- // without a warning. Measured on objectui#11327 with OBJECTSTACK_SPEC_DIST +- // set — adding `zod` here emitted the same two zod copies, byte-identical. +- // The one-zod rule is held by `specDistInjection.singleZodPlugin` and +- // checked by `assertSingleZodInstance` in the plugin list above. ++ // without a warning. Measured on objectui#11327 — adding `zod` here emitted ++ // the same two zod copies, byte-identical. The one-zod rule is checked by ++ // `assertSingleZodInstance` in the plugin list above. + dedupe: ['react', 'react-dom', 'sonner'], + }, + optimizeDeps: { +- include: optimizeDepsInclude ++ include: OPTIMIZE_DEPS_INCLUDE + }, + build: { + target: 'esnext', +@@ -1082,7 +949,7 @@ export default defineConfig({ + // diagnostic. Dropping the tag puts the four modules back on every + // page load, and the lowered ceiling in + // `scripts/check-eager-closure-budget.mjs` is what reds on it. +- { name: 'vendor-objectstack', test: vendorObjectstackTest, priority: 95, tags: ['$initial'] }, ++ { name: 'vendor-objectstack', test: VENDOR_OBJECTSTACK_TEST, priority: 95, tags: ['$initial'] }, + { name: 'vendor-icons-core', test: /[\\/]node_modules[\\/]lucide-react[\\/]dist[\\/](lucide-react|esm[\\/](Icon|createLucideIcon|defaultAttributes|shared))/, priority: 90 }, + // + // ## The icons the eager shell imports by name share ONE chunk (objectui#12187) +@@ -1615,13 +1482,6 @@ export default defineConfig({ + }, + server: { + port: 5180, +- // Widen the fs allow-list only when an out-of-tree override is set (see +- // OBJECTSTACK_CLIENT_DIST / OBJECTSTACK_SPEC_DIST above); otherwise keep +- // Vite's defaults. Both overrides live outside the workspace root, which +- // Vite's default `fs.allow` serves as a 403 (blank page, no build error). +- ...(clientFsAllow.length || specFsAllow.length +- ? { fs: { allow: [path.resolve(import.meta.dirname, '../..'), ...clientFsAllow, ...specFsAllow] } } +- : {}), + proxy: { + '/api': { target: process.env.DEV_PROXY_TARGET || 'http://localhost:3000', changeOrigin: true }, + }, +diff --git a/eslint.config.js b/eslint.config.js +index 8861e1cb5f..b8bea1bbed 100644 +--- a/eslint.config.js ++++ b/eslint.config.js +@@ -54,12 +54,6 @@ export default tseslint.config({ + 'apps/console/plugin.js', + 'apps/console/plugin.d.ts', + 'apps/site/next-env.d.ts', +- // objectui#13008 — the pinned @objectstack/* build, linked by +- // `scripts/objectstack-pin.mjs build` beside the committed manifests and +- // git-ignored (`.gitignore`, "/.objectstack-pin/*/*"); the spec packs its +- // TypeScript `src/`. The one tracked file per package is a `package.json`, +- // which no rule here lints. +- '.objectstack-pin', + ], + }, { + // objectui#4853 — a stale `eslint-disable` is an ERROR, not a warning. +diff --git a/package.json b/package.json +index e722c7596d..cf67fd952e 100644 +--- a/package.json ++++ b/package.json +@@ -16,9 +16,7 @@ + "docs" + ], + "scripts": { +- "setup": "node scripts/objectstack-pin.mjs build && pnpm install --frozen-lockfile", +- "objectstack:build": "node scripts/objectstack-pin.mjs build", +- "objectstack:status": "node scripts/objectstack-pin.mjs status", ++ "setup": "pnpm install --frozen-lockfile", + "dev": "pnpm --filter @object-ui/console dev", + "start": "pnpm --filter @object-ui/console preview", + "test": "vitest run", +@@ -60,8 +58,6 @@ + "check:action-ref-convention": "node scripts/check-action-ref-convention.mjs", + "check:published-dist": "node scripts/check-published-dist-tooling.mjs", + "check:published-tsconfig-exclude": "node scripts/check-published-tsconfig-tooling-exclude.mjs", +- "check:spec-floors": "node scripts/check-spec-range-floors.mjs", +- "check:installed-pin-claims": "node scripts/check-installed-spec-pin-claims.mjs", + "check:i18n-keys": "node scripts/check-i18n-call-site-keys.mjs", + "check:i18n-drift": "node scripts/check-i18n-en-drift.mjs", + "check:i18n-dead-keys": "node scripts/check-i18n-dead-keys.mjs", +@@ -129,7 +125,7 @@ + "changeset:version": "changeset version && node scripts/sync-quick-reference-release.mjs", + "quick-reference:sync": "node scripts/sync-quick-reference-release.mjs", + "quick-reference:check": "node scripts/sync-quick-reference-release.mjs --check", +- "changeset:publish": "node scripts/objectstack-floor.mjs && node scripts/check-published-dist-tooling.mjs && node scripts/check-spec-range-floors.mjs && changeset publish", ++ "changeset:publish": "node scripts/check-published-dist-tooling.mjs && changeset publish", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:live": "playwright test --config=playwright.live.config.ts", +diff --git a/scripts/__tests__/check-changeset-fixed.test.ts b/scripts/__tests__/check-changeset-fixed.test.ts +index 58904d0be8..045bf3070d 100644 +--- a/scripts/__tests__/check-changeset-fixed.test.ts ++++ b/scripts/__tests__/check-changeset-fixed.test.ts +@@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url'; + // `tsconfig.scripts.json` (`allowJs`), so no `@ts-expect-error` here — + // re-adding one is now itself an error (TS2578). See objectui#3494. + import { analyze, globMatcher, normalizePrivatePackages } from '../check-changeset-fixed.mjs'; ++import { changesetConfigPath } from '../workspace-root.mjs'; + + /** + * `scripts/check-changeset-fixed.mjs` used to answer one question — is every +@@ -124,11 +125,27 @@ describe('analyze', () => { + ]); + expect(result.unclassified.map((p) => p.name)).toEqual(['@object-ui/unlisted']); + }); ++ ++ it('classifies a private package outside `fixed` only while privatePackages.version is true', () => { ++ // The monorepo shape (objectstack#25711): the root `fixed` group is public ++ // packages only, and the extension is versioned on its own. ++ const shape = [pkg('@object-ui/core'), pkg('object-ui', true)]; ++ const fixed = [['@object-ui/core']]; ++ expect(analyze({ fixed, privatePackages: { version: true, tag: false } }, shape).unclassified).toEqual([]); ++ expect( ++ analyze({ fixed, privatePackages: { version: false, tag: false } }, shape).unclassified.map((p) => p.name) ++ ).toEqual(['object-ui']); ++ // A PUBLIC package outside `fixed` is still unclassified, whatever the key says. ++ expect( ++ analyze({ fixed, privatePackages: { version: true, tag: false } }, [...shape, pkg('@object-ui/loose')]) ++ .unclassified.map((p) => p.name) ++ ).toEqual(['@object-ui/loose']); ++ }); + }); + + describe('the repository itself', () => { + const config = JSON.parse( +- fs.readFileSync(path.join(repoRoot, '.changeset/config.json'), 'utf8') ++ fs.readFileSync(changesetConfigPath(repoRoot), 'utf8') + ) as { fixed?: string[][]; ignore?: string[]; privatePackages?: unknown }; + + const manifests = ['packages', 'apps'].flatMap((dir) => +@@ -164,13 +181,14 @@ describe('the repository itself', () => { + ).toEqual([]); + }); + +- it('still keeps the private VS Code extension inside the fixed group', () => { +- // The shape the two rules above exist for. If this ever stops being true the +- // guard is not wrong, but it is no longer guarding anything real — and the +- // rationale in both files should be re-read before it is trimmed. ++ it('versions the private VS Code extension on its own, outside the fixed group (objectstack#25711)', () => { ++ // The monorepo's one config holds only public packages in `fixed`; the ++ // extension is versioned by `privatePackages.version: true` instead. If it ++ // ever rejoins the group, rule 3 above is what guards it again. + const vscode = manifests.find((m) => m.path === 'packages/vscode-extension/package.json'); + expect(vscode?.name).toBe('object-ui'); + expect(vscode?.private).toBe(true); +- expect((config.fixed ?? []).flat()).toContain('object-ui'); ++ expect((config.fixed ?? []).flat()).not.toContain('object-ui'); ++ expect(normalizePrivatePackages(config.privatePackages)?.version).toBe(true); + }); + }); +diff --git a/scripts/__tests__/check-lockfile-dedupe.test.ts b/scripts/__tests__/check-lockfile-dedupe.test.ts +index c81bfcb92f..4c83ff8bb0 100644 +--- a/scripts/__tests__/check-lockfile-dedupe.test.ts ++++ b/scripts/__tests__/check-lockfile-dedupe.test.ts +@@ -114,9 +114,7 @@ import { fileURLToPath } from 'node:url'; + + import { describe, expect, it } from 'vitest'; + +-import { DEDUPE_SENTINEL, pinDeclared, pinnedBuildMissing } from '../check-lockfile-dedupe.mjs'; +-import { CACHE_DIR, OVERRIDE_PREFIX, STAMP_FILE } from '../objectstack-pin.mjs'; +-import { isNestedWorkspace } from '../workspace-root.mjs'; ++import { DEDUPE_SENTINEL } from '../check-lockfile-dedupe.mjs'; + import { FLOORS, TEST_FILE } from '../check-test-path-roots.mjs'; + import { NOT_A_GATE, OPTIONAL_CONTEXTS, REQUIRED_CONTEXTS } from '../dependabot-merge-gate.mjs'; + import { maskComments } from '../js-comment-mask.mjs'; +@@ -329,43 +327,6 @@ function runChecker(stub: { stdout?: string; stderr?: string; exit: number }, ar + } + } + +-describe('the pinned @objectstack/* build is the checker\'s precondition (objectui#12957)', () => { +- // `pnpm dedupe --check` re-reads the `file:` directories every `@objectstack/*` +- // package resolves to; without them pnpm stops before judging anything. The +- // checker reads the build's stamp first and names that cause. +- it('reads a root with no build as missing, and the same root with a stamp as present', () => { +- const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lockfile-dedupe-pin-')); +- try { +- expect(pinnedBuildMissing(root)).toBe(true); +- fs.mkdirSync(path.join(root, CACHE_DIR), { recursive: true }); +- fs.writeFileSync(path.join(root, CACHE_DIR, STAMP_FILE), '{}'); +- expect(pinnedBuildMissing(root)).toBe(false); +- } finally { +- fs.rmSync(root, { recursive: true, force: true }); +- } +- }); +- +- it('applies only where the manifest pnpm reads overrides from declares the pin (objectstack#25723)', () => { +- // Standalone the root manifest declares `@objectstack/*: file:.objectstack-pin/...` +- // and the stamp is a precondition; under the monorepo nothing does, the +- // `@objectstack/*` packages are workspace members, and there is no build +- // whose absence could send `pnpm dedupe --check` to the registry. +- expect(pinDeclared({ pnpm: { overrides: { '@objectstack/spec': `${OVERRIDE_PREFIX}spec`, react: '19.3.0' } } })).toBe(true); +- expect(pinDeclared({ pnpm: { overrides: { react: '19.3.0' } } })).toBe(false); +- expect(pinDeclared({ pnpm: {} })).toBe(false); +- expect(pinDeclared({})).toBe(false); +- expect(pinDeclared()).toBe(!isNestedWorkspace()); +- }); +- +- it('checks before it spawns pnpm, and says so in a could-not-run verdict', () => { +- const source = fs.readFileSync(path.join(repoRoot, SCRIPT), 'utf8'); +- const guard = source.indexOf('if (pinDeclared() && pinnedBuildMissing())'); +- expect(guard, 'the precondition is gone from main()').toBeGreaterThan(0); +- expect(guard).toBeLessThan(source.indexOf("spawnSync('pnpm', ['dedupe', '--check']")); +- expect(source.slice(guard, source.indexOf("spawnSync('pnpm', ['dedupe', '--check']"))).toContain("outcome: 'cannot-run'"); +- }); +-}); +- + describe('the lockfile-dedupe checker', () => { + it('ships its own self-test, and it passes as shipped', () => { + const run = spawnSync('node', [SCRIPT, '--self-test'], { cwd: repoRoot, encoding: 'utf8' }); +diff --git a/scripts/__tests__/check-unused-dependencies.test.ts b/scripts/__tests__/check-unused-dependencies.test.ts +index aa2d460bef..f8684361a0 100644 +--- a/scripts/__tests__/check-unused-dependencies.test.ts ++++ b/scripts/__tests__/check-unused-dependencies.test.ts +@@ -401,14 +401,13 @@ describe('this repository', () => { + expect(findings).toEqual([]); + }); + +- it('allows exactly the four rows the gate landed with, each still verifying', () => { ++ it('allows exactly the two rows left since the extension left the release group, each still verifying', () => { + expect( + Object.fromEntries( + Object.entries(DECLARED_WITHOUT_IMPORT).map(([pkg, rows]) => [pkg, Object.keys(rows).sort()]), + ), + ).toEqual({ + '@object-ui/cli': ['@object-ui/components', '@object-ui/react'], +- 'object-ui': ['@object-ui/core', '@object-ui/types'], + }); + }); + +diff --git a/scripts/__tests__/console-lazy-linter.test.ts b/scripts/__tests__/console-lazy-linter.test.ts +new file mode 100644 +index 0000000000..8a65e29615 +--- /dev/null ++++ b/scripts/__tests__/console-lazy-linter.test.ts +@@ -0,0 +1,151 @@ ++import { describe, expect, it } from 'vitest'; ++ ++/** ++ * The console config's `assert-lazy-linter-stays-lazy` guard (objectui#5266, ++ * objectui#5323): the lazily imported `@objectstack/lint` must stay out of every ++ * eagerly loaded chunk, and the guard's counter-probe refuses a verdict when it ++ * cannot see the spec chunk it measures against. ++ * ++ * Moved here from `vite-objectstack-spec-dist.test.ts` when that module's ++ * `OBJECTSTACK_SPEC_DIST` injection left with the objectstack pin ++ * (objectstack#25711). The baseline cases are kept as they were; the cases that ++ * pointed the override at an out-of-tree spec left with it. The workspace ++ * spelling of the spec (`/packages/spec/dist/...`) is held by ++ * `vite-objectstack-workspace.test.ts`. ++ * ++ * These cases drive the REAL plugin off the REAL config over a synthetic bundle, ++ * rather than asserting on the regex the config hands it: the defect this guards ++ * was never a wrong regex, it was a correct regex reaching one consumer and not ++ * the other. ++ */ ++ ++async function loadConsoleConfig(query = ''): Promise { ++ const specifier = `../../apps/console/vite.config.ts${query}`; ++ return (await import(/* @vite-ignore */ specifier)).default; ++} ++ ++interface ProbeChunk { ++ type: 'chunk'; ++ fileName: string; ++ isEntry: boolean; ++ imports: string[]; ++ modules: Record; ++} ++ ++/** A pnpm-store module id for the linter, the spelling a real bundle carries. */ ++const LINT_MODULE_ID = ++ '/repo/node_modules/.pnpm/@objectstack+lint@17.0.0/node_modules/@objectstack/lint/dist/index.js'; ++/** The installed spec's module id, the spelling a node_modules build emits. */ ++const INSTALLED_SPEC_MODULE_ID = '/repo/node_modules/@objectstack/spec/dist/index.mjs'; ++ ++const probeChunk = ( ++ fileName: string, ++ modules: string[], ++ extra: Partial = {} ++): ProbeChunk => ({ ++ type: 'chunk', ++ fileName, ++ isEntry: false, ++ imports: [], ++ modules: Object.fromEntries(modules.map((id) => [id, {}])), ++ ...extra, ++}); ++ ++/** ++ * A bundle shaped like the console's: one entry, one statically imported vendor ++ * chunk holding the spec, and the linter parked behind a dynamic import — which ++ * the plugin's walk deliberately does not follow. ++ * ++ * @param specModuleId the spec id the vendor chunk carries ++ * @param lintFileName which chunk holds the linter, or `null` for none at all ++ */ ++function consoleShapedBundle( ++ specModuleId: string, ++ lintFileName: 'assets/vendor-objectstack.js' | 'assets/lint-lazy.js' | null ++): Record { ++ const vendorModules = [specModuleId]; ++ if (lintFileName === 'assets/vendor-objectstack.js') vendorModules.push(LINT_MODULE_ID); ++ const bundle: Record = { ++ 'assets/index.js': probeChunk('assets/index.js', ['/repo/apps/console/src/main.tsx'], { ++ isEntry: true, ++ imports: ['assets/vendor-objectstack.js'], ++ }), ++ 'assets/vendor-objectstack.js': probeChunk('assets/vendor-objectstack.js', vendorModules), ++ }; ++ if (lintFileName === 'assets/lint-lazy.js') { ++ bundle['assets/lint-lazy.js'] = probeChunk('assets/lint-lazy.js', [LINT_MODULE_ID]); ++ } ++ return bundle; ++} ++ ++/** Runs the console's own guard over one bundle; returns its error, or `null`. */ ++function runLazyLinterProbe(config: any, bundle: Record): string | null { ++ const plugins = (config.plugins as unknown[]).flat(Infinity) as { ++ name?: string; ++ generateBundle?: (this: unknown, options: unknown, bundle: unknown) => void; ++ }[]; ++ const plugin = plugins.find((p) => p && p.name === 'assert-lazy-linter-stays-lazy'); ++ expect(plugin, 'the console config registers assert-lazy-linter-stays-lazy').toBeDefined(); ++ const context = { ++ error(message: string): never { ++ throw new Error(message); ++ }, ++ }; ++ try { ++ plugin!.generateBundle!.call(context, {}, bundle); ++ return null; ++ } catch (error) { ++ return (error as Error).message; ++ } ++} ++ ++describe('the lazy-linter guard, read off the real console config', () => { ++ it('passes a bundle whose eager closure holds the spec and whose linter is lazy', async () => { ++ const config = await loadConsoleConfig(); ++ expect( ++ runLazyLinterProbe(config, consoleShapedBundle(INSTALLED_SPEC_MODULE_ID, 'assets/lint-lazy.js')) ++ ).toBeNull(); ++ }); ++ ++ it('catches an EAGER linter', async () => { ++ const config = await loadConsoleConfig(); ++ const message = runLazyLinterProbe( ++ config, ++ consoleShapedBundle(INSTALLED_SPEC_MODULE_ID, 'assets/vendor-objectstack.js') ++ ); ++ expect(message).toContain('`@objectstack/lint` is in the EAGER closure'); ++ expect(message).toContain('assets/vendor-objectstack.js'); ++ }); ++ ++ it('refuses a verdict when the eager closure holds no spec, and an eager client is not a spec', async () => { ++ const config = await loadConsoleConfig(); ++ const noSpec: Record = { ++ 'assets/index.js': probeChunk('assets/index.js', ['/repo/apps/console/src/main.tsx'], { ++ isEntry: true, ++ }), ++ 'assets/lint-lazy.js': probeChunk('assets/lint-lazy.js', [LINT_MODULE_ID]), ++ }; ++ expect(runLazyLinterProbe(config, noSpec)).toContain('counter-probe failed'); ++ const clientOnly: Record = { ++ 'assets/index.js': probeChunk('assets/index.js', ['/repo/apps/console/src/main.tsx'], { ++ isEntry: true, ++ imports: ['assets/vendor-objectstack.js'], ++ }), ++ 'assets/vendor-objectstack.js': probeChunk('assets/vendor-objectstack.js', [ ++ '/repo/node_modules/@objectstack/client/dist/index.mjs', ++ ]), ++ 'assets/lint-lazy.js': probeChunk('assets/lint-lazy.js', [LINT_MODULE_ID]), ++ }; ++ expect(runLazyLinterProbe(config, clientOnly)).toContain('counter-probe failed'); ++ }); ++ ++ it('refuses a verdict when the LINT test itself has gone blind', async () => { ++ // The linter half's failure mode is the silent one: the assertion on it is ++ // negative, so a regex that stopped matching the emitted ids is ++ // indistinguishable from a clean bundle. ++ const config = await loadConsoleConfig(); ++ const message = runLazyLinterProbe(config, consoleShapedBundle(INSTALLED_SPEC_MODULE_ID, null)); ++ expect(message).toContain('counter-probe failed'); ++ expect(message).toContain('no chunk at all'); ++ }); ++}); +diff --git a/scripts/__tests__/console-optimize-deps.test.ts b/scripts/__tests__/console-optimize-deps.test.ts +new file mode 100644 +index 0000000000..f03a3f5416 +--- /dev/null ++++ b/scripts/__tests__/console-optimize-deps.test.ts +@@ -0,0 +1,132 @@ ++import { describe, expect, it } from 'vitest'; ++import { resolveConfig } from 'vite'; ++import type { InlineConfig } from 'vite'; ++import path from 'node:path'; ++import { fileURLToPath } from 'node:url'; ++ ++/** ++ * objectui#10865 — every `optimizeDeps.include` entry of the console config ++ * resolves the way `pnpm dev` resolves it. ++ * ++ * Moved here from `vite-objectstack-spec-dist.test.ts` when that module's ++ * `OBJECTSTACK_SPEC_DIST` injection left with the objectstack pin ++ * (objectstack#25711): this case is about the console config, not the ++ * injection, and it is kept as it was. ++ */ ++ ++const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); ++ ++/** The spec package, the subject of the "aborts" control below. */ ++const SPEC_PACKAGE_NAME = '@objectstack/spec'; ++ ++/** ++ * A package the console certainly resolves from its own root: the control that ++ * proves the dev resolver resolves anything at all. ++ */ ++const VITE_ORACLE_CONTROL = 'react'; ++ ++async function loadConsoleConfig(query = ''): Promise { ++ const specifier = `../../apps/console/vite.config.ts${query}`; ++ return (await import(/* @vite-ignore */ specifier)).default; ++} ++ ++/** The directory `pnpm dev` runs Vite in, and therefore the root it resolves from. */ ++const consoleRoot = path.join(repoRoot, 'apps/console'); ++ ++/** A package on no `node_modules` walk from the console root: the "skipped" control. */ ++const ABSENT_PACKAGE_CONTROL = 'objectui-10865-no-such-package'; ++ ++/** ++ * A subpath the spec's exports map does not declare: the "aborts" control. It is ++ * the same shape as the bare `react-map-gl` entry, on a package whose map this ++ * file already reconciles. ++ */ ++const UNEXPORTED_SUBPATH_CONTROL = `${SPEC_PACKAGE_NAME}/objectui-10865-no-such-subpath`; ++ ++type IncludeVerdict = { kind: 'resolved'; id: string } | { kind: 'skipped' } | { kind: 'aborts'; message: string }; ++ ++/** ++ * How the dev server treats each `optimizeDeps.include` entry. The answer comes ++ * from Vite itself, not from a transcription of its algorithm. ++ * ++ * The list is read by `vite` (dev) only. `vite build` never reads it, so no ++ * build, E2E or type-check job can see a bad entry. In the Vite this was written ++ * against (8.2), `addManuallyIncludedOptimizeDeps` resolves every entry before ++ * the server listens, through `createOptimizeDepsIncludeResolver`. For the ++ * client environment that resolver is `config.createResolver({ asSrc: false, ++ * scan: true })`, called with NO importer, so it resolves from the root. The ++ * deprecated `createResolver` is used here on purpose: Vite's own include ++ * resolver calls it. ++ * ++ * Two outcomes are defects, and they fail differently: ++ * ++ * - **aborts**: the package is found, but its exports map has no such subpath. ++ * The resolver throws, and `vite` exits 1 before it listens. This is ++ * objectui#10865, measured with a bare `react-map-gl` entry: react-map-gl 8 ++ * exports no `.`. ++ * - **skipped**: the package cannot be reached from the root. Vite logs ++ * `Failed to resolve dependency` and pre-bundles nothing for the entry. This ++ * was the `maplibre-gl` entry objectui#10865 removed. ++ */ ++async function includeVerdicts(config: InlineConfig, specifiers: string[]): Promise> { ++ const resolved = await resolveConfig( ++ { ...config, root: consoleRoot, configFile: false, envFile: false, logLevel: 'silent' }, ++ 'serve', ++ 'development' ++ ); ++ const resolve = resolved.createResolver({ asSrc: false, scan: true }); ++ const verdicts = new Map(); ++ for (const specifier of specifiers) { ++ try { ++ const id = await resolve(specifier); ++ verdicts.set(specifier, id ? { kind: 'resolved', id } : { kind: 'skipped' }); ++ } catch (error) { ++ verdicts.set(specifier, { kind: 'aborts', message: String((error as Error).message).split('\n')[0] }); ++ } ++ } ++ return verdicts; ++} ++ ++describe('objectui#10865: every optimizeDeps.include entry resolves the way the dev server resolves it', () => { ++ it('resolves each entry from the console root under the dev conditions', async () => { ++ // A separate evaluation, so that whatever `resolveConfig` does to the object ++ // it is handed cannot reach the instance the cases above compare. ++ const config = await loadConsoleConfig('?optimize-deps-include=10865'); ++ const include = config.optimizeDeps.include as string[]; ++ // Anti-vacuity: a list that read as empty would "resolve" with nothing to check. ++ expect(include.length).toBeGreaterThan(0); ++ // Vite resolves nested (`dep > sub`) and glob entries by other paths, and ++ // this case models only flat entries. Extend it before adding either form. ++ expect( ++ include.filter((specifier) => specifier.includes('>') || specifier.includes('*')), ++ 'nested or glob include entries, which this case does not model' ++ ).toEqual([]); ++ ++ const verdicts = await includeVerdicts(config, [ ++ ...include, ++ VITE_ORACLE_CONTROL, ++ ABSENT_PACKAGE_CONTROL, ++ UNEXPORTED_SUBPATH_CONTROL, ++ ]); ++ ++ // Controls with a KNOWN outcome, in the SAME run. The instrument has to be ++ // able to report all three outcomes, or a clean result below means nothing. ++ expect(verdicts.get(VITE_ORACLE_CONTROL)?.kind, 'dev resolver resolved nothing: broken instrument').toBe( ++ 'resolved' ++ ); ++ expect(verdicts.get(ABSENT_PACKAGE_CONTROL)?.kind, 'an absent package did not read as skipped').toBe('skipped'); ++ expect(verdicts.get(UNEXPORTED_SUBPATH_CONTROL)?.kind, 'an unexported subpath did not read as aborting').toBe( ++ 'aborts' ++ ); ++ ++ const findings = include.flatMap((specifier) => { ++ const verdict = verdicts.get(specifier)!; ++ if (verdict.kind === 'aborts') return [`${specifier}: aborts the dev server start (${verdict.message})`]; ++ if (verdict.kind === 'skipped') { ++ return [`${specifier}: not reachable from apps/console, so it is skipped with a warning and pre-bundles nothing`]; ++ } ++ return []; ++ }); ++ expect(findings, 'optimizeDeps.include entries the dev server cannot resolve (objectui#10865)').toEqual([]); ++ }); ++}); +diff --git a/scripts/__tests__/quick-reference-current-release-4143.test.ts b/scripts/__tests__/quick-reference-current-release-4143.test.ts +index e24a897155..f1d585cac3 100644 +--- a/scripts/__tests__/quick-reference-current-release-4143.test.ts ++++ b/scripts/__tests__/quick-reference-current-release-4143.test.ts +@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; + import fs from 'node:fs'; + import path from 'node:path'; + import { fileURLToPath } from 'node:url'; ++import { changesetConfigPath } from '../workspace-root.mjs'; + + /** + * objectui#4143: `QUICK_REFERENCE.md`'s "Current Release" block is a deliberately +@@ -261,7 +262,7 @@ describe('QUICK_REFERENCE.md "Current Release" — package + spec versions', () + */ + it('states the one version every workspace package carries', () => { + const changesetConfig = JSON.parse( +- fs.readFileSync(path.join(repoRoot, '.changeset/config.json'), 'utf8'), ++ fs.readFileSync(changesetConfigPath(repoRoot), 'utf8'), + ) as { fixed?: string[][] }; + const fixedGroup = changesetConfig.fixed?.[0] ?? []; + expect(fixedGroup.length, '.changeset/config.json must still declare one `fixed` group').toBeGreaterThan(1); +diff --git a/scripts/__tests__/vite-single-zod.test.ts b/scripts/__tests__/vite-single-zod.test.ts +new file mode 100644 +index 0000000000..4c28d5ff29 +--- /dev/null ++++ b/scripts/__tests__/vite-single-zod.test.ts +@@ -0,0 +1,201 @@ ++import { afterAll, beforeAll, describe, expect, it } from 'vitest'; ++import { build } from 'vite'; ++import fs from 'node:fs'; ++import os from 'node:os'; ++import path from 'node:path'; ++ ++import { SINGLE_ZOD_GUARD_NAME, assertSingleZodInstance, zodPackageDirOf } from '../vite-single-zod'; ++ ++/** ++ * objectui#11327 — the console bundle carries exactly ONE zod instance, measured ++ * on the emitted chunks by `assertSingleZodInstance` in every build. ++ * ++ * Moved here from `vite-objectstack-spec-dist.test.ts` when that module's ++ * `OBJECTSTACK_SPEC_DIST` injection left with the objectstack pin ++ * (objectstack#25711). The guard's cases are kept as they were; the cases of the ++ * injection's own zod redirect (`pinZodToConsoleCopy`) and its range parity check ++ * left with it. ++ * ++ * The fixture is two install trees: a console anchor with its own zod, and a ++ * spec package whose bare `zod` resolves from ITS tree's root — the two-copy ++ * shape one lockfile can still produce. ++ */ ++ ++/** Writes a fake `zod` package whose modules announce which copy they are. */ ++function writeFakeZod(dir: string, version: string, copy: string): void { ++ fs.mkdirSync(dir, { recursive: true }); ++ fs.writeFileSync( ++ path.join(dir, 'package.json'), ++ JSON.stringify({ ++ name: 'zod', ++ version, ++ type: 'module', ++ exports: { '.': './index.js', './v4': './v4.js', './package.json': './package.json' }, ++ }) ++ ); ++ fs.writeFileSync(path.join(dir, 'index.js'), `export const z = { copy: '${copy}' };\n`); ++ fs.writeFileSync(path.join(dir, 'v4.js'), `export const z = { copy: '${copy}_V4' };\n`); ++} ++ ++interface ZodFixture { ++ root: string; ++ /** A console-side package that declares zod, with its own copy. */ ++ anchorDir: string; ++ /** A spec package whose zod is hoisted to ITS tree's root. */ ++ specDir: string; ++} ++ ++function makeZodFixture({ consoleVersion, specOwnVersion }: { consoleVersion: string; specOwnVersion: string }): ZodFixture { ++ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'single-zod-11327-'))); ++ const anchorDir = path.join(root, 'console/packages/app-shell'); ++ fs.mkdirSync(anchorDir, { recursive: true }); ++ fs.writeFileSync( ++ path.join(anchorDir, 'package.json'), ++ JSON.stringify({ name: 'anchor-11327', dependencies: { zod: `^${consoleVersion}` } }) ++ ); ++ writeFakeZod(path.join(anchorDir, 'node_modules/zod'), consoleVersion, 'CONSOLE_ZOD_11327'); ++ fs.writeFileSync( ++ path.join(anchorDir, 'entry.mjs'), ++ [ ++ "import { z } from 'zod';", ++ "import { specZ, specZ4 } from '@objectstack/spec';", ++ 'globalThis.__probe11327 = [z.copy, specZ.copy, specZ4.copy, z === specZ];', ++ ].join('\n') ++ ); ++ ++ const specDir = path.join(root, 'framework/packages/spec'); ++ fs.mkdirSync(path.join(specDir, 'dist'), { recursive: true }); ++ fs.writeFileSync( ++ path.join(specDir, 'package.json'), ++ JSON.stringify({ ++ name: '@objectstack/spec', ++ version: '0.0.0-probe11327', ++ type: 'module', ++ exports: { '.': { import: './dist/index.mjs' } }, ++ dependencies: { zod: `^${specOwnVersion}` }, ++ }) ++ ); ++ fs.writeFileSync( ++ path.join(specDir, 'dist/index.mjs'), ++ [ ++ "import { z } from 'zod';", ++ "import { z as z4 } from 'zod/v4';", ++ 'export const specZ = z;', ++ 'export const specZ4 = z4;', ++ ].join('\n') ++ ); ++ writeFakeZod(path.join(root, 'framework/node_modules/zod'), specOwnVersion, 'SPEC_OWN_ZOD_11327'); ++ return { root, anchorDir, specDir }; ++} ++ ++/** Bundles the fixture's entry for real, with the given plugins; returns the code. */ ++async function bundleFixture(fixture: ZodFixture, plugins: unknown[]): Promise { ++ const result = (await build({ ++ root: fixture.anchorDir, ++ logLevel: 'silent', ++ configFile: false, ++ resolve: { alias: { '@objectstack/spec': path.join(fixture.specDir, 'dist/index.mjs') } }, ++ plugins: plugins as never, ++ build: { ++ write: false, ++ minify: false, ++ lib: { entry: path.join(fixture.anchorDir, 'entry.mjs'), formats: ['es'], fileName: 'probe11327' }, ++ }, ++ })) as { output: { code?: string }[] }[]; ++ return result[0].output.map((o) => o.code ?? '').join('\n'); ++} ++ ++async function loadConsoleConfig(query = ''): Promise { ++ const specifier = `../../apps/console/vite.config.ts${query}`; ++ return (await import(/* @vite-ignore */ specifier)).default; ++} ++ ++describe('objectui#11327: the console bundle carries exactly one zod instance', () => { ++ describe('the guard, in a real Vite build', () => { ++ let fixture: ZodFixture; ++ beforeAll(() => { ++ fixture = makeZodFixture({ consoleVersion: '4.6.5', specOwnVersion: '4.6.1' }); ++ }); ++ afterAll(() => { ++ fs.rmSync(fixture.root, { recursive: true, force: true }); ++ }); ++ ++ it('CONTROL: the fixture really bundles both copies — the split the guard exists for', async () => { ++ const code = await bundleFixture(fixture, []); ++ expect(code).toContain('CONSOLE_ZOD_11327'); ++ expect(code).toContain('SPEC_OWN_ZOD_11327'); ++ expect(code).toContain('SPEC_OWN_ZOD_11327_V4'); ++ }); ++ ++ it('the guard fails a two-copy build, naming both copies, their versions and who imported them', async () => { ++ const message = await bundleFixture(fixture, [assertSingleZodInstance()]).then( ++ () => 'BUILD PASSED', ++ (error: Error) => error.message ++ ); ++ expect(message).toContain(`[${SINGLE_ZOD_GUARD_NAME}]`); ++ expect(message).toContain('carries 2 zod instances'); ++ expect(message).toContain(`zod 4.6.1 at \`${path.join(fixture.root, 'framework/node_modules/zod')}\``); ++ expect(message).toContain(`zod 4.6.5 at \`${path.join(fixture.anchorDir, 'node_modules/zod')}\``); ++ // The spec's copy is traced to the spec, which is what tells a reader where to look. ++ expect(message).toContain(path.join(fixture.specDir, 'dist/index.mjs')); ++ }); ++ ++ it('the guard counts INSTANCES: two copies of one version still fail', async () => { ++ const twin = makeZodFixture({ consoleVersion: '4.6.5', specOwnVersion: '4.6.5' }); ++ try { ++ await expect(bundleFixture(twin, [assertSingleZodInstance()])).rejects.toThrow(/carries 2 zod instances/); ++ } finally { ++ fs.rmSync(twin.root, { recursive: true, force: true }); ++ } ++ }); ++ ++ it('the guard passes a one-copy build', async () => { ++ const single = makeZodFixture({ consoleVersion: '4.6.5', specOwnVersion: '4.6.1' }); ++ try { ++ fs.rmSync(path.join(single.root, 'framework/node_modules/zod'), { recursive: true, force: true }); ++ fs.writeFileSync(path.join(single.specDir, 'dist/index.mjs'), 'export const specZ = {}; export const specZ4 = {};\n'); ++ const code = await bundleFixture(single, [assertSingleZodInstance()]); ++ expect(code).toContain('CONSOLE_ZOD_11327'); ++ expect(code).not.toContain('SPEC_OWN_ZOD_11327'); ++ } finally { ++ fs.rmSync(single.root, { recursive: true, force: true }); ++ } ++ }); ++ ++ it('the guard refuses a verdict when it sees no zod at all — its counter-probe', async () => { ++ const blind = makeZodFixture({ consoleVersion: '4.6.5', specOwnVersion: '4.6.1' }); ++ try { ++ fs.writeFileSync(path.join(blind.anchorDir, 'entry.mjs'), 'globalThis.__probe11327 = 1;\n'); ++ await expect(bundleFixture(blind, [assertSingleZodInstance()])).rejects.toThrow(/counter-probe failed/); ++ } finally { ++ fs.rmSync(blind.root, { recursive: true, force: true }); ++ } ++ }); ++ }); ++ ++ describe('which module ids the guard counts as zod', () => { ++ // Labelled rows: one id carries a leading NUL (a virtual-module marker), and ++ // a `%s` title would print that byte raw into every test report. ++ it.each([ ++ ['a pnpm store path', '/r/node_modules/.pnpm/zod@4.6.5/node_modules/zod/v4/core/core.js', '/r/node_modules/.pnpm/zod@4.6.5/node_modules/zod'], ++ ['a copy nested under another package', '/r/node_modules/a/node_modules/zod/index.js', '/r/node_modules/a/node_modules/zod'], ++ ['a virtual-module id with a query', '\0/r/node_modules/zod/index.js?commonjs-proxy', '/r/node_modules/zod'], ++ ['a sibling package named zod-*', '/r/node_modules/zod-to-json-schema/dist/index.js', null], ++ ['workspace source under a zod/ folder', '/r/packages/types/src/zod/index.zod.ts', null], ++ ])('%s', (_label, id, expected) => { ++ expect(zodPackageDirOf(id)).toBe(expected); ++ }); ++ }); ++ ++ describe('wired into the REAL console config', () => { ++ it('registers the guard in every build, and `zod` stays out of `resolve.dedupe`', async () => { ++ const config = await loadConsoleConfig('?single-zod=11327'); ++ const names = (config.plugins.flat(Infinity) as ({ name?: string } | null)[]) ++ .filter((p): p is { name?: string } => Boolean(p)) ++ .map((p) => p.name ?? ''); ++ expect(names).toContain(SINGLE_ZOD_GUARD_NAME); ++ // `zod` is not in `resolve.dedupe`: measured a silent no-op from this root. ++ expect(config.resolve.dedupe ?? []).not.toContain('zod'); ++ }); ++ }); ++}); +diff --git a/scripts/check-action-ref-convention.mjs b/scripts/check-action-ref-convention.mjs +index 4649366c1c..de1e40af0e 100644 +--- a/scripts/check-action-ref-convention.mjs ++++ b/scripts/check-action-ref-convention.mjs +@@ -141,21 +141,13 @@ export const DECLARED_EXCEPTIONS = [ + 'reviewed moment; a floating ref would re-adopt every upstream change on the next scheduled ' + + 'run, with nothing in this repository to roll back to.', + }, +- // The four below share one reason, said once: each is a third-party action ++ // The three below share one reason, said once: each is a third-party action + // that runs holding a credential, so the code a floating tag resolves to + // would receive that credential on the next run after the tag moved, with + // nothing here reviewing it. A commit sha cannot move; the trailing + // `# vX.Y.Z` comment names the release it is. First-party `actions/*` refs +- // stay on the floating-tag default. +- { +- workflow: 'changeset-release.yml', +- action: 'changesets/action', +- issue: 'objectui#13430', +- reason: +- 'runs with NPM_TOKEN and GITHUB_TOKEN in the publish and version-PR steps; pinned to the ' + +- 'v1.9.0 commit, the one the workflow comment and the ref-pin test say both v1 properties ' + +- 'were read at (scripts/__tests__/changeset-release-action-ref-pin.test.ts holds the sha).', +- }, ++ // stay on the floating-tag default. (A fourth, changesets/action in ++ // changeset-release.yml, left with that workflow: objectstack#25711.) + { + workflow: 'check-links.yml', + action: 'lycheeverse/lychee-action', +diff --git a/scripts/check-changeset-fixed.mjs b/scripts/check-changeset-fixed.mjs +index fc2d74218d..9dee73c030 100644 +--- a/scripts/check-changeset-fixed.mjs ++++ b/scripts/check-changeset-fixed.mjs +@@ -4,11 +4,19 @@ + * release. Three rules, all decided from the config file plus the manifests: + * + * 1. every workspace package under `packages/` and `apps/` is either in the +- * `fixed` group or matched by `ignore`; ++ * `fixed` group or matched by `ignore` — or is PRIVATE while ++ * `privatePackages.version` is true, which versions it on its own; + * 2. `privatePackages` is declared explicitly; + * 3. no PRIVATE package sits in the `fixed` group while + * `privatePackages.version` is false. + * ++ * Rule 1's private arm arrived with the objectstack monorepo (objectstack#25711): ++ * the one `.changeset/config.json` there is the root's, its `fixed` group holds ++ * exactly the PUBLIC packages of both scopes (the root's own ++ * `scripts/check-changeset-fixed.mjs` refuses a private name in it, and the ++ * release scripts read the group as "what npm serves"), so `object-ui` left the ++ * group and is versioned by `privatePackages` instead. ++ * + * Rule 1 is the original one and is version-agnostic. Rules 2 and 3 exist + * because `@changesets/cli@3` changed what an ABSENT `privatePackages` means, + * and this repo has a private package inside the `fixed` group — `object-ui` +@@ -63,6 +71,7 @@ import { readFileSync, readdirSync, statSync } from "fs"; + import { resolve, dirname, join } from "path"; + import { fileURLToPath } from "url"; + import { isEntrypoint } from "./invoked-as.mjs"; ++import { changesetConfigPath } from "./workspace-root.mjs"; + + const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +@@ -124,7 +133,9 @@ export function analyze(config, manifests) { + const isIgnored = globMatcher(config.ignore ?? []); + const privatePackages = normalizePrivatePackages(config.privatePackages); + +- const unclassified = manifests.filter((pkg) => !isFixed(pkg.name) && !isIgnored(pkg.name)); ++ const unclassified = manifests.filter( ++ (pkg) => !isFixed(pkg.name) && !isIgnored(pkg.name) && !(pkg.private && privatePackages?.version === true), ++ ); + const privateInFixed = + privatePackages?.version === true + ? [] +@@ -165,7 +176,7 @@ function readManifests(dirs) { + } + + function main() { +- const config = JSON.parse(readFileSync(resolve(root, ".changeset/config.json"), "utf8")); ++ const config = JSON.parse(readFileSync(changesetConfigPath(root), "utf8")); + const { unclassified, privateInFixed, privatePackages } = analyze( + config, + readManifests(PACKAGE_ROOTS) +diff --git a/scripts/check-changeset-no-major.mjs b/scripts/check-changeset-no-major.mjs +index 585524b06c..7a1ff80d89 100644 +--- a/scripts/check-changeset-no-major.mjs ++++ b/scripts/check-changeset-no-major.mjs +@@ -95,6 +95,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'; + import { resolve, dirname, join } from 'node:path'; + import { fileURLToPath } from 'node:url'; + import { isEntrypoint } from './invoked-as.mjs'; ++import { changesetConfigPath } from './workspace-root.mjs'; + + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +@@ -220,7 +221,7 @@ export function majorOfRange(range) { + + /** The `fixed` groups of `.changeset/config.json`, as arrays of package names. */ + export function readFixedGroups(repoRoot) { +- const config = JSON.parse(readFileSync(join(repoRoot, '.changeset', 'config.json'), 'utf8')); ++ const config = JSON.parse(readFileSync(changesetConfigPath(repoRoot), 'utf8')); + return Array.isArray(config.fixed) ? config.fixed.filter(Array.isArray) : []; + } + +diff --git a/scripts/check-changeset-presence.mjs b/scripts/check-changeset-presence.mjs +index 05cc46b835..542c1e088e 100644 +--- a/scripts/check-changeset-presence.mjs ++++ b/scripts/check-changeset-presence.mjs +@@ -424,6 +424,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; + import { dirname, join, resolve } from 'node:path'; + import { fileURLToPath } from 'node:url'; + import { isEntrypoint } from './invoked-as.mjs'; ++import { changesetConfigPath } from './workspace-root.mjs'; + + const scriptDir = dirname(fileURLToPath(import.meta.url)); + +@@ -626,14 +627,15 @@ function ignoreMatcher(patterns) { + * build and not a quiet pass. + */ + export function readReleaseConfig(root) { +- const dir = join(root, '.changeset'); +- if (!existsSync(dir)) { ++ let configPath; ++ try { ++ configPath = changesetConfigPath(root); ++ } catch { + throw new Error( +- `${dir} does not exist. This gate reads \`.changeset/config.json\` to learn which ` + ++ `${join(root, '.changeset')} does not exist. This gate reads \`.changeset/config.json\` to learn which ` + + 'packages a release covers; without it there is nothing to check against.', + ); + } +- const configPath = join(dir, 'config.json'); + let config; + try { + config = JSON.parse(readFileSync(configPath, 'utf8')); +diff --git a/scripts/check-lockfile-dedupe.mjs b/scripts/check-lockfile-dedupe.mjs +index 1ff08cc6a5..039117dad1 100644 +--- a/scripts/check-lockfile-dedupe.mjs ++++ b/scripts/check-lockfile-dedupe.mjs +@@ -86,17 +86,6 @@ + * carries pnpm's own remedy sentinel; otherwise this exits 2, "could not take a + * reading", which is never a pass. + * +- * ## It needs the pinned `@objectstack/*` build (objectui#12957) +- * +- * Every `@objectstack/*` package resolves to a `file:` directory of the build +- * `.objectstack-sha` pins (`scripts/objectstack-pin.mjs`), and `pnpm dedupe +- * --check` re-reads those directories. Without the build pnpm stops before it +- * judges anything (`ERR_PNPM_LINKED_PKG_DIR_NOT_FOUND`), so the checker asks +- * first and reports a reading it could not take, NAMING the cause rather than +- * leaving it to pnpm's message. `lockfile-dedupe.yml` restores the build before +- * this runs. Measured on objectui#12957 with the build in place: `VERDICT +- * deduped`, the lockfile's sha256 unchanged. +- * + * ## ⚠️ This gate reads the REGISTRY, unlike its neighbour + * + * `check-lockfile-integrity.mjs` is one `node` call over two text files. +@@ -119,14 +108,9 @@ + + import { spawnSync } from 'node:child_process'; + import fs from 'node:fs'; +-import path from 'node:path'; +-import { fileURLToPath } from 'node:url'; + + import { isEntrypoint } from './invoked-as.mjs'; +-import { CACHE_DIR, OVERRIDE_PREFIX, STAMP_FILE } from './objectstack-pin.mjs'; +-import { rootManifest, workspaceRoot } from './workspace-root.mjs'; +- +-const REPO_ROOT = path.resolve(fileURLToPath(import.meta.url), '..', '..'); ++import { workspaceRoot } from './workspace-root.mjs'; + + /** + * pnpm's own remedy line, printed only when `--check` found something to +@@ -322,32 +306,6 @@ export function writeStepSummary(text, env = process.env) { + } + } + +-/** +- * Whether the pinned `@objectstack/*` build is absent, read off its stamp. +- * +- * @param {string} [root] +- * @returns {boolean} +- */ +-export function pinnedBuildMissing(root = REPO_ROOT) { +- return !fs.existsSync(path.join(root, CACHE_DIR, STAMP_FILE)); +-} +- +-/** +- * Whether the workspace resolves any `@objectstack/*` package through the +- * pinned build -- the manifest pnpm reads overrides from declares a +- * `file:.objectstack-pin/...` override. Standalone it does, and the stamp +- * check below applies. Under the objectstack monorepo (objectstack#25723) the +- * `@objectstack/*` packages are workspace members and no manifest declares the +- * pin, so there is no build whose absence could make `pnpm dedupe --check` +- * resolve from the registry; the check is then not a precondition. +- * +- * @param {{ pnpm?: { overrides?: Record } }} [manifest] +- * @returns {boolean} +- */ +-export function pinDeclared(manifest = rootManifest()) { +- return Object.values(manifest?.pnpm?.overrides ?? {}).some((v) => typeof v === 'string' && v.startsWith(OVERRIDE_PREFIX)); +-} +- + /** + * @param {string[]} argv + * @returns {number} process exit code +@@ -358,17 +316,6 @@ export function main(argv = process.argv.slice(2)) { + const annotate = (message) => + console.error(`::${reportOnly ? 'warning' : 'error'} title=Lockfile dedupe::${message}`); + +- if (pinDeclared() && pinnedBuildMissing()) { +- annotate( +- `could not take a reading — the pinned @objectstack/* build is not in ${CACHE_DIR}, so ` + +- '`pnpm dedupe --check` cannot resolve those packages (objectui#12957). Run `pnpm objectstack:build` ' + +- 'first. ⛔ This is NOT a deduped verdict.', +- ); +- for (const line of renderVerdict({ outcome: 'cannot-run', reportOnly })) console.error(line); +- writeStepSummary(renderStepSummary({ outcome: 'cannot-run' })); +- return reportOnly ? EXIT_CLEAN : EXIT_CANNOT_RUN; +- } +- + // At the workspace root: this repository's own, or the monorepo's one level up. + const run = spawnSync('pnpm', ['dedupe', '--check'], { + cwd: workspaceRoot(), +@@ -469,8 +416,6 @@ export function selfTest() { + t('reports each name once, sorted', JSON.stringify(names) === JSON.stringify([...new Set(names)].sort())); + t('finds nothing in clean output', namesFromOutput(GREEN).length === 0); + +- // ── the pinned @objectstack/* build is a precondition (objectui#12957) ───── +- t('a root without the pinned build is reported missing', pinnedBuildMissing(path.join(REPO_ROOT, 'no-such-root')) === true); + t( + 'CONTROL — a red verdict still reds when the parse names nothing', + renderVerdict({ outcome: 'findings', names: [], output: RED }).some((l) => l.startsWith('VERDICT not deduped')), +diff --git a/scripts/check-phantom-dependencies.mjs b/scripts/check-phantom-dependencies.mjs +index 3298563188..6a24ce309e 100644 +--- a/scripts/check-phantom-dependencies.mjs ++++ b/scripts/check-phantom-dependencies.mjs +@@ -201,6 +201,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; + import { dirname, join, relative, resolve } from 'node:path'; + import { fileURLToPath } from 'node:url'; + import { isEntrypoint } from './invoked-as.mjs'; ++import { changesetConfigPath } from './workspace-root.mjs'; + + const scriptDir = dirname(fileURLToPath(import.meta.url)); + +@@ -426,10 +427,9 @@ export function moduleSpecifiers(text, fileName = 'file.tsx') { + * gate whose scope collapsed to nothing passes everything. + */ + export function readReleaseGroup(root) { +- const configPath = join(root, '.changeset/config.json'); + let config; + try { +- config = JSON.parse(readFileSync(configPath, 'utf8')); ++ config = JSON.parse(readFileSync(changesetConfigPath(root), 'utf8')); + } catch (error) { + throw new Error(`cannot read .changeset/config.json: ${error.message}`); + } +diff --git a/scripts/check-ui-root-lookups.mjs b/scripts/check-ui-root-lookups.mjs +index a04f2f0af4..20ac02c4af 100644 +--- a/scripts/check-ui-root-lookups.mjs ++++ b/scripts/check-ui-root-lookups.mjs +@@ -99,10 +99,6 @@ export const ALLOWLIST = new Map([ + 'scripts/workspace-root.mjs', + "decides the WORKSPACE root for every reader of pnpm-workspace.yaml / pnpm-lock.yaml / the root manifest: one probe at `uiRoot()` and one at its parent, anchored on ui-root.mjs -- never a walk, never the cwd (objectstack#25723)", + ], +- [ +- 'scripts/objectstack-pin.mjs', +- 'packs the pin from an objectstack tree and refuses one with no pnpm-workspace.yaml -- a probe of THAT tree, not of this root; the file itself is deleted at cutover', +- ], + [ + 'scripts/check-new-cross-file-line-citations.mjs', + "the gate's subject is the REPOSITORY AT THE CWD (its self-test drives it against a scratch repository), so `gateRoot()` reads the toplevel and narrows to `uiRoot()` only when the objectui tree lies inside it -- a probe of THAT repository, not a lookup of this root", +diff --git a/scripts/check-unused-dependencies.mjs b/scripts/check-unused-dependencies.mjs +index fe12daa98a..ae9a1eaa93 100644 +--- a/scripts/check-unused-dependencies.mjs ++++ b/scripts/check-unused-dependencies.mjs +@@ -301,7 +301,13 @@ export function consumedNames(root, pkgDir) { + * Declarations with a consumer this gate structurally cannot see. + * + * Every row states its evidence and RE-DERIVES it on each run, and a row that +- * has stopped applying fails the build — see the header. Four rows, two shapes. ++ * has stopped applying fails the build — see the header. Two rows, one shape. ++ * ++ * The extension's two rows (`object-ui`: `@object-ui/core`, `@object-ui/types`, ++ * bundled by `noExternal` in its tsup config) left when the release group moved ++ * to the monorepo's `.changeset/config.json` (objectstack#25711): that group may ++ * not name a private package, so the private extension is no longer a released ++ * package this gate scans. + * + * @type {Record>} + */ +@@ -310,10 +316,6 @@ export const DECLARED_WITHOUT_IMPORT = { + '@object-ui/components': generatedSourceAllowance('@object-ui/components'), + '@object-ui/react': generatedSourceAllowance('@object-ui/react'), + }, +- 'object-ui': { +- '@object-ui/core': bundlerInlinedAllowance('@object-ui/core'), +- '@object-ui/types': bundlerInlinedAllowance('@object-ui/types'), +- }, + }; + + /** +@@ -351,37 +353,6 @@ function generatedSourceAllowance(name) { + }; + } + +-/** +- * The extension shape: the bundler INLINES the package by name from its config. +- * +- * `packages/vscode-extension/tsup.config.ts` lists both names in `noExternal`, +- * so their code is compiled into the extension's `dist` even though no file in +- * the extension imports the bare specifier. +- */ +-function bundlerInlinedAllowance(name) { +- const config = 'tsup.config.ts'; +- return { +- reason: +- `bundled into this package's \`dist\` by \`noExternal\` in ${config}, which names it as a string ` + +- 'rather than importing it', +- verify(root, pkg) { +- const path = join(root, pkg.dir, config); +- let text; +- try { +- text = readFileSync(path, 'utf8'); +- } catch { +- return [`${pkg.dir}/${config} cannot be read, so the allowance cannot be verified`]; +- } +- const noExternal = /noExternal\s*:\s*\[([^\]]*)\]/s.exec(text); +- if (!noExternal) return [`${pkg.dir}/${config} no longer declares \`noExternal\``]; +- if (!noExternal[1].includes(`'${name}'`) && !noExternal[1].includes(`"${name}"`)) { +- return [`${pkg.dir}/${config}'s \`noExternal\` no longer names '${name}'`]; +- } +- return []; +- }, +- }; +-} +- + /** + * One package's findings. + * +diff --git a/scripts/markdown-test-inputs.mjs b/scripts/markdown-test-inputs.mjs +index c717fe7cf3..19bfaab5ba 100644 +--- a/scripts/markdown-test-inputs.mjs ++++ b/scripts/markdown-test-inputs.mjs +@@ -894,13 +894,6 @@ export const ADJUDICATED = new Map([ + walker: 'not-markdown: `.github/workflows/*.yml`', + }, + ], +- [ +- 'scripts/__tests__/check-installed-spec-pin-claims.test.ts', +- { +- reads: ['.claude/skills/**', '.github/prompts/component.prompt.md', '.github/prompts/engine.prompt.md', '.github/prompts/ui-library.prompt.md', '.github/pull_request_template.md', 'AGENTS.md', 'CLAUDE.md', 'CONTRIBUTING.md', 'LICENSE-THIRD-PARTY.md', 'QUICK_REFERENCE.md', 'README.md', 'ROADMAP.md', 'apps/**', 'content/docs/**', 'docs/ARCHITECTURE.md', 'docs/CONSOLE-STREAMLINING-SUMMARY.md', 'docs/adr/**', 'docs/audits/**', 'examples/**', 'packages/**', 'patches/README.md', 'skills/objectui/**'], +- notRead: ['CHANGELOG.md'], +- }, +- ], + [ + 'scripts/__tests__/check-links-workflow.test.ts', + { +@@ -1122,12 +1115,6 @@ export const ADJUDICATED = new Map([ + walker: 'not-markdown: `.github/workflows/*.yml`', + }, + ], +- // objectui#12958. Its `CHANGELOG.md` is a fixture written into a temp directory; +- // from this tree it reads the root `package.json` and one workflow, no markdown. +- [ +- 'scripts/__tests__/objectstack-floor.test.ts', +- { reads: [], notRead: ['CHANGELOG.md'] }, +- ], + [ + 'scripts/__tests__/quick-reference-commands-4149.test.ts', + { +diff --git a/scripts/setup.sh b/scripts/setup.sh +index 442a909d91..507e54aa7b 100755 +--- a/scripts/setup.sh ++++ b/scripts/setup.sh +@@ -71,12 +71,11 @@ if [ "$(printf '%s\n%s\n' "$PNPM_FLOOR" "$(pnpm -v)" | sort -V | head -n1)" != " + fi + echo -e "${GREEN}✓ pnpm $(pnpm -v) detected${NC}" + +-# Install dependencies. Every @objectstack/* package resolves to a build of the +-# objectstack commit `.objectstack-sha` pins (objectui#12957), so `setup` builds +-# that first and then installs frozen -- a bare `pnpm install` on a clean +-# checkout fails on the missing build directory. (`pnpm run setup`, never +-# `pnpm setup`: the latter is pnpm's own built-in command.) +-echo -e "\n${YELLOW}📦 Building @objectstack/* at the pin, then installing dependencies...${NC}" ++# Install dependencies. Every @objectstack/* package is a `workspace:^` link to ++# this monorepo's own packages (objectstack#25711), so `setup` is a frozen ++# install and nothing else. (`pnpm run setup`, never `pnpm setup`: the latter is ++# pnpm's own built-in command.) ++echo -e "\n${YELLOW}📦 Installing dependencies...${NC}" + require_root_script setup + pnpm run setup + +diff --git a/scripts/sync-quick-reference-release.mjs b/scripts/sync-quick-reference-release.mjs +index 6cfba47786..3e6ee6ddd2 100644 +--- a/scripts/sync-quick-reference-release.mjs ++++ b/scripts/sync-quick-reference-release.mjs +@@ -63,6 +63,7 @@ import { readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs'; + import { resolve, dirname, join } from 'node:path'; + import { fileURLToPath } from 'node:url'; + import { isEntrypoint } from './invoked-as.mjs'; ++import { changesetConfigPath } from './workspace-root.mjs'; + + const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +@@ -147,7 +148,7 @@ export function readAnchors(root = repoRoot) { + const dataObjectstack = manifest(root, 'packages/data-objectstack/package.json'); + const reactManifest = manifest(root, 'packages/react/package.json'); + +- const changesetConfig = JSON.parse(readFileSync(join(root, '.changeset/config.json'), 'utf8')); ++ const changesetConfig = JSON.parse(readFileSync(changesetConfigPath(root), 'utf8')); + const fixedGroup = changesetConfig.fixed?.[0] ?? []; + if (fixedGroup.length <= 1) { + throw new Error('.changeset/config.json must still declare one `fixed` group of packages'); +diff --git a/scripts/vite-single-zod.ts b/scripts/vite-single-zod.ts +new file mode 100644 +index 0000000000..9f24058c42 +--- /dev/null ++++ b/scripts/vite-single-zod.ts +@@ -0,0 +1,139 @@ ++// Build-time guard: the console bundle carries exactly ONE zod instance ++// (objectui#11327). ++// ++// Two zod instances split zod's internals: a spec schema built by one and walked ++// by the other (`z.toJSONSchema`) throws, and every spec-derived Studio form lost ++// its fields — at the console pin objectui#11327 was reported against, the New ++// Package dialog could not create a package. Two copies that happen to agree, ++// even on a version, are still two instances, so the rule is ONE, not ++// "matching". ++// ++// `resolve.dedupe` cannot deliver it, measured on objectui#11327 against the ++// real console config: Vite resolves a deduped package from its ROOT, the ++// console's root (`apps/console`) declares no `zod`, the root lookup finds ++// nothing, and Vite silently falls back to the importer's own copy. So this ++// guard MEASURES the outcome on the emitted chunks rather than trusting any ++// mechanism, in every console build. ++// ++// Moved out of `vite-objectstack-spec-dist.ts` when that module's ++// `OBJECTSTACK_SPEC_DIST` injection left with the objectstack pin ++// (objectstack#25711): in the objectstack workspace the console's ++// `@objectstack/*` are `workspace:^` links, so there is no out-of-tree spec to ++// inject and no second install tree for zod to come from — but one lockfile can ++// still resolve two zod versions, and this guard is what says so. ++ ++import fs from 'node:fs'; ++import path from 'node:path'; ++import type { Plugin } from 'vite'; ++ ++/** The name `assertSingleZodInstance`'s plugin registers under. */ ++export const SINGLE_ZOD_GUARD_NAME = 'assert-single-zod-instance'; ++ ++/** ++ * The `zod` package directory a module id lives in, or `null`. ++ * ++ * The LAST `node_modules/zod/` segment wins, so pnpm's store path ++ * (`…/.pnpm/zod@4.6.5/node_modules/zod/v4/core/core.js`) and a copy nested ++ * under another package both answer with the directory that IS the package. ++ * A sibling such as `zod-to-json-schema` does not match: the segment must be ++ * exactly `zod`. A leading `\0` (a virtual-module marker) and a query are ++ * dropped first, so one copy reached two ways is not counted twice. ++ */ ++export function zodPackageDirOf(id: string): string | null { ++ const match = /^(.*[\\/]node_modules[\\/]zod)[\\/]/.exec(id.replace(/^\0/, '').split('?')[0]); ++ return match ? match[1] : null; ++} ++ ++/** The minimal slice of a bundle `assertSingleZodInstance` reads. */ ++interface GuardOutput { ++ type: string; ++ fileName: string; ++ modules?: Record; ++} ++ ++/** The minimal slice of the plugin context `assertSingleZodInstance` reads. */ ++interface GuardContext { ++ error(message: string): never; ++ getModuleInfo?(id: string): { importers?: readonly string[] } | null; ++} ++ ++/** ++ * Build-time guard: the console bundle carries exactly ONE zod instance. ++ * ++ * Counted on what the build EMITTED — every module id rendered into a chunk, ++ * grouped by the `zod` package directory it lives in — never on how the ++ * resolver was configured, because the configuration is exactly what fooled ++ * the first fix attempt (see the file header). Two directories fail the build ++ * naming each copy, its version, the chunks it landed in and who imported it; ++ * two directories holding the SAME version fail too, since an instance is what ++ * the crash keys on, not a version. ++ * ++ * Zero directories fail as well, as a counter-probe: the console always bundles ++ * zod (the `@object-ui/types` validators, the metadata-admin schema modules), so ++ * zero means this matcher has gone blind, and a negative guard that cannot see ++ * its subject would read green forever — the shape `assertLazyLinterStaysLazy` ++ * in the console config refuses for the same reason. ++ */ ++export function assertSingleZodInstance(): Plugin { ++ return { ++ name: SINGLE_ZOD_GUARD_NAME, ++ apply: 'build', ++ generateBundle(_options, bundle) { ++ const context = this as unknown as GuardContext; ++ const copies = new Map; importers: Set }>(); ++ let chunkCount = 0; ++ for (const output of Object.values(bundle as Record)) { ++ if (output.type !== 'chunk') continue; ++ chunkCount += 1; ++ for (const id of Object.keys(output.modules ?? {})) { ++ const dir = zodPackageDirOf(id); ++ if (!dir) continue; ++ const copy = copies.get(dir) ?? { chunks: new Set(), importers: new Set() }; ++ copies.set(dir, copy); ++ copy.chunks.add(output.fileName); ++ for (const importer of context.getModuleInfo?.(id)?.importers ?? []) { ++ if (zodPackageDirOf(importer) !== dir) copy.importers.add(importer); ++ } ++ } ++ } ++ ++ if (copies.size === 0) { ++ context.error( ++ `[${SINGLE_ZOD_GUARD_NAME}] counter-probe failed: none of the ${chunkCount} chunks holds a module ` + ++ `under a \`node_modules/zod/\` directory, so this guard can no longer fail. The console always ` + ++ `bundles zod (the \`@object-ui/types\` validators, the metadata-admin schema modules), so zero ` + ++ `is a statement about this matcher — zod reached through a path without that segment — never ` + ++ `a clean bundle (objectui#11327)` ++ ); ++ } ++ if (copies.size > 1) { ++ const lines = [...copies.entries()].map(([dir, copy]) => { ++ let version = '?'; ++ try { ++ version = String( ++ (JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')) as { version?: unknown }).version ++ ); ++ } catch { ++ // The listing still names the directory; the version is a courtesy. ++ } ++ const importers = [...copy.importers].slice(0, 3); ++ return ( ++ ` - zod ${version} at \`${dir}\` — in ${[...copy.chunks].join(', ')}` + ++ (importers.length > 0 ? `; imported by ${importers.join(', ')}` : '') ++ ); ++ }); ++ context.error( ++ `[${SINGLE_ZOD_GUARD_NAME}] the console bundle carries ${copies.size} zod instances and must ` + ++ `carry exactly one (objectui#11327):\n${lines.join('\n')}\n` + ++ `A schema built by one instance and walked by another holds only while their internals happen ` + ++ `to agree — when they did not, \`z.toJSONSchema\` over a spec schema threw and the Studio's ` + ++ `spec-derived forms lost their fields — and two copies that agree, even on a version, are ` + ++ `still two instances. The workspace lockfile resolves zod twice for the console's closure; ` + ++ `dedupe it. Adding \`zod\` to ` + ++ `\`resolve.dedupe\` is not a fix: the console root declares no zod, so Vite falls back to each ` + ++ `importer's own copy without a word.` ++ ); ++ } ++ }, ++ }; ++} +diff --git a/scripts/workspace-root.d.mts b/scripts/workspace-root.d.mts +index 42a1503262..81eb85aa07 100644 +--- a/scripts/workspace-root.d.mts ++++ b/scripts/workspace-root.d.mts +@@ -10,6 +10,7 @@ export declare const WORKSPACE_MANIFEST: 'pnpm-workspace.yaml'; + export declare const LOCKFILE: 'pnpm-lock.yaml'; + export declare const ROOT_MANIFEST: 'package.json'; + export declare const TURBO_CONFIG: 'turbo.json'; ++export declare const CHANGESET_CONFIG: '.changeset/config.json'; + export declare const PARKED_TURBO_CONFIG: 'turbo.objectui.json'; + export declare const TURBO_ROOT_PREFIX: '$TURBO_ROOT$/'; + +@@ -32,6 +33,7 @@ export declare function parseWorkspaceGlobs(text: string): string[]; + export declare function workspaceGlobs(root?: string): string[]; + /** The nearest `turbo.json` upward: what turbo hashes from. */ + export declare function turboConfigPath(root?: string): string; ++export declare function changesetConfigPath(root?: string): string; + /** objectui's own task declaration: `turbo.json` at `root`, else the parked copy. */ + export declare function uiTurboConfigPath(root?: string): string; + +diff --git a/scripts/workspace-root.mjs b/scripts/workspace-root.mjs +index 3e5e068e46..47084d5c2b 100644 +--- a/scripts/workspace-root.mjs ++++ b/scripts/workspace-root.mjs +@@ -81,6 +81,8 @@ export const WORKSPACE_MANIFEST = 'pnpm-workspace.yaml'; + export const LOCKFILE = 'pnpm-lock.yaml'; + export const ROOT_MANIFEST = 'package.json'; + export const TURBO_CONFIG = 'turbo.json'; ++/** The Changesets config, relative to the root that holds it. */ ++export const CHANGESET_CONFIG = '.changeset/config.json'; + /** Where the cutover parks objectui's own `turbo.json` (a nested one must `extends` the root). */ + export const PARKED_TURBO_CONFIG = 'turbo.objectui.json'; + export const TURBO_ROOT_PREFIX = '$TURBO_ROOT$/'; +@@ -233,6 +235,26 @@ export function turboConfigPath(root = uiRoot()) { + throw new Error(`no ${TURBO_CONFIG} at ${own} or at ${parent} (objectstack#25723).`); + } + ++/** ++ * The Changesets config that versions this tree: `.changeset/config.json` at ++ * `root` when it holds one (standalone), else the one at its parent — the ++ * monorepo's, whose one `fixed` group carries `@object-ui/*` and ++ * `@objectstack/*` together (objectstack#25711, ruling D1 「同一个版本号组」; ++ * changesets reads one config per repository). A reader that asks "which of ++ * this tree's packages are published" intersects that group with this tree's ++ * own manifests, so the monorepo's wider group answers the same question. ++ * ++ * @param {string} [root] ++ * @returns {string} ++ */ ++export function changesetConfigPath(root = uiRoot()) { ++ const own = path.resolve(root); ++ if (fs.existsSync(path.join(own, CHANGESET_CONFIG))) return path.join(own, CHANGESET_CONFIG); ++ const parent = path.dirname(own); ++ if (parent !== own && fs.existsSync(path.join(parent, CHANGESET_CONFIG))) return path.join(parent, CHANGESET_CONFIG); ++ throw new Error(`no ${CHANGESET_CONFIG} at ${own} or at ${parent} (objectstack#25711).`); ++} ++ + /** + * objectui's OWN task declaration, for a test that pins objectui's task shapes + * (`$TURBO_ROOT$` inputs relative to the objectui tree, `//#` root tasks of its +diff --git a/turbo.objectui.json b/turbo.objectui.json +index 24c4aaef68..0996d5a3bd 100644 +--- a/turbo.objectui.json ++++ b/turbo.objectui.json +@@ -2,9 +2,7 @@ + "$schema": "https://turbo.build/schema.json", + "globalEnv": ["NODE_ENV"], + "globalDependencies": [ +- "node_modules/@objectstack/spec/.spec-main-shape-gate.json", +- ".objectstack-sha", +- "node_modules/.cache/objectstack/.stamp.json" ++ "node_modules/@objectstack/spec/.spec-main-shape-gate.json" + ], + "tasks": { + "build": { +@@ -16,7 +14,7 @@ + "**/*.tsbuildinfo", + "!**/node_modules/**" + ], +- "env": ["NODE_ENV", "VITE_BASE_PATH", "OBJECTSTACK_CLIENT_DIST", "OBJECTSTACK_SPEC_DIST"], ++ "env": ["NODE_ENV", "VITE_BASE_PATH"], + "inputs": [ + "$TURBO_DEFAULT$", + "$TURBO_ROOT$/tsconfig.json", diff --git a/scripts/ui-import/README.md b/scripts/ui-import/README.md new file mode 100644 index 00000000000..3ade58e9221 --- /dev/null +++ b/scripts/ui-import/README.md @@ -0,0 +1,120 @@ +# ui-import: the objectui side of the release-lane merge (objectstack#25711) + +`4a-ui-side.patch` is the `ui/` half of sub-issue 4a of objectstack#25677: it removes +objectui's pin on objectstack (`.objectstack-sha`, `.objectstack-pin/`, the scripts that +read them), the dist-injection Vite probes, objectui's own release lane and its own +Changesets config. It is a patch file and not tree edits because `ui/` does not exist on +`main` until the subtree PR of sub-issue 5 lands; it is applied in that cutover window, +right after the subtree PR, together with the root half of 4a. + +## What it changes + +Deleted (step 2 below; the patch carries only modifications and additions, so the +deleted files' 14,727 lines are not repeated here): + +- the pin: `.objectstack-sha`, `.objectstack-pin/` (7 packed manifests), + `scripts/bump-objectstack.sh`, `scripts/objectstack-pin.mjs`, + `scripts/objectstack-floor.mjs`, `scripts/check-spec-range-floors.mjs`, + `scripts/check-installed-spec-pin-claims.mjs`, and their tests; +- the dist-pin Vite probes: `scripts/vite-objectstack-spec-dist.ts`, + `scripts/vite-objectstack-client-dist.ts` (`OBJECTSTACK_SPEC_DIST` / + `OBJECTSTACK_CLIENT_DIST`), and their tests. The workspace arm + (`scripts/vite-objectstack-workspace.ts`) is what remains; +- objectui's release lane: `.github/workflows/changeset-release.yml`, + `.github/workflows/spec-range-floors.yml`, `scripts/release-github-releases.mjs`, and + their tests (the root `release.yml` publishes both scopes, and the root + `scripts/release-github-releases.mjs` cuts a Release per package tag of both); +- `.changeset/config.json`: changesets reads one config per repository, the root's. + +Changed by the patch: + +- `scripts/vite-single-zod.ts` (new): the single-zod build guard, moved out of the deleted + spec probe because it runs in every console build. `apps/console/vite.config.ts` drops + both injections and imports the guard from there. Its tests move to + `vite-single-zod.test.ts`, `console-optimize-deps.test.ts` and + `console-lazy-linter.test.ts` (the cases that did not depend on an injection); +- `scripts/workspace-root.mjs` gains `changesetConfigPath()`: the tree's own + `.changeset/config.json` standalone, the monorepo root's when nested. Every reader of + that config (`check-changeset-fixed`, `check-changeset-no-major`, + `check-changeset-presence`, `check-phantom-dependencies`, `check-published-dist-tooling` + through it, `check-unused-dependencies`, `sync-quick-reference-release`, and two tests) + reads it through that helper; +- `check-changeset-fixed.mjs`: a private package outside `fixed` is classified when + `privatePackages.version` is true. The root `fixed` group holds public packages only, + so the private VS Code extension (`object-ui`) now versions on its own; +- `check-unused-dependencies.mjs`: the extension's two allowance rows leave with it; +- `check-lockfile-dedupe.mjs`: the pinned-build precondition is gone; +- `package.json`: `setup` is a frozen install, `changeset:publish` loses the floor and + range-floor legs, `objectstack:build` / `objectstack:status` / `check:spec-floors` / + `check:installed-pin-claims` are gone; +- `.gitignore`, `eslint.config.js`, `turbo.objectui.json`, `scripts/setup.sh`, + `CONTRIBUTING.md`, `QUICK_REFERENCE.md`, `check-action-ref-convention.mjs`, + `check-ui-root-lookups.mjs`, `markdown-test-inputs.mjs`: their pin and release-lane + rows. + +## Apply + +From the monorepo root, on the tree the subtree PR produced: + +```sh +# 1. Pending objectui changesets join the root ones (changesets reads one directory). +for f in ui/.changeset/*.md; do + [ "$(basename "$f")" = README.md ] || git mv "$f" .changeset/ +done + +# 2. The deletions. +git rm -r -q -- \ + ui/.changeset/config.json \ + ui/.objectstack-sha ui/.objectstack-pin \ + ui/.github/workflows/changeset-release.yml ui/.github/workflows/spec-range-floors.yml \ + ui/scripts/bump-objectstack.sh ui/scripts/objectstack-pin.mjs ui/scripts/objectstack-floor.mjs \ + ui/scripts/check-spec-range-floors.mjs ui/scripts/check-installed-spec-pin-claims.mjs \ + ui/scripts/vite-objectstack-spec-dist.ts ui/scripts/vite-objectstack-client-dist.ts \ + ui/scripts/release-github-releases.mjs \ + ui/scripts/__tests__/bump-objectstack.test.ts \ + ui/scripts/__tests__/changeset-release-action-ref-pin.test.ts \ + ui/scripts/__tests__/check-installed-spec-pin-claims.test.ts \ + ui/scripts/__tests__/check-spec-range-floors.test.ts \ + ui/scripts/__tests__/objectstack-floor.test.ts \ + ui/scripts/__tests__/objectstack-pin.test.ts \ + ui/scripts/__tests__/release-github-releases.test.ts \ + ui/scripts/__tests__/vite-objectstack-client-dist.test.ts \ + ui/scripts/__tests__/vite-objectstack-spec-dist.test.ts + +# 3. The modifications and the three new files. +git apply --directory=ui --check scripts/ui-import/4a-ui-side.patch +git apply --directory=ui scripts/ui-import/4a-ui-side.patch +``` + +The patch is written against the merged tree (objectui `main` imported under `ui/` plus +the verification branch's adaptations: `turbo.objectui.json`, the `workspace:^` manifests). +Against objectui `main` standalone, `package.json` and `turbo.objectui.json` do not apply +and every other file does. + +## Verify + +```sh +pnpm --dir ui exec vitest run scripts/__tests__ # the whole suite, never a subset +pnpm --dir ui run type-check:scripts +pnpm --dir ui run type-check:vitest-config +pnpm --dir ui run lint:root +pnpm console:build # the single-zod and lazy-linter guards run inside it +``` + +Measured on the merged scratch tree (objectstack `main` 43edf90b + PR A + objectui +cd70b7818 under `ui/` + the verification branch's adaptations), 2026-10-25: + +- steps 1–3 reproduce the edited tree byte for byte (56 files); +- `type-check:scripts`, `type-check:vitest-config`, `lint:root`: exit 0; +- `pnpm console:build` (CI=true): exit 0, single-zod canary green, the dist file list + identical to the build before the patch modulo content hashes (2,389 files); +- `vitest run scripts/__tests__`: 31 failed / 5,488 passed. 17 of the 31 fail + identically on the tree without the patch (`check-doc-snippet-types`, + `check-lockfile-integrity`, `check-lucide-icon-record-names`, the live-e2e row of + `ci-cd-pipeline-doc`, `quick-reference-*`, `sync-quick-reference-release`, + `vite-maplibre-worker`, `vite-objectstack-workspace`). The other 14 all read + objectui's nested `ui/.github/` (`ci-cd-pipeline-doc`, `workflow-cache-save-bound`, + `check-pre-install-import-graph`, `markdown-test-inputs` over + `.github/pull_request_template.md`, `sync-quick-reference-release` over + `changeset-release.yml`), whose workflows never run nested and are deleted by + sub-issue 4b (objectstack#25712) PR 2 together with the tests that read them. From fca4f55c3952691cc4bc1fe003500f8dd4a5404a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 04:03:41 +0000 Subject: [PATCH 06/12] release(ui-merge): the lockstep count reads 112 and spec's repo-test list drops the deleted Console test (objectstack#25711, WIP) check:lockstep-package-count derives the count from the fixed group, which now holds both scopes (73 @objectstack + 39 @object-ui). vitest.repo-tests.json still named build-console-objectstack-root.test.ts, deleted with build-console.sh. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_0149BHSXfCdQywGaLdpRAtWe --- content/docs/protocol/backward-compatibility.mdx | 4 ++-- packages/spec/vitest.repo-tests.json | 1 - scripts/publish-smoke-pack.mjs | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/content/docs/protocol/backward-compatibility.mdx b/content/docs/protocol/backward-compatibility.mdx index 559f9049dd8..703e0e37a8b 100644 --- a/content/docs/protocol/backward-compatibility.mdx +++ b/content/docs/protocol/backward-compatibility.mdx @@ -116,7 +116,7 @@ Read the **Breaking?** column, not the version number: in the 17.x minors every 1. **RFC (Request for Comments)** — Breaking changes are proposed as GitHub issues with the `protocol:breaking` label. 2. **Deprecation** — The old behavior is deprecated in a MINOR release (see timeline above). 3. **Migration Guide** — A detailed migration guide is published before the removal lands, in the release notes for the version that carries it. -4. **Release** — In the 17.x minors the breaking change shipped in the next **MINOR** version, carrying a changeset entry marked `**BREAKING**`, and `scripts/check-changeset-no-major.mjs` failed a pull request that declared a `major` bump, because under lockstep one `major` would promote all 73 published packages. During the v18 prerelease it shipped in the next `18.0.0-next.N`, because the guard stood aside in pre mode and a pull request could declare a major bump. Changesets has exited pre mode, so the guard fails a `major` bump again. See [how a breaking stage lands on the v18 line](/docs/releases/v18#how-a-breaking-stage-lands-on-this-line). +4. **Release** — In the 17.x minors the breaking change shipped in the next **MINOR** version, carrying a changeset entry marked `**BREAKING**`, and `scripts/check-changeset-no-major.mjs` failed a pull request that declared a `major` bump, because under lockstep one `major` would promote all 112 published packages. During the v18 prerelease it shipped in the next `18.0.0-next.N`, because the guard stood aside in pre mode and a pull request could declare a major bump. Changesets has exited pre mode, so the guard fails a `major` bump again. See [how a breaking stage lands on the v18 line](/docs/releases/v18#how-a-breaking-stage-lands-on-this-line). --- @@ -196,7 +196,7 @@ The `@objectstack/spec` package provides additional stability guarantees: ### Which surfaces this covers -**All of them.** It was not scoped to an experimental corner or a pre-release channel: all **73** packages published from this repository belong to a single Changesets `fixed` group, so they share one version number and one policy. No published surface was exempt. +**All of them.** It was not scoped to an experimental corner or a pre-release channel: all **112** packages published from this repository belong to a single Changesets `fixed` group, so they share one version number and one policy. No published surface was exempt. The convention was enforced rather than informal — `scripts/check-changeset-no-major.mjs` failed a pull request that introduced a `major` bump, because under lockstep a single `major` on one package would promote the entire stack. During the v18 prerelease Changesets was in pre mode with the tag `next` and the guard stood aside, so a pull request on the v18 line could declare a major bump. Changesets has exited pre mode, and the guard fails a `major` bump again. diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index 333c6bf50d7..27ce9fcab89 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -1,5 +1,4 @@ [ - "scripts/build-console-objectstack-root.test.ts", "scripts/build-schemas-check-mode.anchor.test.ts", "scripts/build-schemas-check-mode.deleted-baseline.test.ts", "scripts/build-schemas-check-mode.deletion-proofs.test.ts", diff --git a/scripts/publish-smoke-pack.mjs b/scripts/publish-smoke-pack.mjs index 9bfd0fdf072..b1fee677116 100644 --- a/scripts/publish-smoke-pack.mjs +++ b/scripts/publish-smoke-pack.mjs @@ -33,7 +33,7 @@ * a future exclusion cannot re-open the hole silently. * * Source of truth: the workspace itself. The Changesets `fixed` group in - * .changeset/config.json enumerates the same 73 names, but it is a DERIVED + * .changeset/config.json enumerates the same 112 names, but it is a DERIVED * declaration validated against the workspace by scripts/check-changeset-fixed.mjs * (which reddens both when a public package is missing from the group and * when a group name no longer exists) — deriving from the group would mean From ea18e50bb3287a6e9314c877a329f831f55c4476 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 04:20:04 +0000 Subject: [PATCH 07/12] docs(console): the README names ui/apps/console as a code span, not a relative link (objectstack#25711, WIP) A relative href is a 404 on npm, and on GitHub until ui/ lands. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_0149BHSXfCdQywGaLdpRAtWe --- packages/console/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/console/README.md b/packages/console/README.md index d7797521b11..0287cebc353 100644 --- a/packages/console/README.md +++ b/packages/console/README.md @@ -4,7 +4,7 @@ This package contains nothing but a prebuilt `dist/` directory: the static assets of the ObjectStack runtime Console, built from -[`ui/apps/console`](../../ui/apps/console) (`@object-ui/console`) in the same +`ui/apps/console` (`@object-ui/console`) in the same commit of this repository as the framework release that ships it. You never install it directly. `@objectstack/cli` declares it as a From 60fd8536b9fb97543592004edd40f119bebeca24 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 10:04:48 +0000 Subject: [PATCH 08/12] release(ui-merge): the VS Code extension stays in the one fixed group and the QUICK_REFERENCE release sync retires (objectstack#25711, WIP) Tesla's rulings of 2026-10-25: decision 17b, ruling B, keeps the private VS Code extension `object-ui` in the fixed group. The root check-changeset-fixed now accepts a declared private member (PRIVATE_FIXED_MEMBERS) while privatePackages.version is true, and check:lockstep-package-count counts published members only. The ui-side patch drops its private-arm change to objectui's check-changeset-fixed and keeps the extension rows of check-unused-dependencies. Decision 17c, ruling B, is carried in the same patch: QUICK_REFERENCE.md's synced "Current Release" rows, the sync script and its two tests retire. The patch is regenerated against verify head 9e37acd126. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_0149BHSXfCdQywGaLdpRAtWe --- .../25711-console-built-in-workspace.md | 2 +- .changeset/config.json | 3 +- scripts/check-changeset-fixed.mjs | 99 +++-- scripts/check-lockstep-package-count.mjs | 25 +- scripts/publish-smoke-pack.mjs | 3 +- scripts/ui-import/4a-ui-side.patch | 386 +++++++----------- scripts/ui-import/README.md | 74 ++-- 7 files changed, 295 insertions(+), 297 deletions(-) diff --git a/.changeset/25711-console-built-in-workspace.md b/.changeset/25711-console-built-in-workspace.md index e54b5919d07..0e2463eef14 100644 --- a/.changeset/25711-console-built-in-workspace.md +++ b/.changeset/25711-console-built-in-workspace.md @@ -10,6 +10,6 @@ Metadata-protocol: none - **The package.** `@objectstack/console` ships the same `dist/` it shipped at 18.1.0: the built Console (`index.html`, `assets/`, the manifests it served). Two files leave the tarball: `dist/.objectui-sha`, which named the objectui commit the Console was built from, and `dist/.objectstack-injection.json`, which recorded the spec and client dists injected into that build. Both described a pin that no longer exists: the Console is built from the `ui/` tree of the same commit as the framework, so its version is the framework's version. - **The CLI.** When a framework checkout has no built Console, `objectstack serve` / `objectstack dev` and the 503 at `/_console/` now name `pnpm console:build`, which builds `ui/apps/console` and copies its dist into `packages/console`. The remedy used to be `pnpm objectui:build`, which cloned objectui at the pinned commit. -- **One version.** The `@object-ui/*` packages join the `@objectstack/*` fixed group, so every public package of both scopes publishes at one version. +- **One version.** The `@object-ui/*` packages and the private VS Code extension join the `@objectstack/*` fixed group, so every public package of both scopes publishes at one version and the extension ships at it. What to do: nothing as a consumer; install `@objectstack/console` as before. In a framework checkout, run `pnpm console:build` where you ran `pnpm objectui:build`. diff --git a/.changeset/config.json b/.changeset/config.json index 9493bed7d9d..b1590452921 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -119,7 +119,8 @@ "@object-ui/react-runtime", "@object-ui/runner", "@object-ui/sdui-parser", - "@object-ui/types" + "@object-ui/types", + "object-ui" ] ], "linked": [], diff --git a/scripts/check-changeset-fixed.mjs b/scripts/check-changeset-fixed.mjs index d48d722ce7e..db4910a521f 100644 --- a/scripts/check-changeset-fixed.mjs +++ b/scripts/check-changeset-fixed.mjs @@ -9,7 +9,11 @@ * Exits with code 1 (and a clear diff) if: * - A public (non-private) workspace package is missing from the * `fixed` group in .changeset/config.json - * - A name listed in the `fixed` group no longer exists in the workspace + * - A name listed in the `fixed` group no longer exists in the workspace, + * or names a private package that `PRIVATE_FIXED_MEMBERS` does not declare + * - A declared private member is missing from the group, is not a private + * workspace package, or sits there while `privatePackages.version` is not + * true (Changesets would then leave it unversioned inside a lockstep group) * * The script intentionally has zero third-party dependencies so it can run * in minimal CI environments before `pnpm install`. The workspace walk comes @@ -20,11 +24,32 @@ import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { isEntrypoint } from './invoked-as.mjs'; import { workspacePackages } from './workspace-enumerator.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, '..'); +/** + * Private workspace packages that belong in the `fixed` group anyway, each with + * the reason it is there. Every other name in the group must be public. + * + * `object-ui` is the VS Code extension (`ui/packages/vscode-extension`). It is + * never published to npm, but it ships at the release's version: Tesla's ruling + * of 2026-10-25 (decision 17b, ruling B, objectstack#25711) keeps it in the one + * version group 「同一个版本号组」 the objectui merge created, as objectui's own + * group had. A private package outside this list is still refused in the group, + * so a package marked private by mistake cannot hide in it. + * + * Readers that count "published packages" (`check-lockstep-package-count.mjs`) + * subtract these names. + * + * @type {Readonly>} + */ +export const PRIVATE_FIXED_MEMBERS = Object.freeze({ + 'object-ui': 'the VS Code extension: never on npm, versioned with the release (decision 17b, objectstack#25711)', +}); + /** * Names of all non-private workspace packages. * @@ -43,35 +68,60 @@ const repoRoot = resolve(__dirname, '..'); * * @returns {string[]} */ -function listPublicPackageNames() { - const names = new Set(); +function listPackageNames() { + const publicNames = new Set(); + const privateNames = new Set(); for (const { manifest } of workspacePackages(repoRoot)) { - if (!manifest.name || manifest.private === true) continue; - names.add(manifest.name); + if (!manifest.name) continue; + (manifest.private === true ? privateNames : publicNames).add(manifest.name); } - return [...names].sort(); + return { publicNames: [...publicNames].sort(), privateNames }; } -function readFixedGroups() { +function readConfig() { const configPath = resolve(repoRoot, '.changeset/config.json'); - const config = JSON.parse(readFileSync(configPath, 'utf8')); - if (!Array.isArray(config.fixed)) return []; - return config.fixed; + return JSON.parse(readFileSync(configPath, 'utf8')); } -function main() { - const publicPackages = listPublicPackageNames(); - const fixedGroups = readFixedGroups(); - const fixed = new Set(fixedGroups.flat()); +/** + * Judges a config against the workspace. Pure, so the declared-private rules + * can be read without a tree. + * + * @param {{ fixed?: string[][]; privatePackages?: { version?: boolean } }} config + * @param {{ publicNames: string[]; privateNames: Set }} packages + * @param {Readonly>} [declared] + */ +export function judge(config, { publicNames, privateNames }, declared = PRIVATE_FIXED_MEMBERS) { + const fixed = new Set(Array.isArray(config.fixed) ? config.fixed.flat() : []); + const isDeclaredPrivate = (name) => Object.hasOwn(declared, name) && privateNames.has(name); + + const missing = publicNames.filter((name) => !fixed.has(name)); + const stale = [...fixed].filter((name) => !publicNames.includes(name) && !isDeclaredPrivate(name)).sort(); + const declaredProblems = []; + for (const name of Object.keys(declared).sort()) { + if (publicNames.includes(name)) { + declaredProblems.push(`${name} is declared a private member but its manifest is public; drop it from PRIVATE_FIXED_MEMBERS`); + } else if (!privateNames.has(name)) { + // No such package in this tree: `stale` already names it when the group does. + continue; + } else if (!fixed.has(name)) { + declaredProblems.push(`${name} (${declared[name]}) is missing from "fixed"`); + } else if (config.privatePackages?.version !== true) { + declaredProblems.push(`${name} sits in "fixed" while privatePackages.version is not true, so Changesets would not version it`); + } + } + return { missing, stale, declaredProblems }; +} - const missing = publicPackages.filter((name) => !fixed.has(name)); - const stale = [...fixed] - .filter((name) => !publicPackages.includes(name)) - .sort(); +function main() { + const packages = listPackageNames(); + const { missing, stale, declaredProblems } = judge(readConfig(), packages); - if (missing.length === 0 && stale.length === 0) { + if (missing.length === 0 && stale.length === 0 && declaredProblems.length === 0) { + const privateCount = Object.keys(PRIVATE_FIXED_MEMBERS).filter((name) => packages.privateNames.has(name)).length; console.log( - `✓ .changeset/config.json "fixed" group is in sync with ${publicPackages.length} public workspace packages.`, + `✓ .changeset/config.json "fixed" group is in sync with ${packages.publicNames.length} public workspace packages` + + (privateCount > 0 ? ` and ${privateCount} declared private member(s).` : '.'), ); return; } @@ -85,15 +135,18 @@ function main() { } if (stale.length > 0) { console.error( - '\nNames in "fixed" that no longer exist in the workspace (remove them):', + '\nNames in "fixed" that are no public workspace package and no declared private member (remove them, or declare a private one in PRIVATE_FIXED_MEMBERS):', ); for (const name of stale) console.error(` - ${name}`); } + if (declaredProblems.length > 0) { + console.error('\nDeclared private members (PRIVATE_FIXED_MEMBERS):'); + for (const problem of declaredProblems) console.error(` - ${problem}`); + } console.error( '\nEdit .changeset/config.json so the "fixed" group matches the public workspace, then re-run this script.', ); process.exit(1); } -main(); - +if (isEntrypoint(import.meta.url)) main(); diff --git a/scripts/check-lockstep-package-count.mjs b/scripts/check-lockstep-package-count.mjs index 50df763fc37..637f92b2179 100644 --- a/scripts/check-lockstep-package-count.mjs +++ b/scripts/check-lockstep-package-count.mjs @@ -68,18 +68,25 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod import * as os from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { PRIVATE_FIXED_MEMBERS } from './check-changeset-fixed.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, '..'); -/** Reads the one true count: the length of the lockstep `fixed` group. */ -function trueCount(root) { +/** + * Reads the one true count: the length of the lockstep `fixed` group, less the + * private members `check-changeset-fixed.mjs` declares (`PRIVATE_FIXED_MEMBERS`, + * the VS Code extension since objectstack#25711). Every prose occurrence counts + * PUBLISHED packages, and those members ship at the release's version without + * ever reaching npm. + */ +function trueCount(root, privateMembers = PRIVATE_FIXED_MEMBERS) { const configPath = resolve(root, '.changeset/config.json'); const config = JSON.parse(readFileSync(configPath, 'utf8')); if (!Array.isArray(config.fixed) || !Array.isArray(config.fixed[0])) { throw new Error(`${configPath}: "fixed" is not an array containing a group array`); } - return config.fixed[0].length; + return config.fixed[0].filter((name) => !Object.hasOwn(privateMembers, name)).length; } // Every prose restatement of the lockstep package count, as of #17039. Each @@ -150,7 +157,7 @@ function run(root, { fix = false } = {}) { function formatReport({ truth, results }) { const lines = [ - `lockstep package count (source: .changeset/config.json fixed[0].length) = ${truth}`, + `lockstep package count (source: .changeset/config.json fixed[0].length, less the declared private members) = ${truth}`, ]; for (const r of results) { if (r.status === 'OK') { @@ -268,6 +275,16 @@ function selfTest() { 'duplicated anchor: reported as ROTTED_ANCHOR with matchCount 2', dupedRows.every((r) => r.status === 'ROTTED_ANCHOR' && r.matchCount === 2), ); + + // Case 5: a declared private member sits in the group but is not counted + // (objectstack#25711); an undeclared name is counted like any other. + write( + join(tmp, '.changeset', 'config.json'), + JSON.stringify({ fixed: [['@objectstack/pkg-0', '@objectstack/pkg-1', 'private-ext']] }), + ); + t('declared private member: not counted', trueCount(tmp, { 'private-ext': 'fixture' }) === 2); + t('the same name undeclared: counted', trueCount(tmp, {}) === 3); + t('the real declaration is what the gate subtracts by default', trueCount(tmp) === 3); } finally { rmSync(tmp, { recursive: true, force: true }); } diff --git a/scripts/publish-smoke-pack.mjs b/scripts/publish-smoke-pack.mjs index b1fee677116..0df93be86f5 100644 --- a/scripts/publish-smoke-pack.mjs +++ b/scripts/publish-smoke-pack.mjs @@ -36,7 +36,8 @@ * .changeset/config.json enumerates the same 112 names, but it is a DERIVED * declaration validated against the workspace by scripts/check-changeset-fixed.mjs * (which reddens both when a public package is missing from the group and - * when a group name no longer exists) — deriving from the group would mean + * when a group name no longer exists), and it also holds the private members + * that gate declares, which are never packed here — deriving from the group would mean * reading a copy that a gate keeps honest, rather than the thing itself. */ diff --git a/scripts/ui-import/4a-ui-side.patch b/scripts/ui-import/4a-ui-side.patch index 4daaa6f7bfa..f4883a723c1 100644 --- a/scripts/ui-import/4a-ui-side.patch +++ b/scripts/ui-import/4a-ui-side.patch @@ -1,3 +1,20 @@ +4a-ui-side.patch: the ui/ half of sub-issue 4a (objectstack#25711), applied by +sub-issue 5's adaptation commit with `git apply --directory=ui` after the deletions +listed in scripts/ui-import/README.md. git apply skips every line above the first +file header, so this text is inert. + +Carries Tesla's rulings of 2026-10-25 (director thread, 09:06Z): + - decision 17b, ruling B: the private VS Code extension `object-ui` stays in the + one fixed group, so objectui's check-changeset-fixed and check-unused-dependencies + keep their extension rows, and only their config reads change; + - decision 17c, ruling B, carried here: QUICK_REFERENCE.md's release-synced + "Current Release" rows, scripts/sync-quick-reference-release.mjs and its two + tests are retired, not restored through the release workflows' allowlists. + +Written against the verification branch head 9e37acd126 (objectstack#25697 run 8 +tree: objectui main imported under ui/ plus the adaptations), after the README's +steps 1 and 2. + diff --git a/.gitignore b/.gitignore index fbb270c19d..3298ccde0c 100644 --- a/.gitignore @@ -36,19 +53,48 @@ index b4f5092b75..7f96e932cd 100644 ```text diff --git a/QUICK_REFERENCE.md b/QUICK_REFERENCE.md -index 097d88086c..f518f6d690 100644 +index f58f06776c..283160e7a9 100644 --- a/QUICK_REFERENCE.md +++ b/QUICK_REFERENCE.md -@@ -126,7 +126,8 @@ this block in the same commit, so a release can no longer leave the block a vers - behind (objectui#5394 — that had happened once per release, three times). +@@ -115,28 +115,13 @@ pnpm changeset publish # Publish to npm (CI only) - - **Version:** 18.1.0 (the version every `@object-ui/*` manifest carries — they are one + ## Current Release + +-Every value below is pinned to the manifest that owns it by +-`scripts/__tests__/quick-reference-current-release-4143.test.ts` — edit the anchor and +-that test tells you to edit this block. The one exception is called out on its row. +- +-You rarely have to edit it by hand. `pnpm quick-reference:sync` rewrites these rows from +-the manifests, and `pnpm quick-reference:check` reports drift without writing. The +-release path runs the sync itself: `changeset:version` bumps every manifest and updates +-this block in the same commit, so a release can no longer leave the block a version +-behind (objectui#5394 — that had happened once per release, three times). +- +-- **Version:** 18.1.0 (the version every `@object-ui/*` manifest carries — they are one - `fixed` group in `.changeset/config.json`, so a release moves all of them together) -+ `fixed` group with every `@objectstack/*` package in the monorepo's -+ `../.changeset/config.json`, so a release moves all of them together) - - **Spec:** `@objectstack/spec` ^18.0.0-next.1 (declared by the root `package.json` and by - `apps/console/package.json`) - - **Client:** `@objectstack/client` ^18.0.0-next.1 (declared by `apps/console/package.json` +-- **Spec:** `@objectstack/spec` ^18.0.0-next.1 (declared by the root `package.json` and by +- `apps/console/package.json`) +-- **Client:** `@objectstack/client` ^18.0.0-next.1 (declared by `apps/console/package.json` +- and `packages/data-objectstack/package.json`) +-- **Node.js:** ≥ 22.13 (see root `engines.node`) +-- **pnpm:** ≥ 10 (the workspace pins `pnpm@10.31.0` via `packageManager`) +-- **React:** 19.x (the `peerDependencies.react` range the packages declare) +-- **TypeScript:** ≥ 5.0 (strict mode) — the stack floor stated in AGENTS.md §2, not a +- manifest fact: nothing in this tree declares a `typescript` range to check it against, +- so this is the one row above that no test can hold to account. +- +-> Pending unreleased work is queued in `.changeset/` — list that directory to see what is +-> staged for the next release. ++The release version is the monorepo's. Every `@object-ui/*` package is in one Changesets ++`fixed` group with every `@objectstack/*` package (`../.changeset/config.json`), so a ++release moves all of them together and any package's `package.json` names the current ++version. The toolchain floors are this tree's `package.json`: `engines` and ++`packageManager`. This block deliberately holds no version literal: the rows that did, ++and the script that rewrote them at `changeset:version`, were retired when objectui's own ++release lane left (objectstack#25711, decision 17c). ++ ++> Pending unreleased work is queued in the monorepo's `../.changeset/` — list that ++> directory to see what is staged for the next release. diff --git a/apps/console/vite.config.ts b/apps/console/vite.config.ts index 6800efa897..de0b876d06 100644 --- a/apps/console/vite.config.ts @@ -364,7 +410,7 @@ index 8861e1cb5f..b8bea1bbed 100644 }, { // objectui#4853 — a stale `eslint-disable` is an ERROR, not a warning. diff --git a/package.json b/package.json -index e722c7596d..cf67fd952e 100644 +index e722c7596d..6eb05d7a73 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,7 @@ @@ -387,17 +433,21 @@ index e722c7596d..cf67fd952e 100644 "check:i18n-keys": "node scripts/check-i18n-call-site-keys.mjs", "check:i18n-drift": "node scripts/check-i18n-en-drift.mjs", "check:i18n-dead-keys": "node scripts/check-i18n-dead-keys.mjs", -@@ -129,7 +125,7 @@ - "changeset:version": "changeset version && node scripts/sync-quick-reference-release.mjs", - "quick-reference:sync": "node scripts/sync-quick-reference-release.mjs", - "quick-reference:check": "node scripts/sync-quick-reference-release.mjs --check", +@@ -126,10 +122,8 @@ + "shadcn:list": "node scripts/shadcn-sync.js --list", + "changeset:check": "node scripts/check-changeset-fixed.mjs && node scripts/check-changeset-no-major.mjs", + "changeset": "changeset", +- "changeset:version": "changeset version && node scripts/sync-quick-reference-release.mjs", +- "quick-reference:sync": "node scripts/sync-quick-reference-release.mjs", +- "quick-reference:check": "node scripts/sync-quick-reference-release.mjs --check", - "changeset:publish": "node scripts/objectstack-floor.mjs && node scripts/check-published-dist-tooling.mjs && node scripts/check-spec-range-floors.mjs && changeset publish", ++ "changeset:version": "changeset version", + "changeset:publish": "node scripts/check-published-dist-tooling.mjs && changeset publish", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "test:e2e:live": "playwright test --config=playwright.live.config.ts", diff --git a/scripts/__tests__/check-changeset-fixed.test.ts b/scripts/__tests__/check-changeset-fixed.test.ts -index 58904d0be8..045bf3070d 100644 +index 58904d0be8..c41e20d361 100644 --- a/scripts/__tests__/check-changeset-fixed.test.ts +++ b/scripts/__tests__/check-changeset-fixed.test.ts @@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url'; @@ -408,27 +458,7 @@ index 58904d0be8..045bf3070d 100644 /** * `scripts/check-changeset-fixed.mjs` used to answer one question — is every -@@ -124,11 +125,27 @@ describe('analyze', () => { - ]); - expect(result.unclassified.map((p) => p.name)).toEqual(['@object-ui/unlisted']); - }); -+ -+ it('classifies a private package outside `fixed` only while privatePackages.version is true', () => { -+ // The monorepo shape (objectstack#25711): the root `fixed` group is public -+ // packages only, and the extension is versioned on its own. -+ const shape = [pkg('@object-ui/core'), pkg('object-ui', true)]; -+ const fixed = [['@object-ui/core']]; -+ expect(analyze({ fixed, privatePackages: { version: true, tag: false } }, shape).unclassified).toEqual([]); -+ expect( -+ analyze({ fixed, privatePackages: { version: false, tag: false } }, shape).unclassified.map((p) => p.name) -+ ).toEqual(['object-ui']); -+ // A PUBLIC package outside `fixed` is still unclassified, whatever the key says. -+ expect( -+ analyze({ fixed, privatePackages: { version: true, tag: false } }, [...shape, pkg('@object-ui/loose')]) -+ .unclassified.map((p) => p.name) -+ ).toEqual(['@object-ui/loose']); -+ }); - }); +@@ -128,7 +129,7 @@ describe('analyze', () => { describe('the repository itself', () => { const config = JSON.parse( @@ -437,26 +467,6 @@ index 58904d0be8..045bf3070d 100644 ) as { fixed?: string[][]; ignore?: string[]; privatePackages?: unknown }; const manifests = ['packages', 'apps'].flatMap((dir) => -@@ -164,13 +181,14 @@ describe('the repository itself', () => { - ).toEqual([]); - }); - -- it('still keeps the private VS Code extension inside the fixed group', () => { -- // The shape the two rules above exist for. If this ever stops being true the -- // guard is not wrong, but it is no longer guarding anything real — and the -- // rationale in both files should be re-read before it is trimmed. -+ it('versions the private VS Code extension on its own, outside the fixed group (objectstack#25711)', () => { -+ // The monorepo's one config holds only public packages in `fixed`; the -+ // extension is versioned by `privatePackages.version: true` instead. If it -+ // ever rejoins the group, rule 3 above is what guards it again. - const vscode = manifests.find((m) => m.path === 'packages/vscode-extension/package.json'); - expect(vscode?.name).toBe('object-ui'); - expect(vscode?.private).toBe(true); -- expect((config.fixed ?? []).flat()).toContain('object-ui'); -+ expect((config.fixed ?? []).flat()).not.toContain('object-ui'); -+ expect(normalizePrivatePackages(config.privatePackages)?.version).toBe(true); - }); - }); diff --git a/scripts/__tests__/check-lockfile-dedupe.test.ts b/scripts/__tests__/check-lockfile-dedupe.test.ts index c81bfcb92f..4c83ff8bb0 100644 --- a/scripts/__tests__/check-lockfile-dedupe.test.ts @@ -516,26 +526,6 @@ index c81bfcb92f..4c83ff8bb0 100644 describe('the lockfile-dedupe checker', () => { it('ships its own self-test, and it passes as shipped', () => { const run = spawnSync('node', [SCRIPT, '--self-test'], { cwd: repoRoot, encoding: 'utf8' }); -diff --git a/scripts/__tests__/check-unused-dependencies.test.ts b/scripts/__tests__/check-unused-dependencies.test.ts -index aa2d460bef..f8684361a0 100644 ---- a/scripts/__tests__/check-unused-dependencies.test.ts -+++ b/scripts/__tests__/check-unused-dependencies.test.ts -@@ -401,14 +401,13 @@ describe('this repository', () => { - expect(findings).toEqual([]); - }); - -- it('allows exactly the four rows the gate landed with, each still verifying', () => { -+ it('allows exactly the two rows left since the extension left the release group, each still verifying', () => { - expect( - Object.fromEntries( - Object.entries(DECLARED_WITHOUT_IMPORT).map(([pkg, rows]) => [pkg, Object.keys(rows).sort()]), - ), - ).toEqual({ - '@object-ui/cli': ['@object-ui/components', '@object-ui/react'], -- 'object-ui': ['@object-ui/core', '@object-ui/types'], - }); - }); - diff --git a/scripts/__tests__/console-lazy-linter.test.ts b/scripts/__tests__/console-lazy-linter.test.ts new file mode 100644 index 0000000000..8a65e29615 @@ -831,27 +821,33 @@ index 0000000000..f03a3f5416 + expect(findings, 'optimizeDeps.include entries the dev server cannot resolve (objectui#10865)').toEqual([]); + }); +}); -diff --git a/scripts/__tests__/quick-reference-current-release-4143.test.ts b/scripts/__tests__/quick-reference-current-release-4143.test.ts -index e24a897155..f1d585cac3 100644 ---- a/scripts/__tests__/quick-reference-current-release-4143.test.ts -+++ b/scripts/__tests__/quick-reference-current-release-4143.test.ts -@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; - import fs from 'node:fs'; - import path from 'node:path'; - import { fileURLToPath } from 'node:url'; -+import { changesetConfigPath } from '../workspace-root.mjs'; - - /** - * objectui#4143: `QUICK_REFERENCE.md`'s "Current Release" block is a deliberately -@@ -261,7 +262,7 @@ describe('QUICK_REFERENCE.md "Current Release" — package + spec versions', () - */ - it('states the one version every workspace package carries', () => { - const changesetConfig = JSON.parse( -- fs.readFileSync(path.join(repoRoot, '.changeset/config.json'), 'utf8'), -+ fs.readFileSync(changesetConfigPath(repoRoot), 'utf8'), - ) as { fixed?: string[][] }; - const fixedGroup = changesetConfig.fixed?.[0] ?? []; - expect(fixedGroup.length, '.changeset/config.json must still declare one `fixed` group').toBeGreaterThan(1); +diff --git a/scripts/__tests__/quick-reference-commands-4149.test.ts b/scripts/__tests__/quick-reference-commands-4149.test.ts +index 690797df72..b10d551252 100644 +--- a/scripts/__tests__/quick-reference-commands-4149.test.ts ++++ b/scripts/__tests__/quick-reference-commands-4149.test.ts +@@ -24,15 +24,14 @@ import { fileURLToPath } from 'node:url'; + * - `packages/tenant` was listed in the Package Tiers table; that package was deleted + * by objectui#2564 (commit d5b1bc0b4). + * +- * ## Why this is a sibling file and not more `it`s in the #4143 pin ++ * ## Why this file never reads `## Current Release` + * +- * `quick-reference-current-release-4143.test.ts` is scoped, in its own words, to the +- * `## Current Release` section — deliberately, because "`## Repository Layout` above it +- * also carries numbers, and asserting over the whole file would conflate a status claim +- * with a package count". That scoping is load-bearing for its reverse direction (no +- * un-derived version literal), which would go red on this page's ports and counts if it +- * were widened. So the two files split by section, and this one never reads +- * `## Current Release`. ++ * That section had its own pin, `quick-reference-current-release-4143.test.ts`, scoped ++ * to it because "`## Repository Layout` above it also carries numbers, and asserting ++ * over the whole file would conflate a status claim with a package count". The pin and ++ * the section's version rows were retired together when objectui's release lane left ++ * (objectstack#25711, decision 17c); the section now holds no literal to pin, and this ++ * file still stops short of it. + * + * ## What is pinned here, and what is deliberately not + * diff --git a/scripts/__tests__/vite-single-zod.test.ts b/scripts/__tests__/vite-single-zod.test.ts new file mode 100644 index 0000000000..4c28d5ff29 @@ -1089,31 +1085,10 @@ index 4649366c1c..de1e40af0e 100644 workflow: 'check-links.yml', action: 'lycheeverse/lychee-action', diff --git a/scripts/check-changeset-fixed.mjs b/scripts/check-changeset-fixed.mjs -index fc2d74218d..9dee73c030 100644 +index fc2d74218d..94cf536546 100644 --- a/scripts/check-changeset-fixed.mjs +++ b/scripts/check-changeset-fixed.mjs -@@ -4,11 +4,19 @@ - * release. Three rules, all decided from the config file plus the manifests: - * - * 1. every workspace package under `packages/` and `apps/` is either in the -- * `fixed` group or matched by `ignore`; -+ * `fixed` group or matched by `ignore` — or is PRIVATE while -+ * `privatePackages.version` is true, which versions it on its own; - * 2. `privatePackages` is declared explicitly; - * 3. no PRIVATE package sits in the `fixed` group while - * `privatePackages.version` is false. - * -+ * Rule 1's private arm arrived with the objectstack monorepo (objectstack#25711): -+ * the one `.changeset/config.json` there is the root's, its `fixed` group holds -+ * exactly the PUBLIC packages of both scopes (the root's own -+ * `scripts/check-changeset-fixed.mjs` refuses a private name in it, and the -+ * release scripts read the group as "what npm serves"), so `object-ui` left the -+ * group and is versioned by `privatePackages` instead. -+ * - * Rule 1 is the original one and is version-agnostic. Rules 2 and 3 exist - * because `@changesets/cli@3` changed what an ABSENT `privatePackages` means, - * and this repo has a private package inside the `fixed` group — `object-ui` -@@ -63,6 +71,7 @@ import { readFileSync, readdirSync, statSync } from "fs"; +@@ -63,6 +63,7 @@ import { readFileSync, readdirSync, statSync } from "fs"; import { resolve, dirname, join } from "path"; import { fileURLToPath } from "url"; import { isEntrypoint } from "./invoked-as.mjs"; @@ -1121,18 +1096,7 @@ index fc2d74218d..9dee73c030 100644 const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -@@ -124,7 +133,9 @@ export function analyze(config, manifests) { - const isIgnored = globMatcher(config.ignore ?? []); - const privatePackages = normalizePrivatePackages(config.privatePackages); - -- const unclassified = manifests.filter((pkg) => !isFixed(pkg.name) && !isIgnored(pkg.name)); -+ const unclassified = manifests.filter( -+ (pkg) => !isFixed(pkg.name) && !isIgnored(pkg.name) && !(pkg.private && privatePackages?.version === true), -+ ); - const privateInFixed = - privatePackages?.version === true - ? [] -@@ -165,7 +176,7 @@ function readManifests(dirs) { +@@ -165,7 +166,7 @@ function readManifests(dirs) { } function main() { @@ -1163,18 +1127,19 @@ index 585524b06c..7a1ff80d89 100644 } diff --git a/scripts/check-changeset-presence.mjs b/scripts/check-changeset-presence.mjs -index 05cc46b835..542c1e088e 100644 +index 2971dfffa7..a256fa15c5 100644 --- a/scripts/check-changeset-presence.mjs +++ b/scripts/check-changeset-presence.mjs -@@ -424,6 +424,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +@@ -424,7 +424,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { isEntrypoint } from './invoked-as.mjs'; -+import { changesetConfigPath } from './workspace-root.mjs'; +-import { revPath } from './workspace-root.mjs'; ++import { changesetConfigPath, revPath } from './workspace-root.mjs'; const scriptDir = dirname(fileURLToPath(import.meta.url)); -@@ -626,14 +627,15 @@ function ignoreMatcher(patterns) { +@@ -631,14 +631,15 @@ function ignoreMatcher(patterns) { * build and not a quiet pass. */ export function readReleaseConfig(root) { @@ -1194,6 +1159,19 @@ index 05cc46b835..542c1e088e 100644 let config; try { config = JSON.parse(readFileSync(configPath, 'utf8')); +diff --git a/scripts/check-entry-guard.mjs b/scripts/check-entry-guard.mjs +index 61de3b7df3..c98d2daad8 100644 +--- a/scripts/check-entry-guard.mjs ++++ b/scripts/check-entry-guard.mjs +@@ -112,7 +112,7 @@ + * same reasoning holds here: + * + * • many of these scripts have real side effects (`shadcn-sync`, +- * `sync-quick-reference-release`, `regenerate-known-schema-types`), so a ++ * `regenerate-known-schema-types`), so a + * gate that spawns all of them is a gate nobody can run locally; + * • "produced output" is not a decidable property of an arbitrary tool -- a + * quiet-on-success script is legitimate, so the assertion would have to be diff --git a/scripts/check-lockfile-dedupe.mjs b/scripts/check-lockfile-dedupe.mjs index 1ff08cc6a5..039117dad1 100644 --- a/scripts/check-lockfile-dedupe.mjs @@ -1317,12 +1295,12 @@ index 3298563188..6a24ce309e 100644 throw new Error(`cannot read .changeset/config.json: ${error.message}`); } diff --git a/scripts/check-ui-root-lookups.mjs b/scripts/check-ui-root-lookups.mjs -index a04f2f0af4..20ac02c4af 100644 +index e37d979cb4..00035c8ae6 100644 --- a/scripts/check-ui-root-lookups.mjs +++ b/scripts/check-ui-root-lookups.mjs -@@ -99,10 +99,6 @@ export const ALLOWLIST = new Map([ +@@ -197,10 +197,6 @@ export const ALLOWLIST = new Map([ 'scripts/workspace-root.mjs', - "decides the WORKSPACE root for every reader of pnpm-workspace.yaml / pnpm-lock.yaml / the root manifest: one probe at `uiRoot()` and one at its parent, anchored on ui-root.mjs -- never a walk, never the cwd (objectstack#25723)", + "decides the WORKSPACE root for every reader of pnpm-workspace.yaml / pnpm-lock.yaml / the root manifest / the .pnpm store: one probe at `uiRoot()` and one at its parent, anchored on ui-root.mjs -- never a walk, never the cwd (objectstack#25723, objectstack#25733)", ], - [ - 'scripts/objectstack-pin.mjs', @@ -1331,76 +1309,8 @@ index a04f2f0af4..20ac02c4af 100644 [ 'scripts/check-new-cross-file-line-citations.mjs', "the gate's subject is the REPOSITORY AT THE CWD (its self-test drives it against a scratch repository), so `gateRoot()` reads the toplevel and narrows to `uiRoot()` only when the objectui tree lies inside it -- a probe of THAT repository, not a lookup of this root", -diff --git a/scripts/check-unused-dependencies.mjs b/scripts/check-unused-dependencies.mjs -index fe12daa98a..ae9a1eaa93 100644 ---- a/scripts/check-unused-dependencies.mjs -+++ b/scripts/check-unused-dependencies.mjs -@@ -301,7 +301,13 @@ export function consumedNames(root, pkgDir) { - * Declarations with a consumer this gate structurally cannot see. - * - * Every row states its evidence and RE-DERIVES it on each run, and a row that -- * has stopped applying fails the build — see the header. Four rows, two shapes. -+ * has stopped applying fails the build — see the header. Two rows, one shape. -+ * -+ * The extension's two rows (`object-ui`: `@object-ui/core`, `@object-ui/types`, -+ * bundled by `noExternal` in its tsup config) left when the release group moved -+ * to the monorepo's `.changeset/config.json` (objectstack#25711): that group may -+ * not name a private package, so the private extension is no longer a released -+ * package this gate scans. - * - * @type {Record>} - */ -@@ -310,10 +316,6 @@ export const DECLARED_WITHOUT_IMPORT = { - '@object-ui/components': generatedSourceAllowance('@object-ui/components'), - '@object-ui/react': generatedSourceAllowance('@object-ui/react'), - }, -- 'object-ui': { -- '@object-ui/core': bundlerInlinedAllowance('@object-ui/core'), -- '@object-ui/types': bundlerInlinedAllowance('@object-ui/types'), -- }, - }; - - /** -@@ -351,37 +353,6 @@ function generatedSourceAllowance(name) { - }; - } - --/** -- * The extension shape: the bundler INLINES the package by name from its config. -- * -- * `packages/vscode-extension/tsup.config.ts` lists both names in `noExternal`, -- * so their code is compiled into the extension's `dist` even though no file in -- * the extension imports the bare specifier. -- */ --function bundlerInlinedAllowance(name) { -- const config = 'tsup.config.ts'; -- return { -- reason: -- `bundled into this package's \`dist\` by \`noExternal\` in ${config}, which names it as a string ` + -- 'rather than importing it', -- verify(root, pkg) { -- const path = join(root, pkg.dir, config); -- let text; -- try { -- text = readFileSync(path, 'utf8'); -- } catch { -- return [`${pkg.dir}/${config} cannot be read, so the allowance cannot be verified`]; -- } -- const noExternal = /noExternal\s*:\s*\[([^\]]*)\]/s.exec(text); -- if (!noExternal) return [`${pkg.dir}/${config} no longer declares \`noExternal\``]; -- if (!noExternal[1].includes(`'${name}'`) && !noExternal[1].includes(`"${name}"`)) { -- return [`${pkg.dir}/${config}'s \`noExternal\` no longer names '${name}'`]; -- } -- return []; -- }, -- }; --} -- - /** - * One package's findings. - * diff --git a/scripts/markdown-test-inputs.mjs b/scripts/markdown-test-inputs.mjs -index c717fe7cf3..19bfaab5ba 100644 +index c717fe7cf3..c2f697563c 100644 --- a/scripts/markdown-test-inputs.mjs +++ b/scripts/markdown-test-inputs.mjs @@ -894,13 +894,6 @@ export const ADJUDICATED = new Map([ @@ -1430,6 +1340,33 @@ index c717fe7cf3..19bfaab5ba 100644 [ 'scripts/__tests__/quick-reference-commands-4149.test.ts', { +@@ -1135,13 +1122,6 @@ export const ADJUDICATED = new Map([ + walker: 'not-markdown: directory listings that test whether a documented path exists', + }, + ], +- [ +- 'scripts/__tests__/quick-reference-current-release-4143.test.ts', +- { +- reads: ['QUICK_REFERENCE.md'], +- walker: 'not-markdown: package and app directories', +- }, +- ], + // objectui#12214. Its fixtures write a `README.md` into a temp directory to + // prove a root documentation change is not a full-run trigger; the selector + // it tests never opens a markdown file. +@@ -1152,12 +1132,6 @@ export const ADJUDICATED = new Map([ + notRead: ['README.md'], + }, + ], +- [ +- 'scripts/__tests__/sync-quick-reference-release.test.ts', +- { +- reads: ['QUICK_REFERENCE.md'], +- }, +- ], + [ + 'scripts/__tests__/unconsumed-widget-option-claim-6186.test.ts', + { diff --git a/scripts/setup.sh b/scripts/setup.sh index 442a909d91..507e54aa7b 100755 --- a/scripts/setup.sh @@ -1452,27 +1389,6 @@ index 442a909d91..507e54aa7b 100755 require_root_script setup pnpm run setup -diff --git a/scripts/sync-quick-reference-release.mjs b/scripts/sync-quick-reference-release.mjs -index 6cfba47786..3e6ee6ddd2 100644 ---- a/scripts/sync-quick-reference-release.mjs -+++ b/scripts/sync-quick-reference-release.mjs -@@ -63,6 +63,7 @@ import { readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs'; - import { resolve, dirname, join } from 'node:path'; - import { fileURLToPath } from 'node:url'; - import { isEntrypoint } from './invoked-as.mjs'; -+import { changesetConfigPath } from './workspace-root.mjs'; - - const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); - -@@ -147,7 +148,7 @@ export function readAnchors(root = repoRoot) { - const dataObjectstack = manifest(root, 'packages/data-objectstack/package.json'); - const reactManifest = manifest(root, 'packages/react/package.json'); - -- const changesetConfig = JSON.parse(readFileSync(join(root, '.changeset/config.json'), 'utf8')); -+ const changesetConfig = JSON.parse(readFileSync(changesetConfigPath(root), 'utf8')); - const fixedGroup = changesetConfig.fixed?.[0] ?? []; - if (fixedGroup.length <= 1) { - throw new Error('.changeset/config.json must still declare one `fixed` group of packages'); diff --git a/scripts/vite-single-zod.ts b/scripts/vite-single-zod.ts new file mode 100644 index 0000000000..9f24058c42 @@ -1619,7 +1535,7 @@ index 0000000000..9f24058c42 + }; +} diff --git a/scripts/workspace-root.d.mts b/scripts/workspace-root.d.mts -index 42a1503262..81eb85aa07 100644 +index da7f3ad172..ee6a559ca9 100644 --- a/scripts/workspace-root.d.mts +++ b/scripts/workspace-root.d.mts @@ -10,6 +10,7 @@ export declare const WORKSPACE_MANIFEST: 'pnpm-workspace.yaml'; @@ -1629,8 +1545,8 @@ index 42a1503262..81eb85aa07 100644 +export declare const CHANGESET_CONFIG: '.changeset/config.json'; export declare const PARKED_TURBO_CONFIG: 'turbo.objectui.json'; export declare const TURBO_ROOT_PREFIX: '$TURBO_ROOT$/'; - -@@ -32,6 +33,7 @@ export declare function parseWorkspaceGlobs(text: string): string[]; + export declare const VIRTUAL_STORE: '.pnpm'; +@@ -37,6 +38,7 @@ export declare function parseWorkspaceGlobs(text: string): string[]; export declare function workspaceGlobs(root?: string): string[]; /** The nearest `turbo.json` upward: what turbo hashes from. */ export declare function turboConfigPath(root?: string): string; @@ -1639,10 +1555,10 @@ index 42a1503262..81eb85aa07 100644 export declare function uiTurboConfigPath(root?: string): string; diff --git a/scripts/workspace-root.mjs b/scripts/workspace-root.mjs -index 3e5e068e46..47084d5c2b 100644 +index bdffb350ad..7ad238a44a 100644 --- a/scripts/workspace-root.mjs +++ b/scripts/workspace-root.mjs -@@ -81,6 +81,8 @@ export const WORKSPACE_MANIFEST = 'pnpm-workspace.yaml'; +@@ -113,6 +113,8 @@ export const WORKSPACE_MANIFEST = 'pnpm-workspace.yaml'; export const LOCKFILE = 'pnpm-lock.yaml'; export const ROOT_MANIFEST = 'package.json'; export const TURBO_CONFIG = 'turbo.json'; @@ -1651,7 +1567,7 @@ index 3e5e068e46..47084d5c2b 100644 /** Where the cutover parks objectui's own `turbo.json` (a nested one must `extends` the root). */ export const PARKED_TURBO_CONFIG = 'turbo.objectui.json'; export const TURBO_ROOT_PREFIX = '$TURBO_ROOT$/'; -@@ -233,6 +235,26 @@ export function turboConfigPath(root = uiRoot()) { +@@ -293,6 +295,26 @@ export function turboConfigPath(root = uiRoot()) { throw new Error(`no ${TURBO_CONFIG} at ${own} or at ${parent} (objectstack#25723).`); } diff --git a/scripts/ui-import/README.md b/scripts/ui-import/README.md index 3ade58e9221..f3a1d6f660a 100644 --- a/scripts/ui-import/README.md +++ b/scripts/ui-import/README.md @@ -10,7 +10,7 @@ right after the subtree PR, together with the root half of 4a. ## What it changes Deleted (step 2 below; the patch carries only modifications and additions, so the -deleted files' 14,727 lines are not repeated here): +deleted files' lines are not repeated here): - the pin: `.objectstack-sha`, `.objectstack-pin/` (7 packed manifests), `scripts/bump-objectstack.sh`, `scripts/objectstack-pin.mjs`, @@ -24,7 +24,11 @@ deleted files' 14,727 lines are not repeated here): `.github/workflows/spec-range-floors.yml`, `scripts/release-github-releases.mjs`, and their tests (the root `release.yml` publishes both scopes, and the root `scripts/release-github-releases.mjs` cuts a Release per package tag of both); -- `.changeset/config.json`: changesets reads one config per repository, the root's. +- `.changeset/config.json`: changesets reads one config per repository, the root's; +- the QUICK_REFERENCE release sync (decision 17c, ruling B, carried here): + `scripts/sync-quick-reference-release.mjs`, its test, and the + `quick-reference-current-release-4143` pin. Its only release-path caller was + `changeset-release.yml`, deleted above. Changed by the patch: @@ -36,20 +40,22 @@ Changed by the patch: - `scripts/workspace-root.mjs` gains `changesetConfigPath()`: the tree's own `.changeset/config.json` standalone, the monorepo root's when nested. Every reader of that config (`check-changeset-fixed`, `check-changeset-no-major`, - `check-changeset-presence`, `check-phantom-dependencies`, `check-published-dist-tooling` - through it, `check-unused-dependencies`, `sync-quick-reference-release`, and two tests) - reads it through that helper; -- `check-changeset-fixed.mjs`: a private package outside `fixed` is classified when - `privatePackages.version` is true. The root `fixed` group holds public packages only, - so the private VS Code extension (`object-ui`) now versions on its own; -- `check-unused-dependencies.mjs`: the extension's two allowance rows leave with it; + `check-changeset-presence`, `check-phantom-dependencies`, and the + `check-changeset-fixed` test) reads it through that helper; +- the private VS Code extension `object-ui` stays in the one fixed group (decision 17b, + ruling B): the root config lists it, and objectui's `check-changeset-fixed` and + `check-unused-dependencies` keep their extension rules and rows unchanged; +- `QUICK_REFERENCE.md`: "Current Release" holds no version literal any more and points + at the monorepo's fixed group; `package.json` drops `quick-reference:sync` / + `quick-reference:check` and the sync leg of `changeset:version`; - `check-lockfile-dedupe.mjs`: the pinned-build precondition is gone; - `package.json`: `setup` is a frozen install, `changeset:publish` loses the floor and range-floor legs, `objectstack:build` / `objectstack:status` / `check:spec-floors` / `check:installed-pin-claims` are gone; - `.gitignore`, `eslint.config.js`, `turbo.objectui.json`, `scripts/setup.sh`, - `CONTRIBUTING.md`, `QUICK_REFERENCE.md`, `check-action-ref-convention.mjs`, - `check-ui-root-lookups.mjs`, `markdown-test-inputs.mjs`: their pin and release-lane + `CONTRIBUTING.md`, `check-action-ref-convention.mjs`, `check-ui-root-lookups.mjs`, + `check-entry-guard.mjs`, `markdown-test-inputs.mjs`, + `quick-reference-commands-4149.test.ts`: their pin, release-lane and release-sync rows. ## Apply @@ -70,14 +76,16 @@ git rm -r -q -- \ ui/scripts/bump-objectstack.sh ui/scripts/objectstack-pin.mjs ui/scripts/objectstack-floor.mjs \ ui/scripts/check-spec-range-floors.mjs ui/scripts/check-installed-spec-pin-claims.mjs \ ui/scripts/vite-objectstack-spec-dist.ts ui/scripts/vite-objectstack-client-dist.ts \ - ui/scripts/release-github-releases.mjs \ + ui/scripts/release-github-releases.mjs ui/scripts/sync-quick-reference-release.mjs \ ui/scripts/__tests__/bump-objectstack.test.ts \ ui/scripts/__tests__/changeset-release-action-ref-pin.test.ts \ ui/scripts/__tests__/check-installed-spec-pin-claims.test.ts \ ui/scripts/__tests__/check-spec-range-floors.test.ts \ ui/scripts/__tests__/objectstack-floor.test.ts \ ui/scripts/__tests__/objectstack-pin.test.ts \ + ui/scripts/__tests__/quick-reference-current-release-4143.test.ts \ ui/scripts/__tests__/release-github-releases.test.ts \ + ui/scripts/__tests__/sync-quick-reference-release.test.ts \ ui/scripts/__tests__/vite-objectstack-client-dist.test.ts \ ui/scripts/__tests__/vite-objectstack-spec-dist.test.ts @@ -86,10 +94,10 @@ git apply --directory=ui --check scripts/ui-import/4a-ui-side.patch git apply --directory=ui scripts/ui-import/4a-ui-side.patch ``` -The patch is written against the merged tree (objectui `main` imported under `ui/` plus -the verification branch's adaptations: `turbo.objectui.json`, the `workspace:^` manifests). -Against objectui `main` standalone, `package.json` and `turbo.objectui.json` do not apply -and every other file does. +The patch is written against the verification branch head `9e37acd126` (objectstack#25697, +the run 8 tree: objectui `main` imported under `ui/` plus the adaptations). Before the +cutover, run step 3's `--check` against the final tree and regenerate the patch if it +fails (sub-issue 5 owns that re-check). ## Verify @@ -101,20 +109,22 @@ pnpm --dir ui run lint:root pnpm console:build # the single-zod and lazy-linter guards run inside it ``` -Measured on the merged scratch tree (objectstack `main` 43edf90b + PR A + objectui -cd70b7818 under `ui/` + the verification branch's adaptations), 2026-10-25: - -- steps 1–3 reproduce the edited tree byte for byte (56 files); -- `type-check:scripts`, `type-check:vitest-config`, `lint:root`: exit 0; -- `pnpm console:build` (CI=true): exit 0, single-zod canary green, the dist file list - identical to the build before the patch modulo content hashes (2,389 files); -- `vitest run scripts/__tests__`: 31 failed / 5,488 passed. 17 of the 31 fail - identically on the tree without the patch (`check-doc-snippet-types`, - `check-lockfile-integrity`, `check-lucide-icon-record-names`, the live-e2e row of - `ci-cd-pipeline-doc`, `quick-reference-*`, `sync-quick-reference-release`, - `vite-maplibre-worker`, `vite-objectstack-workspace`). The other 14 all read - objectui's nested `ui/.github/` (`ci-cd-pipeline-doc`, `workflow-cache-save-bound`, +Measured on 2026-10-25: + +- on `9e37acd126`, steps 1–3 apply cleanly and reproduce the edited `ui/` tree byte for + byte; the same edits on the earlier merged tree (objectui `cd70b7818`) did too + (60 files); +- on `9e37acd126` with steps 1–3 and the root half of 4a, after `pnpm install + --frozen-lockfile` and `pnpm run build`: `type-check:scripts`, + `type-check:vitest-config`, `lint:root` exit 0, and root and objectui + `check-changeset-fixed` both pass (112 public packages plus `object-ui`). + `changeset version` moves all 113 to 18.2.0 together, `object-ui` included; +- `vitest run scripts/__tests__`: 17 failed / 5,573 passed. Two of them are 15 s + timeouts under load (`check-vi-mock-inherit`, `dollar-dialect-alias-census`), which + pass alone (125/125). Two failed the same way on the earlier merged tree without the + patch: `check-lockfile-integrity`, and `quick-reference-commands-4149` on the root + scripts the adaptation drops (cutover bill). The other 14 all read objectui's nested `ui/.github/` + (`ci-cd-pipeline-doc` ×10, `workflow-cache-save-bound` ×2, `check-pre-install-import-graph`, `markdown-test-inputs` over - `.github/pull_request_template.md`, `sync-quick-reference-release` over - `changeset-release.yml`), whose workflows never run nested and are deleted by - sub-issue 4b (objectstack#25712) PR 2 together with the tests that read them. + `.github/pull_request_template.md`). Those workflows never run nested, and sub-issue 4b + (objectstack#25712) PR 2 deletes them together with the tests that read them. From 9a776c6cf136da4711defcd4002dc88ad560e935 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 16:55:56 +0000 Subject: [PATCH 09/12] fix(release): keep the now-private @object-ui/runner out of the fixed group (objectstack#25711, WIP) objectui#13506 made @object-ui/runner private and moved it into objectui's changeset ignore list before the freeze at 9e1fe03286. Measured on the cutover head 1c0d02eda4 with this branch merged and the ui-side patch applied, root check-changeset-fixed reported it as stale. The root fixed group drops it and the root ignore list takes it, mirroring objectui. The lockstep count moves 112 -> 111 in the two places that state it. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_0149BHSXfCdQywGaLdpRAtWe --- .changeset/config.json | 3 +-- content/docs/protocol/backward-compatibility.mdx | 4 ++-- scripts/publish-smoke-pack.mjs | 2 +- scripts/ui-import/README.md | 10 ++++++++-- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/.changeset/config.json b/.changeset/config.json index b1590452921..a6098cd3aac 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -117,7 +117,6 @@ "@object-ui/providers", "@object-ui/react", "@object-ui/react-runtime", - "@object-ui/runner", "@object-ui/sdui-parser", "@object-ui/types", "object-ui" @@ -127,6 +126,6 @@ "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": ["@object-ui/example-*", "@object-ui/site", "@object-ui/test-support"], + "ignore": ["@object-ui/example-*", "@object-ui/site", "@object-ui/runner", "@object-ui/test-support"], "privatePackages": { "version": true, "tag": false } } diff --git a/content/docs/protocol/backward-compatibility.mdx b/content/docs/protocol/backward-compatibility.mdx index 703e0e37a8b..3ce98359950 100644 --- a/content/docs/protocol/backward-compatibility.mdx +++ b/content/docs/protocol/backward-compatibility.mdx @@ -116,7 +116,7 @@ Read the **Breaking?** column, not the version number: in the 17.x minors every 1. **RFC (Request for Comments)** — Breaking changes are proposed as GitHub issues with the `protocol:breaking` label. 2. **Deprecation** — The old behavior is deprecated in a MINOR release (see timeline above). 3. **Migration Guide** — A detailed migration guide is published before the removal lands, in the release notes for the version that carries it. -4. **Release** — In the 17.x minors the breaking change shipped in the next **MINOR** version, carrying a changeset entry marked `**BREAKING**`, and `scripts/check-changeset-no-major.mjs` failed a pull request that declared a `major` bump, because under lockstep one `major` would promote all 112 published packages. During the v18 prerelease it shipped in the next `18.0.0-next.N`, because the guard stood aside in pre mode and a pull request could declare a major bump. Changesets has exited pre mode, so the guard fails a `major` bump again. See [how a breaking stage lands on the v18 line](/docs/releases/v18#how-a-breaking-stage-lands-on-this-line). +4. **Release** — In the 17.x minors the breaking change shipped in the next **MINOR** version, carrying a changeset entry marked `**BREAKING**`, and `scripts/check-changeset-no-major.mjs` failed a pull request that declared a `major` bump, because under lockstep one `major` would promote all 111 published packages. During the v18 prerelease it shipped in the next `18.0.0-next.N`, because the guard stood aside in pre mode and a pull request could declare a major bump. Changesets has exited pre mode, so the guard fails a `major` bump again. See [how a breaking stage lands on the v18 line](/docs/releases/v18#how-a-breaking-stage-lands-on-this-line). --- @@ -196,7 +196,7 @@ The `@objectstack/spec` package provides additional stability guarantees: ### Which surfaces this covers -**All of them.** It was not scoped to an experimental corner or a pre-release channel: all **112** packages published from this repository belong to a single Changesets `fixed` group, so they share one version number and one policy. No published surface was exempt. +**All of them.** It was not scoped to an experimental corner or a pre-release channel: all **111** packages published from this repository belong to a single Changesets `fixed` group, so they share one version number and one policy. No published surface was exempt. The convention was enforced rather than informal — `scripts/check-changeset-no-major.mjs` failed a pull request that introduced a `major` bump, because under lockstep a single `major` on one package would promote the entire stack. During the v18 prerelease Changesets was in pre mode with the tag `next` and the guard stood aside, so a pull request on the v18 line could declare a major bump. Changesets has exited pre mode, and the guard fails a `major` bump again. diff --git a/scripts/publish-smoke-pack.mjs b/scripts/publish-smoke-pack.mjs index 0df93be86f5..bd334a4efd3 100644 --- a/scripts/publish-smoke-pack.mjs +++ b/scripts/publish-smoke-pack.mjs @@ -33,7 +33,7 @@ * a future exclusion cannot re-open the hole silently. * * Source of truth: the workspace itself. The Changesets `fixed` group in - * .changeset/config.json enumerates the same 112 names, but it is a DERIVED + * .changeset/config.json enumerates the same 111 names, but it is a DERIVED * declaration validated against the workspace by scripts/check-changeset-fixed.mjs * (which reddens both when a public package is missing from the group and * when a group name no longer exists), and it also holds the private members diff --git a/scripts/ui-import/README.md b/scripts/ui-import/README.md index f3a1d6f660a..8bda78eeac8 100644 --- a/scripts/ui-import/README.md +++ b/scripts/ui-import/README.md @@ -117,8 +117,14 @@ Measured on 2026-10-25: - on `9e37acd126` with steps 1–3 and the root half of 4a, after `pnpm install --frozen-lockfile` and `pnpm run build`: `type-check:scripts`, `type-check:vitest-config`, `lint:root` exit 0, and root and objectui - `check-changeset-fixed` both pass (112 public packages plus `object-ui`). - `changeset version` moves all 113 to 18.2.0 together, `object-ui` included; + `check-changeset-fixed` both pass (112 public packages plus `object-ui` at that base). + `changeset version` moved all 113 to 18.2.0 together, `object-ui` included; +- on the cutover head `1c0d02eda4` (objectui frozen at `9e1fe03286`), with steps 1–3 and + the root half of 4a: `@object-ui/runner` is private there and in objectui's `ignore` + (objectui#13506), so the root `fixed` group drops it and the root `ignore` takes it. + Root and objectui `check-changeset-fixed` exit 0 (111 public packages plus `object-ui`), + `check:lockstep-package-count` reads 111, and `changeset version` moves all 112 members + to 18.2.0 together while `@object-ui/runner` stays at 18.1.0; - `vitest run scripts/__tests__`: 17 failed / 5,573 passed. Two of them are 15 s timeouts under load (`check-vi-mock-inherit`, `dollar-dialect-alias-census`), which pass alone (125/125). Two failed the same way on the earlier merged tree without the From d6ee25e6604192db98b3f110ba49550de1b65010 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 18:32:39 +0000 Subject: [PATCH 10/12] docs(ui-import): steps 1-2 run on the cutover tree as it now stands (objectstack#25711, WIP) Measured on cutover head c04b36dcb9: one objectui changeset name collides with a root one (react-pages-default-off.md), so a colliding name moves under a `ui-` prefix; ui/.objectstack-pin is already gone, so the deletions take --ignore-unmatch. Removing the claims test's markdown-test-inputs row leaves ui/.github/pull_request_template.md with no reader and reds the totality test; the nested template never renders (GitHub reads the root one), so step 2 deletes it. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_0149BHSXfCdQywGaLdpRAtWe --- scripts/ui-import/README.md | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/scripts/ui-import/README.md b/scripts/ui-import/README.md index 8bda78eeac8..dd3dce2cda5 100644 --- a/scripts/ui-import/README.md +++ b/scripts/ui-import/README.md @@ -64,12 +64,18 @@ From the monorepo root, on the tree the subtree PR produced: ```sh # 1. Pending objectui changesets join the root ones (changesets reads one directory). +# A name the root already uses (react-pages-default-off.md on the cutover tree) +# keeps its objectui text under a `ui-` prefix. for f in ui/.changeset/*.md; do - [ "$(basename "$f")" = README.md ] || git mv "$f" .changeset/ + b=$(basename "$f"); [ "$b" = README.md ] && continue + if [ -e ".changeset/$b" ]; then git mv "$f" ".changeset/ui-$b"; else git mv "$f" .changeset/; fi done -# 2. The deletions. -git rm -r -q -- \ +# 2. The deletions. `--ignore-unmatch`: the cutover tree already dropped +# ui/.objectstack-pin. The nested pull_request_template.md never renders (GitHub +# reads the root one) and its only markdown-test-inputs reader was the claims test. +git rm -r -q --ignore-unmatch -- \ + ui/.github/pull_request_template.md \ ui/.changeset/config.json \ ui/.objectstack-sha ui/.objectstack-pin \ ui/.github/workflows/changeset-release.yml ui/.github/workflows/spec-range-floors.yml \ @@ -119,13 +125,24 @@ Measured on 2026-10-25: `type-check:vitest-config`, `lint:root` exit 0, and root and objectui `check-changeset-fixed` both pass (112 public packages plus `object-ui` at that base). `changeset version` moved all 113 to 18.2.0 together, `object-ui` included; -- on the cutover head `1c0d02eda4` (objectui frozen at `9e1fe03286`), with steps 1–3 and +- on the cutover head `1c0d02eda4` (objectui frozen at `9e1fe03286`), with step 3 and the root half of 4a: `@object-ui/runner` is private there and in objectui's `ignore` (objectui#13506), so the root `fixed` group drops it and the root `ignore` takes it. Root and objectui `check-changeset-fixed` exit 0 (111 public packages plus `object-ui`), `check:lockstep-package-count` reads 111, and `changeset version` moves all 112 members to 18.2.0 together while `@object-ui/runner` stays at 18.1.0; -- `vitest run scripts/__tests__`: 17 failed / 5,573 passed. Two of them are 15 s +- on the cutover head `c04b36dcb9` with steps 1–3 and the root half of 4a, against the + same head without them (both installed, neither built): `vitest run scripts/__tests__` + clears the 12 the cutover leaves to 4a (`check-installed-spec-pin-claims` ×4, + `quick-reference-current-release-4143` ×3, `sync-quick-reference-release` ×5) and the + rest of the deleted pin class with them. It adds 12 that read objectui's nested + `ui/.github/workflows/` (`ci-cd-pipeline-doc` ×9, `workflow-cache-save-bound` ×2, + `check-pre-install-import-graph` ×1: they still name the two deleted workflows and + `scripts/objectstack-pin.mjs`). 4b PR 2 (objectstack#25712) deletes those workflows with + the tests that read them. `markdown-test-inputs`, `check-changeset-fixed` and + `check-unused-dependencies` pass. The `optimizeDeps` case fails identically on both + sides, because there is no spec build, and only moved to `console-optimize-deps.test.ts`; +- earlier, on `9e37acd126`: `vitest run scripts/__tests__`: 17 failed / 5,573 passed. Two of them are 15 s timeouts under load (`check-vi-mock-inherit`, `dollar-dialect-alias-census`), which pass alone (125/125). Two failed the same way on the earlier merged tree without the patch: `check-lockfile-integrity`, and `quick-reference-commands-4149` on the root From 50bd417d30c3e0b3caaa51070eaa4eecba94f7d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 19:01:33 +0000 Subject: [PATCH 11/12] docs(ui-import): leave objectui's pending changesets to #25831 (objectstack#25711, WIP) Measured on cutover head c04b36dcb9, moving the 65 ui/.changeset/*.md to the root as they stand reds check-empty-changeset (3 empty frontmatters) and check-adr-0087-registration (41 breaking changesets with no adr-0087 disposition). Per the epic PM's ruling, the move stays with the aftercare card objectstack#25831; step 1 is dropped and step 2 still deletes ui/.changeset/config.json, since changesets reads the root config only. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_0149BHSXfCdQywGaLdpRAtWe --- scripts/ui-import/README.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/scripts/ui-import/README.md b/scripts/ui-import/README.md index dd3dce2cda5..f188b79c4e6 100644 --- a/scripts/ui-import/README.md +++ b/scripts/ui-import/README.md @@ -63,13 +63,12 @@ Changed by the patch: From the monorepo root, on the tree the subtree PR produced: ```sh -# 1. Pending objectui changesets join the root ones (changesets reads one directory). -# A name the root already uses (react-pages-default-off.md on the cutover tree) -# keeps its objectui text under a `ui-` prefix. -for f in ui/.changeset/*.md; do - b=$(basename "$f"); [ "$b" = README.md ] && continue - if [ -e ".changeset/$b" ]; then git mv "$f" ".changeset/ui-$b"; else git mv "$f" .changeset/; fi -done +# 1. Not here: objectui's 65 pending ui/.changeset/*.md stay under ui/ for the +# aftercare card objectstack#25831, which conforms them to the root changeset +# gates and moves them before the first post-cutover Version PR. Measured on +# c04b36dcb9, moving them as they stand reds check-empty-changeset (3 empty +# frontmatters) and check-adr-0087-registration (41 breaking changesets with no +# `adr-0087:` disposition), and one name collides with a root changeset. # 2. The deletions. `--ignore-unmatch`: the cutover tree already dropped # ui/.objectstack-pin. The nested pull_request_template.md never renders (GitHub From fb110c23230736fa5e82a4e9d5f2cc8c6ae0a4c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 22:54:08 +0000 Subject: [PATCH 12/12] feat(release): apply 4a's ui/ half on the merged tree (objectstack#25711) With ui/ on main (#25825), the objectui side of the release lane lands as tree edits instead of a patch file: - README step 2: the pin, the dist-injection probes, objectui's release lane, its Changesets config, the QUICK_REFERENCE release sync (17c) and the nested pull_request_template.md are deleted; objectui's 65 pending changesets stay under ui/ for #25831; - 4a-ui-side.patch applied under ui/ (git apply --check exit 0 on this tree), then deleted; scripts/ui-import/README.md now records what was applied; - the SDUI manifest is regenerated from ui/ for the merged tree (107 -> 106 components; objectui#13449 retired html), from the patch the 4b PR 2a thread measured. check-changeset-fixed, check:lockstep-package-count, check:sdui-lockstep and check-sdui-manifest (--self-test and the run) exit 0. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_0149BHSXfCdQywGaLdpRAtWe --- scripts/sdui-manifest.record.json | 60 +- scripts/ui-import/4a-ui-side.patch | 1620 -------------- scripts/ui-import/README.md | 36 +- sdui.manifest.json | 13 +- ui/.changeset/config.json | 60 - ui/.github/pull_request_template.md | 1 - ui/.github/workflows/changeset-release.yml | 1545 ------------- ui/.github/workflows/spec-range-floors.yml | 148 -- ui/.gitignore | 5 - ui/.objectstack-sha | 1 - ui/CONTRIBUTING.md | 10 +- ui/QUICK_REFERENCE.md | 35 +- ui/apps/console/vite.config.ts | 198 +- ui/eslint.config.js | 6 - ui/package.json | 12 +- ui/scripts/__tests__/bump-objectstack.test.ts | 224 -- .../changeset-release-action-ref-pin.test.ts | 196 -- .../__tests__/check-changeset-fixed.test.ts | 3 +- .../check-installed-spec-pin-claims.test.ts | 365 ---- .../__tests__/check-lockfile-dedupe.test.ts | 41 +- .../__tests__/check-spec-range-floors.test.ts | 633 ------ .../__tests__/console-lazy-linter.test.ts | 151 ++ .../__tests__/console-optimize-deps.test.ts | 132 ++ .../__tests__/objectstack-floor.test.ts | 161 -- ui/scripts/__tests__/objectstack-pin.test.ts | 695 ------ .../quick-reference-commands-4149.test.ts | 15 +- ...ick-reference-current-release-4143.test.ts | 524 ----- .../__tests__/release-github-releases.test.ts | 294 --- .../sync-quick-reference-release.test.ts | 233 -- .../vite-objectstack-client-dist.test.ts | 457 ---- .../vite-objectstack-spec-dist.test.ts | 1844 ---------------- ui/scripts/__tests__/vite-single-zod.test.ts | 201 ++ ui/scripts/bump-objectstack.sh | 280 --- ui/scripts/check-action-ref-convention.mjs | 14 +- ui/scripts/check-changeset-fixed.mjs | 3 +- ui/scripts/check-changeset-no-major.mjs | 3 +- ui/scripts/check-changeset-presence.mjs | 11 +- ui/scripts/check-entry-guard.mjs | 2 +- .../check-installed-spec-pin-claims.mjs | 1178 ---------- ui/scripts/check-lockfile-dedupe.mjs | 57 +- ui/scripts/check-phantom-dependencies.mjs | 4 +- ui/scripts/check-spec-range-floors.mjs | 1102 ---------- ui/scripts/check-ui-root-lookups.mjs | 4 - ui/scripts/markdown-test-inputs.mjs | 26 - ui/scripts/objectstack-floor.mjs | 228 -- ui/scripts/objectstack-pin.mjs | 1119 ---------- ui/scripts/release-github-releases.mjs | 1939 ----------------- ui/scripts/setup.sh | 11 +- ui/scripts/sync-quick-reference-release.mjs | 426 ---- ui/scripts/vite-objectstack-client-dist.ts | 313 --- ui/scripts/vite-objectstack-spec-dist.ts | 892 -------- ui/scripts/vite-single-zod.ts | 139 ++ ui/scripts/workspace-root.d.mts | 2 + ui/scripts/workspace-root.mjs | 22 + ui/turbo.objectui.json | 6 +- 55 files changed, 794 insertions(+), 16906 deletions(-) delete mode 100644 scripts/ui-import/4a-ui-side.patch delete mode 100644 ui/.changeset/config.json delete mode 100644 ui/.github/pull_request_template.md delete mode 100644 ui/.github/workflows/changeset-release.yml delete mode 100644 ui/.github/workflows/spec-range-floors.yml delete mode 100644 ui/.objectstack-sha delete mode 100644 ui/scripts/__tests__/bump-objectstack.test.ts delete mode 100644 ui/scripts/__tests__/changeset-release-action-ref-pin.test.ts delete mode 100644 ui/scripts/__tests__/check-installed-spec-pin-claims.test.ts delete mode 100644 ui/scripts/__tests__/check-spec-range-floors.test.ts create mode 100644 ui/scripts/__tests__/console-lazy-linter.test.ts create mode 100644 ui/scripts/__tests__/console-optimize-deps.test.ts delete mode 100644 ui/scripts/__tests__/objectstack-floor.test.ts delete mode 100644 ui/scripts/__tests__/objectstack-pin.test.ts delete mode 100644 ui/scripts/__tests__/quick-reference-current-release-4143.test.ts delete mode 100644 ui/scripts/__tests__/release-github-releases.test.ts delete mode 100644 ui/scripts/__tests__/sync-quick-reference-release.test.ts delete mode 100644 ui/scripts/__tests__/vite-objectstack-client-dist.test.ts delete mode 100644 ui/scripts/__tests__/vite-objectstack-spec-dist.test.ts create mode 100644 ui/scripts/__tests__/vite-single-zod.test.ts delete mode 100755 ui/scripts/bump-objectstack.sh delete mode 100644 ui/scripts/check-installed-spec-pin-claims.mjs delete mode 100644 ui/scripts/check-spec-range-floors.mjs delete mode 100644 ui/scripts/objectstack-floor.mjs delete mode 100644 ui/scripts/objectstack-pin.mjs delete mode 100644 ui/scripts/release-github-releases.mjs delete mode 100644 ui/scripts/sync-quick-reference-release.mjs delete mode 100644 ui/scripts/vite-objectstack-client-dist.ts delete mode 100644 ui/scripts/vite-objectstack-spec-dist.ts create mode 100644 ui/scripts/vite-single-zod.ts diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index 1b72036ef66..70a8206142a 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -1,22 +1,54 @@ { "//": [ "Provenance record for the repo-root `sdui.manifest.json` (ADR-0080; #12924 wiring ruling 2026-08-29).", - "The artefact is objectui's public-tier component manifest, generated from objectui's BUILT tree at the", - "commit `.objectui-sha` pins (`modulesRoot` below, materialised by `pnpm objectui:build`) by", - "scripts/gen-sdui-manifest-node.mjs, the one producer (ruling 丙 on #17735). Same enumeration as objectui's", - "apps/console/dev/manifest-dump.tsx: eager-import the 16 registry modules, ComponentRegistry.getPublicConfigs(),", - "manifestFromConfigs from @objectstack/sdui-parser. `objectuiWorkspaceVersion` is read from that tree's", - "@object-ui/core/package.json — the pinned commit's own declaration, never an npm tarball's.", - "`scripts/check-sdui-manifest.mjs` holds this record equal to the artefact and to the live pin:", - "a pin bump without regeneration goes RED there — that is the anti-rot half of the freshness gate.", - "Regenerate + re-record: pnpm objectui:build && node scripts/gen-sdui-manifest-node.mjs" + "The artefact is the console's public-tier component manifest, generated from THIS tree's registry by", + "scripts/gen-sdui-manifest-node.mjs, the one producer (ruling 丙 on #17735): it builds the registry packages under", + "ui/packages/, then enumerates them from `inTree.modulesRoot` exactly as ui/apps/console/dev/manifest-dump.tsx", + "does: eager-import the 16 registry modules, ComponentRegistry.getPublicConfigs(), manifestFromConfigs from", + "@objectstack/sdui-parser. `inTree.registrySourcesSha256` digests those packages' src/ as the index holds them;", + "`scripts/check-sdui-manifest.mjs` recomputes it, so a registry source edit without regeneration goes RED there.", + "`inTree.objectuiWorkspaceVersion` is provenance, not a verdict (#25712). Pin-keyed fields, where present, say the", + "artefact is byte-identical to the one generated at that pin.", + "Regenerate + re-record: node scripts/gen-sdui-manifest-node.mjs" ], - "objectuiSha": "b41ed7f68956c8cfda21bc790548361af81b09ac", "source": "built-tree", - "modulesRoot": ".cache/objectui-b41ed7f68956/apps/console", - "objectuiWorkspaceVersion": "18.1.0", "generator": "scripts/gen-sdui-manifest-node.mjs", "generatedAt": "2026-10-25", - "sha256": "d1fde278ffd643900aba9e8fcf834d35a9c345246709f1667ae71618eaad109c", - "components": 107 + "sha256": "1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7", + "components": 106, + "inTree": { + "modulesRoot": "ui/apps/console", + "objectuiWorkspaceVersion": "18.1.0", + "registryPackages": [ + "ui/packages/components", + "ui/packages/core", + "ui/packages/data-objectstack", + "ui/packages/fields", + "ui/packages/i18n", + "ui/packages/mobile", + "ui/packages/permissions", + "ui/packages/plugin-calendar", + "ui/packages/plugin-charts", + "ui/packages/plugin-dashboard", + "ui/packages/plugin-detail", + "ui/packages/plugin-form", + "ui/packages/plugin-gantt", + "ui/packages/plugin-grid", + "ui/packages/plugin-kanban", + "ui/packages/plugin-list", + "ui/packages/plugin-map", + "ui/packages/plugin-markdown", + "ui/packages/plugin-report", + "ui/packages/plugin-timeline", + "ui/packages/plugin-tree", + "ui/packages/plugin-view", + "ui/packages/providers", + "ui/packages/react", + "ui/packages/react-runtime", + "ui/packages/sdui-parser", + "ui/packages/types" + ], + "registrySources": 922, + "registrySourcesSha256": "35ef1abd88b0011a06bfc895b3091d1f8ecf319c3f78d2bfb0d9feb4e53f3bbe" + } } diff --git a/scripts/ui-import/4a-ui-side.patch b/scripts/ui-import/4a-ui-side.patch deleted file mode 100644 index f4883a723c1..00000000000 --- a/scripts/ui-import/4a-ui-side.patch +++ /dev/null @@ -1,1620 +0,0 @@ -4a-ui-side.patch: the ui/ half of sub-issue 4a (objectstack#25711), applied by -sub-issue 5's adaptation commit with `git apply --directory=ui` after the deletions -listed in scripts/ui-import/README.md. git apply skips every line above the first -file header, so this text is inert. - -Carries Tesla's rulings of 2026-10-25 (director thread, 09:06Z): - - decision 17b, ruling B: the private VS Code extension `object-ui` stays in the - one fixed group, so objectui's check-changeset-fixed and check-unused-dependencies - keep their extension rows, and only their config reads change; - - decision 17c, ruling B, carried here: QUICK_REFERENCE.md's release-synced - "Current Release" rows, scripts/sync-quick-reference-release.mjs and its two - tests are retired, not restored through the release workflows' allowlists. - -Written against the verification branch head 9e37acd126 (objectstack#25697 run 8 -tree: objectui main imported under ui/ plus the adaptations), after the README's -steps 1 and 2. - -diff --git a/.gitignore b/.gitignore -index fbb270c19d..3298ccde0c 100644 ---- a/.gitignore -+++ b/.gitignore -@@ -105,11 +105,6 @@ playwright-report - - .objectstack - --# objectui#13008: `.objectstack-pin/NAME/` tracks one file, the packed package.json --# of the pinned @objectstack/* build; `pnpm objectstack:build` links the rest beside it. --/.objectstack-pin/*/* --!/.objectstack-pin/*/package.json -- - .playwright-mcp - # Live e2e auth state (contains a session token) + artifacts - e2e/live/.auth/ -diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md -index b4f5092b75..7f96e932cd 100644 ---- a/CONTRIBUTING.md -+++ b/CONTRIBUTING.md -@@ -625,11 +625,11 @@ The release process is automated: - **The release PR is identified by its head branch, `changeset-release/main`, never - by its title.** The [Changesets action](https://github.com/changesets/action) - derives that branch from the base branch, so it is a stable convention that holds --across repositories. The title is not: it is the `title:` input this repository --passes to the action in --[`.github/workflows/changeset-release.yml`](.github/workflows/changeset-release.yml), --it is repository-local configuration, and it differs from repository to --repository. To locate the current release PR — its number and its title included -+across repositories. The title is not: it is the `title:` input the objectstack -+monorepo's `.github/workflows/release.yml` passes to the action (objectui's own -+`changeset-release.yml` retired when the two scopes joined one release, -+objectstack#25711), it is repository-local configuration, and it differs from -+repository to repository. To locate the current release PR — its number and its title included - — search the head branch: - - ```text -diff --git a/QUICK_REFERENCE.md b/QUICK_REFERENCE.md -index f58f06776c..283160e7a9 100644 ---- a/QUICK_REFERENCE.md -+++ b/QUICK_REFERENCE.md -@@ -115,28 +115,13 @@ pnpm changeset publish # Publish to npm (CI only) - - ## Current Release - --Every value below is pinned to the manifest that owns it by --`scripts/__tests__/quick-reference-current-release-4143.test.ts` — edit the anchor and --that test tells you to edit this block. The one exception is called out on its row. -- --You rarely have to edit it by hand. `pnpm quick-reference:sync` rewrites these rows from --the manifests, and `pnpm quick-reference:check` reports drift without writing. The --release path runs the sync itself: `changeset:version` bumps every manifest and updates --this block in the same commit, so a release can no longer leave the block a version --behind (objectui#5394 — that had happened once per release, three times). -- --- **Version:** 18.1.0 (the version every `@object-ui/*` manifest carries — they are one -- `fixed` group in `.changeset/config.json`, so a release moves all of them together) --- **Spec:** `@objectstack/spec` ^18.0.0-next.1 (declared by the root `package.json` and by -- `apps/console/package.json`) --- **Client:** `@objectstack/client` ^18.0.0-next.1 (declared by `apps/console/package.json` -- and `packages/data-objectstack/package.json`) --- **Node.js:** ≥ 22.13 (see root `engines.node`) --- **pnpm:** ≥ 10 (the workspace pins `pnpm@10.31.0` via `packageManager`) --- **React:** 19.x (the `peerDependencies.react` range the packages declare) --- **TypeScript:** ≥ 5.0 (strict mode) — the stack floor stated in AGENTS.md §2, not a -- manifest fact: nothing in this tree declares a `typescript` range to check it against, -- so this is the one row above that no test can hold to account. -- --> Pending unreleased work is queued in `.changeset/` — list that directory to see what is --> staged for the next release. -+The release version is the monorepo's. Every `@object-ui/*` package is in one Changesets -+`fixed` group with every `@objectstack/*` package (`../.changeset/config.json`), so a -+release moves all of them together and any package's `package.json` names the current -+version. The toolchain floors are this tree's `package.json`: `engines` and -+`packageManager`. This block deliberately holds no version literal: the rows that did, -+and the script that rewrote them at `changeset:version`, were retired when objectui's own -+release lane left (objectstack#25711, decision 17c). -+ -+> Pending unreleased work is queued in the monorepo's `../.changeset/` — list that -+> directory to see what is staged for the next release. -diff --git a/apps/console/vite.config.ts b/apps/console/vite.config.ts -index 6800efa897..de0b876d06 100644 ---- a/apps/console/vite.config.ts -+++ b/apps/console/vite.config.ts -@@ -17,12 +17,7 @@ import { execFileSync } from 'node:child_process'; - // `native` becomes the default loader (objectui#3384). - import { viteCryptoStub } from '../../scripts/vite-crypto-stub.ts'; - import { viteMaplibreWorker } from '../../scripts/vite-maplibre-worker.ts'; --import { resolveClientDistInjection, widenVendorChunkTestForClient } from '../../scripts/vite-objectstack-client-dist.ts'; --import { -- assertSingleZodInstance, -- formatConditionReport, -- resolveSpecDistInjection, --} from '../../scripts/vite-objectstack-spec-dist.ts'; -+import { assertSingleZodInstance } from '../../scripts/vite-single-zod.ts'; - import { viteIneffectiveDynamicImports } from '../../scripts/vite-ineffective-dynamic-imports.ts'; - import { frameworkPackagesDir, workspaceChunkTests } from '../../scripts/vite-objectstack-workspace.ts'; - import { viteDeclaredLazyViews } from '../../scripts/vite-declared-lazy-views.ts'; -@@ -85,26 +80,20 @@ function preloadCriticalChunks(): Plugin { - * closure walk has proven it can see the very chunk the linter used to hide in. - * - * That makes both module-id tests SUBJECTS of the check, not decoration, and -- * neither may be hardcoded here. `OBJECTSTACK_SPEC_DIST` rewrites all 18 -- * `@objectstack/spec` specifiers to absolute paths in the overriding tree — -- * `/…/objectstack/packages/spec/dist/…/index.mjs`, ids with no -- * `@objectstack` segment at all — so a private `/@objectstack[\\/+]spec/` -- * matched zero modules, this counter-probe correctly refused a verdict, and it -- * took every injected build down with it (objectui#5388; measured from the -- * consumer side in objectstack#10136, where it blocked the framework's Console -- * Pin Gate). The spec test is therefore a PARAMETER, fed from the same -- * `resolveSpecDistInjection` output the `vendor-objectstack` group already -- * reads: one producer, both consumers, no second opinion about where the spec -- * lives. -+ * neither may be hardcoded here. A spec reached through a path with no -+ * `@objectstack` segment — the monorepo's own `packages/spec/dist/…` — made a -+ * private `/@objectstack[\\/+]spec/` match zero modules, so this counter-probe -+ * correctly refused a verdict and took the build down with it (objectui#5388). -+ * The spec test is therefore a PARAMETER, fed from the same -+ * `workspaceChunkTests` producer the `vendor-objectstack` group reads: one -+ * producer, both consumers, no second opinion about where the spec lives. - * -- * `LINT` stays literal — nothing injects `@objectstack/lint` today — but it gets -- * its own counter-probe for the same reason, because its failure mode is the -- * worse one. The spec test going blind fails LOUD (this build stops). The lint -- * test going blind fails SILENT: the assertion below is negative, so zero -- * matches reads exactly like a clean bundle and the guard is green forever. -- * objectstack#9659 proposes injecting four more `@objectstack/*` packages the -- * same way and lint is a plausible member; if it ever joins, this build says so -- * instead of quietly ceasing to guard anything. -+ * `LINT` gets its own counter-probe for the same reason, because its failure -+ * mode is the worse one. The spec test going blind fails LOUD (this build -+ * stops). The lint test going blind fails SILENT: the assertion below is -+ * negative, so zero matches reads exactly like a clean bundle and the guard is -+ * green forever; if the linter's module ids ever change shape, this build says -+ * so instead of quietly ceasing to guard anything. - */ - function assertLazyLinterStaysLazy(specTest: RegExp): Plugin { - const LINT = WORKSPACE_CHUNK_TESTS.lint; -@@ -144,9 +133,6 @@ function assertLazyLinterStaysLazy(specTest: RegExp): Plugin { - `from the app entry, so it must be in the eager closure — its absence means ` + - `this walk is reading the graph wrongly, not that the bundle improved. ` + - `Fix the walk before trusting the linter assertion below. ` + -- `If \`OBJECTSTACK_SPEC_DIST\` is set, check that this test carries the ` + -- `override's location: an injected spec resolves outside node_modules and the ` + -- `baseline test cannot see it (objectui#5388). ` + - `(spec test: ${specTest}; ` + - `entry chunks: ${[...chunks.values()].filter((c) => c.isEntry).map((c) => c.fileName).join(', ') || 'NONE'}; ` + - `eager chunks: ${eager.size}/${chunks.size})`, -@@ -168,10 +154,8 @@ function assertLazyLinterStaysLazy(specTest: RegExp): Plugin { - `this bundle and the assertion below can no longer fail. Two ways to get here. ` + - `Either the lazy import in app-shell's \`capabilityLint.ts\` is gone, in which case ` + - `retire this plugin deliberately rather than leaving a guard with no subject; or ` + -- `the linter's module ids changed shape — e.g. \`@objectstack/lint\` joined the ` + -- `\`OBJECTSTACK_SPEC_DIST\`-style injection proposed in objectstack#9659, which ` + -- `rewrites specifiers to absolute paths with no \`@objectstack\` segment. In that ` + -- `case give this test the injection's location, exactly as the spec test above is ` + -+ `the linter's module ids changed shape (a path with no \`@objectstack\` segment). ` + -+ `In that case give this test the new location, exactly as the spec test above is ` + - `already parameterised (objectui#5388). (chunks: ${chunks.size})`, - ); - } -@@ -327,10 +311,8 @@ function emitEagerClosureReport(reportFileName = 'eager-closure.json'): Plugin { - `Fix the import rather than skipping the member here: a bare specifier the browser ` + - `cannot load is a broken bundle, not a measurement gap, and dropping it from the ` + - `walk would make this report under-count — the one direction the counter-probes ` + -- `above exist to refuse. The known source is an out-of-tree override whose own ` + -- `dependencies do not resolve from where it lives: check \`OBJECTSTACK_CLIENT_DIST\` ` + -- `and \`OBJECTSTACK_SPEC_DIST\`, and vite's own "could not be resolved" warnings ` + -- `earlier in this build.`, -+ `above exist to refuse. Read vite's own "could not be resolved" warnings earlier ` + -+ `in this build.`, - ); - } - -@@ -671,31 +653,6 @@ const workspaceAliases: Record = { - '@object-ui/plugin-designer': path.resolve(import.meta.dirname, '../../packages/plugin-designer/src'), - }; - --// Opt-in override of the installed `@objectstack/client`. The published client --// (11.2.0) predates the async import-job API (`data.createImportJob` et al.), --// so to exercise the full background-import + undo flow through the real --// console before that client ships, point OBJECTSTACK_CLIENT_DIST at a locally --// built client (its dist entry or package dir). Inert when unset — production --// and CI builds use the installed client unchanged. --// --// No longer a bare string alias: the value is VALIDATED before it is aliased — --// the path must exist, it must sit inside a `@objectstack/client` package --// (directory, `dist/`, or an entry file all resolve to the same package), and --// that package's own declared `dependencies` must resolve from where it lives. --// Without that last check an out-of-tree override produced a fully written --// bundle whose bare specifiers no browser can load, and nothing in the build --// output named this variable — objectui#6094, the client twin of the spec --// hook's objectui#5391. See the module for the measurement and for why the --// check is re-stated there rather than shared with the spec hook. --const clientDistInjection = resolveClientDistInjection(process.env.OBJECTSTACK_CLIENT_DIST); --if (clientDistInjection) workspaceAliases['@objectstack/client'] = clientDistInjection.aliasTarget; -- --// Extra dirs the dev server may read the override from — it lives outside the --// workspace root, so Vite's default `server.fs.allow` would 403 it (blank page). --// Unchanged values: the containing package (…/dist/index.mjs → …/) and its --// parent, now computed beside the validation that vouches for the path. --const clientFsAllow: string[] = clientDistInjection ? clientDistInjection.fsAllow : []; -- - // Deps pre-bundled for the dev server. Build-time pre-bundling was removed in - // Vite 5.1, so this list is read by `pnpm dev` only, never by `vite build` — - // which is why no build or E2E job can see a bad entry here. -@@ -707,7 +664,7 @@ const clientFsAllow: string[] = clientDistInjection ? clientDistInjection.fsAllo - // package this app does not depend on is skipped with a warning and pre-bundles - // nothing: `maplibre-gl` was one, and needs no entry of its own, because the - // `react-map-gl/maplibre` pre-bundle already carries it. The console-config --// cases in `scripts/__tests__/vite-objectstack-spec-dist.test.ts` resolve every -+// case in `scripts/__tests__/console-optimize-deps.test.ts` resolves every - // entry the way the dev server does. - const OPTIMIZE_DEPS_INCLUDE = [ - '@objectstack/spec', -@@ -763,81 +720,6 @@ const VENDOR_OBJECTSTACK_TEST = WORKSPACE_CHUNK_TESTS.vendor; - // plus the monorepo's workspace path (see `WORKSPACE_CHUNK_TESTS` above). - const SPEC_MODULE_TEST = WORKSPACE_CHUNK_TESTS.spec; - --// Opt-in override of the installed `@objectstack/spec` — the spec twin of --// OBJECTSTACK_CLIENT_DIST above, so a framework build can bundle the console --// against its OWN spec instead of the last published one (objectui#4854, ruled --// on objectstack#8134). Point it at a built spec package (its directory, its --// `dist/`, or an entry file inside it). --// --// It is NOT a copy of the client line: `@objectstack/spec` publishes an 18-entry --// exports map that redirects every subpath into `dist/`, and a Vite string alias --// does not consult exports maps. So the injection is derived from the OVERRIDE's --// own exports map, one alias per entry — see the module for the derivation and --// for why every failure mode throws instead of falling back. --// --// Inert when unset: `null` here leaves the alias table, the pre-bundle list, the --// vendor chunk test and the dev server's fs allow-list at their baseline values. --// --// The injected spec also arrives with its OWN install tree, so left alone its --// bare `zod` import resolves to the framework's zod and the bundle carries two --// instances — at the console pin objectui#11327 was reported against, the --// Studio's spec-derived forms crashed in `z.toJSONSchema` over them. The --// injection therefore pins every `zod` import to ONE --// copy: the one `CONSOLE_ZOD_ANCHOR` resolves. That package is the anchor --// because it is where the console walks spec schemas with its own zod (the --// metadata-admin `*-schema.ts` modules) and it declares `zod` itself; the --// injection refuses the build when that copy is outside the range the --// injected spec declares. --const CONSOLE_ZOD_ANCHOR = path.resolve(import.meta.dirname, '../../packages/app-shell'); --const specDistInjection = resolveSpecDistInjection(process.env.OBJECTSTACK_SPEC_DIST, { -- vendorChunkTest: VENDOR_OBJECTSTACK_TEST, -- specModuleTest: SPEC_MODULE_TEST, -- consoleZodFrom: CONSOLE_ZOD_ANCHOR, --}); --if (specDistInjection) Object.assign(workspaceAliases, specDistInjection.aliases); -- --// objectui#9408 — say which condition arm every entry came from, out loud. --// --// The resolver used to walk its OWN preference array instead of the exports --// map's key order, so the five `browser`-first entries silently resolved to the --// Node arm and the arm added to keep a browser build off `require('fs')` was --// unreachable. Nothing in the build said so. The fix makes the pick correct; the --// table makes it CHECKABLE — a wrong arm is now one grep in the build log --// instead of an unattributable bundler error at some later pin bump. --// --// Printed only when the override is live, so a normal build stays quiet, and to --// stderr so it cannot land in anything that parses stdout. --if (specDistInjection) { -- for (const line of formatConditionReport(specDistInjection)) console.error(line); --} -- --const specFsAllow: string[] = specDistInjection ? specDistInjection.fsAllow : []; -- --// Pre-bundling an ALIASED, out-of-workspace dep is opt-in through this list and --// nothing else: Vite's pre-alias plugin only registers such a resolution as a --// dep when `optimizeDeps.include` names the specifier. Keeping the four spec --// entries while the override is live would therefore park the injected spec in --// `node_modules/.vite`, whose cache key does not move when the framework --// rebuilds its spec in place — a stale pre-bundle serving yesterday's schema is --// the same silent skew this hook exists to end. Dropping them costs a colder --// dev start and nothing else; `vite build` never reads this list. --const optimizeDepsInclude = specDistInjection -- ? OPTIMIZE_DEPS_INCLUDE.filter((specifier) => !Object.hasOwn(specDistInjection.aliases, specifier)) -- : OPTIMIZE_DEPS_INCLUDE; -- --// An injected spec resolves OUTSIDE node_modules, so the baseline test above --// stops matching it and the biggest vendor surface in the bundle (spec is --// imported by 29 packages here) would scatter into its importers' chunks. The --// injected build should differ from a released one in spec CONTENT, not in --// chunk layout, so the override's location joins the group's test. The same --// holds for an injected CLIENT, and there the layout is not cosmetic: left out --// of this group it lands in `framework`, closing a framework ↔ data-adapter --// import cycle that kills the console at boot (see the helper's docblock). --const vendorObjectstackTest = widenVendorChunkTestForClient( -- specDistInjection ? specDistInjection.vendorChunkTest : VENDOR_OBJECTSTACK_TEST, -- clientDistInjection, --); -- - // A lucide icon module, captured by its file name (`circle-arrow-up`). Read by - // the `vendor-icons-shell` group below. - const LUCIDE_ICON_MODULE = /[\\/]node_modules[\\/]lucide-react[\\/]dist[\\/]esm[\\/]icons[\\/]([a-z0-9-]+)\.mjs$/; -@@ -851,16 +733,6 @@ const LAZY_ONLY_INITIAL_ICONS: ReadonlySet = new Set([ - 'webhook', - ]); - --// The chunk grouping is not the only consumer of "where does the spec live". --// `assertLazyLinterStaysLazy`'s counter-probe asks the same question about the --// emitted module ids, and it used to answer it from a private regex that the --// injection made unmatchable — so every build with the override set died on a --// counter-probe that was right about what it saw (objectui#5388). It reads the --// override's location here, from the same producer, for the same reason. --const specModuleTest = specDistInjection -- ? specDistInjection.specModuleTest -- : SPEC_MODULE_TEST; -- - // https://vitejs.dev/config/ - export default defineConfig({ - base: basePath, -@@ -879,15 +751,11 @@ export default defineConfig({ - // Fails the build if the lazily-imported linter is folded back into an - // eagerly-loaded chunk. Runs on CI/Vercel too — it costs microseconds and - // the regression it catches is invisible in every other signal. -- assertLazyLinterStaysLazy(specModuleTest), -- // Under OBJECTSTACK_SPEC_DIST: resolve every bare `zod` import — the injected -- // spec's included — to the console's one copy (objectui#11327). Registered -- // only while the override is live, like every other injection surface. -- ...(specDistInjection ? [specDistInjection.singleZodPlugin] : []), -- // …and in EVERY build, injected or not: fail when the emitted chunks carry -- // more than one zod instance, or none at all (the counter-probe). Measures -- // the outcome rather than trusting the redirect above, because the first -- // mechanism tried for this (`resolve.dedupe`, see below) was a silent no-op. -+ assertLazyLinterStaysLazy(SPEC_MODULE_TEST), -+ // In EVERY build: fail when the emitted chunks carry more than one zod -+ // instance, or none at all (the counter-probe), objectui#11327. Measures the -+ // outcome rather than trusting a mechanism, because the first mechanism -+ // tried for this (`resolve.dedupe`, see below) was a silent no-op. - assertSingleZodInstance(), - // The same refusal one directory over: fails the build if the - // `@object-ui/types/zod` validators rejoin the eager closure, AND if they -@@ -977,14 +845,13 @@ export default defineConfig({ - // ⛔ `zod` is deliberately NOT listed. Dedupe resolves a listed package from - // the Vite root, and this root (`apps/console`) declares no `zod`: the - // lookup finds nothing and Vite falls back to each importer's own copy -- // without a warning. Measured on objectui#11327 with OBJECTSTACK_SPEC_DIST -- // set — adding `zod` here emitted the same two zod copies, byte-identical. -- // The one-zod rule is held by `specDistInjection.singleZodPlugin` and -- // checked by `assertSingleZodInstance` in the plugin list above. -+ // without a warning. Measured on objectui#11327 — adding `zod` here emitted -+ // the same two zod copies, byte-identical. The one-zod rule is checked by -+ // `assertSingleZodInstance` in the plugin list above. - dedupe: ['react', 'react-dom', 'sonner'], - }, - optimizeDeps: { -- include: optimizeDepsInclude -+ include: OPTIMIZE_DEPS_INCLUDE - }, - build: { - target: 'esnext', -@@ -1082,7 +949,7 @@ export default defineConfig({ - // diagnostic. Dropping the tag puts the four modules back on every - // page load, and the lowered ceiling in - // `scripts/check-eager-closure-budget.mjs` is what reds on it. -- { name: 'vendor-objectstack', test: vendorObjectstackTest, priority: 95, tags: ['$initial'] }, -+ { name: 'vendor-objectstack', test: VENDOR_OBJECTSTACK_TEST, priority: 95, tags: ['$initial'] }, - { name: 'vendor-icons-core', test: /[\\/]node_modules[\\/]lucide-react[\\/]dist[\\/](lucide-react|esm[\\/](Icon|createLucideIcon|defaultAttributes|shared))/, priority: 90 }, - // - // ## The icons the eager shell imports by name share ONE chunk (objectui#12187) -@@ -1615,13 +1482,6 @@ export default defineConfig({ - }, - server: { - port: 5180, -- // Widen the fs allow-list only when an out-of-tree override is set (see -- // OBJECTSTACK_CLIENT_DIST / OBJECTSTACK_SPEC_DIST above); otherwise keep -- // Vite's defaults. Both overrides live outside the workspace root, which -- // Vite's default `fs.allow` serves as a 403 (blank page, no build error). -- ...(clientFsAllow.length || specFsAllow.length -- ? { fs: { allow: [path.resolve(import.meta.dirname, '../..'), ...clientFsAllow, ...specFsAllow] } } -- : {}), - proxy: { - '/api': { target: process.env.DEV_PROXY_TARGET || 'http://localhost:3000', changeOrigin: true }, - }, -diff --git a/eslint.config.js b/eslint.config.js -index 8861e1cb5f..b8bea1bbed 100644 ---- a/eslint.config.js -+++ b/eslint.config.js -@@ -54,12 +54,6 @@ export default tseslint.config({ - 'apps/console/plugin.js', - 'apps/console/plugin.d.ts', - 'apps/site/next-env.d.ts', -- // objectui#13008 — the pinned @objectstack/* build, linked by -- // `scripts/objectstack-pin.mjs build` beside the committed manifests and -- // git-ignored (`.gitignore`, "/.objectstack-pin/*/*"); the spec packs its -- // TypeScript `src/`. The one tracked file per package is a `package.json`, -- // which no rule here lints. -- '.objectstack-pin', - ], - }, { - // objectui#4853 — a stale `eslint-disable` is an ERROR, not a warning. -diff --git a/package.json b/package.json -index e722c7596d..6eb05d7a73 100644 ---- a/package.json -+++ b/package.json -@@ -16,9 +16,7 @@ - "docs" - ], - "scripts": { -- "setup": "node scripts/objectstack-pin.mjs build && pnpm install --frozen-lockfile", -- "objectstack:build": "node scripts/objectstack-pin.mjs build", -- "objectstack:status": "node scripts/objectstack-pin.mjs status", -+ "setup": "pnpm install --frozen-lockfile", - "dev": "pnpm --filter @object-ui/console dev", - "start": "pnpm --filter @object-ui/console preview", - "test": "vitest run", -@@ -60,8 +58,6 @@ - "check:action-ref-convention": "node scripts/check-action-ref-convention.mjs", - "check:published-dist": "node scripts/check-published-dist-tooling.mjs", - "check:published-tsconfig-exclude": "node scripts/check-published-tsconfig-tooling-exclude.mjs", -- "check:spec-floors": "node scripts/check-spec-range-floors.mjs", -- "check:installed-pin-claims": "node scripts/check-installed-spec-pin-claims.mjs", - "check:i18n-keys": "node scripts/check-i18n-call-site-keys.mjs", - "check:i18n-drift": "node scripts/check-i18n-en-drift.mjs", - "check:i18n-dead-keys": "node scripts/check-i18n-dead-keys.mjs", -@@ -126,10 +122,8 @@ - "shadcn:list": "node scripts/shadcn-sync.js --list", - "changeset:check": "node scripts/check-changeset-fixed.mjs && node scripts/check-changeset-no-major.mjs", - "changeset": "changeset", -- "changeset:version": "changeset version && node scripts/sync-quick-reference-release.mjs", -- "quick-reference:sync": "node scripts/sync-quick-reference-release.mjs", -- "quick-reference:check": "node scripts/sync-quick-reference-release.mjs --check", -- "changeset:publish": "node scripts/objectstack-floor.mjs && node scripts/check-published-dist-tooling.mjs && node scripts/check-spec-range-floors.mjs && changeset publish", -+ "changeset:version": "changeset version", -+ "changeset:publish": "node scripts/check-published-dist-tooling.mjs && changeset publish", - "test:e2e": "playwright test", - "test:e2e:ui": "playwright test --ui", - "test:e2e:live": "playwright test --config=playwright.live.config.ts", -diff --git a/scripts/__tests__/check-changeset-fixed.test.ts b/scripts/__tests__/check-changeset-fixed.test.ts -index 58904d0be8..c41e20d361 100644 ---- a/scripts/__tests__/check-changeset-fixed.test.ts -+++ b/scripts/__tests__/check-changeset-fixed.test.ts -@@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url'; - // `tsconfig.scripts.json` (`allowJs`), so no `@ts-expect-error` here — - // re-adding one is now itself an error (TS2578). See objectui#3494. - import { analyze, globMatcher, normalizePrivatePackages } from '../check-changeset-fixed.mjs'; -+import { changesetConfigPath } from '../workspace-root.mjs'; - - /** - * `scripts/check-changeset-fixed.mjs` used to answer one question — is every -@@ -128,7 +129,7 @@ describe('analyze', () => { - - describe('the repository itself', () => { - const config = JSON.parse( -- fs.readFileSync(path.join(repoRoot, '.changeset/config.json'), 'utf8') -+ fs.readFileSync(changesetConfigPath(repoRoot), 'utf8') - ) as { fixed?: string[][]; ignore?: string[]; privatePackages?: unknown }; - - const manifests = ['packages', 'apps'].flatMap((dir) => -diff --git a/scripts/__tests__/check-lockfile-dedupe.test.ts b/scripts/__tests__/check-lockfile-dedupe.test.ts -index c81bfcb92f..4c83ff8bb0 100644 ---- a/scripts/__tests__/check-lockfile-dedupe.test.ts -+++ b/scripts/__tests__/check-lockfile-dedupe.test.ts -@@ -114,9 +114,7 @@ import { fileURLToPath } from 'node:url'; - - import { describe, expect, it } from 'vitest'; - --import { DEDUPE_SENTINEL, pinDeclared, pinnedBuildMissing } from '../check-lockfile-dedupe.mjs'; --import { CACHE_DIR, OVERRIDE_PREFIX, STAMP_FILE } from '../objectstack-pin.mjs'; --import { isNestedWorkspace } from '../workspace-root.mjs'; -+import { DEDUPE_SENTINEL } from '../check-lockfile-dedupe.mjs'; - import { FLOORS, TEST_FILE } from '../check-test-path-roots.mjs'; - import { NOT_A_GATE, OPTIONAL_CONTEXTS, REQUIRED_CONTEXTS } from '../dependabot-merge-gate.mjs'; - import { maskComments } from '../js-comment-mask.mjs'; -@@ -329,43 +327,6 @@ function runChecker(stub: { stdout?: string; stderr?: string; exit: number }, ar - } - } - --describe('the pinned @objectstack/* build is the checker\'s precondition (objectui#12957)', () => { -- // `pnpm dedupe --check` re-reads the `file:` directories every `@objectstack/*` -- // package resolves to; without them pnpm stops before judging anything. The -- // checker reads the build's stamp first and names that cause. -- it('reads a root with no build as missing, and the same root with a stamp as present', () => { -- const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lockfile-dedupe-pin-')); -- try { -- expect(pinnedBuildMissing(root)).toBe(true); -- fs.mkdirSync(path.join(root, CACHE_DIR), { recursive: true }); -- fs.writeFileSync(path.join(root, CACHE_DIR, STAMP_FILE), '{}'); -- expect(pinnedBuildMissing(root)).toBe(false); -- } finally { -- fs.rmSync(root, { recursive: true, force: true }); -- } -- }); -- -- it('applies only where the manifest pnpm reads overrides from declares the pin (objectstack#25723)', () => { -- // Standalone the root manifest declares `@objectstack/*: file:.objectstack-pin/...` -- // and the stamp is a precondition; under the monorepo nothing does, the -- // `@objectstack/*` packages are workspace members, and there is no build -- // whose absence could send `pnpm dedupe --check` to the registry. -- expect(pinDeclared({ pnpm: { overrides: { '@objectstack/spec': `${OVERRIDE_PREFIX}spec`, react: '19.3.0' } } })).toBe(true); -- expect(pinDeclared({ pnpm: { overrides: { react: '19.3.0' } } })).toBe(false); -- expect(pinDeclared({ pnpm: {} })).toBe(false); -- expect(pinDeclared({})).toBe(false); -- expect(pinDeclared()).toBe(!isNestedWorkspace()); -- }); -- -- it('checks before it spawns pnpm, and says so in a could-not-run verdict', () => { -- const source = fs.readFileSync(path.join(repoRoot, SCRIPT), 'utf8'); -- const guard = source.indexOf('if (pinDeclared() && pinnedBuildMissing())'); -- expect(guard, 'the precondition is gone from main()').toBeGreaterThan(0); -- expect(guard).toBeLessThan(source.indexOf("spawnSync('pnpm', ['dedupe', '--check']")); -- expect(source.slice(guard, source.indexOf("spawnSync('pnpm', ['dedupe', '--check']"))).toContain("outcome: 'cannot-run'"); -- }); --}); -- - describe('the lockfile-dedupe checker', () => { - it('ships its own self-test, and it passes as shipped', () => { - const run = spawnSync('node', [SCRIPT, '--self-test'], { cwd: repoRoot, encoding: 'utf8' }); -diff --git a/scripts/__tests__/console-lazy-linter.test.ts b/scripts/__tests__/console-lazy-linter.test.ts -new file mode 100644 -index 0000000000..8a65e29615 ---- /dev/null -+++ b/scripts/__tests__/console-lazy-linter.test.ts -@@ -0,0 +1,151 @@ -+import { describe, expect, it } from 'vitest'; -+ -+/** -+ * The console config's `assert-lazy-linter-stays-lazy` guard (objectui#5266, -+ * objectui#5323): the lazily imported `@objectstack/lint` must stay out of every -+ * eagerly loaded chunk, and the guard's counter-probe refuses a verdict when it -+ * cannot see the spec chunk it measures against. -+ * -+ * Moved here from `vite-objectstack-spec-dist.test.ts` when that module's -+ * `OBJECTSTACK_SPEC_DIST` injection left with the objectstack pin -+ * (objectstack#25711). The baseline cases are kept as they were; the cases that -+ * pointed the override at an out-of-tree spec left with it. The workspace -+ * spelling of the spec (`/packages/spec/dist/...`) is held by -+ * `vite-objectstack-workspace.test.ts`. -+ * -+ * These cases drive the REAL plugin off the REAL config over a synthetic bundle, -+ * rather than asserting on the regex the config hands it: the defect this guards -+ * was never a wrong regex, it was a correct regex reaching one consumer and not -+ * the other. -+ */ -+ -+async function loadConsoleConfig(query = ''): Promise { -+ const specifier = `../../apps/console/vite.config.ts${query}`; -+ return (await import(/* @vite-ignore */ specifier)).default; -+} -+ -+interface ProbeChunk { -+ type: 'chunk'; -+ fileName: string; -+ isEntry: boolean; -+ imports: string[]; -+ modules: Record; -+} -+ -+/** A pnpm-store module id for the linter, the spelling a real bundle carries. */ -+const LINT_MODULE_ID = -+ '/repo/node_modules/.pnpm/@objectstack+lint@17.0.0/node_modules/@objectstack/lint/dist/index.js'; -+/** The installed spec's module id, the spelling a node_modules build emits. */ -+const INSTALLED_SPEC_MODULE_ID = '/repo/node_modules/@objectstack/spec/dist/index.mjs'; -+ -+const probeChunk = ( -+ fileName: string, -+ modules: string[], -+ extra: Partial = {} -+): ProbeChunk => ({ -+ type: 'chunk', -+ fileName, -+ isEntry: false, -+ imports: [], -+ modules: Object.fromEntries(modules.map((id) => [id, {}])), -+ ...extra, -+}); -+ -+/** -+ * A bundle shaped like the console's: one entry, one statically imported vendor -+ * chunk holding the spec, and the linter parked behind a dynamic import — which -+ * the plugin's walk deliberately does not follow. -+ * -+ * @param specModuleId the spec id the vendor chunk carries -+ * @param lintFileName which chunk holds the linter, or `null` for none at all -+ */ -+function consoleShapedBundle( -+ specModuleId: string, -+ lintFileName: 'assets/vendor-objectstack.js' | 'assets/lint-lazy.js' | null -+): Record { -+ const vendorModules = [specModuleId]; -+ if (lintFileName === 'assets/vendor-objectstack.js') vendorModules.push(LINT_MODULE_ID); -+ const bundle: Record = { -+ 'assets/index.js': probeChunk('assets/index.js', ['/repo/apps/console/src/main.tsx'], { -+ isEntry: true, -+ imports: ['assets/vendor-objectstack.js'], -+ }), -+ 'assets/vendor-objectstack.js': probeChunk('assets/vendor-objectstack.js', vendorModules), -+ }; -+ if (lintFileName === 'assets/lint-lazy.js') { -+ bundle['assets/lint-lazy.js'] = probeChunk('assets/lint-lazy.js', [LINT_MODULE_ID]); -+ } -+ return bundle; -+} -+ -+/** Runs the console's own guard over one bundle; returns its error, or `null`. */ -+function runLazyLinterProbe(config: any, bundle: Record): string | null { -+ const plugins = (config.plugins as unknown[]).flat(Infinity) as { -+ name?: string; -+ generateBundle?: (this: unknown, options: unknown, bundle: unknown) => void; -+ }[]; -+ const plugin = plugins.find((p) => p && p.name === 'assert-lazy-linter-stays-lazy'); -+ expect(plugin, 'the console config registers assert-lazy-linter-stays-lazy').toBeDefined(); -+ const context = { -+ error(message: string): never { -+ throw new Error(message); -+ }, -+ }; -+ try { -+ plugin!.generateBundle!.call(context, {}, bundle); -+ return null; -+ } catch (error) { -+ return (error as Error).message; -+ } -+} -+ -+describe('the lazy-linter guard, read off the real console config', () => { -+ it('passes a bundle whose eager closure holds the spec and whose linter is lazy', async () => { -+ const config = await loadConsoleConfig(); -+ expect( -+ runLazyLinterProbe(config, consoleShapedBundle(INSTALLED_SPEC_MODULE_ID, 'assets/lint-lazy.js')) -+ ).toBeNull(); -+ }); -+ -+ it('catches an EAGER linter', async () => { -+ const config = await loadConsoleConfig(); -+ const message = runLazyLinterProbe( -+ config, -+ consoleShapedBundle(INSTALLED_SPEC_MODULE_ID, 'assets/vendor-objectstack.js') -+ ); -+ expect(message).toContain('`@objectstack/lint` is in the EAGER closure'); -+ expect(message).toContain('assets/vendor-objectstack.js'); -+ }); -+ -+ it('refuses a verdict when the eager closure holds no spec, and an eager client is not a spec', async () => { -+ const config = await loadConsoleConfig(); -+ const noSpec: Record = { -+ 'assets/index.js': probeChunk('assets/index.js', ['/repo/apps/console/src/main.tsx'], { -+ isEntry: true, -+ }), -+ 'assets/lint-lazy.js': probeChunk('assets/lint-lazy.js', [LINT_MODULE_ID]), -+ }; -+ expect(runLazyLinterProbe(config, noSpec)).toContain('counter-probe failed'); -+ const clientOnly: Record = { -+ 'assets/index.js': probeChunk('assets/index.js', ['/repo/apps/console/src/main.tsx'], { -+ isEntry: true, -+ imports: ['assets/vendor-objectstack.js'], -+ }), -+ 'assets/vendor-objectstack.js': probeChunk('assets/vendor-objectstack.js', [ -+ '/repo/node_modules/@objectstack/client/dist/index.mjs', -+ ]), -+ 'assets/lint-lazy.js': probeChunk('assets/lint-lazy.js', [LINT_MODULE_ID]), -+ }; -+ expect(runLazyLinterProbe(config, clientOnly)).toContain('counter-probe failed'); -+ }); -+ -+ it('refuses a verdict when the LINT test itself has gone blind', async () => { -+ // The linter half's failure mode is the silent one: the assertion on it is -+ // negative, so a regex that stopped matching the emitted ids is -+ // indistinguishable from a clean bundle. -+ const config = await loadConsoleConfig(); -+ const message = runLazyLinterProbe(config, consoleShapedBundle(INSTALLED_SPEC_MODULE_ID, null)); -+ expect(message).toContain('counter-probe failed'); -+ expect(message).toContain('no chunk at all'); -+ }); -+}); -diff --git a/scripts/__tests__/console-optimize-deps.test.ts b/scripts/__tests__/console-optimize-deps.test.ts -new file mode 100644 -index 0000000000..f03a3f5416 ---- /dev/null -+++ b/scripts/__tests__/console-optimize-deps.test.ts -@@ -0,0 +1,132 @@ -+import { describe, expect, it } from 'vitest'; -+import { resolveConfig } from 'vite'; -+import type { InlineConfig } from 'vite'; -+import path from 'node:path'; -+import { fileURLToPath } from 'node:url'; -+ -+/** -+ * objectui#10865 — every `optimizeDeps.include` entry of the console config -+ * resolves the way `pnpm dev` resolves it. -+ * -+ * Moved here from `vite-objectstack-spec-dist.test.ts` when that module's -+ * `OBJECTSTACK_SPEC_DIST` injection left with the objectstack pin -+ * (objectstack#25711): this case is about the console config, not the -+ * injection, and it is kept as it was. -+ */ -+ -+const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -+ -+/** The spec package, the subject of the "aborts" control below. */ -+const SPEC_PACKAGE_NAME = '@objectstack/spec'; -+ -+/** -+ * A package the console certainly resolves from its own root: the control that -+ * proves the dev resolver resolves anything at all. -+ */ -+const VITE_ORACLE_CONTROL = 'react'; -+ -+async function loadConsoleConfig(query = ''): Promise { -+ const specifier = `../../apps/console/vite.config.ts${query}`; -+ return (await import(/* @vite-ignore */ specifier)).default; -+} -+ -+/** The directory `pnpm dev` runs Vite in, and therefore the root it resolves from. */ -+const consoleRoot = path.join(repoRoot, 'apps/console'); -+ -+/** A package on no `node_modules` walk from the console root: the "skipped" control. */ -+const ABSENT_PACKAGE_CONTROL = 'objectui-10865-no-such-package'; -+ -+/** -+ * A subpath the spec's exports map does not declare: the "aborts" control. It is -+ * the same shape as the bare `react-map-gl` entry, on a package whose map this -+ * file already reconciles. -+ */ -+const UNEXPORTED_SUBPATH_CONTROL = `${SPEC_PACKAGE_NAME}/objectui-10865-no-such-subpath`; -+ -+type IncludeVerdict = { kind: 'resolved'; id: string } | { kind: 'skipped' } | { kind: 'aborts'; message: string }; -+ -+/** -+ * How the dev server treats each `optimizeDeps.include` entry. The answer comes -+ * from Vite itself, not from a transcription of its algorithm. -+ * -+ * The list is read by `vite` (dev) only. `vite build` never reads it, so no -+ * build, E2E or type-check job can see a bad entry. In the Vite this was written -+ * against (8.2), `addManuallyIncludedOptimizeDeps` resolves every entry before -+ * the server listens, through `createOptimizeDepsIncludeResolver`. For the -+ * client environment that resolver is `config.createResolver({ asSrc: false, -+ * scan: true })`, called with NO importer, so it resolves from the root. The -+ * deprecated `createResolver` is used here on purpose: Vite's own include -+ * resolver calls it. -+ * -+ * Two outcomes are defects, and they fail differently: -+ * -+ * - **aborts**: the package is found, but its exports map has no such subpath. -+ * The resolver throws, and `vite` exits 1 before it listens. This is -+ * objectui#10865, measured with a bare `react-map-gl` entry: react-map-gl 8 -+ * exports no `.`. -+ * - **skipped**: the package cannot be reached from the root. Vite logs -+ * `Failed to resolve dependency` and pre-bundles nothing for the entry. This -+ * was the `maplibre-gl` entry objectui#10865 removed. -+ */ -+async function includeVerdicts(config: InlineConfig, specifiers: string[]): Promise> { -+ const resolved = await resolveConfig( -+ { ...config, root: consoleRoot, configFile: false, envFile: false, logLevel: 'silent' }, -+ 'serve', -+ 'development' -+ ); -+ const resolve = resolved.createResolver({ asSrc: false, scan: true }); -+ const verdicts = new Map(); -+ for (const specifier of specifiers) { -+ try { -+ const id = await resolve(specifier); -+ verdicts.set(specifier, id ? { kind: 'resolved', id } : { kind: 'skipped' }); -+ } catch (error) { -+ verdicts.set(specifier, { kind: 'aborts', message: String((error as Error).message).split('\n')[0] }); -+ } -+ } -+ return verdicts; -+} -+ -+describe('objectui#10865: every optimizeDeps.include entry resolves the way the dev server resolves it', () => { -+ it('resolves each entry from the console root under the dev conditions', async () => { -+ // A separate evaluation, so that whatever `resolveConfig` does to the object -+ // it is handed cannot reach the instance the cases above compare. -+ const config = await loadConsoleConfig('?optimize-deps-include=10865'); -+ const include = config.optimizeDeps.include as string[]; -+ // Anti-vacuity: a list that read as empty would "resolve" with nothing to check. -+ expect(include.length).toBeGreaterThan(0); -+ // Vite resolves nested (`dep > sub`) and glob entries by other paths, and -+ // this case models only flat entries. Extend it before adding either form. -+ expect( -+ include.filter((specifier) => specifier.includes('>') || specifier.includes('*')), -+ 'nested or glob include entries, which this case does not model' -+ ).toEqual([]); -+ -+ const verdicts = await includeVerdicts(config, [ -+ ...include, -+ VITE_ORACLE_CONTROL, -+ ABSENT_PACKAGE_CONTROL, -+ UNEXPORTED_SUBPATH_CONTROL, -+ ]); -+ -+ // Controls with a KNOWN outcome, in the SAME run. The instrument has to be -+ // able to report all three outcomes, or a clean result below means nothing. -+ expect(verdicts.get(VITE_ORACLE_CONTROL)?.kind, 'dev resolver resolved nothing: broken instrument').toBe( -+ 'resolved' -+ ); -+ expect(verdicts.get(ABSENT_PACKAGE_CONTROL)?.kind, 'an absent package did not read as skipped').toBe('skipped'); -+ expect(verdicts.get(UNEXPORTED_SUBPATH_CONTROL)?.kind, 'an unexported subpath did not read as aborting').toBe( -+ 'aborts' -+ ); -+ -+ const findings = include.flatMap((specifier) => { -+ const verdict = verdicts.get(specifier)!; -+ if (verdict.kind === 'aborts') return [`${specifier}: aborts the dev server start (${verdict.message})`]; -+ if (verdict.kind === 'skipped') { -+ return [`${specifier}: not reachable from apps/console, so it is skipped with a warning and pre-bundles nothing`]; -+ } -+ return []; -+ }); -+ expect(findings, 'optimizeDeps.include entries the dev server cannot resolve (objectui#10865)').toEqual([]); -+ }); -+}); -diff --git a/scripts/__tests__/quick-reference-commands-4149.test.ts b/scripts/__tests__/quick-reference-commands-4149.test.ts -index 690797df72..b10d551252 100644 ---- a/scripts/__tests__/quick-reference-commands-4149.test.ts -+++ b/scripts/__tests__/quick-reference-commands-4149.test.ts -@@ -24,15 +24,14 @@ import { fileURLToPath } from 'node:url'; - * - `packages/tenant` was listed in the Package Tiers table; that package was deleted - * by objectui#2564 (commit d5b1bc0b4). - * -- * ## Why this is a sibling file and not more `it`s in the #4143 pin -+ * ## Why this file never reads `## Current Release` - * -- * `quick-reference-current-release-4143.test.ts` is scoped, in its own words, to the -- * `## Current Release` section — deliberately, because "`## Repository Layout` above it -- * also carries numbers, and asserting over the whole file would conflate a status claim -- * with a package count". That scoping is load-bearing for its reverse direction (no -- * un-derived version literal), which would go red on this page's ports and counts if it -- * were widened. So the two files split by section, and this one never reads -- * `## Current Release`. -+ * That section had its own pin, `quick-reference-current-release-4143.test.ts`, scoped -+ * to it because "`## Repository Layout` above it also carries numbers, and asserting -+ * over the whole file would conflate a status claim with a package count". The pin and -+ * the section's version rows were retired together when objectui's release lane left -+ * (objectstack#25711, decision 17c); the section now holds no literal to pin, and this -+ * file still stops short of it. - * - * ## What is pinned here, and what is deliberately not - * -diff --git a/scripts/__tests__/vite-single-zod.test.ts b/scripts/__tests__/vite-single-zod.test.ts -new file mode 100644 -index 0000000000..4c28d5ff29 ---- /dev/null -+++ b/scripts/__tests__/vite-single-zod.test.ts -@@ -0,0 +1,201 @@ -+import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -+import { build } from 'vite'; -+import fs from 'node:fs'; -+import os from 'node:os'; -+import path from 'node:path'; -+ -+import { SINGLE_ZOD_GUARD_NAME, assertSingleZodInstance, zodPackageDirOf } from '../vite-single-zod'; -+ -+/** -+ * objectui#11327 — the console bundle carries exactly ONE zod instance, measured -+ * on the emitted chunks by `assertSingleZodInstance` in every build. -+ * -+ * Moved here from `vite-objectstack-spec-dist.test.ts` when that module's -+ * `OBJECTSTACK_SPEC_DIST` injection left with the objectstack pin -+ * (objectstack#25711). The guard's cases are kept as they were; the cases of the -+ * injection's own zod redirect (`pinZodToConsoleCopy`) and its range parity check -+ * left with it. -+ * -+ * The fixture is two install trees: a console anchor with its own zod, and a -+ * spec package whose bare `zod` resolves from ITS tree's root — the two-copy -+ * shape one lockfile can still produce. -+ */ -+ -+/** Writes a fake `zod` package whose modules announce which copy they are. */ -+function writeFakeZod(dir: string, version: string, copy: string): void { -+ fs.mkdirSync(dir, { recursive: true }); -+ fs.writeFileSync( -+ path.join(dir, 'package.json'), -+ JSON.stringify({ -+ name: 'zod', -+ version, -+ type: 'module', -+ exports: { '.': './index.js', './v4': './v4.js', './package.json': './package.json' }, -+ }) -+ ); -+ fs.writeFileSync(path.join(dir, 'index.js'), `export const z = { copy: '${copy}' };\n`); -+ fs.writeFileSync(path.join(dir, 'v4.js'), `export const z = { copy: '${copy}_V4' };\n`); -+} -+ -+interface ZodFixture { -+ root: string; -+ /** A console-side package that declares zod, with its own copy. */ -+ anchorDir: string; -+ /** A spec package whose zod is hoisted to ITS tree's root. */ -+ specDir: string; -+} -+ -+function makeZodFixture({ consoleVersion, specOwnVersion }: { consoleVersion: string; specOwnVersion: string }): ZodFixture { -+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'single-zod-11327-'))); -+ const anchorDir = path.join(root, 'console/packages/app-shell'); -+ fs.mkdirSync(anchorDir, { recursive: true }); -+ fs.writeFileSync( -+ path.join(anchorDir, 'package.json'), -+ JSON.stringify({ name: 'anchor-11327', dependencies: { zod: `^${consoleVersion}` } }) -+ ); -+ writeFakeZod(path.join(anchorDir, 'node_modules/zod'), consoleVersion, 'CONSOLE_ZOD_11327'); -+ fs.writeFileSync( -+ path.join(anchorDir, 'entry.mjs'), -+ [ -+ "import { z } from 'zod';", -+ "import { specZ, specZ4 } from '@objectstack/spec';", -+ 'globalThis.__probe11327 = [z.copy, specZ.copy, specZ4.copy, z === specZ];', -+ ].join('\n') -+ ); -+ -+ const specDir = path.join(root, 'framework/packages/spec'); -+ fs.mkdirSync(path.join(specDir, 'dist'), { recursive: true }); -+ fs.writeFileSync( -+ path.join(specDir, 'package.json'), -+ JSON.stringify({ -+ name: '@objectstack/spec', -+ version: '0.0.0-probe11327', -+ type: 'module', -+ exports: { '.': { import: './dist/index.mjs' } }, -+ dependencies: { zod: `^${specOwnVersion}` }, -+ }) -+ ); -+ fs.writeFileSync( -+ path.join(specDir, 'dist/index.mjs'), -+ [ -+ "import { z } from 'zod';", -+ "import { z as z4 } from 'zod/v4';", -+ 'export const specZ = z;', -+ 'export const specZ4 = z4;', -+ ].join('\n') -+ ); -+ writeFakeZod(path.join(root, 'framework/node_modules/zod'), specOwnVersion, 'SPEC_OWN_ZOD_11327'); -+ return { root, anchorDir, specDir }; -+} -+ -+/** Bundles the fixture's entry for real, with the given plugins; returns the code. */ -+async function bundleFixture(fixture: ZodFixture, plugins: unknown[]): Promise { -+ const result = (await build({ -+ root: fixture.anchorDir, -+ logLevel: 'silent', -+ configFile: false, -+ resolve: { alias: { '@objectstack/spec': path.join(fixture.specDir, 'dist/index.mjs') } }, -+ plugins: plugins as never, -+ build: { -+ write: false, -+ minify: false, -+ lib: { entry: path.join(fixture.anchorDir, 'entry.mjs'), formats: ['es'], fileName: 'probe11327' }, -+ }, -+ })) as { output: { code?: string }[] }[]; -+ return result[0].output.map((o) => o.code ?? '').join('\n'); -+} -+ -+async function loadConsoleConfig(query = ''): Promise { -+ const specifier = `../../apps/console/vite.config.ts${query}`; -+ return (await import(/* @vite-ignore */ specifier)).default; -+} -+ -+describe('objectui#11327: the console bundle carries exactly one zod instance', () => { -+ describe('the guard, in a real Vite build', () => { -+ let fixture: ZodFixture; -+ beforeAll(() => { -+ fixture = makeZodFixture({ consoleVersion: '4.6.5', specOwnVersion: '4.6.1' }); -+ }); -+ afterAll(() => { -+ fs.rmSync(fixture.root, { recursive: true, force: true }); -+ }); -+ -+ it('CONTROL: the fixture really bundles both copies — the split the guard exists for', async () => { -+ const code = await bundleFixture(fixture, []); -+ expect(code).toContain('CONSOLE_ZOD_11327'); -+ expect(code).toContain('SPEC_OWN_ZOD_11327'); -+ expect(code).toContain('SPEC_OWN_ZOD_11327_V4'); -+ }); -+ -+ it('the guard fails a two-copy build, naming both copies, their versions and who imported them', async () => { -+ const message = await bundleFixture(fixture, [assertSingleZodInstance()]).then( -+ () => 'BUILD PASSED', -+ (error: Error) => error.message -+ ); -+ expect(message).toContain(`[${SINGLE_ZOD_GUARD_NAME}]`); -+ expect(message).toContain('carries 2 zod instances'); -+ expect(message).toContain(`zod 4.6.1 at \`${path.join(fixture.root, 'framework/node_modules/zod')}\``); -+ expect(message).toContain(`zod 4.6.5 at \`${path.join(fixture.anchorDir, 'node_modules/zod')}\``); -+ // The spec's copy is traced to the spec, which is what tells a reader where to look. -+ expect(message).toContain(path.join(fixture.specDir, 'dist/index.mjs')); -+ }); -+ -+ it('the guard counts INSTANCES: two copies of one version still fail', async () => { -+ const twin = makeZodFixture({ consoleVersion: '4.6.5', specOwnVersion: '4.6.5' }); -+ try { -+ await expect(bundleFixture(twin, [assertSingleZodInstance()])).rejects.toThrow(/carries 2 zod instances/); -+ } finally { -+ fs.rmSync(twin.root, { recursive: true, force: true }); -+ } -+ }); -+ -+ it('the guard passes a one-copy build', async () => { -+ const single = makeZodFixture({ consoleVersion: '4.6.5', specOwnVersion: '4.6.1' }); -+ try { -+ fs.rmSync(path.join(single.root, 'framework/node_modules/zod'), { recursive: true, force: true }); -+ fs.writeFileSync(path.join(single.specDir, 'dist/index.mjs'), 'export const specZ = {}; export const specZ4 = {};\n'); -+ const code = await bundleFixture(single, [assertSingleZodInstance()]); -+ expect(code).toContain('CONSOLE_ZOD_11327'); -+ expect(code).not.toContain('SPEC_OWN_ZOD_11327'); -+ } finally { -+ fs.rmSync(single.root, { recursive: true, force: true }); -+ } -+ }); -+ -+ it('the guard refuses a verdict when it sees no zod at all — its counter-probe', async () => { -+ const blind = makeZodFixture({ consoleVersion: '4.6.5', specOwnVersion: '4.6.1' }); -+ try { -+ fs.writeFileSync(path.join(blind.anchorDir, 'entry.mjs'), 'globalThis.__probe11327 = 1;\n'); -+ await expect(bundleFixture(blind, [assertSingleZodInstance()])).rejects.toThrow(/counter-probe failed/); -+ } finally { -+ fs.rmSync(blind.root, { recursive: true, force: true }); -+ } -+ }); -+ }); -+ -+ describe('which module ids the guard counts as zod', () => { -+ // Labelled rows: one id carries a leading NUL (a virtual-module marker), and -+ // a `%s` title would print that byte raw into every test report. -+ it.each([ -+ ['a pnpm store path', '/r/node_modules/.pnpm/zod@4.6.5/node_modules/zod/v4/core/core.js', '/r/node_modules/.pnpm/zod@4.6.5/node_modules/zod'], -+ ['a copy nested under another package', '/r/node_modules/a/node_modules/zod/index.js', '/r/node_modules/a/node_modules/zod'], -+ ['a virtual-module id with a query', '\0/r/node_modules/zod/index.js?commonjs-proxy', '/r/node_modules/zod'], -+ ['a sibling package named zod-*', '/r/node_modules/zod-to-json-schema/dist/index.js', null], -+ ['workspace source under a zod/ folder', '/r/packages/types/src/zod/index.zod.ts', null], -+ ])('%s', (_label, id, expected) => { -+ expect(zodPackageDirOf(id)).toBe(expected); -+ }); -+ }); -+ -+ describe('wired into the REAL console config', () => { -+ it('registers the guard in every build, and `zod` stays out of `resolve.dedupe`', async () => { -+ const config = await loadConsoleConfig('?single-zod=11327'); -+ const names = (config.plugins.flat(Infinity) as ({ name?: string } | null)[]) -+ .filter((p): p is { name?: string } => Boolean(p)) -+ .map((p) => p.name ?? ''); -+ expect(names).toContain(SINGLE_ZOD_GUARD_NAME); -+ // `zod` is not in `resolve.dedupe`: measured a silent no-op from this root. -+ expect(config.resolve.dedupe ?? []).not.toContain('zod'); -+ }); -+ }); -+}); -diff --git a/scripts/check-action-ref-convention.mjs b/scripts/check-action-ref-convention.mjs -index 4649366c1c..de1e40af0e 100644 ---- a/scripts/check-action-ref-convention.mjs -+++ b/scripts/check-action-ref-convention.mjs -@@ -141,21 +141,13 @@ export const DECLARED_EXCEPTIONS = [ - 'reviewed moment; a floating ref would re-adopt every upstream change on the next scheduled ' + - 'run, with nothing in this repository to roll back to.', - }, -- // The four below share one reason, said once: each is a third-party action -+ // The three below share one reason, said once: each is a third-party action - // that runs holding a credential, so the code a floating tag resolves to - // would receive that credential on the next run after the tag moved, with - // nothing here reviewing it. A commit sha cannot move; the trailing - // `# vX.Y.Z` comment names the release it is. First-party `actions/*` refs -- // stay on the floating-tag default. -- { -- workflow: 'changeset-release.yml', -- action: 'changesets/action', -- issue: 'objectui#13430', -- reason: -- 'runs with NPM_TOKEN and GITHUB_TOKEN in the publish and version-PR steps; pinned to the ' + -- 'v1.9.0 commit, the one the workflow comment and the ref-pin test say both v1 properties ' + -- 'were read at (scripts/__tests__/changeset-release-action-ref-pin.test.ts holds the sha).', -- }, -+ // stay on the floating-tag default. (A fourth, changesets/action in -+ // changeset-release.yml, left with that workflow: objectstack#25711.) - { - workflow: 'check-links.yml', - action: 'lycheeverse/lychee-action', -diff --git a/scripts/check-changeset-fixed.mjs b/scripts/check-changeset-fixed.mjs -index fc2d74218d..94cf536546 100644 ---- a/scripts/check-changeset-fixed.mjs -+++ b/scripts/check-changeset-fixed.mjs -@@ -63,6 +63,7 @@ import { readFileSync, readdirSync, statSync } from "fs"; - import { resolve, dirname, join } from "path"; - import { fileURLToPath } from "url"; - import { isEntrypoint } from "./invoked-as.mjs"; -+import { changesetConfigPath } from "./workspace-root.mjs"; - - const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); - -@@ -165,7 +166,7 @@ function readManifests(dirs) { - } - - function main() { -- const config = JSON.parse(readFileSync(resolve(root, ".changeset/config.json"), "utf8")); -+ const config = JSON.parse(readFileSync(changesetConfigPath(root), "utf8")); - const { unclassified, privateInFixed, privatePackages } = analyze( - config, - readManifests(PACKAGE_ROOTS) -diff --git a/scripts/check-changeset-no-major.mjs b/scripts/check-changeset-no-major.mjs -index 585524b06c..7a1ff80d89 100644 ---- a/scripts/check-changeset-no-major.mjs -+++ b/scripts/check-changeset-no-major.mjs -@@ -95,6 +95,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'; - import { resolve, dirname, join } from 'node:path'; - import { fileURLToPath } from 'node:url'; - import { isEntrypoint } from './invoked-as.mjs'; -+import { changesetConfigPath } from './workspace-root.mjs'; - - const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); - -@@ -220,7 +221,7 @@ export function majorOfRange(range) { - - /** The `fixed` groups of `.changeset/config.json`, as arrays of package names. */ - export function readFixedGroups(repoRoot) { -- const config = JSON.parse(readFileSync(join(repoRoot, '.changeset', 'config.json'), 'utf8')); -+ const config = JSON.parse(readFileSync(changesetConfigPath(repoRoot), 'utf8')); - return Array.isArray(config.fixed) ? config.fixed.filter(Array.isArray) : []; - } - -diff --git a/scripts/check-changeset-presence.mjs b/scripts/check-changeset-presence.mjs -index 2971dfffa7..a256fa15c5 100644 ---- a/scripts/check-changeset-presence.mjs -+++ b/scripts/check-changeset-presence.mjs -@@ -424,7 +424,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; - import { dirname, join, resolve } from 'node:path'; - import { fileURLToPath } from 'node:url'; - import { isEntrypoint } from './invoked-as.mjs'; --import { revPath } from './workspace-root.mjs'; -+import { changesetConfigPath, revPath } from './workspace-root.mjs'; - - const scriptDir = dirname(fileURLToPath(import.meta.url)); - -@@ -631,14 +631,15 @@ function ignoreMatcher(patterns) { - * build and not a quiet pass. - */ - export function readReleaseConfig(root) { -- const dir = join(root, '.changeset'); -- if (!existsSync(dir)) { -+ let configPath; -+ try { -+ configPath = changesetConfigPath(root); -+ } catch { - throw new Error( -- `${dir} does not exist. This gate reads \`.changeset/config.json\` to learn which ` + -+ `${join(root, '.changeset')} does not exist. This gate reads \`.changeset/config.json\` to learn which ` + - 'packages a release covers; without it there is nothing to check against.', - ); - } -- const configPath = join(dir, 'config.json'); - let config; - try { - config = JSON.parse(readFileSync(configPath, 'utf8')); -diff --git a/scripts/check-entry-guard.mjs b/scripts/check-entry-guard.mjs -index 61de3b7df3..c98d2daad8 100644 ---- a/scripts/check-entry-guard.mjs -+++ b/scripts/check-entry-guard.mjs -@@ -112,7 +112,7 @@ - * same reasoning holds here: - * - * • many of these scripts have real side effects (`shadcn-sync`, -- * `sync-quick-reference-release`, `regenerate-known-schema-types`), so a -+ * `regenerate-known-schema-types`), so a - * gate that spawns all of them is a gate nobody can run locally; - * • "produced output" is not a decidable property of an arbitrary tool -- a - * quiet-on-success script is legitimate, so the assertion would have to be -diff --git a/scripts/check-lockfile-dedupe.mjs b/scripts/check-lockfile-dedupe.mjs -index 1ff08cc6a5..039117dad1 100644 ---- a/scripts/check-lockfile-dedupe.mjs -+++ b/scripts/check-lockfile-dedupe.mjs -@@ -86,17 +86,6 @@ - * carries pnpm's own remedy sentinel; otherwise this exits 2, "could not take a - * reading", which is never a pass. - * -- * ## It needs the pinned `@objectstack/*` build (objectui#12957) -- * -- * Every `@objectstack/*` package resolves to a `file:` directory of the build -- * `.objectstack-sha` pins (`scripts/objectstack-pin.mjs`), and `pnpm dedupe -- * --check` re-reads those directories. Without the build pnpm stops before it -- * judges anything (`ERR_PNPM_LINKED_PKG_DIR_NOT_FOUND`), so the checker asks -- * first and reports a reading it could not take, NAMING the cause rather than -- * leaving it to pnpm's message. `lockfile-dedupe.yml` restores the build before -- * this runs. Measured on objectui#12957 with the build in place: `VERDICT -- * deduped`, the lockfile's sha256 unchanged. -- * - * ## ⚠️ This gate reads the REGISTRY, unlike its neighbour - * - * `check-lockfile-integrity.mjs` is one `node` call over two text files. -@@ -119,14 +108,9 @@ - - import { spawnSync } from 'node:child_process'; - import fs from 'node:fs'; --import path from 'node:path'; --import { fileURLToPath } from 'node:url'; - - import { isEntrypoint } from './invoked-as.mjs'; --import { CACHE_DIR, OVERRIDE_PREFIX, STAMP_FILE } from './objectstack-pin.mjs'; --import { rootManifest, workspaceRoot } from './workspace-root.mjs'; -- --const REPO_ROOT = path.resolve(fileURLToPath(import.meta.url), '..', '..'); -+import { workspaceRoot } from './workspace-root.mjs'; - - /** - * pnpm's own remedy line, printed only when `--check` found something to -@@ -322,32 +306,6 @@ export function writeStepSummary(text, env = process.env) { - } - } - --/** -- * Whether the pinned `@objectstack/*` build is absent, read off its stamp. -- * -- * @param {string} [root] -- * @returns {boolean} -- */ --export function pinnedBuildMissing(root = REPO_ROOT) { -- return !fs.existsSync(path.join(root, CACHE_DIR, STAMP_FILE)); --} -- --/** -- * Whether the workspace resolves any `@objectstack/*` package through the -- * pinned build -- the manifest pnpm reads overrides from declares a -- * `file:.objectstack-pin/...` override. Standalone it does, and the stamp -- * check below applies. Under the objectstack monorepo (objectstack#25723) the -- * `@objectstack/*` packages are workspace members and no manifest declares the -- * pin, so there is no build whose absence could make `pnpm dedupe --check` -- * resolve from the registry; the check is then not a precondition. -- * -- * @param {{ pnpm?: { overrides?: Record } }} [manifest] -- * @returns {boolean} -- */ --export function pinDeclared(manifest = rootManifest()) { -- return Object.values(manifest?.pnpm?.overrides ?? {}).some((v) => typeof v === 'string' && v.startsWith(OVERRIDE_PREFIX)); --} -- - /** - * @param {string[]} argv - * @returns {number} process exit code -@@ -358,17 +316,6 @@ export function main(argv = process.argv.slice(2)) { - const annotate = (message) => - console.error(`::${reportOnly ? 'warning' : 'error'} title=Lockfile dedupe::${message}`); - -- if (pinDeclared() && pinnedBuildMissing()) { -- annotate( -- `could not take a reading — the pinned @objectstack/* build is not in ${CACHE_DIR}, so ` + -- '`pnpm dedupe --check` cannot resolve those packages (objectui#12957). Run `pnpm objectstack:build` ' + -- 'first. ⛔ This is NOT a deduped verdict.', -- ); -- for (const line of renderVerdict({ outcome: 'cannot-run', reportOnly })) console.error(line); -- writeStepSummary(renderStepSummary({ outcome: 'cannot-run' })); -- return reportOnly ? EXIT_CLEAN : EXIT_CANNOT_RUN; -- } -- - // At the workspace root: this repository's own, or the monorepo's one level up. - const run = spawnSync('pnpm', ['dedupe', '--check'], { - cwd: workspaceRoot(), -@@ -469,8 +416,6 @@ export function selfTest() { - t('reports each name once, sorted', JSON.stringify(names) === JSON.stringify([...new Set(names)].sort())); - t('finds nothing in clean output', namesFromOutput(GREEN).length === 0); - -- // ── the pinned @objectstack/* build is a precondition (objectui#12957) ───── -- t('a root without the pinned build is reported missing', pinnedBuildMissing(path.join(REPO_ROOT, 'no-such-root')) === true); - t( - 'CONTROL — a red verdict still reds when the parse names nothing', - renderVerdict({ outcome: 'findings', names: [], output: RED }).some((l) => l.startsWith('VERDICT not deduped')), -diff --git a/scripts/check-phantom-dependencies.mjs b/scripts/check-phantom-dependencies.mjs -index 3298563188..6a24ce309e 100644 ---- a/scripts/check-phantom-dependencies.mjs -+++ b/scripts/check-phantom-dependencies.mjs -@@ -201,6 +201,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; - import { dirname, join, relative, resolve } from 'node:path'; - import { fileURLToPath } from 'node:url'; - import { isEntrypoint } from './invoked-as.mjs'; -+import { changesetConfigPath } from './workspace-root.mjs'; - - const scriptDir = dirname(fileURLToPath(import.meta.url)); - -@@ -426,10 +427,9 @@ export function moduleSpecifiers(text, fileName = 'file.tsx') { - * gate whose scope collapsed to nothing passes everything. - */ - export function readReleaseGroup(root) { -- const configPath = join(root, '.changeset/config.json'); - let config; - try { -- config = JSON.parse(readFileSync(configPath, 'utf8')); -+ config = JSON.parse(readFileSync(changesetConfigPath(root), 'utf8')); - } catch (error) { - throw new Error(`cannot read .changeset/config.json: ${error.message}`); - } -diff --git a/scripts/check-ui-root-lookups.mjs b/scripts/check-ui-root-lookups.mjs -index e37d979cb4..00035c8ae6 100644 ---- a/scripts/check-ui-root-lookups.mjs -+++ b/scripts/check-ui-root-lookups.mjs -@@ -197,10 +197,6 @@ export const ALLOWLIST = new Map([ - 'scripts/workspace-root.mjs', - "decides the WORKSPACE root for every reader of pnpm-workspace.yaml / pnpm-lock.yaml / the root manifest / the .pnpm store: one probe at `uiRoot()` and one at its parent, anchored on ui-root.mjs -- never a walk, never the cwd (objectstack#25723, objectstack#25733)", - ], -- [ -- 'scripts/objectstack-pin.mjs', -- 'packs the pin from an objectstack tree and refuses one with no pnpm-workspace.yaml -- a probe of THAT tree, not of this root; the file itself is deleted at cutover', -- ], - [ - 'scripts/check-new-cross-file-line-citations.mjs', - "the gate's subject is the REPOSITORY AT THE CWD (its self-test drives it against a scratch repository), so `gateRoot()` reads the toplevel and narrows to `uiRoot()` only when the objectui tree lies inside it -- a probe of THAT repository, not a lookup of this root", -diff --git a/scripts/markdown-test-inputs.mjs b/scripts/markdown-test-inputs.mjs -index c717fe7cf3..c2f697563c 100644 ---- a/scripts/markdown-test-inputs.mjs -+++ b/scripts/markdown-test-inputs.mjs -@@ -894,13 +894,6 @@ export const ADJUDICATED = new Map([ - walker: 'not-markdown: `.github/workflows/*.yml`', - }, - ], -- [ -- 'scripts/__tests__/check-installed-spec-pin-claims.test.ts', -- { -- reads: ['.claude/skills/**', '.github/prompts/component.prompt.md', '.github/prompts/engine.prompt.md', '.github/prompts/ui-library.prompt.md', '.github/pull_request_template.md', 'AGENTS.md', 'CLAUDE.md', 'CONTRIBUTING.md', 'LICENSE-THIRD-PARTY.md', 'QUICK_REFERENCE.md', 'README.md', 'ROADMAP.md', 'apps/**', 'content/docs/**', 'docs/ARCHITECTURE.md', 'docs/CONSOLE-STREAMLINING-SUMMARY.md', 'docs/adr/**', 'docs/audits/**', 'examples/**', 'packages/**', 'patches/README.md', 'skills/objectui/**'], -- notRead: ['CHANGELOG.md'], -- }, -- ], - [ - 'scripts/__tests__/check-links-workflow.test.ts', - { -@@ -1122,12 +1115,6 @@ export const ADJUDICATED = new Map([ - walker: 'not-markdown: `.github/workflows/*.yml`', - }, - ], -- // objectui#12958. Its `CHANGELOG.md` is a fixture written into a temp directory; -- // from this tree it reads the root `package.json` and one workflow, no markdown. -- [ -- 'scripts/__tests__/objectstack-floor.test.ts', -- { reads: [], notRead: ['CHANGELOG.md'] }, -- ], - [ - 'scripts/__tests__/quick-reference-commands-4149.test.ts', - { -@@ -1135,13 +1122,6 @@ export const ADJUDICATED = new Map([ - walker: 'not-markdown: directory listings that test whether a documented path exists', - }, - ], -- [ -- 'scripts/__tests__/quick-reference-current-release-4143.test.ts', -- { -- reads: ['QUICK_REFERENCE.md'], -- walker: 'not-markdown: package and app directories', -- }, -- ], - // objectui#12214. Its fixtures write a `README.md` into a temp directory to - // prove a root documentation change is not a full-run trigger; the selector - // it tests never opens a markdown file. -@@ -1152,12 +1132,6 @@ export const ADJUDICATED = new Map([ - notRead: ['README.md'], - }, - ], -- [ -- 'scripts/__tests__/sync-quick-reference-release.test.ts', -- { -- reads: ['QUICK_REFERENCE.md'], -- }, -- ], - [ - 'scripts/__tests__/unconsumed-widget-option-claim-6186.test.ts', - { -diff --git a/scripts/setup.sh b/scripts/setup.sh -index 442a909d91..507e54aa7b 100755 ---- a/scripts/setup.sh -+++ b/scripts/setup.sh -@@ -71,12 +71,11 @@ if [ "$(printf '%s\n%s\n' "$PNPM_FLOOR" "$(pnpm -v)" | sort -V | head -n1)" != " - fi - echo -e "${GREEN}✓ pnpm $(pnpm -v) detected${NC}" - --# Install dependencies. Every @objectstack/* package resolves to a build of the --# objectstack commit `.objectstack-sha` pins (objectui#12957), so `setup` builds --# that first and then installs frozen -- a bare `pnpm install` on a clean --# checkout fails on the missing build directory. (`pnpm run setup`, never --# `pnpm setup`: the latter is pnpm's own built-in command.) --echo -e "\n${YELLOW}📦 Building @objectstack/* at the pin, then installing dependencies...${NC}" -+# Install dependencies. Every @objectstack/* package is a `workspace:^` link to -+# this monorepo's own packages (objectstack#25711), so `setup` is a frozen -+# install and nothing else. (`pnpm run setup`, never `pnpm setup`: the latter is -+# pnpm's own built-in command.) -+echo -e "\n${YELLOW}📦 Installing dependencies...${NC}" - require_root_script setup - pnpm run setup - -diff --git a/scripts/vite-single-zod.ts b/scripts/vite-single-zod.ts -new file mode 100644 -index 0000000000..9f24058c42 ---- /dev/null -+++ b/scripts/vite-single-zod.ts -@@ -0,0 +1,139 @@ -+// Build-time guard: the console bundle carries exactly ONE zod instance -+// (objectui#11327). -+// -+// Two zod instances split zod's internals: a spec schema built by one and walked -+// by the other (`z.toJSONSchema`) throws, and every spec-derived Studio form lost -+// its fields — at the console pin objectui#11327 was reported against, the New -+// Package dialog could not create a package. Two copies that happen to agree, -+// even on a version, are still two instances, so the rule is ONE, not -+// "matching". -+// -+// `resolve.dedupe` cannot deliver it, measured on objectui#11327 against the -+// real console config: Vite resolves a deduped package from its ROOT, the -+// console's root (`apps/console`) declares no `zod`, the root lookup finds -+// nothing, and Vite silently falls back to the importer's own copy. So this -+// guard MEASURES the outcome on the emitted chunks rather than trusting any -+// mechanism, in every console build. -+// -+// Moved out of `vite-objectstack-spec-dist.ts` when that module's -+// `OBJECTSTACK_SPEC_DIST` injection left with the objectstack pin -+// (objectstack#25711): in the objectstack workspace the console's -+// `@objectstack/*` are `workspace:^` links, so there is no out-of-tree spec to -+// inject and no second install tree for zod to come from — but one lockfile can -+// still resolve two zod versions, and this guard is what says so. -+ -+import fs from 'node:fs'; -+import path from 'node:path'; -+import type { Plugin } from 'vite'; -+ -+/** The name `assertSingleZodInstance`'s plugin registers under. */ -+export const SINGLE_ZOD_GUARD_NAME = 'assert-single-zod-instance'; -+ -+/** -+ * The `zod` package directory a module id lives in, or `null`. -+ * -+ * The LAST `node_modules/zod/` segment wins, so pnpm's store path -+ * (`…/.pnpm/zod@4.6.5/node_modules/zod/v4/core/core.js`) and a copy nested -+ * under another package both answer with the directory that IS the package. -+ * A sibling such as `zod-to-json-schema` does not match: the segment must be -+ * exactly `zod`. A leading `\0` (a virtual-module marker) and a query are -+ * dropped first, so one copy reached two ways is not counted twice. -+ */ -+export function zodPackageDirOf(id: string): string | null { -+ const match = /^(.*[\\/]node_modules[\\/]zod)[\\/]/.exec(id.replace(/^\0/, '').split('?')[0]); -+ return match ? match[1] : null; -+} -+ -+/** The minimal slice of a bundle `assertSingleZodInstance` reads. */ -+interface GuardOutput { -+ type: string; -+ fileName: string; -+ modules?: Record; -+} -+ -+/** The minimal slice of the plugin context `assertSingleZodInstance` reads. */ -+interface GuardContext { -+ error(message: string): never; -+ getModuleInfo?(id: string): { importers?: readonly string[] } | null; -+} -+ -+/** -+ * Build-time guard: the console bundle carries exactly ONE zod instance. -+ * -+ * Counted on what the build EMITTED — every module id rendered into a chunk, -+ * grouped by the `zod` package directory it lives in — never on how the -+ * resolver was configured, because the configuration is exactly what fooled -+ * the first fix attempt (see the file header). Two directories fail the build -+ * naming each copy, its version, the chunks it landed in and who imported it; -+ * two directories holding the SAME version fail too, since an instance is what -+ * the crash keys on, not a version. -+ * -+ * Zero directories fail as well, as a counter-probe: the console always bundles -+ * zod (the `@object-ui/types` validators, the metadata-admin schema modules), so -+ * zero means this matcher has gone blind, and a negative guard that cannot see -+ * its subject would read green forever — the shape `assertLazyLinterStaysLazy` -+ * in the console config refuses for the same reason. -+ */ -+export function assertSingleZodInstance(): Plugin { -+ return { -+ name: SINGLE_ZOD_GUARD_NAME, -+ apply: 'build', -+ generateBundle(_options, bundle) { -+ const context = this as unknown as GuardContext; -+ const copies = new Map; importers: Set }>(); -+ let chunkCount = 0; -+ for (const output of Object.values(bundle as Record)) { -+ if (output.type !== 'chunk') continue; -+ chunkCount += 1; -+ for (const id of Object.keys(output.modules ?? {})) { -+ const dir = zodPackageDirOf(id); -+ if (!dir) continue; -+ const copy = copies.get(dir) ?? { chunks: new Set(), importers: new Set() }; -+ copies.set(dir, copy); -+ copy.chunks.add(output.fileName); -+ for (const importer of context.getModuleInfo?.(id)?.importers ?? []) { -+ if (zodPackageDirOf(importer) !== dir) copy.importers.add(importer); -+ } -+ } -+ } -+ -+ if (copies.size === 0) { -+ context.error( -+ `[${SINGLE_ZOD_GUARD_NAME}] counter-probe failed: none of the ${chunkCount} chunks holds a module ` + -+ `under a \`node_modules/zod/\` directory, so this guard can no longer fail. The console always ` + -+ `bundles zod (the \`@object-ui/types\` validators, the metadata-admin schema modules), so zero ` + -+ `is a statement about this matcher — zod reached through a path without that segment — never ` + -+ `a clean bundle (objectui#11327)` -+ ); -+ } -+ if (copies.size > 1) { -+ const lines = [...copies.entries()].map(([dir, copy]) => { -+ let version = '?'; -+ try { -+ version = String( -+ (JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')) as { version?: unknown }).version -+ ); -+ } catch { -+ // The listing still names the directory; the version is a courtesy. -+ } -+ const importers = [...copy.importers].slice(0, 3); -+ return ( -+ ` - zod ${version} at \`${dir}\` — in ${[...copy.chunks].join(', ')}` + -+ (importers.length > 0 ? `; imported by ${importers.join(', ')}` : '') -+ ); -+ }); -+ context.error( -+ `[${SINGLE_ZOD_GUARD_NAME}] the console bundle carries ${copies.size} zod instances and must ` + -+ `carry exactly one (objectui#11327):\n${lines.join('\n')}\n` + -+ `A schema built by one instance and walked by another holds only while their internals happen ` + -+ `to agree — when they did not, \`z.toJSONSchema\` over a spec schema threw and the Studio's ` + -+ `spec-derived forms lost their fields — and two copies that agree, even on a version, are ` + -+ `still two instances. The workspace lockfile resolves zod twice for the console's closure; ` + -+ `dedupe it. Adding \`zod\` to ` + -+ `\`resolve.dedupe\` is not a fix: the console root declares no zod, so Vite falls back to each ` + -+ `importer's own copy without a word.` -+ ); -+ } -+ }, -+ }; -+} -diff --git a/scripts/workspace-root.d.mts b/scripts/workspace-root.d.mts -index da7f3ad172..ee6a559ca9 100644 ---- a/scripts/workspace-root.d.mts -+++ b/scripts/workspace-root.d.mts -@@ -10,6 +10,7 @@ export declare const WORKSPACE_MANIFEST: 'pnpm-workspace.yaml'; - export declare const LOCKFILE: 'pnpm-lock.yaml'; - export declare const ROOT_MANIFEST: 'package.json'; - export declare const TURBO_CONFIG: 'turbo.json'; -+export declare const CHANGESET_CONFIG: '.changeset/config.json'; - export declare const PARKED_TURBO_CONFIG: 'turbo.objectui.json'; - export declare const TURBO_ROOT_PREFIX: '$TURBO_ROOT$/'; - export declare const VIRTUAL_STORE: '.pnpm'; -@@ -37,6 +38,7 @@ export declare function parseWorkspaceGlobs(text: string): string[]; - export declare function workspaceGlobs(root?: string): string[]; - /** The nearest `turbo.json` upward: what turbo hashes from. */ - export declare function turboConfigPath(root?: string): string; -+export declare function changesetConfigPath(root?: string): string; - /** objectui's own task declaration: `turbo.json` at `root`, else the parked copy. */ - export declare function uiTurboConfigPath(root?: string): string; - -diff --git a/scripts/workspace-root.mjs b/scripts/workspace-root.mjs -index bdffb350ad..7ad238a44a 100644 ---- a/scripts/workspace-root.mjs -+++ b/scripts/workspace-root.mjs -@@ -113,6 +113,8 @@ export const WORKSPACE_MANIFEST = 'pnpm-workspace.yaml'; - export const LOCKFILE = 'pnpm-lock.yaml'; - export const ROOT_MANIFEST = 'package.json'; - export const TURBO_CONFIG = 'turbo.json'; -+/** The Changesets config, relative to the root that holds it. */ -+export const CHANGESET_CONFIG = '.changeset/config.json'; - /** Where the cutover parks objectui's own `turbo.json` (a nested one must `extends` the root). */ - export const PARKED_TURBO_CONFIG = 'turbo.objectui.json'; - export const TURBO_ROOT_PREFIX = '$TURBO_ROOT$/'; -@@ -293,6 +295,26 @@ export function turboConfigPath(root = uiRoot()) { - throw new Error(`no ${TURBO_CONFIG} at ${own} or at ${parent} (objectstack#25723).`); - } - -+/** -+ * The Changesets config that versions this tree: `.changeset/config.json` at -+ * `root` when it holds one (standalone), else the one at its parent — the -+ * monorepo's, whose one `fixed` group carries `@object-ui/*` and -+ * `@objectstack/*` together (objectstack#25711, ruling D1 「同一个版本号组」; -+ * changesets reads one config per repository). A reader that asks "which of -+ * this tree's packages are published" intersects that group with this tree's -+ * own manifests, so the monorepo's wider group answers the same question. -+ * -+ * @param {string} [root] -+ * @returns {string} -+ */ -+export function changesetConfigPath(root = uiRoot()) { -+ const own = path.resolve(root); -+ if (fs.existsSync(path.join(own, CHANGESET_CONFIG))) return path.join(own, CHANGESET_CONFIG); -+ const parent = path.dirname(own); -+ if (parent !== own && fs.existsSync(path.join(parent, CHANGESET_CONFIG))) return path.join(parent, CHANGESET_CONFIG); -+ throw new Error(`no ${CHANGESET_CONFIG} at ${own} or at ${parent} (objectstack#25711).`); -+} -+ - /** - * objectui's OWN task declaration, for a test that pins objectui's task shapes - * (`$TURBO_ROOT$` inputs relative to the objectui tree, `//#` root tasks of its -diff --git a/turbo.objectui.json b/turbo.objectui.json -index 24c4aaef68..0996d5a3bd 100644 ---- a/turbo.objectui.json -+++ b/turbo.objectui.json -@@ -2,9 +2,7 @@ - "$schema": "https://turbo.build/schema.json", - "globalEnv": ["NODE_ENV"], - "globalDependencies": [ -- "node_modules/@objectstack/spec/.spec-main-shape-gate.json", -- ".objectstack-sha", -- "node_modules/.cache/objectstack/.stamp.json" -+ "node_modules/@objectstack/spec/.spec-main-shape-gate.json" - ], - "tasks": { - "build": { -@@ -16,7 +14,7 @@ - "**/*.tsbuildinfo", - "!**/node_modules/**" - ], -- "env": ["NODE_ENV", "VITE_BASE_PATH", "OBJECTSTACK_CLIENT_DIST", "OBJECTSTACK_SPEC_DIST"], -+ "env": ["NODE_ENV", "VITE_BASE_PATH"], - "inputs": [ - "$TURBO_DEFAULT$", - "$TURBO_ROOT$/tsconfig.json", diff --git a/scripts/ui-import/README.md b/scripts/ui-import/README.md index e82d9ee5b6a..9ead63ca877 100644 --- a/scripts/ui-import/README.md +++ b/scripts/ui-import/README.md @@ -38,17 +38,17 @@ their own changes after the cutover. # ui-import: the objectui side of the release-lane merge (objectstack#25711) -`4a-ui-side.patch` is the `ui/` half of sub-issue 4a of objectstack#25677: it removes +This is the record of the `ui/` half of sub-issue 4a of objectstack#25677. It removed objectui's pin on objectstack (`.objectstack-sha`, `.objectstack-pin/`, the scripts that read them), the dist-injection Vite probes, objectui's own release lane and its own -Changesets config. It is a patch file and not tree edits because `ui/` does not exist on -`main` until the subtree PR of sub-issue 5 lands; it is applied in that cutover window, -right after the subtree PR, together with the root half of 4a. +Changesets config. While `ui/` did not exist on `main` it was carried as a patch file, +`4a-ui-side.patch`. In the cutover sitting, right after the subtree PR (objectstack#25825) +landed on `main` (`fd40831416`), 4a's own PR (objectstack#25745) applied it as tree edits +together with the root half of 4a, and deleted the patch file in the same commit. -## What it changes +## What it changed -Deleted (step 2 below; the patch carries only modifications and additions, so the -deleted files' lines are not repeated here): +Deleted (step 2 below): - the pin: `.objectstack-sha`, `.objectstack-pin/` (7 packed manifests), `scripts/bump-objectstack.sh`, `scripts/objectstack-pin.mjs`, @@ -68,7 +68,7 @@ deleted files' lines are not repeated here): `quick-reference-current-release-4143` pin. Its only release-path caller was `changeset-release.yml`, deleted above. -Changed by the patch: +Changed (step 3 below): - `scripts/vite-single-zod.ts` (new): the single-zod build guard, moved out of the deleted spec probe because it runs in every console build. `apps/console/vite.config.ts` drops @@ -96,9 +96,9 @@ Changed by the patch: `quick-reference-commands-4149.test.ts`: their pin, release-lane and release-sync rows. -## Apply +## How it was applied -From the monorepo root, on the tree the subtree PR produced: +From the monorepo root, after `git merge origin/main` brought the subtree in: ```sh # 1. Not here: objectui's 65 pending ui/.changeset/*.md stay under ui/ for the @@ -132,15 +132,17 @@ git rm -r -q --ignore-unmatch -- \ ui/scripts/__tests__/vite-objectstack-client-dist.test.ts \ ui/scripts/__tests__/vite-objectstack-spec-dist.test.ts -# 3. The modifications and the three new files. -git apply --directory=ui --check scripts/ui-import/4a-ui-side.patch -git apply --directory=ui scripts/ui-import/4a-ui-side.patch +# 3. The modifications and the three new files: `git apply --directory=ui` of +# 4a-ui-side.patch, deleted in the same commit. + +# 4. The SDUI manifest regenerated from ui/ for the merged tree (107 -> 106 +# components; objectui#13449 retired `html`): sdui.manifest.json and +# scripts/sdui-manifest.record.json, from the patch the 4b PR 2a thread measured. ``` -The patch is written against the verification branch head `9e37acd126` (objectstack#25697, -the run 8 tree: objectui `main` imported under `ui/` plus the adaptations). Before the -cutover, run step 3's `--check` against the final tree and regenerate the patch if it -fails (sub-issue 5 owns that re-check). +The patch was written against the verification branch head `9e37acd126` (objectstack#25697, +the run 8 tree) and re-checked with `git apply --check` on the cutover heads `1c0d02eda4` +and `c04b36dcb9` and on `main` at `fd40831416` before it was applied. ## Verify diff --git a/sdui.manifest.json b/sdui.manifest.json index e9427d2f761..60e80870184 100644 --- a/sdui.manifest.json +++ b/sdui.manifest.json @@ -1603,7 +1603,7 @@ "name": "sections", "type": "array", "of": "object", - "description": "Field groups rendered as the detail body, in order. Every entry is an OBJECT — `{ name?, label?, columns?, fields }` — a bare section-id string is NOT accepted (the spec retired that spelling in objectstack#5611, and the renderer reads name/label/fields off each entry, so a string entry renders no fields at all). `fields` are the field names shown in this section, in order — required unless `group` supplies the members instead (the spec refuses a section carrying neither, and refuses one carrying both). `label` is the section heading; omit it for an untitled, borderless section. `name` is a stable snake_case identifier and the i18n anchor — the heading resolves through objects.._sections..label, so a section without a name shows its authored label in every locale. `columns` (1-4) is THIS section's field-grid width; omit it and the renderer derives the width. Authoring `sections` at all makes it the only source of the detail body; omit it and the body falls back to the object's highlightFields. @objectstack/spec 17.3.0 declares eight more member keys on an entry, and this renderer honours all eight: `icon` (a Lucide name on the section header), `description` (sub-heading copy under the heading), `collapsible` and `defaultCollapsed` (a foldable section and its initial state), `showBorder` (force the Card wrapper on or off, overriding the heading-derived default) and `headerColor` (a header tint from the shared palette) through DetailSection, plus `group` — the ADR-0085 §5 REFERENCE form, the alternative to enumerating `fields`. `{ group: 'contact_info' }` inherits the object's `fieldGroups` entry with that key: its members (every visible field pointing at it, in declaration order), its presentation (label, icon, description, collapse) and its `visibleWhen` all come from the group, so the section restates none of it — the predicate is evaluated against the bound record exactly as the entry form evaluates it, and a group it hides renders nothing (a `visibleWhen` written on a section itself is refused by the spec and not read: gate the group and reference it) and the spec refuses those keys beside `group`; `columns`, `showBorder` and `headerColor` stay yours because they are how THIS page lays the section out. A `group` naming no declared group renders nothing and is reported to the console (`@objectstack/lint` flags it as `page-section-group-unknown`). `hideEmpty` is the eighth, and it decides whether an ALL-empty section exists: it defaults to on, so a section whose fields are every one of them empty renders nothing at all — no heading, no skeleton — and `hideEmpty: false` is the spelling that keeps that heading and its label skeleton on a brand-new record. It decides ONLY the all-empty case; the empty rows of a section that still has a filled one belong to DetailSection's auto-hide heuristic plus the reader's show-empty toggle, which no authored value overrides in either polarity (objectui#7129 Q2-C), and inline-edit mode renders the section either way so its fields stay reachable. Restored under objectui#8603 (maintainer 2026-09-15) after objectui#7129 retired it on the premise, since falsified upstream, that the spec refused the key. None of the eight has a designer control yet; they are authorable in source mode only, tracked as a deferred feature. @objectstack/spec 18 declares a ninth, `count` (`{ field }`): the renderer fills the heading's `{{ count }}` hole with how many values that multi-value field holds on the current record — `{ name: 'tagging', label: '{{ count }} tags', count: { field: 'tags' }, fields: ['tags'] }` reads `3 tags` when three are selected. An absent or empty value counts 0; `count` changes only the heading, never whether the section renders, so with `hideEmpty` on an all-empty section is still absent. The hole is filled after the heading is translated, so each locale of an inline label map and a translated objects.._sections..label write the same `{{ count }}`. The spec refuses `count` beside `group`, on a section with no `label`, and on a label with no hole; a heading on a section without `count` prints as written. Like the eight, `count` has no designer control yet." + "description": "Field groups rendered as the detail body, in order. Every entry is an OBJECT — `{ name?, label?, columns?, fields }` — a bare section-id string is NOT accepted (the spec retired that spelling in objectstack#5611, and the renderer reads name/label/fields off each entry, so a string entry renders no fields at all). `fields` are the field names shown in this section, in order — required unless `group` supplies the members instead (the spec refuses a section carrying neither, and refuses one carrying both). `label` is the section heading; omit it for an untitled, borderless section. `name` is a stable snake_case identifier and the i18n anchor — the heading resolves through objects.._sections..label, so a section without a name shows its authored label in every locale. `columns` (1-4) is THIS section's field-grid width; omit it and the renderer derives the width. Authoring `sections` at all makes it the only source of the detail body; omit it and the body falls back to the object's highlightFields. @objectstack/spec 17.3.0 declares eight more member keys on an entry, and this renderer honours all eight: `icon` (a Lucide name on the section header), `description` (sub-heading copy under the heading), `collapsible` and `defaultCollapsed` (a foldable section and its initial state), `showBorder` (force the Card wrapper on or off, overriding the heading-derived default) and `headerColor` (a header tint from the shared palette) through DetailSection, plus `group` — the ADR-0085 §5 REFERENCE form, the alternative to enumerating `fields`. `{ group: 'contact_info' }` inherits the object's `fieldGroups` entry with that key: its members (every visible field pointing at it, in declaration order), its presentation (label, icon, description, collapse) and its `visibleWhen` all come from the group, so the section restates none of it — the predicate is evaluated against the bound record exactly as the entry form evaluates it, and a group it hides renders nothing (a `visibleWhen` written on a section itself is refused by the spec and not read: gate the group and reference it) and the spec refuses those keys beside `group`; `columns`, `showBorder` and `headerColor` stay yours because they are how THIS page lays the section out. A `group` naming no declared group renders nothing and is reported to the console (`@objectstack/lint` flags it as `page-section-group-unknown`). `hideEmpty` is the eighth, and it decides whether an ALL-empty section exists: it defaults to on, so a section whose fields are every one of them empty renders nothing at all — no heading, no skeleton — and `hideEmpty: false` is the spelling that keeps the section on a brand-new record: in read-only mode as its heading plus one fold row that counts the empty fields and reveals them on request (the same fold a partly-filled section uses, objectui#13386), in edit mode as every field an editable row. It decides ONLY the all-empty case; the empty rows of a section that still has a filled one belong to DetailSection's auto-hide heuristic plus the reader's show-empty toggle, which no authored value overrides in either polarity (objectui#7129 Q2-C), and inline-edit mode renders the section either way so its fields stay reachable. Restored under objectui#8603 (maintainer 2026-09-15) after objectui#7129 retired it on the premise, since falsified upstream, that the spec refused the key. None of the eight has a designer control yet; they are authorable in source mode only, tracked as a deferred feature. @objectstack/spec 18 declares a ninth, `count` (`{ field }`): the renderer fills the heading's `{{ count }}` hole with how many values that multi-value field holds on the current record — `{ name: 'tagging', label: '{{ count }} tags', count: { field: 'tags' }, fields: ['tags'] }` reads `3 tags` when three are selected. An absent or empty value counts 0; `count` changes only the heading, never whether the section renders, so with `hideEmpty` on an all-empty section is still absent. The hole is filled after the heading is translated, so each locale of an inline label map and a translated objects.._sections..label write the same `{{ count }}`. The spec refuses `count` beside `group`, on a section with no `label`, and on a label with no hole; a heading on a section without `count` prints as written. Like the eight, `count` has no designer control yet." }, { "name": "fields", @@ -3738,17 +3738,6 @@ } ] }, - "html": { - "type": "html", - "namespace": "ui", - "inputs": [ - { - "name": "html", - "type": "string", - "description": "Raw HTML content" - } - ] - }, "h1": { "type": "h1", "namespace": "ui", diff --git a/ui/.changeset/config.json b/ui/.changeset/config.json deleted file mode 100644 index 8d113e1b840..00000000000 --- a/ui/.changeset/config.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "$schema": "https://unpkg.com/@changesets/config@4.0.0/schema.json", - "changelog": "@changesets/cli/changelog", - "commit": false, - "fixed": [ - [ - "@object-ui/types", - "@object-ui/core", - "@object-ui/i18n", - "@object-ui/react", - "@object-ui/components", "@object-ui/react-runtime", "@object-ui/sdui-parser", - "@object-ui/fields", - "@object-ui/layout", - "@object-ui/data-objectstack", - "@object-ui/cli", - "@object-ui/create-plugin", - "@object-ui/auth", - "@object-ui/permissions", - "@object-ui/mobile", - "@object-ui/plugin-ai", - "@object-ui/plugin-calendar", - "@object-ui/plugin-charts", - "@object-ui/plugin-chatbot", - "@object-ui/plugin-dashboard", - "@object-ui/plugin-designer", - "@object-ui/plugin-detail", - "@object-ui/plugin-editor", - "@object-ui/plugin-form", - "@object-ui/plugin-gantt", - "@object-ui/plugin-grid", - "@object-ui/plugin-kanban", - "@object-ui/plugin-list", - "@object-ui/plugin-map", - "@object-ui/plugin-markdown", - "@object-ui/plugin-report", - "@object-ui/plugin-timeline", - "@object-ui/plugin-tree", - "@object-ui/plugin-view", - "@object-ui/collaboration", - "@object-ui/app-shell", - "@object-ui/providers", - "@object-ui/console", - "object-ui" - ] - ], - "linked": [], - "access": "public", - "baseBranch": "main", - "updateInternalDependencies": "patch", - "ignore": [ - "@object-ui/example-*", - "@object-ui/site", - "@object-ui/runner", - "@object-ui/test-support" - ], - "privatePackages": { - "version": true, - "tag": false - } -} diff --git a/ui/.github/pull_request_template.md b/ui/.github/pull_request_template.md deleted file mode 100644 index d1d1220f047..00000000000 --- a/ui/.github/pull_request_template.md +++ /dev/null @@ -1 +0,0 @@ -- [ ] Adds no product-specific widget, dialog, hook or string (see AGENTS.md › Product-neutral renderer) diff --git a/ui/.github/workflows/changeset-release.yml b/ui/.github/workflows/changeset-release.yml deleted file mode 100644 index 8f28eee19ef..00000000000 --- a/ui/.github/workflows/changeset-release.yml +++ /dev/null @@ -1,1545 +0,0 @@ -name: Changeset Release - -# ══════════════════════════════════════════════════════════════════════════════ -# TWO LANES, ONE FILE: THE VERSION PR REFRESHES ON A CLOCK, npm PUBLISHES ON A -# MERGE. (objectstack#10850) -# ══════════════════════════════════════════════════════════════════════════════ -# -# Maintainer ruling, 2026-08-21 (verbatim, do not translate): -# -# 「我每周才发一次版本,changeset 每个pr 都跑有必要吗」 -# 「立卡吧,version 工作流改成发版前手动触发」 -# 「包括 objectui 仓库」 -# -# The sister half landed first in `../objectstack` as its `release.yml` -# `version-pr` job (objectstack#11233 / PR #11238, maintainer 2026-08-23): a -# 6-hourly `schedule` plus an on-demand dispatch input, which is the later and -# more specific spelling of "手动触发" and the one mirrored here. Per-PR changeset -# VALIDATION is explicitly untouched by the ruling — `changeset-guard.yml` and -# `changeset-presence.yml` still run on every pull request. -# -# WHY THE REFRESH LEFT THE PUSH TRIGGER -# ------------------------------------- -# With pending changesets this file's changesets step recomputed and FORCE-PUSHED -# the standing "chore: release packages" PR (objectui#5400) on every landing, and -# `main` takes ~18 merges a working day (the same cadence the concurrency note -# below measures). The PR therefore never held still long enough for its own -# branch CI to finish. Releases are weekly, so every one of those refreshes was a -# CI run spent on bookkeeping nobody reads until release day. Between refreshes -# `changeset-release/main` is now a static branch whose CI can converge. -# -# The staleness window is the whole cost, and it is bounded on demand: dispatch -# with `refresh_version_pr` when you want the PR current NOW (immediately before -# a release, say). The bookkeeping is not time-critical — the changesets are -# already committed on `main`; objectui#5400 is only their rendering. -# -# WHY THE SPLIT NEEDS A SEPARATE `lane` JOB, AND NOT JUST AN `if:` ON THE STEP -# --------------------------------------------------------------------------- -# ⚠️ Unlike objectstack's `release.yml` — which has a version job and a publish -# job and could simply give each one its own trigger — this repository publishes -# from THE SAME changesets step that refreshes the PR. Which of the two it does -# is not ours to choose per invocation: the action decides from repository state. -# Its v1 source (`changesets/action`, `src/index.ts`) dispatches on exactly two -# facts, `hasChangesets` and whether a `publish:` input was given: -# -# case !hasChangesets && !hasPublishScript: -> nothing -# case !hasChangesets && hasPublishScript: -> runPublish (npm + tags) -# case hasChangesets && !hasNonEmptyChangesets: -> nothing -# case hasChangesets: -> runVersion (force-push PR) -# -# ⚠️ "(npm + tags)" is v1's design, not what it does here any more: it pushes a -# tag (and creates a Release) only for the packages whose `New tag:` line it -# parses from the publish script's stdout, and `@changesets/cli` v3 prints -# none. So under v3 `runPublish` is npm only, and the tags and Releases come -# from two steps of this file's own after the npm check (objectui#11596). -# -# Two consequences drive everything below, and they are NOT symmetric: -# -# - Omitting `publish:` makes publishing UNREACHABLE BY CONSTRUCTION. There is -# no state in which the action publishes without that input. -# - Omitting `version:` does NOT make the refresh unreachable. `runVersion` -# falls back to plain `changeset version` when the input is absent, so a step -# invoked on a push that still carries pending changesets force-pushes -# objectui#5400 no matter how it is configured. -# -# So the refresh lane is closed by construction, and the publish lane can only be -# closed by NOT INVOKING THE ACTION on a push that carries pending changesets. -# That question — "does `main` carry pending changesets?" — has to be answered -# before the expensive steps, or the saving the ruling asked for is not made: the -# job would still install and build on all ~18 daily landings just to decide to -# do nothing. Hence one cheap job that answers it from a sparse checkout, and one -# job-level `if:` that states the policy. A second workflow FILE was ruled out -# (no new lanes to maintain, and the publish invariants would be duplicated where -# they can drift apart). -# -# THE LANES -# --------- -# push to main, version NOT on npm -> PUBLISH what this commit declares, -# then push its tags and create its -# GitHub Releases (objectui#11596). -# Normally that is the version-PR merge -# itself. It is also the retry: any -# later push re-attempts a release whose -# own run failed, which is how -# @object-ui/*@17.5.0 eventually shipped -# 6h50m late (see the `pnpm test` note -# further down) — except that it is no -# longer luck, because it no longer -# depends on `.changeset/` happening to -# still be empty (objectui#5442). -# push to main, version already on npm-> NOTHING. This is every ordinary -# landing: a merge that is not a release -# does not move the manifest version, so -# there is nothing to publish and the -# expensive job is skipped. -# schedule (6-hourly) -> REFRESH ONLY. The changesets step is -# or a dispatch with invoked WITHOUT a `publish:` script -# `refresh_version_pr` and WITHOUT npm credentials, so the -# publish branch is unreachable by -# construction rather than by an `if:` -# someone can get wrong. -# dispatch without the input -> NOTHING, loudly. See the input's note. -# -# THE REFRESH LANE VALIDATES WHAT IT IS ABOUT TO RENDER (objectui#5397) -# --------------------------------------------------------------------- -# The release PR runs no CI and cannot be made to — 849 `ci.yml` runs on -# `changeset-release/main`, every recent one `action_required`, created and -# parked with nothing executed — so the release commit is the only commit that -# reaches `main` without passing the merge queue. Before the refresh step -# invokes the action, this job therefore renders the post-version tree itself, -# validates the surfaces the version step can move, and puts the tree back. The -# ruling, the measurements that chose that scope over a 40-minute `pnpm test`, -# and why the restore is load-bearing are at "POST-VERSION VALIDATION" below. -# -# WHY THE PUBLISH LANE IS KEYED ON npm AND NOT ON `.changeset/` -# ------------------------------------------------------------- -# It used to be keyed on "does this commit carry pending changesets?", and that -# predicate answers a question about BOOKKEEPING while being read as if it -# answered a question about the REGISTRY. The two come apart exactly where it -# costs a release (objectui#5442, maintainer ruling 2026-08-22, Option B): -# -# The version PR is cut from `main` at T and merged at T+n. `main` takes ~18 -# merges a working day, so by T+n it has almost always accumulated changesets -# that the merge does not remove. Those changesets belong to the NEXT version; -# the version this commit declares is finished and unpublished. Keyed on -# changesets, that release is skipped, and the next version PR bumps straight -# past it. -# -# Measured, and still true at the commit this paragraph was written on: of the 90 -# versions `packages/core/CHANGELOG.md` declares, 16 never reached npm, and -# `packages/core/package.json` says 17.6.0 while `dist-tags.latest` says 17.5.0. -# `@object-ui/react` and `@object-ui/types` are missing the exact same 16, which -# is the counter-probe that matters: whole RELEASES never published, rather than -# some package failing to. -# -# So the predicate is now "is the version this commit declares already on npm?". -# It is answered in the `lane` job by one read of `registry.npmjs.org`, and it is -# better than its predecessor on both axes at once: -# -# - CORRECTNESS. Pending changesets stop being the publish lane's business. -# What this commit declares gets published, whatever the next version is -# accumulating. -# - COST. It is STRICTLY cheaper than "no pending changesets", not a trade -# against it. An ordinary landing does not move the manifest version, so it -# answers "already on npm" and skips the expensive job — where the old -# predicate ran the job in full on every landing that happened to find -# `.changeset/` empty, only to publish nothing. -# -# The saving objectstack#10850 asked for is therefore kept, and kept for a -# sounder reason: a landing pays for the release job when it has a release to -# make, rather than when a directory happens to be empty. -# -# ⚠️ The predicate cannot reach the action on its own. `changesets/action@v1` -# does not take a "publish" instruction — it picks a branch from repository -# state, and `hasChangesets` is the discriminator (algorithm quoted above). On a -# release-merge commit carrying the next version's changesets it takes -# `runVersion` and publishes nothing. So the publish lane hands it a tree with -# nothing pending; see the step that does it, which explains why that is safe. -# -# ⛔ A refresh lane that could publish would be a NEW capability, not a moved -# one: this repository's release act is the version-PR MERGE, a human action. A -# scheduled tick that reached npm would publish with nobody having merged -# anything. That is why the publish half is denied to it twice over — no -# `publish:` input and no `NPM_TOKEN`/`NODE_AUTH_TOKEN` in its env — and why the -# publish half keeps its trigger exactly as it was. This card changed WHEN the -# version PR refreshes and nothing else: no gate was added, and none removed. -# -# ⚠️ Scheduled runs are queued, not guaranteed on the minute — GitHub delays or -# drops them under load, and disables them entirely after 60 days of repository -# inactivity. Both are acceptable HERE and would not be on a publishing lane: a -# refresh that arrives late leaves objectui#5400 stale, which is visible on the -# PR and fixable with one dispatch. It is a second reason the publish lane must -# never be reachable from `schedule`. - -on: - # The PUBLISH lane (and only it — see the header). Kept exactly as it was: the - # merge of the version PR is the release act in this repository. - push: - branches: - - main - # The REFRESH lane. GitHub runs `schedule` on the DEFAULT BRANCH exclusively, - # which is the only ref this workflow may regenerate the version PR from, so - # the trigger cannot reach a ref the lane is not meant to touch. - schedule: - - cron: '0 */6 * * *' - workflow_dispatch: - inputs: - # The on-demand half of the schedule. Its ONLY effect is to select the - # refresh lane; it can start nothing the 6-hourly tick does not start, so - # it cannot widen anything. - # - # ⚠️ It defaults to FALSE to match objectstack's spelling of the same - # input, which leaves "Run workflow" with default inputs doing NOTHING in - # this repository — objectstack's bare dispatch is its publish REPAIR lane, - # and this repository has no such lane (publishing here is not gated on an - # environment approval, so there is nothing to re-approve). Rather than - # invent one under this card, the `lane` job below prints a `::notice::` - # saying why nothing ran, so a bare dispatch reports instead of vanishing. - refresh_version_pr: - description: >- - Regenerate the "chore: release packages" PR (objectui#5400) now instead - of waiting for the next 6-hourly refresh. Runs the bookkeeping lane - ONLY: nothing is published to npm. Leave unchecked and this workflow - does nothing at all. - required: false - default: false - type: boolean - -# One concurrency group per COMMIT — never one shared by every push to `main`. -# -# A shared group does not queue. GitHub keeps at most ONE pending run per group and -# cancels the previous pending run whenever a newer one arrives, so on a branch that -# takes ~18 merges a day a long-running release job discards most of what follows it. -# Measured over runs #3712-#3911 (2026-08-17 .. 2026-08-20, 200 runs): 93 were -# `conclusion: cancelled` with an EMPTY jobs array — not one step ever ran — while the -# 106 that did execute held the group for a median of 33m22s. Median wait for a run -# that survived: 5m16s (objectui#5404, method and figures from objectui#5395). -# -# A push run of this workflow can publish to npm, so a discarded run is a discarded -# publish. `cancel-in-progress: true` is NOT the fix: it keeps one pending slot by -# killing the run that may be mid-`changeset publish`, which is strictly worse than -# any wait. -# -# ⚠️ "A push run" is where that sentence used to say "this workflow", and the -# distinction is new with objectstack#10850: since the refresh lane arrived, `push` -# is no longer the only event here, and `github.sha` is no longer unique per run. -# A `schedule` tick and a `workflow_dispatch` both read the head of `main`, so two -# of them between merges land in the SAME group and the older pending one is -# evicted. That is harmless, and provably so rather than by luck: -# -# - Only refresh runs can be evicted. A push run at `` is always the FIRST -# run in its group — the sha did not exist before that push — so it is never -# the pending one, and `cancel-in-progress: false` means it is never cancelled -# once started. The publish lane keeps exactly the protection this key was -# added for. -# - An evicted refresh is nothing lost. The refresh regenerates objectui#5400 -# from scratch, so the newest run's result is the one that was wanted anyway — -# the same reasoning `../objectstack`'s `version-pr` job records for its own -# concurrency group. -# -# Keyed by `github.sha` the group holds exactly one run, so nothing is ever discarded. -# Serialisation is not given up — it moves into the "Wait for older release runs" step -# below, which queues runs for real and in order, and which can only ever delay a run, -# never drop one. -concurrency: - group: ${{ github.workflow }}-${{ github.sha }} - cancel-in-progress: false - -permissions: - # `actions: read` lets the queue step below list this workflow's own runs. Declaring - # any `permissions:` block sets every unlisted scope to `none`, so it has to be named. - actions: read - contents: write - pull-requests: write - id-token: write - -jobs: - # ══════════════════════════════════════════════════════════════════════════ - # Which lane is this run in? (objectstack#10850) - # ══════════════════════════════════════════════════════════════════════════ - # The header explains why this cannot be an `if:` on the changesets step: the - # publish lane is only safe on a commit where `.changeset/` is empty, and that - # fact has to be known BEFORE install and build, or every one of `main`'s ~18 - # daily landings still pays for a job that will decide to do nothing. - # - # This job answers the FACT ("does this commit carry pending changesets?") and - # nothing else. The POLICY lives in the release job's `if:` below, in workflow - # expression language where it can be read in one place — a shell script that - # emitted "the lane" would move the split somewhere no reviewer looks. - lane: - name: Decide lane - runs-on: ubuntu-latest - - # ── The job ceiling, DERIVED FOR THIS JOB (objectui#7956) ──────────── - # Before this line the only backstop was GitHub's 360-minute default — on - # the job that gates every landing on `main`, sitting directly above - # `release`, which objectui#7270 bounded at 40. Two jobs in one file with - # two answers is what objectui#7956 was filed to close. - # - # ⚠️ The stakes here are NOT objectui#7270's. That card's argument leaned on - # two of its three jobs producing a REQUIRED context on a shared serial - # queue. Re-measured for this one on `GET /repos/objectstack-ai/objectui/ - # rules/branches/main` at 2026-09-06T17:20Z, the required checks are `Lint`, - # `Type Check`, `Build & E2E`, `Test (shard 1/4)` through `(4/4)`, - # `Build Docs` and `Changeset Declaration` — this workflow produces none of - # them. What is left is the generic exposure: a wedged job holds a runner - # for six hours and then reports `cancelled`, a verdict the merge queue - # cannot tell from `failure`. - # - # ⛔ NOT inherited from `release`'s 40 directly below, and not from - # `ci.yml`'s 10/15/20/30/40 — objectui#7048 fences exactly that. The - # arithmetic below is this job's own. - # - # - Population: the last 300 successful runs of this workflow, window - # 2026-09-02T14:14Z .. 2026-09-06T17:19Z, n=300 `Decide lane` jobs. - # Per-job wall clock from the Actions jobs endpoint (`completed_at` - # minus `started_at`), never the run's total. This job carries no `if:`, - # so it runs on every run of the workflow and its sample is not thinned - # the way `release`'s is by the skip-on-push branch. - # - min 4s / median 6s / p95 8s / max 43s. - # - By event: push n=297 (median 6s, max 43s), schedule n=3 (median 7s, - # max 7s). No material difference, so one ceiling covers both. - # - max/p95 = 5.3 — a fat tail, unlike `Lint`'s 1.25, and worth naming - # because it is not WORK: on the three slowest runs the steps sum to 3 - # seconds while the job's wall clock is 41-43s, so the tail is runner - # overhead that no step reports. It is still wall clock the ceiling has - # to cover, which is why the max above is the job's and not the steps'. - # - # Ceiling = the smallest round number that is both >= 3x max (2.2min) and - # >= max + 15min (15.7min) => 20. That is ~28x the slowest run ever observed - # here, so it cannot fire on a healthy-but-slow runner; a wedge dies in 20 - # minutes instead of 6 hours. - # - # ⚠️ Four of the six jobs objectui#7956 bounded land on 20 by this same - # arithmetic. That is NOT one number copied across them, which precondition - # ② of that card's triage forbids: for any job whose slowest run is under - # 7.5 minutes the additive term dominates the multiplicative one, so the - # rule puts every sub-minute job on the same rung. Each of the four carries - # its own sample and its own derivation beside its own key. - # - # ⛔ Raising this toward 360 is the ruled-out non-fix (objectui#6577, - # objectui#7048): the hang just runs longer and the gate still reports - # `cancelled`. - timeout-minutes: 20 - # Narrower than the workflow-level block deliberately: this job reads files - # and writes nothing. Declaring any `permissions:` sets every unlisted scope - # to `none`, which is the point — a new job on a workflow that can publish - # should not inherit the publish credentials' scopes. - permissions: - contents: read - outputs: - manifest_version: ${{ steps.npm.outputs.manifest_version }} - version_on_npm: ${{ steps.npm.outputs.version_on_npm }} - steps: - # Two directories, because the lane now turns on two facts: `.changeset/` - # answers "are changesets pending?" and `packages/core/` carries the fixed - # group's version anchor, which answers "is that version already on npm?". - # Still far short of a full checkout of this repository — ~15s that every - # landing would otherwise pay. - - name: Check out the lane inputs - uses: actions/checkout@v7 - with: - sparse-checkout: | - .changeset - packages/core - - # This step no longer PREDICTS anything: it reports the lane's inputs and - # says why a bare "Run workflow" did nothing. That is all it does. - # - # It used to also mirror, in bash and before any install, the file scan - # that `changesets/action` performs through `readChangesetState`, and - # publish the result as a `pending_changesets` output gating the release - # job's "Clear pending changesets" step. objectui#6081 DELETED that mirror. - # - # ⛔ Do not reintroduce it. The thing it mirrored has no stable referent, - # which is why no amount of care could keep it honest: `changesets/action` - # is used at a MOVING TAG, its bundled `@changesets/read@^0.6.7` is a - # different major from the `1.0.0` this repository installs, the two - # disagree in OPPOSITE directions (`AGENTS.md` / `CLAUDE.md` / `GEMINI.md` - # count for one and not the other; `.changeset/pre/*.md` the other way), - # and the bundled copy is a minified chunk that nothing here installs — so - # no test could execute it and no lockfile recorded it. A retag of `v1` in - # place changed what the mirror was mirroring and NOTHING in this - # repository failed. On the release lane, where objectui#5442 already - # produced one silent lost release, that is the worst available property. - # - # What replaced it is not a better prediction but the absence of one: the - # clear step is IDEMPOTENT and a measured no-op on the very input where - # this output used to skip it — `Removed 0 pending changeset file(s)`, - # exit 0, byte-identical tree, and a rerun after a real clear also removes - # 0. A step that does nothing when there is nothing to do does not need - # something else to predict whether there is anything to do. - # - # ⛔ Nor should anyone reintroduce it to "save" the clear step a run. It - # costs a directory glob and some `rm -f` on a job whose other steps take - # ~90s, and the price of buying that back is a hand-maintained copy of - # another project's minified file scan. - # - # ⚠️ `.changeset` stays in the sparse-checkout above: the `npm` step below - # reads `.changeset/config.json` for the `fixed` group. It is no longer - # read for changeset FILES. - - name: Report the lane - env: - REFRESH_VERSION_PR: ${{ inputs.refresh_version_pr }} - run: | - set -euo pipefail - - { - echo '### Changeset Release lane' - echo - echo "- event: \`${GITHUB_EVENT_NAME}\`" - echo "- commit: \`${GITHUB_SHA}\`" - } >> "$GITHUB_STEP_SUMMARY" - - # A bare "Run workflow" reports instead of vanishing — see the input's - # note in the `on:` block above. - if [ "${GITHUB_EVENT_NAME}" = 'workflow_dispatch' ] && [ "${REFRESH_VERSION_PR}" != 'true' ]; then - echo "::notice::Nothing to do. Check 'refresh_version_pr' to regenerate the version PR; publishing to npm happens when that PR is merged, not from a dispatch." - fi - - # ══════════════════════════════════════════════════════════════════════ - # THE PUBLISH PREDICATE: is the version this commit declares on npm? - # (objectui#5442 — the ruled Option B, argued in this file's header) - # ══════════════════════════════════════════════════════════════════════ - # `@object-ui/core` is an ANCHOR, not a special case: every package in the - # `fixed` group of `.changeset/config.json` moves as one version, so any - # member answers for the whole release. That membership is ASSERTED rather - # than assumed — if `@object-ui/core` ever leaves the group it stops - # answering for the release, and the publish lane must fail rather than key - # itself on a package that no longer represents one. - # - # LOUD on every answer that is not a clean yes or no. 200 is present, 404 - # is absent, and anything else — 5xx, a proxy, no network — is neither. - # Guessing "absent" publishes on a registry hiccup; guessing "present" - # skips a real release. Neither is worth making silently, which is the same - # reason the detector above refuses to guess a lane. - - name: Is this commit's version already on npm? - id: npm - env: - ANCHOR_PKG: '@object-ui/core' - ANCHOR_MANIFEST: packages/core/package.json - ATTEMPTS: '5' - BACKOFF_SECONDS: '5' - run: | - set -euo pipefail - - if ! jq -e --arg p "$ANCHOR_PKG" '[.fixed[]?[]?] | index($p)' \ - .changeset/config.json > /dev/null; then - echo "::error::${ANCHOR_PKG} is not in the 'fixed' group of .changeset/config.json, so it no longer answers for the release version. Point this step at a package that is still in the group (objectui#5442)." - exit 1 - fi - - manifest_version=$(jq -r '.version // empty' "$ANCHOR_MANIFEST") - if [ -z "$manifest_version" ]; then - echo "::error::Could not read a version from ${ANCHOR_MANIFEST} (objectui#5442)." - exit 1 - fi - - # The registry's per-version endpoint, which answers 200 for a version - # it has published and 404 for one it has never seen. `/` in the scope - # has to be percent-encoded. - encoded=$(printf '%s' "$ANCHOR_PKG" | sed 's|/|%2F|') - url="https://registry.npmjs.org/${encoded}/${manifest_version}" - - on_npm='' - for attempt in $(seq 1 "$ATTEMPTS"); do - code=$(curl -sS -o /dev/null -w '%{http_code}' \ - -H 'Cache-Control: no-cache' --max-time 20 "$url" 2>/dev/null) || code='000' - case "$code" in - 200) on_npm=true; break ;; - 404) on_npm=false; break ;; - *) echo "attempt ${attempt}/${ATTEMPTS}: registry answered ${code} for ${url}" ;; - esac - # An explicit `if`, not `[ ... ] && sleep`: bash exempts AND-lists - # from `set -e`, so the short form works, but it works for a reason - # a reader has to already know. - if [ "${attempt}" -lt "${ATTEMPTS}" ]; then sleep "$BACKOFF_SECONDS"; fi - done - - if [ -z "$on_npm" ]; then - echo "::error::Could not establish whether ${ANCHOR_PKG}@${manifest_version} is on npm after ${ATTEMPTS} attempts, so the publish lane has no predicate to run on. Refusing to guess (objectui#5442)." - exit 1 - fi - - echo "manifest_version=${manifest_version}" >> "$GITHUB_OUTPUT" - echo "version_on_npm=${on_npm}" >> "$GITHUB_OUTPUT" - - { - echo "- declared version: \`${ANCHOR_PKG}@${manifest_version}\`" - echo "- already on npm: \`${on_npm}\`" - } >> "$GITHUB_STEP_SUMMARY" - - if [ "$on_npm" = false ] && [ "${GITHUB_EVENT_NAME}" != 'push' ]; then - # Visible, but not a failure here: this lane cannot publish, and the - # next push to `main` closes the gap. The failure lives on the push - # lane, where it is actionable — see "Verify the release reached npm". - echo "::notice::${ANCHOR_PKG}@${manifest_version} is not on npm. The next push to main will publish it (objectui#5442)." - fi - - release: - name: Changeset Release - needs: lane - # The whole lane split, in one place (objectstack#10850). Each half names its - # own event rather than leaning on the complement of the other: - # - # push + version not yet on npm -> PUBLISH what this commit declares. - # push + version already on npm -> skipped; nothing to release. - # schedule -> REFRESH. - # dispatch WITH the input -> the same refresh, on demand. - # dispatch WITHOUT it -> skipped; the `lane` job says why. - # - # ⚠️ The push half reads `version_on_npm`, NOT a count of pending - # changesets. That is the whole of objectui#5442: pending changesets belong - # to the next version and say nothing about whether THIS one shipped. Since - # objectui#6081 nothing counts them at all — `.changeset/` reaches the - # release only through the step that CLEARS it for the action, never through - # a decision about whether there is a release to make. - # - # `inputs.refresh_version_pr` is guarded by its event test rather than read - # bare: the `inputs` context exists only on `workflow_dispatch`, so on a - # `schedule` run it is null — falsy, and therefore the right answer by - # accident. Say which event we are on, so the guard states the lane split - # instead of leaning on a context's emptiness. - if: >- - (github.event_name == 'push' && needs.lane.outputs.version_on_npm == 'false') || - github.event_name == 'schedule' || - (github.event_name == 'workflow_dispatch' && inputs.refresh_version_pr) - runs-on: ubuntu-latest - - # ── The job ceiling, DERIVED FOR THIS JOB (objectui#7270) ──────────── - # Before this line the only backstop was GitHub's 360-minute default, on - # the lane that PUBLISHES. This ceiling is derived differently from the two - # that objectui#7270 set on `Lint` and `Bundle Analysis`, because this - # job's honest slowest run is not the slowest run in its sample: the `Wait - # for older release runs to finish` step below declares - # `MAX_WAIT_SECONDS: '900'`, so a contended release may legitimately spend - # 15 minutes waiting before it does any work at all. None of the sampled - # runs was contended, so that 15 minutes appears nowhere in the - # distribution below and has to be ADDED rather than measured. - # - # ⛔ NOT inherited from `ci.yml`'s 10/15/20/30/40 — objectui#7048 fences - # exactly that. The arithmetic below is this job's own. - # - # - Population: every successful `schedule` run of this workflow, window - # 2026-08-23T06:08Z .. 2026-09-06T00:23Z, n=25 `Changeset Release` - # jobs. Per-job wall clock from the Actions jobs endpoint - # (`completed_at` minus `started_at`), never the run's total. - # - min 1m23s / median 2m37s / p95 5m37s / max 7m15s. - # - ⚠️ n=25 is the whole recent population, not a sample cut short. This - # job is `skipped` on a push unless that push left the manifest version - # absent from npm, and the 400 most recent successful `push` runs - # (2026-08-30 .. 2026-09-06) skipped it 400 times out of 400. Paging - # further buys skips, not samples. - # - max/p95 = 1.29 — no fat tail inside the sample. - # - # Ceiling = 15min (the declared wait cap, taken at face value: it is - # already a hard, fail-open bound, so multiplying it would only buy slack - # for a clock that cannot overrun) + 3x the measured max work - # (3 x 7m15s = 21.8min) => 36.8min, rounded up to 40. The plain rule the - # other two jobs use would have given 25, which leaves a legitimately - # contended release (15min of waiting + 7m15s of work = 22m15s) inside 12% - # of its own ceiling — a ceiling set under a job's honest slowest run, - # which is the mirror hazard objectui#7048 fenced. - # - # ⛔ Raising this toward 360 is the ruled-out non-fix, and ⛔ lowering it - # under 23 minutes would let the clock truncate a release that the workflow - # itself says is behaving. - timeout-minutes: 40 - - steps: - # The queue the `concurrency:` key above cannot provide. GitHub offers exactly two - # behaviours for a contended group — cancel the running one, or discard the pending - # ones — and neither is acceptable on a workflow that publishes. So the ordering is - # done here: a run waits until every release run older than itself has finished, then - # proceeds. Ordering is by `run_number`, a total order, so the oldest run is always - # free to proceed and this cannot deadlock. - # - # It is deliberately FAIL-OPEN. Every exit path is `exit 0`, and the step additionally - # carries `continue-on-error: true`: if the API cannot be read, or an older run is - # wedged past the cap, this step gives up waiting and lets the release proceed. A run - # that never runs is the defect this queue exists to remove — degrading to today's - # unserialised behaviour is the acceptable failure, blocking a publish is not. - - name: Wait for older release runs to finish - continue-on-error: true - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RUN_NUMBER: ${{ github.run_number }} - # A release job is ~90s of work; this cap is a wedged-run escape hatch, not a - # normal wait. - MAX_WAIT_SECONDS: '900' - POLL_SECONDS: '20' - run: | - set -uo pipefail - - # A rename of this file makes the path 404, `gh` exits non-zero, and the step - # fails open below — it does not silently stop serialising while looking green. - runs_path="repos/${GITHUB_REPOSITORY}/actions/workflows/changeset-release.yml/runs?per_page=100" - older_jq='[.workflow_runs[] | select(.status != "completed" and .run_number < '"${RUN_NUMBER}"')] - | .[] | " waiting on run #\(.run_number) (\(.status)) \(.html_url)"' - - deadline=$(( $(date +%s) + MAX_WAIT_SECONDS )) - while : ; do - if ! older=$(gh api "${runs_path}" --jq "${older_jq}"); then - echo "::warning::Could not list this workflow's runs, so the release lane is not serialised for this run. Proceeding — this step may delay a release, never block one (objectui#5404)." - exit 0 - fi - - count=$(printf '%s\n' "${older}" | grep -c 'waiting on run' || true) - if [ "${count}" -eq 0 ]; then - echo "No older release run is still in flight. Proceeding." - exit 0 - fi - - if [ "$(date +%s)" -ge "${deadline}" ]; then - echo "::warning::${count} older release run(s) still unfinished after ${MAX_WAIT_SECONDS}s. Proceeding without waiting further — a release run that never executes is the defect this queue exists to prevent (objectui#5404)." - exit 0 - fi - - echo "${count} older release run(s) still in flight:" - printf '%s\n' "${older}" - sleep "${POLL_SECONDS}" - done - - - name: Checkout Repo - uses: actions/checkout@v7 - with: - fetch-depth: 0 - submodules: true - - - 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' - registry-url: 'https://registry.npmjs.org' - - # ⛔ Do NOT add a lockfile merge-driver `git config` step back here. - # - # There used to be one, and it could never fire. Removed by objectui#6436 - # under the maintainer's ruling of 2026-08-27 ("dead in CI, live for - # contributors"): this job performs NO local merge, so a driver configured - # here had no occasion to run. - # - # ⭐ The MECHANISM is not retired — only this file's dead half is. - # `.gitattributes` still routes `pnpm-lock.yaml` through `merge=pnpm-merge`, - # and `CONTRIBUTING.md` still tells contributors to define that driver - # locally before `git merge upstream/main`. Measured both ways in a scratch - # repo, one variable changed: WITH the attribute the driver fires and the - # lockfile is regenerated; WITHOUT it, nothing else altered, the identical - # merge ends in `CONFLICT (content)` with conflict markers left inside - # `pnpm-lock.yaml`. The mechanism is contributor-facing with zero CI - # consumers, and this file is where the last CI consumer used to be. - # - # The sweep, so nobody re-derives it a fourth time (objectui#6358 and - # objectui#6369 each re-derived it for a different workflow): - # - # Every `git` in this file, enumerated rather than grepped-for-absence: - # `git status --porcelain` (x2), `git checkout -- .` and `git clean -fdq` - # in "Restore the pre-version tree", and — since objectui#11596 — - # `git rev-parse -q --verify` and `git push origin ` in "Push - # the release tags", which reads and pushes refs and resolves nothing. - # No `merge`, `rebase`, `pull`, `cherry-pick`, `am`, `apply` or `revert` - # anywhere in the file — each of those zero-hits was taken with a - # control term that DID hit the same file, because a zero-hit with no - # control is not a reading. - # - # `git checkout -- .` (below, in the restore step) takes tracked paths - # from the INDEX to undo what `pnpm changeset:version` wrote. That is a - # discard, not a merge; no merge driver participates. - # - # ⭐ The load-bearing part is not in this file at all — it is what - # `changesets/action@v1` does, and it has to be read to be known. Read at - # v1.9.0 (`a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d`), `src/git.ts` and the - # executed `dist/` bundle agreeing: the action's COMPLETE git surface is - # `checkout ` / `checkout -b`, `reset --hard `, `add .`, - # `commit -m`, `push origin HEAD: --force`, `push origin ` - # and `config user.*`. The version-branch update is `prepareBranch` - # (checkout, then `reset --hard github.context.sha`) followed by a commit - # and a FORCE-PUSH. It never merges, rebases or three-ways anything, so on - # this side too the driver had no occasion to fire. (Its `commitMode: - # github-api` path — not selected here; the default is `git-cli` — builds - # the commit through the GitHub API instead, and the bundle reads no - # `.gitattributes` and implements no merge driver at all.) - # - # ⚠️ Re-adding the step reddens `ci-cd-pipeline-doc.test.ts`, which now - # asserts that ZERO workflows configure the driver — in both directions. If - # some future job really does merge locally on the runner, it needs the step - # AND a row in the "Lockfile Merge Driver" table of - # `content/docs/guide/ci-cd-pipeline.md`; the pin fails otherwise. Note that - # pin greps workflow files for the config key, so even NAMING the key in a - # comment here trips it — deliberately the safe direction (a false red, never - # a false green), which is why this comment spells the attribute form. - - # ── Cache bookkeeping cannot void a recorded verdict (objectui#7048) ── - # THE ORDERING, carried here from the type-check split in `ci.yml` - # (objectui#6577, PR #7047) because it is what a future reader needs in - # order to judge whether these steps may be touched: the verdict is - # recorded by the checking steps; everything after them is bookkeeping, - # and bookkeeping must never discard an answer the gate already produced. - # In this job the answer at stake is the publish/refresh outcome and the - # post-version tree validation below it. - # - # Why the SPLIT rather than a timeout on one step: combined - # `actions/cache` declares `main: dist/restore/index.js` plus - # `post: dist/save/index.js`, so its save is a step the RUNNER generates - # at job end (`Post Turbo Cache`). No workflow syntax attaches - # `timeout-minutes` or `continue-on-error` to a generated post step. - # Measured once, on `ci.yml`'s type-check cache: a 1-second save took - # 789s and ran that job into its ceiling (objectui#6577). - # `actions/cache`'s own `save-always` deprecation text points at this - # same split. - # - # ⚠️ This job's ceiling is `timeout-minutes: 40`, DERIVED at the job - # header above from this job's own run distribution PLUS the 900-second - # wait cap this job declares for itself (objectui#7270). Until that line - # existed the ceiling was GitHub's 360-minute default — and this is the - # PUBLISH lane, where a job that hangs after `Publish to npm` has already - # shipped and the concurrency note above records what a long-running - # release job does to the pushes behind it. The constraint that used to - # argue against any ceiling at all is the one that shaped 40: the release - # act must never be truncated by a clock, so the number is generous - # rather than tight. The bound below is still on the bookkeeping step - # alone, which runs after every release step. - # - # The restore half is deliberately left UNBOUNDED: a restore stall fails - # BEFORE anything is published, which is the honest failure. - # - # ONE restore key, this job's own prefix (objectui#12276, the shape - # `ci.yml`'s `type-check` job took in objectui#12222). Until then this key - # was `turbo--` with a bare `turbo--` fallback, and `Lint` - # and `Bundle Analysis` saved under that same key, so this job restored - # whichever of their caches reached the sha first -- and its own save - # below found an exact hit and skipped. Every run of this job is on - # `main` (push, `schedule`, dispatch), so its own previous entry is what - # the prefix finds. - - name: Restore Turbo Cache - id: turbo-cache - uses: actions/cache/restore@v6 - with: - path: .turbo/cache - key: turbo-${{ runner.os }}-release-${{ github.sha }} - restore-keys: | - turbo-${{ runner.os }}-release- - - # objectui#12957: every @objectstack/* package resolves to the build of the - # commit `.objectstack-sha` pins, read from node_modules/.cache/objectstack, so - # it must be there before pnpm resolves anything. Restored by the pin and built - # only on a miss (scripts/objectstack-pin.mjs); the save is this job's last step. - - name: Restore the pinned @objectstack build - id: objectstack-pin - uses: actions/cache/restore@v6 - with: - path: node_modules/.cache/objectstack - key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }} - - - name: Build @objectstack at the pin (skips a restored build) - id: objectstack-build - run: node scripts/objectstack-pin.mjs build - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - # ⛔ Do not add a `pnpm test` step back here. - # - # There used to be one, and it was 31m19s of a 33m22s job (medians over the 106 runs - # of #3712-#3911 that executed; everything else combined is ~90s — install 6s, build - # 35s, the changesets action 24s). It re-tested a commit that is ALREADY on `main`, so - # it could not keep anything out; the only thing it could do was stop the release - # afterwards, and it did: 2 of the last 27 release-PR merges (runs #3606 and #3901) - # failed here and skipped the changesets step entirely. #3606 was the run that would - # have published @object-ui/*@17.5.0, which reached npm 6h50m late, on a later push - # that happened to still find `.changeset/` empty. - # - # The backstop for `main` is `ci.yml`'s push lane, which runs the whole suite under - # coverage across every shard of its `test-coverage` matrix on every push and enforces - # the thresholds on the merged report (objectui#5403). That is the copy that tests this - # commit; this one only ever duplicated it, ~34 minutes later. - - - name: Build packages - env: - TURBO_RUN_SUMMARY: 'true' - run: pnpm build - - # ══════════════════════════════════════════════════════════════════════ - # One changesets step per lane, and each one CANNOT do the other's job. - # ══════════════════════════════════════════════════════════════════════ - # These were a single step carrying both `version:` and `publish:`, which - # let repository state pick the behaviour (the algorithm is quoted in this - # file's header). Splitting them is what makes each lane's limit structural - # instead of conditional: the refresh step has no `publish:` input, so no - # state — not even a `.changeset/` that has just been emptied by a merge - # racing the tick — can make a scheduled run publish. An `if:` cannot say - # that; an absent input can. - # - # The refresh step's `id:` is for the log; nothing reads it. The publish - # step's IS read: the two tag/Release steps after the npm check gate on - # its outcome (objectui#11596). - # - # ══════════════════════════════════════════════════════════════════════ - # ⛔ THE `@v1` PIN IS LOAD-BEARING. READ THIS BEFORE BUMPING IT. - # ══════════════════════════════════════════════════════════════════════ - # `changesets/action@v1` is pinned because two lanes in this file are - # built on properties asserted against **v1's source**, not because anyone - # prefers an old version. Dependabot has proposed the v1 -> v2 bump in both - # repositories and it was declined in both, deliberately and on the same - # reasoning: objectstack#9208 (closed unmerged 2026-08-19) and - # objectui#4945 (closed unmerged 2026-08-23). Neither was merged; both - # repositories stay on v1. - # - # A bump is not forbidden — it is UNVERIFIED. What follows is what a future - # upgrade has to re-establish, written here because the next reader stands - # here and not in a closed PR. - # - # THE TWO PROPERTIES AN UPGRADE MUST RE-VERIFY - # -------------------------------------------- - # (1) THE DISPATCH TABLE. `!hasChangesets && hasPublishScript -> - # runPublish` (quoted in full in this file's header, from v1's - # `src/index.ts`). BOTH the `lane` job's predicate and the "Clear - # pending changesets" step exist only because the action chooses its - # branch from repository state rather than from an input. If v-next - # takes an explicit instruction instead, the clear step is no longer a - # workaround to keep — it is dead weight to delete, and keeping it - # would be the bug. - # - # (2) `runPublish` NEVER COMMITS AND NEVER PUSHES A BRANCH. Verified in - # v1's `src/run.ts`: `runPublish` spans lines 83-198 and the only git - # operation inside it is `git.pushTag()` (lines 124 and 146); - # `git.prepareBranch()` (line 290) and `git.pushChanges()` (line 362) - # occur solely inside `runVersion`. THIS is what makes the clear - # step's runner-local deletion safe. ⚠️ If that ever stops holding, - # that step stops being a local trick and becomes a commit that - # DELETES EVERY PENDING CHANGESET IN THE REPOSITORY — ~161 files at - # the time of writing. Re-verify it by reading the source, not by - # reading release notes. - # ⚠️ Under CLI v3 even that `git.pushTag()` is never reached — it sits - # behind the `New tag:` parse, which finds nothing — so the tags are - # pushed by this file's own "Push the release tags" step. That step - # pushes tag refs alone, so the property's conclusion still holds: - # nothing on the publish lane commits (objectui#11596). - # - # WHAT v2 ACTUALLY CHANGES (read from its CHANGELOG, not from compiled - # source; every item below was checked against the v2.0.0 entry) - # --------------------------------------------------------------------- - # - #681 / #668 — INPUTS RENAMED AND KEBAB-CASED: `version` -> - # `version-script`, `publish` -> `publish-script`, `commit` -> - # `commit-message`, `title` -> `pr-title` (also `branch` -> - # `pr-base-branch`, which this file does not use). This file passes all - # four of the old names. Under v2 they are simply unknown inputs, and - # the two failures are both SILENT: the publish lane loses its - # `publish:` and stops publishing, and the refresh lane loses its - # `version:` and falls back to bare `changeset version` — which skips - # `scripts/sync-quick-reference-release.mjs` and breaks objectui#5394. - # (`scripts/__tests__/sync-quick-reference-release.test.ts` pins the - # `version:` line and would catch the rename, so that half fails loudly - # at least in CI.) - # - #692 — RELEASE COMMITS AND TAGS ARE PUSHED VIA THE GitHub API BY - # DEFAULT (`commit-mode` replaced by a boolean `push-with-git-cli`). - # ⚠️ This is the one that undercuts property (2): that verification is - # about v1's git-CLI path. An API-based push is a different code path - # and property (2) must be re-established against it from scratch. - # - #674 — the `GITHUB_TOKEN` ENVIRONMENT VARIABLE IS NO LONGER ACCEPTED; - # a custom token must go through the `github-token` input. Both steps - # below pass it via `env:`. - # - #695 — `.npmrc` HANDLING REMOVED when `NPM_TOKEN` is set; npm auth is - # expected via Trusted Publishing or `actions/setup-node`'s - # `registry-url`. This job does run `actions/setup-node` with - # `registry-url`, so that half may already be satisfied — but it is - # `NODE_AUTH_TOKEN` that such a setup consumes, and the interaction has - # to be checked rather than assumed. - # - #678 — PUBLISHED-PACKAGE DETECTION MOVED off stdout parsing onto a - # shared output file named by a `CHANGESETS_OUTPUT` environment - # variable, which custom scripts must forward to the Changesets CLI. - # `pnpm changeset:publish` is a custom script - # (`check-published-dist-tooling.mjs && changeset publish`), so it would - # have to forward it or git tags and GitHub releases stop being created - # (#697 downgrades that to a warning rather than an error — i.e. it - # fails QUIETLY, which is this card's whole subject matter). - # ⚠️ v1 met that same quiet failure WITHOUT a bump: its detection is - # the stdout parse for `New tag:` lines, CLI v3 prints none, and - # 17.6.0 and 17.7.0 shipped with no tag and no Release - # (objectui#11596). Tags and Releases no longer depend on the action's - # detection in either major — the two steps after the npm check make - # them — so this item now concerns only the action's OWN Releases. - # - # ⛔ DO NOT WRITE "we are held back by the Changesets CLI generation." - # That is the plausible wrong answer. v2's #699 validates that projects use - # Changesets CLI v3 and directs CLI **v2** users to `changesets/action@v1` - # — and this repository is already on v3: `package.json` declares - # `@changesets/cli: ^3.0.0` and `pnpm-lock.yaml` resolves it to 3.0.1 - # (`../objectstack` declares `^3.0.0` too). Measured, not assumed. The CLI - # generation is NOT what pins us; the two properties above are. - # - # Worth knowing rather than acting on: v2's #656 adds `/select-mode`, - # `/version` and `/publish` SUB-ACTIONS. A dedicated publish sub-action is - # plausibly the shape that makes the clear step unnecessary — which is an - # argument for revisiting the pin eventually, not for bumping it blind. - # - # ⛔ WHAT NOTICES A BUMP. This v1 ref is pinned by exactly one thing: - # `scripts/__tests__/changeset-release-action-ref-pin.test.ts`. It fails on - # a bump of EITHER `uses: changesets/action@...` line in this file and names - # the two properties above as what a bump must re-establish. There is no - # second guard — the type checker and the linters do not read this file at - # all — so ⛔ do not delete that test as collateral of some other cleanup. - # - # It is deliberately a pin on the REF and not a copy of the action's - # behaviour. objectui#5775 tried the other shape: the `lane` job carried a - # bash mirror of the action's file scan, measured against BOTH readers and - # found at different majors (this action bundles `@changesets/read@^0.6.7`, - # the repository installs `1.0.0`, and they disagree on `AGENTS.md` / - # `CLAUDE.md` / `GEMINI.md` and on `.changeset/pre/`). objectui#6081 - # deleted that mirror, because the bundled copy is a minified chunk behind - # a MOVING tag and cannot be pinned from here at any effort — a retag of - # `v1` in place changed it and nothing here failed. A ref pin catches the - # DELIBERATE bump, which is the only moment anyone can act on. - # - # ⭐ THE REF IS A COMMIT SHA, NOT THE MOVING `v1`. Both `uses:` lines name - # `a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d`, the commit the `v1` branch - # and the `v1.9.0` tag both resolved to when this was written, and the - # same commit the git-surface reading above was taken at. This step runs - # with `NPM_TOKEN`, so a retag of `v1` would otherwise hand the publish - # credential to code nobody here has read; a sha cannot move. A bump is - # still a deliberate edit of both lines to another sha (the trailing - # `# vX.Y.Z` comment names the release it is), and it still has to - # re-establish the two properties above. The sha is a declared exception - # to `scripts/check-action-ref-convention.mjs`'s floating-tag default. - - # ⚠️ THE STEP THAT LETS THE PREDICATE REACH THE ACTION (objectui#5442). - # - # The job guard above no longer says anything about `.changeset/` — it says - # the declared version is not on npm. But `changesets/action@v1` still - # picks its own branch from `hasChangesets`, so on a release-merge commit - # carrying the NEXT version's changesets it would take `runVersion` and - # publish nothing. That is objectui#5442's defect arriving through the - # action instead of through the job guard, and no `if:` can talk the action - # out of it. A tree with nothing pending can. - # - # ⛔ Nothing is committed, and no branch is pushed. That is verified - # against the action's source rather than assumed: `runPublish` - # (`src/run.ts`) runs the publish script, then for each package whose - # `New tag:` line it parses — none, under CLI v3 (objectui#11596) — pushes - # a TAG and creates a GitHub release. It never commits, and never pushes a - # branch. The one push this lane does make is the tag-ref push after the - # npm check, of tags at HEAD, which is `github.sha` because nothing - # commits. So this deletion cannot leave the runner, and `.changeset/` on - # `main` is untouched — those changesets are still owed to the next - # version PR, which the 6-hourly refresh lane will render as usual. - # - # This is deliberately a SUPERSET of what the action counts, and it is NOT - # a second copy of the `lane` job's mirror. The mirror's job is to PREDICT - # the action's branch, and predicting requires agreeing exactly. This step - # has a one-directional obligation — leave the count at zero — so removing - # more than the action would count is safe, and it is checked directly - # below instead of being reasoned about. Keeping only `README.md` covers - # every name the reader ignores (`README.md` case-insensitively, plus - # `AGENTS.md` / `CLAUDE.md` / `GEMINI.md`) as well as `.changeset/pre/*.md`, - # without owning a list that has to track theirs. - # - # `.changeset/config.json` is NOT touched: `changeset publish` reads the - # `fixed` group from it, and so does `check-published-dist-tooling.mjs`, - # which `pnpm changeset:publish` runs first. - # - # ⛔ THE `github.event_name == 'push'` TEST IS LOAD-BEARING. DO NOT DROP IT. - # - # This guard used to carry a second half that read the `lane` job's - # `pending_changesets` output. objectui#6081 deleted that half — the - # step is idempotent and a measured no-op on an empty tree, so predicting - # whether it has work was buying nothing at the price of a mirror of - # another project's file scan (see the `lane` job's report step). - # - # ⚠️ The EVENT half is a different thing entirely and dropping it kills the - # release SILENTLY. On the refresh lane this step would empty the tree - # before the action runs, so `hasChangesets` is false — and the refresh - # step passes no `publish:` input, so `hasPublishScript` is false too. - # v1 then takes `case !hasChangesets && !hasPublishScript` (`src/index.ts` - # line 68), logs "No changesets present or were removed by merging release - # PR", and RETURNS. The 6-hourly refresh becomes a permanent no-op and the - # standing "chore: release packages" PR fossilises forever, with nothing - # failing anywhere. That is this file's own failure class, on the other - # lane. What objectui#6081 deleted is the PREDICTION, not the event scoping. - # - # ⚠️ ONE CASE WHERE THIS STEP IS NOT LITERALLY A NO-OP: PRE MODE. Under - # `@changesets/cli` 3, `.changeset/pre.json` holds only `mode` and `tag`. - # Each pre-mode `changeset version` MOVES the changesets it consumes into - # `.changeset/pre/` instead of deleting them; later pre-mode runs skip - # them, and the version run after `changeset pre exit` deletes them - # together with `pre.json`. This step's glob takes `.changeset/pre/*.md` - # too, so on a pre-mode tree it removes those consumed records along with - # anything pending, while `pre.json` itself SURVIVES (it is not a `.md`), - # so pre mode is still detected. Harmless on two independent grounds — - # the deletion is runner-local (property (2) above), so on `main` the - # records stay where the version run put them; and `changeset publish` - # takes its dist-tag from `pre.json` and reads no changeset file. Neither - # a count nor the repository's mode is written down here, because both - # move: the step's `Removed N` line prints the count on every run (on a - # pre-mode tree it includes every consumed record), and whether - # `.changeset/pre.json` exists, and what it says, is the record of the - # mode. The layout above was read from the CLI 3 source and measured once - # on a scratch tree; nothing re-derives it, so re-read it when the CLI - # major moves. Written down because a pre-release is precisely when - # nobody wants to rediscover it. - - name: Clear pending changesets for the publish branch - if: github.event_name == 'push' - run: | - set -euo pipefail - shopt -s nullglob - - # `/^README\.md$/i` is how `@changesets/read` spells its exclusion, so - # the comparison is case-folded rather than matching one spelling. - keep_this() { - [ "$(printf '%s' "${1##*/}" | tr '[:upper:]' '[:lower:]')" = 'readme.md' ] - } - - removed=0 - for file in .changeset/*.md .changeset/pre/*.md; do - if keep_this "$file"; then continue; fi - rm -f "$file" - removed=$((removed + 1)) - done - echo "Removed ${removed} pending changeset file(s) from the runner's working tree." - - left=0 - for file in .changeset/*.md .changeset/pre/*.md; do - if keep_this "$file"; then continue; fi - echo "still pending: ${file}" - left=$((left + 1)) - done - if [ "${left}" -ne 0 ]; then - echo "::error::${left} changeset file(s) survived the clear step, so changesets/action will take its runVersion branch and publish nothing (objectui#5442)." - exit 1 - fi - - # PUBLISH. Reachable only from `push`. Between the job guard (the declared - # version is not on npm) and the step above (nothing pending in the tree), - # the action takes its `!hasChangesets && hasPublishScript` branch, which is - # `runPublish`. No `version:` here: it would be dead configuration, and a - # reader is better served by a step that carries only what it can use. - # - # ⚠️ Do not mistake that for a guarantee. Omitting `version:` does NOT - # disable the refresh branch — the action falls back to plain - # `changeset version` — so what keeps THIS step from force-pushing the - # version PR is now the clear step above, and only that. If you ever drop - # or weaken it, this step starts refreshing the PR again, and the release - # goes back to being skipped exactly as objectui#5442 describes. - - name: Publish to npm - id: changesets-publish - if: github.event_name == 'push' - uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 - with: - publish: pnpm changeset:publish - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - # ══════════════════════════════════════════════════════════════════════ - # THE LOUD CHECK (objectui#5442, ruled alongside the predicate) - # ══════════════════════════════════════════════════════════════════════ - # The defect this closes was never a red run — it was a GREEN one. Run 3370 - # on `cfeb378b5` (#3598) completed `success` having published nothing, - # purely because three changesets were pending; 17.4.0 was skipped, the next - # version PR bumped past it, and only an audit of CHANGELOG-against-registry - # noticed, 16 versions later. A publish lane whose failure mode is a green - # tick is not a publish lane, so this one is not allowed to finish green - # without the version it exists to ship being on npm. - # - # There is no innocent reading of a failure here: the job only reached this - # step because the `lane` job said this version was ABSENT from npm. - # - # Scope, so it is not mistaken for more. This asserts the fixed group's - # anchor, and that is enough because the OTHER failure shapes are already - # red without it: a per-package publish failure makes `changeset publish` - # exit non-zero and the action forwards that exit code - # (`process.exit(result.exitCode)` in its `src/index.ts`). What only this - # step can catch is the lane publishing NOTHING while reporting success. - # - # The retries are for registry propagation, not for flakiness: `npm publish` - # returns before the version is readable through every CDN edge. An - # unreadable registry is a failure too — a release nobody can confirm is not - # a confirmed release. - - name: Verify the release reached npm - # The `id:` is read: the two tag/Release steps below gate on this - # step's outcome (objectui#11596). - id: verify-npm - if: github.event_name == 'push' - env: - ANCHOR_PKG: '@object-ui/core' - ANCHOR_MANIFEST: packages/core/package.json - EXPECTED: ${{ needs.lane.outputs.manifest_version }} - ATTEMPTS: '10' - BACKOFF_SECONDS: '15' - run: | - set -euo pipefail - - # The lane job read the anchor from a sparse checkout of the same sha; - # if the two disagree the predicate was computed against a different - # tree than the one just published, and nothing below means what it says. - from_tree=$(jq -r '.version // empty' "$ANCHOR_MANIFEST") - if [ "${from_tree}" != "${EXPECTED}" ]; then - echo "::error::The lane job keyed on ${ANCHOR_PKG}@${EXPECTED} but this checkout declares ${from_tree}. The publish decision was made against a different tree (objectui#5442)." - exit 1 - fi - - encoded=$(printf '%s' "$ANCHOR_PKG" | sed 's|/|%2F|') - url="https://registry.npmjs.org/${encoded}/${EXPECTED}" - - for attempt in $(seq 1 "$ATTEMPTS"); do - code=$(curl -sS -o /dev/null -w '%{http_code}' \ - -H 'Cache-Control: no-cache' --max-time 20 "$url" 2>/dev/null) || code='000' - if [ "${code}" = '200' ]; then - echo "${ANCHOR_PKG}@${EXPECTED} is on npm." - echo "- published: \`${ANCHOR_PKG}@${EXPECTED}\`" >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - echo "attempt ${attempt}/${ATTEMPTS}: registry answered ${code} for ${url}" - # An explicit `if`, not `[ ... ] && sleep`: bash exempts AND-lists - # from `set -e`, so the short form works, but it works for a reason - # a reader has to already know. - if [ "${attempt}" -lt "${ATTEMPTS}" ]; then sleep "$BACKOFF_SECONDS"; fi - done - - echo "::error::${ANCHOR_PKG}@${EXPECTED} is STILL not on npm after a publish run that reported success. The repository declares a version the registry has never seen — this is objectui#5442's silent failure, made loud. Check the 'Publish to npm' step's log for which branch changesets/action took." - exit 1 - - # ══════════════════════════════════════════════════════════════════════ - # TAGS AND GITHUB RELEASES (objectui#11596) - # ══════════════════════════════════════════════════════════════════════ - # `changesets/action@v1` used to do both of these itself, and since - # objectui#5296 it does neither. v1's `runPublish` learns WHAT was - # published by parsing the publish script's stdout for - # `New tag: @` lines, and pushes a tag and creates a - # Release only for the packages it finds there. `@changesets/cli` v3 - # prints no such line — it reports through `@clack/prompts` and an output - # report — so v1 found nothing, and 17.6.0 and 17.7.0 reached npm with no - # git tag and no GitHub Release while every step above stayed green. The - # npm check above reads npm only; nothing read the tags or the Releases. - # - # `changeset publish` still CREATES the tags, on this runner, at HEAD: an - # annotated `@` tag per package it published, under the git - # identity v1's default `setupGitUser` configures before it runs the - # publish script. They just never left the runner. So the two steps below - # take over the half of `runPublish` v1 can no longer reach, and neither - # depends on its stdout parse: - # - # 1. push the tags `changeset publish` created — exactly the - # `@` set this version owes, in ONE push - # (objectstack#2191: per-tag pushes fired concurrently raced - # GitHub's ref backend), and loudly when one is missing; - # 2. create one GitHub Release per public package with - # `scripts/release-github-releases.mjs`, ported from objectstack: - # idempotent, and every body truncated to the Releases API's - # 125,000-character limit, which the 17.7.0 entries far exceed. - # - # ⛔ PUBLISH LANE ONLY. Both carry `github.event_name == 'push'`, like - # every publish-lane step in this job, and both read the outcomes of the - # publish step and of the npm check — two steps a refresh run never - # executes, so on `schedule` / `workflow_dispatch` both outcomes are - # `skipped` and neither condition can be true. A version already on npm - # never reaches this job at all (the job guard above). - # - # `!cancelled()` rather than the implicit success(), as objectstack's - # `release.yml` gates its own Release step (objectstack#4900): once the - # npm check has passed the version is public, so a failure between here - # and the Release step — the tag push, say — must not also cost the - # release its record. The two outcomes are the gate, not the job status. - # - # ⚠️ This is the only push the publish lane makes, and it pushes tag refs - # alone. Each points at HEAD, which is still `github.sha`: no step on this - # lane commits, so the clear step's runner-local deletions are in no - # commit and cannot leave the runner through a tag. Property (2) of the - # `@v1` pin above is about the ACTION's pushes; this adds tag refs to - # them and nothing else. - # - # No `git config user.*` here, deliberately. objectstack's publish step - # configures one because it runs `changeset publish` itself; here the - # action runs it, after its own `setupGitUser`, and by the time this step - # runs the tags exist. Pushing a ref creates no object, so there is - # nothing for an identity to sign. - # - # `contents: write` is what both need — a tag push and a Release create - # are both contents writes — and this job inherits it from the - # workflow-level `permissions:` block, declaring none of its own. - - name: Push the release tags - if: >- - ${{ !cancelled() && github.event_name == 'push' - && steps.changesets-publish.outcome == 'success' - && steps.verify-npm.outcome == 'success' }} - env: - RELEASE_VERSION: ${{ needs.lane.outputs.manifest_version }} - run: | - set -euo pipefail - - # The set this version owes, from the same enumeration the Release - # step below uses rather than a second copy of it in bash. - owed=$(node scripts/release-github-releases.mjs --print-tags) - - refs=() - missing=() - while IFS= read -r tag; do - if [ -z "${tag}" ]; then continue; fi - if git rev-parse -q --verify "refs/tags/${tag}" > /dev/null; then - refs+=("refs/tags/${tag}") - else - missing+=("${tag}") - fi - done <<< "${owed}" - - # ONE push for the whole set. A tag the remote already holds at the - # same object is a no-op, so this never fails on a tag it has - # already pushed. - if [ "${#refs[@]}" -gt 0 ]; then - git push origin "${refs[@]}" - fi - echo "- release tags pushed: \`${#refs[@]}\`" >> "$GITHUB_STEP_SUMMARY" - - if [ "${#missing[@]}" -gt 0 ]; then - echo "::error::changeset publish left no local tag for ${#missing[@]} package(s) this release owes: ${missing[*]}. The next step still creates their Releases, and the API then creates each missing tag itself as a LIGHTWEIGHT tag at ${GITHUB_SHA} (objectui#11596)." - exit 1 - fi - - # Ordering is load-bearing: a Release POSTed for a tag the remote does not - # have makes the API create that tag itself — lightweight, at - # `target_commitish`, which the script sets to this run's `github.sha`. - # The annotated tags therefore go first, and this step finds them there. - # - # ⛔ No `--dry-run` here: it plans and prints, calls no API, and exits 0 — - # a Release step that would report success having released nothing. - - name: Create GitHub Releases (bodies truncated to the API limit) - if: >- - ${{ !cancelled() && github.event_name == 'push' - && steps.changesets-publish.outcome == 'success' - && steps.verify-npm.outcome == 'success' }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # The fixed group releases every public package at one version, so - # the version alone drives the whole publishable workspace — the - # value the npm check above has just confirmed. - RELEASE_VERSION: ${{ needs.lane.outputs.manifest_version }} - run: node scripts/release-github-releases.mjs - - # ══════════════════════════════════════════════════════════════════════ - # POST-VERSION VALIDATION — THE ONLY GATE THE RELEASE COMMIT EVER PASSES - # (objectui#5397) - # ══════════════════════════════════════════════════════════════════════ - # The release PR runs NO CI, and cannot be made to. Re-measured - # 2026-08-25: `ci.yml` has 849 runs on `changeset-release/main` and every - # recent one is `action_required` with - # `created_at == run_started_at == updated_at` — created and immediately - # parked, nothing executed. GitHub does not start workflow runs from - # events raised by `GITHUB_TOKEN`, and the refresh step below force-pushes - # that branch, so there is no stable head for a human to re-run against - # either. On the 17.6.0 release PR the check-runs endpoint returned - # `total_count: 1` — one job, started 7 SECONDS AFTER the merge. The - # largest machine-generated diff of the week is therefore the only commit - # that reaches `main` without passing the merge queue. - # - # Maintainer ruling 2026-08-22 (「接受所有」): validate the POST-VERSION - # tree HERE, before the action opens or updates the PR. Not by giving the - # PR a PAT / GitHub App token (widens the supply-chain trust surface and - # adds rotation obligations), and not by guaranteeing outputs one artifact - # at a time (objectui#5396 saved exactly one doc row and did not - # generalise). The ruling attached a condition — PRICE IT FIRST — and the - # price is what chose the shape below. - # - # WHY THIS IS NOT `pnpm test` (measured 2026-08-25, not argued) - # ------------------------------------------------------------ - # The naive reading of the ruling is "run the suite on the post-version - # tree". It is the wrong shape, because THE VERSION STEP CANNOT MOVE A - # SOURCE BYTE. Rendered against this repository's real tree (328 pending - # changesets, 17.6.0 -> 17.7.0) `pnpm changeset:version` touches 411 paths - # and every one of them is one of four kinds: - # - # 330 .changeset/*.md deleted (consumed by the version) - # 40 */package.json the `"version"` key and NOTHING else — - # 80 changed lines, all of them `"version":` - # 40 */CHANGELOG.md generated from the changeset bodies - # 1 QUICK_REFERENCE.md rewritten by `changeset:version`'s - # `sync-quick-reference-release.mjs` - # - # Zero `.ts`, zero `.tsx`, zero config. The source in the post-version - # tree is byte-identical to the `main` commit this run checked out, and - # `ci.yml`'s push lane already ran the whole suite over that commit under - # coverage across every shard. A `pnpm test` here would re-test bytes that - # were tested ~13 min ago and would report nothing new — at 40 minutes a - # go (`ci.yml` measured its own unsharded suite at 39 min 51 s of tests / - # 40 min 19 s of job), four times a day on the cron below, i.e. ~2.7 h of - # runner time daily for a PR nobody reads until release day. That is the - # cost objectstack#10850 was closed to REMOVE, walking back in. - # - # ⚠️ "SOURCE" ABOVE MEANS `.ts`/`.tsx`/config AND NOT EVERY BUILD INPUT: a - # generated `CHANGELOG.md` IS a Tailwind source input for any package whose - # stylesheet entry scans it, which is how a changeset body that merely - # QUOTED a class name used to compile that class into a published sheet and - # subtract it back out of the plugin sheets (objectui#9569). - # - # So the validation is scoped to what the diff can actually move, and the - # union — `ci.yml` on the source, these steps on the version outputs — is - # the whole post-version tree. - # - # pnpm quick-reference:check QUICK_REFERENCE.md ~1 s - # pnpm check:control-bytes the 40 generated CHANGELOGs ~4 s - # pnpm test scripts/__tests__ every test in that directory ~50 s - # - # The tooling suite is named as a DIRECTORY rather than as a list of test - # files, deliberately: every test in this repository that reads a - # manifest version, `QUICK_REFERENCE.md` or a `CHANGELOG.md` lives in it - # (measured — 7 of 7 matches), `doc-version-claims.test.ts` among them, - # and a list would go stale the first time someone adds an eighth. - # - # ⭐ The middle column names that directory instead of counting it, for - # the same reason. It read "73 files / 1996 tests" until objectui#8122, - # by which time the directory held 121 files and 3618 tests -- a figure - # copied into a comment is true only on the day it is written, and this - # one was false even on 2026-08-25, the date this block measures from: - # `main` carried 81 test files there, not 73. - # The run prints its own `Test Files` / `Tests` line; that is the count. - # - # Measured green on a real rendered tree, 2026-08-25: 1 s + 4 s + 50 s. - # ⚠️ Those wall-clocks are THAT DAY's readings, kept because the scoping - # decision was made on their order of magnitude. The tooling suite has - # grown since, so re-measure before quoting them as today's. - # - # ⛔ WHAT IS DELIBERATELY NOT RUN, AND WHY IT IS NOT AN OMISSION. - # `check:spec-floors` and `check:published-dist` read dependency RANGES - # and built `dist/` output. The measurement above is what rules them out: - # no range moved (the only changed key is `"version"`) and no source moved, - # so on this tree they can only restate the verdict they gave on `main`. - # `check:published-dist` also costs a full build (8 m 39 s cold, measured), - # and `pnpm changeset:publish` runs both of them first on the publish lane - # anyway, where the tree they judge is the one being shipped. - # - # ⛔ WHY THERE IS NO "ONLY WHEN THE PR CONTENT CHANGED" CONDITION. That - # layer was the other half of the ruling's condition, and the measurement - # retired it: across the seven consecutive 6-hourly windows from - # 2026-08-23T06:08Z to 2026-08-25T00:10Z, SIX carried new changeset files - # (median 18 per window; 219, 46, 23, 18, 13, 9, and one zero). This - # repository lands ~18 merges a working day and most carry a changeset, so - # a content-change predicate would skip about one refresh in seven — it - # would buy ~14% of 70 seconds while adding exactly the thing - # objectui#6081 just deleted from this file: a local prediction of another - # project's state that is free to be wrong silently. The cost was solved - # by SCOPE, so frequency does not need solving. - # - # FAILURE SEMANTICS. A red validation fails the job and the refresh step - # below never runs, so the standing PR keeps its last VALIDATED content - # instead of being force-pushed to a broken one. If the post-version tree - # stays broken the PR goes stale — but loudly, on a red run every six - # hours, which is the opposite of this card's defect. Nothing here can - # block the publish lane: every step is scoped to `schedule` / - # `workflow_dispatch`, and `push` skips all three. - # - # ⚠️ THE RESTORE STEP IS LOAD-BEARING — DO NOT DROP IT. These steps render - # the version into the runner's working tree, and `changesets/action` - # picks its branch from that tree: with `.changeset/` already consumed it - # would find `hasChangesets` false, and with no `publish:` input on the - # refresh step `hasPublishScript` is false too, so v1 takes - # `case !hasChangesets && !hasPublishScript` (`src/index.ts` line 68), - # logs "No changesets present or were removed by merging release PR", and - # RETURNS. The refresh becomes a permanent no-op and the PR fossilises - # with nothing failing anywhere — this file's own failure class, one lane - # over. So the tree is put back and the restoration is ASSERTED rather - # than assumed, in the same shape the clear step asserts its own work. - # The action then runs `pnpm changeset:version` itself, on the inputs - # these steps measured, and it — not this job — owns the commit and the - # push. - - name: Render the post-version tree - id: post_version - if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' - run: | - set -euo pipefail - shopt -s nullglob - - # `/^README\.md$/i` is how `@changesets/read` spells its exclusion, so - # the comparison is case-folded rather than matching one spelling. - count_pending() { - local n=0 file - for file in .changeset/*.md .changeset/pre/*.md; do - if [ "$(printf '%s' "${file##*/}" | tr '[:upper:]' '[:lower:]')" = 'readme.md' ]; then - continue - fi - n=$((n + 1)) - done - printf '%s' "$n" - } - - pending_before=$(count_pending) - echo "pending_before=${pending_before}" >> "$GITHUB_OUTPUT" - - pnpm changeset:version - - changed=$(git status --porcelain | wc -l | tr -d ' ') - { - echo "- post-version tree rendered from \`${GITHUB_SHA}\`" - echo "- pending changesets consumed: \`${pending_before}\`" - echo "- paths the version step moved: \`${changed}\`" - } >> "$GITHUB_STEP_SUMMARY" - - - name: Validate the post-version tree - if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' - run: | - set -euo pipefail - pnpm quick-reference:check - pnpm check:control-bytes - pnpm test scripts/__tests__ - - - name: Restore the pre-version tree - if: always() && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') - env: - PENDING_BEFORE: ${{ steps.post_version.outputs.pending_before }} - run: | - set -euo pipefail - shopt -s nullglob - - if [ -z "${PENDING_BEFORE}" ]; then - echo "The render step did not run, so there is nothing to restore." - exit 0 - fi - - git checkout -- . - # Narrow on purpose: the version step can only create files beside a - # package manifest, and a `git clean` in a job that also publishes - # should never be able to reach further than the thing it undoes. - git clean -fdq -- packages apps examples .changeset - - count_pending() { - local n=0 file - for file in .changeset/*.md .changeset/pre/*.md; do - if [ "$(printf '%s' "${file##*/}" | tr '[:upper:]' '[:lower:]')" = 'readme.md' ]; then - continue - fi - n=$((n + 1)) - done - printf '%s' "$n" - } - - pending_after=$(count_pending) - # `--untracked-files=no`: the build that ran earlier in this job - # leaves its own untracked output around, and none of it is part of - # what the version step moved or of what the action reads. Tracked - # state plus the pending count is the whole restoration. - dirty=$(git status --porcelain --untracked-files=no | wc -l | tr -d ' ') - - if [ "${pending_after}" != "${PENDING_BEFORE}" ] || [ "${dirty}" -ne 0 ]; then - echo "::error::The pre-version tree was not restored (${PENDING_BEFORE} changesets before, ${pending_after} after; ${dirty} tracked path(s) still modified). changesets/action would read this tree, find nothing pending and return without refreshing the PR — a silent permanent no-op (objectui#5397)." - exit 1 - fi - echo "Pre-version tree restored: ${pending_after} pending changeset(s), working tree clean." - - # REFRESH. No `publish:` input, so `runPublish` is unreachable; no npm - # credentials in `env:`, so it is unreachable a second time over. Both are - # deliberate — this lane runs unattended on a clock, and the release act in - # this repository is a human merging the PR it regenerates. - # - # `version:` must stay `pnpm changeset:version` rather than bare - # `changeset version`: the npm script also runs - # `scripts/sync-quick-reference-release.mjs`, which is the only thing that - # keeps `QUICK_REFERENCE.md` from fossilising at release time - # (objectui#5394). `scripts/__tests__/sync-quick-reference-release.test.ts` - # reads this very line and fails if it changes. - # - # ⛔ `@v1` is pinned deliberately here too — see "THE `@v1` PIN IS - # LOAD-BEARING" above the publish step before changing it. - - name: Refresh the version PR - id: changesets-version - if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' - uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 - with: - version: pnpm changeset:version - title: 'chore: release packages' - commit: 'chore: release packages' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - # ── What the cache replayed, and only what it used is kept (objectui#12276) ── - # The `type-check` job's two bookkeeping steps (objectui#12222), for this - # job's cache. `Build packages` above sets `TURBO_RUN_SUMMARY`, so turbo - # wrote its run summary to `.turbo/runs/`; the report reads it and puts - # what the restored cache replayed, what re-ran and the restored key on - # ONE `::notice` annotation, readable through the check-runs API by - # anyone who cannot open this log. The prune then deletes every entry - # that build did not use, so the entry saved below is exactly the - # current build outputs. Every run of this job is a `main` run, so both - # run on every run, after every release step: `!cancelled()` on the - # report so a failed release still reports, the prune on success like - # the save it prepares. - # - # Bounded and `continue-on-error`, like the save: neither can redden a - # job that has already published. `timeout-minutes: 1` each is DERIVED - # FOR THIS SITE: the script reads one run summary and lists one - # directory, and measured 87 ms on a lint run's summary - # (objectui#12276); a minute is several hundred times that. - - name: Report what the turbo cache replayed - if: ${{ !cancelled() }} - timeout-minutes: 1 - continue-on-error: true - env: - RESTORED_CACHE_KEY: ${{ steps.turbo-cache.outputs.cache-matched-key }} - run: node scripts/report-turbo-cache.mjs - - - name: Prune the turbo cache to what this run used - timeout-minutes: 1 - continue-on-error: true - run: node scripts/report-turbo-cache.mjs --prune - - # The bookkeeping half of the split documented at the restore step above, - # placed HERE — last in the job, after publish, after the post-version - # tree validation and after the refresh — because that is exactly where - # the post phase it replaces already ran. Nothing this job publishes, - # validates or restores moves with it, and no step above it is touched. - # - # `timeout-minutes: 5` is DERIVED, and the derivation is weaker here than - # at the other six sites — said plainly rather than dressed up. - # objectui#7048 fences inheriting PR #7047's 5, so: this cache is - # `.turbo/cache`, under the same `turbo-${{ runner.os }}-${{ github.sha }}` - # key as `lint.yml` and `performance-budget.yml` when this was derived - # (each job has its own key prefix since objectui#12276), and the saves - # of that cache measured 1s, 2s, 5s and 6s across four jobs on 2026-09-02 - # (`Bundle Analysis` 100096569559, `Type Check` 100096569872, - # `Build Docs` 100094597253, `Lint` 100097466846). A same-SITE save was - # not observable in the sampled runs: this job runs on `push` to `main` - # only when the version is not yet on npm, and on the 6-hourly `schedule` - # the primary key was an exact hit -- another job's save under the shared - # key -- so `Post Turbo Cache` measured 0s on - # the sampled scheduled run (job 99021319583, 2026-08-29) with its - # restore at 4s. 5 minutes is 50x the slowest of the four same-cache - # saves; if a directly measured save of this site ever lands and lies - # outside that range, re-derive rather than defend this number. - # - # `continue-on-error: true` is the other half. A cache that failed to - # upload costs the next run some build time; it says nothing about - # whether the release succeeded, so it must not be able to redden a job - # that has already published. - # - # Behaviour preserved, spelled out so the equivalence is checkable: - # - `cache-hit != 'true'` reproduces the combined action's own "exact - # hit on the primary key ⇒ do not save" skip — which is precisely the - # path the scheduled run above took. - # - the condition names no status function, so the implicit `success()` - # still applies — matching the combined action's `post-if: success()`. - # - same `path` and same `key` as the restore step above. - - name: Save Turbo Cache - if: steps.turbo-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v6 - timeout-minutes: 5 - continue-on-error: true - with: - path: .turbo/cache - key: turbo-${{ runner.os }}-release-${{ github.sha }} - - # The pinned @objectstack build, saved only when this job built it (a cache - # miss) and whatever the verdict above was -- it does not depend on it. - # Bookkeeping: bounded and non-fatal (objectui#6577, objectui#7048). The bound - # is not yet derived from a measured save; re-derive it from this step's runs. - - name: Save the pinned @objectstack build - if: ${{ !cancelled() && steps.objectstack-build.outcome == 'success' && steps.objectstack-pin.outputs.cache-hit != 'true' }} - continue-on-error: true - timeout-minutes: 2 - uses: actions/cache/save@v6 - with: - path: node_modules/.cache/objectstack - key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }} diff --git a/ui/.github/workflows/spec-range-floors.yml b/ui/.github/workflows/spec-range-floors.yml deleted file mode 100644 index 17e230cc658..00000000000 --- a/ui/.github/workflows/spec-range-floors.yml +++ /dev/null @@ -1,148 +0,0 @@ -name: Spec Range Floors - -# objectui#5793: a package's declared `@objectstack/spec` floor must carry every -# symbol that package's own published artifact references. The gate, and the two -# ways of building it that return a confident green, are documented at length in -# scripts/check-spec-range-floors.mjs. -# -# ── Why this is scheduled and NOT a pull-request job ───────────────────────── -# -# The criterion is over the BUILT artifact, because `src/` answers a different -# question: an `import type` used only inside a function body is erased and -# never reaches `dist/`, so a source-level gate demands floor bumps nothing -# published justifies. That makes this the second artifact-level gate in the -# repository, and the 2026-08-16 ruling on objectui#4846 (comment 5307574139) -# already settled where those go: `published-dist-gate.yml` carries the long -# version of the argument, and the short version is that this repository -# deliberately has no per-PR full-repo build. `ci.yml`'s `Build & E2E` builds -# `@object-ui/console` alone and its `Type Check` gets only turbo's dependency -# closure, so leaf packages are never built there. -# -# ⛔ Do not add `pull_request` here. `scripts/__tests__/check-spec-range-floors.test.ts` -# fails if this gate is ever wired into a workflow carrying that trigger — the -# same guard `check-published-dist-tooling.test.ts` holds over its own gate. -# -# The BLOCKING copy is on the publish path: `pnpm changeset:publish` runs this -# gate before a single tarball reaches npm, so a floor that lies cancels the -# release. This workflow is an ALARM in addition, so a regression surfaces the -# night it lands rather than at the next release. It cannot make anyone's pull -# request red. -# -# ── Why it needs the network ───────────────────────────────────────────────── -# -# The gate fetches each declared FLOOR version from the registry and reads that -# tarball's own `exports` map. It deliberately resolves nothing through -# `node_modules`: the root `package.json` declares `@objectstack/spec`, so pnpm -# hoists it to the workspace root and every resolution succeeds from every -# package regardless of that package's own manifest — the trap -# `check-phantom-dependencies.mjs` records for `react`, in a place where it -# would produce a green verdict about a version nobody installed. - -on: - workflow_dispatch: - - # Nightly at 04:11 UTC — off the top of the hour, where GitHub's scheduled - # queue is most congested, and off `published-dist-gate.yml`'s 03:41 so the - # two full-repo builds do not contend for the same runner minute. - schedule: - - cron: '11 4 * * *' - - # Changes to the gate or its wiring get a real run. Its verdict logic is - # exercised per-PR by the ordinary vitest suite over synthetic inputs, which - # cannot exercise a real build or a real registry fetch. - push: - branches: [main] - paths: - - 'scripts/check-spec-range-floors.mjs' - - 'scripts/objectstack-floor.mjs' - - '.github/workflows/spec-range-floors.yml' - -concurrency: - group: spec-range-floors-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - spec-range-floors: - name: Spec Range Floor Scan - runs-on: ubuntu-latest - timeout-minutes: 45 - - steps: - - name: Checkout code - uses: actions/checkout@v7 - with: - submodules: true - - - name: Enable Corepack and download the pinned pnpm - run: bash scripts/ci-setup-pnpm.sh - - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: '22.x' - cache: 'pnpm' - - # objectui#12957: every @objectstack/* package resolves to the build of the - # commit `.objectstack-sha` pins, read from node_modules/.cache/objectstack, so - # it must be there before pnpm resolves anything. Restored by the pin and built - # only on a miss (scripts/objectstack-pin.mjs); the save is this job's last step. - - name: Restore the pinned @objectstack build - id: objectstack-pin - uses: actions/cache/restore@v6 - with: - path: node_modules/.cache/objectstack - key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }} - - - name: Build @objectstack at the pin (skips a restored build) - id: objectstack-build - run: node scripts/objectstack-pin.mjs build - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - # The gate READS artifacts and refuses to guess when they are absent (a - # package that declares the spec and produced no build output is a - # `no-artifact` finding, not a skip). Building here rather than inside the - # gate keeps the build visible in the log as its own step, and keeps the - # gate a pure reader that the unit tests can drive with synthetic inputs. - # - # `--filter=!@object-ui/site` matches `pnpm build`: the docs site is not a - # published package and building it here buys nothing. - - name: Build every published package - run: pnpm exec turbo run build --filter='!@object-ui/site' - - # objectui#12958: the scan judges the floors a publish ships, so the floor - # step first computes them from the pin, as `pnpm changeset:publish` does. - # Exit 2 = no published objectstack release contains the pin yet, the normal - # state between a pin bump and objectstack's next release: nothing to judge. - - name: Compute the @objectstack floors from the pin - id: floor - run: | - code=0 - node scripts/objectstack-floor.mjs || code=$? - if [ "${code}" -eq 2 ]; then - echo "::notice::No published @objectstack release contains .objectstack-sha yet, so the scan is skipped." - echo "skip=true" >> "$GITHUB_OUTPUT" - elif [ "${code}" -ne 0 ]; then - exit "${code}" - fi - - - name: Verify every declared spec floor carries the symbols its artifact uses - if: steps.floor.outputs.skip != 'true' - run: pnpm check:spec-floors -- --cross-check - - # The pinned @objectstack build, saved only when this job built it (a cache - # miss) and whatever the verdict above was -- it does not depend on it. - # Bookkeeping: bounded and non-fatal (objectui#6577, objectui#7048). The bound - # is not yet derived from a measured save; re-derive it from this step's runs. - - name: Save the pinned @objectstack build - if: ${{ !cancelled() && steps.objectstack-build.outcome == 'success' && steps.objectstack-pin.outputs.cache-hit != 'true' }} - continue-on-error: true - timeout-minutes: 2 - uses: actions/cache/save@v6 - with: - path: node_modules/.cache/objectstack - key: objectstack-pin-${{ runner.os }}-${{ hashFiles('.objectstack-sha', 'scripts/objectstack-pin.mjs') }} diff --git a/ui/.gitignore b/ui/.gitignore index fbb270c19de..3298ccde0c9 100644 --- a/ui/.gitignore +++ b/ui/.gitignore @@ -105,11 +105,6 @@ playwright-report .objectstack -# objectui#13008: `.objectstack-pin/NAME/` tracks one file, the packed package.json -# of the pinned @objectstack/* build; `pnpm objectstack:build` links the rest beside it. -/.objectstack-pin/*/* -!/.objectstack-pin/*/package.json - .playwright-mcp # Live e2e auth state (contains a session token) + artifacts e2e/live/.auth/ diff --git a/ui/.objectstack-sha b/ui/.objectstack-sha deleted file mode 100644 index 4f7b8b61f95..00000000000 --- a/ui/.objectstack-sha +++ /dev/null @@ -1 +0,0 @@ -5e7cf63e71d92198a77cc88ed22f34afb1e86cb6 diff --git a/ui/CONTRIBUTING.md b/ui/CONTRIBUTING.md index b4f5092b75b..7f96e932cda 100644 --- a/ui/CONTRIBUTING.md +++ b/ui/CONTRIBUTING.md @@ -625,11 +625,11 @@ The release process is automated: **The release PR is identified by its head branch, `changeset-release/main`, never by its title.** The [Changesets action](https://github.com/changesets/action) derives that branch from the base branch, so it is a stable convention that holds -across repositories. The title is not: it is the `title:` input this repository -passes to the action in -[`.github/workflows/changeset-release.yml`](.github/workflows/changeset-release.yml), -it is repository-local configuration, and it differs from repository to -repository. To locate the current release PR — its number and its title included +across repositories. The title is not: it is the `title:` input the objectstack +monorepo's `.github/workflows/release.yml` passes to the action (objectui's own +`changeset-release.yml` retired when the two scopes joined one release, +objectstack#25711), it is repository-local configuration, and it differs from +repository to repository. To locate the current release PR — its number and its title included — search the head branch: ```text diff --git a/ui/QUICK_REFERENCE.md b/ui/QUICK_REFERENCE.md index 40c40f03b27..e0a452d165d 100644 --- a/ui/QUICK_REFERENCE.md +++ b/ui/QUICK_REFERENCE.md @@ -121,28 +121,13 @@ pnpm changeset publish # Publish to npm (CI only) ## Current Release -Every value below is pinned to the manifest that owns it by -`scripts/__tests__/quick-reference-current-release-4143.test.ts` — edit the anchor and -that test tells you to edit this block. The one exception is called out on its row. - -You rarely have to edit it by hand. `pnpm quick-reference:sync` rewrites these rows from -the manifests, and `pnpm quick-reference:check` reports drift without writing. The -release path runs the sync itself: `changeset:version` bumps every manifest and updates -this block in the same commit, so a release can no longer leave the block a version -behind (objectui#5394 — that had happened once per release, three times). - -- **Version:** 18.1.0 (the version every `@object-ui/*` manifest carries — they are one - `fixed` group in `.changeset/config.json`, so a release moves all of them together) -- **Spec:** `@objectstack/spec` ^18.0.0-next.1 (declared by the root `package.json` and by - `apps/console/package.json`) -- **Client:** `@objectstack/client` ^18.0.0-next.1 (declared by `apps/console/package.json` - and `packages/data-objectstack/package.json`) -- **Node.js:** ≥ 22.13 (see root `engines.node`) -- **pnpm:** ≥ 10 (the workspace pins `pnpm@10.31.0` via `packageManager`) -- **React:** 19.x (the `peerDependencies.react` range the packages declare) -- **TypeScript:** ≥ 5.0 (strict mode) — the stack floor stated in AGENTS.md §2, not a - manifest fact: nothing in this tree declares a `typescript` range to check it against, - so this is the one row above that no test can hold to account. - -> Pending unreleased work is queued in `.changeset/` — list that directory to see what is -> staged for the next release. +The release version is the monorepo's. Every `@object-ui/*` package is in one Changesets +`fixed` group with every `@objectstack/*` package (`../.changeset/config.json`), so a +release moves all of them together and any package's `package.json` names the current +version. The toolchain floors are this tree's `package.json`: `engines` and +`packageManager`. This block deliberately holds no version literal: the rows that did, +and the script that rewrote them at `changeset:version`, were retired when objectui's own +release lane left (objectstack#25711, decision 17c). + +> Pending unreleased work is queued in the monorepo's `../.changeset/` — list that +> directory to see what is staged for the next release. diff --git a/ui/apps/console/vite.config.ts b/ui/apps/console/vite.config.ts index 6800efa8973..de0b876d06b 100644 --- a/ui/apps/console/vite.config.ts +++ b/ui/apps/console/vite.config.ts @@ -17,12 +17,7 @@ import { execFileSync } from 'node:child_process'; // `native` becomes the default loader (objectui#3384). import { viteCryptoStub } from '../../scripts/vite-crypto-stub.ts'; import { viteMaplibreWorker } from '../../scripts/vite-maplibre-worker.ts'; -import { resolveClientDistInjection, widenVendorChunkTestForClient } from '../../scripts/vite-objectstack-client-dist.ts'; -import { - assertSingleZodInstance, - formatConditionReport, - resolveSpecDistInjection, -} from '../../scripts/vite-objectstack-spec-dist.ts'; +import { assertSingleZodInstance } from '../../scripts/vite-single-zod.ts'; import { viteIneffectiveDynamicImports } from '../../scripts/vite-ineffective-dynamic-imports.ts'; import { frameworkPackagesDir, workspaceChunkTests } from '../../scripts/vite-objectstack-workspace.ts'; import { viteDeclaredLazyViews } from '../../scripts/vite-declared-lazy-views.ts'; @@ -85,26 +80,20 @@ function preloadCriticalChunks(): Plugin { * closure walk has proven it can see the very chunk the linter used to hide in. * * That makes both module-id tests SUBJECTS of the check, not decoration, and - * neither may be hardcoded here. `OBJECTSTACK_SPEC_DIST` rewrites all 18 - * `@objectstack/spec` specifiers to absolute paths in the overriding tree — - * `/…/objectstack/packages/spec/dist/…/index.mjs`, ids with no - * `@objectstack` segment at all — so a private `/@objectstack[\\/+]spec/` - * matched zero modules, this counter-probe correctly refused a verdict, and it - * took every injected build down with it (objectui#5388; measured from the - * consumer side in objectstack#10136, where it blocked the framework's Console - * Pin Gate). The spec test is therefore a PARAMETER, fed from the same - * `resolveSpecDistInjection` output the `vendor-objectstack` group already - * reads: one producer, both consumers, no second opinion about where the spec - * lives. + * neither may be hardcoded here. A spec reached through a path with no + * `@objectstack` segment — the monorepo's own `packages/spec/dist/…` — made a + * private `/@objectstack[\\/+]spec/` match zero modules, so this counter-probe + * correctly refused a verdict and took the build down with it (objectui#5388). + * The spec test is therefore a PARAMETER, fed from the same + * `workspaceChunkTests` producer the `vendor-objectstack` group reads: one + * producer, both consumers, no second opinion about where the spec lives. * - * `LINT` stays literal — nothing injects `@objectstack/lint` today — but it gets - * its own counter-probe for the same reason, because its failure mode is the - * worse one. The spec test going blind fails LOUD (this build stops). The lint - * test going blind fails SILENT: the assertion below is negative, so zero - * matches reads exactly like a clean bundle and the guard is green forever. - * objectstack#9659 proposes injecting four more `@objectstack/*` packages the - * same way and lint is a plausible member; if it ever joins, this build says so - * instead of quietly ceasing to guard anything. + * `LINT` gets its own counter-probe for the same reason, because its failure + * mode is the worse one. The spec test going blind fails LOUD (this build + * stops). The lint test going blind fails SILENT: the assertion below is + * negative, so zero matches reads exactly like a clean bundle and the guard is + * green forever; if the linter's module ids ever change shape, this build says + * so instead of quietly ceasing to guard anything. */ function assertLazyLinterStaysLazy(specTest: RegExp): Plugin { const LINT = WORKSPACE_CHUNK_TESTS.lint; @@ -144,9 +133,6 @@ function assertLazyLinterStaysLazy(specTest: RegExp): Plugin { `from the app entry, so it must be in the eager closure — its absence means ` + `this walk is reading the graph wrongly, not that the bundle improved. ` + `Fix the walk before trusting the linter assertion below. ` + - `If \`OBJECTSTACK_SPEC_DIST\` is set, check that this test carries the ` + - `override's location: an injected spec resolves outside node_modules and the ` + - `baseline test cannot see it (objectui#5388). ` + `(spec test: ${specTest}; ` + `entry chunks: ${[...chunks.values()].filter((c) => c.isEntry).map((c) => c.fileName).join(', ') || 'NONE'}; ` + `eager chunks: ${eager.size}/${chunks.size})`, @@ -168,10 +154,8 @@ function assertLazyLinterStaysLazy(specTest: RegExp): Plugin { `this bundle and the assertion below can no longer fail. Two ways to get here. ` + `Either the lazy import in app-shell's \`capabilityLint.ts\` is gone, in which case ` + `retire this plugin deliberately rather than leaving a guard with no subject; or ` + - `the linter's module ids changed shape — e.g. \`@objectstack/lint\` joined the ` + - `\`OBJECTSTACK_SPEC_DIST\`-style injection proposed in objectstack#9659, which ` + - `rewrites specifiers to absolute paths with no \`@objectstack\` segment. In that ` + - `case give this test the injection's location, exactly as the spec test above is ` + + `the linter's module ids changed shape (a path with no \`@objectstack\` segment). ` + + `In that case give this test the new location, exactly as the spec test above is ` + `already parameterised (objectui#5388). (chunks: ${chunks.size})`, ); } @@ -327,10 +311,8 @@ function emitEagerClosureReport(reportFileName = 'eager-closure.json'): Plugin { `Fix the import rather than skipping the member here: a bare specifier the browser ` + `cannot load is a broken bundle, not a measurement gap, and dropping it from the ` + `walk would make this report under-count — the one direction the counter-probes ` + - `above exist to refuse. The known source is an out-of-tree override whose own ` + - `dependencies do not resolve from where it lives: check \`OBJECTSTACK_CLIENT_DIST\` ` + - `and \`OBJECTSTACK_SPEC_DIST\`, and vite's own "could not be resolved" warnings ` + - `earlier in this build.`, + `above exist to refuse. Read vite's own "could not be resolved" warnings earlier ` + + `in this build.`, ); } @@ -671,31 +653,6 @@ const workspaceAliases: Record = { '@object-ui/plugin-designer': path.resolve(import.meta.dirname, '../../packages/plugin-designer/src'), }; -// Opt-in override of the installed `@objectstack/client`. The published client -// (11.2.0) predates the async import-job API (`data.createImportJob` et al.), -// so to exercise the full background-import + undo flow through the real -// console before that client ships, point OBJECTSTACK_CLIENT_DIST at a locally -// built client (its dist entry or package dir). Inert when unset — production -// and CI builds use the installed client unchanged. -// -// No longer a bare string alias: the value is VALIDATED before it is aliased — -// the path must exist, it must sit inside a `@objectstack/client` package -// (directory, `dist/`, or an entry file all resolve to the same package), and -// that package's own declared `dependencies` must resolve from where it lives. -// Without that last check an out-of-tree override produced a fully written -// bundle whose bare specifiers no browser can load, and nothing in the build -// output named this variable — objectui#6094, the client twin of the spec -// hook's objectui#5391. See the module for the measurement and for why the -// check is re-stated there rather than shared with the spec hook. -const clientDistInjection = resolveClientDistInjection(process.env.OBJECTSTACK_CLIENT_DIST); -if (clientDistInjection) workspaceAliases['@objectstack/client'] = clientDistInjection.aliasTarget; - -// Extra dirs the dev server may read the override from — it lives outside the -// workspace root, so Vite's default `server.fs.allow` would 403 it (blank page). -// Unchanged values: the containing package (…/dist/index.mjs → …/) and its -// parent, now computed beside the validation that vouches for the path. -const clientFsAllow: string[] = clientDistInjection ? clientDistInjection.fsAllow : []; - // Deps pre-bundled for the dev server. Build-time pre-bundling was removed in // Vite 5.1, so this list is read by `pnpm dev` only, never by `vite build` — // which is why no build or E2E job can see a bad entry here. @@ -707,7 +664,7 @@ const clientFsAllow: string[] = clientDistInjection ? clientDistInjection.fsAllo // package this app does not depend on is skipped with a warning and pre-bundles // nothing: `maplibre-gl` was one, and needs no entry of its own, because the // `react-map-gl/maplibre` pre-bundle already carries it. The console-config -// cases in `scripts/__tests__/vite-objectstack-spec-dist.test.ts` resolve every +// case in `scripts/__tests__/console-optimize-deps.test.ts` resolves every // entry the way the dev server does. const OPTIMIZE_DEPS_INCLUDE = [ '@objectstack/spec', @@ -763,81 +720,6 @@ const VENDOR_OBJECTSTACK_TEST = WORKSPACE_CHUNK_TESTS.vendor; // plus the monorepo's workspace path (see `WORKSPACE_CHUNK_TESTS` above). const SPEC_MODULE_TEST = WORKSPACE_CHUNK_TESTS.spec; -// Opt-in override of the installed `@objectstack/spec` — the spec twin of -// OBJECTSTACK_CLIENT_DIST above, so a framework build can bundle the console -// against its OWN spec instead of the last published one (objectui#4854, ruled -// on objectstack#8134). Point it at a built spec package (its directory, its -// `dist/`, or an entry file inside it). -// -// It is NOT a copy of the client line: `@objectstack/spec` publishes an 18-entry -// exports map that redirects every subpath into `dist/`, and a Vite string alias -// does not consult exports maps. So the injection is derived from the OVERRIDE's -// own exports map, one alias per entry — see the module for the derivation and -// for why every failure mode throws instead of falling back. -// -// Inert when unset: `null` here leaves the alias table, the pre-bundle list, the -// vendor chunk test and the dev server's fs allow-list at their baseline values. -// -// The injected spec also arrives with its OWN install tree, so left alone its -// bare `zod` import resolves to the framework's zod and the bundle carries two -// instances — at the console pin objectui#11327 was reported against, the -// Studio's spec-derived forms crashed in `z.toJSONSchema` over them. The -// injection therefore pins every `zod` import to ONE -// copy: the one `CONSOLE_ZOD_ANCHOR` resolves. That package is the anchor -// because it is where the console walks spec schemas with its own zod (the -// metadata-admin `*-schema.ts` modules) and it declares `zod` itself; the -// injection refuses the build when that copy is outside the range the -// injected spec declares. -const CONSOLE_ZOD_ANCHOR = path.resolve(import.meta.dirname, '../../packages/app-shell'); -const specDistInjection = resolveSpecDistInjection(process.env.OBJECTSTACK_SPEC_DIST, { - vendorChunkTest: VENDOR_OBJECTSTACK_TEST, - specModuleTest: SPEC_MODULE_TEST, - consoleZodFrom: CONSOLE_ZOD_ANCHOR, -}); -if (specDistInjection) Object.assign(workspaceAliases, specDistInjection.aliases); - -// objectui#9408 — say which condition arm every entry came from, out loud. -// -// The resolver used to walk its OWN preference array instead of the exports -// map's key order, so the five `browser`-first entries silently resolved to the -// Node arm and the arm added to keep a browser build off `require('fs')` was -// unreachable. Nothing in the build said so. The fix makes the pick correct; the -// table makes it CHECKABLE — a wrong arm is now one grep in the build log -// instead of an unattributable bundler error at some later pin bump. -// -// Printed only when the override is live, so a normal build stays quiet, and to -// stderr so it cannot land in anything that parses stdout. -if (specDistInjection) { - for (const line of formatConditionReport(specDistInjection)) console.error(line); -} - -const specFsAllow: string[] = specDistInjection ? specDistInjection.fsAllow : []; - -// Pre-bundling an ALIASED, out-of-workspace dep is opt-in through this list and -// nothing else: Vite's pre-alias plugin only registers such a resolution as a -// dep when `optimizeDeps.include` names the specifier. Keeping the four spec -// entries while the override is live would therefore park the injected spec in -// `node_modules/.vite`, whose cache key does not move when the framework -// rebuilds its spec in place — a stale pre-bundle serving yesterday's schema is -// the same silent skew this hook exists to end. Dropping them costs a colder -// dev start and nothing else; `vite build` never reads this list. -const optimizeDepsInclude = specDistInjection - ? OPTIMIZE_DEPS_INCLUDE.filter((specifier) => !Object.hasOwn(specDistInjection.aliases, specifier)) - : OPTIMIZE_DEPS_INCLUDE; - -// An injected spec resolves OUTSIDE node_modules, so the baseline test above -// stops matching it and the biggest vendor surface in the bundle (spec is -// imported by 29 packages here) would scatter into its importers' chunks. The -// injected build should differ from a released one in spec CONTENT, not in -// chunk layout, so the override's location joins the group's test. The same -// holds for an injected CLIENT, and there the layout is not cosmetic: left out -// of this group it lands in `framework`, closing a framework ↔ data-adapter -// import cycle that kills the console at boot (see the helper's docblock). -const vendorObjectstackTest = widenVendorChunkTestForClient( - specDistInjection ? specDistInjection.vendorChunkTest : VENDOR_OBJECTSTACK_TEST, - clientDistInjection, -); - // A lucide icon module, captured by its file name (`circle-arrow-up`). Read by // the `vendor-icons-shell` group below. const LUCIDE_ICON_MODULE = /[\\/]node_modules[\\/]lucide-react[\\/]dist[\\/]esm[\\/]icons[\\/]([a-z0-9-]+)\.mjs$/; @@ -851,16 +733,6 @@ const LAZY_ONLY_INITIAL_ICONS: ReadonlySet = new Set([ 'webhook', ]); -// The chunk grouping is not the only consumer of "where does the spec live". -// `assertLazyLinterStaysLazy`'s counter-probe asks the same question about the -// emitted module ids, and it used to answer it from a private regex that the -// injection made unmatchable — so every build with the override set died on a -// counter-probe that was right about what it saw (objectui#5388). It reads the -// override's location here, from the same producer, for the same reason. -const specModuleTest = specDistInjection - ? specDistInjection.specModuleTest - : SPEC_MODULE_TEST; - // https://vitejs.dev/config/ export default defineConfig({ base: basePath, @@ -879,15 +751,11 @@ export default defineConfig({ // Fails the build if the lazily-imported linter is folded back into an // eagerly-loaded chunk. Runs on CI/Vercel too — it costs microseconds and // the regression it catches is invisible in every other signal. - assertLazyLinterStaysLazy(specModuleTest), - // Under OBJECTSTACK_SPEC_DIST: resolve every bare `zod` import — the injected - // spec's included — to the console's one copy (objectui#11327). Registered - // only while the override is live, like every other injection surface. - ...(specDistInjection ? [specDistInjection.singleZodPlugin] : []), - // …and in EVERY build, injected or not: fail when the emitted chunks carry - // more than one zod instance, or none at all (the counter-probe). Measures - // the outcome rather than trusting the redirect above, because the first - // mechanism tried for this (`resolve.dedupe`, see below) was a silent no-op. + assertLazyLinterStaysLazy(SPEC_MODULE_TEST), + // In EVERY build: fail when the emitted chunks carry more than one zod + // instance, or none at all (the counter-probe), objectui#11327. Measures the + // outcome rather than trusting a mechanism, because the first mechanism + // tried for this (`resolve.dedupe`, see below) was a silent no-op. assertSingleZodInstance(), // The same refusal one directory over: fails the build if the // `@object-ui/types/zod` validators rejoin the eager closure, AND if they @@ -977,14 +845,13 @@ export default defineConfig({ // ⛔ `zod` is deliberately NOT listed. Dedupe resolves a listed package from // the Vite root, and this root (`apps/console`) declares no `zod`: the // lookup finds nothing and Vite falls back to each importer's own copy - // without a warning. Measured on objectui#11327 with OBJECTSTACK_SPEC_DIST - // set — adding `zod` here emitted the same two zod copies, byte-identical. - // The one-zod rule is held by `specDistInjection.singleZodPlugin` and - // checked by `assertSingleZodInstance` in the plugin list above. + // without a warning. Measured on objectui#11327 — adding `zod` here emitted + // the same two zod copies, byte-identical. The one-zod rule is checked by + // `assertSingleZodInstance` in the plugin list above. dedupe: ['react', 'react-dom', 'sonner'], }, optimizeDeps: { - include: optimizeDepsInclude + include: OPTIMIZE_DEPS_INCLUDE }, build: { target: 'esnext', @@ -1082,7 +949,7 @@ export default defineConfig({ // diagnostic. Dropping the tag puts the four modules back on every // page load, and the lowered ceiling in // `scripts/check-eager-closure-budget.mjs` is what reds on it. - { name: 'vendor-objectstack', test: vendorObjectstackTest, priority: 95, tags: ['$initial'] }, + { name: 'vendor-objectstack', test: VENDOR_OBJECTSTACK_TEST, priority: 95, tags: ['$initial'] }, { name: 'vendor-icons-core', test: /[\\/]node_modules[\\/]lucide-react[\\/]dist[\\/](lucide-react|esm[\\/](Icon|createLucideIcon|defaultAttributes|shared))/, priority: 90 }, // // ## The icons the eager shell imports by name share ONE chunk (objectui#12187) @@ -1615,13 +1482,6 @@ export default defineConfig({ }, server: { port: 5180, - // Widen the fs allow-list only when an out-of-tree override is set (see - // OBJECTSTACK_CLIENT_DIST / OBJECTSTACK_SPEC_DIST above); otherwise keep - // Vite's defaults. Both overrides live outside the workspace root, which - // Vite's default `fs.allow` serves as a 403 (blank page, no build error). - ...(clientFsAllow.length || specFsAllow.length - ? { fs: { allow: [path.resolve(import.meta.dirname, '../..'), ...clientFsAllow, ...specFsAllow] } } - : {}), proxy: { '/api': { target: process.env.DEV_PROXY_TARGET || 'http://localhost:3000', changeOrigin: true }, }, diff --git a/ui/eslint.config.js b/ui/eslint.config.js index 8861e1cb5f7..b8bea1bbed4 100644 --- a/ui/eslint.config.js +++ b/ui/eslint.config.js @@ -54,12 +54,6 @@ export default tseslint.config({ 'apps/console/plugin.js', 'apps/console/plugin.d.ts', 'apps/site/next-env.d.ts', - // objectui#13008 — the pinned @objectstack/* build, linked by - // `scripts/objectstack-pin.mjs build` beside the committed manifests and - // git-ignored (`.gitignore`, "/.objectstack-pin/*/*"); the spec packs its - // TypeScript `src/`. The one tracked file per package is a `package.json`, - // which no rule here lints. - '.objectstack-pin', ], }, { // objectui#4853 — a stale `eslint-disable` is an ERROR, not a warning. diff --git a/ui/package.json b/ui/package.json index 175b26cec49..206648cfd0c 100644 --- a/ui/package.json +++ b/ui/package.json @@ -16,9 +16,7 @@ "docs" ], "scripts": { - "setup": "node scripts/objectstack-pin.mjs build && pnpm install --frozen-lockfile", - "objectstack:build": "node scripts/objectstack-pin.mjs build", - "objectstack:status": "node scripts/objectstack-pin.mjs status", + "setup": "pnpm install --frozen-lockfile", "dev": "pnpm --filter @object-ui/console dev", "start": "pnpm --filter @object-ui/console preview", "test": "vitest run", @@ -60,8 +58,6 @@ "check:action-ref-convention": "node scripts/check-action-ref-convention.mjs", "check:published-dist": "node scripts/check-published-dist-tooling.mjs", "check:published-tsconfig-exclude": "node scripts/check-published-tsconfig-tooling-exclude.mjs", - "check:spec-floors": "node scripts/check-spec-range-floors.mjs", - "check:installed-pin-claims": "node scripts/check-installed-spec-pin-claims.mjs", "check:i18n-keys": "node scripts/check-i18n-call-site-keys.mjs", "check:i18n-drift": "node scripts/check-i18n-en-drift.mjs", "check:i18n-dead-keys": "node scripts/check-i18n-dead-keys.mjs", @@ -126,10 +122,8 @@ "shadcn:list": "node scripts/shadcn-sync.js --list", "changeset:check": "node scripts/check-changeset-fixed.mjs && node scripts/check-changeset-no-major.mjs", "changeset": "changeset", - "changeset:version": "changeset version && node scripts/sync-quick-reference-release.mjs", - "quick-reference:sync": "node scripts/sync-quick-reference-release.mjs", - "quick-reference:check": "node scripts/sync-quick-reference-release.mjs --check", - "changeset:publish": "node scripts/objectstack-floor.mjs && node scripts/check-published-dist-tooling.mjs && node scripts/check-spec-range-floors.mjs && changeset publish", + "changeset:version": "changeset version", + "changeset:publish": "node scripts/check-published-dist-tooling.mjs && changeset publish", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "test:e2e:live": "playwright test --config=playwright.live.config.ts", diff --git a/ui/scripts/__tests__/bump-objectstack.test.ts b/ui/scripts/__tests__/bump-objectstack.test.ts deleted file mode 100644 index 8afa6574992..00000000000 --- a/ui/scripts/__tests__/bump-objectstack.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -/** - * objectui#12957 — `scripts/bump-objectstack.sh`, driven as shipped over throwaway - * git repositories: the refusal and warning contract it copies from objectstack's - * `scripts/bump-objectui.sh`. - * - * - a commit that cannot be resolved or read REFUSES before anything is written: - * `.objectstack-sha` stays byte-identical (the failed-run invariant); - * - a readable commit on `origin/main` is pinned with no warning; - * - a readable commit NOT on `origin/main` is pinned WITH a warning naming the - * branch it is on — a warning, never a refusal; - * - a checkout with no `origin/main` says the question is unanswered rather than - * answering it either way; - * - re-pinning the same commit writes nothing; - * - the new pin is BUILT with `--write-manifests` (objectui#13008) and its packed - * manifests are committed with it; a build that fails restores the old pin. - * The build itself is objectstack-pin.test.ts's subject: here a stub stands in - * for `scripts/objectstack-pin.mjs` and records how it was called. - * - * The cases that pin a commit are the firing controls of the refusal cases: a - * guard that refused everything would fail them. - */ -import { afterAll, describe, expect, it } from 'vitest'; -import { spawnSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const SCRIPT = path.join(repoRoot, 'scripts/bump-objectstack.sh'); -const OLD_PIN = 'a'.repeat(40); - -// Anchored on the OS temp directory at module scope, so no path below can be -// read as relative to the process cwd (`pnpm check:test-path-roots`). -const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'bump-objectstack-')); - -function git(cwd: string, ...args: string[]): string { - const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: GIT_ENV }); - if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed in ${cwd}: ${r.stderr}`); - return r.stdout.trim(); -} - -const GIT_ENV = { - ...process.env, - GIT_AUTHOR_NAME: 'test', - GIT_AUTHOR_EMAIL: 'test@example.invalid', - GIT_COMMITTER_NAME: 'test', - GIT_COMMITTER_EMAIL: 'test@example.invalid', - GIT_CONFIG_GLOBAL: '/dev/null', - GIT_CONFIG_NOSYSTEM: '1', -}; - -/** - * The pin script's stand-in: records its argv and whether OBJECTSTACK_ROOT reached - * it, writes a manifest naming the pin it read, and fails when STUB_FAIL is set. - */ -const PIN_STUB = `import fs from 'node:fs'; -fs.writeFileSync('build.called', JSON.stringify({ argv: process.argv.slice(2), standIn: 'OBJECTSTACK_ROOT' in process.env })); -if (process.env.STUB_FAIL) process.exit(1); -fs.writeFileSync('.objectstack-pin/spec/package.json', JSON.stringify({ name: '@objectstack/spec', pin: fs.readFileSync('.objectstack-sha', 'utf8').trim() }) + '\\n'); -`; - -/** A fresh objectui-shaped directory holding the REAL script, a stub pin script, an old pin and its manifest. */ -function makeObjectui(name: string): string { - const dir = path.join(scratch, name, 'objectui'); - fs.mkdirSync(path.join(dir, 'scripts'), { recursive: true }); - fs.copyFileSync(SCRIPT, path.join(dir, 'scripts/bump-objectstack.sh')); - fs.writeFileSync(path.join(dir, 'scripts/objectstack-pin.mjs'), PIN_STUB); - fs.writeFileSync(path.join(dir, '.gitignore'), 'build.called\n'); - fs.writeFileSync(path.join(dir, '.objectstack-sha'), `${OLD_PIN}\n`); - fs.mkdirSync(path.join(dir, '.objectstack-pin/spec'), { recursive: true }); - fs.writeFileSync(path.join(dir, '.objectstack-pin/spec/package.json'), '{"name":"@objectstack/spec","pin":"old"}\n'); - git(dir, 'init', '-q', '-b', 'main'); - git(dir, 'add', '.'); - git(dir, 'commit', '-q', '-m', 'init'); - return dir; -} - -/** An objectstack checkout with `origin/main`, plus one commit on an unmerged local branch. */ -function makeObjectstack(name: string, { withOrigin = true } = {}) { - const base = path.join(scratch, name); - const upstream = path.join(base, 'upstream'); - fs.mkdirSync(upstream, { recursive: true }); - git(upstream, 'init', '-q', '-b', 'main'); - fs.writeFileSync(path.join(upstream, 'VERSION.txt'), 'one\n'); - git(upstream, 'add', '.'); - git(upstream, 'commit', '-q', '-m', 'feat(spec): first'); - fs.writeFileSync(path.join(upstream, 'VERSION.txt'), 'two\n'); - git(upstream, 'commit', '-qam', 'feat(spec): second on main'); - const checkout = path.join(base, 'objectstack'); - if (withOrigin) { - git(base, 'clone', '-q', upstream, checkout); - } else { - git(base, 'clone', '-q', '--no-local', upstream, checkout); - git(checkout, 'remote', 'remove', 'origin'); - } - const mainTip = git(checkout, 'rev-parse', 'HEAD'); - git(checkout, 'checkout', '-q', '-b', 'feature'); - fs.writeFileSync(path.join(checkout, 'VERSION.txt'), 'three\n'); - git(checkout, 'commit', '-qam', 'wip: not on main'); - const featureTip = git(checkout, 'rev-parse', 'HEAD'); - git(checkout, 'checkout', '-q', 'main'); - return { checkout, mainTip, featureTip }; -} - -function bump(objectui: string, objectstack: string, ...args: string[]) { - return bumpWith({}, objectui, objectstack, ...args); -} - -function bumpWith(env: Record, objectui: string, objectstack: string, ...args: string[]) { - return spawnSync('bash', [path.join(objectui, 'scripts/bump-objectstack.sh'), ...args], { - cwd: objectui, - encoding: 'utf8', - env: { ...GIT_ENV, OBJECTSTACK_ROOT: objectstack, ...env }, - }); -} - -const pinOf = (objectui: string) => fs.readFileSync(path.join(objectui, '.objectstack-sha'), 'utf8'); -/** How the stub pin script was called, or null when the bump never built. */ -const buildCall = (objectui: string) => { - const file = path.join(objectui, 'build.called'); - return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : null; -}; - -afterAll(() => { - fs.rmSync(scratch, { recursive: true, force: true }); -}); - -describe('scripts/bump-objectstack.sh (objectui#12957)', () => { - it('pins a readable commit on origin/main, with no warning, and commits the pin with its built manifests', () => { - const { checkout, mainTip } = makeObjectstack('on-main'); - const objectui = makeObjectui('on-main'); - const r = bump(objectui, checkout, 'origin/main'); - expect(r.status, r.stderr).toBe(0); - expect(pinOf(objectui)).toBe(`${mainTip}\n`); - expect(r.stdout).toContain('(on origin/main)'); - expect(r.stderr).not.toContain('NOT reachable'); - // The pin was built, as the pin and not as a stand-in for the checkout read above. - expect(buildCall(objectui)).toEqual({ argv: ['build', '--write-manifests'], standIn: false }); - expect(git(objectui, 'log', '-1', '--format=%s')).toBe(`chore: bump objectstack to ${mainTip.slice(0, 12)}`); - expect(git(objectui, 'show', '--name-only', '--relative', '--format=', 'HEAD').split('\n').sort()).toEqual(['.objectstack-pin/spec/package.json', '.objectstack-sha']); - expect(git(objectui, 'show', 'HEAD:./.objectstack-pin/spec/package.json')).toContain(mainTip); - }); - - it('REFUSES when the build of the new pin fails, restores the old pin, and commits nothing', () => { - const { checkout } = makeObjectstack('build-fails'); - const objectui = makeObjectui('build-fails'); - const r = bumpWith({ STUB_FAIL: '1' }, objectui, checkout, 'origin/main'); - expect(r.status).toBe(1); - expect(buildCall(objectui), 'the failure must come from the build, or this case tests nothing').not.toBeNull(); - expect(r.stderr).toContain('REFUSING to bump'); - expect(pinOf(objectui)).toBe(`${OLD_PIN}\n`); - expect(git(objectui, 'status', '--porcelain')).toBe(''); - expect(git(objectui, 'log', '-1', '--format=%s')).toBe('init'); - }); - - it('WARNS, and still pins, a readable commit that is not on origin/main', () => { - const { checkout, featureTip } = makeObjectstack('off-main'); - const objectui = makeObjectui('off-main'); - const r = bump(objectui, checkout, 'feature', '--no-commit'); - expect(r.status, r.stderr).toBe(0); - expect(pinOf(objectui)).toBe(`${featureTip}\n`); - expect(r.stderr).toContain(`objectstack pin ${featureTip.slice(0, 12)} is NOT reachable from origin/main`); - expect(r.stderr).toContain('on local branches : feature'); - expect(r.stdout).toContain('--no-commit: leaving .objectstack-sha and .objectstack-pin unstaged'); - expect(git(objectui, 'status', '--porcelain').split('\n').map((l) => l.trim()).sort()).toEqual(['M .objectstack-pin/spec/package.json', 'M .objectstack-sha']); - }); - - it('says the question is UNANSWERED when the checkout has no origin/main', () => { - const { checkout, mainTip } = makeObjectstack('no-origin', { withOrigin: false }); - const objectui = makeObjectui('no-origin'); - const r = bump(objectui, checkout, '--no-commit'); - expect(r.status, r.stderr).toBe(0); - expect(pinOf(objectui)).toBe(`${mainTip}\n`); - expect(r.stderr).toContain(`COULD NOT DETERMINE whether ${mainTip.slice(0, 12)} is on objectstack main`); - expect(r.stderr).not.toContain('NOT reachable'); - }); - - it('REFUSES a ref that does not resolve, and writes nothing', () => { - const { checkout } = makeObjectstack('unresolvable'); - const objectui = makeObjectui('unresolvable'); - const r = bump(objectui, checkout, 'no-such-ref'); - expect(r.status).toBe(1); - expect(r.stderr).toContain("REFUSING to bump: 'no-such-ref' does not resolve"); - expect(buildCall(objectui)).toBeNull(); - expect(pinOf(objectui)).toBe(`${OLD_PIN}\n`); - expect(git(objectui, 'status', '--porcelain')).toBe(''); - }); - - it('REFUSES a commit whose object cannot be read, and writes nothing', () => { - const { checkout, mainTip } = makeObjectstack('unreadable'); - const objectui = makeObjectui('unreadable'); - // Delete the loose object of HEAD's commit: `rev-parse HEAD` still resolves the - // ref, the object behind it is gone. - const objectFile = path.join(checkout, '.git/objects', mainTip.slice(0, 2), mainTip.slice(2)); - expect(fs.existsSync(objectFile), 'the commit must be a loose object for this case to mean anything').toBe(true); - fs.rmSync(objectFile, { force: true }); - expect(spawnSync('git', ['-C', checkout, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).stdout.trim()).toBe(mainTip); - const r = bump(objectui, checkout); - expect(r.status).toBe(1); - expect(r.stderr).toContain('REFUSING to bump'); - expect(r.stderr).toContain('NOTHING WAS WRITTEN'); - expect(pinOf(objectui)).toBe(`${OLD_PIN}\n`); - expect(git(objectui, 'status', '--porcelain')).toBe(''); - }); - - it('writes nothing when the commit is already the pin', () => { - const { checkout, mainTip } = makeObjectstack('same'); - const objectui = makeObjectui('same'); - fs.writeFileSync(path.join(objectui, '.objectstack-sha'), `${mainTip}\n`); - git(objectui, 'commit', '-qam', 'pin'); - const r = bump(objectui, checkout); - expect(r.status, r.stderr).toBe(0); - expect(r.stdout).toContain(`Already at ${mainTip.slice(0, 12)} (on origin/main), nothing to do.`); - expect(git(objectui, 'status', '--porcelain')).toBe(''); - }); - - it('prints its help from the header and stops at the sentinel', () => { - const r = spawnSync('bash', [SCRIPT, '--help'], { encoding: 'utf8' }); - expect(r.status).toBe(0); - expect(r.stdout).toContain('Bump the objectstack commit this workspace builds @objectstack/* from.'); - expect(r.stdout).not.toContain('SENTINEL, not prose'); - }); -}); diff --git a/ui/scripts/__tests__/changeset-release-action-ref-pin.test.ts b/ui/scripts/__tests__/changeset-release-action-ref-pin.test.ts deleted file mode 100644 index b9b9905a3db..00000000000 --- a/ui/scripts/__tests__/changeset-release-action-ref-pin.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { parse as parseYaml } from 'yaml'; - -/** - * The pin on `changesets/action`'s ref in - * `.github/workflows/changeset-release.yml` (objectui#6081). - * - * ## Why this file exists at all - * - * `.github/workflows/**` is read by nothing else in this repository: not the - * type checker, not the linters, not a schema. So a deliberate bump of that - * action lands with no mechanical objection anywhere — and two lanes of that - * workflow are built on properties asserted against **v1's source**: - * - * (1) THE DISPATCH TABLE. `!hasChangesets && hasPublishScript -> runPublish` - * (v1's `src/index.ts`). The "Clear pending changesets for the publish - * branch" step exists ONLY because the action picks its branch from - * repository state rather than from an input. If a later major takes an - * explicit instruction instead, that step is not a workaround to keep — - * it is dead weight, and keeping it would be the bug. - * (2) `runPublish` NEVER COMMITS AND NEVER PUSHES A BRANCH. Verified in v1's - * `src/run.ts`: the only git operation inside `runPublish` is - * `git.pushTag()` (lines 124 and 146); `git.prepareBranch()` (line 290) - * and `git.pushChanges()` (line 362) occur solely inside `runVersion`. - * THIS is what makes the clear step's deletion safe — it cannot leave the - * runner. v2 pushes via the GitHub API by default, which would put this - * property back in question rather than preserve it. - * - * Neither property is checkable from here. What IS checkable is the ref, so a - * bump fails loudly and prompts someone to re-establish both before shipping. - * - * ## Why the assertion lives in its own file - * - * It used to be one `it()` inside `changeset-release-lane-mirror.test.ts`, - * whose subject was the `lane` job's bash mirror of the action's file scan. - * objectui#6081 deleted that mirror — but the ref pin was never about the - * mirror. It guards property (1), which the clear step keeps depending on after - * the mirror is gone. Deleting the mirror's test file wholesale would therefore - * have removed the only thing in the repository that notices an action bump, in - * the same commit that closed the hole the bump would widen. Hence a file whose - * subject is the ref itself, which no future cleanup of something else can take - * with it by accident. - * - * ⛔ If you are deleting this file, you are deleting the only mechanical guard - * on that ref. Move the assertion somewhere that survives first — do not just - * drop it. - * - * ## The ref is a commit sha, so a retag cannot reach it - * - * `changesets/action@v1` names a moving branch whose bundled `@changesets/read` - * is a minified chunk nothing here installs, so no test can execute it and no - * lockfile records it — that gap is objectui#6081's subject, and it is the - * reason the mirror was deleted rather than given a better referent. Both - * `uses:` lines now name the v1.9.0 commit instead, so a retag of `v1` in place - * no longer changes what runs; this pin catches the DELIBERATE bump, which is - * the only moment anyone can act on anyway, and a step that drifts back onto - * the moving `v1`. - */ -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const workflowPath = path.join(repoRoot, '.github/workflows/changeset-release.yml'); -const workflowSource = fs.readFileSync(workflowPath, 'utf8'); - -/** - * The v1 commit, not the moving `v1` branch: `v1` and the `v1.9.0` tag both - * resolved to this sha when the pin moved off the branch, and it is the commit - * the workflow's git-surface reading was taken at. The publish step runs with - * the npm token, so the ref it executes must not be able to move under it. - */ -const PINNED_SHA = 'a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d'; -const PINNED_REF = `changesets/action@${PINNED_SHA}`; -const PINNED_LINE = ` uses: ${PINNED_REF} # v1.9.0`; - -interface Step { - name?: string; - uses?: string; -} - -/** - * Every `uses:` in the workflow that resolves to the `changesets/action` - * repository, sub-actions included. - * - * Matching on the repository rather than on the exact pinned string is what - * makes a bump FAIL here instead of vanishing: `changesets/action/publish@v2` - * is a different action path AND a different major, and a pin that only looked - * for the literal `changesets/action@v1` would simply stop finding anything and - * report a healthy green over a workflow that no longer contains it. - */ -function changesetsActionSteps(source: string): Array<{ job: string; step: string; uses: string }> { - const workflow = parseYaml(source) as { jobs: Record }; - const found: Array<{ job: string; step: string; uses: string }> = []; - for (const [job, definition] of Object.entries(workflow.jobs ?? {})) { - for (const step of definition.steps ?? []) { - if (typeof step.uses !== 'string') continue; - const [actionPath] = step.uses.split('@'); - if (actionPath === 'changesets/action' || actionPath.startsWith('changesets/action/')) { - found.push({ job, step: step.name ?? '(unnamed)', uses: step.uses }); - } - } - } - return found; -} - -describe('changeset-release.yml pins changesets/action to the v1.9.0 commit', () => { - it('still uses the action at all — the pin is not vacuous', () => { - // Without this, every assertion below passes over a workflow that dropped - // the action entirely, which is the one way a "toContain" pin lies. - const steps = changesetsActionSteps(workflowSource); - expect( - steps.length, - 'no step in changeset-release.yml uses changesets/action any more. If that is deliberate, this whole ' + - 'pin is obsolete and should be deleted WITH the reasoning that made it obsolete written down. If it ' + - 'is not deliberate, the release lane just lost its publish step.', - ).toBeGreaterThan(0); - }); - - it('pins EVERY usage — the publish step and the refresh step alike', () => { - const steps = changesetsActionSteps(workflowSource); - const offenders = steps.filter((s) => s.uses !== PINNED_REF); - expect( - offenders.map((s) => `${s.job} / ${s.step}: ${s.uses}`), - `changesets/action must stay at ${PINNED_REF} in every job. Both lanes of this workflow rest on ` + - `properties asserted against v1's SOURCE — the dispatch table that makes the clear step necessary, ` + - `and runPublish never committing or pushing a branch, which is what makes that step safe. v2 moves ` + - `the dispatch into an opt-in select-mode sub-action and pushes via the GitHub API by default, so a ` + - `bump does not preserve either property; it invalidates both. Re-measure them, update the ⛔ block ` + - `above the publish step in the workflow, and update this test — in that order.`, - ).toEqual([]); - }); - - it('names both usages, so a silently DROPPED lane is caught too', () => { - // A bump is not the only way this workflow loses a property. Deleting the - // refresh step would fossilise the version PR; deleting the publish step - // would end releases. Either shows up here as a missing entry. - const steps = changesetsActionSteps(workflowSource); - expect(steps.map((s) => `${s.job} / ${s.step}`).sort()).toEqual([ - 'release / Publish to npm', - 'release / Refresh the version PR', - ]); - }); -}); - -describe('the pin can fail (non-vacuity)', () => { - // The pin is worth exactly what its red branch is worth, and a pin over a - // file nothing else validates has no other way to show that it works. Each - // mutation is asserted to have APPLIED before its result is read — a no-op - // edit would leave the unmutated source under test and report a green that - // means nothing. - function mutate(from: string, to: string): string { - const mutated = workflowSource.replace(from, to); - expect(mutated, `mutation did not apply: ${from}`).not.toBe(workflowSource); - return mutated; - } - - it('goes red when the publish step is bumped to @v2', () => { - const bumped = mutate(`${PINNED_LINE}\n with:\n publish:`, ' uses: changesets/action@v2\n with:\n publish:'); - const offenders = changesetsActionSteps(bumped).filter((s) => s.uses !== PINNED_REF); - expect(offenders).toEqual([ - { job: 'release', step: 'Publish to npm', uses: 'changesets/action@v2' }, - ]); - }); - - it('goes red when the refresh step is bumped to @v2', () => { - const bumped = mutate(`${PINNED_LINE}\n with:\n version:`, ' uses: changesets/action@v2\n with:\n version:'); - const offenders = changesetsActionSteps(bumped).filter((s) => s.uses !== PINNED_REF); - expect(offenders).toEqual([ - { job: 'release', step: 'Refresh the version PR', uses: 'changesets/action@v2' }, - ]); - }); - - it('goes red when a usage is swapped for a v2 SUB-ACTION', () => { - // The shape a real v2 migration takes: `changesets/action/publish@v2`. A - // pin that matched only the exact old string would find nothing here and - // pass. - const bumped = mutate(`${PINNED_LINE}\n with:\n publish:`, ' uses: changesets/action/publish@v2\n with:\n publish:'); - const offenders = changesetsActionSteps(bumped).filter((s) => s.uses !== PINNED_REF); - expect(offenders).toEqual([ - { job: 'release', step: 'Publish to npm', uses: 'changesets/action/publish@v2' }, - ]); - }); - - it('goes red when the publish step goes back to the moving v1 branch', () => { - const floating = mutate(`${PINNED_LINE}\n with:\n publish:`, ' uses: changesets/action@v1\n with:\n publish:'); - const offenders = changesetsActionSteps(floating).filter((s) => s.uses !== PINNED_REF); - expect(offenders).toEqual([ - { job: 'release', step: 'Publish to npm', uses: 'changesets/action@v1' }, - ]); - }); - - it('goes red when the action is dropped from the workflow entirely', () => { - const stripped = mutate(PINNED_LINE, ' uses: actions/checkout@v7'); - expect(changesetsActionSteps(stripped).length).toBeLessThan(2); - }); -}); diff --git a/ui/scripts/__tests__/check-changeset-fixed.test.ts b/ui/scripts/__tests__/check-changeset-fixed.test.ts index 58904d0be87..c41e20d3616 100644 --- a/ui/scripts/__tests__/check-changeset-fixed.test.ts +++ b/ui/scripts/__tests__/check-changeset-fixed.test.ts @@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url'; // `tsconfig.scripts.json` (`allowJs`), so no `@ts-expect-error` here — // re-adding one is now itself an error (TS2578). See objectui#3494. import { analyze, globMatcher, normalizePrivatePackages } from '../check-changeset-fixed.mjs'; +import { changesetConfigPath } from '../workspace-root.mjs'; /** * `scripts/check-changeset-fixed.mjs` used to answer one question — is every @@ -128,7 +129,7 @@ describe('analyze', () => { describe('the repository itself', () => { const config = JSON.parse( - fs.readFileSync(path.join(repoRoot, '.changeset/config.json'), 'utf8') + fs.readFileSync(changesetConfigPath(repoRoot), 'utf8') ) as { fixed?: string[][]; ignore?: string[]; privatePackages?: unknown }; const manifests = ['packages', 'apps'].flatMap((dir) => diff --git a/ui/scripts/__tests__/check-installed-spec-pin-claims.test.ts b/ui/scripts/__tests__/check-installed-spec-pin-claims.test.ts deleted file mode 100644 index 1cd6ff5d08a..00000000000 --- a/ui/scripts/__tests__/check-installed-spec-pin-claims.test.ts +++ /dev/null @@ -1,365 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { lockfilePath } from '../workspace-root.mjs'; -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -// Plain-JS CI helper; its types are INFERRED from the .mjs source by -// `tsconfig.scripts.json` (`allowJs`), so no `@ts-expect-error` here. -import { - CLAIM_MARKERS, - HISTORICAL_CUES, - LEDGER, - LEDGER_CLASSES, - attributePackage, - bareMajorsFor, - isScanned, - judge, - pinsFromLockfile, - pinsFromResolvedTree, - recogniseLine, - reconcilePins, - run, -} from '../check-installed-spec-pin-claims.mjs'; - -/** - * objectui#8924 — the test for the installed-pin claim gate. - * - * ## Why the controls are shaped the way they are - * - * objectui#8897 corrected the "the pin is 17.3.0" sentences with a probe that - * was version-literal on `17.3.0`. It could not have found the identical - * sentence about 17.2.0, and it did not. The instrument, not the sentences, is - * what this file is about — so every refusal below is demonstrated on a REAL - * line of this tree, LOCATED AT RUN TIME by a stable substring rather than - * copied in, and every refusal is paired with a FIRING CONTROL: the same line, - * minimally mutated, which the recogniser DOES flag. - * - * ⚠️ The pairing is the whole point. A refusal shown only on lines where - * nothing could have fired is not evidence of a distinction — it is evidence of - * a probe that never fires, which passes exactly as green. - */ - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const MAJORS = ['17']; - -/** The one line of `file` containing every fragment, or a failure naming what was missing. */ -function lineContaining(file: string, ...fragments: string[]): string { - const hits = fs - .readFileSync(path.join(repoRoot, file), 'utf8') - .split('\n') - .filter((l) => fragments.every((f) => l.includes(f))); - expect( - hits.length, - `expected exactly one line of ${file} containing ${JSON.stringify(fragments)}; found ${hits.length}. ` + - 'If the sentence was rewritten, re-read it and re-point this control — do not delete it.', - ).toBe(1); - return hits[0]; -} - -describe('the "when it changed" refusal, on real sentences, each with a firing control', () => { - /** - * REFUSAL 1 — refused at the MARKER, by a word boundary. - * - * The line is a textbook "when it changed" sentence: "New in - * `@objectstack/spec` 17.3.0, which declares it with no top-level key at - * all". It contains the letters `installed` only inside the identifier - * `MarketplaceRowActions.installedAppsPins.test.tsx`. An unbounded - * `/installed/` marker — the obvious spelling — puts this line in the - * population, and "repairing" it would delete a true fact about 17.3.0. - */ - const historicalMarkerLine = () => - lineContaining( - 'apps/console/src/__tests__/registry-inputs-spec-parity.test.ts', - 'installedAppsPins', - '17.3.0', - 'New in', - ); - - it('refuses "New in @objectstack/spec 17.3.0 ... installedAppsPins"', () => { - const line = historicalMarkerLine(); - expect(line).toMatch(/17\.3\.0/); - expect(recogniseLine(line, MAJORS).verdict).toBe('none'); - }); - - it('FIRING CONTROL: the same line fires once a real marker is put on it', () => { - const line = historicalMarkerLine().replace('New in', 'the pin is'); - const verdict = recogniseLine(line, MAJORS); - expect(verdict.verdict).toBe('claim'); - expect(verdict.claims.map((c: { version: string }) => c.version)).toContain('17.3.0'); - }); - - /** - * REFUSAL 2 — refused at a CUE, past-tense copula in front of the marker. - * "What held them was the installed contract, not a judgement — - * `@objectstack/spec` 17.0.0". The marker is genuinely there; the tense is - * what declines it. - */ - const historicalCueLine = () => - lineContaining('apps/console/src/__tests__/registry-inputs-spec-parity.test.ts', 'was the installed contract'); - - it('refuses "was the installed contract ... @objectstack/spec 17.0.0"', () => { - const line = historicalCueLine(); - const verdict = recogniseLine(line, MAJORS); - expect(verdict.verdict).toBe('refused'); - expect(verdict.refusedBy).toBe('was-the-installed'); - // The marker and the version were both found: nothing here is declining on - // a technicality, the sentence's TENSE is what declines it. - expect(verdict.marker).toBe('installed'); - expect(verdict.claims.map((c: { version: string }) => c.version)).toContain('17.0.0'); - }); - - it('FIRING CONTROL: the same line fires in the present tense', () => { - const line = historicalCueLine().replace('was the installed', 'is the installed'); - const verdict = recogniseLine(line, MAJORS); - expect(verdict.verdict).toBe('claim'); - expect(verdict.claims.map((c: { version: string }) => c.version)).toContain('17.0.0'); - }); - - it('every historical cue still fires on the line it was taken from', () => { - // A cue whose citation has drifted is a rule nobody can check. This walks - // the whole set rather than the two featured above, so the set cannot rot - // one entry at a time. - for (const cue of HISTORICAL_CUES) { - expect(cue.cited, `cue ${cue.id} carries no citation`).toBeTruthy(); - expect(cue.re.test(cue.cited), `cue ${cue.id} does not match its own citation`).toBe(true); - } - }); -}); - -describe('the recogniser, on real claim shapes', () => { - it('recognises the present-tense claim shapes objectui#8924 enumerated', () => { - const lines = [ - ' * Measured against the installed `@objectstack/spec` 17.2.0 (ESM build):', - " * `@objectstack/spec` 17.1.0 (this repo's pin resolves 17.2.0). Its published", - ' * Interim, and it self-expires: the pin is still `@objectstack/spec` 17.2.0,', - ' * installed pin (`@objectstack/spec@17.2.0`), the element at all four converted', - ' * is pinned at 17.0.0, whose `I18nLabelSchema` is', - ]; - for (const line of lines) expect(recogniseLine(line, MAJORS).verdict, line).toBe('claim'); - }); - - it('declines a declared RANGE — a floor is not a statement about what is installed', () => { - expect(recogniseLine(' * this repo is pinned to `^17.0.0-rc.6`, which has no such key', MAJORS).verdict).toBe('none'); - expect(recogniseLine(' * the installed spec satisfies `>=17.2.0`', MAJORS).verdict).toBe('none'); - }); - - it('keeps a prerelease tail whole, and stops at the sentence full stop', () => { - const v = recogniseLine(' * `LocaleConfigSchema` up to and including 17.0.0-rc.5. The pinned', MAJORS); - expect(v.claims.map((c: { version: string }) => c.version)).toEqual(['17.0.0-rc.5']); - }); -}); - -describe('attribution — a version on the line is not automatically the anchor', () => { - const at = (line: string, needle: string) => attributePackage(line, line.indexOf(needle), MAJORS); - - it('an ATTACHED foreign package declines outright', () => { - expect(at('transitive dependency pinned to `zod@3.25.76`. It cannot convert', '3.25.76')).toBeNull(); - }); - - it('a bare version on another package\'s major line declines', () => { - // Real lines from this tree, each of which an unguarded default would have - // read as a claim about the spec. - expect(at("* current install state (\"Installed v1.0.0\" / \"Update to v1.0.1\")", '1.0.0')).toBeNull(); - expect(at(' * in the installed tree, all ten of them resolving to 1.7.3 before the pin:', '1.7.3')).toBeNull(); - expect(at(' * the installed recharts 3.10.1 for bar, line and area, clicking a mark and', '3.10.1')).toBeNull(); - }); - - it('a NAMED foreign package declines even on the anchor major', () => { - // The guard the major test cannot provide: same major, different package. - expect(at(' * Measured on this repo\'s pinned React 17.0.2 while', '17.0.2')).toBeNull(); - }); - - it('a bare version on the anchor major, after a connective, is the anchor', () => { - expect(at(' * Measured on the installed 17.2.0 and asserted in `the instrument` below:', '17.2.0')).toBe('@objectstack/spec'); - }); - - it('an attached anchor package wins over a second anchor named on the line', () => { - expect(at(' * pointed at a copy of the installed `@objectstack/client@17.2.0` placed', '17.2.0')).toBe('@objectstack/client'); - }); -}); - -describe('the pin, read from two faces', () => { - const lock = pinsFromLockfile(fs.readFileSync(lockfilePath(), 'utf8')); - const workspaces = execFileSync('git', ['-C', repoRoot, 'ls-files', '--', '*/package.json'], { encoding: 'utf8' }) - .split('\n') - .filter((f) => /^(packages|apps|examples)\/[^/]+\/package\.json$/.test(f)) - .map((f) => path.dirname(f)); - - it('the lockfile and the resolved tree agree about @objectstack/spec', () => { - const tree = pinsFromResolvedTree(repoRoot, workspaces); - const { pins, conflicts } = reconcilePins(lock, tree); - expect(conflicts.filter((c: { package: string }) => c.package === '@objectstack/spec')).toEqual([]); - expect(pins.get('@objectstack/spec')).toMatch(/^\d+\.\d+\.\d+/); - }); - - it('a disagreement between the faces yields NO pin — it does not pick a side', () => { - const twoFaced = reconcilePins( - new Map([['@objectstack/spec', new Set(['17.4.0'])]]), - new Map([['@objectstack/spec', new Set(['17.3.0'])]]), - ); - expect(twoFaced.pins.has('@objectstack/spec')).toBe(false); - expect(twoFaced.conflicts).toHaveLength(1); - }); - - it('follows a pinned-build `file:` resolution to the version its directory declares (objectui#12957)', () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pin-claims-')); - try { - const key = " '@objectstack/spec@file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7))':\n"; - expect(pinsFromLockfile(key, root).get('@objectstack/spec')).toEqual( - new Set(['(pinned build absent: node_modules/.cache/objectstack/spec — run `pnpm objectstack:build`)']), - ); - fs.mkdirSync(path.join(root, 'node_modules/.cache/objectstack/spec'), { recursive: true }); - fs.writeFileSync(path.join(root, 'node_modules/.cache/objectstack/spec/package.json'), '{"version":"18.0.0-next.3"}'); - expect(pinsFromLockfile(key, root).get('@objectstack/spec')).toEqual(new Set(['18.0.0-next.3'])); - // An absent build is a conflict that names it, never an empty face. - const absent = reconcilePins(pinsFromLockfile(key, path.join(root, 'nowhere')), new Map([['@objectstack/spec', new Set(['18.0.0-next.3'])]])); - expect(absent.pins.has('@objectstack/spec')).toBe(false); - expect(absent.conflicts[0].lock[0]).toMatch(/pinned build absent/); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } - }); - - it('a package in the lockfile but linked nowhere is transitive, not a conflict', () => { - const transitive = reconcilePins(new Map([['@objectstack/core', new Set(['17.4.0'])]]), new Map()); - expect(transitive.conflicts).toEqual([]); - expect(transitive.unlinked.map((u: { package: string }) => u.package)).toEqual(['@objectstack/core']); - }); -}); - -describe('the ledger ratchets in both directions', () => { - const pins = new Map([['@objectstack/spec', '17.4.0']]); - const claim = (file: string, line: number, version: string) => ({ - file, - line, - package: '@objectstack/spec', - version, - marker: 'installed', - text: `installed ${version}`, - }); - const entry = (sites: number) => ({ - file: 'a.ts', - package: '@objectstack/spec', - version: '17.2.0', - sites, - class: 'stale', - why: 'a reason long enough to be a reason at all', - }); - - it('an UNLEDGERED drifting claim fails', () => { - const v = judge({ pins, conflicts: [], claims: [claim('a.ts', 5, '17.2.0')], ledger: [] }); - expect(v.findings).toHaveLength(1); - expect(v.findings[0]).toContain('the artifact resolves 17.4.0'); - }); - - it('a ledgered drifting claim passes', () => { - const v = judge({ pins, conflicts: [], claims: [claim('a.ts', 5, '17.2.0')], ledger: [entry(1)] }); - expect(v.findings).toEqual([]); - }); - - it('a ledger entry whose site was repaired fails — the list cannot rot into a hole', () => { - const v = judge({ pins, conflicts: [], claims: [], ledger: [entry(1)] }); - expect(v.findings).toHaveLength(1); - expect(v.findings[0]).toContain('LEDGER is stale'); - }); - - it('a PARTIAL repair fails on the count, so half a fix cannot hide behind the entry', () => { - const v = judge({ pins, conflicts: [], claims: [claim('a.ts', 5, '17.2.0')], ledger: [entry(2)] }); - expect(v.findings).toHaveLength(1); - expect(v.findings[0]).toContain('LEDGER count is wrong'); - }); - - it('a claim AT the pin needs no entry and is the half that goes red at the next bump', () => { - const v = judge({ pins, conflicts: [], claims: [claim('a.ts', 5, '17.4.0')], ledger: [] }); - expect(v.findings).toEqual([]); - expect(v.atPin).toHaveLength(1); - - // The same claim, judged against a moved pin: now it fails, unledgered. - const bumped = judge({ - pins: new Map([['@objectstack/spec', '17.5.0']]), - conflicts: [], - claims: [claim('a.ts', 5, '17.4.0')], - ledger: [], - }); - expect(bumped.findings).toHaveLength(1); - }); - - it('an entry with no class, or no reason, fails', () => { - const noClass = judge({ - pins, - conflicts: [], - claims: [claim('a.ts', 5, '17.2.0')], - ledger: [{ ...entry(1), class: 'whatever' }], - }); - expect(noClass.findings.join(' ')).toContain('one of stale, historical'); - - const noWhy = judge({ pins, conflicts: [], claims: [claim('a.ts', 5, '17.2.0')], ledger: [{ ...entry(1), why: 'x' }] }); - expect(noWhy.findings.join(' ')).toContain('no usable reason'); - }); - - it('bare-major attribution widens with the ledger, so a MAJOR bump does not empty the gate', () => { - const majors = bareMajorsFor(new Map([['@objectstack/spec', '18.0.0']]), [{ version: '17.2.0' }]); - expect(majors).toContain('18'); - expect(majors).toContain('17'); - }); -}); - -describe('the scan surface', () => { - it('excludes every CHANGELOG and every pending changeset by PATH', () => { - expect(isScanned('packages/react-runtime/CHANGELOG.md')).toBe(false); - expect(isScanned('CHANGELOG.md')).toBe(false); - // ⛔ A synthetic name, never a PENDING changeset this repository carries - // (objectui#9472). `scripts/markdown-test-inputs.mjs` resolves markdown - // path literals out of test sources and offers only the ones that EXIST in - // the tree, so a literal naming a pending declaration becomes a ledger - // entry — and `pnpm changeset:version` deletes exactly those files, which - // turned this assertion's old literal into a `stale-not-read` finding and - // reddened `Validate the post-version tree` on every scheduled release run. - // The exclusion under test is by PATH PREFIX, so the basename carries no - // meaning here beyond sitting under the changeset directory. - expect(isScanned('.changeset/fixture-pending-declaration.md')).toBe(false); - expect(isScanned('pnpm-lock.yaml')).toBe(false); - expect(isScanned('packages/types/src/field-types.ts')).toBe(true); - }); -}); - -describe('this repository, right now', () => { - const result = run(repoRoot); - - it('has no unledgered drift and no rotted ledger entry', () => { - expect(result.findings).toEqual([]); - }); - - it('resolves one pin per anchor package from both faces', () => { - expect(result.conflicts).toEqual([]); - expect(result.pins.get('@objectstack/spec')).toBeTruthy(); - }); - - it('still sees a population — an empty scan is a FAILED run, not a clean one', () => { - // The failure this guards is the one objectui#8897's probe had: an - // instrument that stops matching reads exactly like a tree that got fixed. - expect(result.claims.length).toBeGreaterThan(40); - expect(LEDGER.length).toBeGreaterThan(20); - expect(result.atPin.length).toBeGreaterThan(0); - }); - - it('every ledger entry carries a class from the closed set and a real reason', () => { - for (const e of LEDGER) { - expect(LEDGER_CLASSES, `${e.file} ${e.version}`).toContain(e.class); - expect(e.why.length, `${e.file} ${e.version}`).toBeGreaterThan(20); - } - }); - - it('every claim marker is one objectui#8924 named', () => { - expect(CLAIM_MARKERS.map((m: { id: string }) => m.id)).toEqual([ - 'installed', - 'repo-pin', - 'pin-is', - 'pinned', - 'at-that-pin', - ]); - }); -}); diff --git a/ui/scripts/__tests__/check-lockfile-dedupe.test.ts b/ui/scripts/__tests__/check-lockfile-dedupe.test.ts index c81bfcb92fa..4c83ff8bb0d 100644 --- a/ui/scripts/__tests__/check-lockfile-dedupe.test.ts +++ b/ui/scripts/__tests__/check-lockfile-dedupe.test.ts @@ -114,9 +114,7 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import { DEDUPE_SENTINEL, pinDeclared, pinnedBuildMissing } from '../check-lockfile-dedupe.mjs'; -import { CACHE_DIR, OVERRIDE_PREFIX, STAMP_FILE } from '../objectstack-pin.mjs'; -import { isNestedWorkspace } from '../workspace-root.mjs'; +import { DEDUPE_SENTINEL } from '../check-lockfile-dedupe.mjs'; import { FLOORS, TEST_FILE } from '../check-test-path-roots.mjs'; import { NOT_A_GATE, OPTIONAL_CONTEXTS, REQUIRED_CONTEXTS } from '../dependabot-merge-gate.mjs'; import { maskComments } from '../js-comment-mask.mjs'; @@ -329,43 +327,6 @@ function runChecker(stub: { stdout?: string; stderr?: string; exit: number }, ar } } -describe('the pinned @objectstack/* build is the checker\'s precondition (objectui#12957)', () => { - // `pnpm dedupe --check` re-reads the `file:` directories every `@objectstack/*` - // package resolves to; without them pnpm stops before judging anything. The - // checker reads the build's stamp first and names that cause. - it('reads a root with no build as missing, and the same root with a stamp as present', () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lockfile-dedupe-pin-')); - try { - expect(pinnedBuildMissing(root)).toBe(true); - fs.mkdirSync(path.join(root, CACHE_DIR), { recursive: true }); - fs.writeFileSync(path.join(root, CACHE_DIR, STAMP_FILE), '{}'); - expect(pinnedBuildMissing(root)).toBe(false); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } - }); - - it('applies only where the manifest pnpm reads overrides from declares the pin (objectstack#25723)', () => { - // Standalone the root manifest declares `@objectstack/*: file:.objectstack-pin/...` - // and the stamp is a precondition; under the monorepo nothing does, the - // `@objectstack/*` packages are workspace members, and there is no build - // whose absence could send `pnpm dedupe --check` to the registry. - expect(pinDeclared({ pnpm: { overrides: { '@objectstack/spec': `${OVERRIDE_PREFIX}spec`, react: '19.3.0' } } })).toBe(true); - expect(pinDeclared({ pnpm: { overrides: { react: '19.3.0' } } })).toBe(false); - expect(pinDeclared({ pnpm: {} })).toBe(false); - expect(pinDeclared({})).toBe(false); - expect(pinDeclared()).toBe(!isNestedWorkspace()); - }); - - it('checks before it spawns pnpm, and says so in a could-not-run verdict', () => { - const source = fs.readFileSync(path.join(repoRoot, SCRIPT), 'utf8'); - const guard = source.indexOf('if (pinDeclared() && pinnedBuildMissing())'); - expect(guard, 'the precondition is gone from main()').toBeGreaterThan(0); - expect(guard).toBeLessThan(source.indexOf("spawnSync('pnpm', ['dedupe', '--check']")); - expect(source.slice(guard, source.indexOf("spawnSync('pnpm', ['dedupe', '--check']"))).toContain("outcome: 'cannot-run'"); - }); -}); - describe('the lockfile-dedupe checker', () => { it('ships its own self-test, and it passes as shipped', () => { const run = spawnSync('node', [SCRIPT, '--self-test'], { cwd: repoRoot, encoding: 'utf8' }); diff --git a/ui/scripts/__tests__/check-spec-range-floors.test.ts b/ui/scripts/__tests__/check-spec-range-floors.test.ts deleted file mode 100644 index 6fe6aaaf8e0..00000000000 --- a/ui/scripts/__tests__/check-spec-range-floors.test.ts +++ /dev/null @@ -1,633 +0,0 @@ -import { afterAll, describe, expect, it } from 'vitest'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { RUNTIME_FIELDS } from '../check-phantom-dependencies.mjs'; -import { - MIN_FLOORED_PACKAGES, - SPEC_PACKAGE, - auditPackage, - collectSpecUsage, - compareVersions, - declaredExportNames, - FLOOR_VERSION, - effectiveFloor, - isNameableArtifact, - lowestSatisfyingVersion, - minVersionOf, - replacementFloorLine, - resolveExportTarget, - specSubpathOf, - specSubpathsFromLiterals, - specSymbols, - workspaceSpecVersion, -} from '../check-spec-range-floors.mjs'; - -// Plain-JS CI helper; its types are INFERRED from the .mjs source by -// `tsconfig.scripts.json` (`allowJs`), so no `@ts-expect-error` here — unlike -// the 28 package-side carriers, whose `tsconfig.test.json` has `allowJs: false` -// and genuinely errors on this import (objectui#3494). -import { maskComments } from '../js-comment-mask.mjs'; - -// The annotation is NOT redundant under `allowJs`, and this was measured rather -// than assumed: `maskComments`'s JSDoc carries no `@param` tag (its neighbour -// `scanSource` does), so inference gives the parameter an implicit `any` and -// `maskComments(12345)` type-checks clean. Naming the signature here is what -// keeps the one call site below checked. -const mask: (source: string) => string = maskComments; - -/** - * objectui#5793 — a declared `@objectstack/spec` floor must carry every symbol - * the declaring package's own published artifact references. - * - * The gate itself needs a full build and a registry fetch, so it runs on the - * publish path and nightly (see `.github/workflows/spec-range-floors.yml`). - * What runs per-PR is this file, and what it mostly pins is the ways this gate - * could be GREEN ABOUT NOTHING — which is the entire failure mode of the defect - * class it closes, where every existing check in the repository was already - * green while `plugin-detail`'s published `.d.ts` re-exported a symbol its - * declared floor does not have. - * - * 1. **Floors are computed, never assumed.** An unreadable range THROWS rather - * than defaulting to a permissive floor. - * 2. **The export surface is read from the FETCHED manifest's `exports` map, - * under the `import` condition** — never resolved from `node_modules`, - * which is hoisted and would answer for 17.2.0 from every directory. - * 3. **The UMD exclusion is guarded, not trusted**: a subpath that reaches - * consumers only through an unreadable bundle is a finding. - * 4. **...and the guard reads string LITERALS, not text.** The first spelling - * scanned raw text and produced 30 findings, every one a doc comment. - * 5. **`devDependencies` floors nothing**, and consumer-facing fields are - * derived from the sibling gate rather than retyped. - * 6. **The vacuous verdict is RED**: a package that declares the spec and - * produced no artifact is a finding. - * 7. **The gate is NOT wired into a per-PR job** — the objectui#4846 ruling. - */ -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const GATE = 'scripts/check-spec-range-floors.mjs'; - -interface Finding { - reason: string; - pkg?: string; - subpath?: string; - symbol?: string; - detail?: string; -} - -const fixtures: string[] = []; -afterAll(() => { - for (const dir of fixtures) fs.rmSync(dir, { recursive: true, force: true }); -}); - -/** A stand-in for a fetched, parsed spec version. */ -function fakeSpec(version: string, entries: Record) { - return { - version, - entryFor(subpath: string) { - if (!(subpath in entries)) return null; - return { - target: `./dist${subpath === '.' ? '' : subpath}/index.d.mts`, - path: ['import', 'types'], - names: new Set(entries[subpath]), - starFrom: [], - }; - }, - }; -} - -const pkgOf = (name: string, manifest: Record = {}) => ({ - name, - dir: `packages/${name.split('/').pop()}`, - manifest: { name, ...manifest }, -}); - -/** Run `collectSpecUsage` over an in-memory artifact tree. */ -const usageOf = (files: Record) => - collectSpecUsage(Object.keys(files), (file: string) => files[file] ?? null); - -// ── 1. floors are computed, and an unreadable range is a failure ───────────── - -describe('the declared floor', () => { - it('reads the caret ranges this workspace actually uses', () => { - expect(minVersionOf('^17.0.0')).toBe('17.0.0'); - expect(minVersionOf('^17.1.0')).toBe('17.1.0'); - expect(minVersionOf('~17.2.0')).toBe('17.2.0'); - expect(minVersionOf('17.0.0')).toBe('17.0.0'); - expect(minVersionOf('=17.0.0')).toBe('17.0.0'); - expect(minVersionOf('>=17.1.0 <18.0.0')).toBe('17.1.0'); - }); - - it('reads a PRERELEASE floor as that exact prerelease — the 18 line exists only as one', () => { - // objectui#12334. The workspace declares `^18.0.0-next.N` since objectui#12030; - // when this threw, no package counted as floored and the run stopped at the - // collapse alarm, cancelling `pnpm changeset:publish`. - expect(minVersionOf('^18.0.0-next.1')).toBe('18.0.0-next.1'); - expect(minVersionOf('~18.0.0-rc.2')).toBe('18.0.0-rc.2'); - expect(minVersionOf('18.0.0-next.1')).toBe('18.0.0-next.1'); - expect(minVersionOf('>=18.0.0-next.1 <19.0.0')).toBe('18.0.0-next.1'); - expect(FLOOR_VERSION.test('18.0.0-next.1')).toBe(true); - expect(FLOOR_VERSION.test('18.0.0')).toBe(true); - expect(FLOOR_VERSION.test('18.0.0-')).toBe(false); - expect(FLOOR_VERSION.test('18.0.0-next.1+build.5')).toBe(false); - }); - - it('takes the LOWEST branch of a `||` union — a consumer may satisfy any one', () => { - expect(minVersionOf('^17.1.0 || ^18.0.0')).toBe('17.1.0'); - expect(minVersionOf('^18.0.0 || ^17.0.0')).toBe('17.0.0'); - }); - - it('THROWS on a range it does not read, rather than guessing a floor', () => { - // The direction that matters. A range silently read as "floor 0.0.0" is - // satisfied by every version ever published, and the gate goes green while - // asserting nothing — which is precisely the state this card found. - for (const range of ['*', '', 'latest', '>17.0.0', 'workspace:*', '^18.0.0-next.1+build.5', '^18.0.0-', '>18.0.0-next.1']) { - expect(() => minVersionOf(range), range).toThrow(); - } - // Reading a prerelease is not ORDERING one: the lowest branch of a union - // is an ordering, so a union with a prerelease branch is still unread. - expect(() => minVersionOf('^17.1.0 || ^18.0.0-next.1')).toThrow(/cannot order/); - // An UPPER bound alone. Added because a mutation battery found it: every - // range above is rejected while parsing a comparator, so none of them ever - // reached the "parsed fine, named no floor" branch — and a mutation - // returning '0.0.0' from that branch passed the whole suite. A range that - // caps the version without flooring it is the one spelling that gets there. - for (const range of ['<18.0.0', '<=18.0.0', '<18.0.0 <17.9.0']) { - expect(() => minVersionOf(range), range).toThrow(/names no lower bound/); - } - }); - - it('reads a workspace: range as the workspace spec\'s own version, and only when told it (objectstack ruling D1)', () => { - // Inside the objectstack monorepo the ranges are `workspace:^`, which publishes as - // `^`: that version is the floor. Without the version the - // spelling stays UNREADABLE (the loop above keeps `workspace:*` in its list), - // because a workspace range read without its workspace has no floor at all. - for (const range of ['workspace:*', 'workspace:^', 'workspace:~', 'workspace:']) { - expect(minVersionOf(range, { workspaceVersion: '18.1.0' }), range).toBe('18.1.0'); - expect(() => minVersionOf(range), range).toThrow(/workspace package's own version/); - expect(() => minVersionOf(range, { workspaceVersion: 'latest' }), range).toThrow(/is not one/); - } - // A range tail is that range, with or without the workspace version. - expect(minVersionOf('workspace:^18.0.0-next.1')).toBe('18.0.0-next.1'); - expect(minVersionOf('workspace:~17.2.0', { workspaceVersion: '18.1.0' })).toBe('17.2.0'); - // Every non-workspace spelling reads exactly as before, version told or not. - expect(minVersionOf('^17.0.0', { workspaceVersion: '18.1.0' })).toBe('17.0.0'); - expect(() => minVersionOf('latest', { workspaceVersion: '18.1.0' })).toThrow(); - // Through the declared floor: the workspace version takes part in "highest wins". - const floor = effectiveFloor( - { dependencies: { [SPEC_PACKAGE]: 'workspace:^' }, peerDependencies: { [SPEC_PACKAGE]: '^17.1.0' } }, - undefined, - { workspaceVersion: '18.1.0' }, - ); - expect(floor?.version).toBe('18.1.0'); - expect(() => effectiveFloor({ dependencies: { [SPEC_PACKAGE]: 'workspace:^' } })).toThrow(/dependencies/); - }); - - it('finds the workspace spec\'s version in the nearest node_modules above the root, and null when none is installed', () => { - const tree = fs.mkdtempSync(path.join(os.tmpdir(), 'spec-range-floors-ws-')); - try { - expect(workspaceSpecVersion(tree)).toBeNull(); - fs.mkdirSync(path.join(tree, 'node_modules', SPEC_PACKAGE), { recursive: true }); - fs.writeFileSync(path.join(tree, 'node_modules', SPEC_PACKAGE, 'package.json'), JSON.stringify({ name: SPEC_PACKAGE, version: '18.1.0' })); - expect(workspaceSpecVersion(tree)).toBe('18.1.0'); - // A nested tree (`ui/`) with no install of its own reads the one above it. - fs.mkdirSync(path.join(tree, 'ui/packages'), { recursive: true }); - expect(workspaceSpecVersion(path.join(tree, 'ui'))).toBe('18.1.0'); - // This checkout resolves the installed spec, which is what a run reads by default. - expect(workspaceSpecVersion(repoRoot)).toMatch(/^\d+\.\d+\.\d+/); - } finally { - fs.rmSync(tree, { recursive: true, force: true }); - } - }); - - it('is the HIGHEST minimum when several consumer-facing fields declare it', () => { - const floor = effectiveFloor({ - dependencies: { [SPEC_PACKAGE]: '^17.0.0' }, - peerDependencies: { [SPEC_PACKAGE]: '^17.2.0' }, - }); - // A consumer install must satisfy both at once, so 17.0.0 is not admitted. - expect(floor?.version).toBe('17.2.0'); - expect(floor?.ranges).toHaveLength(2); - }); - - it('takes a prerelease floor declared twice, and refuses two DIFFERENT ones rather than order them', () => { - const same = effectiveFloor({ - dependencies: { [SPEC_PACKAGE]: '^18.0.0-next.1' }, - peerDependencies: { [SPEC_PACKAGE]: '^18.0.0-next.1' }, - }); - expect(same?.version).toBe('18.0.0-next.1'); - expect(() => - effectiveFloor({ - dependencies: { [SPEC_PACKAGE]: '^18.0.0-next.1' }, - peerDependencies: { [SPEC_PACKAGE]: '^18.0.0-next.2' }, - }), - ).toThrow(/peerDependencies.*does not order prereleases/); - }); - - it('is null when only `devDependencies` declares it — that floors nobody', () => { - expect(effectiveFloor({ devDependencies: { [SPEC_PACKAGE]: '^17.0.0' } })).toBeNull(); - // An artifact importing a dev-only dependency is a PHANTOM dependency, - // which is `check-phantom-dependencies.mjs`'s finding and deliberately not - // restated here. - expect(RUNTIME_FIELDS).not.toContain('devDependencies'); - }); - - it('derives the consumer-facing field list from the sibling gate', () => { - // Retyping it is how two gates drift into covering different fields while - // both stay green. - const source = fs.readFileSync(path.join(repoRoot, GATE), 'utf8'); - expect(source).toContain("import { RUNTIME_FIELDS } from './check-phantom-dependencies.mjs'"); - expect(RUNTIME_FIELDS).toEqual(['dependencies', 'peerDependencies', 'optionalDependencies']); - }); - - it('surfaces an unreadable range by naming the field and the range', () => { - expect(() => effectiveFloor({ dependencies: { [SPEC_PACKAGE]: 'latest' } })).toThrow(/dependencies/); - expect(() => effectiveFloor({ dependencies: { [SPEC_PACKAGE]: 'latest' } })).toThrow(/latest/); - }); -}); - -// ── 2. what the artifact references ────────────────────────────────────────── - -describe('the symbols an artifact takes from the spec', () => { - it('maps a specifier to the `exports`-map spelling of its subpath', () => { - expect(specSubpathOf(SPEC_PACKAGE)).toBe('.'); - expect(specSubpathOf(`${SPEC_PACKAGE}/ui`)).toBe('./ui'); - expect(specSubpathOf('@objectstack/spec-extras/ui')).toBeNull(); - expect(specSubpathOf('react')).toBeNull(); - }); - - it('reads the IMPORTED name, never the local alias', () => { - const { named } = specSymbols( - `import { FeedFilterMode as Ln, FeedItemType } from '@objectstack/spec/data';`, - 'dist/index.js', - ); - expect([...named.get('./data')!].sort()).toEqual(['FeedFilterMode', 'FeedItemType']); - expect(named.get('./data')!.has('Ln')).toBe(false); - }); - - it('reads the exact shape objectui#5494 published — the defect in one file', () => { - // packages/plugin-detail/dist/renderers/record-reference-rail.d.ts, verbatim. - const { named } = specSymbols( - [ - `import { ReferenceRailEntry } from '@objectstack/spec/ui';`, - `export type { ReferenceRailEntry } from '@objectstack/spec/ui';`, - ].join('\n'), - 'dist/renderers/record-reference-rail.d.ts', - ); - expect([...named.get('./ui')!]).toEqual(['ReferenceRailEntry']); - }); - - it('covers type-only, `import()` type, and re-export forms', () => { - const { named } = specSymbols( - [ - `import type { ViewFilterRule } from '@objectstack/spec/ui';`, - `export { defineView } from '@objectstack/spec/ui';`, - `type X = import('@objectstack/spec/data').FeedFilterMode;`, - ].join('\n'), - 'dist/a.d.ts', - ); - expect([...named.get('./ui')!].sort()).toEqual(['ViewFilterRule', 'defineView']); - expect([...named.get('./data')!]).toEqual(['FeedFilterMode']); - }); - - it('records a namespace import as a SUBPATH requirement naming no symbol', () => { - const { named, subpaths } = specSymbols(`import * as spec from '@objectstack/spec/ui';`, 'dist/a.js'); - expect(subpaths.has('./ui')).toBe(true); - expect(named.has('./ui')).toBe(false); - }); -}); - -// ── 3 & 4. the UMD exclusion is guarded, and the guard reads literals ──────── - -describe('the guard on the unreadable-bundle exclusion', () => { - it('excludes minified UMD/CJS bundles and source maps from the name scan', () => { - expect(isNameableArtifact('dist/index.d.ts')).toBe(true); - expect(isNameableArtifact('dist/index.d.mts')).toBe(true); - expect(isNameableArtifact('dist/index.js')).toBe(true); - expect(isNameableArtifact('dist/index.umd.cjs')).toBe(false); - expect(isNameableArtifact('dist/index.js.map')).toBe(false); - }); - - it('finds the subpaths a minified UMD still names, though no symbol survives', () => { - // The real shape, from packages/plugin-detail/dist/index.umd.cjs. - const umd = 'require("@object-ui/types"),require("@objectstack/spec/data")):typeof define==`function`&&define([`exports`,`@objectstack/spec/data`],t)'; - expect([...specSubpathsFromLiterals(umd, 'dist/index.umd.cjs')]).toEqual(['./data']); - }); - - it('does NOT fire on a doc comment — the 30 false findings the first spelling produced', () => { - // Every one of these is real prose from this repository's `dist/*.d.ts`. - const prose = [ - '/** Aligned with @objectstack/spec ReactionSchema. */', - '// a `@objectstack/spec` `FieldType` name. `rich_text` was not one.', - '/** Re-exported from `@objectstack/spec/data/object.zod.ts`, not restated. */', - '/** @objectstack/spec/view is not an entry point that exists. */', - 'export declare const x: number;', - ].join('\n'); - expect([...specSubpathsFromLiterals(prose, 'dist/a.js')]).toEqual([]); - }); - - it('reports a subpath reachable only through an unreadable bundle', () => { - const usage = usageOf({ - 'dist/index.umd.cjs': 'require("@objectstack/spec/kernel")', - 'dist/index.js': `import { defineView } from '@objectstack/spec/ui';`, - }); - const spec = fakeSpec('17.0.0', { './ui': ['defineView'], './kernel': ['whatever'] }); - const { findings } = auditPackage(pkgOf('@object-ui/core'), usage, spec, '17.0.0') as { findings: Finding[] }; - expect(findings.map((f) => f.reason)).toEqual(['opaque-subpath']); - expect(findings[0].subpath).toBe('./kernel'); - }); - - it('says nothing when the ESM half already covers the subpaths the bundle names', () => { - const usage = usageOf({ - 'dist/index.umd.cjs': 'require("@objectstack/spec/data")', - 'dist/index.js': `import { FeedFilterMode } from '@objectstack/spec/data';`, - }); - const spec = fakeSpec('17.0.0', { './data': ['FeedFilterMode'] }); - const { findings } = auditPackage(pkgOf('@object-ui/plugin-detail'), usage, spec, '17.0.0') as { findings: Finding[] }; - expect(findings).toEqual([]); - }); -}); - -// ── 5. the verdict itself ──────────────────────────────────────────────────── - -describe('the verdict', () => { - const railUsage = () => - usageOf({ - 'dist/renderers/record-reference-rail.d.ts': `export type { ReferenceRailEntry } from '@objectstack/spec/ui';`, - }); - - it('goes RED naming the symbol when the floor lacks it — objectui#5793 exactly', () => { - const spec = fakeSpec('17.0.0', { './ui': ['ViewFilterRule', 'defineView'] }); - const { findings } = auditPackage(pkgOf('@object-ui/plugin-detail'), railUsage(), spec, '17.0.0') as { - findings: Finding[]; - }; - expect(findings).toHaveLength(1); - expect(findings[0].reason).toBe('floor-too-low'); - expect(findings[0].symbol).toBe('ReferenceRailEntry'); - expect(findings[0].subpath).toBe('./ui'); - // The finding must NAME the file, or the bump it justifies is unverifiable. - expect(findings[0].detail).toContain('record-reference-rail.d.ts'); - expect(findings[0].detail).toContain('17.0.0'); - }); - - it('goes GREEN once the floor carries it — the same usage, one version up', () => { - const spec = fakeSpec('17.1.0', { './ui': ['ViewFilterRule', 'defineView', 'ReferenceRailEntry'] }); - const { findings } = auditPackage(pkgOf('@object-ui/plugin-detail'), railUsage(), spec, '17.1.0') as { - findings: Finding[]; - }; - expect(findings).toEqual([]); - }); - - it('reports an entry point the floor does not publish at all', () => { - const usage = usageOf({ 'dist/a.d.ts': `import { X } from '@objectstack/spec/studio';` }); - const spec = fakeSpec('17.0.0', { './ui': ['X'] }); - const { findings } = auditPackage(pkgOf('@object-ui/react'), usage, spec, '17.0.0') as { findings: Finding[] }; - expect(findings.map((f) => f.reason)).toEqual(['unknown-subpath']); - }); - - it('refuses to approve an entry whose own export set it cannot read', () => { - const usage = usageOf({ 'dist/a.d.ts': `import { X } from '@objectstack/spec/ui';` }); - const spec = { - version: '17.0.0', - entryFor: () => ({ target: './dist/ui/index.d.mts', path: ['import'], names: new Set(['X']), starFrom: ['../chunk.mjs'] }), - }; - const { findings } = auditPackage(pkgOf('@object-ui/react'), usage, spec, '17.0.0') as { findings: Finding[] }; - // `X` IS in `names`. The finding fires anyway, because a partially-read - // export set cannot distinguish "exported" from "not seen yet". - expect(findings.map((f) => f.reason)).toEqual(['unfollowable-star']); - }); - - it('says nothing about a package whose artifact never reaches for the spec', () => { - // Over-declared is a different card; a floor with nothing behind it cannot - // admit a version that lacks something. - const spec = fakeSpec('17.0.0', {}); - const { findings } = auditPackage(pkgOf('@object-ui/mobile'), usageOf({ 'dist/a.js': 'export const a = 1;' }), spec, '17.0.0'); - expect(findings).toEqual([]); - }); - - it('names the lowest version that would satisfy the package, so a bump is justified', () => { - const wanted = new Map([['./ui', new Map([['ReferenceRailEntry', ['dist/a.d.ts']]])]]); - const load = (v: string) => - fakeSpec(v, { './ui': v === '17.0.0' ? ['other'] : ['other', 'ReferenceRailEntry'] }); - expect(lowestSatisfyingVersion(wanted, ['17.0.0', '17.1.0', '17.2.0'], load)).toBe('17.1.0'); - expect(lowestSatisfyingVersion(wanted, ['17.0.0'], load)).toBeNull(); - }); - - it('names no replacement for a PRERELEASE floor, and never searches for one', () => { - // Finding a replacement orders published versions against the floor, and - // this gate does not order prereleases. - const wanted = new Map([['./ui', new Map([['ReferenceRailEntry', ['dist/a.d.ts']]])]]); - const load = () => { - throw new Error('searched for a replacement of a prerelease floor'); - }; - const line = replacementFloorLine('@object-ui/plugin-detail', '18.0.0-next.1', wanted, ['17.7.0'], load); - expect(line).toContain('18.0.0-next.1 is a prerelease'); - expect(line).not.toMatch(/"\^/); - - // A release floor keeps the search it always had. - const found = replacementFloorLine('@object-ui/plugin-detail', '17.0.0', wanted, ['17.0.0', '17.1.0'], (v: string) => - fakeSpec(v, { './ui': v === '17.0.0' ? [] : ['ReferenceRailEntry'] }), - ); - expect(found).toContain(`"${SPEC_PACKAGE}": "^17.1.0"`); - }); -}); - -// ── 6. the export surface is read from the fetched manifest, ESM half ──────── - -describe('reading the export surface of a published version', () => { - it('walks the `exports` map under the `import` condition, never `require`', () => { - // The real shape of `@objectstack/spec`'s map, trimmed. - const exportsField = { - './ui': { - browser: { - import: { types: './dist/ui/index.d.mts', default: './dist/browser/ui/index.mjs' }, - require: { types: './dist/ui/index.d.ts', default: './dist/browser/ui/index.js' }, - }, - import: { types: './dist/ui/index.d.mts', default: './dist/ui/index.mjs' }, - require: { types: './dist/ui/index.d.ts', default: './dist/ui/index.js' }, - }, - }; - const esm = resolveExportTarget(exportsField, './ui', ['import', 'types', 'default']); - expect(esm).toEqual({ target: './dist/ui/index.d.mts', path: ['import', 'types'] }); - - // The trap: `createRequire` reads a build no bundler puts in an app. - const cjs = resolveExportTarget(exportsField, './ui', ['require', 'types', 'default']); - expect(cjs!.target).toBe('./dist/ui/index.d.ts'); - expect(esm!.target).not.toBe(cjs!.target); - - expect(resolveExportTarget(exportsField, './nope', ['import', 'types'])).toBeNull(); - }); - - it('reads the exported name out of a renaming chunk re-export, never the local one', () => { - // Verbatim shape of `dist/ui/index.d.mts`: the spec's entries re-export - // single-letter locals from content-hashed chunk files. - const { names, starFrom } = declaredExportNames( - [ - `export { A as ActionNavItem, b as ActionNavItemParsed } from '../app.zod-CH7IEmsS.mjs';`, - `declare const ChartTypeSchema: unknown;`, - `export { ChartTypeSchema };`, - `export declare function defineView(): void;`, - `export type ViewKind = 'list';`, - ].join('\n'), - ); - expect([...names].sort()).toEqual(['ActionNavItem', 'ActionNavItemParsed', 'ChartTypeSchema', 'ViewKind', 'defineView']); - expect(names.has('A')).toBe(false); - expect(starFrom).toEqual([]); - }); - - it('records an `export *` it cannot follow instead of dropping it', () => { - const { starFrom } = declaredExportNames(`export * from '../chunk.mjs';`); - expect(starFrom).toEqual(['../chunk.mjs']); - }); - - it('resolves nothing through `node_modules` — the hoisting trap, pinned in the source', () => { - // Comments are stripped first, on purpose: the gate's header NAMES - // `createRequire` and `require.resolve` to explain the trap, and a test - // that could not tell an explanation from a call site would have to choose - // between firing on the documentation and not existing. - const source = fs.readFileSync(path.join(repoRoot, GATE), 'utf8'); - const code = mask(source); - expect(code).toMatch(/loadPublishedSpec/); - expect(code).not.toMatch(/createRequire|require\.resolve|import\.meta\.resolve/); - expect(source).toContain('never through the installed tree'); - }); - - it('orders plain releases and refuses prereleases', () => { - expect(compareVersions('17.0.0', '17.1.0')).toBe(-1); - expect(compareVersions('17.2.0', '17.10.0')).toBe(-1); - expect(compareVersions('17.1.0', '17.1.0')).toBe(0); - expect(() => compareVersions('17.0.0-rc.1', '17.0.0')).toThrow(); - }); -}); - -// ── 7. the wiring: publish path yes, per-PR no ─────────────────────────────── - -describe('where the gate is wired', () => { - const workflowDir = path.join(repoRoot, '.github/workflows'); - const rootManifest = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as { - scripts: Record; - }; - - it('runs on the publish path, before a tarball can reach npm', () => { - expect(rootManifest.scripts['changeset:publish']).toContain('check-spec-range-floors.mjs'); - expect(rootManifest.scripts['changeset:publish']).toMatch(/check-spec-range-floors\.mjs.*changeset publish/); - expect(rootManifest.scripts['check:spec-floors']).toBe('node scripts/check-spec-range-floors.mjs'); - }); - - it('is in NO workflow carrying a `pull_request` trigger — the objectui#4846 ruling', () => { - // The criterion needs a full-repo build, and the 2026-08-16 ruling rejected - // a per-PR full-repo build. This test is what stops it being added later by - // someone who only sees a gate that "should" block PRs. - for (const file of fs.readdirSync(workflowDir).filter((f) => f.endsWith('.yml'))) { - const text = fs.readFileSync(path.join(workflowDir, file), 'utf8'); - if (!text.includes('check:spec-floors') && !text.includes('check-spec-range-floors')) continue; - const triggers = text.slice(0, text.indexOf('\njobs:')); - expect(triggers, `${file} must not run this gate on pull_request`).not.toMatch(/^\s*pull_request:/m); - } - }); - - it('has a nightly alarm that builds before it reads', () => { - const workflow = fs.readFileSync(path.join(workflowDir, 'spec-range-floors.yml'), 'utf8'); - expect(workflow).toMatch(/schedule:/); - expect(workflow).toMatch(/turbo run build/); - const buildAt = workflow.indexOf('turbo run build'); - const gateAt = workflow.indexOf('check:spec-floors'); - // Reading before building is the `no-artifact` verdict for the whole repo. - expect(buildAt).toBeGreaterThan(-1); - expect(gateAt).toBeGreaterThan(buildAt); - }); - - it('keeps a collapse alarm under the population it actually has', () => { - expect(MIN_FLOORED_PACKAGES).toBeGreaterThan(0); - const manifests = fs - .readdirSync(path.join(repoRoot, 'packages')) - .map((dir) => path.join(repoRoot, 'packages', dir, 'package.json')) - .filter((file) => fs.existsSync(file)) - .map((file) => JSON.parse(fs.readFileSync(file, 'utf8')) as Record>); - const floored = manifests.filter((m) => RUNTIME_FIELDS.some((f) => m[f]?.[SPEC_PACKAGE])); - expect(floored.length).toBeGreaterThanOrEqual(MIN_FLOORED_PACKAGES); - }); -}); - -// ── 8. the vacuous verdict is RED ──────────────────────────────────────────── - -describe('a prerelease floor is judged like any other', () => { - /** One package declaring the spec at a prerelease floor, with one artifact. */ - function prereleaseCheckout(artifact: string) { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spec-range-floors-pre-')); - fixtures.push(root); - fs.mkdirSync(path.join(root, 'packages/detail/dist'), { recursive: true }); - fs.writeFileSync(path.join(root, 'packages/detail/dist/index.d.ts'), artifact); - const manifest = { name: '@object-ui/plugin-detail', dependencies: { [SPEC_PACKAGE]: '^18.0.0-next.1' } }; - return { root, packages: [{ name: manifest.name, dir: 'packages/detail', manifest }] }; - } - - it('fetches THAT prerelease and reds on a symbol it does not export', async () => { - const { analyze } = await import('../check-spec-range-floors.mjs'); - const { root, packages } = prereleaseCheckout(`export type { ReferenceRailEntry } from '@objectstack/spec/ui';`); - const asked: string[] = []; - const result = analyze(root, { - packages, - load: (version: string) => { - asked.push(version); - return fakeSpec(version, { './ui': ['defineView'] }); - }, - }) as { findings: Finding[]; counters: { floored: number } }; - - expect(asked).toEqual(['18.0.0-next.1']); - expect(result.counters.floored).toBe(1); - expect(result.findings.map((f) => `${f.reason} ${f.symbol}`)).toEqual(['floor-too-low ReferenceRailEntry']); - }); - - it('goes GREEN when that prerelease exports the symbol', async () => { - const { analyze } = await import('../check-spec-range-floors.mjs'); - const { root, packages } = prereleaseCheckout(`export type { ReferenceRailEntry } from '@objectstack/spec/ui';`); - const result = analyze(root, { - packages, - load: (version: string) => fakeSpec(version, { './ui': ['ReferenceRailEntry'] }), - }) as { findings: Finding[] }; - expect(result.findings).toEqual([]); - }); - - it('still reports a range it cannot read, and does not count it as floored', async () => { - const { analyze } = await import('../check-spec-range-floors.mjs'); - const { root, packages } = prereleaseCheckout('export {};'); - packages[0].manifest.dependencies[SPEC_PACKAGE] = '>18.0.0-next.1'; - const result = analyze(root, { - packages, - load: () => { - throw new Error('the gate fetched a spec version for a range it could not read'); - }, - }) as { findings: Finding[]; counters: { floored: number } }; - expect(result.counters.floored).toBe(0); - expect(result.findings.map((f) => f.reason)).toEqual(['unreadable-range']); - }); -}); - -describe('nothing to inspect is a FAILURE, not a pass', () => { - it('reports a package that declares the spec and produced no build output', async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spec-range-floors-')); - fixtures.push(root); - fs.mkdirSync(path.join(root, '.changeset'), { recursive: true }); - fs.writeFileSync(path.join(root, '.changeset/config.json'), JSON.stringify({ fixed: [['@object-ui/ghost']] })); - fs.mkdirSync(path.join(root, 'packages/ghost'), { recursive: true }); - fs.writeFileSync( - path.join(root, 'packages/ghost/package.json'), - JSON.stringify({ name: '@object-ui/ghost', dependencies: { [SPEC_PACKAGE]: '^17.0.0' } }), - ); - - const { analyze } = await import('../check-spec-range-floors.mjs'); - const result = analyze(root, { - // A load that would THROW if reached: the no-artifact verdict must be - // decided before anything is fetched. - load: () => { - throw new Error('the gate fetched a spec version for a package with no artifacts'); - }, - }) as { findings: Finding[]; counters: { floored: number } }; - - expect(result.counters.floored).toBe(1); - expect(result.findings.map((f) => f.reason)).toEqual(['no-artifact']); - expect(result.findings[0].detail).toContain('must never return'); - }); -}); diff --git a/ui/scripts/__tests__/console-lazy-linter.test.ts b/ui/scripts/__tests__/console-lazy-linter.test.ts new file mode 100644 index 00000000000..8a65e296153 --- /dev/null +++ b/ui/scripts/__tests__/console-lazy-linter.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest'; + +/** + * The console config's `assert-lazy-linter-stays-lazy` guard (objectui#5266, + * objectui#5323): the lazily imported `@objectstack/lint` must stay out of every + * eagerly loaded chunk, and the guard's counter-probe refuses a verdict when it + * cannot see the spec chunk it measures against. + * + * Moved here from `vite-objectstack-spec-dist.test.ts` when that module's + * `OBJECTSTACK_SPEC_DIST` injection left with the objectstack pin + * (objectstack#25711). The baseline cases are kept as they were; the cases that + * pointed the override at an out-of-tree spec left with it. The workspace + * spelling of the spec (`/packages/spec/dist/...`) is held by + * `vite-objectstack-workspace.test.ts`. + * + * These cases drive the REAL plugin off the REAL config over a synthetic bundle, + * rather than asserting on the regex the config hands it: the defect this guards + * was never a wrong regex, it was a correct regex reaching one consumer and not + * the other. + */ + +async function loadConsoleConfig(query = ''): Promise { + const specifier = `../../apps/console/vite.config.ts${query}`; + return (await import(/* @vite-ignore */ specifier)).default; +} + +interface ProbeChunk { + type: 'chunk'; + fileName: string; + isEntry: boolean; + imports: string[]; + modules: Record; +} + +/** A pnpm-store module id for the linter, the spelling a real bundle carries. */ +const LINT_MODULE_ID = + '/repo/node_modules/.pnpm/@objectstack+lint@17.0.0/node_modules/@objectstack/lint/dist/index.js'; +/** The installed spec's module id, the spelling a node_modules build emits. */ +const INSTALLED_SPEC_MODULE_ID = '/repo/node_modules/@objectstack/spec/dist/index.mjs'; + +const probeChunk = ( + fileName: string, + modules: string[], + extra: Partial = {} +): ProbeChunk => ({ + type: 'chunk', + fileName, + isEntry: false, + imports: [], + modules: Object.fromEntries(modules.map((id) => [id, {}])), + ...extra, +}); + +/** + * A bundle shaped like the console's: one entry, one statically imported vendor + * chunk holding the spec, and the linter parked behind a dynamic import — which + * the plugin's walk deliberately does not follow. + * + * @param specModuleId the spec id the vendor chunk carries + * @param lintFileName which chunk holds the linter, or `null` for none at all + */ +function consoleShapedBundle( + specModuleId: string, + lintFileName: 'assets/vendor-objectstack.js' | 'assets/lint-lazy.js' | null +): Record { + const vendorModules = [specModuleId]; + if (lintFileName === 'assets/vendor-objectstack.js') vendorModules.push(LINT_MODULE_ID); + const bundle: Record = { + 'assets/index.js': probeChunk('assets/index.js', ['/repo/apps/console/src/main.tsx'], { + isEntry: true, + imports: ['assets/vendor-objectstack.js'], + }), + 'assets/vendor-objectstack.js': probeChunk('assets/vendor-objectstack.js', vendorModules), + }; + if (lintFileName === 'assets/lint-lazy.js') { + bundle['assets/lint-lazy.js'] = probeChunk('assets/lint-lazy.js', [LINT_MODULE_ID]); + } + return bundle; +} + +/** Runs the console's own guard over one bundle; returns its error, or `null`. */ +function runLazyLinterProbe(config: any, bundle: Record): string | null { + const plugins = (config.plugins as unknown[]).flat(Infinity) as { + name?: string; + generateBundle?: (this: unknown, options: unknown, bundle: unknown) => void; + }[]; + const plugin = plugins.find((p) => p && p.name === 'assert-lazy-linter-stays-lazy'); + expect(plugin, 'the console config registers assert-lazy-linter-stays-lazy').toBeDefined(); + const context = { + error(message: string): never { + throw new Error(message); + }, + }; + try { + plugin!.generateBundle!.call(context, {}, bundle); + return null; + } catch (error) { + return (error as Error).message; + } +} + +describe('the lazy-linter guard, read off the real console config', () => { + it('passes a bundle whose eager closure holds the spec and whose linter is lazy', async () => { + const config = await loadConsoleConfig(); + expect( + runLazyLinterProbe(config, consoleShapedBundle(INSTALLED_SPEC_MODULE_ID, 'assets/lint-lazy.js')) + ).toBeNull(); + }); + + it('catches an EAGER linter', async () => { + const config = await loadConsoleConfig(); + const message = runLazyLinterProbe( + config, + consoleShapedBundle(INSTALLED_SPEC_MODULE_ID, 'assets/vendor-objectstack.js') + ); + expect(message).toContain('`@objectstack/lint` is in the EAGER closure'); + expect(message).toContain('assets/vendor-objectstack.js'); + }); + + it('refuses a verdict when the eager closure holds no spec, and an eager client is not a spec', async () => { + const config = await loadConsoleConfig(); + const noSpec: Record = { + 'assets/index.js': probeChunk('assets/index.js', ['/repo/apps/console/src/main.tsx'], { + isEntry: true, + }), + 'assets/lint-lazy.js': probeChunk('assets/lint-lazy.js', [LINT_MODULE_ID]), + }; + expect(runLazyLinterProbe(config, noSpec)).toContain('counter-probe failed'); + const clientOnly: Record = { + 'assets/index.js': probeChunk('assets/index.js', ['/repo/apps/console/src/main.tsx'], { + isEntry: true, + imports: ['assets/vendor-objectstack.js'], + }), + 'assets/vendor-objectstack.js': probeChunk('assets/vendor-objectstack.js', [ + '/repo/node_modules/@objectstack/client/dist/index.mjs', + ]), + 'assets/lint-lazy.js': probeChunk('assets/lint-lazy.js', [LINT_MODULE_ID]), + }; + expect(runLazyLinterProbe(config, clientOnly)).toContain('counter-probe failed'); + }); + + it('refuses a verdict when the LINT test itself has gone blind', async () => { + // The linter half's failure mode is the silent one: the assertion on it is + // negative, so a regex that stopped matching the emitted ids is + // indistinguishable from a clean bundle. + const config = await loadConsoleConfig(); + const message = runLazyLinterProbe(config, consoleShapedBundle(INSTALLED_SPEC_MODULE_ID, null)); + expect(message).toContain('counter-probe failed'); + expect(message).toContain('no chunk at all'); + }); +}); diff --git a/ui/scripts/__tests__/console-optimize-deps.test.ts b/ui/scripts/__tests__/console-optimize-deps.test.ts new file mode 100644 index 00000000000..f03a3f54163 --- /dev/null +++ b/ui/scripts/__tests__/console-optimize-deps.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest'; +import { resolveConfig } from 'vite'; +import type { InlineConfig } from 'vite'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * objectui#10865 — every `optimizeDeps.include` entry of the console config + * resolves the way `pnpm dev` resolves it. + * + * Moved here from `vite-objectstack-spec-dist.test.ts` when that module's + * `OBJECTSTACK_SPEC_DIST` injection left with the objectstack pin + * (objectstack#25711): this case is about the console config, not the + * injection, and it is kept as it was. + */ + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +/** The spec package, the subject of the "aborts" control below. */ +const SPEC_PACKAGE_NAME = '@objectstack/spec'; + +/** + * A package the console certainly resolves from its own root: the control that + * proves the dev resolver resolves anything at all. + */ +const VITE_ORACLE_CONTROL = 'react'; + +async function loadConsoleConfig(query = ''): Promise { + const specifier = `../../apps/console/vite.config.ts${query}`; + return (await import(/* @vite-ignore */ specifier)).default; +} + +/** The directory `pnpm dev` runs Vite in, and therefore the root it resolves from. */ +const consoleRoot = path.join(repoRoot, 'apps/console'); + +/** A package on no `node_modules` walk from the console root: the "skipped" control. */ +const ABSENT_PACKAGE_CONTROL = 'objectui-10865-no-such-package'; + +/** + * A subpath the spec's exports map does not declare: the "aborts" control. It is + * the same shape as the bare `react-map-gl` entry, on a package whose map this + * file already reconciles. + */ +const UNEXPORTED_SUBPATH_CONTROL = `${SPEC_PACKAGE_NAME}/objectui-10865-no-such-subpath`; + +type IncludeVerdict = { kind: 'resolved'; id: string } | { kind: 'skipped' } | { kind: 'aborts'; message: string }; + +/** + * How the dev server treats each `optimizeDeps.include` entry. The answer comes + * from Vite itself, not from a transcription of its algorithm. + * + * The list is read by `vite` (dev) only. `vite build` never reads it, so no + * build, E2E or type-check job can see a bad entry. In the Vite this was written + * against (8.2), `addManuallyIncludedOptimizeDeps` resolves every entry before + * the server listens, through `createOptimizeDepsIncludeResolver`. For the + * client environment that resolver is `config.createResolver({ asSrc: false, + * scan: true })`, called with NO importer, so it resolves from the root. The + * deprecated `createResolver` is used here on purpose: Vite's own include + * resolver calls it. + * + * Two outcomes are defects, and they fail differently: + * + * - **aborts**: the package is found, but its exports map has no such subpath. + * The resolver throws, and `vite` exits 1 before it listens. This is + * objectui#10865, measured with a bare `react-map-gl` entry: react-map-gl 8 + * exports no `.`. + * - **skipped**: the package cannot be reached from the root. Vite logs + * `Failed to resolve dependency` and pre-bundles nothing for the entry. This + * was the `maplibre-gl` entry objectui#10865 removed. + */ +async function includeVerdicts(config: InlineConfig, specifiers: string[]): Promise> { + const resolved = await resolveConfig( + { ...config, root: consoleRoot, configFile: false, envFile: false, logLevel: 'silent' }, + 'serve', + 'development' + ); + const resolve = resolved.createResolver({ asSrc: false, scan: true }); + const verdicts = new Map(); + for (const specifier of specifiers) { + try { + const id = await resolve(specifier); + verdicts.set(specifier, id ? { kind: 'resolved', id } : { kind: 'skipped' }); + } catch (error) { + verdicts.set(specifier, { kind: 'aborts', message: String((error as Error).message).split('\n')[0] }); + } + } + return verdicts; +} + +describe('objectui#10865: every optimizeDeps.include entry resolves the way the dev server resolves it', () => { + it('resolves each entry from the console root under the dev conditions', async () => { + // A separate evaluation, so that whatever `resolveConfig` does to the object + // it is handed cannot reach the instance the cases above compare. + const config = await loadConsoleConfig('?optimize-deps-include=10865'); + const include = config.optimizeDeps.include as string[]; + // Anti-vacuity: a list that read as empty would "resolve" with nothing to check. + expect(include.length).toBeGreaterThan(0); + // Vite resolves nested (`dep > sub`) and glob entries by other paths, and + // this case models only flat entries. Extend it before adding either form. + expect( + include.filter((specifier) => specifier.includes('>') || specifier.includes('*')), + 'nested or glob include entries, which this case does not model' + ).toEqual([]); + + const verdicts = await includeVerdicts(config, [ + ...include, + VITE_ORACLE_CONTROL, + ABSENT_PACKAGE_CONTROL, + UNEXPORTED_SUBPATH_CONTROL, + ]); + + // Controls with a KNOWN outcome, in the SAME run. The instrument has to be + // able to report all three outcomes, or a clean result below means nothing. + expect(verdicts.get(VITE_ORACLE_CONTROL)?.kind, 'dev resolver resolved nothing: broken instrument').toBe( + 'resolved' + ); + expect(verdicts.get(ABSENT_PACKAGE_CONTROL)?.kind, 'an absent package did not read as skipped').toBe('skipped'); + expect(verdicts.get(UNEXPORTED_SUBPATH_CONTROL)?.kind, 'an unexported subpath did not read as aborting').toBe( + 'aborts' + ); + + const findings = include.flatMap((specifier) => { + const verdict = verdicts.get(specifier)!; + if (verdict.kind === 'aborts') return [`${specifier}: aborts the dev server start (${verdict.message})`]; + if (verdict.kind === 'skipped') { + return [`${specifier}: not reachable from apps/console, so it is skipped with a warning and pre-bundles nothing`]; + } + return []; + }); + expect(findings, 'optimizeDeps.include entries the dev server cannot resolve (objectui#10865)').toEqual([]); + }); +}); diff --git a/ui/scripts/__tests__/objectstack-floor.test.ts b/ui/scripts/__tests__/objectstack-floor.test.ts deleted file mode 100644 index afb56c1950d..00000000000 --- a/ui/scripts/__tests__/objectstack-floor.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -/** - * objectui#12958 — `scripts/objectstack-floor.mjs`. The card's three pins run through REAL - * git over a throwaway objectstack-shaped history with annotated release tags; npm is a - * recorded version list. No network. - */ -import { afterAll, describe, expect, it, vi } from 'vitest'; -import { spawnSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { compareVersions, computeFloor, floorOfRange, installedVersion, main, planRanges, releaseTags } from '../objectstack-floor.mjs'; - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'objectstack-floor-test-')); -afterAll(() => fs.rmSync(scratch, { recursive: true, force: true })); - -const env = { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' }; -const git = (cwd: string, ...args: string[]) => spawnSync('git', args, { cwd, encoding: 'utf8', env }).stdout.trim(); - -// c1 - c2 [next.0] - c3 - c4 [next.1] - c5 [next.2: tagged, never on npm] - c6 -const upstream = fs.mkdtempSync(path.join(scratch, 'objectstack-')); -git(upstream, 'init', '-q'); -const sha: Record = {}; -for (const c of ['c1', 'c2', 'c3', 'c4', 'c5', 'c6']) { - git(upstream, 'commit', '-q', '--allow-empty', '-m', c); - sha[c] = git(upstream, 'rev-parse', 'HEAD'); -} -for (const [c, v] of [['c2', '1.0.0-next.0'], ['c4', '1.0.0-next.1'], ['c5', '1.0.0-next.2']]) git(upstream, 'tag', '-a', '-m', v, `@objectstack/spec@${v}`, sha[c]); -const published = ['0.9.0', '1.0.0-next.0', '1.0.0-next.1']; -const npm = (args: string[]) => (args[2] === 'versions' ? published : published.find((v) => args[1].endsWith(`@${v}`))); - -describe('the floor is the earliest published release whose tag contains the pin (objectui#12958)', () => { - it.each([ - ['inside a release', 'c3', '1.0.0-next.1'], - ['exactly a release commit', 'c4', '1.0.0-next.1'], - ['newer than every published release (its tag is not on npm)', 'c5', null], - ['newer than every tag', 'c6', null], - ])('pin %s', (_, c, floor) => { - expect(sha[c]).toMatch(/^[0-9a-f]{40}$/); - const tags = releaseTags(fs.mkdtempSync(path.join(scratch, 'fetch-')), upstream, sha[c]); - expect(computeFloor({ published, ...tags })).toEqual({ floor, newest: '1.0.0-next.1' }); - }); - - it('orders by semver precedence, prereleases included', () => { - const ordered = ['0.9.0', '1.0.0-alpha', '1.0.0-next.2', '1.0.0-next.10', '1.0.0', '1.1.0']; - expect([...ordered].reverse().sort(compareVersions)).toEqual(ordered); - expect(() => compareVersions('1.0.0+build', '1.0.0')).toThrow(); - }); -}); - -describe('the step, end to end', () => { - function fixture(pin: string, spec = '^1.0.0-next.0') { - const root = fs.mkdtempSync(path.join(scratch, 'root-')); - const files: Record = { - '.objectstack-sha': `${pin}\n`, - '.changeset/config.json': JSON.stringify({ fixed: [['@x/a']] }), - 'packages/a/package.json': JSON.stringify({ name: '@x/a', version: '2.0.0', dependencies: { '@objectstack/spec': spec, react: '^19.0.0' }, devDependencies: { '@objectstack/lint': '^1.0.0-next.2' } }), - 'packages/a/CHANGELOG.md': '# @x/a\n\n## 2.0.0\n\n### Minor Changes\n\n- abc123: a change\n', - }; - for (const [file, text] of Object.entries(files)) { - fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); - fs.writeFileSync(path.join(root, file), text); - } - const read = () => ['package.json', 'CHANGELOG.md'].map((f) => fs.readFileSync(path.join(root, 'packages/a', f), 'utf8')); - return { root, read }; - } - const run = (root: string, argv: string[] = []) => { - const spies = [vi.spyOn(console, 'log'), vi.spyOn(console, 'error')].map((s) => s.mockImplementation(() => {})); - const code = main({ root, argv, env: { OBJECTSTACK_REPO_URL: upstream }, npm }); - const out = spies.flatMap((s) => s.mock.calls).join('\n'); - spies.forEach((s) => s.mockRestore()); - return { code, out }; - }; - - it('moves a range below the floor, keeps one at or above it, and records the move in CHANGELOG.md', () => { - const { root, read } = fixture(sha.c3); - expect(run(root).code).toBe(0); - const [manifest, changelog] = read(); - expect(JSON.parse(manifest)).toMatchObject({ - dependencies: { '@objectstack/spec': '^1.0.0-next.1', react: '^19.0.0' }, - devDependencies: { '@objectstack/lint': '^1.0.0-next.2' }, - }); - expect(changelog).toMatch( - /^# @x\/a\n\n## 2\.0\.0\n\n### Dependency floors\n\n.*`1\.0\.0-next\.1`\.\n\n- `@objectstack\/spec` \(`dependencies`\): `\^1\.0\.0-next\.0` → `\^1\.0\.0-next\.1`\n\n### Minor Changes\n/, - ); - }); - - it('stops with exit 2 when no published release contains the pin, writing nothing', () => { - const { root, read } = fixture(sha.c6); - const before = read(); - const { code, out } = run(root); - expect(code).toBe(2); - expect(out).toContain(`the pin ${sha.c6}`); - expect(out).toContain('the newest published release is 1.0.0-next.1'); - expect(read()).toEqual(before); - }); - - it('refuses a range it cannot read, and --dry-run reports without writing', () => { - const unreadable = fixture(sha.c3, 'latest'); - const before = unreadable.read(); - expect(run(unreadable.root).code).toBe(1); - expect(unreadable.read()).toEqual(before); - const dry = fixture(sha.c4); - const pristine = dry.read(); - expect(run(dry.root, ['--dry-run'])).toMatchObject({ code: 0, out: expect.stringContaining('^1.0.0-next.0 → ^1.0.0-next.1') }); - expect(dry.read()).toEqual(pristine); - }); - - it('reads a workspace: range as the workspace package\'s own version (objectstack ruling D1)', () => { - // Inside the objectstack monorepo the ranges are `workspace:^`, which publishes as - // `^`; the floor is that version, resolved per name. - const own = (name: string) => (name === '@objectstack/spec' ? '1.0.0-next.1' : null); - expect(floorOfRange('workspace:^', '@objectstack/spec', own)).toBe('1.0.0-next.1'); - expect(floorOfRange('workspace:*', '@objectstack/spec', own)).toBe('1.0.0-next.1'); - expect(floorOfRange('workspace:~', '@objectstack/spec', own)).toBe('1.0.0-next.1'); - expect(floorOfRange('workspace:^1.0.0-next.0', '@objectstack/spec', own)).toBe('1.0.0-next.0'); - // A workspace package this tree does not resolve, or a resolver that answers nothing, is UNREADABLE, never 0.0.0. - expect(floorOfRange('workspace:^', '@objectstack/lint', own)).toBeNull(); - expect(floorOfRange('workspace:^', '@objectstack/spec', () => 'latest')).toBeNull(); - // Every semver spelling reads as before. - expect(floorOfRange('^1.0.0-next.0', '@objectstack/spec', own)).toBe('1.0.0-next.0'); - expect(floorOfRange('latest', '@objectstack/spec', own)).toBeNull(); - - const pkg = { name: '@x/a', dir: 'packages/a', manifest: { dependencies: { '@objectstack/spec': 'workspace:^', '@objectstack/lint': '^1.0.0-next.2' } } }; - const kept = planRanges([pkg], '1.0.0-next.1', { workspaceVersionOf: own }); - expect(kept.map((r) => [r.name, r.action, r.to])).toEqual([['@objectstack/spec', 'kept', 'workspace:^'], ['@objectstack/lint', 'kept', '^1.0.0-next.2']]); - const below = planRanges([pkg], '1.0.0-next.2', { workspaceVersionOf: own }); - expect(below.map((r) => [r.name, r.action, r.to])).toEqual([['@objectstack/spec', 'moved', '^1.0.0-next.2'], ['@objectstack/lint', 'kept', '^1.0.0-next.2']]); - // The default resolver knows no workspace: the range is unreadable, as before this change. - expect(planRanges([pkg], '1.0.0-next.1').map((r) => r.action)).toEqual(['unreadable', 'kept']); - }); - - it('resolves a workspace package\'s version from the nearest node_modules, end to end', () => { - const { root, read } = fixture(sha.c3, 'workspace:^'); - // Nothing installed: the range cannot be read, nothing is written. - const before = read(); - expect(run(root).code).toBe(1); - expect(read()).toEqual(before); - expect(installedVersion(root, '@objectstack/spec')).toBeNull(); - // The workspace spec at the floor: kept. - fs.mkdirSync(path.join(root, 'node_modules/@objectstack/spec'), { recursive: true }); - fs.writeFileSync(path.join(root, 'node_modules/@objectstack/spec/package.json'), JSON.stringify({ name: '@objectstack/spec', version: '1.0.0-next.1' })); - expect(installedVersion(root, '@objectstack/spec')).toBe('1.0.0-next.1'); - expect(installedVersion(path.join(root, 'packages/a'), '@objectstack/spec')).toBe('1.0.0-next.1'); - const { code, out } = run(root); - expect(code).toBe(0); - expect(out).toContain('kept'); - expect(JSON.parse(read()[0]).dependencies['@objectstack/spec']).toBe('workspace:^'); - }); - - it('runs first in `pnpm changeset:publish`, and between the build and the scan nightly', () => { - const publish = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')).scripts['changeset:publish']; - expect(publish).toMatch(/^node scripts\/objectstack-floor\.mjs && .*check-spec-range-floors\.mjs && changeset publish$/); - const nightly = fs.readFileSync(path.join(repoRoot, '.github/workflows/spec-range-floors.yml'), 'utf8'); - const step = nightly.indexOf('\n node scripts/objectstack-floor.mjs || code=$?\n'); - expect(step).toBeGreaterThan(nightly.indexOf('run: pnpm exec turbo run build')); - expect(step).toBeLessThan(nightly.indexOf('run: pnpm check:spec-floors')); - }); -}); diff --git a/ui/scripts/__tests__/objectstack-pin.test.ts b/ui/scripts/__tests__/objectstack-pin.test.ts deleted file mode 100644 index 93548ff3b05..00000000000 --- a/ui/scripts/__tests__/objectstack-pin.test.ts +++ /dev/null @@ -1,695 +0,0 @@ -/** - * objectui#12957 — `scripts/objectstack-pin.mjs`, the pinned build of `@objectstack/*`. - * - * Three layers: - * 1. the contract readers and the override-set check, on literals — every - * refusal paired with the accepted shape beside it; - * 2. the REAL tree: since the import into objectstack (objectstack#25803) every - * `@objectstack/*` name the workspace declares is a workspace member by - * `workspace:` protocol, nothing is overridden, and no pin manifest is - * committed, so none can resolve from npm; - * 3. the stand-in path end to end (`OBJECTSTACK_ROOT`), over a throwaway - * objectstack-shaped workspace packed by the real `pnpm pack` — the input - * objectstack#24306 consumes. No network: nothing is fetched or installed. - */ -import { afterAll, describe, expect, it, vi } from 'vitest'; -import { spawnSync } from 'node:child_process'; -import crypto from 'node:crypto'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { - CACHE_DIR, - ENTRY_FILE, - OVERRIDE_PREFIX, - PIN_DIR, - PinRefusal, - RECIPE, - STAMP_FILE, - STORE_ENV, - build, - canonicalManifest, - check, - checkOverrideSet, - checkoutPin, - declaredObjectstackNames, - dependencyDrift, - entryTargets, - hashTree, - judgeStamp, - lockfileDependencyNames, - materialize, - objectstackEnv, - publishToStore, - pruneStore, - storeDir, - storeKey, - takeFromStore, - annotation, - tailLines, - readOverrides, - readPin, - runtimeClosure, - workspaceGlobs, -} from '../objectstack-pin.mjs'; -import { lockfilePath, rootManifest, workspaceGlobs as uiWorkspaceGlobs, workspaceManifestPath } from '../workspace-root.mjs'; - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const SHA = '0123456789abcdef0123456789abcdef01234567'; - -// Anchored on the OS temp directory at module scope, so no path below can be -// read as relative to the process cwd (`pnpm check:test-path-roots`). -const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'objectstack-pin-test-')); -// No build in this file reaches the machine's real store (objectui#12972), whatever a regression does. -vi.stubEnv(STORE_ENV, path.join(scratch, 'machine-store')); -afterAll(() => { - vi.unstubAllEnvs(); - fs.rmSync(scratch, { recursive: true, force: true }); -}); - -function writeJson(file: string, value: unknown) { - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); -} - -const override = (dir: string) => `${OVERRIDE_PREFIX}${dir}`; - -describe('the contract readers (objectui#12957)', () => { - it('readPin takes one full sha with or without a trailing newline, and refuses anything else', () => { - const dir = fs.mkdtempSync(path.join(scratch, 'pin-')); - const pinFile = path.join(dir, '.objectstack-sha'); - fs.writeFileSync(pinFile, `${SHA}\n`); - expect(readPin(dir)).toBe(SHA); - fs.writeFileSync(pinFile, SHA); - expect(readPin(dir)).toBe(SHA); - for (const bad of [`${SHA.slice(0, 12)}\n`, `${SHA}\n\n`, `${SHA.toUpperCase()}\n`, ` ${SHA}\n`, '']) { - fs.writeFileSync(pinFile, bad); - expect(() => readPin(dir), JSON.stringify(bad)).toThrow(PinRefusal); - } - fs.rmSync(pinFile); - expect(() => readPin(dir)).toThrow(/is missing/); - }); - - it('readOverrides accepts only the pinned-build form for @objectstack/* and ignores other overrides', () => { - const ok = readOverrides({ pnpm: { overrides: { react: '19.2.8', '@objectstack/spec': override('spec') } } }); - expect([...ok]).toEqual([['@objectstack/spec', 'spec']]); - expect(() => readOverrides({ pnpm: { overrides: { '@objectstack/spec': '^18.0.0-next.3' } } })).toThrow(/must be "file:\.objectstack-pin\/spec"/); - expect(() => readOverrides({ pnpm: { overrides: { '@objectstack/spec': override('core') } } })).toThrow(PinRefusal); - expect(() => readOverrides({ pnpm: { overrides: { '@objectstack/spec@<18': override('spec') } } })).toThrow(/no version selector/); - expect(() => readOverrides({ pnpm: { overrides: { react: '19.2.8' } } })).toThrow(/no @objectstack\/\* override/); - }); - - it('workspaceGlobs reads the packages list only, through quotes and comments', () => { - const yaml = "packages:\n - 'packages/*'\n - \"apps/*\" # apps\n - docs\n\nonlyBuiltDependencies:\n - esbuild\n"; - expect(workspaceGlobs(yaml)).toEqual(['packages/*', 'apps/*', 'docs']); - }); - - it('declaredObjectstackNames reads every dependency field, and nothing outside the scope', () => { - const names = declaredObjectstackNames([ - { manifest: { dependencies: { '@objectstack/spec': '^1', react: '1' } } }, - { manifest: { devDependencies: { '@objectstack/lint': '^1' }, peerDependencies: { '@objectstack/client': '^1' } } }, - { manifest: { optionalDependencies: { '@objectstack/formula': '^1' } } }, - ]); - expect([...names].sort()).toEqual(['@objectstack/client', '@objectstack/formula', '@objectstack/lint', '@objectstack/spec']); - }); - - it('runtimeClosure follows runtime edges and NOT devDependencies', () => { - const p = (deps: Record, dev: Record = {}) => ({ dir: 'x', manifest: { dependencies: deps, devDependencies: dev } }); - const byName = new Map>([ - ['@objectstack/spec', p({ zod: '^4' })], - ['@objectstack/types', p({ '@objectstack/spec': 'workspace:*' })], - ['@objectstack/core', p({ '@objectstack/spec': 'workspace:*', '@objectstack/types': 'workspace:*' }, { '@objectstack/runtime': 'workspace:*' })], - ['@objectstack/client', p({ '@objectstack/core': 'workspace:*' })], - ['@objectstack/runtime', p({})], - ]); - expect([...runtimeClosure(byName, ['@objectstack/client'])].sort()).toEqual([ - '@objectstack/client', - '@objectstack/core', - '@objectstack/spec', - '@objectstack/types', - ]); - expect(() => runtimeClosure(byName, ['@objectstack/nope'])).toThrow(/not a package of the objectstack workspace/); - }); - - it('checkOverrideSet requires the overrides to EQUAL the closure of what objectui declares', () => { - const byName = new Map([ - ['@objectstack/spec', { dir: 'packages/spec', manifest: {} }], - ['@objectstack/types', { dir: 'packages/types', manifest: { dependencies: { '@objectstack/spec': 'workspace:*' } } }], - ['@objectstack/core', { dir: 'packages/core', manifest: { dependencies: { '@objectstack/types': 'workspace:*' } } }], - ]); - const all = new Map([['@objectstack/spec', 'spec'], ['@objectstack/types', 'types'], ['@objectstack/core', 'core']]); - expect(checkOverrideSet({ overrides: all, declared: new Set(['@objectstack/core']), byName })).toEqual([ - '@objectstack/core', - '@objectstack/spec', - '@objectstack/types', - ]); - // A declared package with no override would resolve from npm. - expect(() => - checkOverrideSet({ overrides: new Map([['@objectstack/spec', 'spec']]), declared: new Set(['@objectstack/core']), byName }), - ).toThrow(/declares @objectstack\/core but the root package\.json does not override it/); - // A package the closure needs with no override would resolve from npm. - expect(() => - checkOverrideSet({ overrides: new Map([['@objectstack/core', 'core'], ['@objectstack/spec', 'spec']]), declared: new Set(['@objectstack/core']), byName }), - ).toThrow(/need @objectstack\/types at this objectstack commit/); - // An override nothing reaches is refused too. - expect(() => - checkOverrideSet({ overrides: all, declared: new Set(['@objectstack/spec']), byName }), - ).toThrow(/names @objectstack\/core, @objectstack\/types, which nothing objectui uses reaches/); - }); - - it('entryTargets collects main/module/types and every non-pattern exports leaf', () => { - expect( - entryTargets({ - main: './dist/index.cjs', - types: 'dist/index.d.ts', - exports: { - '.': { types: './dist/index.d.ts', import: './dist/index.mjs', require: './dist/index.cjs' }, - './openapi.json': './json-schema/openapi.json', - './*': './dist/*.mjs', - }, - }), - ).toEqual(['dist/index.cjs', 'dist/index.d.ts', 'dist/index.mjs', 'json-schema/openapi.json']); - }); - - it('judgeStamp trusts a cache only when it is the pin, this recipe, and these packages', () => { - const root = fs.mkdtempSync(path.join(scratch, 'stamp-')); - writeJson(path.join(root, CACHE_DIR, 'spec/package.json'), { name: '@objectstack/spec' }); - const overrides = new Map([['@objectstack/spec', 'spec']]); - const good = { recipe: RECIPE, source: 'pin', sha: SHA, packages: { '@objectstack/spec': '1.0.0' } }; - expect(judgeStamp(good, { pin: SHA, overrides, root }).current).toBe(true); - expect(judgeStamp(null, { pin: SHA, overrides, root }).current).toBe(false); - expect(judgeStamp({ ...good, sha: 'f'.repeat(40) }, { pin: SHA, overrides, root }).why).toMatch(/the pin is 0123456789ab/); - expect(judgeStamp({ ...good, recipe: RECIPE + 1 }, { pin: SHA, overrides, root }).current).toBe(false); - expect(judgeStamp({ ...good, source: 'external', root: '/x' }, { pin: SHA, overrides, root }).why).toMatch(/stand-in/); - expect(judgeStamp(good, { pin: SHA, overrides: new Map([...overrides, ['@objectstack/core', 'core']]), root }).current).toBe(false); - fs.rmSync(path.join(root, CACHE_DIR, 'spec'), { recursive: true }); - expect(judgeStamp(good, { pin: SHA, overrides, root }).why).toMatch(/does not hold @objectstack\/spec/); - }); - - it('hashTree moves with any byte and ignores the stamp itself', () => { - const dir = fs.mkdtempSync(path.join(scratch, 'hash-')); - fs.mkdirSync(path.join(dir, 'spec/dist'), { recursive: true }); - fs.writeFileSync(path.join(dir, 'spec/dist/index.mjs'), 'a'); - const first = hashTree(dir); - fs.writeFileSync(path.join(dir, STAMP_FILE), '{}'); - expect(hashTree(dir)).toBe(first); - fs.writeFileSync(path.join(dir, 'spec/dist/index.mjs'), 'b'); - expect(hashTree(dir)).not.toBe(first); - }); -}); - -describe('the lockfile must record what the build declares (objectui#12957)', () => { - // A frozen install links a directory dependency's files but resolves its - // dependencies from the lockfile alone — measured: a manifest given a dependency - // the lockfile lacks installed with exit 0 and without it. - const LOCK = [ - 'lockfileVersion: \'9.0\'', - '', - 'snapshots:', - '', - " '@objectstack/core@file:.objectstack-pin/core(ai@7.0.65(zod@4.6.7))':", - ' dependencies:', - " '@objectstack/spec': file:.objectstack-pin/spec(ai@7.0.65(zod@4.6.7))", - ' zod: 4.6.7', - ' transitivePeerDependencies:', - ' - ai', - '', - " '@objectstack/spec@file:.objectstack-pin/spec(ai@7.0.65(zod@4.6.7))':", - ' dependencies:', - ' zod: 4.6.7', - ' optionalDependencies:', - ' ai: 7.0.65(zod@4.6.7)', - '', - " '@objectstack/sdui-parser@file:.objectstack-pin/sdui-parser': {}", - '', - ' zod@4.6.7: {}', - '', - ].join('\n'); - - it('reads the recorded names per pinned-build package, and nothing else', () => { - const recorded = lockfileDependencyNames(LOCK); - expect([...recorded.keys()].sort()).toEqual(['@objectstack/core', '@objectstack/sdui-parser', '@objectstack/spec']); - expect([...recorded.get('@objectstack/core')!].sort()).toEqual(['@objectstack/spec', 'zod']); - expect([...recorded.get('@objectstack/spec')!].sort()).toEqual(['ai', 'zod']); - expect(recorded.get('@objectstack/sdui-parser')!.size).toBe(0); - }); - - it('agrees with manifests that declare what it records — a peer the lockfile installs included', () => { - const manifests = new Map>([ - ['@objectstack/core', { dependencies: { '@objectstack/spec': '18.0.0', zod: '^4.6.5' } }], - ['@objectstack/spec', { dependencies: { zod: '^4.6.5' }, peerDependencies: { ai: '^7.0.0' } }], - ['@objectstack/sdui-parser', {}], - ]); - expect(dependencyDrift(lockfileDependencyNames(LOCK), manifests)).toEqual([]); - }); - - it('names a dependency the lockfile lacks, one it records that nothing declares, and a package it has no entry for', () => { - const manifests = new Map>([ - ['@objectstack/core', { dependencies: { '@objectstack/spec': '18.0.0', zod: '^4.6.5', 'left-pad': '^1.3.0' } }], - ['@objectstack/spec', { peerDependencies: { ai: '^7.0.0' } }], - ['@objectstack/types', { dependencies: { '@objectstack/spec': '18.0.0' } }], - ]); - expect(dependencyDrift(lockfileDependencyNames(LOCK), manifests)).toEqual([ - { name: '@objectstack/core', missing: ['left-pad'], extra: [], absent: false }, - { name: '@objectstack/spec', missing: [], extra: ['zod'], absent: false }, - { name: '@objectstack/types', missing: ['@objectstack/spec'], extra: [], absent: true }, - ]); - }); -}); - -describe('the committed manifests, and the build beside them (objectui#13008)', () => { - const sha256 = (text: string) => `sha256-${crypto.createHash('sha256').update(text).digest('hex')}`; - const PACKED = `${JSON.stringify({ name: '@objectstack/spec', version: '1.0.0', exports: { '.': './dist/index.mjs' }, dependencies: { a: '1', b: '1' } }, null, 2)}\n`; - const overrides = new Map([['@objectstack/spec', 'spec']]); - /** A packed build to link from, and a root whose committed manifest is `committed` (absent when null). */ - function layout(name: string, committed: string | null, packed = PACKED) { - const base = fs.mkdtempSync(path.join(scratch, `${name}-`)); - const from = path.join(base, 'build'); - fs.mkdirSync(path.join(from, 'spec/dist'), { recursive: true }); - fs.writeFileSync(path.join(from, 'spec/package.json'), packed); - fs.writeFileSync(path.join(from, 'spec/dist/index.mjs'), 'export {};\n'); - const root = path.join(base, 'objectui'); - writeJson(path.join(root, 'package.json'), { name: 'fake-objectui', pnpm: { overrides: { '@objectstack/spec': override('spec') } } }); - if (committed !== null) { - fs.mkdirSync(path.join(root, PIN_DIR, 'spec'), { recursive: true }); - fs.writeFileSync(path.join(root, PIN_DIR, 'spec/package.json'), committed); - } - return { from, root, target: path.join(root, PIN_DIR, 'spec') }; - } - - it('links the build beside a manifest that is the pin\'s, and the install check passes only then', () => { - const { from, root, target } = layout('equal', PACKED); - expect(() => check({ root })).toThrow(PinRefusal); - expect(() => check({ root }), 'the refusal names the hollow package').toThrow(/@objectstack\/spec/); - fs.mkdirSync(path.join(target, 'stale')); - expect(materialize(root, overrides, { from, mode: 'pin' })).toEqual({ '@objectstack/spec': sha256(PACKED) }); - // A hard link: pnpm links these same files at install time. - expect(fs.statSync(path.join(target, 'dist/index.mjs')).ino).toBe(fs.statSync(path.join(from, 'spec/dist/index.mjs')).ino); - expect(fs.readdirSync(target).sort(), 'what an older build left is gone').toEqual(['dist', 'package.json']); - expect(() => check({ root })).not.toThrow(); - }); - - it('REFUSES at the pin a committed manifest that differs or is missing, and writes nothing', () => { - const { from, root, target } = layout('differs', PACKED.replace('1.0.0', '0.9.0')); - expect(() => materialize(root, overrides, { from, mode: 'pin' })).toThrow(PinRefusal); - expect(() => materialize(root, overrides, { from, mode: 'pin' }), 'the refusal names the manifest').toThrow(/\.objectstack-pin\/spec\/package\.json/); - expect(fs.readdirSync(target)).toEqual(['package.json']); - expect(fs.readFileSync(path.join(target, 'package.json'), 'utf8')).toContain('0.9.0'); - const missing = layout('missing', null); - expect(() => materialize(missing.root, overrides, { from: missing.from, mode: 'pin' })).toThrow(PinRefusal); - expect(fs.existsSync(missing.target)).toBe(false); - }); - - it('compares manifests with dependency maps sorted -- pnpm pack orders them by completion -- and nothing else', () => { - const reordered = PACKED.replace('"a": "1",\n "b": "1"', '"b": "1",\n "a": "1"'); - expect(reordered, 'the reorder landed').not.toBe(PACKED); - expect(canonicalManifest(reordered)).toBe(PACKED); - // Equal in canonical form: accepted at the pin, and the committed bytes are left alone. - const { from, root, target } = layout('reordered', reordered); - materialize(root, overrides, { from, mode: 'pin' }); - expect(fs.readFileSync(path.join(target, 'package.json'), 'utf8')).toBe(reordered); - // The order of exports conditions is meaning, not noise. - const conditions = (first: string, second: string) => `${JSON.stringify({ name: 'x', exports: { '.': { [first]: './a', [second]: './b' } } })}\n`; - expect(canonicalManifest(conditions('types', 'import'))).not.toBe(canonicalManifest(conditions('import', 'types'))); - }); - - it('writes the packed manifest with --write-manifests and for a stand-in, and the pin restores exactly what a stand-in replaced', () => { - const { from, root, target } = layout('write', PACKED.replace('1.0.0', '0.9.0')); - materialize(root, overrides, { from, mode: 'write' }); - expect(fs.readFileSync(path.join(target, 'package.json'), 'utf8')).toBe(PACKED); - // A stand-in replaces the committed manifest with its own and reports what was there. - const standIn = layout('stand-in', PACKED, PACKED.replace('1.0.0', '2.0.0-dev')); - const replaced = materialize(standIn.root, overrides, { from: standIn.from, mode: 'stand-in' }); - expect(fs.readFileSync(path.join(standIn.target, 'package.json'), 'utf8')).toContain('2.0.0-dev'); - // Back at the pin: the stand-in's manifest differs from the pin's, and the pin's is the one it replaced. - const pin = layout('pin-again', null); - expect(() => materialize(standIn.root, overrides, { from: pin.from, mode: 'pin' }), 'without the record it is drift').toThrow(PinRefusal); - materialize(standIn.root, overrides, { from: pin.from, mode: 'pin', replaced }); - expect(fs.readFileSync(path.join(standIn.target, 'package.json'), 'utf8')).toBe(PACKED); - // ...and nothing else: a record of other bytes does not let a different manifest through. - const other = layout('other', PACKED.replace('1.0.0', '0.9.0')); - expect(() => materialize(other.root, overrides, { from: other.from, mode: 'pin', replaced: { '@objectstack/spec': sha256('{}') } })).toThrow(PinRefusal); - }); -}); - -describe('the objectstack tree is configured by its own .npmrc (objectui#12957)', () => { - it('drops this repository\'s npm_config_* settings and keeps the machine\'s network ones', () => { - const env = objectstackEnv({ - PATH: '/bin', - npm_config_engine_strict: 'true', - npm_config_auto_install_peers: 'true', - npm_config_https_proxy: 'http://proxy', - npm_config_noproxy: 'localhost', - npm_config_registry: 'https://registry.npmjs.org/', - HTTPS_PROXY: 'http://proxy', - }); - expect(Object.keys(env).sort()).toEqual(['HTTPS_PROXY', 'PATH', 'npm_config_https_proxy', 'npm_config_noproxy', 'npm_config_registry']); - }); -}); - -describe('a refusal reaches the checks API (objectui#12957)', () => { - it('encodes a multi-line refusal as one GitHub error annotation', () => { - expect(annotation('a 100% fail\nline two\r')).toBe('::error title=objectstack-pin::a 100%25 fail%0Aline two%0D'); - }); - - it('keeps the last lines of a command\'s output', () => { - expect(tailLines('1\n2\n3\n4\n', 2)).toBe('3\n4'); - }); -}); - -describe('the pinned checkout is hermetic (objectui#12957)', () => { - it('fetches exactly the pinned commit and then drops the remote, so no build script can read a moving main', () => { - const git = (cwd: string, ...args: string[]) => { - const r = spawnSync('git', args, { - cwd, - encoding: 'utf8', - env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@example.invalid', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@example.invalid' }, - }); - if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`); - return r.stdout.trim(); - }; - const upstream = path.join(scratch, 'hermetic-upstream'); - fs.mkdirSync(upstream, { recursive: true }); - git(upstream, 'init', '-q', '-b', 'main'); - git(upstream, 'config', 'uploadpack.allowAnySHA1InWant', 'true'); - fs.writeFileSync(path.join(upstream, 'a.txt'), 'pinned\n'); - git(upstream, 'add', '.'); - git(upstream, 'commit', '-qm', 'pinned'); - const pinned = git(upstream, 'rev-parse', 'HEAD'); - fs.writeFileSync(path.join(upstream, 'a.txt'), 'later on main\n'); - git(upstream, 'commit', '-qam', 'later'); - - const dir = path.join(scratch, 'hermetic-checkout'); - checkoutPin(dir, pinned, `file://${upstream}`, path.join(scratch, 'hermetic-fetch.log')); - expect(git(dir, 'rev-parse', 'HEAD')).toBe(pinned); - expect(fs.readFileSync(path.join(dir, 'a.txt'), 'utf8')).toBe('pinned\n'); - expect(git(dir, 'remote')).toBe(''); - }); -}); - -/** - * The real tree, since objectui moved into the objectstack repository under `ui/` - * (objectstack#25803). Every `@objectstack/*` package is a member of the same pnpm - * workspace there, so the pinned build is not in force: nothing is overridden, no - * pin manifest is committed, and what kept objectui off npm is the `workspace:` - * protocol. These are the two facts that replace the override-set cases, read off - * the same tree; the readers above stay pinned on literals for the script itself. - */ -describe('the real tree (objectui#12957, objectstack#25803)', () => { - /** The objectui workspace projects, the ui root included, read through workspace-root.mjs. */ - function uiProjects(): { dir: string; manifest: Record | undefined> }[] { - const projects = [{ dir: '.', manifest: JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) }]; - for (const glob of uiWorkspaceGlobs(repoRoot)) { - const dirs = glob.endsWith('/*') - ? fs.readdirSync(path.join(repoRoot, glob.slice(0, -2))).map((name) => path.posix.join(glob.slice(0, -2), name)) - : [glob]; - for (const dir of dirs) { - const manifestPath = path.join(repoRoot, dir, 'package.json'); - if (fs.existsSync(manifestPath)) projects.push({ dir, manifest: JSON.parse(fs.readFileSync(manifestPath, 'utf8')) }); - } - } - return projects; - } - - it('declares every @objectstack/* dependency by workspace protocol, and the lockfile holds no physical copy, so none resolves from npm', () => { - const projects = uiProjects(); - const declared = declaredObjectstackNames(projects); - expect(declared.size, 'the workspace scan found no @objectstack/* declaration at all').toBeGreaterThan(0); - const offWorkspace = projects.flatMap(({ dir, manifest }) => - ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'].flatMap((field) => - Object.entries(manifest[field] ?? {}) - .filter(([name, range]) => name.startsWith('@objectstack/') && !range.startsWith('workspace:')) - .map(([name, range]) => `${dir} ${field} ${name}@${range}`), - ), - ); - expect(offWorkspace).toEqual([]); - // A `link:` importer entry has no `packages:`/`snapshots:` key; a key at two - // spaces under the scope is a registry or `file:` copy beside the workspace one. - const lock = fs.readFileSync(lockfilePath(repoRoot), 'utf8'); - expect(lock.match(/^ {2}'?@objectstack\/[^\s']+/gm) ?? []).toEqual([]); - }); - - it('pins one full objectstack sha', () => { - expect(readPin(repoRoot)).toMatch(/^[0-9a-f]{40}$/); - }); - - it('overrides no @objectstack/* package and commits no pin manifest (objectui#13008, objectstack#25803)', () => { - expect(() => readOverrides(JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')))).toThrow(PinRefusal); - expect(() => readOverrides(rootManifest(repoRoot))).toThrow(PinRefusal); - // The monorepo keeps its overrides in pnpm-workspace.yaml, not in a `pnpm` field. - const overridesBlock = fs.readFileSync(workspaceManifestPath(repoRoot), 'utf8').match(/^overrides:\n((?:[ \t]+.*\n|[ \t]*\n)*)/m); - expect(overridesBlock, 'the workspace root declares no overrides block at all').not.toBeNull(); - expect(overridesBlock![1].match(/^\s+'?@objectstack\/.*$/gm) ?? []).toEqual([]); - const tracked = spawnSync('git', ['ls-files', '--', PIN_DIR], { cwd: repoRoot, encoding: 'utf8' }); - expect(tracked.status).toBe(0); - expect(tracked.stdout).toBe(''); - }); -}); - -describe('OBJECTSTACK_ROOT stands an existing tree in (objectui#12957, objectstack#24306)', () => { - /** A minimal objectstack-shaped workspace: spec, and core depending on it by workspace protocol. */ - function makeObjectstack(name: string, { built = true } = {}) { - const tree = path.join(scratch, name, 'objectstack'); - writeJson(path.join(tree, 'package.json'), { name: 'fake-objectstack-root', private: true }); - fs.writeFileSync(path.join(tree, 'pnpm-workspace.yaml'), "packages:\n - 'packages/*'\n"); - writeJson(path.join(tree, 'packages/spec/package.json'), { - name: '@objectstack/spec', - version: '9.9.9-test.1', - files: ['dist'], - exports: { '.': { types: './dist/index.d.ts', import: './dist/index.mjs' } }, - dependencies: { zod: '^4.0.0' }, - }); - writeJson(path.join(tree, 'packages/core/package.json'), { - name: '@objectstack/core', - version: '9.9.9-test.1', - files: ['dist'], - exports: { '.': { import: './dist/index.mjs' } }, - dependencies: { '@objectstack/spec': 'workspace:*' }, - }); - // What `pnpm install` leaves behind: core's workspace link to spec. `pnpm pack` - // reads the linked version to rewrite `workspace:*`, so a stand-in tree is an - // INSTALLED tree (ERR_PNPM_CANNOT_RESOLVE_WORKSPACE_PROTOCOL without it). - fs.mkdirSync(path.join(tree, 'packages/core/node_modules/@objectstack'), { recursive: true }); - fs.symlinkSync('../../../spec', path.join(tree, 'packages/core/node_modules/@objectstack/spec')); - if (built) { - for (const pkg of ['spec', 'core']) { - fs.mkdirSync(path.join(tree, 'packages', pkg, 'dist'), { recursive: true }); - fs.writeFileSync(path.join(tree, 'packages', pkg, 'dist/index.mjs'), `export const name = '${pkg}';\n`); - } - fs.writeFileSync(path.join(tree, 'packages/spec/dist/index.d.ts'), 'export declare const name: string;\n'); - } - return tree; - } - - function makeObjectui(name: string) { - const root = path.join(scratch, name, 'objectui'); - writeJson(path.join(root, 'package.json'), { - name: 'fake-objectui', - private: true, - dependencies: { '@objectstack/core': '^9.0.0' }, - pnpm: { overrides: { '@objectstack/core': override('core'), '@objectstack/spec': override('spec') } }, - }); - fs.writeFileSync(path.join(root, 'pnpm-workspace.yaml'), 'packages: []\n'); - fs.writeFileSync(path.join(root, '.objectstack-sha'), `${SHA}\n`); - // What `pnpm install` records for the two packages above. - fs.writeFileSync( - path.join(root, 'pnpm-lock.yaml'), - [ - 'snapshots:', - " '@objectstack/core@file:.objectstack-pin/core':", - ' dependencies:', - " '@objectstack/spec': file:.objectstack-pin/spec", - " '@objectstack/spec@file:.objectstack-pin/spec':", - ' dependencies:', - ' zod: 4.6.7', - '', - ].join('\n'), - ); - return root; - } - - it('packs the tree into the pinned-build directories, publish-shaped, and stamps it as a stand-in', () => { - const tree = makeObjectstack('stand-in'); - const root = makeObjectui('stand-in'); - const store = path.join(scratch, 'stand-in', 'store'); - const result = build({ root, env: { ...process.env, OBJECTSTACK_ROOT: tree, [STORE_ENV]: store } }); - expect(result.skipped).toBe(false); - expect(fs.existsSync(store), 'a stand-in is not the pin: it never reaches the machine-wide store (objectui#12972)').toBe(false); - const cache = path.join(root, CACHE_DIR); - const core = JSON.parse(fs.readFileSync(path.join(cache, 'core/package.json'), 'utf8')); - expect(core.dependencies['@objectstack/spec'], 'pnpm pack rewrote the workspace protocol').toBe('9.9.9-test.1'); - expect(fs.readFileSync(path.join(cache, 'spec/dist/index.mjs'), 'utf8')).toContain("'spec'"); - const stamp = JSON.parse(fs.readFileSync(path.join(cache, STAMP_FILE), 'utf8')); - expect(stamp).toMatchObject({ recipe: RECIPE, source: 'external', root: tree, packages: { '@objectstack/core': '9.9.9-test.1', '@objectstack/spec': '9.9.9-test.1' } }); - expect(stamp.contentHash).toMatch(/^sha256-[0-9a-f]{64}$/); - // Linked beside its own manifests, where the overrides point; nothing was committed there before. - expect(JSON.parse(fs.readFileSync(path.join(root, PIN_DIR, 'core/package.json'), 'utf8'))).toEqual(core); - expect(fs.readFileSync(path.join(root, PIN_DIR, 'spec/dist/index.mjs'), 'utf8')).toContain("'spec'"); - expect(stamp.replaced).toEqual({}); - // Nothing was written into the stand-in tree. - expect(fs.readdirSync(path.join(tree, 'packages/spec')).sort()).toEqual(['dist', 'package.json']); - expect(fs.readdirSync(path.join(tree, 'packages/core')).sort()).toEqual(['dist', 'node_modules', 'package.json']); - // A stand-in is never mistaken for the pin. - expect(judgeStamp(stamp, { pin: SHA, overrides: readOverrides(JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))), root }).current).toBe(false); - }); - - it('REFUSES an unbuilt tree, names the command that builds it, and keeps the previous cache', () => { - const builtTree = makeObjectstack('unbuilt-control'); - const root = makeObjectui('unbuilt'); - build({ root, env: { ...process.env, OBJECTSTACK_ROOT: builtTree } }); - const before = fs.readFileSync(path.join(root, CACHE_DIR, STAMP_FILE), 'utf8'); - const tree = makeObjectstack('unbuilt', { built: false }); - expect(() => build({ root, env: { ...process.env, OBJECTSTACK_ROOT: tree } })).toThrow( - /@objectstack\/core is not built in .*dist\/index\.mjs.*pnpm --filter-prod @objectstack\/core\.\.\. --filter-prod @objectstack\/spec\.\.\. run build/s, - ); - expect(fs.readFileSync(path.join(root, CACHE_DIR, STAMP_FILE), 'utf8')).toBe(before); - expect(fs.readdirSync(path.dirname(path.join(root, CACHE_DIR))).filter((n) => n.includes('staging'))).toEqual([]); - }); - - it('prints a remedy that builds WITH declarations whatever OS_SKIP_DTS the shell exported', () => { - // Run verbatim under an exported OS_SKIP_DTS, a remedy without the unset builds typeless again - // and the same refusal repeats. The unset is part of the command, so it holds in both states. - const command = /\(cd [^\n]+ && env -u OS_SKIP_DTS pnpm --filter-prod @objectstack\/core\.\.\. --filter-prod @objectstack\/spec\.\.\. run build\)/; - const refusal = (name: string, skipDts: string | undefined) => { - const tree = makeObjectstack(name, { built: false }); - const root = makeObjectui(name); - const env: NodeJS.ProcessEnv = { ...process.env, OBJECTSTACK_ROOT: tree }; - if (skipDts === undefined) delete env.OS_SKIP_DTS; - else env.OS_SKIP_DTS = skipDts; - try { - build({ root, env }); - } catch (error) { - expect(error).toBeInstanceOf(PinRefusal); - return (error as Error).message; - } - throw new Error('the unbuilt tree was not refused'); - }; - const unset = refusal('remedy-skip-dts-unset', undefined); - expect(unset).toMatch(command); - expect(unset, 'the shell did not export it, so there is nothing to name').not.toMatch(/OS_SKIP_DTS is set/); - const set = refusal('remedy-skip-dts-set', '1'); - expect(set).toMatch(command); - expect(set, 'the shell exported it: the refusal names the variable').toMatch(/OS_SKIP_DTS is set in this shell/); - }); - - it('REFUSES, after the write, a tree whose packages declare dependencies the lockfile does not record', () => { - const tree = makeObjectstack('drift'); - const specPath = path.join(tree, 'packages/spec/package.json'); - const spec = JSON.parse(fs.readFileSync(specPath, 'utf8')); - spec.dependencies['left-pad'] = '^1.3.0'; - writeJson(specPath, spec); - const root = makeObjectui('drift'); - expect(() => build({ root, env: { ...process.env, OBJECTSTACK_ROOT: tree } })).toThrow( - /@objectstack\/spec: declares left-pad, which the lockfile does not record;.*run `pnpm install`/s, - ); - // The build stays in place: the remedy reads it. - expect(JSON.parse(fs.readFileSync(path.join(root, CACHE_DIR, 'spec/package.json'), 'utf8')).dependencies['left-pad']).toBe('^1.3.0'); - }); - - it('REFUSES a tree whose packages need an @objectstack/* package with no override', () => { - const tree = makeObjectstack('needs-more'); - writeJson(path.join(tree, 'packages/types/package.json'), { name: '@objectstack/types', version: '9.9.9-test.1' }); - const corePath = path.join(tree, 'packages/core/package.json'); - const core = JSON.parse(fs.readFileSync(corePath, 'utf8')); - core.dependencies['@objectstack/types'] = 'workspace:*'; - writeJson(corePath, core); - const root = makeObjectui('needs-more'); - expect(() => build({ root, env: { ...process.env, OBJECTSTACK_ROOT: tree } })).toThrow(/need @objectstack\/types/); - expect(fs.existsSync(path.join(root, CACHE_DIR))).toBe(false); - }); - - it('is what `pnpm pack` publishes: the pnpm on PATH answers', () => { - // The stand-in cases above are only meaningful if they reached the real packer. - const r = spawnSync('pnpm', ['--version'], { encoding: 'utf8' }); - expect(r.status).toBe(0); - }); -}); - -describe('one build per machine, outside every checkout (objectui#12972)', () => { - const MANIFEST = `${JSON.stringify({ name: '@objectstack/spec', version: '1.0.0', exports: { '.': './dist/index.mjs' }, dependencies: { a: '1' } })}\n`; - const BUILT = 'export const pinned = true;\n'; - /** A checkout at the pin with no build, a cache as `build` leaves one, and the store's entry for it. */ - function machine(name: string) { - const base = fs.mkdtempSync(path.join(scratch, `${name.replace(/ /g, '-')}-`)); - const root = path.join(base, 'objectui'); - writeJson(path.join(root, 'package.json'), { name: 'x', dependencies: { '@objectstack/spec': '1' }, pnpm: { overrides: { '@objectstack/spec': override('spec') } } }); - fs.writeFileSync(path.join(root, 'pnpm-workspace.yaml'), 'packages: []\n'); - fs.writeFileSync(path.join(root, '.objectstack-sha'), `${SHA}\n`); - fs.writeFileSync(path.join(root, 'pnpm-lock.yaml'), "snapshots:\n '@objectstack/spec@file:.objectstack-pin/spec':\n dependencies:\n a: 1.0.0\n"); - const [cache, store] = [path.join(base, 'cache'), path.join(base, 'store')]; - for (const [file, text] of [[path.join(root, PIN_DIR, 'spec/package.json'), MANIFEST], [path.join(cache, 'spec/package.json'), MANIFEST], [path.join(cache, 'spec/dist/index.mjs'), BUILT]]) { - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, text); - } - writeJson(path.join(cache, STAMP_FILE), { recipe: RECIPE, source: 'pin', sha: SHA, packages: { '@objectstack/spec': '1.0.0' } }); - // Offline: a build the store does not satisfy fails fast, at the fetch. - const env = { ...process.env, OBJECTSTACK_ROOT: '', [STORE_ENV]: store, OBJECTSTACK_REPO_URL: `file://${base}/no-such-repo` }; - return { root, cache, store, entry: path.join(store, storeKey(SHA)), env }; - } - - it('lives at an overridable path outside any checkout, keyed by the pin and this script', () => { - expect(storeDir({ [STORE_ENV]: '/srv/pins' })).toBe('/srv/pins'); - expect(storeDir({ XDG_CACHE_HOME: '/c' })).toBe('/c/objectstack-pin'); - expect(storeDir({})).toBe(path.join(os.homedir(), '.cache', 'objectstack-pin')); - const other = path.join(scratch, 'other-script.mjs'); - fs.writeFileSync(other, 'another script'); - expect(storeKey(SHA)).toMatch(new RegExp(`^${SHA}-[0-9a-f]{16}$`)); - expect(storeKey(SHA, other), 'another script never reuses the entry').not.toBe(storeKey(SHA)); - }); - - it('publishes once, and a fresh checkout COPIES the entry -- a write into the checkout never reaches it', () => { - const { root, cache, store, entry, env } = machine('reuse'); - expect([publishToStore(cache, entry), publishToStore(cache, entry)]).toEqual(['published', 'present']); - expect(fs.readdirSync(store), 'nothing temporary is left').toEqual([path.basename(entry)]); - expect(build({ root, env })).toMatchObject({ skipped: false, reused: true, stamp: { source: 'pin', sha: SHA } }); - const linked = path.join(root, PIN_DIR, 'spec/dist/index.mjs'); - expect(fs.statSync(linked).ino, 'beside the manifest').toBe(fs.statSync(path.join(root, CACHE_DIR, 'spec/dist/index.mjs')).ino); - expect(fs.statSync(linked).ino, 'a copy, not a link').not.toBe(fs.statSync(path.join(entry, 'spec/dist/index.mjs')).ino); - expect(() => check({ root })).not.toThrow(); - fs.appendFileSync(linked, 'export const marker = 1;\n'); - expect(fs.readFileSync(path.join(entry, 'spec/dist/index.mjs'), 'utf8')).toBe(BUILT); - }); - - it.each<[string, (entry: string) => void, RegExp]>([ - ['another pin', (e) => writeJson(path.join(e, STAMP_FILE), { recipe: RECIPE, source: 'pin', sha: 'f'.repeat(40), packages: { '@objectstack/spec': '1.0.0' } }), /not the pin: the cache holds f{12}, /], - ['a package gone', (e) => fs.rmSync(path.join(e, 'spec'), { recursive: true }), /not the pin: .*does not hold @objectstack\/spec/], - ['a file gone', (e) => fs.rmSync(path.join(e, 'spec/dist/index.mjs')), /not the ones it was published with/], - ['a byte changed', (e) => fs.appendFileSync(path.join(e, 'spec/dist/index.mjs'), ' '), /not the ones it was published with/], - ['no record', (e) => fs.rmSync(path.join(e, ENTRY_FILE)), /could not be read/], - ])('never takes an entry with %s: the reason is said, staging is emptied, the pin is built', (label, spoil, reason) => { - const { root, cache, entry, env } = machine(label); - publishToStore(cache, entry); - spoil(entry); - const staging = fs.mkdtempSync(path.join(scratch, 'staging-')); - expect(takeFromStore(entry, staging, { pin: SHA, overrides: new Map([['@objectstack/spec', 'spec']]) })).toMatch(reason); - expect(fs.readdirSync(staging)).toEqual([]); - expect(() => build({ root, env })).toThrow(/git fetch .*exited/); - expect(fs.existsSync(path.join(root, CACHE_DIR))).toBe(false); - }); - - it('drops its own copy when a concurrent run renamed its entry into place first', () => { - const { cache, store, entry } = machine('race'); - publishToStore(cache, entry); - fs.writeFileSync(path.join(cache, 'spec/dist/index.mjs'), 'export const second = true;\n'); - const spy = vi.spyOn(fs, 'existsSync').mockReturnValueOnce(false); // both runs looked before either renamed - try { - expect(publishToStore(cache, entry)).toBe('present'); - } finally { - spy.mockRestore(); - } - expect(fs.readFileSync(path.join(entry, 'spec/dist/index.mjs'), 'utf8')).toBe(BUILT); - expect(fs.readdirSync(store)).toEqual([path.basename(entry)]); - }); - - it('keeps the current entry and the most recently used others, and clears what a dead run left', () => { - const store = fs.mkdtempSync(path.join(scratch, 'prune-')); - for (const [name, age] of [['current', 9e4], ['newest', 1], ['newer', 2], ['old', 3], ['.tmp-live', 1], ['.tmp-dead', 7200]] as const) { - fs.mkdirSync(path.join(store, name)); - fs.utimesSync(path.join(store, name), Date.now() / 1000 - age, Date.now() / 1000 - age); - } - pruneStore(store, 3, 'current'); - expect(fs.readdirSync(store).sort()).toEqual(['.tmp-live', 'current', 'newer', 'newest']); - }); -}); diff --git a/ui/scripts/__tests__/quick-reference-commands-4149.test.ts b/ui/scripts/__tests__/quick-reference-commands-4149.test.ts index 690797df72e..b10d551252b 100644 --- a/ui/scripts/__tests__/quick-reference-commands-4149.test.ts +++ b/ui/scripts/__tests__/quick-reference-commands-4149.test.ts @@ -24,15 +24,14 @@ import { fileURLToPath } from 'node:url'; * - `packages/tenant` was listed in the Package Tiers table; that package was deleted * by objectui#2564 (commit d5b1bc0b4). * - * ## Why this is a sibling file and not more `it`s in the #4143 pin + * ## Why this file never reads `## Current Release` * - * `quick-reference-current-release-4143.test.ts` is scoped, in its own words, to the - * `## Current Release` section — deliberately, because "`## Repository Layout` above it - * also carries numbers, and asserting over the whole file would conflate a status claim - * with a package count". That scoping is load-bearing for its reverse direction (no - * un-derived version literal), which would go red on this page's ports and counts if it - * were widened. So the two files split by section, and this one never reads - * `## Current Release`. + * That section had its own pin, `quick-reference-current-release-4143.test.ts`, scoped + * to it because "`## Repository Layout` above it also carries numbers, and asserting + * over the whole file would conflate a status claim with a package count". The pin and + * the section's version rows were retired together when objectui's release lane left + * (objectstack#25711, decision 17c); the section now holds no literal to pin, and this + * file still stops short of it. * * ## What is pinned here, and what is deliberately not * diff --git a/ui/scripts/__tests__/quick-reference-current-release-4143.test.ts b/ui/scripts/__tests__/quick-reference-current-release-4143.test.ts deleted file mode 100644 index e24a8971551..00000000000 --- a/ui/scripts/__tests__/quick-reference-current-release-4143.test.ts +++ /dev/null @@ -1,524 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -/** - * objectui#4143: `QUICK_REFERENCE.md`'s "Current Release" block is a deliberately - * maintained status section — and nothing was maintaining it. At the time this test - * was written it claimed `v3.3.2`, `@objectstack/spec ^4.0.4` and - * `@objectstack/client ^4.0.4` against manifests carrying `17.4.0` and - * `^17.0.0-rc.5`: thirteen majors of drift under a heading that says "Current". - * - * The card's premise also said the block's Node/pnpm/React rows were "currently - * accurate". Two of the three were. **Node was not** — the row read `≥ 20` while - * root `engines.node` was `>=22` at the time, and the row *names that anchor in - * its own text*. A claim that cites its anchor and disagrees with it is the - * strongest possible argument that review alone does not hold this block: the - * reviewer had the pointer and still did not follow it. - * - * `engines.node` has since moved again, to `>=22.11` (objectui#5306 / PR #6311) — - * and objectui#6313 is the same shape of drift one decimal place down: the - * forward assertion below derived its expected floor with `match(/(\d+)/)`, - * which keeps only the LEADING integer group and silently discards everything - * after it. `>=22.11` derived a floor of `22`, so a row reading exactly `≥ 22` - * (missing the `.11` its own anchor carries) passed. The derivation below now - * strips the comparator and keeps the WHOLE version string instead, so the row - * and the anchor can no longer disagree at the minor version. - * - * ## Why a per-block pin and not the repo's existing ratchet - * - * `scripts/__tests__/doc-version-claims.test.ts` (objectui#3697) already inventories - * version literals in prose, and its header states the residual hole plainly: its - * scan roots are `content/docs` and each package README, so "a version literal in - * a file OUTSIDE the two scan roots is invisible here". `QUICK_REFERENCE.md` sits in - * that hole, and so does `apps/console/README.md` — which is exactly how objectui#4143 - * became a card at all. - * - * Widening those scan roots was considered and rejected for this change. That gate is - * a RATCHET: it decides whether a literal was *recorded with a reason*, explicitly not - * whether it is *true* ("It cannot know whether 'Tailwind CSS v3.3+' is true — that - * would need a per-claim anchor"). Every row in this block HAS a per-claim anchor, so - * the stronger instrument is available here and the weaker one would have inventoried - * `^4.0.4` as a known-stale entry rather than caught it. This file follows - * `ci-cd-pipeline-doc.test.ts` instead: read the doc, read the anchor, derive the - * expected text FROM the anchor. No version literal is written twice — every expected - * value below is computed from a manifest, so this test cannot itself go stale. - * - * ## The two directions - * - * Forward: each anchored row must state EXACTLY the version literals its manifest - * produces — a row's literals are extracted as whole tokens and compared to the derived - * set by equality. Bump the manifest without touching the block and the row that lies - * goes red, naming the file to edit. - * - * That sentence used to be false, and objectui#4913 is the accounting. The forward - * assertions asked `toContain`, which accepts any row carrying the derived value as a - * SUBSTRING — so every PREFIX SUPERSET of the anchor passed. Measured on this file: - * GA manifests against a doc still reading `^17.0.0-rc.6` kept both range rows green, - * because `'^17.0.0-rc.6'.includes('^17.0.0')`. `^17.0.0.1` and `^17.0.0-beta` passed - * identically, and so did a `≥ 220` Node row against an `engines.node` floor of 22 — - * the hole was the comparison, not the prerelease suffix, so it was in every anchored - * row here rather than in the two that happened to get caught. What did catch the drift - * was the reverse sweep, answering a different question and covering this one only by - * accident: of the two directions, the broken one was the one this header guaranteed. - * - * Reverse (the last `it` below): no version literal may appear in the block that this - * test did not derive. Without it the gate covers only the rows that exist today — a - * new hand-written `- **Turbo:** 2.x` row would sail in beside them and rot on the - * same schedule, which is the whole defect class, re-created one bullet to the left. - * The forward equality does not make it redundant and it must not be dropped on that - * argument: forward asks whether THIS row still says what its anchor says, reverse asks - * whether anything here was never derived at all. A brand-new row is invisible to the - * first and caught by the second — two directions, two questions. - * - * ## Where the forward direction still stops - * - * Both directions reason about version LITERALS (`VERSION_LITERAL` below), so prose - * wrapped around a correct literal is pinned by neither. A row hedged to - * `^17.0.0 or higher` states its anchor's literal and no other one, and passes — that - * is measured too, not assumed. Closing it would mean writing the rows' wording into - * this file, and the rows carry hand-written anchor notes precisely so they can be - * reworded; the boundary is recorded here instead, so the next reader knows where the - * guarantee above runs out. - * - * ## The one row nothing here can hold to account - * - * `**TypeScript:** ≥ 5.0` has no anchor: no workspace manifest declares a `typescript` - * peer or engine range, so there is nothing to compare it against. It is a stack floor - * stated in AGENTS.md §2, and the block says so on the row itself rather than passing - * it off as a manifest fact. It is allowlisted below by exact text, with a guard that - * fails the moment an anchor for it DOES appear — the `ci-cd-pipeline-doc.test.ts` - * move of covering the dangerous direction twice, so the exemption cannot outlive its - * own justification. - */ -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const docRel = 'QUICK_REFERENCE.md'; -const doc = fs.readFileSync(path.join(repoRoot, docRel), 'utf8'); - -interface Manifest { - name?: string; - private?: boolean; - version?: string; - packageManager?: string; - engines?: Record; - dependencies?: Record; - devDependencies?: Record; - peerDependencies?: Record; -} - -function manifest(rel: string): Manifest { - return JSON.parse(fs.readFileSync(path.join(repoRoot, rel), 'utf8')) as Manifest; -} - -const rootManifest = manifest('package.json'); -const consoleManifest = manifest('apps/console/package.json'); -const dataObjectstackManifest = manifest('packages/data-objectstack/package.json'); -const reactManifest = manifest('packages/react/package.json'); - -/** - * The majors named by a peer range like `^18.0.0 || ^19.0.0`. - * - * Split on the `||` comparators first and take the leading number of each — reading - * majors with one global `\d+\.` sweep instead returns `[18, 0, 19, 0]`, because the - * patch segments match it too. That mistake fails LOUD here (the row is asserted to - * name "0.x") but it would fail SILENT in the derived-literal set below, where an - * extra member only widens what the block is allowed to say. - */ -function peerMajors(range: string): string[] { - return range - .split('||') - .map((comparator) => comparator.trim().match(/^[\^~>=<\s]*(\d+)\./)?.[1]) - .filter((major): major is string => major !== undefined); -} - -/** - * The floor an `engines`-style range states, as the WHOLE version string — not just - * its leading integer group. - * - * objectui#6313: the previous derivation, `match(/(\d+)/)?.[1]`, kept only the - * leading digits, so `>=22.11` produced a floor of `22` and silently discarded the - * `.11` rather than failing. A row reading exactly `≥ 22` then satisfied an anchor - * that actually says `22.11`. Stripping the comparator and keeping the remainder - * whole means the row and the anchor can no longer disagree at any segment the - * anchor carries. - */ -function versionFloor(range: string): string | undefined { - return range.match(/^[\^~>=<\s]*(.+)$/)?.[1]; -} - -/** - * The `## Current Release` section, from its heading to the next `## ` heading (or EOF). - * Scoping matters: `## Repository Layout` above it also carries numbers, and asserting - * over the whole file would conflate a status claim with a package count. - */ -function currentReleaseSection(): string { - const lines = doc.split('\n'); - const start = lines.findIndex((line) => line.trim() === '## Current Release'); - // Thrown rather than asserted: this runs at module load, where a failed `expect` has - // no test to attach itself to. A throw fails collection with the same message. - if (start === -1) throw new Error(`${docRel} must still have a "## Current Release" heading`); - - const rest = lines.slice(start + 1); - const end = rest.findIndex((line) => /^## /.test(line)); - return (end === -1 ? rest : rest.slice(0, end)).join('\n'); -} - -const section = currentReleaseSection(); - -/** - * The bullet rows only — the section's prose intro is deliberately excluded. - * - * A row may wrap onto continuation lines, so a row runs from its `- **Label:**` line - * up to the next bullet or blank line. Reading a row as a single physical line would - * make the gate silently miss whatever a future editor pushes onto line two, which is - * where the "see root `engines.node`" style of self-citing anchor tends to live. - */ -function rows(): Map { - const out = new Map(); - const lines = section.split('\n'); - - for (let i = 0; i < lines.length; i += 1) { - const head = lines[i].match(/^- \*\*(.+?):\*\*\s*(.*)$/); - if (!head) continue; - - const parts = [head[2]]; - for (let j = i + 1; j < lines.length; j += 1) { - const next = lines[j]; - if (next.trim() === '' || /^- \*\*/.test(next) || /^>/.test(next)) break; - parts.push(next.trim()); - } - out.set(head[1], parts.join(' ').trim()); - } - - return out; -} - -const releaseRows = rows(); - -function row(label: string): string { - const value = releaseRows.get(label); - expect( - value, - `${docRel}'s "Current Release" block must still have a "- **${label}:**" row ` + - `(found: ${[...releaseRows.keys()].map((k) => `"${k}"`).join(', ')})`, - ).toBeDefined(); - return value as string; -} - -/** - * A version literal, in the spellings this block uses. Deliberately narrow in the same - * way `doc-version-claims.test.ts` argues for: a bare integer is not a version (the - * rows contain `§2`, and prose elsewhere counts things), so a literal must be - * operator-prefixed or dotted. - * - * Both directions read the block through this one regex: the forward assertions extract - * a row's literals with it, and the reverse sweep collects the same tokens across every - * row. One tokenizer means the two cannot disagree about what a version literal IS — a - * spelling neither can see is one hole to record (see the header), not two to discover - * separately. - */ -const VERSION_LITERAL = /(?:\^|~|>=|<=|>|<|≥|≤)\s*v?\d[\w.-]*|\bv?\d+\.[A-Za-z0-9][\w.-]*/g; - -const normalize = (literal: string): string => literal.replace(/\s+/g, ''); - -/** - * The version literals a row states, as WHOLE tokens — de-duplicated and sorted, so the - * comparison below is about WHICH literals a row states, not the order it states them - * in or how often. `matchAll` does not advance `VERSION_LITERAL.lastIndex` (it iterates - * over an internal clone), so the shared global regex is safe to reuse per row. - */ -function statedLiterals(value: string): string[] { - return [...new Set([...value.matchAll(VERSION_LITERAL)].map((match) => normalize(match[0])))].sort(); -} - -/** - * The forward direction, for one anchored row: the literals the row states must be - * EXACTLY the literals its anchor produced. - * - * Equality on extracted tokens, never `toContain` over the raw row (objectui#4913, and - * the header's first section for the measurements). Two properties follow from - * comparing the whole set rather than asking "does it include": - * - * - A row may not smuggle in a SECOND, un-anchored literal beside the right one. That - * is the reverse sweep's rule applied per row, so the failure names the row to edit - * instead of pointing at the block. - * - A derivation that produces a literal the row does not state fails LOUD here. In the - * reverse set an over-broad derivation only widens what the block is allowed to say - * (see `peerMajors` above for the shape of that mistake). - */ -function expectRowStates(label: string, derived: string[], message: string): void { - expect(statedLiterals(row(label)), message).toEqual([...new Set(derived.map(normalize))].sort()); -} - -describe('QUICK_REFERENCE.md "Current Release" — package + spec versions', () => { - /** - * Every `@object-ui/*` package is one `fixed` group in `.changeset/config.json`, so - * "the version" is a single fact about the workspace rather than a per-package one. - * This asserts that premise before quoting it: if the group ever splits, a single - * `**Version:**` row stops being expressible and this test says so instead of - * silently pinning whichever manifest it happened to read. - */ - it('states the one version every workspace package carries', () => { - const changesetConfig = JSON.parse( - fs.readFileSync(path.join(repoRoot, '.changeset/config.json'), 'utf8'), - ) as { fixed?: string[][] }; - const fixedGroup = changesetConfig.fixed?.[0] ?? []; - expect(fixedGroup.length, '.changeset/config.json must still declare one `fixed` group').toBeGreaterThan(1); - - const versions = new Map(); - for (const rel of ['packages', 'apps'] as const) { - for (const entry of fs.readdirSync(path.join(repoRoot, rel), { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const pkgRel = `${rel}/${entry.name}/package.json`; - if (!fs.existsSync(path.join(repoRoot, pkgRel))) continue; - const pkg = manifest(pkgRel); - if (!pkg.name || !fixedGroup.includes(pkg.name)) continue; - versions.set(pkg.name, pkg.version ?? ''); - } - } - - const distinct = new Set(versions.values()); - expect( - [...distinct], - `the changeset \`fixed\` group must carry ONE version; got ${JSON.stringify([...versions])}`, - ).toHaveLength(1); - - const workspaceVersion = [...distinct][0]; - expectRowStates( - 'Version', - [workspaceVersion], - `${docRel} must state the workspace version as exactly "${workspaceVersion}"`, - ); - }); - - it('quotes the `@objectstack/spec` range the manifests declare', () => { - const fromRoot = rootManifest.devDependencies?.['@objectstack/spec']; - const fromConsole = consoleManifest.devDependencies?.['@objectstack/spec']; - expect(fromRoot, 'the root package.json must still declare @objectstack/spec').toBeDefined(); - expect(fromConsole, 'apps/console must still declare @objectstack/spec').toBeDefined(); - - // The row names both files as its anchor, so it may only state one literal while - // the two agree. Disagreement is a real event and belongs in the doc, not hidden. - expect( - fromConsole, - 'root and apps/console declare different @objectstack/spec ranges — ' + - `${docRel}'s Spec row names both as its anchor and can no longer state one value`, - ).toBe(fromRoot); - - expectRowStates('Spec', [fromRoot as string], `${docRel} must state the spec range as exactly "${fromRoot}"`); - }); - - it('quotes the `@objectstack/client` range the manifests declare', () => { - const fromConsole = consoleManifest.devDependencies?.['@objectstack/client']; - const fromAdapter = dataObjectstackManifest.dependencies?.['@objectstack/client']; - expect(fromConsole, 'apps/console must still declare @objectstack/client').toBeDefined(); - expect(fromAdapter, 'packages/data-objectstack must still declare @objectstack/client').toBeDefined(); - expect( - fromAdapter, - 'apps/console and packages/data-objectstack declare different @objectstack/client ranges — ' + - `${docRel}'s Client row names both as its anchor and can no longer state one value`, - ).toBe(fromConsole); - - expectRowStates( - 'Client', - [fromConsole as string], - `${docRel} must state the client range as exactly "${fromConsole}"`, - ); - }); -}); - -describe('QUICK_REFERENCE.md "Current Release" — toolchain rows', () => { - /** - * The row that was actually wrong while the card called it accurate. It cites - * `engines.node` in its own text, so the floor it prints and the floor the manifest - * declares are now the same number by construction. - */ - it('states the Node floor from root `engines.node`', () => { - const engine = rootManifest.engines?.node; - expect(engine, 'root package.json must still declare engines.node').toBeDefined(); - - const floor = versionFloor(engine as string); - expect(floor, `engines.node must still carry a version floor (got "${engine}")`).toBeDefined(); - expectRowStates( - 'Node.js', - [`≥ ${floor}`], - `${docRel} must state the Node floor as exactly "≥ ${floor}" to match engines.node "${engine}"`, - ); - }); - - /** - * objectui#6313: `match(/(\d+)/)?.[1]` on `engines.node` kept only the leading - * integer group, so `>=22.11` derived a floor of `22` and a row reading exactly - * `≥ 22` — missing the `.11` its own anchor states — passed. Two properties, both - * measured against the REAL `engines.node` rather than a hard-coded literal, so - * this cannot itself go stale the way the row it guards did: - * - * - The old (leading-integer) and new (whole-string) derivations must disagree - * whenever the anchor carries a minor — proving the fix changed what floor - * comes OUT, not just how it is computed. If `engines.node` ever loses its - * minor this assertion goes quiet, which is why the next one does not depend - * on that disagreement to make its point. - * - A row stating the coarser, pre-fix floor must be REJECTED by the same - * equality `expectRowStates` uses above. This is the entire point of the card: - * if `≥ 22` were accepted before the fix and still accepted after it, the - * derivation changed without changing what the pin enforces. - */ - it('derives the Node floor as the whole version, not just its leading integer group', () => { - const engine = rootManifest.engines?.node as string; - const staleFloor = engine.match(/(\d+)/)?.[1]; - const floor = versionFloor(engine); - - expect( - floor, - `engines.node ("${engine}") no longer carries a minor segment for the leading-integer ` + - `derivation ("${staleFloor}") to differ from — objectui#6313's regression case needs one ` + - 're-anchored to keep exercising the disagreement', - ).not.toBe(staleFloor); - - expect( - statedLiterals(`≥ ${staleFloor}`), - `a row reading "≥ ${staleFloor}" must NOT satisfy the derived Node floor "≥ ${floor}" — ` + - 'accepting it is exactly the objectui#6313 staleness this pin exists to catch', - ).not.toEqual(statedLiterals(`≥ ${floor}`)); - }); - - /** - * The coarseness objectui#4913 already fixed for `toContain` (a numerically-prefixed - * SUPERSET like `≥ 220` passing against a derived `≥ 22`) must not come back now that - * the floor is derived differently. Built off the real anchor's leading integer group - * so this states the exact shape the card's own history section records, rather than - * a made-up number. - */ - it('rejects a `≥ 220`-shaped row as equal to a shorter derived Node floor', () => { - const engine = rootManifest.engines?.node as string; - const staleFloor = engine.match(/(\d+)/)?.[1] as string; - - expect( - statedLiterals(`≥ ${staleFloor}0`), - `"≥ ${staleFloor}0" must not equal "≥ ${staleFloor}" — a numeric-prefix superset is not the ` + - 'same version literal, the objectui#4913 regression this guards', - ).not.toEqual(statedLiterals(`≥ ${staleFloor}`)); - }); - - it('states the pnpm floor and the pinned `packageManager`', () => { - const engine = rootManifest.engines?.pnpm; - expect(engine, 'root package.json must still declare engines.pnpm').toBeDefined(); - const floor = versionFloor(engine as string); - - const pinned = rootManifest.packageManager; - expect(pinned, 'root package.json must still declare packageManager').toBeDefined(); - // Split off the version the same way the reverse sweep does, so one derivation - // serves both directions and they cannot drift apart on this row. - const pinnedVersion = (pinned as string).split('@').pop() as string; - - expectRowStates( - 'pnpm', - [`≥ ${floor}`, pinnedVersion], - `${docRel}'s pnpm row must state exactly the floor "≥ ${floor}" and the pinned "${pinned}"`, - ); - - // Containment deliberately, and only over the part that is NOT a version literal: - // the equality above holds the digits, this holds the tool they belong to, so a row - // quoting the right version against the wrong package manager still goes red. - expect(row('pnpm'), `${docRel} must quote the pinned packageManager "${pinned}"`).toContain(pinned as string); - }); - - /** - * Derived from a manifest rather than from `react-peer-range-norm-3741.test.ts`'s - * `REACT_PEER_NORM` constant on purpose: that test already pins every workspace - * manifest to one range, so any single manifest is representative — and going - * through the manifest keeps this file free of a second copy of the range. - */ - it('states the React majors the packages declare as peers', () => { - const peer = reactManifest.peerDependencies?.react; - expect(peer, 'packages/react must still declare a react peer range').toBeDefined(); - - const majors = peerMajors(peer as string); - expect(majors.length, `could not read majors out of the react peer range "${peer}"`).toBeGreaterThan(0); - - const expected = majors.map((major) => `${major}.x`); - expectRowStates( - 'React', - expected, - `${docRel} must name exactly React ${expected.join(' + ')} — the peer range is "${peer}"`, - ); - }); -}); - -/** - * The unanchored row, and the guard that retires the exemption for us. - * - * `≥ 5.0` is believed true and is checkable against nothing. If a `typescript` peer or - * engine range ever lands in a workspace manifest, an anchor exists and the row must - * start quoting it — so this fails at that moment rather than leaving a stale exemption - * in place for the next reader to trust. - */ -const UNANCHORED_LITERAL = '≥ 5.0'; - -describe('QUICK_REFERENCE.md "Current Release" — the unanchored TypeScript row', () => { - it('is still unanchored, and still says so on the row', () => { - const anchors: string[] = []; - for (const rel of ['packages', 'apps'] as const) { - for (const entry of fs.readdirSync(path.join(repoRoot, rel), { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const pkgRel = `${rel}/${entry.name}/package.json`; - if (!fs.existsSync(path.join(repoRoot, pkgRel))) continue; - const pkg = manifest(pkgRel); - if (pkg.peerDependencies?.typescript || pkg.engines?.typescript) anchors.push(pkgRel); - } - } - - expect( - anchors, - `a typescript range is now declared in ${anchors.join(', ')} — ${docRel}'s TypeScript row ` + - 'has an anchor at last and must be pinned to it here, replacing this exemption', - ).toEqual([]); - - expectRowStates( - 'TypeScript', - [UNANCHORED_LITERAL], - `${docRel}'s TypeScript row must still state exactly "${UNANCHORED_LITERAL}"`, - ); - expect( - row('TypeScript'), - `${docRel}'s TypeScript row is the one unanchored claim in the block and must keep saying so, ` + - 'so a reader can tell it apart from the rows a test holds to account', - ).toMatch(/not a manifest fact/i); - }); -}); - -describe('QUICK_REFERENCE.md "Current Release" — no un-derived literal', () => { - it('contains only version literals this test derives from a manifest', () => { - const derived = new Set(); - const add = (literal: string | undefined): void => { - if (literal) derived.add(normalize(literal)); - }; - - add(consoleManifest.version); - add(rootManifest.devDependencies?.['@objectstack/spec']); - add(consoleManifest.devDependencies?.['@objectstack/client']); - add(`≥ ${versionFloor(rootManifest.engines?.node ?? '')}`); - add(`≥ ${versionFloor(rootManifest.engines?.pnpm ?? '')}`); - add(rootManifest.packageManager?.split('@').pop()); - for (const major of peerMajors(reactManifest.peerDependencies?.react ?? '')) { - add(`${major}.x`); - } - add(UNANCHORED_LITERAL); - - const undecided: string[] = []; - for (const value of releaseRows.values()) { - for (const match of value.matchAll(VERSION_LITERAL)) { - if (!derived.has(normalize(match[0]))) undecided.push(match[0]); - } - } - - expect( - undecided, - `${docRel}'s "Current Release" block states ${JSON.stringify(undecided)}, which no manifest in ` + - 'this tree produced. Every row under a heading that says "Current" must be derived from an ' + - 'anchor and asserted above, or it is the next objectui#4143. Derived values are: ' + - `${JSON.stringify([...derived])}.`, - ).toEqual([]); - }); -}); diff --git a/ui/scripts/__tests__/release-github-releases.test.ts b/ui/scripts/__tests__/release-github-releases.test.ts deleted file mode 100644 index 920e5b7661e..00000000000 --- a/ui/scripts/__tests__/release-github-releases.test.ts +++ /dev/null @@ -1,294 +0,0 @@ -/** - * objectui#11596 — the publish lane pushes the version's git tags and creates its - * GitHub Releases itself, because `changesets/action@v1` no longer does. - * - * v1 pushes a tag and creates a Release only for the packages whose - * `New tag: @` line it parses from the publish script's stdout, and - * `@changesets/cli` v3 prints none. 17.6.0 and 17.7.0 reached npm with no tag and - * no Release while the run stayed green. Two steps of - * `.github/workflows/changeset-release.yml` now do that half of the action's - * `runPublish`, after the npm check: "Push the release tags" and "Create GitHub - * Releases", the second running `scripts/release-github-releases.mjs` (ported from - * objectstack). - * - * What this file pins, and why each half lives here: - * - * 1. The script's own logic — body truncation, idempotency, race convergence, - * the release set and the tag list — is its `--self-test`, which carries a - * battery floor of its own. This file runs it, so `pnpm test` fails when it - * does; nothing else in CI runs it. - * 2. The two steps' LANE SCOPING. A publish-lane step reachable from the - * `schedule` refresh lane, a Release step that runs `--dry-run` (exit 0, - * released nothing), or a Release step ahead of the tag push (the API then - * creates every tag itself, lightweight) are each a green run that is - * wrong. Workflow files are read by nothing else in this repository, so - * each property is asserted here, and each assertion is shown able to fail - * by a mutation that is itself asserted to have applied. - * - * ⛔ Nothing here calls the GitHub API or creates a tag. `--dry-run` is exercised - * with no token and an unreachable API URL, so a regression that made it write - * would fail rather than write. - */ -import { execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import path from 'node:path'; -import { describe, expect, it } from 'vitest'; -import { parse as parseYaml } from 'yaml'; - -import { repoRoot, workflowDir } from './workflow-checks'; - -const SCRIPT = path.join(repoRoot, 'scripts/release-github-releases.mjs'); -const workflowSource = fs.readFileSync(path.join(workflowDir, 'changeset-release.yml'), 'utf8'); - -const PUBLISH = 'Publish to npm'; -const VERIFY = 'Verify the release reached npm'; -const PUSH_TAGS = 'Push the release tags'; -const CREATE_RELEASES = 'Create GitHub Releases (bodies truncated to the API limit)'; - -const RELEASE_VERSION_SOURCE = '${{ needs.lane.outputs.manifest_version }}'; - -interface Step { - name?: string; - id?: string; - if?: string; - run?: string; - uses?: string; - env?: Record; -} - -interface Workflow { - permissions?: Record; - jobs: Record; steps?: Step[] }>; -} - -/** The `if:` text with the `${{ }}` wrapper and runs of whitespace normalised away. */ -function condition(step: Step | undefined): string { - return String(step?.if ?? '') - .replace(/^\s*\$\{\{\s*/, '') - .replace(/\s*\}\}\s*$/, '') - .replace(/\s+/g, ' ') - .trim(); -} - -/** - * Every way the two steps can stop meaning "push this version's tags and create its - * Releases, on the publish lane only". Empty means the wiring holds. - */ -function wiringProblems(source: string): string[] { - const workflow = parseYaml(source) as Workflow; - const job = workflow.jobs?.release; - const steps = job?.steps ?? []; - const problems: string[] = []; - const indexOf = (name: string) => steps.findIndex((s) => s.name === name); - const stepNamed = (name: string) => steps.find((s) => s.name === name); - - for (const name of [PUBLISH, VERIFY, PUSH_TAGS, CREATE_RELEASES]) { - const count = steps.filter((s) => s.name === name).length; - if (count !== 1) problems.push(`the release job has ${count} step(s) named "${name}", not exactly one`); - } - if (problems.length) return problems; - - const order = [PUBLISH, VERIFY, PUSH_TAGS, CREATE_RELEASES].map(indexOf); - if (!order.every((at, i) => i === 0 || at > order[i - 1])) { - problems.push( - `steps run out of order (${[PUBLISH, VERIFY, PUSH_TAGS, CREATE_RELEASES].join(' -> ')} expected, ` + - `got positions ${order.join(', ')}): the tags must be pushed after the npm check and BEFORE the ` + - 'Releases, or the API creates each tag itself as a lightweight tag', - ); - } - - const publishId = stepNamed(PUBLISH)?.id; - const verifyId = stepNamed(VERIFY)?.id; - if (!publishId || !verifyId) { - problems.push(`the publish and npm-check steps need ids for the conditions to read (got ${publishId} / ${verifyId})`); - return problems; - } - - for (const name of [PUSH_TAGS, CREATE_RELEASES]) { - const step = stepNamed(name); - const cond = condition(step); - const required = [ - "github.event_name == 'push'", - '!cancelled()', - `steps.${publishId}.outcome == 'success'`, - `steps.${verifyId}.outcome == 'success'`, - ]; - for (const clause of required) { - if (!cond.includes(clause)) problems.push(`"${name}" if: is missing \`${clause}\` (got \`${cond}\`)`); - } - // Every clause is ANDed. An `||` — or a lane named at all — opens a second way in. - for (const forbidden of ['||', 'schedule', 'workflow_dispatch', 'always()']) { - if (cond.includes(forbidden)) { - problems.push(`"${name}" if: contains \`${forbidden}\`, which can reach it from outside the publish lane`); - } - } - if (step?.env?.RELEASE_VERSION !== RELEASE_VERSION_SOURCE) { - problems.push( - `"${name}" must take RELEASE_VERSION from ${RELEASE_VERSION_SOURCE} — the version the npm check ` + - `confirmed (got ${step?.env?.RELEASE_VERSION})`, - ); - } - } - - const pushRun = String(stepNamed(PUSH_TAGS)?.run ?? ''); - if (!pushRun.includes('node scripts/release-github-releases.mjs --print-tags')) { - problems.push('"Push the release tags" no longer reads the tag list from `release-github-releases.mjs --print-tags`'); - } - const pushes = pushRun.split('\n').filter((l) => /\bgit\s+push\b/.test(l)); - if (pushes.length !== 1) problems.push(`"Push the release tags" should push exactly once (found ${pushes.length})`); - for (const line of pushes) { - if (/--force|\s-f\b|--delete|\s-d\b|\bHEAD\b|--tags|--mirror|--all/.test(line)) { - problems.push(`"Push the release tags" pushes more than this version's tag refs: \`${line.trim()}\``); - } - } - - const releases = stepNamed(CREATE_RELEASES); - if (String(releases?.run ?? '').trim() !== 'node scripts/release-github-releases.mjs') { - problems.push( - `"Create GitHub Releases" must run the script bare — \`--dry-run\` and \`--self-test\` both exit 0 having ` + - `released nothing (got \`${releases?.run}\`)`, - ); - } - if (releases?.env?.GITHUB_TOKEN !== '${{ secrets.GITHUB_TOKEN }}') { - problems.push(`"Create GitHub Releases" needs GITHUB_TOKEN from secrets (got ${releases?.env?.GITHUB_TOKEN})`); - } - - // A tag push and a Release create are both `contents: write`. - const contents = job?.permissions ? job.permissions.contents : workflow.permissions?.contents; - if (contents !== 'write') { - problems.push(`the release job's effective \`contents\` permission is ${contents}, not write`); - } - - // The job guard is what keeps a version that is ALREADY on npm from reaching these steps. - const jobIf = String(job?.if ?? '').replace(/\s+/g, ' '); - if (!jobIf.includes("(github.event_name == 'push' && needs.lane.outputs.version_on_npm == 'false')")) { - problems.push('the release job no longer limits `push` to a version that is not on npm yet'); - } - - return problems; -} - -describe('the ported script passes its own self-test', () => { - it('reaches its verdict with every battery at or above its floor', () => { - const out = execFileSync(process.execPath, [SCRIPT, '--self-test'], { encoding: 'utf8' }); - expect(out).toMatch(/^✓ release-github-releases --self-test: \d+ assertions/m); - }, 60_000); -}); - -describe('--dry-run plans without calling the API', () => { - it('plans a Release for every tag the version owes, with no token and an unreachable API', () => { - // The anchor's manifest version: the release `main` currently declares, whose - // CHANGELOG entries `changeset version` wrote for every package of the group. - const version = JSON.parse(fs.readFileSync(path.join(repoRoot, 'packages/core/package.json'), 'utf8')).version; - const env: NodeJS.ProcessEnv = { - ...process.env, - RELEASE_VERSION: version, - GITHUB_REPOSITORY: 'objectstack-ai/objectui', - GITHUB_SHA: 'deadbeef', - GITHUB_API_URL: 'http://127.0.0.1:9', - }; - delete env.GITHUB_TOKEN; - delete env.GH_TOKEN; - delete env.PUBLISHED; - - const tags = execFileSync(process.execPath, [SCRIPT, '--print-tags'], { encoding: 'utf8', env }) - .trim() - .split('\n'); - const out = execFileSync(process.execPath, [SCRIPT, '--dry-run'], { encoding: 'utf8', env }); - - expect(tags.length, 'the tag list is empty, so the comparison below would compare nothing').toBeGreaterThan(0); - expect(tags.every((t) => t.endsWith(`@${version}`))).toBe(true); - const planned = out - .split('\n') - .filter((l) => l.includes('\t')) - .map((l) => l.split('\t')[0]); - // A public package that ships no CHANGELOG.md yet (a new one, before its first - // release) is tagged but gets no Release; the script says so on a `skipped` line. - const skipped = [...out.matchAll(/^skipped {2}(\S+) ships no CHANGELOG\.md$/gm)].map((m) => `${m[1]}@${version}`); - expect([...planned, ...skipped].sort()).toEqual([...tags].sort()); - expect(out).toMatch(new RegExp(`^${tags.length} release\\(s\\) planned .*, 0 failed to plan\\.$`, 'm')); - expect(out).not.toMatch(/^(created|updated) /m); - }, 60_000); -}); - -describe('changeset-release.yml pushes the tags and creates the Releases on the publish lane only', () => { - it('holds every wiring property on the real workflow', () => { - expect(wiringProblems(workflowSource)).toEqual([]); - }); -}); - -describe('the wiring pin can fail (non-vacuity)', () => { - // Each mutation is asserted to have APPLIED before its result is read — a no-op - // edit would leave the real workflow under test and report a green that means - // nothing. - function mutate(from: string, to: string, source = workflowSource): string { - const mutated = source.replace(from, to); - expect(mutated, `mutation did not apply: ${from}`).not.toBe(source); - return mutated; - } - - const PUSH_IF = - " - name: Push the release tags\n if: >-\n ${{ !cancelled() && github.event_name == 'push'"; - const RELEASE_IF = - " - name: Create GitHub Releases (bodies truncated to the API limit)\n if: >-\n ${{ !cancelled() && github.event_name == 'push'"; - - it('goes red when a step loses its event test', () => { - const mutated = mutate(PUSH_IF, PUSH_IF.replace(" && github.event_name == 'push'", '')); - expect(wiringProblems(mutated).join('\n')).toContain(`"${PUSH_TAGS}" if: is missing \`github.event_name == 'push'\``); - }); - - it('goes red when a step is opened to the refresh lane with an `||`', () => { - const mutated = mutate( - RELEASE_IF, - RELEASE_IF.replace("github.event_name == 'push'", "(github.event_name == 'push' || github.event_name == 'schedule')"), - ); - const problems = wiringProblems(mutated).join('\n'); - expect(problems).toContain(`"${CREATE_RELEASES}" if: contains \`||\``); - expect(problems).toContain(`"${CREATE_RELEASES}" if: contains \`schedule\``); - }); - - it('goes red when `!cancelled()` is weakened to `always()`', () => { - const mutated = mutate(RELEASE_IF, RELEASE_IF.replace('!cancelled()', 'always()')); - const problems = wiringProblems(mutated).join('\n'); - expect(problems).toContain(`"${CREATE_RELEASES}" if: is missing \`!cancelled()\``); - expect(problems).toContain(`"${CREATE_RELEASES}" if: contains \`always()\``); - }); - - it('goes red when the Release step runs --dry-run', () => { - const mutated = mutate( - ' run: node scripts/release-github-releases.mjs\n', - ' run: node scripts/release-github-releases.mjs --dry-run\n', - ); - expect(wiringProblems(mutated).join('\n')).toContain('must run the script bare'); - }); - - it('goes red when the version comes from anywhere but the lane output', () => { - const at = workflowSource.indexOf(` - name: ${CREATE_RELEASES}`); - const tail = mutate( - `RELEASE_VERSION: ${RELEASE_VERSION_SOURCE}`, - 'RELEASE_VERSION: ${{ github.ref_name }}', - workflowSource.slice(at), - ); - expect(wiringProblems(workflowSource.slice(0, at) + tail).join('\n')).toContain( - `"${CREATE_RELEASES}" must take RELEASE_VERSION from`, - ); - }); - - it('goes red when the Releases are created before the tags are pushed', () => { - const workflow = parseYaml(workflowSource) as Workflow; - const steps = workflow.jobs.release.steps ?? []; - const push = steps.findIndex((s) => s.name === PUSH_TAGS); - const releases = steps.findIndex((s) => s.name === CREATE_RELEASES); - expect(push, 'fixture: both steps exist').toBeGreaterThan(-1); - expect(releases).toBe(push + 1); - [steps[push], steps[releases]] = [steps[releases], steps[push]]; - const swapped = JSON.stringify(workflow); - expect(swapped).not.toBe(JSON.stringify(parseYaml(workflowSource))); - expect(wiringProblems(swapped).join('\n')).toContain('steps run out of order'); - }); - - it('goes red when the tag push widens to every local tag', () => { - const mutated = mutate('git push origin "${refs[@]}"', 'git push origin --tags'); - expect(wiringProblems(mutated).join('\n')).toContain('pushes more than this version\'s tag refs'); - }); -}); diff --git a/ui/scripts/__tests__/sync-quick-reference-release.test.ts b/ui/scripts/__tests__/sync-quick-reference-release.test.ts deleted file mode 100644 index 419277b54c6..00000000000 --- a/ui/scripts/__tests__/sync-quick-reference-release.test.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -// Plain-JS release helper. Its types are INFERRED from the .mjs source by -// `tsconfig.scripts.json` (`allowJs`), the same arrangement -// `check-changeset-no-major.test.ts` documents — so no `@ts-expect-error` here. -import { - assignSlots, - derivedLiteralsByRow, - normalize, - readAnchors, - syncDoc, - VERSION_LITERAL, -} from '../sync-quick-reference-release.mjs'; - -/** - * objectui#5394 — `QUICK_REFERENCE.md`'s "Current Release" block fossilised once per - * release, three times (objectui#4642, objectui#4977, objectui#5394). The gate that - * catches it (`quick-reference-current-release-4143.test.ts`) is correct and untouched; - * what was missing is anything that MOVES the doc when the anchor moves. - * - * The anchor moves inside `changeset version`, run by `changeset-release.yml` with no - * human present, and the release PR is not a backstop: every `ci.yml` run on branch - * `changeset-release/main` is `conclusion: "action_required"` with - * `created_at == run_started_at`, because a `GITHUB_TOKEN`-authored PR does not start - * workflows. The first thing that ever reads the release is the `push` build on `main`, - * which is why `main` — not a PR — went red every time. - * - * So the fix is a script wired INTO `changeset:version`, and the load-bearing assertion - * in this file is the wiring one. A sync script nothing calls is exactly as effective as - * the hand edit it replaces: the first two instances were both fixed by someone who knew - * the right value and typed it in. - */ -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const read = (rel: string): string => fs.readFileSync(path.join(repoRoot, rel), 'utf8'); - -const SCRIPT_REL = 'scripts/sync-quick-reference-release.mjs'; -const GATE_REL = 'scripts/__tests__/quick-reference-current-release-4143.test.ts'; -const DOC_REL = 'QUICK_REFERENCE.md'; - -describe('the release path actually runs the sync', () => { - /** - * The whole card in one assertion. `changeset version` is the only moment the - * workspace version moves, and nobody is watching it — if this script is not on - * that command line, the doc goes stale again on the next release and `main` goes - * red again, exactly as it did three times. - */ - it('`changeset:version` invokes the sync script after bumping the manifests', () => { - const pkg = JSON.parse(read('package.json')) as { scripts?: Record }; - const script = pkg.scripts?.['changeset:version']; - - expect(script, 'root package.json must still declare a `changeset:version` script').toBeDefined(); - expect( - script, - '`changeset:version` must still run `changeset version` — it is what the release ' + - 'workflow calls to bump the manifests', - ).toContain('changeset version'); - expect( - script, - `\`changeset:version\` must also run ${SCRIPT_REL}, or the release bumps every manifest ` + - `and leaves ${DOC_REL} a version behind (objectui#5394, and objectui#4642 before it)`, - ).toContain(SCRIPT_REL); - }); - - /** - * The other half of the wiring: the workflow must go THROUGH the npm script rather - * than calling `changeset version` itself. Pinning only `package.json` would leave a - * one-word edit here able to skip the sync silently. - */ - it('`changeset-release.yml` versions through `pnpm changeset:version`', () => { - const workflow = read('.github/workflows/changeset-release.yml'); - const versionInput = workflow.match(/^\s*version:\s*(.+)$/m)?.[1]?.trim(); - - expect( - versionInput, - 'changeset-release.yml must still hand the changesets action a `version:` command', - ).toBeDefined(); - expect( - versionInput, - 'the release must version through the `changeset:version` npm script — calling ' + - '`changeset version` directly here would bypass the sync that keeps ' + - `${DOC_REL} in step with the manifests`, - ).toBe('pnpm changeset:version'); - }); -}); - -describe('the writer and the judge read the same tokens', () => { - /** - * The script writes literals; the gate judges them. Two tokenizers would be free to - * drift, and the failure mode is a doc that this script calls synced and the gate - * calls stale — a release that cannot be made green by running the tool that is - * supposed to make it green. Compared as SOURCE TEXT so the two spellings cannot - * diverge even in ways that happen to behave alike today. - */ - it('the script and the gate spell `VERSION_LITERAL` identically', () => { - const extract = (source: string): string | undefined => - source.match(/VERSION_LITERAL = (\/.*\/g);/)?.[1]; - - const fromScript = extract(read(SCRIPT_REL)); - const fromGate = extract(read(GATE_REL)); - - expect(fromScript, `${SCRIPT_REL} must still define VERSION_LITERAL as a regex literal`).toBeDefined(); - expect(fromGate, `${GATE_REL} must still define VERSION_LITERAL as a regex literal`).toBeDefined(); - expect( - fromScript, - 'the sync script and the gate must tokenize version literals identically, or the ' + - 'script can "fix" a row the gate still reports', - ).toBe(fromGate); - expect(String(VERSION_LITERAL)).toBe(fromScript); - }); -}); - -describe('the block is in step with its anchors right now', () => { - /** - * The same fact the gate asserts, from the writing side: running the sync against the - * committed tree must be a no-op. A green gate plus a sync that still wants to edit - * the file would mean the two disagree about what the anchors say. - */ - it('syncing the committed tree changes nothing and refuses nothing', () => { - const result = syncDoc(read(DOC_REL), derivedLiteralsByRow(readAnchors(repoRoot))); - - expect(result.refusals, 'the sync must be able to read every anchored row').toEqual([]); - expect( - result.changes, - `${DOC_REL} has drifted from its anchors — run \`pnpm quick-reference:sync\``, - ).toEqual([]); - expect(result.doc).toBe(read(DOC_REL)); - }); -}); - -describe('a release bump carries the doc with it', () => { - const anchors = readAnchors(repoRoot); - const doc = read(DOC_REL); - - /** - * The recurrence, simulated at the unit level: move the one anchor `changeset - * version` moves, and the row must follow without anyone touching the file. - */ - it('rewrites the Version row when the fixed group moves', () => { - const bumped = { ...anchors, version: '99.9.9' }; - const result = syncDoc(doc, derivedLiteralsByRow(bumped)); - - expect(result.refusals).toEqual([]); - expect(result.changes).toEqual([{ label: 'Version', from: anchors.version, to: '99.9.9' }]); - expect(result.doc).toContain(`- **Version:** 99.9.9 (the version every`); - // Whole literals, never a raw substring, as the gate compares them. The - // group's version can be a prefix of another row's literal (the group at - // `18.0.0` beside a `^18.0.0-next.N` spec range), and a substring check then - // reddened the release lane's validation on a correct rewrite (objectui#13108). - const literals = [...result.doc.matchAll(VERSION_LITERAL)].map((match) => normalize(match[0])); - expect(literals).not.toContain(anchors.version); - }); - - /** - * The prose is the reason these rows are readable — each one names its own anchor in - * hand-written text, and the gate's header calls that out as deliberately rewordable. - * A sync that reflowed or regenerated the block would take that away and would make - * the gate assert generated text against the manifests it was generated from. Only - * the literal moves. - */ - it('touches nothing but the literal', () => { - const { doc: after } = syncDoc(doc, derivedLiteralsByRow({ ...anchors, version: '99.9.9' })); - - const before = doc.split('\n'); - const lines = after.split('\n'); - expect(lines.length).toBe(before.length); - - const differing = lines.map((line, i) => [i, line, before[i]] as const).filter(([, a, b]) => a !== b); - expect(differing).toHaveLength(1); - expect(differing[0][1]).toBe(differing[0][2].replace(anchors.version, '99.9.9')); - }); - - /** - * Two literals on one row, one of them moving: the pnpm row bumps its - * `packageManager` pin while `engines.pnpm` holds. Identity-first assignment is what - * keeps the floor in the floor's slot; naive positional filling would write the - * pinned version into it. - */ - it('moves only the pnpm literal that changed', () => { - const { doc: after, changes, refusals } = syncDoc( - doc, - derivedLiteralsByRow({ ...anchors, pnpmPinned: '11.0.0' }), - ); - - expect(refusals).toEqual([]); - expect(changes).toEqual([{ label: 'pnpm', from: anchors.pnpmPinned, to: '11.0.0' }]); - expect(after).toContain(`≥ ${anchors.pnpmFloor} (the workspace pins \`pnpm@11.0.0\``); - }); - - /** - * A row whose literal count no longer matches its anchor's. Which slot gets which - * value is a guess at that point, and a doc that is confidently wrong is worse than - * one that is visibly stale — so the script refuses the row, exits non-zero, and the - * release stops instead of committing the guess. - */ - it('refuses a row whose shape no longer matches its anchor', () => { - const grown = { ...anchors, reactMajors: [...anchors.reactMajors, '20'] }; - const { doc: after, changes, refusals } = syncDoc(doc, derivedLiteralsByRow(grown)); - - expect(changes).toEqual([]); - expect(after).toBe(doc); - expect(refusals).toHaveLength(1); - expect(refusals[0].label).toBe('React'); - expect(refusals[0].reason).toMatch(/would be a guess/); - }); - - /** - * The unanchored TypeScript row derives from nothing, so the sync must leave it - * alone rather than invent a value for it. The gate allowlists it by exact text and - * fails the day an anchor for it appears; that is where the row's fate is decided. - */ - it('leaves the unanchored TypeScript row alone', () => { - expect([...derivedLiteralsByRow(anchors).keys()]).not.toContain('TypeScript'); - const { doc: after } = syncDoc(doc, derivedLiteralsByRow({ ...anchors, version: '99.9.9' })); - expect(after).toContain('- **TypeScript:** ≥ 5.0 (strict mode)'); - }); -}); - -describe('assignSlots', () => { - it('keeps the literals that are already right where they are', () => { - expect(assignSlots(['≥9', '10.31.0'], ['≥ 9', '11.0.0'])).toEqual([null, '11.0.0']); - }); - - it('fills leftover slots in row order when everything moved', () => { - expect(assignSlots(['≥9', '10.31.0'], ['≥ 10', '11.0.0'])).toEqual(['≥ 10', '11.0.0']); - }); - - it('is a no-op when the row already states its anchor', () => { - expect(assignSlots(['17.6.0'], ['17.6.0'])).toEqual([null]); - }); -}); diff --git a/ui/scripts/__tests__/vite-objectstack-client-dist.test.ts b/ui/scripts/__tests__/vite-objectstack-client-dist.test.ts deleted file mode 100644 index 597855bdf69..00000000000 --- a/ui/scripts/__tests__/vite-objectstack-client-dist.test.ts +++ /dev/null @@ -1,457 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { - CLIENT_PACKAGE_NAME, - resolveClientDistInjection, - widenVendorChunkTestForClient, -} from '../vite-objectstack-client-dist'; - -/** - * objectui#6094 — `OBJECTSTACK_CLIENT_DIST` validates the override it aliases. - * - * The hook lets a developer point the console at a locally built - * `@objectstack/client`. Before this card it was a bare string alias: no - * existence check, no manifest read, no dependency check. Measured on - * `a100f77d3` with the override aimed at a copy of the installed client placed - * outside the workspace, `vite build` printed `✓ 8615 modules transformed`, - * wrote every chunk, and left the client's three own bare specifiers - * (`@objectstack/core/logger`, `@objectstack/spec/api`, `@objectstack/spec/data`) - * in `assets/framework-*.js` as calls to rolldown's `require` shim — a bundle no - * browser can load, produced by a build whose output never named the variable. - * - * Three facts are pinned here, and they pull against each other on purpose: - * - * 1. **Set and broken → refused, by NAME.** The card's complaint is not that - * the build survived; it is that nothing named `OBJECTSTACK_CLIENT_DIST`. - * So the cases below assert the variable appears in the message, not - * merely that something threw. - * 2. **Set and valid → still injected.** A check that refused every override - * would pass (1) while destroying the hook's only purpose. Both valid - * spellings are exercised against the REAL installed client. - * 3. **Unset → nothing moves.** The alias table and `server.fs.allow` stay at - * their baseline values, read off the real console config, so a check that - * stopped being conditional turns red here rather than shipping a - * different production bundle. - * - * The shape axis cuts across all three: a directory, its `dist/`, and an entry - * file inside it are all legal, and a dependency check is parameterised over a - * DIRECTORY. A check that quietly did nothing for the entry-file spelling would - * be the same defect wearing a green build, so every refusal case is asserted - * for both spellings. - * - * Reverse verification — direction predicted BEFORE each run, and one - * prediction was wrong in a way worth keeping: - * - * - **Start the walk at `resolved` even for a file** (drop the - * `shape === 'directory'` branch in `findClientPackageDir`). Predicted RED - * on the entry-file cases. Measured **GREEN, 19/19** — and that is a fact - * about the code, not a vacuous suite. The walk probes - * `/package.json` and climbs; handed `…/dist/index.mjs` its first - * probe simply misses (`…/index.mjs/package.json`) and the next iteration - * lands on `…/dist` anyway. The branch is explicitness about where the walk - * starts, not the mechanism that makes the file spelling work — the walk - * itself is indifferent. Recorded rather than deleted: a green ablation - * left unexplained reads as a test that pins nothing. - * - **Skip validation entirely for the entry-file spelling** — the naive port - * of a directory-parameterised check, and the exact defect class the card - * names ("a check that silently does nothing when pointed at a - * `dist/index.js`"). Predicted RED on the entry-file cases; measured 3 red, - * including *refuses an out-of-tree copy given as an entry file*. That is - * the pin the first leg was reaching for. - * - **Drop the `realpathSync`** in `findClientPackageDir`. Predicted RED on - * the valid installed-client cases, because pnpm reaches the client through - * a symlink whose ancestors never include the store directory holding its - * dependencies. Measured 5 red, every one of them "a VALID override is - * rejected" — the direction that matters, since a suite blind to it would - * have shipped a hook that refuses everything and still passed the - * reproduction. - */ - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); - -/** - * The installed client, as the console resolves it — under pnpm a SYMLINK into - * the store, which is the whole point of the realpath cases below. - */ -const installedClientDir = path.join(repoRoot, 'apps/console/node_modules/@objectstack/client'); - -/** Fixture roots created here, removed in `afterAll`. */ -const fixtureRoots: string[] = []; - -function makeFixtureRoot(): string { - // Each fixture gets its OWN `mkdtempSync` root rather than sharing - // `os.tmpdir()`, which every other suite and every parallel agent also writes - // to — and, more to the point here, the walk under test climbs its ancestors. - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'client-dist-6094-')); - fixtureRoots.push(root); - return root; -} - -/** - * A copy of the installed client placed outside the workspace — manifest and - * `dist/` intact, no reachable `node_modules` for its two dependencies. - * - * This is the card's reproduction, reduced to a fixture. The package's OWN - * `node_modules` stays behind: a store copy has none (its dependencies are its - * siblings in the store), but under the objectstack monorepo the installed - * client is a workspace link to a package that carries one, and copying that - * would carry the dependencies along (objectstack#25733). - */ -function outOfTreeClientCopy(): string { - const dest = path.join(makeFixtureRoot(), 'client'); - const source = fs.realpathSync(installedClientDir); - const ownNodeModules = path.join(source, 'node_modules'); - fs.cpSync(source, dest, { recursive: true, dereference: true, filter: (src) => src !== ownNodeModules }); - return dest; -} - -/** - * pnpm's store layout, on disk: the client in - * `.pnpm/@objectstack+client@1.0.0/node_modules/@objectstack/client` with its - * dependency as a sibling there, and a console whose `node_modules` holds only - * a SYMLINK to it. That is how a standalone checkout installs the client; under - * the objectstack monorepo it is a workspace link instead, so the realpath - * measurement below builds this layout rather than reading the checkout's - * (objectstack#25733). - */ -function storeLayoutClient(): { linked: string; real: string } { - const root = makeFixtureRoot(); - const storeModules = path.join(root, 'node_modules/.pnpm/@objectstack+client@1.0.0/node_modules'); - const real = path.join(storeModules, '@objectstack/client'); - fs.mkdirSync(real, { recursive: true }); - fs.writeFileSync( - path.join(real, 'package.json'), - JSON.stringify({ name: CLIENT_PACKAGE_NAME, dependencies: { '@objectstack/core': '1.0.0' } }) - ); - fs.mkdirSync(path.join(storeModules, '@objectstack/core'), { recursive: true }); - fs.writeFileSync(path.join(storeModules, '@objectstack/core/package.json'), '{"name":"@objectstack/core"}'); - const linked = path.join(root, 'console/node_modules/@objectstack/client'); - fs.mkdirSync(path.dirname(linked), { recursive: true }); - fs.symlinkSync(real, linked, 'dir'); - return { linked, real: fs.realpathSync(real) }; -} - -/** - * The walk `packageResolvesFrom` performs, re-stated here as a MEASUREMENT - * rather than an import: these cases exist to show what the production walk - * would answer if it ran from the un-realpath'd path, which is precisely what - * the module refuses to do. - */ -function walkResolves(startDir: string, name: string): boolean { - let dir = startDir; - for (;;) { - if (fs.existsSync(path.join(dir, 'node_modules', name))) return true; - const parent = path.dirname(dir); - if (parent === dir) return false; - dir = parent; - } -} - -/** - * A fresh evaluation of the console's Vite config, keyed by `query`. - * - * The specifier is assembled at runtime on purpose — a literal one would pull - * `apps/console/vite.config.ts` into this program, where `tsconfig.scripts.json` - * (no `allowImportingTsExtensions`) turns its own `.ts` imports into TS5097. - * Same reasoning, same spelling as the spec-dist suite next door. - */ -async function loadConsoleConfig(query = ''): Promise { - const specifier = `../../apps/console/vite.config.ts${query}`; - return (await import(/* @vite-ignore */ specifier)).default; -} - -beforeAll(() => { - // Every case below reads the installed client. If the workspace is not - // installed, say so here rather than letting each case invent its own - // explanation for a missing directory. - expect(fs.existsSync(path.join(installedClientDir, 'package.json'))).toBe(true); -}); - -afterAll(() => { - for (const root of fixtureRoots) fs.rmSync(root, { recursive: true, force: true }); -}); - -describe('objectui#6094: unset leaves the hook inert', () => { - it.each([undefined, '', ' '])('returns null for %j', (raw) => { - expect(resolveClientDistInjection(raw)).toBeNull(); - }); - - it('leaves the console config at its baseline alias and fs.allow', async () => { - const config = await loadConsoleConfig(); - expect(config.resolve.alias[CLIENT_PACKAGE_NAME]).toBeUndefined(); - // `server.fs` is absent entirely when neither override is set, so Vite - // keeps its own default allow-list. - expect(config.server.fs).toBeUndefined(); - }); -}); - -describe('objectui#6094: a VALID override is still injected — all three spellings', () => { - const realClientDir = () => fs.realpathSync(installedClientDir); - - it('accepts the package directory', () => { - const injection = resolveClientDistInjection(installedClientDir); - expect(injection).not.toBeNull(); - expect(injection!.aliasTarget).toBe(installedClientDir); - expect(injection!.packageDir).toBe(realClientDir()); - }); - - it('accepts the `dist/` directory inside the package', () => { - const dist = path.join(installedClientDir, 'dist'); - const injection = resolveClientDistInjection(dist); - expect(injection!.aliasTarget).toBe(dist); - // The walk climbed out of `dist/` to the package that owns it. - expect(injection!.packageDir).toBe(realClientDir()); - }); - - it('accepts a built entry FILE inside the package', () => { - const entry = path.join(installedClientDir, 'dist/index.mjs'); - expect(fs.statSync(entry).isFile()).toBe(true); - const injection = resolveClientDistInjection(entry); - expect(injection!.aliasTarget).toBe(entry); - expect(injection!.packageDir).toBe(realClientDir()); - // The alias target is the file, unchanged from the pre-check config; only - // the dependency walk uses the package directory. - expect(injection!.fsAllow).toEqual([ - path.join(installedClientDir, 'dist'), - installedClientDir, - ]); - }); - - it('resolves the dependency walk through the pnpm symlink, not around it', () => { - // The measurement that makes `realpathSync` load-bearing rather than - // tidy, on the store layout: from the symlink path, the client's own - // dependency is NOT found — the walk climbs `console/node_modules` → - // `console` → the fixture root, none of which carry - // `node_modules/@objectstack/core`. From the store path it is found, - // beside the store copy of the client. - const { linked, real } = storeLayoutClient(); - expect(real).not.toBe(linked); - expect(walkResolves(linked, '@objectstack/core')).toBe(false); - expect(walkResolves(real, '@objectstack/core')).toBe(true); - // …so the injection, which walks from `packageDir`, must be reading the - // store path. Without this the check would reject the installed client — - // the one input it must accept. - expect(resolveClientDistInjection(linked)!.packageDir).toBe(real); - // And this checkout's own installed client, store copy or workspace link, - // is judged at its real path too. - expect(resolveClientDistInjection(installedClientDir)!.packageDir).toBe(realClientDir()); - }); - - it('does not demand `peerDependencies` — they come from the consuming app', () => { - const dir = path.join(makeFixtureRoot(), 'client'); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync( - path.join(dir, 'package.json'), - JSON.stringify({ name: CLIENT_PACKAGE_NAME, peerDependencies: { react: '^19' } }) - ); - expect(resolveClientDistInjection(dir)).not.toBeNull(); - }); - - it('tolerates a manifest with no `dependencies` at all', () => { - const dir = path.join(makeFixtureRoot(), 'client'); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: CLIENT_PACKAGE_NAME })); - expect(resolveClientDistInjection(dir)).not.toBeNull(); - }); - - it('injects into the real console config: alias plus a widened fs.allow', async () => { - const baseline = await loadConsoleConfig(); - process.env.OBJECTSTACK_CLIENT_DIST = installedClientDir; - let injected: any; - try { - // A distinct module id (the query suffix) so the baseline evaluation - // above stays intact and the two can be compared. `vi.resetModules()` is - // not an option: the `unit` project runs `isolate: false`. - injected = await loadConsoleConfig('?objectstack-client-dist=6094'); - } finally { - delete process.env.OBJECTSTACK_CLIENT_DIST; - } - - expect(injected.resolve.alias[CLIENT_PACKAGE_NAME]).toBe(installedClientDir); - expect(injected.server.fs.allow).toEqual([ - repoRoot, - path.dirname(installedClientDir), - path.resolve(path.dirname(installedClientDir), '..'), - ]); - // …and the baseline evaluation is untouched by the second one, which is - // what makes the inert case above meaningful. - expect(baseline.resolve.alias[CLIENT_PACKAGE_NAME]).toBeUndefined(); - }); -}); - -describe('objectui#6094: a BROKEN override is refused, naming the variable', () => { - it('refuses a path that does not exist', () => { - const missing = path.join(makeFixtureRoot(), 'no-such-client'); - expect(() => resolveClientDistInjection(missing)).toThrow(/OBJECTSTACK_CLIENT_DIST/); - expect(() => resolveClientDistInjection(missing)).toThrow(/does not exist/); - }); - - // The card's reproduction, both spellings. The entry-file row is the one that - // matters most: a check parameterised over a package directory, handed a - // file, is exactly where "silently does nothing" hides. - const brokenShapes = [ - ['directory', (root: string) => root], - ['`dist/` directory', (root: string) => path.join(root, 'dist')], - ['entry file', (root: string) => path.join(root, 'dist/index.mjs')], - ] as const; - - it.each(brokenShapes)( - 'refuses an out-of-tree copy given as a %s, naming every unresolved dependency', - (_label, pick) => { - const copy = outOfTreeClientCopy(); - const value = pick(copy); - expect(fs.existsSync(value)).toBe(true); - - let thrown: Error | undefined; - try { - resolveClientDistInjection(value); - } catch (error) { - thrown = error as Error; - } - expect(thrown).toBeDefined(); - const message = thrown!.message; - - // 1. It names the variable — the card's actual complaint. - expect(message).toContain('OBJECTSTACK_CLIENT_DIST'); - // 2. It names the dependencies that do not resolve, both of them: these - // are the packages owning the three bare specifiers that survived into - // the bundle (`@objectstack/core/logger`, `@objectstack/spec/api`, - // `@objectstack/spec/data`). - expect(message).toContain('@objectstack/core'); - expect(message).toContain('@objectstack/spec'); - // 3. It names the package directory the walk judged, so a reader can see - // WHICH tree was searched — the fact that decides whether the fix is - // "install deps there" or "point somewhere else". - expect(message).toContain(fs.realpathSync(copy)); - // 4. It is the dependency verdict, not the shape verdict: a check that - // failed the entry-file row with "not inside a package" would satisfy - // (1) while telling the reader nothing true. - expect(message).toMatch(/do(es)? not resolve from/); - } - ); - - it('refuses a bare `dist/` copied out with no manifest anywhere above it', () => { - // The other half of the card's reproduction: copy only `dist/`, and there - // is no `package.json` in any ancestor — so there is no manifest to read - // dependencies from, and the refusal has to say THAT rather than invent a - // dependency verdict. - const root = makeFixtureRoot(); - fs.cpSync(path.join(fs.realpathSync(installedClientDir), 'dist'), path.join(root, 'dist'), { - recursive: true, - dereference: true, - }); - for (const value of [path.join(root, 'dist'), path.join(root, 'dist/index.mjs')]) { - expect(() => resolveClientDistInjection(value)).toThrow(/OBJECTSTACK_CLIENT_DIST/); - expect(() => resolveClientDistInjection(value)).toThrow(/is not inside a/); - expect(() => resolveClientDistInjection(value)).toThrow(/@objectstack\/client/); - } - }); - - it('refuses a path whose nearest manifest is some OTHER package', () => { - // Without the name match the walk would stop at the first `package.json` - // above the value and validate THAT package's dependencies — a check that - // always passes, on the wrong subject. `apps/console` is a real package - // with real, resolvable dependencies, so it would pass exactly that way. - const consoleDir = path.join(repoRoot, 'apps/console'); - expect(() => resolveClientDistInjection(consoleDir)).toThrow(/is not inside a/); - expect(() => resolveClientDistInjection(consoleDir)).toThrow(/OBJECTSTACK_CLIENT_DIST/); - }); - - it('refuses an unreadable manifest instead of skipping past it', () => { - const dir = path.join(makeFixtureRoot(), 'client'); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, 'package.json'), '{ not json'); - expect(() => resolveClientDistInjection(dir)).toThrow(/is not readable JSON/); - expect(() => resolveClientDistInjection(dir)).toThrow(/OBJECTSTACK_CLIENT_DIST/); - }); - - it('fails the console config LOAD, before any module is bundled', async () => { - // Where the refusal lands is the whole value of the card: at config - // evaluation, with the variable named — not a build later, in a resolve - // error naming a specifier the reader never typed. - const copy = outOfTreeClientCopy(); - process.env.OBJECTSTACK_CLIENT_DIST = copy; - try { - await expect(loadConsoleConfig('?objectstack-client-dist=6094-broken')).rejects.toThrow( - /OBJECTSTACK_CLIENT_DIST/ - ); - } finally { - delete process.env.OBJECTSTACK_CLIENT_DIST; - } - }); -}); - -/** - * An injected client stays in `vendor-objectstack`. - * - * objectstack's `build-console.sh` always injects its own client, from a - * workspace path (`/packages/client`) that the baseline group test — - * written against `node_modules/@objectstack/` and pnpm's `@objectstack+` store - * paths — does not match. Unmatched, the client landed in `framework` while - * `data-adapter` required it at top level, and the console died at boot with - * `TypeError: u is not a function` (measured on `9f0c84a44`). The fixture below - * reproduces that layout: a client package outside any `node_modules`, its - * dependencies reachable from a workspace-root `node_modules`. - * - * Reverse verification: with `vendorObjectstackTest` back on the bare - * `specDistInjection ? … : VENDOR_OBJECTSTACK_TEST` expression, the config case - * below goes red (the injected module id is not claimed) while the helper cases - * stay green — the config case is the one that pins the wiring. - */ -describe('an injected client joins the vendor-objectstack group', () => { - function workspaceClient(): string { - const root = makeFixtureRoot(); - const clientDir = path.join(root, 'packages', 'client'); - fs.cpSync(fs.realpathSync(installedClientDir), clientDir, { recursive: true, dereference: true }); - for (const dep of ['core', 'spec']) { - fs.mkdirSync(path.join(root, 'node_modules', '@objectstack', dep), { recursive: true }); - } - return clientDir; - } - - const BASELINE = /([\\/]node_modules[\\/]@objectstack[\\/](?!lint[\\/])|[\\/]@objectstack\+(?!lint@))/; - - it('returns the baseline test itself when nothing is injected', () => { - expect(widenVendorChunkTestForClient(BASELINE, null)).toBe(BASELINE); - }); - - it('claims a module under the injected package, and still everything the baseline claimed', () => { - const clientDir = workspaceClient(); - const injection = resolveClientDistInjection(clientDir); - expect(injection).not.toBeNull(); - const moduleId = path.join(fs.realpathSync(clientDir), 'dist', 'index.js').split(path.sep).join('/'); - - expect(BASELINE.test(moduleId)).toBe(false); - const widened = widenVendorChunkTestForClient(BASELINE, injection); - expect(widened.test(moduleId)).toBe(true); - expect(widened.test('/r/node_modules/@objectstack/spec/dist/index.mjs')).toBe(true); - expect(widened.test('/r/node_modules/.pnpm/@objectstack+core@1.0.0/x.js')).toBe(true); - // A sibling directory that merely shares the prefix is not claimed. - expect(widened.test(`${fs.realpathSync(clientDir).split(path.sep).join('/')}-other/dist/index.js`)).toBe(false); - }); - - it('widens the vendor-objectstack group of the real console config', async () => { - const clientDir = workspaceClient(); - const moduleId = path.join(fs.realpathSync(clientDir), 'dist', 'index.js').split(path.sep).join('/'); - const vendorTest = (config: any): RegExp => - config.build.rollupOptions.output.advancedChunks.groups.find( - (g: { name: string }) => g.name === 'vendor-objectstack' - ).test; - - const baseline = await loadConsoleConfig(); - process.env.OBJECTSTACK_CLIENT_DIST = clientDir; - let injected: any; - try { - injected = await loadConsoleConfig('?objectstack-client-dist=vendor-chunk'); - } finally { - delete process.env.OBJECTSTACK_CLIENT_DIST; - } - - expect(vendorTest(baseline).test(moduleId)).toBe(false); - expect(vendorTest(injected).test(moduleId)).toBe(true); - }); -}); diff --git a/ui/scripts/__tests__/vite-objectstack-spec-dist.test.ts b/ui/scripts/__tests__/vite-objectstack-spec-dist.test.ts deleted file mode 100644 index 4da035aa930..00000000000 --- a/ui/scripts/__tests__/vite-objectstack-spec-dist.test.ts +++ /dev/null @@ -1,1844 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { uiTurboConfigPath } from '../workspace-root.mjs'; -import { build, resolveConfig } from 'vite'; -import type { InlineConfig } from 'vite'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { createRequire } from 'node:module'; -import { fileURLToPath } from 'node:url'; - -import { - SINGLE_ZOD_GUARD_NAME, - SINGLE_ZOD_PLUGIN_NAME, - SPEC_PACKAGE_NAME, - assertSingleZodInstance, - escapeRegExp, - formatConditionReport, - readSpecExportTargets, - resolveConsoleZod, - resolveSpecDistInjection, - versionSatisfiesRange, - zodPackageDirOf, -} from '../vite-objectstack-spec-dist'; -import { BASELINE_CHUNK_TESTS, frameworkPackagesDir, workspaceChunkTests } from '../vite-objectstack-workspace.ts'; - -/** - * objectui#4854 — `apps/console/vite.config.ts` honours `OBJECTSTACK_SPEC_DIST`, - * so a framework build can bundle the console against ITS OWN `@objectstack/spec` - * instead of the last published one (mechanism ruled on objectstack#8134). - * - * Two facts are pinned here, and they pull in opposite directions on purpose: - * - * 1. **Set → every subpath is mapped.** The client hook this mirrors is one - * prefix alias, which is safe only because `@objectstack/client` exports a - * single entry. The spec's map has 21 and redirects each into `dist/`, so a - * copied client line rewrites `@objectstack/spec/ui` to a path that does not - * exist — measured as 214 broken import sites for `/ui` alone. The - * reconciliation case below therefore checks the derivation against Node's - * OWN resolver, entry by entry, and separately sweeps every spec specifier - * this repository actually imports. - * 2. **Unset → nothing moves.** Each of the four surfaces the hook can touch - * (`resolve.alias`, `optimizeDeps.include`, the `vendor-objectstack` chunk - * test, `server.fs.allow`) is pinned at its baseline value, read off the - * REAL console config rather than off the helper, so a hook that stopped - * being conditional turns these red rather than shipping a silently - * different production bundle. - * - * Reverse verification, direction predicted BEFORE running. Both plain RED — the - * derivation is the sole input to case 1 and the `null` branch the sole input to - * case 2, so a mutation in either can only ADD findings; neither has the - * count-shaped or inverted direction some pins do. Predicted, then measured: - * - * - **Drop one subpath** from the derived table (`… && s !== - * '@objectstack/spec/ui'`) → 5 red. Worth recording precisely, because the - * obvious expectation is wrong: the dropped specifier does NOT show up as - * "no alias". It falls through to the bare entry and is reported as - * `@objectstack/spec/ui -> …/dist/index.mjs/ui` — a path that cannot exist - * — so the finding lands in the repo sweep's `unresolved` list, while - * `missing` stays empty. The reconciliation case fails one step earlier, on - * 19-vs-18. - * - **Make the hook unconditional** (default the env read to the installed - * spec dir) → 2 red, both in the console-config block: the alias table gains - * 19 `@objectstack` keys, and `optimizeDeps.include` drops from 7 to 3 - * (measured while the list still carried the two map entries that - * objectui#10865 removed; the four spec entries are the ones that drop). - * - * The real-build cases at the bottom carry their own control rather than a - * mutation: the same bundle is built a second time through the literal - * client-hook alias, and a green build there would mean this whole derivation is - * unnecessary. Measured: it fails with 2 resolve errors. - */ - -const require_ = createRequire(import.meta.url); -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); - -/** - * The baseline `vendor-objectstack` group test, as the console config spells it. - * - * Mirror of `VENDOR_OBJECTSTACK_TEST` in `apps/console/vite.config.ts`. When - * that constant changes this one must change with it — but do NOT treat the two - * assertions below as a string to re-paste. They pin two different things: - * `.source` equality pins that the spec-dist override left the baseline INERT, - * and `startsWith` pins that the override WIDENED the baseline instead of - * replacing it. The semantic assertions beside them say which modules the - * grouping is supposed to catch, so a future edit that keeps the shape while - * changing the membership still fails. - * - * The negative lookaheads exclude `@objectstack/lint` from the group: it is - * imported lazily (app-shell's `capabilityLint.ts`) and a group that claims it - * overrides that laziness, putting ~89 KiB gzipped on every console page load - * (objectui#5266). Both alternatives need the guard — under pnpm the module id - * contains both `/@objectstack+` and `/node_modules/@objectstack/`. - */ -const BASE_VENDOR_TEST = - /([\\/]node_modules[\\/]@objectstack[\\/](?!lint[\\/])|[\\/]@objectstack\+(?!lint@))/; - -/** - * The vendor test the console config builds in THIS checkout's layout with no - * override set: `BASE_VENDOR_TEST` standalone, the same arms plus the - * framework's `packages/` when the tree is `ui/` of the objectstack monorepo - * (objectstack#25733). "Inert" means "unmoved from this", in either layout; - * that the standalone arm IS the baseline literal, byte for byte, is pinned on - * a fixture layout in `vite-objectstack-workspace.test.ts`. - */ -const LAYOUT_VENDOR_TEST = workspaceChunkTests(frameworkPackagesDir(path.join(repoRoot, 'apps/console'))).vendor; - -/** - * The baseline "this module id is the spec" test, as the console config spells it. - * - * Mirror of `SPEC_MODULE_TEST` in `apps/console/vite.config.ts`. Deliberately - * NOT the same regex as `BASE_VENDOR_TEST`: that one is the whole vendor scope - * minus the linter, and the counter-probe it feeds has to be able to distinguish - * "an eager chunk holds the spec" from "an eager chunk holds some - * `@objectstack` package". objectui#5388 is what happens when the two are - * conflated in the other direction — one consumer reading the injection, the - * other not. - */ -const BASE_SPEC_TEST = /@objectstack[\\/+]spec/; - -/** The installed spec package — a real, fully built override target. */ -const installedSpecDir = path.dirname(require_.resolve('@objectstack/spec/package.json')); - -/** - * The console's zod anchor, as the console config spells it (`CONSOLE_ZOD_ANCHOR` - * in `apps/console/vite.config.ts`): the workspace package whose zod copy an - * injected build is pinned to (objectui#11327). - */ -const CONSOLE_ZOD_ANCHOR = path.join(repoRoot, 'packages/app-shell'); - -const inject = (raw: string | undefined, consoleZodFrom: string = CONSOLE_ZOD_ANCHOR) => - resolveSpecDistInjection(raw, { - vendorChunkTest: BASE_VENDOR_TEST, - specModuleTest: BASE_SPEC_TEST, - consoleZodFrom, - }); - -/** - * A fresh evaluation of the console's Vite config, keyed by `query`. - * - * The specifier is assembled at runtime on purpose. A literal one would pull - * `apps/console/vite.config.ts` into THIS program, where it does not belong — - * `tsconfig.scripts.json` leaves `allowImportingTsExtensions` off, so its own - * `.ts` imports become TS5097 in a project that never compiled them before. - * Vitest resolves the runtime specifier relative to this file identically. - */ -async function loadConsoleConfig(query = ''): Promise { - const specifier = `../../apps/console/vite.config.ts${query}`; - return (await import(/* @vite-ignore */ specifier)).default; -} - -/** - * Vite's own alias matcher, transcribed from `node_modules/vite` (`matches()` in - * the alias plugin): exact hit, or the specifier continues past a separator. - * First match wins, which is why the table's key ORDER decides correctness. - */ -function resolveThroughAliases(aliases: Record, specifier: string): string | null { - for (const [find, replacement] of Object.entries(aliases)) { - if (specifier === find || specifier.startsWith(`${find}/`)) { - return specifier.replace(find, replacement); - } - } - return null; -} - -/** - * A package that must resolve, so a run in which the oracle resolved NOTHING is - * distinguishable from a run in which it agreed with everything. - */ -const VITE_ORACLE_CONTROL = 'react'; - -/** - * A throwaway root for the Vite oracle below, made under `node_modules/`. - * - * Two constraints decide this path and they pull in opposite directions. - * - * 1. It may NOT sit at `os.tmpdir()` like the fixtures elsewhere in this file. - * Bare-specifier resolution walks up from the importer looking for - * `node_modules`, and from `/tmp` there is none to find. Measured, and worth - * recording because it fails in the direction that reads as a result: every - * specifier came back `(unresolved)`, which an oracle without - * `VITE_ORACLE_CONTROL` would have reported as "no disagreements". - * 2. It may NOT sit directly in the repo root either (objectui#9468). The i18n - * dead-key gate sweeps the whole repo root in one `grep -rFn` pass, and that - * grep exits 2 — not 1 — when a directory it has already enumerated is - * removed before it descends into it. `textFootprint()` in - * `scripts/check-i18n-dead-keys.mjs` rethrows every status but 1 deliberately - * and its comment says why, so an oracle run scheduled concurrently with that - * gate's test in the same shard took the whole shard down with it. The - * occurrences, with their job ids, are recorded on objectui#9468 — including - * one that ejected an already-green pull request from the merge queue. - * - * `/node_modules` satisfies both: it is still inside the repository, - * so the walk-up in (1) finds it, and it is skipped by the repo-wide scanners in - * `scripts/` — the i18n sweep's own skip set among them — so nothing transient - * placed here is ever walked. `check-action-ref-convention.test.ts` already puts - * its throwaway root in the same place. - * - * ⚠ The invariant is POSITIONAL, not a name. Teaching one scanner to skip a - * `.vite-oracle-*` prefix would fix that scanner and leave every other repo-root - * sweep exposed to the next scratch directory anyone adds; the pin below - * therefore asserts the position, not the prefix. - */ -function makeOracleScratchRoot(): string { - return fs.mkdtempSync(path.join(repoRoot, 'node_modules', '.vite-oracle-9408-')); -} - -/** - * What VITE resolves each specifier to, with no alias table in play. - * - * The oracle for objectui#9408. Node's `import.meta.resolve` cannot express the - * `browser` condition — it does not satisfy it — so on the five entries whose - * map ranks `browser` first it answers with the Node arm no matter what the map - * says, which is the exact failure this hook shipped for. Vite satisfies it, and - * Vite is what the console builds with, so it is the resolver the derivation has - * to agree with. - * - * The throwaway root it builds in comes from `makeOracleScratchRoot()`, whose - * docstring carries the two constraints that decide where such a root may live. - */ -async function viteResolves(specifiers: string[]): Promise> { - const resolved = new Map(); - const dir = makeOracleScratchRoot(); - try { - const entry = path.join(dir, 'entry.mjs'); - fs.writeFileSync(entry, 'export const probe = 1;\n'); - await build({ - root: dir, - logLevel: 'silent', - configFile: false, - build: { write: false, lib: { entry, formats: ['es'], fileName: 'oracle' } }, - plugins: [ - { - name: 'objectui-9408-resolve-oracle', - async buildStart(this: { resolve: (id: string, importer: string, opts: object) => Promise<{ id: string } | null> }) { - for (const specifier of specifiers) { - const hit = await this.resolve(specifier, entry, { skipSelf: true }); - resolved.set(specifier, hit ? fs.realpathSync(hit.id) : null); - } - }, - }, - ], - }); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - return resolved; -} - -describe('objectui#9468: the oracle\'s scratch root is never a repo-root entry', () => { - it('makes it under node_modules, which the repo-wide sweeps already skip', () => { - // Asserts the REAL creation rather than a constant: a scratch directory - // that is a direct child of the repo root is what `grep -r ` - // enumerates and then trips over when it vanishes mid-walk. Moving it back - // there would keep every other assertion in this file green, which is why - // the position needs an assertion of its own. - const dir = makeOracleScratchRoot(); - try { - expect(path.relative(repoRoot, dir).split(path.sep)[0]).toBe('node_modules'); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); -}); - -describe('objectui#4854: OBJECTSTACK_SPEC_DIST is subpath-aware', () => { - it('maps every exports-map entry to the file the BUNDLER resolves', async () => { - const injection = inject(installedSpecDir); - expect(injection).not.toBeNull(); - - const manifest = JSON.parse( - fs.readFileSync(path.join(installedSpecDir, 'package.json'), 'utf8') - ) as { exports: Record }; - const declared = Object.keys(manifest.exports); - - // Anti-vacuity: the map this is reconciled against is the measured 21-entry - // one, not an empty object a silently-changed reader would also "cover". - // 18 -> 19 on the @objectstack/spec 17.2.0 refresh (objectui#5668): the - // one added subpath, measured by diffing 17.1.0's exports map against - // 17.2.0's, is `./meta-spelling` — nothing was removed. The pin did its - // job on that bump: the un-updated 18 turned this red in CI rather than - // letting the new entry ride through unreconciled. - // 19 -> 20 on the @objectstack/spec 17.5.0 refresh (objectui#11073), - // measured by diffing 17.4.0's exports map against 17.5.0's: `./cloud` was - // REMOVED (the last step of the objectstack#16325 chain objectui#8225 names - // below), `./api-assembled` and `./marketplace` were ADDED. The pin did its - // job again: the un-updated 19 turned this red on the bump's CI. - // 20 -> 21 on the @objectstack/spec 17.6.0 refresh (objectui#11438), - // measured by diffing 17.5.0's exports map against 17.6.0's: `./migrations` - // was ADDED (the spec's migrations-entry-split; it declares no `browser` - // arm), nothing was removed. The un-updated 20 turned this red on the bump. - expect(declared.length).toBe(21); - expect(Object.keys(injection!.aliases).length).toBe(declared.length); - - // objectui#9408 — the population splits, and WHICH oracle applies is the - // whole point. Node's resolver does not satisfy `browser`, so for an entry - // that declares one it answers with the Node arm BY CONSTRUCTION and cannot - // be the oracle for a browser bundler. Partitioned off the MANIFEST, never - // off this module's own output: an oracle chosen by the thing under test - // agrees with it for free. - const declaresBrowser = (key: string): boolean => { - const value = manifest.exports[key]; - return ( - value !== null && typeof value === 'object' && !Array.isArray(value) && Object.hasOwn(value, 'browser') - ); - }; - const browserKeys = declared.filter(declaresBrowser); - const nodeOracleKeys = declared.filter((k) => !declaresBrowser(k)); - - // Anti-vacuity, both halves. 5 on @objectstack/spec 17.5.0 (`.`, `./data`, - // `./system`, `./kernel`, `./api-assembled`). Still 5 after the 17.5.0 - // refresh (objectui#11073) but NOT the same 5: 17.4.0's set carried - // `./cloud`, which left with its subpath, and the new `./api-assembled` - // arrived browser-first. Pinned exactly, like the 21 above, - // because an unpinned browser set is precisely how this went unnoticed: - // objectui#9408 was FILED naming `./api` as browser-first, and by the time - // it was worked `./api` had lost its browser arm upstream with nothing - // anywhere to notice the move. A bump re-pins this and says what changed. - expect(browserKeys.sort()).toEqual(['.', './api-assembled', './data', './kernel', './system']); - expect(nodeOracleKeys.length).toBe(declared.length - browserKeys.length); - expect(nodeOracleKeys.length).toBeGreaterThan(0); - - const missing: string[] = []; - const mismatched: string[] = []; - const notDiverged: string[] = []; - for (const key of declared) { - const specifier = key === '.' ? SPEC_PACKAGE_NAME : `${SPEC_PACKAGE_NAME}/${key.slice(2)}`; - const aliased = resolveThroughAliases(injection!.aliases, specifier); - if (!aliased) { - missing.push(specifier); - continue; - } - // What Node's ESM resolver returns for the same specifier under the - // `import` condition — the algorithm, not a second reading of the map. - const node = fs.realpathSync(fileURLToPath(import.meta.resolve(specifier))); - if (declaresBrowser(key)) { - // The divergence is the FIX, so it is pinned as such rather than - // tolerated: the map ranks `browser` first, this module honours that, - // and Node — which cannot — must therefore land somewhere else. Were - // these to agree again, the array-order bug would be back. - if (fs.realpathSync(aliased) === node) { - notDiverged.push(`${specifier}: both -> ${node}`); - } - } else if (fs.realpathSync(aliased) !== node) { - mismatched.push(`${specifier}: alias -> ${aliased}, node -> ${node}`); - } - } - expect(missing, 'exports-map entries with no alias — these keep resolving to the INSTALLED spec').toEqual([]); - expect(mismatched, 'aliases disagreeing with Node on entries Node CAN express').toEqual([]); - expect( - notDiverged, - 'entries whose map ranks `browser` first but that still resolve to the Node arm — objectui#9408' - ).toEqual([]); - }); - - it('agrees with a REAL Vite build on every entry, `browser` arm included', async () => { - // The oracle that actually counts. Vite is the resolver this hook MODELS, - // and unlike Node it satisfies `browser`, so it can answer for all 21 - // entries where Node can only answer for 16. Run through a real build - // rather than a transcription, for the same reason the alias-matcher cases - // below bundle for real: a transcribed algorithm agrees with its own - // transcription, not with Vite. - const injection = inject(installedSpecDir)!; - const specifiers = Object.keys(injection.aliases); - const resolved = await viteResolves([...specifiers, VITE_ORACLE_CONTROL]); - - // Control with a KNOWN direction, in the SAME run: a package that must - // resolve. Without it an oracle that silently resolved NOTHING would report - // every specifier as "no disagreement" and read as a clean pass. - expect(resolved.get(VITE_ORACLE_CONTROL), 'Vite oracle resolved nothing — broken instrument').toBeTruthy(); - - const disagreed: string[] = []; - for (const specifier of specifiers) { - const vite = resolved.get(specifier); - if (!vite) { - disagreed.push(`${specifier}: vite -> (unresolved)`); - continue; - } - const ours = fs.realpathSync(injection.aliases[specifier]); - if (ours !== vite) disagreed.push(`${specifier}: hook -> ${ours}, vite -> ${vite}`); - } - expect(disagreed, 'entries where the hook and Vite pick different files').toEqual([]); - - // Anti-vacuity: the sweep really did exercise the browser arm, i.e. the - // agreement above is not agreement about 21 Node-arm files. - const browserArm = specifiers.filter((s) => resolved.get(s)?.includes(`${path.sep}browser${path.sep}`)); - expect(browserArm.length).toBe(5); - }); - - it('does not assume `dist//index.mjs` — `./openapi.json` is the counterexample', () => { - const injection = inject(installedSpecDir)!; - // The obvious hand-written rule the issue sketched would emit - // `dist/openapi.json/index.mjs` here. The map says otherwise, and the map wins. - expect(injection.aliases[`${SPEC_PACKAGE_NAME}/openapi.json`]).toBe( - path.join(installedSpecDir, 'json-schema/openapi.json') - ); - expect(injection.aliases[`${SPEC_PACKAGE_NAME}/package.json`]).toBe( - path.join(installedSpecDir, 'package.json') - ); - expect(injection.aliases[`${SPEC_PACKAGE_NAME}/ui`]).toBe( - path.join(installedSpecDir, 'dist/ui/index.mjs') - ); - }); - - it('orders the bare specifier LAST so subpaths win the prefix match', () => { - const keys = Object.keys(inject(installedSpecDir)!.aliases); - expect(keys[keys.length - 1]).toBe(SPEC_PACKAGE_NAME); - expect(keys.filter((k) => k === SPEC_PACKAGE_NAME)).toHaveLength(1); - - // The consequence, stated as behaviour: a subpath resolves to its own entry… - const injection = inject(installedSpecDir)!; - expect(resolveThroughAliases(injection.aliases, `${SPEC_PACKAGE_NAME}/ui`)).toBe( - injection.aliases[`${SPEC_PACKAGE_NAME}/ui`] - ); - // …and a subpath the override does NOT declare is rewritten to a path that - // cannot exist, i.e. a loud resolve error naming the specifier, rather than - // quietly falling through to the installed spec. - const undeclared = resolveThroughAliases(injection.aliases, `${SPEC_PACKAGE_NAME}/not-a-real-subpath`); - expect(undeclared).toBe(`${injection.aliases[SPEC_PACKAGE_NAME]}/not-a-real-subpath`); - expect(fs.existsSync(undeclared!)).toBe(false); - }); - - it('covers every spec specifier this repository imports', () => { - const injection = inject(installedSpecDir)!; - const specifiers = collectSpecSpecifiers(); - - // Anti-vacuity: the sweep found the measured surface, not an empty scan. - // 16 distinct specifiers since objectui#8225 removed the repository's only - // `@objectstack/spec/cloud` literal (the `Cloud` namespace re-export in - // `packages/types/src/index.ts`; step 2 of the objectstack#16325 chain that - // deletes the `/cloud` subpath upstream) — 17 before that, `/ui` and `/data` - // the heaviest. A floor, so a specifier gained does not move it; one lost - // does, and the commit that loses it re-pins it here and says why. - expect(specifiers.size).toBeGreaterThanOrEqual(16); - expect([...specifiers]).toContain(`${SPEC_PACKAGE_NAME}/ui`); - expect([...specifiers]).toContain(SPEC_PACKAGE_NAME); - - const unresolved: string[] = []; - for (const specifier of specifiers) { - const aliased = resolveThroughAliases(injection.aliases, specifier); - if (!aliased || !fs.existsSync(aliased)) unresolved.push(`${specifier} -> ${aliased ?? '(no alias)'}`); - } - expect(unresolved, 'specifiers the injected spec cannot serve').toEqual([]); - }); - - it('allows the dev server to read the injected package', () => { - const injection = inject(installedSpecDir)!; - expect(injection.fsAllow).toEqual([fs.realpathSync(installedSpecDir)]); - expect(injection.packageDir).toBe(fs.realpathSync(installedSpecDir)); - }); - - it('keeps the injected spec inside the `vendor-objectstack` chunk', () => { - const injection = inject(installedSpecDir)!; - const outOfTree = '/framework/packages/spec'; - const outOfTreeInjection = resolveSpecDistInjection(installedSpecDir, { - vendorChunkTest: BASE_VENDOR_TEST, - specModuleTest: BASE_SPEC_TEST, - consoleZodFrom: CONSOLE_ZOD_ANCHOR, - })!; - - // The baseline test cannot see an injected package: that is the whole - // reason the group test is widened rather than left alone. - expect(BASE_VENDOR_TEST.test(`${outOfTree}/dist/ui/index.mjs`)).toBe(false); - expect(injection.vendorChunkTest.test(`${injection.packageDir}/dist/ui/index.mjs`)).toBe(true); - // Widened, never replaced — the installed-package arms still match. - expect(outOfTreeInjection.vendorChunkTest.test('/repo/node_modules/@objectstack/client/dist/index.mjs')).toBe(true); - expect(outOfTreeInjection.vendorChunkTest.test('/repo/node_modules/.pnpm/@objectstack+spec@1/x.mjs')).toBe(true); - // And it stays a spec-shaped test, not a catch-all. - expect(injection.vendorChunkTest.test('/repo/packages/core/src/index.ts')).toBe(false); - // The baseline arms' `@objectstack/lint` exclusion survives the widening. - expect( - outOfTreeInjection.vendorChunkTest.test( - '/repo/node_modules/.pnpm/@objectstack+lint@17.0.0/node_modules/@objectstack/lint/dist/index.js' - ) - ).toBe(false); - }); - - it('keeps the injected spec RECOGNISABLE AS THE SPEC, for the counter-probe', () => { - // objectui#5388. The chunk grouping was not the only consumer of "where - // does the spec live" — `assertLazyLinterStaysLazy` asks the same question - // of the emitted module ids, and answering it from an un-widened private - // regex failed every build made with the override set. - const injection = inject(installedSpecDir)!; - const injectedId = `${injection.packageDir}/dist/ui/index.mjs`; - - // Anti-vacuity: the baseline genuinely cannot see an injected package. If - // this ever went true the assertion below would pass without the widening - // doing anything, and the bug would be back with a green test over it. - expect(BASE_SPEC_TEST.test('/framework/packages/spec/dist/ui/index.mjs')).toBe(false); - expect(injection.specModuleTest.test(injectedId)).toBe(true); - - // Widened, never replaced — an injected build still resolves plenty of - // installed packages through node_modules, in both spellings. - expect(injection.specModuleTest.test('/repo/node_modules/@objectstack/spec/dist/index.js')).toBe(true); - expect(injection.specModuleTest.test('/repo/node_modules/.pnpm/@objectstack+spec@17.0.0/x.js')).toBe(true); - - // And it stays a SPEC test, not the vendor group's. The counter-probe's job - // is to prove the walk can see the spec chunk specifically; a test that also - // matched `@objectstack/client` would keep the build green by lowering the - // bar rather than by seeing the spec. - expect(injection.specModuleTest.test('/repo/node_modules/@objectstack/client/dist/index.js')).toBe(false); - expect(injection.vendorChunkTest.test('/repo/node_modules/@objectstack/client/dist/index.js')).toBe(true); - expect(injection.specModuleTest.test('/repo/packages/core/src/index.ts')).toBe(false); - }); - - it('accepts a `dist/` or entry-file spelling of the same package', () => { - const fromDir = inject(installedSpecDir)!; - const fromDist = inject(path.join(installedSpecDir, 'dist'))!; - const fromEntry = inject(path.join(installedSpecDir, 'dist/index.mjs'))!; - expect(fromDist.aliases).toEqual(fromDir.aliases); - expect(fromEntry.aliases).toEqual(fromDir.aliases); - }); -}); - -describe('objectui#4854: the override fails loudly, never leniently', () => { - it('is inert when unset, empty, or blank', () => { - for (const raw of [undefined, '', ' ']) { - expect(inject(raw)).toBeNull(); - } - }); - - it('throws when the path does not exist', () => { - expect(() => inject('/nope/objectstack-spec-4854')).toThrow(/does not exist/); - }); - - it('throws when the path is not the spec package', () => { - // The repo root has a `package.json`, so this fails on IDENTITY rather than - // on absence — the case a name-blind walk-up would accept. - expect(() => inject(repoRoot)).toThrow(/is not inside a `@objectstack\/spec` package/); - }); - - it('throws when the built package is missing a file its exports map names', () => { - const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'spec-dist-4854-')); - try { - fs.writeFileSync( - path.join(fixture, 'package.json'), - JSON.stringify({ - name: SPEC_PACKAGE_NAME, - exports: { - '.': { import: { types: './dist/index.d.mts', default: './dist/index.mjs' } }, - }, - }) - ); - // A half-built override is exactly how a silent skew would return: the - // bundle would keep resolving `@objectstack/spec` from the lockfile while - // the build reported success. - expect(() => inject(fixture)).toThrow(/does not contain/); - } finally { - fs.rmSync(fixture, { recursive: true, force: true }); - } - }); - - it('throws on a wildcard exports pattern rather than guessing', () => { - const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'spec-dist-4854-')); - try { - fs.mkdirSync(path.join(fixture, 'dist')); - fs.writeFileSync(path.join(fixture, 'dist/index.mjs'), 'export {};\n'); - fs.writeFileSync( - path.join(fixture, 'package.json'), - JSON.stringify({ - name: SPEC_PACKAGE_NAME, - exports: { '.': './dist/index.mjs', './*': './dist/*/index.mjs' }, - }) - ); - expect(() => inject(fixture)).toThrow(/wildcard pattern/); - } finally { - fs.rmSync(fixture, { recursive: true, force: true }); - } - }); - - it('never picks the `types` condition, which sits first in the real map', () => { - for (const target of readSpecExportTargets(installedSpecDir).values()) { - expect(target.endsWith('.d.ts') || target.endsWith('.d.mts')).toBe(false); - } - }); -}); - -/** - * objectui#5391 — `readSpecExportTargets` validates the override's own FILES; - * it says nothing about what those files `import`. A spec dist built without - * a reachable `node_modules` passed cleanly and only failed once a consuming - * bundler tried to resolve a bare specifier deep inside the injected build — - * measured (module header, `assertSpecDependenciesResolve`) as a rolldown - * `failed to resolve zod` a build-length after the override was read, in an - * error naming `zod` and never `OBJECTSTACK_SPEC_DIST`. - */ -describe('objectui#5391: the override validates its own dependencies too', () => { - /** A minimal, legally-shaped spec package with a declared `dependencies` entry. */ - function makeFixtureWithDependency(dependencies: Record): string { - const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'spec-dist-5391-')); - fs.mkdirSync(path.join(fixture, 'dist')); - fs.writeFileSync(path.join(fixture, 'dist/index.mjs'), 'export {};\n'); - fs.writeFileSync( - path.join(fixture, 'package.json'), - JSON.stringify({ - name: SPEC_PACKAGE_NAME, - exports: { '.': './dist/index.mjs' }, - dependencies, - }) - ); - return fixture; - } - - it('throws, naming the override and the unresolvable dependency, when a declared dependency has no reachable `node_modules`', () => { - const fixture = makeFixtureWithDependency({ zod: '^4.0.0' }); - try { - // The override (packageDir/manifest path) AND the missing dependency - // must both be in the message — a fail-fast that only says "validation - // failed" reproduces the original diagnosis problem one step earlier. - expect(() => inject(fixture)).toThrow(/OBJECTSTACK_SPEC_DIST/); - expect(() => inject(fixture)).toThrow(new RegExp(escapeRegExp(fs.realpathSync(fixture)))); - expect(() => inject(fixture)).toThrow(/does not resolve.*zod/s); - } finally { - fs.rmSync(fixture, { recursive: true, force: true }); - } - }); - - it('names every unresolvable dependency, not just the first', () => { - const fixture = makeFixtureWithDependency({ zod: '^4.0.0', 'pg-connection-string': '^2.0.0' }); - try { - expect(() => inject(fixture)).toThrow(/zod, pg-connection-string/); - } finally { - fs.rmSync(fixture, { recursive: true, force: true }); - } - }); - - it('passes once the declared dependency is reachable from the package directory', () => { - const fixture = makeFixtureWithDependency({ zod: '^4.0.0' }); - try { - fs.mkdirSync(path.join(fixture, 'node_modules')); - // A minimal but real package at the expected location — the fixture - // does not need the REAL zod, only something a directory walk finds. - fs.mkdirSync(path.join(fixture, 'node_modules/zod')); - fs.writeFileSync( - path.join(fixture, 'node_modules/zod/package.json'), - JSON.stringify({ name: 'zod', version: '0.0.0-fixture' }) - ); - expect(inject(fixture)).not.toBeNull(); - } finally { - fs.rmSync(fixture, { recursive: true, force: true }); - } - }); - - it('finds a dependency hoisted to an ANCESTOR `node_modules`, not only the package\'s own', () => { - // pnpm/npm both routinely hoist a dependency above the package that - // declares it; the check has to walk up, not just look inside `packageDir`. - // - // The whole fixture tree lives under its OWN `mkdtempSync` root — never at - // `os.tmpdir()` itself — because that directory is shared with every other - // process on the machine (parallel test runs, other agents' worktrees); a - // `node_modules` planted directly there would be both a collision risk and - // stray shared state this suite has no business creating. - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spec-dist-5391-')); - try { - const fixture = path.join(root, 'workspace', SPEC_PACKAGE_NAME.split('/')[1]); - fs.mkdirSync(path.join(fixture, 'dist'), { recursive: true }); - fs.writeFileSync(path.join(fixture, 'dist/index.mjs'), 'export {};\n'); - fs.writeFileSync( - path.join(fixture, 'package.json'), - JSON.stringify({ - name: SPEC_PACKAGE_NAME, - exports: { '.': './dist/index.mjs' }, - dependencies: { zod: '^4.0.0' }, - }) - ); - // Hoisted to `root/workspace/node_modules` — an ANCESTOR of `fixture`, - // not `fixture`'s own `node_modules` (which does not exist here at all). - fs.mkdirSync(path.join(root, 'workspace/node_modules/zod'), { recursive: true }); - fs.writeFileSync( - path.join(root, 'workspace/node_modules/zod/package.json'), - JSON.stringify({ name: 'zod', version: '0.0.0-fixture' }) - ); - expect(inject(fixture)).not.toBeNull(); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } - }); - - it('does NOT require `peerDependencies` to resolve from the package directory', () => { - // A peer dependency is supplied by the CONSUMING app, not the override's - // own tree — requiring it here would fail a correctly built override. - const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'spec-dist-5391-')); - try { - fs.mkdirSync(path.join(fixture, 'dist')); - fs.writeFileSync(path.join(fixture, 'dist/index.mjs'), 'export {};\n'); - fs.writeFileSync( - path.join(fixture, 'package.json'), - JSON.stringify({ - name: SPEC_PACKAGE_NAME, - exports: { '.': './dist/index.mjs' }, - peerDependencies: { ai: '^7.0.0' }, - }) - ); - expect(inject(fixture)).not.toBeNull(); - } finally { - fs.rmSync(fixture, { recursive: true, force: true }); - } - }); - - it('is inert when `dependencies` is absent', () => { - const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'spec-dist-5391-')); - try { - fs.mkdirSync(path.join(fixture, 'dist')); - fs.writeFileSync(path.join(fixture, 'dist/index.mjs'), 'export {};\n'); - fs.writeFileSync( - path.join(fixture, 'package.json'), - JSON.stringify({ name: SPEC_PACKAGE_NAME, exports: { '.': './dist/index.mjs' } }) - ); - expect(inject(fixture)).not.toBeNull(); - } finally { - fs.rmSync(fixture, { recursive: true, force: true }); - } - }); - - it('the REAL installed spec — measured to declare `zod` and `pg-connection-string` — passes', () => { - // Anti-vacuity: this only means something if the installed package - // actually declares dependencies for the check to walk. - const manifest = JSON.parse( - fs.readFileSync(path.join(installedSpecDir, 'package.json'), 'utf8') - ) as { dependencies?: Record }; - expect(Object.keys(manifest.dependencies ?? {}).length).toBeGreaterThan(0); - expect(manifest.dependencies).toHaveProperty('zod'); - expect(inject(installedSpecDir)).not.toBeNull(); - }); - - /** - * The regression this hook exists to catch, in a form that pins the FIX - * rather than only the symptom. - * - * `apps/console/node_modules/.bin/vite` — pnpm's own shim — exports - * `NODE_PATH` pointing at pnpm's flat `.pnpm/node_modules` hoist directory, - * which holds a copy of nearly every package the workspace has ever - * installed. Node's module resolution consults `NODE_PATH` - * (`Module.globalPaths`) REGARDLESS of an explicit `paths` option, so a - * dependency check built on `require.resolve(name, { paths: [packageDir] })` - * silently passed for ANY package name that happens to be hoisted anywhere - * in the workspace — which, for a real npm package name like `typescript`, - * is every one of them. Measured: that version of this check never caught - * anything when run through the real `vite` CLI, only through a bare `node` - * invocation that never set `NODE_PATH` — the one path this hook actually - * runs on in production. `typescript` here stands in for that failure mode: - * a name this repository has installed SOMEWHERE, but not inside this - * fixture's own tree, which is the only tree that should count. - */ - it('is not fooled by a dependency name that resolves globally but not from the package directory', () => { - // Anti-vacuity: `typescript` really is reachable from somewhere ordinary - // in this repo — the failure mode under test is specifically that - // reachability elsewhere must NOT count as reachability from `packageDir`. - expect(() => require_.resolve('typescript')).not.toThrow(); - const fixture = makeFixtureWithDependency({ typescript: '^5.0.0' }); - try { - expect(() => inject(fixture)).toThrow(/does not resolve.*typescript/s); - } finally { - fs.rmSync(fixture, { recursive: true, force: true }); - } - }); -}); - -/* -------------------------------------------------------------------------- */ -/* objectui#9408 — whose precedence wins, and saying which arm was taken. */ -/* -------------------------------------------------------------------------- */ - -/** - * The resolver used to walk its OWN `['import', 'module', 'browser', 'default']` - * array, so a consumer-side ranking overrode the precedence the package - * declared with its key order. Measured on `@objectstack/spec@17.4.0`, both - * sides in one run: the hook returned `dist/index.mjs` for the five - * `browser`-first entries while Vite returned `dist/browser/index.mjs`. - * - * The cases above pin that against the REAL map, which is the strongest form - * but also the most perishable — the filed card named `./api` as browser-first - * and upstream had already dropped that arm by the time it was worked. These - * cases pin the ALGORITHM instead, on fixtures that cannot move under us, so - * the rule survives any shape the published map takes next. - * - * Reverse verification, direction predicted before running: plain RED, and the - * mutation is the bug itself. Restoring the array walk - * (`for (const condition of ['import','module','browser','default'])` against - * `Object.hasOwn`) → the `browser`-first case below fails with - * `dist/node.mjs`, and the `import`-first case stays GREEN. That asymmetry is - * the point: it is why reordering the array was never the fix, and why a - * one-direction fixture would have ratified the bug. - */ -describe('objectui#9408: the exports map declares precedence, not this module', () => { - /** - * A legal spec package whose one subpath declares `browser` and `import` in a - * caller-chosen ORDER, with a distinct file behind each arm. - */ - function makeOrderedFixture(order: ('browser' | 'import')[]): string { - const fixture = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'spec-dist-9408-'))); - fs.mkdirSync(path.join(fixture, 'dist'), { recursive: true }); - for (const name of ['node', 'browser']) { - fs.writeFileSync(path.join(fixture, `dist/${name}.mjs`), `export const arm = '${name}';\n`); - } - // `types` first inside every arm, exactly as the real map spells it — the - // condition a key-order walk reaches BEFORE anything it should take. - const arms: Record = { - browser: { types: './dist/browser.d.mts', default: './dist/browser.mjs' }, - import: { types: './dist/node.d.mts', default: './dist/node.mjs' }, - }; - fs.writeFileSync( - path.join(fixture, 'package.json'), - JSON.stringify({ - name: SPEC_PACKAGE_NAME, - // Object key order IS the declaration order for these names: they are - // not integer-like, so JavaScript preserves insertion order. - exports: { '.': Object.fromEntries(order.map((c) => [c, arms[c]])) }, - }) - ); - return fixture; - } - - const armOf = (fixture: string): string => - path.basename(inject(fixture)!.aliases[SPEC_PACKAGE_NAME]); - - it('takes `browser` when the map ranks `browser` first', () => { - const fixture = makeOrderedFixture(['browser', 'import']); - try { - expect(armOf(fixture)).toBe('browser.mjs'); - } finally { - fs.rmSync(fixture, { recursive: true, force: true }); - } - }); - - it('takes `import` when the SAME two conditions are ranked the other way', () => { - // The control, and the reason direction (b) on the card — reordering the - // array to put `browser` first — would have been wrong. A module that - // simply prefers `browser` passes the case above and fails this one. - const fixture = makeOrderedFixture(['import', 'browser']); - try { - expect(armOf(fixture)).toBe('node.mjs'); - } finally { - fs.rmSync(fixture, { recursive: true, force: true }); - } - }); - - it('still never takes `types`, which a key-order walk reaches first of all', () => { - // Under the old array walk `types` was skipped because it was absent from - // the preference list. Under a key-order walk it is the FIRST key in every - // arm, so the omission stopped being incidental and became load-bearing. - for (const order of [['browser', 'import'], ['import', 'browser']] as const) { - const fixture = makeOrderedFixture([...order]); - try { - const target = inject(fixture)!.aliases[SPEC_PACKAGE_NAME]; - expect(target.endsWith('.d.mts') || target.endsWith('.d.ts')).toBe(false); - } finally { - fs.rmSync(fixture, { recursive: true, force: true }); - } - } - }); - - it('names the conditions it satisfies when an entry offers none of them', () => { - const fixture = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'spec-dist-9408-'))); - try { - fs.mkdirSync(path.join(fixture, 'dist')); - fs.writeFileSync(path.join(fixture, 'dist/index.cjs'), 'module.exports = {};\n'); - fs.writeFileSync( - path.join(fixture, 'package.json'), - JSON.stringify({ - name: SPEC_PACKAGE_NAME, - exports: { '.': { require: './dist/index.cjs' } }, - }) - ); - // A `require`-only entry is unreachable for a browser/ESM bundler, and - // the message has to say which conditions were on the table — otherwise - // "resolves to nothing" is unactionable. - expect(() => inject(fixture)).toThrow(/resolves to nothing under/); - expect(() => inject(fixture)).toThrow(/browser/); - expect(() => inject(fixture)).toThrow(/import/); - } finally { - fs.rmSync(fixture, { recursive: true, force: true }); - } - }); - - it('reports the condition path for EVERY entry, and marks the ones it outranked', () => { - // The audible half. The card's hazard is not that the arm was wrong, it is - // that nothing anywhere said which arm was taken, so the first symptom - // would surface as a bundler error at a pin bump nobody connects to this - // file. A report that omitted the decided entries would leave that intact. - const injection = inject(installedSpecDir)!; - expect(injection.resolutions).toHaveLength(21); - - const report = formatConditionReport(injection).join('\n'); - expect(report).toContain(injection.packageDir); - - for (const resolution of injection.resolutions) { - // Every specifier appears, and so does the arm it came from. - expect(report).toContain(resolution.specifier); - } - // The five decided entries name `browser` as the arm TAKEN and `import` as - // the one it outranked — the exact sentence that was missing. - const decided = injection.resolutions.filter((r) => r.passedOver.length > 0); - expect(decided.map((r) => r.specifier).sort()).toEqual([ - SPEC_PACKAGE_NAME, - `${SPEC_PACKAGE_NAME}/api-assembled`, - `${SPEC_PACKAGE_NAME}/data`, - `${SPEC_PACKAGE_NAME}/kernel`, - `${SPEC_PACKAGE_NAME}/system`, - ]); - for (const resolution of decided) { - expect(resolution.conditionPath[0]).toBe('browser'); - expect(resolution.passedOver).toContain('import'); - } - expect(report).toContain('browser > import > default'); - expect(report).toContain('ranked above: import'); - - // Anti-vacuity on the other half: entries with no choice to make are still - // reported, with their arm, rather than silently dropped. - const forced = injection.resolutions.filter((r) => r.passedOver.length === 0); - expect(forced.length).toBe(21 - decided.length); - expect(forced.length).toBeGreaterThan(0); - }); - - it('is carried on the injection, so the console config cannot invent its own', () => { - // Same lesson as `specModuleTest` (objectui#5388): two consumers reading - // one producer. A config that formatted its own table could report an arm - // the resolver did not take. - const injection = inject(installedSpecDir)!; - for (const resolution of injection.resolutions) { - expect(injection.aliases[resolution.specifier]).toBe(resolution.target); - } - }); -}); - -describe('objectui#4854: the four flagged surfaces in the console config', () => { - // Read off the REAL config, not the helper: a correct helper wired into - // nothing is the failure this repo has paid for before. - it('leaves all four flagged surfaces at their baseline values', async () => { - expect(process.env.OBJECTSTACK_SPEC_DIST ?? '').toBe(''); - const config = await loadConsoleConfig(); - - // 1. resolve.alias — no `@objectstack` entry at all. - const aliasKeys = Object.keys(config.resolve.alias); - expect(aliasKeys.filter((k: string) => k.startsWith('@objectstack'))).toEqual([]); - - // 2. optimizeDeps.include — byte-identical to the baseline list. - expect(config.optimizeDeps.include).toEqual([ - '@objectstack/spec', - '@objectstack/spec/data', - '@objectstack/spec/system', - '@objectstack/spec/ui', - 'react-map-gl/maplibre', - ]); - - // 3. the vendor-objectstack chunk test — the layout's own, unwidened. The - // mirror above is still the helper's baseline, so it cannot drift alone. - const groups = config.build.rollupOptions.output.advancedChunks.groups as { name: string; test: RegExp }[]; - const vendor = groups.find((g) => g.name === 'vendor-objectstack'); - expect(vendor).toBeDefined(); - expect(BASE_VENDOR_TEST.source).toBe(BASELINE_CHUNK_TESTS.vendor.source); - expect(vendor!.test.source).toBe(LAYOUT_VENDOR_TEST.source); - // …and what that literal MEANS, so a future rewrite that keeps the shape - // but changes the membership cannot pass by pasting a new string in. - // Reads off the live config's own regex, never the mirror above. - const LINT_ID = - '/repo/node_modules/.pnpm/@objectstack+lint@17.0.0/node_modules/@objectstack/lint/dist/index.js'; - expect(vendor!.test.test(LINT_ID)).toBe(false); - // Counter-probe: the packages that DO belong in the group still match, via - // both spellings — otherwise the line above would also pass if the group - // test had been broken into matching nothing at all. - expect(vendor!.test.test('/repo/node_modules/@objectstack/spec/dist/index.js')).toBe(true); - expect(vendor!.test.test('/repo/node_modules/.pnpm/@objectstack+spec@17.0.0/x.js')).toBe(true); - expect(vendor!.test.test('/repo/node_modules/@objectstack/client/dist/index.js')).toBe(true); - // The exclusion is scoped to the `lint` package, not a `lint*` prefix. - expect(vendor!.test.test('/repo/node_modules/@objectstack/lint-utils/dist/index.js')).toBe(true); - - // 4. server.fs — absent, so Vite keeps its own default allow-list. - expect(config.server.fs).toBeUndefined(); - }); - - it('moves all four surfaces — and only those — once the override IS set', async () => { - // A second evaluation of the same config under a distinct module id (the - // query suffix), so the unset instance above stays intact and the two can be - // compared. `vi.resetModules()` was rejected: the `unit` project runs - // `isolate: false`, so resetting the registry reaches other files' modules. - const baseline = await loadConsoleConfig(); - process.env.OBJECTSTACK_SPEC_DIST = installedSpecDir; - let injected: any; - try { - injected = await loadConsoleConfig('?objectstack-spec-dist=4854'); - } finally { - delete process.env.OBJECTSTACK_SPEC_DIST; - } - - // 1. alias — one entry per exports-map entry, subpaths before the bare name. - const injectedSpecKeys = Object.keys(injected.resolve.alias).filter((k: string) => - k === SPEC_PACKAGE_NAME || k.startsWith(`${SPEC_PACKAGE_NAME}/`) - ); - expect(injectedSpecKeys).toHaveLength(21); - expect(injectedSpecKeys[injectedSpecKeys.length - 1]).toBe(SPEC_PACKAGE_NAME); - expect(injected.resolve.alias[`${SPEC_PACKAGE_NAME}/ui`]).toBe( - path.join(fs.realpathSync(installedSpecDir), 'dist/ui/index.mjs') - ); - // The `@object-ui/*` workspace aliases are untouched by the injection. - const objectUiKeys = (alias: Record) => - Object.keys(alias).filter((k) => k.startsWith('@object-ui/')); - expect(objectUiKeys(injected.resolve.alias)).toEqual(objectUiKeys(baseline.resolve.alias)); - - // 2. optimizeDeps.include — the four spec entries drop out, the rest stay. - expect(injected.optimizeDeps.include).toEqual(['react-map-gl/maplibre']); - - // 3. the vendor chunk test — widened with the override, baseline arms kept. - const vendorOf = (config: any) => - (config.build.rollupOptions.output.advancedChunks.groups as { name: string; test: RegExp }[]).find( - (g) => g.name === 'vendor-objectstack' - )!.test; - expect(vendorOf(injected).source.startsWith(LAYOUT_VENDOR_TEST.source)).toBe(true); - expect(vendorOf(injected).test(`${fs.realpathSync(installedSpecDir)}/dist/ui/index.mjs`)).toBe(true); - // Widening appends an arm; it must not resurrect the lint exclusion the - // baseline arms carry, or a spec-dist build would silently re-eagerize the - // linter while a released build stayed lazy (objectui#5266). - expect( - vendorOf(injected).test( - '/repo/node_modules/.pnpm/@objectstack+lint@17.0.0/node_modules/@objectstack/lint/dist/index.js' - ) - ).toBe(false); - - // 4. server.fs.allow — the workspace root plus the injected package. - expect(injected.server.fs.allow).toEqual([repoRoot, fs.realpathSync(installedSpecDir)]); - - // …and nothing else moved: the unset instance is untouched by the second - // evaluation, which is what makes the laziness case above meaningful. - expect(baseline.optimizeDeps.include).toHaveLength(5); - expect(baseline.server.fs).toBeUndefined(); - }); - - it('declares the var in turbo.json, which strict env mode would otherwise strip', () => { - const turbo = JSON.parse(fs.readFileSync(uiTurboConfigPath(), 'utf8')) as { - tasks: { build: { env: string[] } }; - }; - // Turbo v2 runs tasks in strict env mode: an undeclared var never reaches - // the task, so the hook would read `undefined` and stay inert while the - // caller believed it had injected a spec. - expect(turbo.tasks.build.env).toContain('OBJECTSTACK_SPEC_DIST'); - expect(turbo.tasks.build.env).toContain('OBJECTSTACK_CLIENT_DIST'); - }); -}); - -/* -------------------------------------------------------------------------- */ -/* objectui#10865 — every pre-bundle entry resolves the way `pnpm dev` does. */ -/* -------------------------------------------------------------------------- */ - -/** The directory `pnpm dev` runs Vite in, and therefore the root it resolves from. */ -const consoleRoot = path.join(repoRoot, 'apps/console'); - -/** A package on no `node_modules` walk from the console root: the "skipped" control. */ -const ABSENT_PACKAGE_CONTROL = 'objectui-10865-no-such-package'; - -/** - * A subpath the spec's exports map does not declare: the "aborts" control. It is - * the same shape as the bare `react-map-gl` entry, on a package whose map this - * file already reconciles. - */ -const UNEXPORTED_SUBPATH_CONTROL = `${SPEC_PACKAGE_NAME}/objectui-10865-no-such-subpath`; - -type IncludeVerdict = { kind: 'resolved'; id: string } | { kind: 'skipped' } | { kind: 'aborts'; message: string }; - -/** - * How the dev server treats each `optimizeDeps.include` entry. The answer comes - * from Vite itself, not from a transcription of its algorithm. - * - * The list is read by `vite` (dev) only. `vite build` never reads it, so no - * build, E2E or type-check job can see a bad entry. In the Vite this was written - * against (8.2), `addManuallyIncludedOptimizeDeps` resolves every entry before - * the server listens, through `createOptimizeDepsIncludeResolver`. For the - * client environment that resolver is `config.createResolver({ asSrc: false, - * scan: true })`, called with NO importer, so it resolves from the root. The - * deprecated `createResolver` is used here on purpose: Vite's own include - * resolver calls it. - * - * Two outcomes are defects, and they fail differently: - * - * - **aborts**: the package is found, but its exports map has no such subpath. - * The resolver throws, and `vite` exits 1 before it listens. This is - * objectui#10865, measured with a bare `react-map-gl` entry: react-map-gl 8 - * exports no `.`. - * - **skipped**: the package cannot be reached from the root. Vite logs - * `Failed to resolve dependency` and pre-bundles nothing for the entry. This - * was the `maplibre-gl` entry objectui#10865 removed. - */ -async function includeVerdicts(config: InlineConfig, specifiers: string[]): Promise> { - const resolved = await resolveConfig( - { ...config, root: consoleRoot, configFile: false, envFile: false, logLevel: 'silent' }, - 'serve', - 'development' - ); - const resolve = resolved.createResolver({ asSrc: false, scan: true }); - const verdicts = new Map(); - for (const specifier of specifiers) { - try { - const id = await resolve(specifier); - verdicts.set(specifier, id ? { kind: 'resolved', id } : { kind: 'skipped' }); - } catch (error) { - verdicts.set(specifier, { kind: 'aborts', message: String((error as Error).message).split('\n')[0] }); - } - } - return verdicts; -} - -describe('objectui#10865: every optimizeDeps.include entry resolves the way the dev server resolves it', () => { - it('resolves each entry from the console root under the dev conditions', async () => { - // A separate evaluation, so that whatever `resolveConfig` does to the object - // it is handed cannot reach the instance the cases above compare. - const config = await loadConsoleConfig('?optimize-deps-include=10865'); - const include = config.optimizeDeps.include as string[]; - // Anti-vacuity: a list that read as empty would "resolve" with nothing to check. - expect(include.length).toBeGreaterThan(0); - // Vite resolves nested (`dep > sub`) and glob entries by other paths, and - // this case models only flat entries. Extend it before adding either form. - expect( - include.filter((specifier) => specifier.includes('>') || specifier.includes('*')), - 'nested or glob include entries, which this case does not model' - ).toEqual([]); - - const verdicts = await includeVerdicts(config, [ - ...include, - VITE_ORACLE_CONTROL, - ABSENT_PACKAGE_CONTROL, - UNEXPORTED_SUBPATH_CONTROL, - ]); - - // Controls with a KNOWN outcome, in the SAME run. The instrument has to be - // able to report all three outcomes, or a clean result below means nothing. - expect(verdicts.get(VITE_ORACLE_CONTROL)?.kind, 'dev resolver resolved nothing: broken instrument').toBe( - 'resolved' - ); - expect(verdicts.get(ABSENT_PACKAGE_CONTROL)?.kind, 'an absent package did not read as skipped').toBe('skipped'); - expect(verdicts.get(UNEXPORTED_SUBPATH_CONTROL)?.kind, 'an unexported subpath did not read as aborting').toBe( - 'aborts' - ); - - const findings = include.flatMap((specifier) => { - const verdict = verdicts.get(specifier)!; - if (verdict.kind === 'aborts') return [`${specifier}: aborts the dev server start (${verdict.message})`]; - if (verdict.kind === 'skipped') { - return [`${specifier}: not reachable from apps/console, so it is skipped with a warning and pre-bundles nothing`]; - } - return []; - }); - expect(findings, 'optimizeDeps.include entries the dev server cannot resolve (objectui#10865)').toEqual([]); - }); -}); - -/* -------------------------------------------------------------------------- */ -/* objectui#5388 — the counter-probe is the FIFTH surface the override moves. */ -/* -------------------------------------------------------------------------- */ - -/** - * `apps/console/vite.config.ts` registers `assert-lazy-linter-stays-lazy`, whose - * counter-probe demands a known-eager `@objectstack/spec` chunk before it will - * read its own linter verdict (objectui#5323). Under the override every spec - * module id becomes an absolute path in the overriding tree, with no - * `@objectstack` segment — so the plugin's private regex matched nothing, the - * probe refused a verdict, and `scripts/build-console.sh` in the framework could - * not build ANY objectui pin at or after that commit (objectstack#10136). - * - * These cases drive the REAL plugin off the REAL config over a synthetic bundle, - * rather than asserting on the regex the config hands it. That is the difference - * between pinning the wiring and pinning a value: the bug was never a wrong - * regex, it was a correct regex reaching one consumer and not the other. - * - * Reverse verification, direction predicted before running: plain RED, and the - * mutation is the bug itself. Reverting `assertLazyLinterStaysLazy(specModuleTest)` - * to the no-argument form with its private `SPEC` → the two injected cases below - * fail on the counter-probe message, and the two baseline cases stay green — - * which is exactly the asymmetry that let this ship. - */ -interface ProbeChunk { - type: 'chunk'; - fileName: string; - isEntry: boolean; - imports: string[]; - modules: Record; -} - -/** A pnpm-store module id for the linter, the spelling a real bundle carries. */ -const LINT_MODULE_ID = - '/repo/node_modules/.pnpm/@objectstack+lint@17.0.0/node_modules/@objectstack/lint/dist/index.js'; -/** The installed spec, i.e. what an un-injected build emits. */ -const INSTALLED_SPEC_MODULE_ID = '/repo/node_modules/@objectstack/spec/dist/index.mjs'; - -const probeChunk = ( - fileName: string, - modules: string[], - extra: Partial = {} -): ProbeChunk => ({ - type: 'chunk', - fileName, - isEntry: false, - imports: [], - modules: Object.fromEntries(modules.map((id) => [id, {}])), - ...extra, -}); - -/** - * A bundle shaped like the console's: one entry, one statically imported vendor - * chunk holding the spec, and the linter parked behind a dynamic import — which - * the plugin's walk deliberately does not follow. - * - * @param specModuleId the spec id the vendor chunk carries (installed or injected) - * @param lintFileName which chunk holds the linter, or `null` for none at all - */ -function consoleShapedBundle( - specModuleId: string, - lintFileName: 'assets/vendor-objectstack.js' | 'assets/lint-lazy.js' | null -): Record { - const vendorModules = [specModuleId]; - if (lintFileName === 'assets/vendor-objectstack.js') vendorModules.push(LINT_MODULE_ID); - const bundle: Record = { - 'assets/index.js': probeChunk('assets/index.js', ['/repo/apps/console/src/main.tsx'], { - isEntry: true, - imports: ['assets/vendor-objectstack.js'], - }), - 'assets/vendor-objectstack.js': probeChunk('assets/vendor-objectstack.js', vendorModules), - }; - if (lintFileName === 'assets/lint-lazy.js') { - bundle['assets/lint-lazy.js'] = probeChunk('assets/lint-lazy.js', [LINT_MODULE_ID]); - } - return bundle; -} - -/** - * A minimal but REAL `@objectstack/spec` package living outside `node_modules`. - * - * `installedSpecDir` cannot stand in for an injected package here, and finding - * that out is worth writing down: its own path is - * `…/node_modules/.pnpm/@objectstack+spec@17…/node_modules/@objectstack/spec`, - * which the BASELINE test already matches. A case built on it goes green under - * the un-injected config too — measured, before this fixture existed — so it - * would have pinned nothing at all. - * - * The framework tree the override actually points at - * (`/…/objectstack/packages/spec`, or `/home/runner/work/objectstack/objectstack/ - * packages/spec` on CI) has no `@objectstack` segment anywhere, and that is the - * one property this fixture has to reproduce. It stays minimal on purpose: the - * exports-map derivation is covered above against the real 21-entry map, and - * what these cases need is a legal package at a path of the wrong SHAPE. - */ -function makeOutOfTreeSpecPackage(): string { - const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'framework-spec-5388-'))); - fs.mkdirSync(path.join(dir, 'dist'), { recursive: true }); - fs.writeFileSync(path.join(dir, 'dist/index.mjs'), 'export const __probe5388 = true;\n'); - fs.writeFileSync( - path.join(dir, 'package.json'), - JSON.stringify( - { - name: SPEC_PACKAGE_NAME, - version: '0.0.0-probe5388', - type: 'module', - exports: { '.': { import: './dist/index.mjs' } }, - }, - null, - 2 - ) - ); - return dir; -} - -/** Runs the console's own guard over one bundle; returns its error, or `null`. */ -function runLazyLinterProbe(config: any, bundle: Record): string | null { - const plugins = (config.plugins as unknown[]).flat(Infinity) as { - name?: string; - generateBundle?: (this: unknown, options: unknown, bundle: unknown) => void; - }[]; - const plugin = plugins.find((p) => p && p.name === 'assert-lazy-linter-stays-lazy'); - expect(plugin, 'the console config registers assert-lazy-linter-stays-lazy').toBeDefined(); - const context = { - error(message: string): never { - throw new Error(message); - }, - }; - try { - plugin!.generateBundle!.call(context, {}, bundle); - return null; - } catch (error) { - return (error as Error).message; - } -} - -describe('objectui#5388: the lazy-linter counter-probe reads the injection', () => { - let outOfTreeSpecDir: string; - /** The module id an injected build emits for the spec. */ - let injectedSpecModuleId: string; - - beforeAll(() => { - outOfTreeSpecDir = makeOutOfTreeSpecPackage(); - injectedSpecModuleId = `${outOfTreeSpecDir}/dist/index.mjs`; - }); - afterAll(() => { - fs.rmSync(outOfTreeSpecDir, { recursive: true, force: true }); - }); - - /** Loads the console config with the override pointed at the fixture. */ - async function loadInjectedConsoleConfig(query: string): Promise { - process.env.OBJECTSTACK_SPEC_DIST = outOfTreeSpecDir; - try { - return await loadConsoleConfig(query); - } finally { - delete process.env.OBJECTSTACK_SPEC_DIST; - } - } - - it('gives the fixture the one shape that matters: no `@objectstack` segment', () => { - // Anti-vacuity for every case below. If the fixture ever lands somewhere - // the baseline test already matches, the "blind" case goes green for the - // wrong reason and the "sees it" case stops proving the widening did - // anything — which is exactly what happened with `installedSpecDir`. - expect(injectedSpecModuleId).not.toContain('@objectstack'); - expect(BASE_SPEC_TEST.test(injectedSpecModuleId)).toBe(false); - }); - - it('sees the INSTALLED spec when no override is set', async () => { - expect(process.env.OBJECTSTACK_SPEC_DIST ?? '').toBe(''); - const config = await loadConsoleConfig(); - expect( - runLazyLinterProbe(config, consoleShapedBundle(INSTALLED_SPEC_MODULE_ID, 'assets/lint-lazy.js')) - ).toBeNull(); - }); - - it('is BLIND to an injected spec while the config stays un-injected', async () => { - // The bug's mechanism, isolated: same plugin, same bundle shape, only the - // spec's module id moved out of node_modules. Without the override the - // config has no business recognising that path — so this failing is CORRECT - // here, and it is the control that makes the passing case below mean - // something rather than being a probe that stopped looking. - const config = await loadConsoleConfig(); - const message = runLazyLinterProbe( - config, - consoleShapedBundle(injectedSpecModuleId, 'assets/lint-lazy.js') - ); - expect(message).toContain('counter-probe failed'); - expect(message).toContain('no eagerly loaded chunk'); - }); - - it('finds the injected spec once the override IS set — the probe, not skipped', async () => { - const config = await loadInjectedConsoleConfig('?objectstack-spec-dist=5388'); - - // It PASSES on the injected id… - expect( - runLazyLinterProbe(config, consoleShapedBundle(injectedSpecModuleId, 'assets/lint-lazy.js')) - ).toBeNull(); - // …and still refuses a verdict when the eager closure really holds no spec, - // so the fix widened the probe's reach rather than defanging it. - const noSpec: Record = { - 'assets/index.js': probeChunk('assets/index.js', ['/repo/apps/console/src/main.tsx'], { - isEntry: true, - }), - 'assets/lint-lazy.js': probeChunk('assets/lint-lazy.js', [LINT_MODULE_ID]), - }; - expect(runLazyLinterProbe(config, noSpec)).toContain('counter-probe failed'); - // Nor did widening turn the SPEC test into the vendor group's: an eager - // `@objectstack/client` is not evidence that the walk can see the spec. - const clientOnly: Record = { - 'assets/index.js': probeChunk('assets/index.js', ['/repo/apps/console/src/main.tsx'], { - isEntry: true, - imports: ['assets/vendor-objectstack.js'], - }), - 'assets/vendor-objectstack.js': probeChunk('assets/vendor-objectstack.js', [ - '/repo/node_modules/@objectstack/client/dist/index.mjs', - ]), - 'assets/lint-lazy.js': probeChunk('assets/lint-lazy.js', [LINT_MODULE_ID]), - }; - expect(runLazyLinterProbe(config, clientOnly)).toContain('counter-probe failed'); - }); - - it('still catches an EAGER linter under the override', async () => { - // The guard's actual job, asserted in the mode that used to never reach it: - // before this fix the counter-probe threw first and the linter verdict was - // never read at all under the override. - const config = await loadInjectedConsoleConfig('?objectstack-spec-dist=5388-eager'); - const message = runLazyLinterProbe( - config, - consoleShapedBundle(injectedSpecModuleId, 'assets/vendor-objectstack.js') - ); - expect(message).toContain('`@objectstack/lint` is in the EAGER closure'); - expect(message).toContain('assets/vendor-objectstack.js'); - }); - - it('refuses a verdict when the LINT test itself has gone blind', async () => { - // The linter half's failure mode is the silent one: the assertion on it is - // negative, so a regex that stopped matching the emitted ids is - // indistinguishable from a clean bundle. objectstack#9659 proposes injecting - // four more `@objectstack/*` packages the same way; if lint joins them this - // must fail loudly rather than go permanently green. - const config = await loadConsoleConfig(); - const message = runLazyLinterProbe( - config, - consoleShapedBundle(INSTALLED_SPEC_MODULE_ID, null) - ); - expect(message).toContain('counter-probe failed'); - expect(message).toContain('no chunk at all'); - }); -}); - -describe('objectui#4854: a real Vite build resolves the injected spec', () => { - // The transcribed matcher above agrees with Vite's source, but only Vite can - // answer whether it preserves the alias table's KEY ORDER through - // `normalizeAlias` — and the order is what makes the bare entry a backstop - // rather than a swallow-everything. So this bundles for real: ~300ms, because - // the entry is three modules and the output is never written. - const ENTRY = [ - "import * as root from '@objectstack/spec';", - "import * as ui from '@objectstack/spec/ui';", - "import * as data from '@objectstack/spec/data';", - "import pkg from '@objectstack/spec/package.json';", - "globalThis.__probe4854 = [root, ui, data, pkg.name];", - ].join('\n'); - - async function bundle( - alias: Record, - root: string - ): Promise<{ code: string; imports: string[] }> { - const result = (await build({ - root, - logLevel: 'silent', - resolve: { alias }, - build: { - write: false, - minify: false, - lib: { entry: path.join(root, 'entry.mjs'), formats: ['es'], fileName: 'probe' }, - }, - })) as { output: { code?: string; imports?: string[] }[] }[]; - const chunk = result[0].output[0]; - return { code: chunk.code ?? '', imports: chunk.imports ?? [] }; - } - - function withEntry(run: (root: string) => Promise): Promise { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'spec-build-4854-')); - fs.writeFileSync(path.join(dir, 'entry.mjs'), ENTRY); - return run(dir).finally(() => fs.rmSync(dir, { recursive: true, force: true })); - } - - it('bundles the bare specifier and its subpaths with nothing left unresolved', async () => { - const injection = inject(installedSpecDir)!; - const chunk = await withEntry((root) => bundle(injection.aliases, root)); - - // Anti-vacuity: the injected package's own schema code is in the output… - expect(chunk.code.length).toBeGreaterThan(100_000); - expect(chunk.code).toContain(SPEC_PACKAGE_NAME); - // …and the chunk imports nothing from outside itself, so no specifier fell - // back to the installed spec or leaked out as an external. (Asserted on the - // rollup chunk's import list rather than on the code text: the spec bundles - // its own name into string literals, which a text scan reads as an import.) - expect(chunk.imports).toEqual([]); - }); - - it('is what the literal client-hook copy cannot do', async () => { - // The premise of the card, measured rather than asserted: one prefix alias - // at the package directory rewrites `@objectstack/spec/ui` to `SPEC_PKG/ui`, - // which does not exist. A green build here would mean the whole - // exports-map derivation is unnecessary. - await expect( - withEntry((root) => bundle({ [SPEC_PACKAGE_NAME]: installedSpecDir }, root)) - ).rejects.toThrow(); - }); -}); - -/* -------------------------------------------------------------------------- */ -/* objectui#11327 — an injected console bundles exactly ONE zod instance. */ -/* -------------------------------------------------------------------------- */ - -/** - * The injected spec arrives with its own install tree, so its bare `zod` import - * resolved to the framework's zod and the console bundled two instances; the - * metadata-admin schema modules' `z.toJSONSchema` then threw over the spec's - * schemas and the Studio's New Package dialog lost its Name / Id / Namespace. - * - * Three facts are pinned, each in both directions: - * - * 1. **Parity.** The console's zod must satisfy the range the injected spec - * declares — refused by name when it does not (the 4.4.3-vs-`^4.6.1` - * shape the card was reported at), accepted when it does. - * 2. **The redirect.** A REAL Vite build of a spec fixture whose own tree - * carries a different zod emits both copies without the redirect (the - * control: this fixture reproduces the split) and only the console's with - * it, subpaths included. - * 3. **The guard.** On the same fixture, `assertSingleZodInstance` fails a - * two-copy build naming both, fails a two-copy build whose copies share a - * VERSION (instances, not versions), passes the one-copy build, and refuses - * a verdict when it sees no zod at all. - * - * Why not `resolve.dedupe`: measured on the real console config while this was - * written, adding `zod` there emitted the same two copies byte-identical, - * because Vite resolves a deduped package from the root and `apps/console` - * declares no zod. The fixture builds below are the regression form of that - * reading: the "no redirect" leg IS the dedupe-less shape. - */ -describe('objectui#11327: an injected console carries exactly one zod instance', () => { - /** Writes a fake `zod` package whose modules announce which copy they are. */ - function writeFakeZod(dir: string, version: string, copy: string): void { - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync( - path.join(dir, 'package.json'), - JSON.stringify({ - name: 'zod', - version, - type: 'module', - exports: { '.': './index.js', './v4': './v4.js', './package.json': './package.json' }, - }) - ); - fs.writeFileSync(path.join(dir, 'index.js'), `export const z = { copy: '${copy}' };\n`); - fs.writeFileSync(path.join(dir, 'v4.js'), `export const z = { copy: '${copy}_V4' };\n`); - } - - interface ZodFixture { - root: string; - /** A console-side package that declares zod, with its own copy. */ - anchorDir: string; - /** A framework-side spec package whose zod is hoisted to ITS tree's root. */ - specDir: string; - } - - /** - * Two install trees, the shape the card measured: a console anchor with its - * own zod, and a framework spec whose bare `zod` resolves from the framework's - * root `node_modules` — never from the console's. - */ - function makeZodFixture( - { consoleVersion, specOwnVersion, specRange }: { consoleVersion: string; specOwnVersion: string; specRange: string } - ): ZodFixture { - const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'spec-zod-11327-'))); - const anchorDir = path.join(root, 'console/packages/app-shell'); - fs.mkdirSync(anchorDir, { recursive: true }); - fs.writeFileSync( - path.join(anchorDir, 'package.json'), - JSON.stringify({ name: 'anchor-11327', dependencies: { zod: `^${consoleVersion}` } }) - ); - writeFakeZod(path.join(anchorDir, 'node_modules/zod'), consoleVersion, 'CONSOLE_ZOD_11327'); - fs.writeFileSync( - path.join(anchorDir, 'entry.mjs'), - [ - "import { z } from 'zod';", - "import { specZ, specZ4 } from '@objectstack/spec';", - 'globalThis.__probe11327 = [z.copy, specZ.copy, specZ4.copy, z === specZ];', - ].join('\n') - ); - - const specDir = path.join(root, 'framework/packages/spec'); - fs.mkdirSync(path.join(specDir, 'dist'), { recursive: true }); - fs.writeFileSync( - path.join(specDir, 'package.json'), - JSON.stringify({ - name: SPEC_PACKAGE_NAME, - version: '0.0.0-probe11327', - type: 'module', - exports: { '.': { import: './dist/index.mjs' } }, - dependencies: { zod: specRange }, - }) - ); - fs.writeFileSync( - path.join(specDir, 'dist/index.mjs'), - [ - "import { z } from 'zod';", - "import { z as z4 } from 'zod/v4';", - 'export const specZ = z;', - 'export const specZ4 = z4;', - ].join('\n') - ); - writeFakeZod(path.join(root, 'framework/node_modules/zod'), specOwnVersion, 'SPEC_OWN_ZOD_11327'); - return { root, anchorDir, specDir }; - } - - /** Bundles the fixture's entry for real, with the given plugins; returns the code. */ - async function bundleFixture(fixture: ZodFixture, plugins: unknown[]): Promise { - const injection = inject(fixture.specDir, fixture.anchorDir)!; - const result = (await build({ - root: fixture.anchorDir, - logLevel: 'silent', - configFile: false, - resolve: { alias: injection.aliases }, - plugins: plugins as never, - build: { - write: false, - minify: false, - lib: { entry: path.join(fixture.anchorDir, 'entry.mjs'), formats: ['es'], fileName: 'probe11327' }, - }, - })) as { output: { code?: string }[] }[]; - return result[0].output.map((o) => o.code ?? '').join('\n'); - } - - describe('the narrow range reader', () => { - it.each([ - ['4.6.5', '^4.6.1', true], - ['4.6.1', '^4.6.1', true], - ['4.4.3', '^4.6.1', false], - ['5.0.0', '^4.6.1', false], - ['0.3.9', '^0.3.1', true], - ['0.4.0', '^0.3.1', false], - ['0.0.4', '^0.0.3', false], - ['4.6.9', '~4.6.1', true], - ['4.7.0', '~4.6.1', false], - ['4.6.1', '4.6.1', true], - ['4.6.2', '=4.6.1', false], - ['9.0.0', '>=4.6.1', true], - ['4.6.0', '>=4.6.1', false], - ['4.9.0', '>=4.6.1 <5.0.0', true], - ['5.0.0', '>=4.6.1 <5.0.0', false], - ['3.25.76', '^3.25.0 || ^4.6.1', true], - ['4.0.0', '^3.25.0 || ^4.6.1', false], - ])('%s satisfies %s → %s', (version, range, expected) => { - expect(versionSatisfiesRange(version, range)).toBe(expected); - }); - - it.each([['4.x'], ['*'], ['workspace:^4.6.1'], ['catalog:'], ['latest'], ['']])( - 'refuses to read `%s` rather than guess', - (range) => { - expect(() => versionSatisfiesRange('4.6.5', range)).toThrow(); - } - ); - - it('refuses a version that is not a plain release', () => { - expect(() => versionSatisfiesRange('4.7.0-beta.1', '^4.6.1')).toThrow(/plain X\.Y\.Z/); - }); - }); - - describe('the console zod and the parity check', () => { - it('resolves the REAL console anchor to a zod package directory and its version', () => { - const zod = resolveConsoleZod(CONSOLE_ZOD_ANCHOR); - const manifest = JSON.parse(fs.readFileSync(path.join(zod.packageDir, 'package.json'), 'utf8')) as { - name: string; - version: string; - }; - expect(manifest.name).toBe('zod'); - expect(zod.version).toBe(manifest.version); - expect(zod.packageDir).toBe(fs.realpathSync(zod.packageDir)); - // The anchor really declares zod, which is what makes it an anchor. - const anchorManifest = JSON.parse(fs.readFileSync(path.join(CONSOLE_ZOD_ANCHOR, 'package.json'), 'utf8')) as { - dependencies?: Record; - }; - expect(anchorManifest.dependencies).toHaveProperty('zod'); - }); - - it('throws, naming the anchor, when nothing on the walk up resolves zod', () => { - const lonely = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'spec-zod-11327-anchor-'))); - try { - expect(() => resolveConsoleZod(lonely)).toThrow(new RegExp(escapeRegExp(lonely))); - expect(() => resolveConsoleZod(lonely)).toThrow(/does not resolve/); - } finally { - fs.rmSync(lonely, { recursive: true, force: true }); - } - }); - - it('REFUSES the injection when the console zod is older than the spec declares — the card\'s shape', () => { - const fixture = makeZodFixture({ consoleVersion: '4.4.3', specOwnVersion: '4.6.1', specRange: '^4.6.1' }); - try { - const attempt = () => inject(fixture.specDir, fixture.anchorDir); - expect(attempt).toThrow(/OBJECTSTACK_SPEC_DIST/); - // Both sides are named: the spec's range and the console's version and where it came from. - expect(attempt).toThrow(/`\^4\.6\.1`/); - expect(attempt).toThrow(/4\.4\.3/); - expect(attempt).toThrow(new RegExp(escapeRegExp(fixture.anchorDir))); - } finally { - fs.rmSync(fixture.root, { recursive: true, force: true }); - } - }); - - it('accepts the injection when the console zod satisfies the spec\'s range', () => { - const fixture = makeZodFixture({ consoleVersion: '4.6.5', specOwnVersion: '4.6.1', specRange: '^4.6.1' }); - try { - const injection = inject(fixture.specDir, fixture.anchorDir)!; - expect(injection.consoleZod).toEqual({ - anchorDir: fixture.anchorDir, - packageDir: path.join(fixture.anchorDir, 'node_modules/zod'), - version: '4.6.5', - }); - expect((injection.singleZodPlugin as { name: string }).name).toBe(SINGLE_ZOD_PLUGIN_NAME); - } finally { - fs.rmSync(fixture.root, { recursive: true, force: true }); - } - }); - - it('reads a `peerDependencies` range too, and refuses one it cannot read', () => { - const fixture = makeZodFixture({ consoleVersion: '4.6.5', specOwnVersion: '4.6.1', specRange: '^4.6.1' }); - try { - const manifestPath = path.join(fixture.specDir, 'package.json'); - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - fs.writeFileSync(manifestPath, JSON.stringify({ ...manifest, peerDependencies: { zod: '^5.0.0' } })); - expect(() => inject(fixture.specDir, fixture.anchorDir)).toThrow(/`\^5\.0\.0` in `peerDependencies`/); - fs.writeFileSync(manifestPath, JSON.stringify({ ...manifest, dependencies: { zod: 'catalog:' } })); - expect(() => inject(fixture.specDir, fixture.anchorDir)).toThrow(/cannot be read here/); - } finally { - fs.rmSync(fixture.root, { recursive: true, force: true }); - } - }); - - it('the REAL installed spec passes against the REAL console zod', () => { - // Anti-vacuity: the installed spec really declares a zod range to compare. - const manifest = JSON.parse(fs.readFileSync(path.join(installedSpecDir, 'package.json'), 'utf8')) as { - dependencies?: Record; - }; - expect(typeof manifest.dependencies?.zod).toBe('string'); - expect(versionSatisfiesRange(resolveConsoleZod(CONSOLE_ZOD_ANCHOR).version, manifest.dependencies!.zod)).toBe( - true - ); - expect(inject(installedSpecDir)!.consoleZod.packageDir).toBe(resolveConsoleZod(CONSOLE_ZOD_ANCHOR).packageDir); - }); - }); - - describe('the redirect and the guard, in a real Vite build', () => { - let fixture: ZodFixture; - beforeAll(() => { - fixture = makeZodFixture({ consoleVersion: '4.6.5', specOwnVersion: '4.6.1', specRange: '^4.6.1' }); - }); - afterAll(() => { - fs.rmSync(fixture.root, { recursive: true, force: true }); - }); - - it('CONTROL: without the redirect the fixture bundles both copies — the split the card measured', async () => { - const code = await bundleFixture(fixture, []); - expect(code).toContain('CONSOLE_ZOD_11327'); - expect(code).toContain('SPEC_OWN_ZOD_11327'); - expect(code).toContain('SPEC_OWN_ZOD_11327_V4'); - }); - - it('with the redirect, every zod import — the spec\'s subpath included — is the console\'s copy', async () => { - const injection = inject(fixture.specDir, fixture.anchorDir)!; - const code = await bundleFixture(fixture, [injection.singleZodPlugin, assertSingleZodInstance()]); - expect(code).toContain('CONSOLE_ZOD_11327'); - expect(code).toContain('CONSOLE_ZOD_11327_V4'); - expect(code).not.toContain('SPEC_OWN_ZOD_11327'); - }); - - it('the guard fails a two-copy build, naming both copies, their versions and who imported them', async () => { - const message = await bundleFixture(fixture, [assertSingleZodInstance()]).then( - () => 'BUILD PASSED', - (error: Error) => error.message - ); - expect(message).toContain(`[${SINGLE_ZOD_GUARD_NAME}]`); - expect(message).toContain('carries 2 zod instances'); - expect(message).toContain(`zod 4.6.1 at \`${path.join(fixture.root, 'framework/node_modules/zod')}\``); - expect(message).toContain(`zod 4.6.5 at \`${path.join(fixture.anchorDir, 'node_modules/zod')}\``); - // The spec's copy is traced to the spec, which is what tells a reader where to look. - expect(message).toContain(path.join(fixture.specDir, 'dist/index.mjs')); - }); - - it('the guard counts INSTANCES: two copies of one version still fail', async () => { - const twin = makeZodFixture({ consoleVersion: '4.6.5', specOwnVersion: '4.6.5', specRange: '^4.6.1' }); - try { - await expect(bundleFixture(twin, [assertSingleZodInstance()])).rejects.toThrow(/carries 2 zod instances/); - } finally { - fs.rmSync(twin.root, { recursive: true, force: true }); - } - }); - - it('the guard refuses a verdict when it sees no zod at all — its counter-probe', async () => { - const blind = makeZodFixture({ consoleVersion: '4.6.5', specOwnVersion: '4.6.1', specRange: '^4.6.1' }); - try { - fs.writeFileSync(path.join(blind.anchorDir, 'entry.mjs'), 'globalThis.__probe11327 = 1;\n'); - await expect(bundleFixture(blind, [assertSingleZodInstance()])).rejects.toThrow(/counter-probe failed/); - } finally { - fs.rmSync(blind.root, { recursive: true, force: true }); - } - }); - }); - - describe('which module ids the guard counts as zod', () => { - // Labelled rows: one id carries a leading NUL (a virtual-module marker), and - // a `%s` title would print that byte raw into every test report. - it.each([ - ['a pnpm store path', '/r/node_modules/.pnpm/zod@4.6.5/node_modules/zod/v4/core/core.js', '/r/node_modules/.pnpm/zod@4.6.5/node_modules/zod'], - ['a copy nested under another package', '/r/node_modules/a/node_modules/zod/index.js', '/r/node_modules/a/node_modules/zod'], - ['a virtual-module id with a query', '\0/r/node_modules/zod/index.js?commonjs-proxy', '/r/node_modules/zod'], - ['a sibling package named zod-*', '/r/node_modules/zod-to-json-schema/dist/index.js', null], - ['workspace source under a zod/ folder', '/r/packages/types/src/zod/index.zod.ts', null], - ])('%s', (_label, id, expected) => { - expect(zodPackageDirOf(id)).toBe(expected); - }); - }); - - describe('wired into the REAL console config', () => { - /** The slice of the console config these cases read. */ - interface ConsoleConfigSlice { - plugins: unknown[]; - resolve: { dedupe: string[] }; - } - const registered = (config: ConsoleConfigSlice) => - (config.plugins.flat(Infinity) as ({ name?: string; enforce?: string } | null)[]).filter( - (p): p is { name?: string; enforce?: string } => Boolean(p) - ); - const pluginNames = (config: ConsoleConfigSlice): string[] => registered(config).map((p) => p.name ?? ''); - - it('registers the guard in every build and the redirect only under the override', async () => { - const baseline = await loadConsoleConfig(); - expect(pluginNames(baseline)).toContain(SINGLE_ZOD_GUARD_NAME); - expect(pluginNames(baseline)).not.toContain(SINGLE_ZOD_PLUGIN_NAME); - - process.env.OBJECTSTACK_SPEC_DIST = installedSpecDir; - let injected: ConsoleConfigSlice; - try { - injected = await loadConsoleConfig('?objectstack-spec-dist=11327'); - } finally { - delete process.env.OBJECTSTACK_SPEC_DIST; - } - expect(pluginNames(injected)).toContain(SINGLE_ZOD_GUARD_NAME); - expect(pluginNames(injected)).toContain(SINGLE_ZOD_PLUGIN_NAME); - // The redirect resolves before Vite's own resolver, or it would answer too late. - const redirect = registered(injected).find((p) => p.name === SINGLE_ZOD_PLUGIN_NAME); - expect(redirect!.enforce).toBe('pre'); - // `zod` is not in `resolve.dedupe`: measured a silent no-op from this root. - expect(injected.resolve.dedupe).not.toContain('zod'); - }); - - it('anchors the parity check on app-shell: a spec demanding a zod the console lacks fails the config', async () => { - const fixture = makeZodFixture({ consoleVersion: '4.6.5', specOwnVersion: '4.6.1', specRange: '^99.0.0' }); - process.env.OBJECTSTACK_SPEC_DIST = fixture.specDir; - try { - await expect(loadConsoleConfig('?objectstack-spec-dist=11327-parity')).rejects.toThrow( - new RegExp(`\`\\^99\\.0\\.0\`[\\s\\S]*${escapeRegExp(CONSOLE_ZOD_ANCHOR)}`) - ); - } finally { - delete process.env.OBJECTSTACK_SPEC_DIST; - fs.rmSync(fixture.root, { recursive: true, force: true }); - } - }); - }); -}); - -/* -------------------------------------------------------------------------- */ -/* Repo sweep — the consumption radius, derived rather than listed. */ -/* -------------------------------------------------------------------------- */ - -const SCAN_ROOTS = ['packages', 'apps', 'examples']; -const SCAN_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs', '.jsx']); -const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', '.turbo', '.next', 'coverage', '.git']); -const SPEC_SPECIFIER_RE = /['"](@objectstack\/spec(?:\/[a-zA-Z0-9._-]+)?)['"]/g; - -/** Every `@objectstack/spec` specifier written in the workspace's source. */ -function collectSpecSpecifiers(): Set { - const found = new Set(); - const stack = SCAN_ROOTS.map((r) => path.join(repoRoot, r)).filter((d) => fs.existsSync(d)); - while (stack.length) { - const dir = stack.pop()!; - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - if (entry.isDirectory()) { - if (!SKIP_DIRS.has(entry.name)) stack.push(path.join(dir, entry.name)); - continue; - } - if (!SCAN_EXTENSIONS.has(path.extname(entry.name))) continue; - const source = fs.readFileSync(path.join(dir, entry.name), 'utf8'); - if (!source.includes(SPEC_PACKAGE_NAME)) continue; - for (const match of source.matchAll(SPEC_SPECIFIER_RE)) found.add(match[1]); - } - } - return found; -} diff --git a/ui/scripts/__tests__/vite-single-zod.test.ts b/ui/scripts/__tests__/vite-single-zod.test.ts new file mode 100644 index 00000000000..4c28d5ff296 --- /dev/null +++ b/ui/scripts/__tests__/vite-single-zod.test.ts @@ -0,0 +1,201 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { build } from 'vite'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { SINGLE_ZOD_GUARD_NAME, assertSingleZodInstance, zodPackageDirOf } from '../vite-single-zod'; + +/** + * objectui#11327 — the console bundle carries exactly ONE zod instance, measured + * on the emitted chunks by `assertSingleZodInstance` in every build. + * + * Moved here from `vite-objectstack-spec-dist.test.ts` when that module's + * `OBJECTSTACK_SPEC_DIST` injection left with the objectstack pin + * (objectstack#25711). The guard's cases are kept as they were; the cases of the + * injection's own zod redirect (`pinZodToConsoleCopy`) and its range parity check + * left with it. + * + * The fixture is two install trees: a console anchor with its own zod, and a + * spec package whose bare `zod` resolves from ITS tree's root — the two-copy + * shape one lockfile can still produce. + */ + +/** Writes a fake `zod` package whose modules announce which copy they are. */ +function writeFakeZod(dir: string, version: string, copy: string): void { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ + name: 'zod', + version, + type: 'module', + exports: { '.': './index.js', './v4': './v4.js', './package.json': './package.json' }, + }) + ); + fs.writeFileSync(path.join(dir, 'index.js'), `export const z = { copy: '${copy}' };\n`); + fs.writeFileSync(path.join(dir, 'v4.js'), `export const z = { copy: '${copy}_V4' };\n`); +} + +interface ZodFixture { + root: string; + /** A console-side package that declares zod, with its own copy. */ + anchorDir: string; + /** A spec package whose zod is hoisted to ITS tree's root. */ + specDir: string; +} + +function makeZodFixture({ consoleVersion, specOwnVersion }: { consoleVersion: string; specOwnVersion: string }): ZodFixture { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'single-zod-11327-'))); + const anchorDir = path.join(root, 'console/packages/app-shell'); + fs.mkdirSync(anchorDir, { recursive: true }); + fs.writeFileSync( + path.join(anchorDir, 'package.json'), + JSON.stringify({ name: 'anchor-11327', dependencies: { zod: `^${consoleVersion}` } }) + ); + writeFakeZod(path.join(anchorDir, 'node_modules/zod'), consoleVersion, 'CONSOLE_ZOD_11327'); + fs.writeFileSync( + path.join(anchorDir, 'entry.mjs'), + [ + "import { z } from 'zod';", + "import { specZ, specZ4 } from '@objectstack/spec';", + 'globalThis.__probe11327 = [z.copy, specZ.copy, specZ4.copy, z === specZ];', + ].join('\n') + ); + + const specDir = path.join(root, 'framework/packages/spec'); + fs.mkdirSync(path.join(specDir, 'dist'), { recursive: true }); + fs.writeFileSync( + path.join(specDir, 'package.json'), + JSON.stringify({ + name: '@objectstack/spec', + version: '0.0.0-probe11327', + type: 'module', + exports: { '.': { import: './dist/index.mjs' } }, + dependencies: { zod: `^${specOwnVersion}` }, + }) + ); + fs.writeFileSync( + path.join(specDir, 'dist/index.mjs'), + [ + "import { z } from 'zod';", + "import { z as z4 } from 'zod/v4';", + 'export const specZ = z;', + 'export const specZ4 = z4;', + ].join('\n') + ); + writeFakeZod(path.join(root, 'framework/node_modules/zod'), specOwnVersion, 'SPEC_OWN_ZOD_11327'); + return { root, anchorDir, specDir }; +} + +/** Bundles the fixture's entry for real, with the given plugins; returns the code. */ +async function bundleFixture(fixture: ZodFixture, plugins: unknown[]): Promise { + const result = (await build({ + root: fixture.anchorDir, + logLevel: 'silent', + configFile: false, + resolve: { alias: { '@objectstack/spec': path.join(fixture.specDir, 'dist/index.mjs') } }, + plugins: plugins as never, + build: { + write: false, + minify: false, + lib: { entry: path.join(fixture.anchorDir, 'entry.mjs'), formats: ['es'], fileName: 'probe11327' }, + }, + })) as { output: { code?: string }[] }[]; + return result[0].output.map((o) => o.code ?? '').join('\n'); +} + +async function loadConsoleConfig(query = ''): Promise { + const specifier = `../../apps/console/vite.config.ts${query}`; + return (await import(/* @vite-ignore */ specifier)).default; +} + +describe('objectui#11327: the console bundle carries exactly one zod instance', () => { + describe('the guard, in a real Vite build', () => { + let fixture: ZodFixture; + beforeAll(() => { + fixture = makeZodFixture({ consoleVersion: '4.6.5', specOwnVersion: '4.6.1' }); + }); + afterAll(() => { + fs.rmSync(fixture.root, { recursive: true, force: true }); + }); + + it('CONTROL: the fixture really bundles both copies — the split the guard exists for', async () => { + const code = await bundleFixture(fixture, []); + expect(code).toContain('CONSOLE_ZOD_11327'); + expect(code).toContain('SPEC_OWN_ZOD_11327'); + expect(code).toContain('SPEC_OWN_ZOD_11327_V4'); + }); + + it('the guard fails a two-copy build, naming both copies, their versions and who imported them', async () => { + const message = await bundleFixture(fixture, [assertSingleZodInstance()]).then( + () => 'BUILD PASSED', + (error: Error) => error.message + ); + expect(message).toContain(`[${SINGLE_ZOD_GUARD_NAME}]`); + expect(message).toContain('carries 2 zod instances'); + expect(message).toContain(`zod 4.6.1 at \`${path.join(fixture.root, 'framework/node_modules/zod')}\``); + expect(message).toContain(`zod 4.6.5 at \`${path.join(fixture.anchorDir, 'node_modules/zod')}\``); + // The spec's copy is traced to the spec, which is what tells a reader where to look. + expect(message).toContain(path.join(fixture.specDir, 'dist/index.mjs')); + }); + + it('the guard counts INSTANCES: two copies of one version still fail', async () => { + const twin = makeZodFixture({ consoleVersion: '4.6.5', specOwnVersion: '4.6.5' }); + try { + await expect(bundleFixture(twin, [assertSingleZodInstance()])).rejects.toThrow(/carries 2 zod instances/); + } finally { + fs.rmSync(twin.root, { recursive: true, force: true }); + } + }); + + it('the guard passes a one-copy build', async () => { + const single = makeZodFixture({ consoleVersion: '4.6.5', specOwnVersion: '4.6.1' }); + try { + fs.rmSync(path.join(single.root, 'framework/node_modules/zod'), { recursive: true, force: true }); + fs.writeFileSync(path.join(single.specDir, 'dist/index.mjs'), 'export const specZ = {}; export const specZ4 = {};\n'); + const code = await bundleFixture(single, [assertSingleZodInstance()]); + expect(code).toContain('CONSOLE_ZOD_11327'); + expect(code).not.toContain('SPEC_OWN_ZOD_11327'); + } finally { + fs.rmSync(single.root, { recursive: true, force: true }); + } + }); + + it('the guard refuses a verdict when it sees no zod at all — its counter-probe', async () => { + const blind = makeZodFixture({ consoleVersion: '4.6.5', specOwnVersion: '4.6.1' }); + try { + fs.writeFileSync(path.join(blind.anchorDir, 'entry.mjs'), 'globalThis.__probe11327 = 1;\n'); + await expect(bundleFixture(blind, [assertSingleZodInstance()])).rejects.toThrow(/counter-probe failed/); + } finally { + fs.rmSync(blind.root, { recursive: true, force: true }); + } + }); + }); + + describe('which module ids the guard counts as zod', () => { + // Labelled rows: one id carries a leading NUL (a virtual-module marker), and + // a `%s` title would print that byte raw into every test report. + it.each([ + ['a pnpm store path', '/r/node_modules/.pnpm/zod@4.6.5/node_modules/zod/v4/core/core.js', '/r/node_modules/.pnpm/zod@4.6.5/node_modules/zod'], + ['a copy nested under another package', '/r/node_modules/a/node_modules/zod/index.js', '/r/node_modules/a/node_modules/zod'], + ['a virtual-module id with a query', '\0/r/node_modules/zod/index.js?commonjs-proxy', '/r/node_modules/zod'], + ['a sibling package named zod-*', '/r/node_modules/zod-to-json-schema/dist/index.js', null], + ['workspace source under a zod/ folder', '/r/packages/types/src/zod/index.zod.ts', null], + ])('%s', (_label, id, expected) => { + expect(zodPackageDirOf(id)).toBe(expected); + }); + }); + + describe('wired into the REAL console config', () => { + it('registers the guard in every build, and `zod` stays out of `resolve.dedupe`', async () => { + const config = await loadConsoleConfig('?single-zod=11327'); + const names = (config.plugins.flat(Infinity) as ({ name?: string } | null)[]) + .filter((p): p is { name?: string } => Boolean(p)) + .map((p) => p.name ?? ''); + expect(names).toContain(SINGLE_ZOD_GUARD_NAME); + // `zod` is not in `resolve.dedupe`: measured a silent no-op from this root. + expect(config.resolve.dedupe ?? []).not.toContain('zod'); + }); + }); +}); diff --git a/ui/scripts/bump-objectstack.sh b/ui/scripts/bump-objectstack.sh deleted file mode 100755 index 42568cdfa46..00000000000 --- a/ui/scripts/bump-objectstack.sh +++ /dev/null @@ -1,280 +0,0 @@ -#!/usr/bin/env bash -# Bump the objectstack commit this workspace builds @objectstack/* from. -# -# Usage: -# scripts/bump-objectstack.sh # pin the HEAD of ../objectstack -# scripts/bump-objectstack.sh # pin an explicit commit, e.g. origin/main -# scripts/bump-objectstack.sh --no-commit # write the pin and its manifests, do not commit -# -# The bump BUILDS the new pin (`objectstack-pin.mjs build --write-manifests`, which -# fetches the commit and needs the network) and commits `.objectstack-sha` together -# with the packed manifests in `.objectstack-pin/` -- what Dependabot resolves the -# lockfile from (objectui#13008). A build that fails restores the old pin. -# -# After the bump -- the second half, printed on the way out: -# pnpm install # links that build; rewrites pnpm-lock.yaml only when the pinned -# # packages' dependencies, peers or engines moved -- commit it then, -# # or every frozen install (CI's) refuses the lockfile -# -# The pin must name a commit that is on objectstack MAIN. This script WARNS -- it -# does not refuse -- when the commit being pinned is not reachable from the -# objectstack checkout's `origin/main`, and names the branch(es) it IS on. It never -# fetches for you: `origin/main` is only as fresh as your last fetch, and this -# script stays usable offline. Three answers, never two -- a checkout that cannot -# answer the question says so rather than guessing. -# -# It DOES refuse -- before writing anything -- when the commit cannot be resolved -# or its object cannot be read in the objectstack checkout. There is nothing -# meaningful to pin, and a failed run leaves no half-applied state: -# `.objectstack-sha` stays byte-identical to what it was before the run. -# -# Env: -# OBJECTSTACK_ROOT the objectstack checkout to read (default: ../objectstack -# beside this repository) -# -# This mirrors objectstack's `scripts/bump-objectui.sh` -- its refusal and warning -# contract, not its changeset or SDUI-manifest steps, which have no counterpart -# here. Self-test: `scripts/__tests__/bump-objectstack.test.ts` drives this file's -# real bytes over throwaway git repositories. -# --help ends here -# -# ^ SENTINEL, not prose -- `--help` prints from the shebang down to the line above -# and stops there, so the terminator travels with the text it terminates. -# Everything from here down is internal rationale and is NOT user-facing help. - -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -OBJECTSTACK_ROOT="${OBJECTSTACK_ROOT:-$(cd "${ROOT}/../objectstack" 2>/dev/null && pwd || true)}" -PIN_FILE="${ROOT}/.objectstack-sha" - -NO_COMMIT=0 -EXPLICIT_REV="" -for arg in "$@"; do - case "$arg" in - --no-commit) NO_COMMIT=1 ;; - -h|--help) - # A missing sentinel EXITS 1 rather than printing to EOF: a truncated help and - # a complete one would both exit 0 and both print something. - if ! grep -qxF '# --help ends here' "$0"; then - echo "✗ ${0##*/}: the '# --help ends here' sentinel is missing -- cannot tell" >&2 - echo " where the help text ends. Restore it at the end of the header block." >&2 - exit 1 - fi - sed -n '2,/^# --help ends here$/p' "$0" \ - | grep -vxF '# --help ends here' \ - | sed 's/^# \{0,1\}//' - exit 0 - ;; - -*) - echo "✗ unknown option: ${arg} (see --help)" >&2 - exit 1 - ;; - *) EXPLICIT_REV="$arg" ;; - esac -done - -# `-e`, not `-d`: in a git WORKTREE `.git` is a file holding a `gitdir:` pointer. -if [[ -z "${OBJECTSTACK_ROOT}" || ! -e "${OBJECTSTACK_ROOT}/.git" ]]; then - if [[ -n "${OBJECTSTACK_ROOT}" ]]; then - echo "✗ ${OBJECTSTACK_ROOT} is not a git checkout (no .git)" >&2 - else - echo "✗ Cannot find an objectstack checkout at ${ROOT}/../objectstack" >&2 - fi - echo " Override with: OBJECTSTACK_ROOT=/path/to/objectstack scripts/bump-objectstack.sh" >&2 - exit 1 -fi - -# --- RESOLVE AND READ THE COMMIT BEFORE TOUCHING ANYTHING --------------------- -# The guard is the read itself, not a probe standing in for it: `git rev-parse -# HEAD` resolves the ref WITHOUT reading the object (a missing or unreadable -# commit object still prints a sha), and `cat-file -e` tests presence, not -# readability. `git log -1` reads the object, so anything that would fail later -# fails here, with the pin untouched. -REV="${EXPLICIT_REV:-HEAD}" -if ! NEW_SHA="$(git -C "$OBJECTSTACK_ROOT" rev-parse --verify --quiet "${REV}^{commit}")"; then - { - echo "✗ REFUSING to bump: '${REV}' does not resolve to a readable commit in" - echo " ${OBJECTSTACK_ROOT}." - echo " NOTHING WAS WRITTEN -- .objectstack-sha is untouched." - echo " Fetch it first: git -C ${OBJECTSTACK_ROOT} fetch origin" - } >&2 - exit 1 -fi -SHORT="${NEW_SHA:0:12}" -if ! SUBJECT_LINE="$(git -C "$OBJECTSTACK_ROOT" log -1 --format=%s "$NEW_SHA")"; then - { - echo "✗ REFUSING to bump: the objectstack commit object ${SHORT} cannot be read in" - echo " ${OBJECTSTACK_ROOT}." - echo " 'git log -1 --format=%s ${SHORT}' fails there, so there is nothing meaningful" - echo " to pin: no build of it could be made either." - echo " NOTHING WAS WRITTEN -- .objectstack-sha is untouched and still holds the old pin." - echo " Fetch or repair the objectstack object store, then re-run this bump:" - echo " git -C ${OBJECTSTACK_ROOT} fetch origin" - echo " git -C ${OBJECTSTACK_ROOT} cat-file -e ${SHORT}^{commit} && echo present" - } >&2 - exit 1 -fi - -# --- Is the commit we are about to pin actually ON objectstack main? ---------- -# A WARNING, NOT A GATE: `origin/main` in a local checkout is only as fresh as the -# last fetch, so refusing would reject a commit merged a minute ago. The build of -# the pin fetches the commit by sha from the objectstack remote, so a commit that -# was never pushed fails loudly there. -# -# THREE ANSWERS, NOT TWO. `merge-base --is-ancestor` exits 0 (ancestor), 1 (not an -# ancestor) and 128 on an error; a checkout with no `origin/main` cannot answer at -# all. Neither error is folded into either verdict. -REACH_TAG="" -REACH_RECALL="" - -reach_ref_list() { - local n="$#" - if (( n == 0 )); then printf 'none'; return 0; fi - local out - out="$(printf '%s, ' "${@:1:10}")" - out="${out%, }" - if (( n > 10 )); then out="${out} … (+$(( n - 10 )) more; ${n} total)"; fi - printf '%s' "$out" -} - -report_reachability() { - local sha="$1" - local short="${sha:0:12}" - local rc=0 - - if ! git -C "$OBJECTSTACK_ROOT" rev-parse --verify --quiet origin/main >/dev/null 2>&1; then - REACH_TAG=" (reachability UNKNOWN)" - REACH_RECALL=" -- ⚠️ pin reachability UNKNOWN, see above" - { - echo - echo "⚠️ COULD NOT DETERMINE whether ${short} is on objectstack main." - echo " ${OBJECTSTACK_ROOT} has no 'origin/main' ref, so the question cannot be asked" - echo " in this checkout at all." - echo " This is NOT 'the pin is fine' and NOT 'the pin left main'. It is unanswered," - echo " and the pin is being written anyway." - echo " To make it answerable here: git -C ${OBJECTSTACK_ROOT} fetch origin main" - echo " (not run for you -- this script stays usable offline.)" - echo - } >&2 - return 0 - fi - - git -C "$OBJECTSTACK_ROOT" merge-base --is-ancestor "$sha" origin/main 2>/dev/null || rc=$? - - if [[ "$rc" -eq 0 ]]; then - REACH_TAG=" (on origin/main)" - return 0 - fi - - if [[ "$rc" -ne 1 ]]; then - REACH_TAG=" (reachability UNKNOWN)" - REACH_RECALL=" -- ⚠️ pin reachability UNKNOWN, see above" - { - echo - echo "⚠️ COULD NOT DETERMINE whether ${short} is on objectstack main." - echo " 'git merge-base --is-ancestor ${short} origin/main' exited ${rc}; only 0" - echo " (is an ancestor) and 1 (is not) are verdicts. Anything else is an error," - echo " and is reported as one rather than folded into either answer." - echo " In a shallow checkout a missing history reads this way: deepen it with" - echo " git -C ${OBJECTSTACK_ROOT} fetch --unshallow origin" - echo - } >&2 - return 0 - fi - - REACH_TAG=" (NOT on origin/main)" - REACH_RECALL=" -- ⚠️ pin is NOT on objectstack origin/main, see above" - - # A read loop, not `mapfile`: this runs by hand on macOS, whose bash is 3.2. - local -a local_refs=() remote_refs=() - local ref_line='' - while IFS= read -r ref_line; do - if [[ -n "$ref_line" ]]; then local_refs+=("$ref_line"); fi - done < <( - git -C "$OBJECTSTACK_ROOT" for-each-ref --contains "$sha" --format='%(refname:short)' refs/heads 2>/dev/null || true - ) - while IFS= read -r ref_line; do - if [[ -n "$ref_line" ]]; then remote_refs+=("$ref_line"); fi - done < <( - git -C "$OBJECTSTACK_ROOT" for-each-ref --contains "$sha" --format='%(refname:short)' refs/remotes 2>/dev/null || true - ) - - local main_tip - main_tip="$(git -C "$OBJECTSTACK_ROOT" log -1 --format='%h, committed %cr' origin/main 2>/dev/null || echo 'unknown')" - - { - echo - echo "⚠️ objectstack pin ${short} is NOT reachable from origin/main in" - echo " ${OBJECTSTACK_ROOT} -- it names a commit that is not on objectstack main." - echo " on local branches : $(reach_ref_list ${local_refs[@]+"${local_refs[@]}"})" - echo " on remote branches: $(reach_ref_list ${remote_refs[@]+"${remote_refs[@]}"})" - if (( ${#remote_refs[@]} > 0 )); then - echo " → It IS pushed, but only onto branch(es) that have not merged into main." - elif (( ${#local_refs[@]} > 0 )); then - echo " → It is on a LOCAL branch only -- the pinned build fetches it from the" - echo " objectstack remote by sha, so it fails until this commit is pushed." - else - echo " → No branch in this checkout contains it (detached HEAD on a discarded commit?)." - fi - echo " Or your origin/main is simply STALE: it points at ${main_tip}." - echo " Refresh it with: git -C ${OBJECTSTACK_ROOT} fetch origin main" - echo " (not run for you -- this script stays usable offline, and the answer to" - echo " this question is yours to judge.)" - echo " Pinning it anyway." - echo - } >&2 - return 0 -} - -report_reachability "$NEW_SHA" - -OLD_SHA='(none)' -if [[ -f "$PIN_FILE" ]]; then OLD_SHA="$(tr -d '[:space:]' < "$PIN_FILE")"; fi - -print_next_step() { - echo - echo "→ NEXT STEP -- let pnpm link the build of ${SHORT}:" - echo " pnpm install # commit pnpm-lock.yaml if this changes it" - echo " Then type-check and test against it. OBJECTSTACK_ROOT= pnpm objectstack:build" - echo " stands an existing objectstack tree in instead of the pin." -} - -if [[ "$OLD_SHA" == "$NEW_SHA" ]]; then - echo "→ Already at ${SHORT}${REACH_TAG}, nothing to do." - exit 0 -fi - -# THE INVARIANT THAT MUST HOLD HERE: no read of the objectstack CHECKOUT happens -# below this point. Everything that can fail on such a read already ran, and -# refused, above. The build below fetches the commit by sha; when it fails, the -# old pin is put back byte for byte. -PIN_BACKUP="$(mktemp)" -trap 'rm -f "$PIN_BACKUP"' EXIT -if [[ -f "$PIN_FILE" ]]; then cp "$PIN_FILE" "$PIN_BACKUP"; fi -echo "$NEW_SHA" > "$PIN_FILE" -echo "→ objectstack pin: ${OLD_SHA:0:12} → ${SHORT}${REACH_TAG}" -echo " ${SUBJECT_LINE}" - -# `env -u`: OBJECTSTACK_ROOT names the checkout read above; reaching the build it -# would stand that tree in for the pin. -if ! (cd "$ROOT" && env -u OBJECTSTACK_ROOT node scripts/objectstack-pin.mjs build --write-manifests); then - if [[ "$OLD_SHA" == '(none)' ]]; then rm -f "$PIN_FILE"; else cp "$PIN_BACKUP" "$PIN_FILE"; fi - echo "✗ REFUSING to bump: the build of ${SHORT} failed (above). .objectstack-sha is restored; nothing was committed." >&2 - exit 1 -fi - -if [[ "$NO_COMMIT" -eq 1 ]]; then - echo "→ --no-commit: leaving .objectstack-sha and .objectstack-pin unstaged${REACH_RECALL}." - print_next_step - exit 0 -fi - -git -C "$ROOT" add .objectstack-sha .objectstack-pin -git -C "$ROOT" commit -m "chore: bump objectstack to ${SHORT} - -${SUBJECT_LINE} - -objectstack@${NEW_SHA}" -- .objectstack-sha .objectstack-pin -echo "✓ Committed${REACH_RECALL}. Push with: git push" -print_next_step diff --git a/ui/scripts/check-action-ref-convention.mjs b/ui/scripts/check-action-ref-convention.mjs index 4649366c1c6..de1e40af0e1 100644 --- a/ui/scripts/check-action-ref-convention.mjs +++ b/ui/scripts/check-action-ref-convention.mjs @@ -141,21 +141,13 @@ export const DECLARED_EXCEPTIONS = [ 'reviewed moment; a floating ref would re-adopt every upstream change on the next scheduled ' + 'run, with nothing in this repository to roll back to.', }, - // The four below share one reason, said once: each is a third-party action + // The three below share one reason, said once: each is a third-party action // that runs holding a credential, so the code a floating tag resolves to // would receive that credential on the next run after the tag moved, with // nothing here reviewing it. A commit sha cannot move; the trailing // `# vX.Y.Z` comment names the release it is. First-party `actions/*` refs - // stay on the floating-tag default. - { - workflow: 'changeset-release.yml', - action: 'changesets/action', - issue: 'objectui#13430', - reason: - 'runs with NPM_TOKEN and GITHUB_TOKEN in the publish and version-PR steps; pinned to the ' + - 'v1.9.0 commit, the one the workflow comment and the ref-pin test say both v1 properties ' + - 'were read at (scripts/__tests__/changeset-release-action-ref-pin.test.ts holds the sha).', - }, + // stay on the floating-tag default. (A fourth, changesets/action in + // changeset-release.yml, left with that workflow: objectstack#25711.) { workflow: 'check-links.yml', action: 'lycheeverse/lychee-action', diff --git a/ui/scripts/check-changeset-fixed.mjs b/ui/scripts/check-changeset-fixed.mjs index fc2d74218d2..94cf5365463 100644 --- a/ui/scripts/check-changeset-fixed.mjs +++ b/ui/scripts/check-changeset-fixed.mjs @@ -63,6 +63,7 @@ import { readFileSync, readdirSync, statSync } from "fs"; import { resolve, dirname, join } from "path"; import { fileURLToPath } from "url"; import { isEntrypoint } from "./invoked-as.mjs"; +import { changesetConfigPath } from "./workspace-root.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -165,7 +166,7 @@ function readManifests(dirs) { } function main() { - const config = JSON.parse(readFileSync(resolve(root, ".changeset/config.json"), "utf8")); + const config = JSON.parse(readFileSync(changesetConfigPath(root), "utf8")); const { unclassified, privateInFixed, privatePackages } = analyze( config, readManifests(PACKAGE_ROOTS) diff --git a/ui/scripts/check-changeset-no-major.mjs b/ui/scripts/check-changeset-no-major.mjs index 585524b06c6..7a1ff80d896 100644 --- a/ui/scripts/check-changeset-no-major.mjs +++ b/ui/scripts/check-changeset-no-major.mjs @@ -95,6 +95,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { resolve, dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { isEntrypoint } from './invoked-as.mjs'; +import { changesetConfigPath } from './workspace-root.mjs'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); @@ -220,7 +221,7 @@ export function majorOfRange(range) { /** The `fixed` groups of `.changeset/config.json`, as arrays of package names. */ export function readFixedGroups(repoRoot) { - const config = JSON.parse(readFileSync(join(repoRoot, '.changeset', 'config.json'), 'utf8')); + const config = JSON.parse(readFileSync(changesetConfigPath(repoRoot), 'utf8')); return Array.isArray(config.fixed) ? config.fixed.filter(Array.isArray) : []; } diff --git a/ui/scripts/check-changeset-presence.mjs b/ui/scripts/check-changeset-presence.mjs index 2971dfffa75..a256fa15c5a 100644 --- a/ui/scripts/check-changeset-presence.mjs +++ b/ui/scripts/check-changeset-presence.mjs @@ -424,7 +424,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { isEntrypoint } from './invoked-as.mjs'; -import { revPath } from './workspace-root.mjs'; +import { changesetConfigPath, revPath } from './workspace-root.mjs'; const scriptDir = dirname(fileURLToPath(import.meta.url)); @@ -631,14 +631,15 @@ function ignoreMatcher(patterns) { * build and not a quiet pass. */ export function readReleaseConfig(root) { - const dir = join(root, '.changeset'); - if (!existsSync(dir)) { + let configPath; + try { + configPath = changesetConfigPath(root); + } catch { throw new Error( - `${dir} does not exist. This gate reads \`.changeset/config.json\` to learn which ` + + `${join(root, '.changeset')} does not exist. This gate reads \`.changeset/config.json\` to learn which ` + 'packages a release covers; without it there is nothing to check against.', ); } - const configPath = join(dir, 'config.json'); let config; try { config = JSON.parse(readFileSync(configPath, 'utf8')); diff --git a/ui/scripts/check-entry-guard.mjs b/ui/scripts/check-entry-guard.mjs index 61de3b7df34..c98d2daad87 100644 --- a/ui/scripts/check-entry-guard.mjs +++ b/ui/scripts/check-entry-guard.mjs @@ -112,7 +112,7 @@ * same reasoning holds here: * * • many of these scripts have real side effects (`shadcn-sync`, - * `sync-quick-reference-release`, `regenerate-known-schema-types`), so a + * `regenerate-known-schema-types`), so a * gate that spawns all of them is a gate nobody can run locally; * • "produced output" is not a decidable property of an arbitrary tool -- a * quiet-on-success script is legitimate, so the assertion would have to be diff --git a/ui/scripts/check-installed-spec-pin-claims.mjs b/ui/scripts/check-installed-spec-pin-claims.mjs deleted file mode 100644 index 6e2a7698be3..00000000000 --- a/ui/scripts/check-installed-spec-pin-claims.mjs +++ /dev/null @@ -1,1178 +0,0 @@ -#!/usr/bin/env node -/** - * objectui#8924: prose in this tree states which `@objectstack/*` version is - * INSTALLED, and nothing reads those sentences. - * - * ## Why a gate and not a sweep of corrected sentences - * - * objectui#8897 corrected SIX statements that said the pin **is 17.3.0**. It - * found them with a probe that was version-literal on `17.3.0`, so it was blind - * BY CONSTRUCTION to the identical sentence written about an earlier pin: the - * `17.2.0` cohort was never in its field of view. Re-running by PREDICATE - * rather than by version string, on `16fc4cf20`, finds 68 present-tense claim - * sites in 55 files — 9 of them naming the pin correctly and 59 off it, spread - * over 16 workspace packages and `scripts/`. The LEDGER below is that - * population, entry by entry. - * - * That is the whole lesson, and it is a lesson about instruments, not about - * sentences: a probe shaped like the example you already have cannot measure - * the class it belongs to. A corrected version number with nothing checking it - * regenerates the same card at the next bump — objectui#8606, #8629 and #8752 - * reached that conclusion from three other directions before this one did. - * - * So this file is the reader those sentences never had. It resolves the pin - * from the ARTIFACT, recognises claims about the pin in prose, and fails when - * the two disagree. - * - * ## The distinction this gate exists to preserve - * - * Two classes of sentence name a version, and only one of them can rot: - * - * - **"when it changed"** — "17.3.0 made `reference` a hard requirement", - * "43 keys at 17.3.0", "New in `@objectstack/spec` 17.3.0". These are - * permanently true. A gate that "fixes" them rewrites history into a - * falsehood, and that is strictly worse than the stale claim it replaces. - * - **"what is installed"** — "Measured against the installed - * `@objectstack/spec` 17.2.0", "the pin is still 17.2.0". These are the - * subject, and they are false the moment the lockfile moves — or, since - * objectui#12957, the moment the pinned build's version string does. - * - * A bare version literal cannot tell them apart, which is why the predicate - * below requires an installed-ness MARKER as well as a version, and why - * `HISTORICAL_CUES` refuses lines that carry the marker inside a sentence about - * the past. `scripts/__tests__/check-installed-spec-pin-claims.test.ts` pins - * both directions on REAL lines of this tree, each with a firing control — a - * refusal demonstrated only on lines where nothing could have fired is not a - * demonstration of the distinction. - * - * ## What it promises, and what it does not - * - * PROMISED. Every recognised, present-tense claim about an installed - * `@objectstack/*` version either names the version the artifact resolves to, - * or carries a `LEDGER` entry with a class and a reason. The ledger ratchets in - * BOTH directions: an entry naming a claim that is no longer in the tree fails - * too, so cleaning a docblock forces the list to shrink and the list can never - * rot into a permanent hole. (Same stance as `KNOWN_CLAIMS` in - * `scripts/__tests__/doc-version-claims.test.ts` and `DOCUMENTATION_EXEMPT` in - * `ci-cd-pipeline-doc.test.ts`.) - * - * NOT PROMISED, stated so it is not mistaken for covered: - * - * - **The measured FACT beside the version.** "installed 17.2.0: - * `SelectOptionSchema` is `.strict()`" carries two claims; this gate judges - * the version and says nothing about the schema. That is why a ledger entry - * is not a licence to find-and-replace the number — each site has to be - * re-parsed against the resolved artifact before its stamp moves, or the - * repair plants a FRESH false premise. objectui#8897 did that re-parse for - * each of its six sites; it is the expensive half and it is deliberately - * not in this gate's remit. - * - **Claims split across lines.** Recognition is per line, because the - * marker and the version have to be read together and a docblock paragraph - * has no machine-visible boundary. A claim whose marker sits one line above - * its version is invisible here, and this tree has one: - * `packages/i18n/src/utils/spec-formatters.ts` ends a line with "The pinned" - * and opens the next with "17.0.0-rc.6 removed all four". That claim is as - * stale as any in the LEDGER and this gate cannot see it — recorded here - * rather than left for the next reader to discover as a silent hole. - * - **CHANGELOG and `.changeset/*.md`.** A pending changeset becomes a - * CHANGELOG entry verbatim, so both are DATED RECORDS of a change, not - * claims about today. Excluded by path, never by classification. - * - * ## Where it runs - * - * `scripts/__tests__/check-installed-spec-pin-claims.test.ts` runs it over the - * real tree, so the ordinary vitest suite is the blocking copy — the same - * placement `doc-version-claims.test.ts` has, and for the same reason: this - * reads TEXT and a lockfile, needs no build and no network, so a dedicated - * workflow would buy a second thing to keep wired and nothing else. - * `pnpm check:installed-pin-claims` is the human entry point. - * - * Run: node scripts/check-installed-spec-pin-claims.mjs - * node scripts/check-installed-spec-pin-claims.mjs --explain - * node scripts/check-installed-spec-pin-claims.mjs --json - * Exit: 0 = OK, 1 = a claim drifted, or the ledger is stale - */ - -import { execFileSync } from 'node:child_process'; -import { existsSync, readdirSync, readFileSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -// The ONE entry-guard predicate; a hand-typed `process.argv[1]` comparison -// answers false through a symlink and the gate then does nothing, with exit 0 -// and no output. `scripts/check-entry-guard.mjs` fails on the hand-typed form. -import { isEntrypoint } from './invoked-as.mjs'; -import { lockfilePath } from './workspace-root.mjs'; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const ROOT = resolve(HERE, '..'); - -// ── The anchor packages ────────────────────────────────────────────────────── -// The `@objectstack/*` line publishes in lockstep, and prose in this repo names -// two of them by version. `DEFAULT_ANCHOR` is what a claim that names no -// package at all ("the pin is still 17.2.0", "measured on the installed -// 17.2.0") is about: every such line in this tree, checked one by one when this -// gate was written, is about the spec. -export const ANCHOR_PACKAGES = [ - '@objectstack/spec', - '@objectstack/client', - '@objectstack/core', - '@objectstack/formula', -]; -export const DEFAULT_ANCHOR = '@objectstack/spec'; - -// ── The predicate ──────────────────────────────────────────────────────────── - -/** - * A three-part version, optionally with a prerelease tail. The tail is part of - * the token ON PURPOSE: `17.0.0-rc.6` is not `17.0.0`, and a regex that stops - * at the patch digit turns eleven distinct rc claims in this tree into one. - */ -export const VERSION_TOKEN = /\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?/g; - -/** - * A version preceded by any of these is a RANGE, not a claim about what is - * installed — `^17.2.0` is a declared floor and stays put across bumps. The - * card's predicate excluded ranges for the same reason. - */ -const RANGE_PREFIX = /[~^=]$|[<>]=?$/; - -/** - * The installed-ness markers, verbatim from objectui#8924's predicate, each - * word-bounded. - * - * ⭐ The boundaries are load-bearing, not tidiness. `/installed/` without them - * matches inside `MarketplaceRowActions.installedAppsPins.test.tsx`, and the - * line that identifier sits on is - * `'EMPTY SPEC SHAPE. New in @objectstack/spec 17.3.0 …'` — a textbook - * "when it changed" sentence naming an in-range version. An unbounded marker - * puts it in the population, and "fixing" it would delete a true fact about - * 17.3.0. That line is the refusal control in the test file. - */ -export const CLAIM_MARKERS = [ - { id: 'installed', re: /\binstalled\b/i }, - { id: 'repo-pin', re: /\bthis repo'?s pin\b/i }, - { id: 'pin-is', re: /\bthe pin (?:is|remains|resolves)\b/i }, - { id: 'pinned', re: /\bpinned\b/i }, - { id: 'at-that-pin', re: /\bat that pin\b/i }, -]; - -/** - * Lines carrying a marker INSIDE a sentence about the past. Each cue was read - * off a real line of this tree; the citation is the line it was taken from, so - * a future reader can check the cue still earns its place. - * - * A cue that fires wrongly costs a HOLE (a stale claim goes unreported), never - * a false repair — this gate reports, it never rewrites. The ledger's - * `historical` class is the other half: it catches the sentences whose pastness - * no cue can see. - */ -export const HISTORICAL_CUES = [ - { - id: 'then-pinned', - re: /\bthen[-\s]pinned\b/i, - cited: 'apps/console/src/__tests__/registry-inputs-spec-parity.test.ts — "the then-pinned 17.0.0-rc.6 declared none"', - }, - { - id: 'installed-then', - re: /\binstalled then\b/i, - cited: 'packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.inactiveRetained.test.ts — "Measured on the spec installed then: 17.2.0"', - }, - { - id: 'was-the-installed', - re: /\bwas the installed\b/i, - cited: 'apps/console/src/__tests__/registry-inputs-spec-parity.test.ts — "What held them was the installed contract"', - }, - { - id: 'marker-past-copula', - re: /\b(?:pin|installed(?:\s+\w+){0,2}|version)\s+(?:was|were)\b/i, - cited: 'packages/types/src/__tests__/validation-rule-spec-parity.test.ts — "installed spec was 17.0.0-rc.0"', - }, - { - id: 'used-to', - re: /\bused to\b/i, - cited: 'packages/plugin-designer/src/MetadataFieldsPage.specKeyReference.test.tsx — "The sentence that used to sit here said the installed pin WAS 17.3.0"', - }, - { - id: 'still-said', - re: /\bstill said\b/i, - cited: 'packages/app-shell/src/services/MetadataService.specKeyReference.test.ts — "docblock still said \\"this repo\'s pin is 17.3.0\\""', - }, - { - id: 'pinned-recording-sense', - re: /\bpinned (?:in|by|below|above)\b/i, - cited: 'packages/core/src/utils/__tests__/filter-date-comparand-8555.test.ts — "pinned in section 3" means RECORDED there, not dependency-pinned', - }, - { - id: 'until-version', - re: /\bUntil\b/, - cited: 'packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts — "Until `@objectstack/spec` 17.2.0 this block pinned mutual assignability"', - }, -]; - -// ── Recognition ────────────────────────────────────────────────────────────── - -/** - * Which majors a version naming NO package may still be attributed to the - * anchor on. See `attributePackage` case 3 for why this exists and what it is - * protecting against; `run()` widens it with every major the LEDGER names, so a - * major bump does not make the previous line's bare claims invisible on the day - * it stops being able to see them. - */ -export const DEFAULT_BARE_MAJORS = ['17']; - -/** - * Words that may stand immediately before a version WITHOUT naming what the - * version belongs to. Anything else in that position is read as a package name - * — "React 19.2.8", "recharts 3.10.1", "lucide-react 1.31.0" — and the version - * is not the anchor's. - */ -const CONNECTIVES = new Set([ - 'a', 'against', 'an', 'and', 'artifact', 'as', 'at', 'both', 'by', 'carries', 'from', 'in', 'including', - 'installed', 'is', 'its', 'measured', 'now', 'of', 'on', 'only', 'or', 'pin', 'pinned', 'plus', 'reaches', - 'remains', 'repo', 'resolves', 'spec', 'still', 'that', 'the', 'this', 'through', 'to', 'today', 'tree', - 'until', 'version', 'was', 'with', -]); - -/** - * Which package a version token on this line is about. - * - * 1. ATTACHED (`@objectstack/client@17.2.0`) wins outright — including when it - * attaches to a package that is NOT an anchor (`zod@3.25.76`), which is how - * the gate declines to judge a claim about somebody else's pin. - * 2. Otherwise, if the line names exactly one anchor package, it is that one. - * 3. Otherwise the line names NO package — "Measured on the installed 17.2.0", - * "installed 17.2.0, `x_plugin_thing` is `unrecognized_keys`" — and those - * are real claim sites this gate must see. They are attributed to - * `DEFAULT_ANCHOR` only when BOTH guards hold: - * - the major is one the anchor line uses, and - * - the word immediately before it is not a package name. - * ⚠️ Both are needed and neither is redundant. This tree carries - * `Installed v1.0.0` (a marketplace UI string), `resolving to 1.7.3` (a - * better-auth pin) and `version: '1.4.2'` (an i18n fixture) — the first two - * are preceded by connectives and only the major guard declines them; and - * `pinned React 19.2.8` shares no major but is declined by the name guard - * the day React and the anchor line do share one. - */ -export function attributePackage(line, index, bareMajors = DEFAULT_BARE_MAJORS) { - const before = line.slice(0, index); - const attached = before.match(/(@?[a-z0-9][a-z0-9._/-]*)@$/i); - if (attached) return ANCHOR_PACKAGES.includes(attached[1]) ? attached[1] : null; - - const named = [...new Set(ANCHOR_PACKAGES.filter((p) => line.includes(p)))]; - if (named.length === 1) return named[0]; - if (named.length > 1) return null; - - const major = line.slice(index).match(/^(\d+)\./)[1]; - if (!bareMajors.includes(major)) return null; - const preceding = before.match(/([A-Za-z][A-Za-z0-9_.@/-]{0,40})\s+v?$/); - if (preceding && !CONNECTIVES.has(preceding[1].toLowerCase())) return null; - return DEFAULT_ANCHOR; -} - -/** - * Recognise the installed-pin claims on ONE line. - * - * @returns {{ verdict: 'none' | 'refused' | 'claim', refusedBy?: string, - * marker?: string, claims: Array<{ package: string, version: string }> }} - */ -export function recogniseLine(line, bareMajors = DEFAULT_BARE_MAJORS) { - const marker = CLAIM_MARKERS.find((m) => m.re.test(line)); - if (!marker) return { verdict: 'none', claims: [] }; - - const claims = []; - const seen = new Set(); - VERSION_TOKEN.lastIndex = 0; - let hit; - while ((hit = VERSION_TOKEN.exec(line)) !== null) { - if (RANGE_PREFIX.test(line.slice(0, hit.index))) continue; - const pkg = attributePackage(line, hit.index, bareMajors); - if (pkg === null) continue; - const key = `${pkg}@${hit[0]}`; - if (seen.has(key)) continue; - seen.add(key); - claims.push({ package: pkg, version: hit[0] }); - } - if (claims.length === 0) return { verdict: 'none', claims: [] }; - - const cue = HISTORICAL_CUES.find((c) => c.re.test(line)); - if (cue) return { verdict: 'refused', refusedBy: cue.id, marker: marker.id, claims }; - - return { verdict: 'claim', marker: marker.id, claims }; -} - -// ── The artifact: the pin read from two independent faces ──────────────────── - -/** - * Face A — `pnpm-lock.yaml`. Every snapshot key for an anchor package. - * - * Read as TEXT, not parsed as YAML, because the shape being read is the KEY - * spelling (`'@objectstack/spec@17.4.0(ai@7.0.65(zod@4.4.3))'`), and a YAML - * load would hand back a peer-suffixed key that still has to be split the same - * way. Two keys resolving one package at two versions is itself a finding. - * - * ⭐ Since objectui#12957 the lockfile names no `@objectstack/*` VERSION: every - * one resolves to a directory of the build `.objectstack-sha` pins - * (`'@objectstack/spec@file:.objectstack-pin/spec(ai@…)'`), and the - * lockfile records that directory with no version. So this face FOLLOWS the - * resolution: the version is the one the directory's manifest declares — the - * packed `package.json` of the pinned commit. A directory that is not there (the - * build was never made) answers `(pinned build absent: )`, which equals no - * version and so turns into a CONFLICT naming it, never a silent empty face. - * - * ⚠️ What the version means changed with it: it is the version string the pinned - * COMMIT carries, and objectstack moves that string only when it releases — so a - * pin between two releases reads as the earlier release's number while carrying - * later commits. A claim judged "at the pin" here is right about the number and - * says nothing about which commit; the commit is `.objectstack-sha`. - * - * @param {string} lockText - * @param {string} [root] what the lockfile's `file:` paths are relative to - */ -export function pinsFromLockfile(lockText, root = ROOT) { - const found = new Map(); - const add = (pkg, version) => { - if (!ANCHOR_PACKAGES.includes(pkg)) return; - if (!found.has(pkg)) found.set(pkg, new Set()); - found.get(pkg).add(version); - }; - for (const line of lockText.split('\n')) { - const m = line.match(/^\s*'?(@objectstack\/[a-z-]+)@(\d[^'():\s]*)/); - if (m) { - add(m[1], m[2]); - continue; - } - const dir = line.match(/^\s*'?(@objectstack\/[a-z-]+)@file:([^'():\s]+)/); - if (dir) add(dir[1], versionOfDirectory(root, dir[2])); - } - return found; -} - -/** - * @param {string} root - * @param {string} dir a lockfile `file:` path, relative to `root` - * @returns {string} - */ -function versionOfDirectory(root, dir) { - try { - return JSON.parse(readFileSync(join(root, dir, 'package.json'), 'utf8')).version; - } catch { - return `(pinned build absent: ${dir} — run \`pnpm objectstack:build\`)`; - } -} - -/** - * Face B — the RESOLVED TREE. pnpm links a copy of each dependency into every - * workspace package that declares it, so this reads what `import` would - * actually load — the face a docblock saying "measured against the installed - * artifact" is talking about. - * - * ⛔ Read only. Nothing under `node_modules` is written or moved here; those - * entries are hardlinks into the pnpm store and mutating one corrupts every - * other project sharing it. - */ -export function pinsFromResolvedTree(root, dirs) { - const found = new Map(); - for (const dir of ['.', ...dirs]) { - for (const pkg of ANCHOR_PACKAGES) { - const manifest = join(root, dir, 'node_modules', pkg, 'package.json'); - if (!existsSync(manifest)) continue; - const version = JSON.parse(readFileSync(manifest, 'utf8')).version; - if (!found.has(pkg)) found.set(pkg, new Set()); - found.get(pkg).add(version); - } - } - return found; -} - -/** - * The two faces, reconciled. A package the faces disagree about has NO pin as - * far as this gate is concerned — every claim about it becomes a finding, - * rather than being judged against a number the gate had to pick a side on. - */ -export function reconcilePins(lockPins, treePins) { - const pins = new Map(); - const conflicts = []; - const unlinked = []; - for (const pkg of ANCHOR_PACKAGES) { - const lock = [...(lockPins.get(pkg) ?? [])].sort(); - const tree = [...(treePins.get(pkg) ?? [])].sort(); - if (lock.length === 0 && tree.length === 0) continue; - // Present in the lockfile, linked into no workspace package: a TRANSITIVE - // dependency. There is no installed artifact for prose to have been - // measured against, so it gets no pin and no conflict — a claim naming it - // becomes a finding in `judge`, which is the honest answer rather than - // judging it against a number only one face can see. - if (tree.length === 0) { - unlinked.push({ package: pkg, lock }); - continue; - } - if (lock.length !== 1 || tree.length !== 1 || lock[0] !== tree[0]) { - conflicts.push({ package: pkg, lock, tree }); - continue; - } - pins.set(pkg, lock[0]); - } - return { pins, conflicts, unlinked }; -} - -// ── The scan surface ───────────────────────────────────────────────────────── - -/** - * Paths this gate does not read, and why each is excluded BY PATH rather than - * by classification — a path exclusion cannot be argued with later. - */ -export const SCAN_EXCLUSIONS = [ - { id: 'lockfile', test: (f) => f === 'pnpm-lock.yaml', why: 'the artifact itself, not a claim about it' }, - { - id: 'changelog', - test: (f) => /(^|\/)CHANGELOG[^/]*$/i.test(f), - why: 'a dated record of a change, never a statement about today (objectui#8924)', - }, - { - id: 'changeset', - test: (f) => f.startsWith('.changeset/'), - why: 'a pending changeset becomes a CHANGELOG entry verbatim, so it is dated the same way', - }, - { - id: 'this-gate', - test: (f) => - f === 'scripts/check-installed-spec-pin-claims.mjs' || - f === 'scripts/__tests__/check-installed-spec-pin-claims.test.ts', - why: 'this gate quotes real claim sentences as examples and its test builds synthetic ones; a scanner reading its own fixtures reports itself', - }, -]; - -export function isScanned(file) { - return !SCAN_EXCLUSIONS.some((x) => x.test(file)); -} - -// ── The ledger ─────────────────────────────────────────────────────────────── - -/** - * Every recognised, present-tense claim in this tree naming a version the - * artifact does NOT resolve to. One entry per (file, package, version), with - * the number of sites in that file so a partial repair cannot hide. - * - * Classes: - * - * - `stale` — a real installed-pin claim, false today. The backlog. ⚠️ An - * entry here is NOT a licence to restamp the number: the sentence carries a - * measured FACT as well as a version, and the fact has to be re-parsed - * against the resolved artifact first (see NOT PROMISED in the header). - * - `historical` — the predicate matched, but the version is not a statement - * about today: the sentence says WHEN something changed, records a - * measurement anchored to a named commit, names a release's record SET, or - * quotes a refusal message verbatim. ⛔ Permanently true; never restamp. - * These are exactly the sentences a version-literal sweep would rewrite - * into a falsehood, which is why the class exists at all rather than - * everything off the pin being called drift. - * - * Two classes and no more, deliberately. Every entry answers ONE question — - * may the number be moved? — and a third class would be a place to put - * sentences nobody decided about. - * - * The ledger ratchets both ways. Repair a site and the count falls, so the - * entry must be edited or deleted in the same change; an entry naming a claim - * no longer in the tree fails exactly as loudly as an unledgered claim. - */ -export const LEDGER_CLASSES = ['stale', 'historical']; - -export const LEDGER = [ - { - file: "apps/console/src/__tests__/registry-inputs-spec-parity.test.ts", - package: "@objectstack/spec", - version: "17.0.0", - sites: 1, - class: "historical", - why: "Past narrative of the objectui#5328 bump: \"while the installed 17.0.0 still carried both as live\". The version dates the state that ENDED, not today.", - }, - { - file: "apps/console/src/__tests__/registry-inputs-spec-parity.test.ts", - package: "@objectstack/spec", - version: "17.0.0-rc.6", - sites: 1, - class: "stale", - why: "\"the pinned 17.0.0-rc.6 does not carry at all\" \u2014 present tense about the pin, false today. Re-measure whether GA-added `object-*` blocks are in `ComponentPropsMap` at the resolved pin before restamping.", - }, - { - file: "apps/console/src/__tests__/registry-inputs-spec-parity.test.ts", - package: "@objectstack/spec", - version: "17.1.0", - sites: 2, - class: "historical", - why: "Two lines, both about the release rather than the install: \":507\" asks whether the installed spec carries \"the 17.1.0 record set\" (the version names a record SET), and \":841\" names \"the 17.1.0 pin\" as the bump that DELIVERED the retirements. Neither restamps.", - }, - { - file: "apps/console/src/__tests__/registry-inputs-spec-parity.test.ts", - package: "@objectstack/spec", - version: "17.3.0", - sites: 1, - class: "stale", - why: "\"MEASURED on the installed @objectstack/spec 17.3.0 artifact\" \u2014 re-parse `record:activity.types` against the resolved artifact before moving the stamp.", - }, - { - file: "apps/console/src/__tests__/registry-inputs-spec-parity.test.ts", - package: "@objectstack/spec", - version: "17.5.0", - sites: 4, - class: "historical", - why: "Four lines about the release, not the install. Two ask whether the installed spec carries \"the 17.5.0 set\" / \"the loaded 17.5.0 blocks\" (the version names a record SET, as the 17.1.0 entry above does). Two are `pins` prose whose `pinned` is an unrelated verb (\"pinned narrowly\", \"pinned as THE RESIDUAL LEG\") beside a WHEN sentence (`allDayField` \"since `@objectstack/spec` 17.5.0\"; `redactFields` \"until `@objectstack/spec` 17.5.0 declared it\"). None restamps (objectui#11438).", - }, - { - file: "apps/console/src/__tests__/registry-inputs-spec-parity.test.ts", - package: "@objectstack/spec", - version: "17.6.0", - sites: 1, - class: "historical", - why: "The `object-master-detail-form.details` pins prose: \"Since `@objectstack/spec` 17.6.0 (objectstack-ai/objectstack#21215) the spec row is an array of CLOSED entries\", the release the closed entry landed in, and \"a fork through 17.6.0, closed by `@objectstack/spec` 17.7.0\", the release that still declared `sortField`. `pinned` on that line is the verb in \"the pinned file\". Neither restamps (objectui#11717).", - }, - { - file: "apps/console/src/__tests__/registry-inputs-spec-parity.test.ts", - package: "@objectstack/spec", - version: "17.7.0", - sites: 1, - class: "historical", - why: "The `object-master-detail-form.details` pins prose: \"a fork through 17.6.0, closed by `@objectstack/spec` 17.7.0, whose entry refuses an authored `sortField`\" names the release that CLOSED the fork (objectui#11717), not the install. The two `conditionalFormatting` lines that named 17.7.0 as the installed row were re-measured and restamped by objectui#12030.", - }, - { - file: "apps/console/src/components/FormPage.tsx", - package: "@objectstack/spec", - version: "17.0.0", - sites: 1, - class: "stale", - why: "\"measured against the installed 17.0.0\" backs an `unrecognized_keys` claim about a form config; re-parse at the resolved pin first.", - }, - { - file: "packages/app-shell/src/services/MetadataService.objectPayloadFieldsMap.test.ts", - package: "@objectstack/spec", - version: "17.2.0", - sites: 1, - class: "stale", - why: "\"Measured on the installed 17.2.0\" stamps the fields-map shape the instrument below asserts.", - }, - { - file: "packages/app-shell/src/services/MetadataService.retiredFieldSortOrder.test.ts", - package: "@objectstack/spec", - version: "17.2.0", - sites: 1, - class: "stale", - why: "Stamps what the installed schema does with the retired `sortOrder` key.", - }, - { - file: "packages/app-shell/src/services/MetadataService.retiredObjectEnabled.test.ts", - package: "@objectstack/spec", - version: "17.2.0", - sites: 1, - class: "stale", - why: "Stamps the installed ESM build's handling of the retired `enabled` key.", - }, - { - file: "packages/app-shell/src/services/MetadataService.ts", - package: "@objectstack/spec", - version: "17.2.0", - sites: 2, - class: "stale", - why: "Two sites in one docblock family, both stamping `unrecognized_keys` behaviour \"measured against the installed @objectstack/spec 17.2.0 (ESM build)\". Was three until objectui#8676: the third sat on the relationship-type list's docblock, which moved to `@object-ui/data-objectstack`'s `object-metadata-write-guard.ts` \u2014 and did NOT take the version stamp with it. The claim it stamped is now re-measured on every run by that module's derivation pin instead of recalled at a version, which is what this ledger wants of a citation rather than one more row.", - }, - { - file: "packages/app-shell/src/views/RecordDetailView.relatedListFilter-4664.test.tsx", - package: "@objectstack/spec", - version: "17.1.0", - sites: 1, - class: "historical", - why: "\"ships from @objectstack/spec 17.1.0\" \u2014 the release the key landed in. Permanently true.", - }, - { - file: "packages/app-shell/src/views/RecordDetailView.relatedListFilter-4664.test.tsx", - package: "@objectstack/spec", - version: "17.2.0", - sites: 1, - class: "stale", - why: "\"(this repo's pin resolves 17.2.0)\" on the same line as the historical 17.1.0 \u2014 the parenthesis is the stale half, the sentence around it is not.", - }, - { - file: "packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.retiredLifecycleKeys.test.tsx", - package: "@objectstack/spec", - version: "17.2.0", - sites: 1, - class: "stale", - why: "\"installed @objectstack/spec (17.2.0, measured 2026-08-27) still ACCEPTS\" \u2014 the date does not anchor it; the subject is still the install.", - }, - { - file: "packages/app-shell/src/views/metadata-admin/SchemaForm.unresolvedPredicate.test.tsx", - package: "@objectstack/spec", - version: "17.0.0-rc.5", - sites: 1, - class: "stale", - why: "Stamps the bare-spelling objectForm fields against \"installed spec 17.0.0-rc.5\".", - }, - { - file: "packages/app-shell/src/views/metadata-admin/anchors.validation-retired.test.ts", - package: "@objectstack/spec", - version: "17.0.0-rc.6", - sites: 1, - class: "stale", - why: "\"Re-measured here against the INSTALLED @objectstack/spec (17.0.0-rc.6)\" \u2014 the re-measurement has to happen again, not just its number.", - }, - { - file: "packages/app-shell/src/views/metadata-admin/form-spec.ts", - package: "@objectstack/spec", - version: "17.0.0", - sites: 1, - class: "stale", - why: "Stamps an `unrecognized_keys` reading \"measured against the installed @objectstack/spec 17.0.0\".", - }, - { - file: "packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.tsx", - package: "@objectstack/spec", - version: "17.0.0", - sites: 1, - class: "stale", - why: "\"@objectstack/spec is pinned at 17.0.0, so a stored map is reachable\" \u2014 the reachability argument rests on the pin, so it is the pin that has to be re-read.", - }, - { - file: "packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.tsx", - package: "@objectstack/spec", - version: "17.0.0-rc.5", - sites: 1, - class: "historical", - why: "Same line, the other version: a verbatim quotation of the justification this code REPLACED \u2014 \"`I18nLabel` was plain `string` through 17.0.0-rc.5\". Quoting a retired justification is history.", - }, - { - file: "packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.declaredDefault.test.tsx", - package: "@objectstack/spec", - version: "17.5.0", - sites: 1, - class: "historical", - why: "\"the nine the installed spec applied; `@objectstack/spec` 17.5.0 began applying `screen.mode`'s\" says WHEN the tenth declaration returned (objectui#11073). The installed-spec reading on the same line has no version. Never restamp (objectui#11438).", - }, - { - file: "packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.sectionName.test.tsx", - package: "@objectstack/spec", - version: "17.0.0-rc.5", - sites: 1, - class: "stale", - why: "\"the pinned @objectstack/spec@17.0.0-rc.5: `PageSchema.parse` does not ...\" \u2014 re-parse before restamping.", - }, - { - file: "packages/app-shell/src/views/metadata-admin/previews/ValidationPreview.tsx", - package: "@objectstack/spec", - version: "17.0.0-rc.6", - sites: 1, - class: "stale", - why: "\"Measured against the installed @objectstack/spec 17.0.0-rc.6\" stamps a NAME-level reading.", - }, - { - file: "packages/app-shell/src/views/studio-design/navSurface.test.ts", - package: "@objectstack/spec", - version: "17.0.0", - sites: 1, - class: "stale", - why: "\"Measured against the installed @objectstack/spec 17.0.0\".", - }, - { - file: "packages/components/src/__tests__/record-picker-empty-text-i18n.test.tsx", - package: "@objectstack/spec", - version: "17.0.0", - sites: 1, - class: "stale", - why: "\"the installed 17.0.0 GA still carries it \u2014 measured, not assumed\" \u2014 the carrying has to be re-measured at the resolved pin.", - }, - { - file: "packages/components/src/__tests__/text-input-inputs-spec-parity.test.ts", - package: "@objectstack/spec", - version: "17.1.0", - sites: 1, - class: "stale", - why: "\"Measured on the installed 17.1.0 pin.\"", - }, - { - file: "packages/components/src/renderers/basic/__tests__/record-picker-label-placeholder-i18n.test.tsx", - package: "@objectstack/spec", - version: "17.1.0", - sites: 1, - class: "stale", - why: "\"Measured on the installed pin (@objectstack/spec 17.1.0), all three members ...\"", - }, - { - file: "packages/core/src/utils/filter-tokens.ts", - package: "@objectstack/spec", - version: "17.0.0-rc.0", - sites: 1, - class: "stale", - why: "\"the installed spec (17.0.0-rc.0) has exported it for some time\".", - }, - { - file: "packages/data-objectstack/src/metadata-client.ts", - package: "@objectstack/spec", - version: "17.2.0", - sites: 1, - class: "stale", - why: "\"installed spec (17.2.0) rather than assumed\" \u2014 stamps a `GetMetaItemLayeredResponseSchema` reading.", - }, - { - file: "packages/i18n/src/utils/spec-formatters.ts", - package: "@objectstack/spec", - version: "17.0.0-rc.5", - sites: 1, - class: "historical", - why: "\"exported ... `LocaleConfigSchema` up to and including 17.0.0-rc.5\" \u2014 the release the four shapes survived to, permanently true. \u26a0\ufe0f The STALE claim in the same docblock is the next line down (\"The pinned\" / \"17.0.0-rc.6 removed all four\"), split across two lines and therefore outside this gate's reach; see the header's NOT PROMISED section.", - }, - { - file: "packages/layout/src/NavigationRenderer.tsx", - package: "@objectstack/spec", - version: "17.0.0", - sites: 1, - class: "stale", - why: "\"The installed @objectstack/spec@17.0.0\" backs a claim about a surface that never had the action.", - }, - { - file: "packages/layout/src/ResponsiveGrid.tsx", - package: "@objectstack/spec", - version: "17.2.0", - sites: 1, - class: "stale", - why: "A note that declares itself SELF-EXPIRING \u2014 \"Interim, and it self-expires: the pin is still @objectstack/spec 17.2.0\" \u2014 and the pin has since moved twice. Its substance (the symbol collision, and the ALLOW entry in check-spec-symbol-derivation.mjs that ratchet 3 will fail once it excuses nothing) is what decides whether the note goes or is restamped.", - }, - { - file: "packages/layout/src/__tests__/resolveHref.runAction.test.ts", - package: "@objectstack/spec", - version: "17.0.0", - sites: 1, - class: "stale", - why: "\"in the installed @objectstack/spec@17.0.0\".", - }, - { - file: "packages/plugin-calendar/src/ObjectCalendar.tsx", - package: "@objectstack/spec", - version: "17.3.0", - sites: 1, - class: "stale", - why: "\"refuses `allDayField` BY NAME ... on the pinned 17.3.0 and on objectstack main\" \u2014 re-probe `CalendarConfigSchema` at the resolved pin.", - }, - { - file: "packages/plugin-designer/src/DashboardEditor.tsx", - package: "@objectstack/spec", - version: "17.0.0", - sites: 1, - class: "stale", - why: "\"is pinned at 17.0.0, whose `I18nLabelSchema` is ...\" \u2014 the schema shape has to be re-read at the resolved pin.", - }, - { - file: "packages/plugin-designer/src/MetadataFieldsPage.fieldsMapKeying.test.tsx", - package: "@objectstack/spec", - version: "17.2.0", - sites: 1, - class: "stale", - why: "\"Measured against the installed @objectstack/spec 17.2.0 and asserted in ...\"", - }, - { - file: "packages/plugin-designer/src/MetadataFieldsPage.specKeySystem.test.tsx", - package: "@objectstack/spec", - version: "17.2.0", - sites: 1, - class: "stale", - why: "Stamps the installed schema's system-key reading.", - }, - { - file: "packages/plugin-designer/src/MetadataObjectsPage.lookupKeying.test.tsx", - package: "@objectstack/spec", - version: "17.2.0", - sites: 1, - class: "stale", - why: "Stamps the installed schema's lookup keying.", - }, - { - file: "packages/plugin-designer/src/MetadataObjectsPage.specKeyGroup.test.tsx", - package: "@objectstack/spec", - version: "17.2.0", - sites: 1, - class: "stale", - why: "Stamps the installed schema's key grouping.", - }, - { - file: "packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts", - package: "@objectstack/spec", - version: "17.0.0-rc.6", - sites: 1, - class: "stale", - why: "\"the pinned 17.0.0-rc.6 strips\" \u2014 the verdict holds either way per the docblock, but the EVIDENCE named is the pin's, and it is the evidence that moved.", - }, - { - file: "packages/plugin-detail/src/__tests__/recordHighlightsInputs.spec-parity.test.ts", - package: "@objectstack/spec", - version: "17.0.0-rc.6", - sites: 1, - class: "stale", - why: "\"the pinned 17.0.0-rc.6 still strips it silently\" \u2014 same shape as the sibling above.", - }, - { - file: "packages/plugin-detail/src/index.tsx", - package: "@objectstack/spec", - version: "17.2.0", - sites: 1, - class: "stale", - why: "One site, \"measured on the installed pin, 17.2.0\" with a named control, on the `record:highlights` `readonly` refusal. It was TWO until objectui#8603: the `record:details` never-teach paragraph carried the same stamp for `hideEmpty`, and the restoration of that key rewrote the paragraph, which now points at the instrument that re-derives the set (`packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts`) instead of stamping a measurement. The count moving DOWN is this ledger ratcheting, not a hole.", - }, - { - file: "packages/plugin-form/src/sectionFields.spec-parity.test.ts", - package: "@objectstack/spec", - version: "17.0.0", - sites: 1, - class: "historical", - why: "\"`publicPicker` arrives in @objectstack/spec 17.0.0 GA\" \u2014 the arrival release. \u26a0\ufe0f The stale claim in the SAME docblock (\"this repo is pinned to `^17.0.0-rc.6`\") is a RANGE and therefore outside this gate's predicate by construction; it is recorded in objectui#8924 rather than silently covered here.", - }, - { - file: "packages/plugin-grid/src/__tests__/gridNavigationMembers-8071.test.tsx", - package: "@objectstack/spec", - version: "17.5.0", - sites: 1, - class: "historical", - why: "\"the row that pinned this**: `@objectstack/spec` 17.5.0 retired `view.list.navigation.view`\" names the release that DID the retirement (objectui#9874); `pinned` is the row's verb, not a claim about the install. Never restamp (objectui#11438).", - }, - { - file: "packages/plugin-tree/src/ObjectTree.schemaTyped-8655.test.ts", - package: "@objectstack/spec", - version: "17.5.0", - sites: 1, - class: "historical", - why: "\"Re-measured on the installed 17.5.0:\" sits under the dated heading \"UPDATE at `@objectstack/spec` 17.5.0 (objectui#11073)\" and records that bump's re-measurement; the rows below it re-derive the readings from the installed spec at run time. Never restamp (objectui#11438).", - }, - { - file: "packages/react/src/hooks/__tests__/offline-nav-performance-spec-parity.test.ts", - package: "@objectstack/spec", - version: "17.0.0-rc.1", - sites: 2, - class: "historical", - why: "Two lines narrating objectui#3159: what the old comments claimed \"against an installed spec of 17.0.0-rc.1\", and that the spec \"retired that name in 17.0.0-rc.1\". Both date the past.", - }, - { - file: "packages/test-support/src/spec-array-element.ts", - package: "@objectstack/spec", - version: "17.2.0", - sites: 1, - class: "stale", - why: "\"installed pin (@objectstack/spec@17.2.0), the element at all four converted ...\" \u2014 a published test-support helper whose docblock teaches the reading.", - }, - { - file: "packages/test-support/src/spec-enum-options.ts", - package: "@objectstack/spec", - version: "17.2.0", - sites: 1, - class: "stale", - why: "\"installed pin (@objectstack/spec@17.2.0, zod@4.4.3) it returns ...\" \u2014 two pins on one line; both are re-measurable.", - }, - { - file: "packages/test-support/src/spec-zod-wrappers.ts", - package: "@objectstack/spec", - version: "17.2.0", - sites: 1, - class: "stale", - why: "\"Measured against the installed pin (zod@4.4.3, @objectstack/spec@17.2.0)\" \u2014 the wrapper's whole contract is this measurement.", - }, - { - file: "packages/types/src/__tests__/chart-series-chart-type-alias-refusal-7694.test.ts", - package: "@objectstack/spec", - version: "17.2.0", - sites: 1, - class: "stale", - why: "\"Measured on the installed 17.2.0 that cut is 91 bytes\" \u2014 a byte count measured at a pin that moved twice.", - }, - { - file: "packages/types/src/__tests__/spec-derived-unions.test.ts", - package: "@objectstack/spec", - version: "17.2.0", - sites: 1, - class: "stale", - why: "\"the pin is still 17.2.0, which PRE-dates the ...\" \u2014 the whole sentence is an argument from the pin's age.", - }, - { - file: "packages/types/src/mobile.ts", - package: "@objectstack/spec", - version: "17.2.0", - sites: 1, - class: "stale", - why: "The sibling of the `ResponsiveGrid.tsx` note above, same self-expiring wording, same expired-twice state.", - }, - { - file: "scripts/__tests__/doc-version-claims.test.ts", - package: "@objectstack/spec", - version: "17.0.0", - sites: 2, - class: "historical", - why: "Two lines inside another gate's header, neither a claim about today: \":222\" narrates the objectui#5081 measurement that falsified a rule, and \":1046\" is a verbatim quotation of a tombstone's own refusal message (\"removed in @objectstack/spec 17.0.0\") kept verbatim on purpose.", - }, - { - file: "scripts/check-spec-range-floors.mjs", - package: "@objectstack/spec", - version: "17.2.0", - sites: 1, - class: "stale", - why: "\"both of those answer from the INSTALLED tree, which is 17.2.0 here\" \u2014 the only claim in this population with NO schema fact attached, so re-measuring it is the two-face read this gate already performs.", - }, - { - file: "scripts/vite-objectstack-client-dist.ts", - package: "@objectstack/client", - version: "17.2.0", - sites: 1, - class: "historical", - why: "Anchored to a named commit: \"Measured on `a100f77d3` (vite 8.2.1 + rolldown 1.2.3) with the override pointed at a copy of the installed @objectstack/client@17.2.0\". A measurement that names the tree it ran on is a record, not a claim about today.", - }, - { - file: "scripts/vite-objectstack-spec-dist.ts", - package: "@objectstack/spec", - version: "17.0.0-rc.6", - sites: 1, - class: "stale", - why: "\"measured on the installed 17.0.0-rc.6: 18 exports entries, 17 of them reached by this repository's own imports, across 29 packages\" \u2014 three counts, all re-measurable against the resolved artifact.", - }, - { - file: "packages/plugin-grid/src/index.tsx", - package: "@objectstack/spec", - version: "17.6.0", - sites: 1, - class: "historical", - why: "\"the row declares it since 17.6.0\" on the `keyboardNavigation` input's comment \u2014 the release `object-grid` gained the member; the line's `pinned` is the verb in \"is pinned there\". Permanently true (objectui#11717).", - }, - { - file: "scripts/check-eager-closure-budget.mjs", - package: "@objectstack/spec", - version: "18.0.0-next.0", - sites: 1, - class: "historical", - why: "Anchored to a named commit: \"`5488ae7` is objectui#12030's branch commit: `main` at `cea429b` with `@objectstack/*` 18.0.0-next.0 resolved\" describes the tree the eager-closure baseline was measured on. A measurement that names its tree is a record, not a claim about today (objectui#12370).", - }, - { - file: "scripts/check-lockfile-integrity.mjs", - package: "@objectstack/spec", - version: "18.0.0-next.3", - sites: 2, - class: "historical", - why: "Two lines of the gate's own `--self-test`, neither a claim about today: `@objectstack/spec@18.0.0-next.3` is a synthetic REGISTRY lockfile key the fixture sets beside the pinned build (`file:` directory) to show a registry copy is a duplication and a move onto the build is clean. The line's `pinned` is the build's name, and the fixture means a registry version that is NOT the pin, so it never restamps (objectui#12971).", - }, -]; - -// ── The run ────────────────────────────────────────────────────────────────── - -/** - * A NUL byte means the blob is binary, and `git grep -I` skips the same files. - * Built from a char code rather than written as a literal: `check-control-bytes.mjs` - * fails on a raw control byte in a source file, and a raw NUL would make this - * file unsearchable to every tool that reads it afterwards. - */ -const NUL = String.fromCharCode(0); - -export function workspaceDirs(root) { - const out = []; - for (const group of ['packages', 'apps', 'examples']) { - const dir = join(root, group); - if (!existsSync(dir)) continue; - for (const entry of readdirSync(dir)) { - if (existsSync(join(dir, entry, 'package.json'))) out.push(`${group}/${entry}`); - } - } - return out; -} - -export function collectClaims(root, files, bareMajors = DEFAULT_BARE_MAJORS) { - const claims = []; - for (const file of files) { - if (!isScanned(file)) continue; - let text; - try { - text = readFileSync(join(root, file), 'utf8'); - } catch { - continue; - } - if (text.includes(NUL)) continue; - const lines = text.split('\n'); - for (let i = 0; i < lines.length; i += 1) { - const verdict = recogniseLine(lines[i], bareMajors); - if (verdict.verdict !== 'claim') continue; - for (const claim of verdict.claims) { - claims.push({ - file, - line: i + 1, - package: claim.package, - version: claim.version, - marker: verdict.marker, - text: lines[i].trim(), - }); - } - } - } - return claims; -} - -const keyOf = (file, pkg, version) => [file, pkg, version].join('::'); - -/** - * The verdict. `unledgered` is what fails; `atPin` is the LIVE half — the - * claims that are true today and that go red the day the pin moves. That half - * is the reason this exists instead of a corrected sentence. - */ -export function judge({ pins, conflicts, claims, ledger }) { - const findings = []; - const groups = new Map(); - for (const claim of claims) { - const key = keyOf(claim.file, claim.package, claim.version); - if (!groups.has(key)) groups.set(key, []); - groups.get(key).push(claim); - } - - const atPin = []; - const drift = new Map(); - for (const [key, members] of groups) { - const { file, package: pkg, version } = members[0]; - if (conflicts.some((c) => c.package === pkg)) { - findings.push(`${file}: claims ${pkg} ${version}, but the two faces of the pin disagree — nothing is judgeable until they do not`); - continue; - } - const pin = pins.get(pkg); - if (pin === undefined) { - findings.push(`${file}: claims ${pkg} ${version}, but no pin for ${pkg} was resolvable from either face`); - continue; - } - if (pin === version) atPin.push({ key, members }); - else drift.set(key, members); - } - - const seen = new Set(); - for (const entry of ledger) { - const key = keyOf(entry.file, entry.package, entry.version); - if (seen.has(key)) findings.push(`LEDGER: duplicate entry for ${entry.file} ${entry.package} ${entry.version}`); - seen.add(key); - if (!LEDGER_CLASSES.includes(entry.class)) { - findings.push(`LEDGER: ${entry.file} ${entry.version} has class ${JSON.stringify(entry.class)}; one of ${LEDGER_CLASSES.join(', ')} is required`); - } - if (typeof entry.why !== 'string' || entry.why.trim().length < 20) { - findings.push(`LEDGER: ${entry.file} ${entry.version} has no usable reason. An entry without one is an unexplained hole.`); - } - const members = drift.get(key); - if (!members) { - // Two different situations wear the same shape, so name which one it is: - // the site was repaired (delete the entry), or the RECOGNISER stopped - // seeing it — which is what a major bump does to a bare claim that names - // no package, and is the one case where deleting the entry would be - // exactly wrong. - const stillNamed = claims.some((c) => c.file === entry.file && c.version === entry.version); - findings.push( - `LEDGER is stale: ${entry.file} no longer carries a drifting ${entry.package} ${entry.version} claim` + - (stillNamed - ? ' under this package attribution, though the version literal is still recognised there. Re-check the attribution before deleting.' - : '. Delete the entry in the same change that repaired the site.'), - ); - continue; - } - if (members.length !== entry.sites) { - findings.push( - `LEDGER count is wrong: ${entry.file} ${entry.package} ${entry.version} — entry says ${entry.sites} site(s), ` + - `the tree has ${members.length} (line${members.length === 1 ? '' : 's'} ${members.map((m) => m.line).join(', ')}).`, - ); - } - drift.delete(key); - } - - for (const members of drift.values()) { - const m = members[0]; - findings.push( - `${m.file}:${members.map((x) => x.line).join(',')} claims ${m.package} ${m.version}, the artifact resolves ` + - `${pins.get(m.package)}. Re-measure the fact this sentence states against the resolved artifact and restamp it, ` + - 'or add a LEDGER entry naming its class and why.', - ); - } - - const byClass = {}; - for (const e of ledger) byClass[e.class] = (byClass[e.class] ?? 0) + (e.sites ?? 0); - return { findings, atPin, ledgered: ledger.length, byClass, unledgered: [...drift.values()] }; -} - -/** - * The majors a bare version may be attributed to the anchor on: the ones the - * pin itself uses, plus every major the ledger already names. The second half - * is what keeps a MAJOR bump from silently emptying this gate — the day the - * line goes to 18, the 17.x claims the ledger is tracking stay visible instead - * of falling out of the predicate, which is the exact failure objectui#8897's - * version-literal probe had one minor at a time. - */ -export function bareMajorsFor(pins, ledger) { - const majors = new Set(); - for (const v of pins.values()) majors.add(v.split('.')[0]); - for (const e of ledger) majors.add(String(e.version).split('.')[0]); - if (majors.size === 0) for (const m of DEFAULT_BARE_MAJORS) majors.add(m); - return [...majors]; -} - -export function run(root = ROOT, ledger = LEDGER) { - const files = execFileSync('git', ['-C', root, 'ls-files'], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }) - .split('\n') - .filter(Boolean); - const lockPins = pinsFromLockfile(readFileSync(lockfilePath(root), 'utf8'), root); - const treePins = pinsFromResolvedTree(root, workspaceDirs(root)); - const { pins, conflicts, unlinked } = reconcilePins(lockPins, treePins); - const bareMajors = bareMajorsFor(pins, ledger); - const claims = collectClaims(root, files, bareMajors); - return { pins, conflicts, unlinked, bareMajors, claims, ...judge({ pins, conflicts, claims, ledger }) }; -} - -// ── CLI ────────────────────────────────────────────────────────────────────── - -if (isEntrypoint(import.meta.url)) { - const explain = process.argv.includes('--explain'); - const asJson = process.argv.includes('--json'); - const result = run(); - const failed = result.findings.length > 0 || result.conflicts.length > 0; - - if (asJson) { - console.log(JSON.stringify(result, (_k, v) => (v instanceof Map ? Object.fromEntries(v) : v), 2)); - process.exit(failed ? 1 : 0); - } - - console.log('Installed `@objectstack/*` pin claims (objectui#8924)'); - console.log(''); - for (const [pkg, version] of result.pins) { - console.log(` pin ${pkg} = ${version} (pnpm-lock.yaml AND the resolved tree agree)`); - } - for (const u of result.unlinked ?? []) { - console.log(` (transitive) ${u.package} = ${u.lock.join(', ')} in the lockfile, linked into no workspace package — no installed face, so no claim about it is judged`); - } - for (const c of result.conflicts) { - console.log(` CONFLICT ${c.package}: lockfile says [${c.lock.join(', ') || 'nothing'}], resolved tree says [${c.tree.join(', ') || 'nothing'}]`); - } - console.log(''); - const atPinCount = result.atPin.reduce((n, g) => n + g.members.length, 0); - console.log(` ${result.claims.length} recognised claim site(s) — ${atPinCount} at the pin, ${result.ledgered} ledger entr(ies)`); - console.log( - ` ledgered sites by class: ` + - Object.entries(result.byClass) - .sort() - .map(([k, v]) => `${k} ${v}`) - .join(', '), - ); - - if (explain) { - console.log(''); - console.log(' AT THE PIN — true today, red the day it moves:'); - for (const g of result.atPin) { - for (const m of g.members) console.log(` ${m.file}:${m.line} ${m.package} ${m.version} [${m.marker}]`); - } - console.log(''); - console.log(' OFF THE PIN — ledgered or not:'); - for (const c of result.claims) { - if (result.pins.get(c.package) === c.version) continue; - console.log(` ${c.file}:${c.line} ${c.package} ${c.version} [${c.marker}] ${c.text.slice(0, 110)}`); - } - } - - if (result.findings.length > 0) { - console.log(''); - for (const f of result.findings) console.log(` FAIL ${f}`); - console.log(''); - console.log(`FAIL: ${result.findings.length} finding(s). Run with --explain for the whole population.`); - process.exit(1); - } - if (failed) process.exit(1); - console.log(''); - console.log('OK'); -} diff --git a/ui/scripts/check-lockfile-dedupe.mjs b/ui/scripts/check-lockfile-dedupe.mjs index 1ff08cc6a55..039117dad1a 100644 --- a/ui/scripts/check-lockfile-dedupe.mjs +++ b/ui/scripts/check-lockfile-dedupe.mjs @@ -86,17 +86,6 @@ * carries pnpm's own remedy sentinel; otherwise this exits 2, "could not take a * reading", which is never a pass. * - * ## It needs the pinned `@objectstack/*` build (objectui#12957) - * - * Every `@objectstack/*` package resolves to a `file:` directory of the build - * `.objectstack-sha` pins (`scripts/objectstack-pin.mjs`), and `pnpm dedupe - * --check` re-reads those directories. Without the build pnpm stops before it - * judges anything (`ERR_PNPM_LINKED_PKG_DIR_NOT_FOUND`), so the checker asks - * first and reports a reading it could not take, NAMING the cause rather than - * leaving it to pnpm's message. `lockfile-dedupe.yml` restores the build before - * this runs. Measured on objectui#12957 with the build in place: `VERDICT - * deduped`, the lockfile's sha256 unchanged. - * * ## ⚠️ This gate reads the REGISTRY, unlike its neighbour * * `check-lockfile-integrity.mjs` is one `node` call over two text files. @@ -119,14 +108,9 @@ import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { isEntrypoint } from './invoked-as.mjs'; -import { CACHE_DIR, OVERRIDE_PREFIX, STAMP_FILE } from './objectstack-pin.mjs'; -import { rootManifest, workspaceRoot } from './workspace-root.mjs'; - -const REPO_ROOT = path.resolve(fileURLToPath(import.meta.url), '..', '..'); +import { workspaceRoot } from './workspace-root.mjs'; /** * pnpm's own remedy line, printed only when `--check` found something to @@ -322,32 +306,6 @@ export function writeStepSummary(text, env = process.env) { } } -/** - * Whether the pinned `@objectstack/*` build is absent, read off its stamp. - * - * @param {string} [root] - * @returns {boolean} - */ -export function pinnedBuildMissing(root = REPO_ROOT) { - return !fs.existsSync(path.join(root, CACHE_DIR, STAMP_FILE)); -} - -/** - * Whether the workspace resolves any `@objectstack/*` package through the - * pinned build -- the manifest pnpm reads overrides from declares a - * `file:.objectstack-pin/...` override. Standalone it does, and the stamp - * check below applies. Under the objectstack monorepo (objectstack#25723) the - * `@objectstack/*` packages are workspace members and no manifest declares the - * pin, so there is no build whose absence could make `pnpm dedupe --check` - * resolve from the registry; the check is then not a precondition. - * - * @param {{ pnpm?: { overrides?: Record } }} [manifest] - * @returns {boolean} - */ -export function pinDeclared(manifest = rootManifest()) { - return Object.values(manifest?.pnpm?.overrides ?? {}).some((v) => typeof v === 'string' && v.startsWith(OVERRIDE_PREFIX)); -} - /** * @param {string[]} argv * @returns {number} process exit code @@ -358,17 +316,6 @@ export function main(argv = process.argv.slice(2)) { const annotate = (message) => console.error(`::${reportOnly ? 'warning' : 'error'} title=Lockfile dedupe::${message}`); - if (pinDeclared() && pinnedBuildMissing()) { - annotate( - `could not take a reading — the pinned @objectstack/* build is not in ${CACHE_DIR}, so ` + - '`pnpm dedupe --check` cannot resolve those packages (objectui#12957). Run `pnpm objectstack:build` ' + - 'first. ⛔ This is NOT a deduped verdict.', - ); - for (const line of renderVerdict({ outcome: 'cannot-run', reportOnly })) console.error(line); - writeStepSummary(renderStepSummary({ outcome: 'cannot-run' })); - return reportOnly ? EXIT_CLEAN : EXIT_CANNOT_RUN; - } - // At the workspace root: this repository's own, or the monorepo's one level up. const run = spawnSync('pnpm', ['dedupe', '--check'], { cwd: workspaceRoot(), @@ -469,8 +416,6 @@ export function selfTest() { t('reports each name once, sorted', JSON.stringify(names) === JSON.stringify([...new Set(names)].sort())); t('finds nothing in clean output', namesFromOutput(GREEN).length === 0); - // ── the pinned @objectstack/* build is a precondition (objectui#12957) ───── - t('a root without the pinned build is reported missing', pinnedBuildMissing(path.join(REPO_ROOT, 'no-such-root')) === true); t( 'CONTROL — a red verdict still reds when the parse names nothing', renderVerdict({ outcome: 'findings', names: [], output: RED }).some((l) => l.startsWith('VERDICT not deduped')), diff --git a/ui/scripts/check-phantom-dependencies.mjs b/ui/scripts/check-phantom-dependencies.mjs index 3298563188b..6a24ce309e0 100644 --- a/ui/scripts/check-phantom-dependencies.mjs +++ b/ui/scripts/check-phantom-dependencies.mjs @@ -201,6 +201,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { isEntrypoint } from './invoked-as.mjs'; +import { changesetConfigPath } from './workspace-root.mjs'; const scriptDir = dirname(fileURLToPath(import.meta.url)); @@ -426,10 +427,9 @@ export function moduleSpecifiers(text, fileName = 'file.tsx') { * gate whose scope collapsed to nothing passes everything. */ export function readReleaseGroup(root) { - const configPath = join(root, '.changeset/config.json'); let config; try { - config = JSON.parse(readFileSync(configPath, 'utf8')); + config = JSON.parse(readFileSync(changesetConfigPath(root), 'utf8')); } catch (error) { throw new Error(`cannot read .changeset/config.json: ${error.message}`); } diff --git a/ui/scripts/check-spec-range-floors.mjs b/ui/scripts/check-spec-range-floors.mjs deleted file mode 100644 index ec5af1deb31..00000000000 --- a/ui/scripts/check-spec-range-floors.mjs +++ /dev/null @@ -1,1102 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * A package's declared `@objectstack/spec` FLOOR must carry every symbol that - * package's own published artifact references. - * - * Run: node scripts/check-spec-range-floors.mjs (also `pnpm check:spec-floors`) - * Exit: 0 = every consumer-facing floor admits only specs that carry the symbols, - * 1 = at least one floor admits a spec that lacks one, OR the gate could - * not read an artifact, a published export surface, or a floor - * - * Since objectui#12958 the floors it judges at publish (and nightly) are computed - * from `.objectstack-sha` by `scripts/objectstack-floor.mjs`, which runs first. It - * stays: a release that contains the pin can still have removed a symbol the - * artifact uses, and only this gate reads the floor release's exports. - * - * ## What a GREEN run does NOT judge: key REFUSAL (objectui#9036) - * - * This gate judges SYMBOL PRESENCE, and only that. A floor that admits a spec - * which DECLARES the symbol but REFUSES the keys this repository's packages - * write into it is GREEN either way: nothing here ever parses a payload against - * the floor's own schema. So a green run of this gate is not evidence that the - * floors are behaviourally sound, and must not be read as such. - * - * That boundary is deliberate and measured, not an oversight. objectui#9036 - * priced the behavioural leg end to end — the census of closed schemas, the - * false-positive rate of the cheap criterion, the fixture cost of the expensive - * one — and the measurement lives there. Per commandment #9 no figure from it is - * copied into this header: a restated number is unfalsifiable from where it sits - * and goes wrong the moment the tree moves, while a pointer stays true. - * - * The variance is live in this repository, not hypothetical: `GanttConfigSchema` - * is CLOSED (unknown keys rejected) at the floor `@object-ui/types` declares and - * OPEN at the floor `@object-ui/plugin-gantt` declares — one schema, two floors, - * opposite answers. That was true when this was written; what re-derives it is - * the two manifests' declared ranges read against the spec, never this sentence. - * - * One fact outlives that example, and anyone reading this section as a starting - * point needs it before anything else: a key ABSENT from a schema does NOT imply - * that the schema refuses it. Whether the schema is CLOSED decides that, and it - * varies per schema AND per version. Deriving refusal from a key list is the - * step that makes a naive version of this criterion confidently wrong. - * - * ## The defect class (objectui#5793) - * - * `@objectstack/spec@17.1.0` added exports that 17.0.0 does not have. - * objectui#5494 then landed `packages/plugin-detail/src/renderers/ - * record-reference-rail.tsx`, whose published `dist/renderers/ - * record-reference-rail.d.ts` reads - * - * import { ReferenceRailEntry } from '@objectstack/spec/ui'; - * export type { ReferenceRailEntry } from '@objectstack/spec/ui'; - * - * while `packages/plugin-detail/package.json` declared - * `"@objectstack/spec": "^17.0.0"` — the same range every package in the - * workspace declared, uniformly. - * - * A declared range is a PUBLIC CLAIM about what the package works against. Any - * consumer resolution that lands 17.0.0 — a sibling pinning it exactly, an - * `overrides` entry, an offline mirror a minor behind — satisfies `^17.0.0` and - * gets a dangling type re-export. Normal installs resolve the newest 17.x and - * never see it, which is precisely why nothing found this for a release and a - * half: it is a floor-honesty defect, not a live breakage, and the lockfile - * hides it from every green check in this repository. - * - * The direction was ruled on objectui#5793 (triage comment 5385335220): floors - * track reality. "The uniform `^17.0.0` is deliberate" would be a maintainer - * ruling and is NOT assumed here — the platform default is contract-tightening - * over consumer tolerance. - * - * ## Two ways to build this gate that return a confident GREEN - * - * Both were live hazards on this card, and both are avoided by construction - * rather than by care: - * - * 1. **Resolution answers the wrong question.** The ROOT `package.json` - * declares `@objectstack/spec`, so pnpm hoists it to the workspace root and - * Node's upward walk finds it from ANY package directory regardless of that - * package's own manifest. `require.resolve('@objectstack/spec/ui', { paths: - * [pkgDir] })` therefore succeeds everywhere, and a green `tsc` proves - * nothing whatsoever about a floor — it type-checks against the INSTALLED - * 17.2.0, not against the declared minimum. This is the same trap - * `check-phantom-dependencies.mjs` records for `react`. So this gate - * resolves NOTHING through the installed tree: it fetches the declared - * minimum version from the registry and reads that tarball's own manifest. - * - * 2. **The dual-package halves are two different builds.** `@objectstack/spec` - * ships `require` → `dist//index.js` and `import` → - * `dist//index.mjs`, with separate type entries (`index.d.ts` / - * `index.d.mts`). Reaching the package through `createRequire` reads a build - * no bundler ever puts in an application. This gate walks the fetched - * manifest's own `exports` map under the **`import`** condition and prints - * the entry it landed on, so the artifact it judged is named in the log - * rather than assumed. - * - * The two halves were measured on 17.0.0 `./ui` while this gate was written: - * `dist/ui/index.d.mts`, `dist/ui/index.d.ts` and the package's own published - * `api-surface/ui.json` each list 425 exported names with an empty symmetric - * difference. They agree today — which is a MEASUREMENT, not a licence to - * read whichever is convenient, because a divergence would be invisible to a - * gate that only ever read one. `--cross-check` re-runs that comparison over - * every entry the run consulted. - * - * ## Why the criterion is the ARTIFACT, not `src/` - * - * Reading `src/` is cheaper and needs no build, and it is wrong in the - * direction that costs most here: a `import type` used only inside a function - * body is ERASED and never reaches `dist/`, so a src-based gate would demand a - * floor bump that nothing published justifies. The dispatch order for this card - * is explicit that every range changed must be backed by a symbol the gate - * names, and a gate that over-names symbols corrupts exactly that. `dist/` is - * also the only thing that cannot be wrong about what a consumer installs — - * the same argument `check-published-dist-tooling.mjs` makes at length. - * - * The cost of that choice is a build, so this gate is wired the way objectui's - * other artifact-level gate is: the RELEASE path plus a nightly alarm, never a - * per-PR job. The 2026-08-16 ruling on objectui#4846 rejected a per-PR - * full-repo build, and nothing about this card reopens it. Its verdict logic is - * exercised per-PR by `scripts/__tests__/check-spec-range-floors.test.ts` - * against synthetic inputs. - * - * ## What is scanned, and the hole that exclusion could leave - * - * Symbol names are read out of the ESM artifacts — type declarations - * (`.d.ts` / `.d.mts` / `.d.cts`) and ES modules — with TypeScript's parser. - * The UMD/CJS bundle is NOT parsed for names: it reaches its externals as - * minified property accesses on a factory parameter - * (`e.ObjectUISpecData.FeedFilterMode`), so no symbol name survives in it. - * - * An exclusion is where a gate goes quietly blind, so it is guarded rather than - * trusted: every spec SUBPATH mentioned anywhere in the artifact set — the UMD - * bundle included, found by plain text scan — must also be covered by an - * artifact the gate could name symbols in. A subpath that reaches consumers - * only through a bundle this gate cannot read is a finding (`opaque-subpath`), - * not a silent pass. - * - * ## Consumer-facing fields only - * - * `dependencies` / `peerDependencies` / `optionalDependencies` — the fields - * `check-phantom-dependencies.mjs` calls RUNTIME_FIELDS, imported from there - * rather than retyped. A `devDependencies` range is not installed for anybody - * and floors nothing. A package whose artifact references spec while declaring - * it ONLY as a dev dependency is a phantom dependency, which is that gate's - * finding and deliberately not restated here. - * - * When more than one consumer-facing field declares the package, a consumer - * install must satisfy all of them, so the effective floor is the HIGHEST of - * their minimum versions. - */ - -import { execFileSync } from 'node:child_process'; -import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs'; -import { join, relative } from 'node:path'; - -import ts from 'typescript'; - -import { RUNTIME_FIELDS } from './check-phantom-dependencies.mjs'; -import { discoverPublishedPackages } from './check-published-dist-tooling.mjs'; -import { isEntrypoint } from './invoked-as.mjs'; - -/** The dependency whose floor this gate judges. */ -export const SPEC_PACKAGE = '@objectstack/spec'; - -/** - * Artifact files whose exported/imported symbol names TypeScript can read. - * `.js` is included because every published package here is `"type": "module"`; - * `artifactKindOf` checks that per package rather than assuming it. - */ -export const NAMEABLE_ARTIFACT = /\.(?:d\.[cm]ts|d\.ts|mjs|js|jsx|tsx?)$/; - -/** Build output roots, matching `check-published-dist-tooling.mjs`. */ -export const ARTIFACT_DIRS = ['dist', 'build']; - -/** - * A scan that collapses passes everything. This repository has 27 published - * packages declaring the spec at the time of writing, 19 of them - * consumer-facing. The number below is a COLLAPSE ALARM an order of magnitude - * under that — deliberately not a census, which would need editing every time a - * package gains or drops the edge (the drift argument `.github/workflows/ - * lint.yml` makes about hand-copied counts). - */ -export const MIN_FLOORED_PACKAGES = 8; - -// ── range arithmetic, deliberately narrow ──────────────────────────────────── - -/** - * The versions this gate can ORDER: `X.Y.Z`, no prerelease, no build metadata. - * Prereleases are never ordered — not against each other, not against a - * release — because semver's prerelease precedence is a question this gate has - * never had to answer, and a wrong answer here would read as a verdict. - */ -export const RELEASE_VERSION = /^(\d+)\.(\d+)\.(\d+)$/; - -/** - * The versions this gate can READ AS A FLOOR: a plain release, or a prerelease - * of one (`18.0.0-next.1`), still with no build metadata (objectui#12334). - * - * A prerelease floor is judged exactly like a release floor: that exact version - * is fetched from the registry and its own export surface is read. It became - * necessary when the workspace moved onto `@objectstack/*` 18, which exists only - * as prereleases on the `next` dist-tag (objectui#12030): every range read - * `^18.0.0-next.N`, every one threw here, no package counted as floored, and - * the run stopped at the collapse alarm — so `pnpm changeset:publish`, which - * runs this gate first, could publish nothing. - * - * Reading one is not ordering one. Where a floor would have to be COMPARED — - * two consumer-facing fields naming different floors, a `||` union, the search - * for a replacement floor — a prerelease still throws or names nothing, as - * before. Two fields naming the SAME floor need no comparison. - */ -const FLOOR_SPELLING = String.raw`\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?`; -export const FLOOR_VERSION = new RegExp(`^${FLOOR_SPELLING}$`); - -/** One lower-bound comparator: an optional operator, then a FLOOR_VERSION. */ -const LOWER_BOUND = new RegExp(String.raw`^(\^|~|>=|=)?(${FLOOR_SPELLING})$`); - -/** - * pnpm's workspace protocol (objectstack ruling D1: inside the objectstack - * monorepo objectui declares `@objectstack/spec` as `workspace:^`). The tail is - * `*`, `^`, `~`, empty, or a range: the first four say "the workspace package - * at whatever version it has", and publish as `^`, so their floor - * IS the workspace package's own version; a range tail is read as that range. - */ -export const WORKSPACE_PROTOCOL = /^workspace:(.*)$/; -const WORKSPACE_OWN_VERSION = new Set(['', '*', '^', '~']); - -/** `[major, minor, patch]`, or `null` when `version` is not a plain release. */ -export function parseVersion(version) { - const match = RELEASE_VERSION.exec(String(version).trim()); - return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null; -} - -/** -1 / 0 / 1, ordering two plain releases. */ -export function compareVersions(a, b) { - const left = parseVersion(a); - const right = parseVersion(b); - if (!left || !right) throw new Error(`cannot order "${a}" against "${b}": both must be plain X.Y.Z releases`); - for (let i = 0; i < 3; i += 1) { - if (left[i] !== right[i]) return left[i] < right[i] ? -1 : 1; - } - return 0; -} - -/** The major of a plain release. */ -export function majorOf(version) { - const parsed = parseVersion(version); - if (!parsed) throw new Error(`"${version}" is not a plain X.Y.Z release`); - return parsed[0]; -} - -/** - * The lowest version a consumer install may land for `range`. - * - * A hand-written, deliberately narrow reader rather than the `semver` package, - * which is not a dependency of this repository's root — adding one to answer a - * question about four range spellings is the wrong trade, and a phantom import - * of a hoisted transitive copy is what `check-phantom-dependencies.mjs` exists - * to stop. - * - * The narrowness IS the safety property: anything this function does not - * recognise THROWS, and the caller turns that into a red `unreadable-range` - * finding. The failure direction that matters is a range silently read as - * "floor 0.0.0" and therefore satisfied by everything — the vacuous green this - * whole gate exists to prevent — so an unrecognised spelling must never fall - * through to a pass. - * - * Supported: `X.Y.Z`, `=X.Y.Z`, `^X.Y.Z`, `~X.Y.Z`, `>=X.Y.Z`, and a - * space-separated comparator set carrying exactly one of those as its lower - * bound (`>=17.1.0 <18.0.0`). Each lower bound may name a prerelease - * (`^18.0.0-next.1`), which is then the floor itself: the lowest version such a - * range admits is that exact prerelease (FLOOR_VERSION above). A `||` union - * takes the LOWEST branch: a consumer may satisfy the range through any - * alternative, so the lowest is what the range admits — and since finding the - * lowest is an ordering, a union with a prerelease branch still throws. - * - * A `workspace:` range (WORKSPACE_PROTOCOL above) reads as the workspace - * package's own version, which the caller passes as `workspaceVersion`; with - * none passed it THROWS like any other unreadable spelling, so a tree where the - * workspace package cannot be found never reads as "floor 0.0.0". - * - * @param {string} range - * @param {{ workspaceVersion?: string | null }} [options] - */ -export function minVersionOf(range, { workspaceVersion = null } = {}) { - const text = String(range).trim(); - if (text.length === 0) throw new Error('an empty range names no floor.'); - - const workspace = WORKSPACE_PROTOCOL.exec(text); - if (workspace) { - const tail = workspace[1].trim(); - if (!WORKSPACE_OWN_VERSION.has(tail)) return minVersionOf(tail, { workspaceVersion }); - if (typeof workspaceVersion !== 'string' || !FLOOR_VERSION.test(workspaceVersion)) { - throw new Error( - `range "${text}" names the workspace package's own version as its floor, and this run was not told that ` + - `version${workspaceVersion ? ` ("${workspaceVersion}" is not one)` : ''}. Reported as a failure rather than guessed: ` + - 'a workspace range read without its workspace is a range with no floor at all.', - ); - } - return workspaceVersion; - } - - if (text.includes('||')) { - const branches = text.split('||').map((branch) => minVersionOf(branch, { workspaceVersion })); - return branches.reduce((lowest, candidate) => (compareVersions(candidate, lowest) < 0 ? candidate : lowest)); - } - - let floor = null; - for (const comparator of text.split(/\s+/).filter(Boolean)) { - const lower = LOWER_BOUND.exec(comparator); - if (lower) { - if (floor !== null) { - throw new Error(`range "${text}" carries more than one lower bound; this gate reads exactly one.`); - } - floor = lower[2]; - continue; - } - // Upper bounds are irrelevant to a floor and are skipped on purpose. - if (/^<=?\d+(\.\d+){0,2}$/.test(comparator)) continue; - throw new Error( - `range "${text}" contains "${comparator}", which this gate does not read. Reported as a failure ` + - 'rather than guessed: a range whose floor is guessed wrongly is approved wrongly.', - ); - } - if (floor === null) throw new Error(`range "${text}" names no lower bound.`); - return floor; -} - -// ── the declared floor ─────────────────────────────────────────────────────── - -/** - * The lowest `@objectstack/spec` a consumer install of this package may land. - * - * @param {object} manifest - * @param {readonly string[]} [fields] - * @param {{ workspaceVersion?: string | null }} [options] the workspace spec's own version, for a `workspace:` range - * @returns {{ version: string, ranges: {field: string, range: string}[] } | null} - * `null` when no consumer-facing field declares the spec at all. - */ -export function effectiveFloor(manifest, fields = RUNTIME_FIELDS, { workspaceVersion = null } = {}) { - const ranges = []; - for (const field of fields) { - const range = manifest?.[field]?.[SPEC_PACKAGE]; - if (typeof range === 'string' && range.length > 0) ranges.push({ field, range }); - } - if (ranges.length === 0) return null; - - let floor = null; - for (const { field, range } of ranges) { - let min; - try { - min = minVersionOf(range, { workspaceVersion }); - } catch (error) { - throw new Error( - `${field}["${SPEC_PACKAGE}"] = "${range}": ${error.message} A range whose floor cannot be named ` + - 'cannot be judged, and "unjudgeable, therefore fine" is the verdict this gate must never return.', - ); - } - // Highest minimum wins: a consumer must satisfy every declared field at once. - // Two fields naming the SAME floor need no ordering, which is what lets a - // prerelease floor declared twice through; two different prerelease floors - // would need one, and stay unreadable (FLOOR_VERSION above). - if (floor === null || min === floor) { - floor = min; - continue; - } - try { - if (compareVersions(min, floor) > 0) floor = min; - } catch (error) { - throw new Error( - `${ranges.map((r) => `${r.field}["${SPEC_PACKAGE}"] = "${r.range}"`).join(', ')}: ${error.message}. ` + - 'Two different floors have to be ordered to name the effective one, and this gate does not order prereleases.', - ); - } - } - return { version: floor, ranges }; -} - -/** - * The version of `@objectstack/spec` the workspace resolves, read from the - * installed package's manifest -- the nearest `node_modules/@objectstack/spec` - * walking up from `root`. Standalone that is the pinned build linked into - * `node_modules`; inside the objectstack monorepo it is the workspace link to - * `packages/spec`. `null` when nothing is installed, which a `workspace:` range - * then turns into an unreadable-range finding rather than a floor. - * - * @param {string} root - * @returns {string | null} - */ -export function workspaceSpecVersion(root) { - let dir = root; - for (;;) { - const manifest = join(dir, 'node_modules', SPEC_PACKAGE, 'package.json'); - if (existsSync(manifest)) { - try { - const version = JSON.parse(readFileSync(manifest, 'utf8')).version; - return typeof version === 'string' ? version : null; - } catch { - return null; - } - } - const parent = join(dir, '..'); - if (parent === dir) return null; - dir = parent; - } -} - -// ── what the artifact references ───────────────────────────────────────────── - -/** - * The spec subpath a specifier names, or `null` when it is not the spec. - * - * `@objectstack/spec` → `.`, `@objectstack/spec/ui` → `./ui` — the spelling the - * `exports` map uses, so no second normalisation is needed downstream. - */ -export function specSubpathOf(specifier) { - if (specifier === SPEC_PACKAGE) return '.'; - if (!specifier.startsWith(`${SPEC_PACKAGE}/`)) return null; - return `./${specifier.slice(SPEC_PACKAGE.length + 1)}`; -} - -/** - * Every spec symbol one artifact file references, by subpath. - * - * Deliberately a sibling of `check-phantom-dependencies.mjs`'s - * `moduleSpecifiers()` rather than a caller of it: that function answers "which - * PACKAGE does this file reach for", this one answers "which SYMBOLS does it - * take from it". The imported name is what matters, never the local alias — - * `import { A as B }` needs the spec to export `A`. - * - * A namespace import (`import * as spec`) or a bare side-effect import names no - * symbol, so it is recorded as a subpath requirement only. - * - * @returns {{ named: Map>, subpaths: Set }} - */ -export function specSymbols(text, fileName = 'file.d.ts') { - const source = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, /* setParentNodes */ false, ts.ScriptKind.TSX); - const named = new Map(); - const subpaths = new Set(); - - const note = (subpath, name) => { - subpaths.add(subpath); - if (!name) return; - if (!named.has(subpath)) named.set(subpath, new Set()); - named.get(subpath).add(name); - }; - - const visit = (node) => { - let specifier = null; - let clause = null; - - if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { - specifier = node.moduleSpecifier.text; - clause = node.importClause?.namedBindings; - // `import Default from '…'` — the spec publishes no default export, but - // naming it is the caller's claim, so record it as one. - if (node.importClause?.name) { - const sub = specSubpathOf(specifier); - if (sub) note(sub, 'default'); - } - } else if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) { - specifier = node.moduleSpecifier.text; - clause = node.exportClause; - } else if ( - ts.isImportTypeNode(node) && - ts.isLiteralTypeNode(node.argument) && - ts.isStringLiteral(node.argument.literal) - ) { - // `import('@objectstack/spec/ui').ReferenceRailEntry` - const sub = specSubpathOf(node.argument.literal.text); - if (sub) note(sub, node.qualifier && ts.isIdentifier(node.qualifier) ? node.qualifier.text : null); - } else if ( - ts.isImportEqualsDeclaration(node) && - ts.isExternalModuleReference(node.moduleReference) && - ts.isStringLiteral(node.moduleReference.expression) - ) { - const sub = specSubpathOf(node.moduleReference.expression.text); - if (sub) note(sub, null); - } - - if (specifier) { - const sub = specSubpathOf(specifier); - if (sub) { - if (clause && (ts.isNamedImports(clause) || ts.isNamedExports(clause))) { - for (const element of clause.elements) note(sub, (element.propertyName ?? element.name).text); - } else { - // namespace binding, or `export * from`, or a side-effect import - note(sub, null); - } - } - } - - ts.forEachChild(node, visit); - }; - visit(source); - return { named, subpaths }; -} - -/** - * Every spec subpath one artifact reaches for as a STRING LITERAL. - * - * The guard on the UMD exclusion. A minified bundle still names its externals - * in full — `require("@objectstack/spec/data")`, and again in the AMD - * `define([...])` array — even though every member access on them has been - * renamed to a single letter. So the subpath survives where the symbol does - * not, and that is enough to tell "this gate did not need to read that file" - * apart from "this gate could not". - * - * String literals rather than raw text, and this is not a refinement: the first - * spelling of this guard scanned the file's TEXT and produced THIRTY findings - * across fifteen packages on its first run, every one of them a `@objectstack/ - * spec` mention inside a DOC COMMENT (`@objectstack/spec/ui.` with the full - * stop of the sentence attached, `@objectstack/spec/data/object.zod.ts` naming - * a source file, `@objectstack/spec/view` naming an entry point that does not - * exist). A guard that fires on prose is a guard that gets deleted, taking the - * real coverage question with it. Comments are not literals, so parsing is what - * makes the guard survivable. - */ -export function specSubpathsFromLiterals(text, fileName = 'bundle.js') { - const source = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, /* setParentNodes */ false, ts.ScriptKind.JS); - const found = new Set(); - const visit = (node) => { - if (ts.isStringLiteralLike(node)) { - const sub = specSubpathOf(node.text); - if (sub) found.add(sub); - } - ts.forEachChild(node, visit); - }; - visit(source); - return found; -} - -/** Files that can carry a module specifier at all. */ -export const EXECUTABLE_ARTIFACT = /\.[cm]?jsx?$/; - -/** Is this artifact one whose symbol names the parser can read? */ -export function isNameableArtifact(file) { - if (/\.umd\.[cm]?js$/.test(file)) return false; - if (/\.map$/.test(file)) return false; - return NAMEABLE_ARTIFACT.test(file); -} - -/** Is this file part of a package's build output? */ -export function isArtifactFile(file) { - return ARTIFACT_DIRS.includes(file.split('/')[0]); -} - -/** - * Walk one package's build output. - * - * @returns {string[]} package-relative, `/`-separated paths - */ -export function listArtifactFiles(root, pkg) { - const found = []; - for (const dir of ARTIFACT_DIRS) { - const base = join(root, pkg.dir, dir); - if (!existsSync(base)) continue; - const walk = (current) => { - for (const entry of readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { - const full = join(current, entry.name); - if (entry.isDirectory()) walk(full); - else if (entry.isFile()) found.push(relative(join(root, pkg.dir), full).split('\\').join('/')); - } - }; - walk(base); - } - return found; -} - -/** - * What one package's artifact takes from the spec. - * - * @returns {{ named: Map>, covered: Set, mentioned: Set, scanned: number }} - */ -export function collectSpecUsage(files, readArtifact) { - const named = new Map(); - const covered = new Set(); - const mentioned = new Set(); - let scanned = 0; - - for (const file of files) { - if (!isArtifactFile(file)) continue; - const text = readArtifact(file); - if (text === null || text === undefined) continue; - if (!text.includes(SPEC_PACKAGE)) continue; - - if (!isNameableArtifact(file)) { - // Unreadable for symbol names — but its subpaths still have to be - // accounted for, or the exclusion is where this gate goes blind. - if (EXECUTABLE_ARTIFACT.test(file)) { - for (const sub of specSubpathsFromLiterals(text, file)) mentioned.add(sub); - } - continue; - } - - scanned += 1; - const { named: fileNamed, subpaths } = specSymbols(text, file); - for (const sub of subpaths) { - covered.add(sub); - mentioned.add(sub); - } - for (const [sub, names] of fileNamed) { - if (!named.has(sub)) named.set(sub, new Map()); - const bucket = named.get(sub); - for (const name of names) { - if (!bucket.has(name)) bucket.set(name, []); - bucket.get(name).push(file); - } - } - } - return { named, covered, mentioned, scanned }; -} - -// ── the published export surface of a given spec version ───────────────────── - -/** - * Resolve one subpath of a package's `exports` map under a condition list. - * - * A hand-rolled resolver rather than `import.meta.resolve` / `require.resolve` - * ON PURPOSE: both of those answer from the INSTALLED tree, which is 17.2.0 - * here and hoisted to the workspace root — the exact hazard this gate exists to - * step around. This walks the manifest that was FETCHED for the version under - * judgement, and returns the conditions it took so the caller can print them. - * - * @returns {{ target: string, path: string[] } | null} - */ -export function resolveExportTarget(exportsField, subpath, conditions) { - if (!exportsField || typeof exportsField !== 'object') return null; - const entry = exportsField[subpath]; - if (entry === undefined) return null; - - const walk = (node, path) => { - if (typeof node === 'string') return { target: node, path }; - if (node === null) return null; - if (Array.isArray(node)) { - for (const candidate of node) { - const hit = walk(candidate, path); - if (hit) return hit; - } - return null; - } - if (typeof node !== 'object') return null; - for (const condition of conditions) { - if (!(condition in node)) continue; - const hit = walk(node[condition], [...path, condition]); - if (hit) return hit; - } - return null; - }; - return walk(entry, []); -} - -/** - * Every name a `.d.ts` / `.d.mts` entry exports. - * - * The spec's entries re-export from internal chunk files under `as` renames - * (`export { A as ActionNavItem } from '../app.zod-CH7IEmsS.mjs'`), so the - * EXPORTED name — never the local one — is what a consumer can import. No - * `export *` appears in any 17.x entry (checked while writing this gate), so - * following chunks is not needed; one that appeared later would be recorded as - * `*` and reported rather than silently dropped. - */ -export function declaredExportNames(text, fileName = 'index.d.mts') { - const source = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, /* setParentNodes */ false, ts.ScriptKind.TS); - const names = new Set(); - const starFrom = []; - - for (const statement of source.statements) { - if (ts.isExportDeclaration(statement)) { - if (statement.exportClause && ts.isNamedExports(statement.exportClause)) { - for (const element of statement.exportClause.elements) names.add(element.name.text); - } else if (!statement.exportClause && statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)) { - starFrom.push(statement.moduleSpecifier.text); - } - continue; - } - if (ts.isExportAssignment(statement)) { - names.add('default'); - continue; - } - const modifiers = ts.canHaveModifiers(statement) ? (ts.getModifiers(statement) ?? []) : []; - if (!modifiers.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) continue; - if (modifiers.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword)) names.add('default'); - if (ts.isVariableStatement(statement)) { - for (const declaration of statement.declarationList.declarations) { - if (ts.isIdentifier(declaration.name)) names.add(declaration.name.text); - } - } else if (statement.name && ts.isIdentifier(statement.name)) { - names.add(statement.name.text); - } - } - return { names, starFrom }; -} - -const defaultFetch = (version, dir) => - execFileSync( - 'npm', - [ - 'install', - '--prefix', - dir, - `${SPEC_PACKAGE}@${version}`, - '--no-save', - '--no-package-lock', - '--ignore-scripts', - '--no-audit', - '--no-fund', - '--omit=dev', - '--omit=optional', - ], - { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, - ); - -/** - * Fetch one published spec version and read its export surface. - * - * NOT resolved from `node_modules` — see hazard 1 in this file's header. - * - * @returns {{ version: string, dir: string, entryFor(subpath): {target, path, names, starFrom} | null }} - */ -export function loadPublishedSpec(version, cacheRoot, { fetch = defaultFetch, log = () => {} } = {}) { - const dir = join(cacheRoot, version); - const installed = join(dir, 'node_modules', SPEC_PACKAGE); - if (!existsSync(join(installed, 'package.json'))) { - mkdirSync(dir, { recursive: true }); - log(`Fetching ${SPEC_PACKAGE}@${version} from the registry (declared floor, not the installed tree)…`); - try { - fetch(version, dir); - } catch (error) { - throw new Error( - `could not fetch ${SPEC_PACKAGE}@${version}: ${error.message}. Reported as a FAILURE rather ` + - 'than a skip — a floor this gate could not read is not a floor it may approve.', - ); - } - } - const manifest = JSON.parse(readFileSync(join(installed, 'package.json'), 'utf8')); - if (manifest.version !== version) { - throw new Error( - `asked the registry for ${SPEC_PACKAGE}@${version} and got ${manifest.version}. The export ` + - 'surface read would belong to the wrong version, which is the whole failure this gate is about.', - ); - } - - const cache = new Map(); - return { - version, - dir: installed, - manifest, - /** The `import`-condition type entry for one subpath, parsed. */ - entryFor(subpath, conditions = ['import', 'types', 'default']) { - const key = `${subpath}\u0000${conditions.join(',')}`; - if (cache.has(key)) return cache.get(key); - const resolved = resolveExportTarget(manifest.exports, subpath, conditions); - if (!resolved) { - cache.set(key, null); - return null; - } - const file = join(installed, resolved.target); - const text = readFileSync(file, 'utf8'); - const { names, starFrom } = declaredExportNames(text, resolved.target); - const value = { ...resolved, file, names, starFrom }; - cache.set(key, value); - return value; - }, - }; -} - -// ── the judgement ──────────────────────────────────────────────────────────── - -/** - * One package's verdict. - * - * Takes only `entryFor` off the loaded spec, never the whole handle: the - * verdict must be drivable from a synthetic export set, or the unit tests can - * exercise nothing without a registry. - * - * @param {{name: string, dir: string, manifest: object}} pkg - * @param {{named: Map, covered: Set, mentioned: Set, scanned: number}} usage - * @param {{ entryFor: (subpath: string) => ({ target: string, path: string[], names: Set, starFrom: string[] } | null) }} spec - * the published export surface AT THIS PACKAGE'S FLOOR - * @param {string} floor - * @returns {{findings: object[], entries: {subpath: string, target: string, path: string[]}[]}} - */ -export function auditPackage(pkg, usage, spec, floor) { - const findings = []; - const entries = []; - - if (usage.mentioned.size === 0) { - // Declares the spec and never reaches for it in the artifact. Over-declared - // is a different card; a floor with nothing behind it cannot be too low. - return { findings, entries }; - } - - const opaque = [...usage.mentioned].filter((sub) => !usage.covered.has(sub)); - for (const subpath of opaque.sort()) { - findings.push({ - reason: 'opaque-subpath', - pkg: pkg.name, - subpath, - detail: - `reaches ${SPEC_PACKAGE}${subpath.slice(1)} only from artifacts this gate cannot read symbol ` + - 'names in (a minified UMD/CJS bundle), so its floor is unjudged rather than approved', - }); - } - - for (const subpath of [...usage.named.keys()].sort()) { - const entry = spec.entryFor(subpath); - if (!entry) { - findings.push({ - reason: 'unknown-subpath', - pkg: pkg.name, - subpath, - floor, - detail: `${SPEC_PACKAGE}@${floor} publishes no "${subpath}" export at all`, - }); - continue; - } - entries.push({ subpath, target: entry.target, path: entry.path }); - if (entry.starFrom.length > 0) { - findings.push({ - reason: 'unfollowable-star', - pkg: pkg.name, - subpath, - floor, - detail: - `${SPEC_PACKAGE}@${floor}'s "${subpath}" entry re-exports \`*\` from ` + - `${entry.starFrom.join(', ')}; its export set is not fully readable, so no symbol under it is approved`, - }); - continue; - } - for (const [name, files] of [...usage.named.get(subpath)].sort((a, b) => a[0].localeCompare(b[0]))) { - if (entry.names.has(name)) continue; - findings.push({ - reason: 'floor-too-low', - pkg: pkg.name, - subpath, - symbol: name, - floor, - files: files.slice(0, 3), - detail: - `${pkg.dir}/${files[0]} references \`${name}\` from ${SPEC_PACKAGE}${subpath.slice(1)}, ` + - `which ${SPEC_PACKAGE}@${floor} does not export`, - }); - } - } - return { findings, entries }; -} - -/** - * The lowest version within `range` that exports every symbol in `wanted`. - * - * Named so the gate can say what the floor should BE, not only that it is - * wrong — the dispatch order for objectui#5793 requires every range changed to - * be backed by a symbol the gate names, and a bump nobody can justify from the - * output is the failure mode on the other side. - */ -export function lowestSatisfyingVersion(wanted, candidates, load) { - for (const version of candidates) { - const spec = load(version); - let ok = true; - for (const [subpath, names] of wanted) { - const entry = spec.entryFor(subpath); - if (!entry || entry.starFrom.length > 0) { - ok = false; - break; - } - for (const name of names instanceof Map ? names.keys() : names) { - if (!entry.names.has(name)) { - ok = false; - break; - } - } - if (!ok) break; - } - if (ok) return version; - } - return null; -} - -/** - * The remedy line printed under the `floor-too-low` findings for one package: - * the lowest published release of the floor's major that exports every symbol - * the package references, or the statement that none does. - * - * A PRERELEASE floor names no replacement. Finding one means ordering published - * versions against the floor, and this gate does not order prereleases - * (FLOOR_VERSION above) — so it says that, rather than offering a release it - * reached by an ordering it does not define. - */ -export function replacementFloorLine(pkg, floor, wanted, versions, load) { - if (parseVersion(floor) === null) { - return ( - ` ${pkg} → its floor ${floor} is a prerelease, which this gate does not order, so it names no ` + - `replacement: raise the range to a published ${SPEC_PACKAGE} that exports the symbols above.` - ); - } - const candidates = versions.filter((v) => compareVersions(v, floor) > 0 && majorOf(v) === majorOf(floor)); - const answer = lowestSatisfyingVersion(wanted, candidates, load); - return answer - ? ` ${pkg} → "${SPEC_PACKAGE}": "^${answer}"` - : ` ${pkg} → no published ${majorOf(floor)}.x carries every symbol it references; the spec side is what is wrong.`; -} - -/** Published versions of the spec, oldest first, prereleases dropped. */ -export function publishedVersions(run = () => execFileSync('npm', ['view', SPEC_PACKAGE, 'versions', '--json'], { encoding: 'utf8' })) { - const parsed = JSON.parse(run()); - const list = Array.isArray(parsed) ? parsed : [parsed]; - return list.filter((v) => parseVersion(v) !== null).sort(compareVersions); -} - -// ── the run ────────────────────────────────────────────────────────────────── - -/** - * The whole run, as data. - * - * @param {string} root - * @param {{ - * cacheRoot?: string, - * load?: (version: string) => { entryFor: (subpath: string) => any }, - * log?: (line: string) => void, - * packages?: {name: string, dir: string, manifest: object}[], - * workspaceVersion?: string | null, - * }} [options] `workspaceVersion` replaces the installed spec's version a `workspace:` range reads as its floor - */ -export function analyze(root, { cacheRoot, load, log = () => {}, packages, workspaceVersion } = {}) { - const resolvedCache = cacheRoot ?? join(root, 'node_modules/.cache/spec-range-floors'); - const specVersion = workspaceVersion === undefined ? workspaceSpecVersion(root) : workspaceVersion; - const loadSpec = load ?? ((version) => loadPublishedSpec(version, resolvedCache, { log })); - - const all = packages ?? discoverPublishedPackages(root); - const findings = []; - const counters = { published: all.length, floored: 0, artifacts: 0, scanned: 0, symbols: 0 }; - const consulted = []; - - for (const pkg of all) { - let floor; - try { - floor = effectiveFloor(pkg.manifest, RUNTIME_FIELDS, { workspaceVersion: specVersion }); - } catch (error) { - findings.push({ reason: 'unreadable-range', pkg: pkg.name, detail: error.message }); - continue; - } - if (floor === null) continue; - counters.floored += 1; - - const files = listArtifactFiles(root, pkg); - if (files.length === 0) { - findings.push({ - reason: 'no-artifact', - pkg: pkg.name, - detail: - `${pkg.dir} declares ${SPEC_PACKAGE} ${floor.ranges.map((r) => `${r.field}:${r.range}`).join(' ')} but ` + - 'produced no build output to judge. Reported as a FAILURE rather than a skip: "nothing to ' + - 'inspect, therefore clean" is the verdict this gate must never return.', - }); - continue; - } - counters.artifacts += files.length; - - const usage = collectSpecUsage(files, (file) => { - const full = join(root, pkg.dir, file); - try { - if (statSync(full).size > 32 * 1024 * 1024) return null; - return readFileSync(full, 'utf8'); - } catch { - return null; - } - }); - counters.scanned += usage.scanned; - for (const names of usage.named.values()) counters.symbols += names.size; - - const spec = loadSpec(floor.version); - const { findings: packageFindings, entries } = auditPackage(pkg, usage, spec, floor.version); - for (const entry of entries) consulted.push({ version: floor.version, ...entry }); - for (const finding of packageFindings) findings.push({ ...finding, ranges: floor.ranges, usage: usage.named }); - } - - return { findings, counters, consulted }; -} - -const HINTS = { - 'floor-too-low': - 'Raise that package\'s `@objectstack/spec` range to the lowest version that exports the symbol — the ' + - 'line above names it. Do not add a tolerant re-declaration on this side: the range is the claim, and ' + - 'the claim is what is wrong (objectui#5793).', - 'unknown-subpath': - 'The declared floor publishes no such entry point. Either the range is far too low, or the import is a ' + - 'typo the installed 17.x happens to satisfy.', - 'opaque-subpath': - 'Every spec subpath a package ships must appear in an artifact whose symbol names are readable — a ' + - 'type declaration or an ES module. A subpath reaching consumers only through a minified bundle leaves ' + - 'the floor unjudged, and this gate reports that rather than passing it.', - 'no-artifact': 'Build the workspace before running this gate: `pnpm exec turbo run build --filter=!@object-ui/site`.', - 'unreadable-range': 'A `@objectstack/spec` range with no computable minimum cannot be judged.', - 'unfollowable-star': 'The spec entry re-exports `*`; teach this gate to follow chunk files before trusting it here.', -}; - -export async function main(argv = process.argv.slice(2)) { - const root = process.cwd(); - const log = (line) => console.log(line); - - const result = analyze(root, { log }); - const { findings, counters, consulted } = result; - - if (counters.floored < MIN_FLOORED_PACKAGES) { - console.error( - `The scan collapsed: ${counters.floored} package(s) declare ${SPEC_PACKAGE} in a consumer-facing ` + - `field, fewer than the ${MIN_FLOORED_PACKAGES} this repository has. The manifest walk, the release ` + - 'group or the field list is broken, and an empty comparison would pass while asserting nothing.', - ); - process.exit(1); - } - - // Name the artifact that was judged — hazard 2 in the header. The entry is - // printed, not assumed, so a run that silently read the `require` half is - // visible in its own log. - const byEntry = new Map(); - for (const entry of consulted) byEntry.set(`${entry.version}${entry.subpath}`, entry); - console.log( - `Read the export surface of ${byEntry.size} ${SPEC_PACKAGE} entry point(s), each resolved through the ` + - "FETCHED package's own `exports` map — never through the installed tree:", - ); - for (const entry of [...byEntry.values()].sort((a, b) => `${a.version}${a.subpath}`.localeCompare(`${b.version}${b.subpath}`))) { - console.log(` ${SPEC_PACKAGE}@${entry.version} "${entry.subpath}" [${entry.path.join(' > ')}] ${entry.target}`); - } - console.log( - `Inspected ${counters.floored} of ${counters.published} published package(s) — ${counters.artifacts} ` + - `artifact file(s), ${counters.scanned} of them carrying a ${SPEC_PACKAGE} reference this gate could ` + - `read symbol names in, ${counters.symbols} (subpath, symbol) pair(s) judged.`, - ); - - if (argv.includes('--cross-check')) crossCheck(root, consulted, log); - - if (findings.length === 0) { - console.log(`✅ Every consumer-facing ${SPEC_PACKAGE} floor carries the symbols its package's artifact references.`); - process.exit(0); - } - - const tooLow = findings.filter((f) => f.reason === 'floor-too-low'); - const affected = new Set(findings.map((f) => f.pkg)); - console.error(`\n❌ ${findings.length} finding(s) across ${affected.size} published package(s):\n`); - for (const finding of findings) { - console.error(` ${finding.pkg} [${finding.reason}] ${finding.detail}`); - } - - if (tooLow.length > 0) { - console.error('\nThe lowest published spec that carries every symbol each package references:'); - const versions = publishedVersions(); - const cacheRoot = join(root, 'node_modules/.cache/spec-range-floors'); - for (const pkg of [...new Set(tooLow.map((f) => f.pkg))].sort()) { - const wanted = tooLow.find((f) => f.pkg === pkg).usage; - const floor = tooLow.find((f) => f.pkg === pkg).floor; - console.error(replacementFloorLine(pkg, floor, wanted, versions, (v) => loadPublishedSpec(v, cacheRoot, { log }))); - } - } - - for (const reason of Object.keys(HINTS)) { - if (findings.some((f) => f.reason === reason)) console.error(`\n${reason}: ${HINTS[reason]}`); - } - console.error(`\nSee the header of scripts/check-spec-range-floors.mjs (objectui#5793).`); - process.exit(1); -} - -/** - * The dual-package cross-check, on demand. - * - * Re-reads every entry the run consulted through the `require` condition and - * compares export sets with the `import` half the verdict used. They agree on - * 17.x today; a divergence would mean the verdict covers one half of a - * dual-package build only, which is hazard 2 in this file's header cashed in. - */ -export function crossCheck(root, consulted, log = () => {}) { - const cacheRoot = join(root, 'node_modules/.cache/spec-range-floors'); - const loaded = new Map(); - let compared = 0; - const divergent = []; - for (const { version, subpath } of consulted) { - if (!loaded.has(version)) loaded.set(version, loadPublishedSpec(version, cacheRoot, { log })); - const spec = loaded.get(version); - const esm = spec.entryFor(subpath, ['import', 'types', 'default']); - const cjs = spec.entryFor(subpath, ['require', 'types', 'default']); - if (!esm || !cjs) continue; - compared += 1; - const only = (a, b) => [...a.names].filter((n) => !b.names.has(n)); - const left = only(esm, cjs); - const right = only(cjs, esm); - if (left.length || right.length) divergent.push({ version, subpath, esm: esm.target, cjs: cjs.target, left, right }); - } - if (divergent.length === 0) { - log(`Dual-package cross-check: ${compared} entry point(s) — the \`import\` and \`require\` halves export the same names.`); - return true; - } - for (const d of divergent) { - console.error( - ` ${SPEC_PACKAGE}@${d.version} "${d.subpath}": ${d.esm} and ${d.cjs} disagree — ` + - `${d.left.length} name(s) only in the import half, ${d.right.length} only in the require half.`, - ); - } - return false; -} - -if (isEntrypoint(import.meta.url)) await main(); diff --git a/ui/scripts/check-ui-root-lookups.mjs b/ui/scripts/check-ui-root-lookups.mjs index e37d979cb41..00035c8ae60 100644 --- a/ui/scripts/check-ui-root-lookups.mjs +++ b/ui/scripts/check-ui-root-lookups.mjs @@ -197,10 +197,6 @@ export const ALLOWLIST = new Map([ 'scripts/workspace-root.mjs', "decides the WORKSPACE root for every reader of pnpm-workspace.yaml / pnpm-lock.yaml / the root manifest / the .pnpm store: one probe at `uiRoot()` and one at its parent, anchored on ui-root.mjs -- never a walk, never the cwd (objectstack#25723, objectstack#25733)", ], - [ - 'scripts/objectstack-pin.mjs', - 'packs the pin from an objectstack tree and refuses one with no pnpm-workspace.yaml -- a probe of THAT tree, not of this root; the file itself is deleted at cutover', - ], [ 'scripts/check-new-cross-file-line-citations.mjs', "the gate's subject is the REPOSITORY AT THE CWD (its self-test drives it against a scratch repository), so `gateRoot()` reads the toplevel and narrows to `uiRoot()` only when the objectui tree lies inside it -- a probe of THAT repository, not a lookup of this root", diff --git a/ui/scripts/markdown-test-inputs.mjs b/ui/scripts/markdown-test-inputs.mjs index c717fe7cf3d..c2f697563ca 100644 --- a/ui/scripts/markdown-test-inputs.mjs +++ b/ui/scripts/markdown-test-inputs.mjs @@ -894,13 +894,6 @@ export const ADJUDICATED = new Map([ walker: 'not-markdown: `.github/workflows/*.yml`', }, ], - [ - 'scripts/__tests__/check-installed-spec-pin-claims.test.ts', - { - reads: ['.claude/skills/**', '.github/prompts/component.prompt.md', '.github/prompts/engine.prompt.md', '.github/prompts/ui-library.prompt.md', '.github/pull_request_template.md', 'AGENTS.md', 'CLAUDE.md', 'CONTRIBUTING.md', 'LICENSE-THIRD-PARTY.md', 'QUICK_REFERENCE.md', 'README.md', 'ROADMAP.md', 'apps/**', 'content/docs/**', 'docs/ARCHITECTURE.md', 'docs/CONSOLE-STREAMLINING-SUMMARY.md', 'docs/adr/**', 'docs/audits/**', 'examples/**', 'packages/**', 'patches/README.md', 'skills/objectui/**'], - notRead: ['CHANGELOG.md'], - }, - ], [ 'scripts/__tests__/check-links-workflow.test.ts', { @@ -1122,12 +1115,6 @@ export const ADJUDICATED = new Map([ walker: 'not-markdown: `.github/workflows/*.yml`', }, ], - // objectui#12958. Its `CHANGELOG.md` is a fixture written into a temp directory; - // from this tree it reads the root `package.json` and one workflow, no markdown. - [ - 'scripts/__tests__/objectstack-floor.test.ts', - { reads: [], notRead: ['CHANGELOG.md'] }, - ], [ 'scripts/__tests__/quick-reference-commands-4149.test.ts', { @@ -1135,13 +1122,6 @@ export const ADJUDICATED = new Map([ walker: 'not-markdown: directory listings that test whether a documented path exists', }, ], - [ - 'scripts/__tests__/quick-reference-current-release-4143.test.ts', - { - reads: ['QUICK_REFERENCE.md'], - walker: 'not-markdown: package and app directories', - }, - ], // objectui#12214. Its fixtures write a `README.md` into a temp directory to // prove a root documentation change is not a full-run trigger; the selector // it tests never opens a markdown file. @@ -1152,12 +1132,6 @@ export const ADJUDICATED = new Map([ notRead: ['README.md'], }, ], - [ - 'scripts/__tests__/sync-quick-reference-release.test.ts', - { - reads: ['QUICK_REFERENCE.md'], - }, - ], [ 'scripts/__tests__/unconsumed-widget-option-claim-6186.test.ts', { diff --git a/ui/scripts/objectstack-floor.mjs b/ui/scripts/objectstack-floor.mjs deleted file mode 100644 index af31dfc0fef..00000000000 --- a/ui/scripts/objectstack-floor.mjs +++ /dev/null @@ -1,228 +0,0 @@ -#!/usr/bin/env node -/** - * objectstack-floor -- at publish, every `@objectstack/*` floor objectui ships is - * computed from `.objectstack-sha` (ruling B on objectui#12955, executed by objectui#12958). - * - * node scripts/objectstack-floor.mjs # first step of `pnpm changeset:publish` - * node scripts/objectstack-floor.mjs --dry-run # compute and report, write nothing - * - * THE FLOOR is the earliest `@objectstack/spec` version npm carries whose objectstack - * release tag `@objectstack/spec@` contains the pin (`git merge-base - * --is-ancestor`); a tag npm does not carry is not a release. Prereleases count -- an - * unshipped major exists on npm only as `X.0.0-next.N` -- ordered by semver precedence. - * objectstack versions its packages as one fixed group, so that version floors every - * `@objectstack/*` range, and npm is asked for each moved name at that version. - * Tags and history: a treeless fetch (commits only, full depth, so a non-ancestor - * answer is real) of objectstack's public repository into a temporary directory -- no - * secret, the access `scripts/objectstack-pin.mjs` already uses (`OBJECTSTACK_REPO_URL`). - * - * THE RANGES, in every dependency field of every published manifest: one below the - * floor becomes `^`, with a `### Dependency floors` entry under the package's - * `## ` CHANGELOG.md section -- the tarball's CHANGELOG and the GitHub Release - * body (`scripts/release-github-releases.mjs`) carry it. One at or above the floor stays - * as written: it already admits no release older than the pin. The writes are the - * runner's; the tagged commit keeps `main`'s ranges. `check-spec-range-floors.mjs` runs - * after this step, because a release that contains the pin can still have removed a - * symbol the artifact uses. - * - * EXIT 0 computed (and written, unless --dry-run) · 1 refused (an unreadable range, a - * fetch or npm failure, a moved name npm lacks at the floor) · 2 no published release - * contains the pin, so publishing waits for objectstack's next release. Nothing is - * written unless the answer is 0. - */ - -import { spawnSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; - -import { ALL_FIELDS } from './check-phantom-dependencies.mjs'; -import { discoverPublishedPackages } from './check-published-dist-tooling.mjs'; -import { isEntrypoint } from './invoked-as.mjs'; -import { DEFAULT_REPO_URL, REPO_ROOT, readPin } from './objectstack-pin.mjs'; - -export const SPEC = '@objectstack/spec'; -export const TAG_PREFIX = `${SPEC}@`; -const VERSION = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/; -const RANGE = /^(?:\^|~|>=|=)?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/; -// pnpm's workspace protocol (objectstack ruling D1: inside the objectstack monorepo the -// `@objectstack/*` ranges are `workspace:^`). `workspace:*` / `^` / `~` / bare publish as -// `^`, so that version is the range's floor; a range -// tail (`workspace:^1.2.3`) is read as the range. -const WORKSPACE = /^workspace:(.*)$/; -const WORKSPACE_OWN_VERSION = new Set(['', '*', '^', '~']); - -/** - * The version of `name` the workspace resolves: the nearest `node_modules//package.json` - * walking up from `root` -- the pinned build standalone, the workspace link in the monorepo. - * `null` when nothing is installed, which a `workspace:` range then reports as unreadable. - */ -export function installedVersion(root, name) { - let dir = root; - for (;;) { - const manifest = path.join(dir, 'node_modules', name, 'package.json'); - if (fs.existsSync(manifest)) { - try { - const version = JSON.parse(fs.readFileSync(manifest, 'utf8')).version; - return typeof version === 'string' ? version : null; - } catch { - return null; - } - } - const parent = path.dirname(dir); - if (parent === dir) return null; - dir = parent; - } -} - -/** The lowest version `from` admits, or `null` when this step cannot read it. */ -export function floorOfRange(from, name, workspaceVersionOf) { - const text = String(from).trim(); - const workspace = WORKSPACE.exec(text); - if (!workspace) return RANGE.exec(text)?.[1] ?? null; - const tail = workspace[1].trim(); - if (!WORKSPACE_OWN_VERSION.has(tail)) return RANGE.exec(tail)?.[1] ?? null; - const own = workspaceVersionOf(name); - return typeof own === 'string' && VERSION.test(own) ? own : null; -} - -/** Semver precedence of two `X.Y.Z[-prerelease]` versions: negative, 0 or positive. */ -export function compareVersions(a, b) { - const [x, y] = [a, b].map((v) => { - const m = VERSION.exec(v); - if (!m) throw new Error(`"${v}" is not an X.Y.Z[-prerelease] version`); - return { core: m.slice(1, 4).map(Number), pre: m[4] ? m[4].split('.') : [] }; - }); - for (let i = 0; i < 3; i += 1) if (x.core[i] !== y.core[i]) return x.core[i] - y.core[i]; - if (!x.pre.length || !y.pre.length) return y.pre.length - x.pre.length; // a release outranks its prereleases - for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i += 1) { - const [p, q] = [x.pre[i], y.pre[i]]; - if (p === undefined || q === undefined) return p === undefined ? -1 : 1; - if (p === q) continue; - const [np, nq] = [/^\d+$/.test(p), /^\d+$/.test(q)]; - if (np && nq) return Number(p) - Number(q); - return np !== nq ? (np ? -1 : 1) : p < q ? -1 : 1; - } - return 0; -} - -/** The earliest published version whose release tag contains the pin, and the newest published one. */ -export function computeFloor({ published, tagged, contains }) { - const releases = published.filter((v) => VERSION.test(v)).sort(compareVersions); - return { floor: releases.find((v) => tagged.has(v) && contains(v)) ?? null, newest: releases.at(-1) ?? null }; -} - -/** Fetch every spec release tag with its history (commits only) and answer containment of the pin. */ -export function releaseTags(dir, url, pin) { - const git = (args) => spawnSync('git', args, { cwd: dir, encoding: 'utf8' }); - fs.mkdirSync(dir, { recursive: true }); - // The commit-graph is what makes one `--is-ancestor` per tag cheap: without it each - // call walks the whole history (measured on objectui#12958: 89 s against 0.5 s). - const fetch = ['fetch', '-q', '--filter=tree:0', '--no-tags', url, `+refs/tags/${TAG_PREFIX}*:refs/tags/${TAG_PREFIX}*`]; - const steps = [['init', '-q', '--bare'], fetch, ['commit-graph', 'write', '--reachable']]; - for (const args of steps) { - const r = git(args); - if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${(r.stderr || String(r.error)).trim()}`); - } - const refs = git(['for-each-ref', '--format=%(refname:lstrip=2)', `refs/tags/${TAG_PREFIX}*`]).stdout; - const tagged = new Set(refs.split('\n').filter(Boolean).map((t) => t.slice(TAG_PREFIX.length))); - const present = git(['cat-file', '-e', `${pin}^{commit}`]).status === 0; - const contains = (version) => { - if (!present) return false; - const r = git(['merge-base', '--is-ancestor', pin, `refs/tags/${TAG_PREFIX}${version}`]); - if (r.status !== 0 && r.status !== 1) throw new Error(`git merge-base --is-ancestor ${pin} ${TAG_PREFIX}${version}: ${r.stderr.trim()}`); - return r.status === 0; - }; - return { tagged, contains }; -} - -/** - * Every `@objectstack/*` range of every published manifest, judged against the floor. - * - * @param {{ name: string, dir: string, manifest: object }[]} packages - * @param {string} floor - * @param {{ workspaceVersionOf?: (name: string) => string | null }} [options] - * what a `workspace:` range of `name` reads as its floor (default: nothing is a workspace) - */ -export function planRanges(packages, floor, { workspaceVersionOf = () => null } = {}) { - const rows = []; - for (const pkg of packages) { - for (const field of ALL_FIELDS) { - for (const [name, from] of Object.entries(pkg.manifest[field] ?? {})) { - if (!name.startsWith('@objectstack/')) continue; - const min = floorOfRange(from, name, workspaceVersionOf); - const action = !min ? 'unreadable' : compareVersions(min, floor) < 0 ? 'moved' : 'kept'; - rows.push({ pkg, field, name, from, to: action === 'moved' ? `^${floor}` : from, action }); - } - } - } - return rows; -} - -/** CHANGELOG.md with a `### Dependency floors` entry under `## ` (added when absent). */ -export function withFloorEntry(text, version, moved, floor, pin) { - const entry = - `### Dependency floors\n\nComputed from \`.objectstack-sha\` \`${pin.slice(0, 12)}\`: the earliest published ` + - `\`${SPEC}\` that contains it is \`${floor}\`.\n\n` + - moved.map((r) => `- \`${r.name}\` (\`${r.field}\`): \`${r.from}\` → \`${r.to}\`\n`).join(''); - const heading = `\n## ${version}\n`; - const at = text.indexOf(heading); - if (at === -1) { - const cut = text.indexOf('\n') + 1; - return `${text.slice(0, cut)}\n## ${version}\n\n${entry}${text.slice(cut)}`; - } - let cut = at + heading.length; - if (text[cut] === '\n') cut += 1; - return `${text.slice(0, cut)}${entry}\n${text.slice(cut)}`; -} - -function npmJson(args) { - const r = spawnSync('npm', [...args, '--json'], { encoding: 'utf8' }); - if (r.status !== 0) throw new Error(`npm ${args.join(' ')} failed: ${(r.stderr || r.stdout || String(r.error)).trim().slice(0, 400)}`); - return r.stdout.trim() ? JSON.parse(r.stdout) : null; -} - -export function main({ root = REPO_ROOT, argv = process.argv.slice(2), env = process.env, npm = npmJson } = {}) { - const dryRun = argv.includes('--dry-run'); - const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'objectstack-floor-')); - try { - const pin = readPin(root); - const published = [].concat(npm(['view', SPEC, 'versions']) ?? []); - const tags = releaseTags(path.join(scratch, 'objectstack.git'), env.OBJECTSTACK_REPO_URL || DEFAULT_REPO_URL, pin); - const { floor, newest } = computeFloor({ published, ...tags }); - if (!floor) { - console.error( - `✗ No published ${SPEC} release contains the pin ${pin} (.objectstack-sha); the newest published ` + - `release is ${newest}. Nothing is published until objectstack releases a commit that contains the pin.`, - ); - return 2; - } - console.log(`Floor ${floor}: the earliest published ${SPEC} whose tag ${TAG_PREFIX}${floor} contains the pin ${pin}.`); - const rows = planRanges(discoverPublishedPackages(root), floor, { workspaceVersionOf: (name) => installedVersion(root, name) }); - for (const r of rows) console.log(` ${r.action.padEnd(10)} ${r.pkg.name} ${r.field} ${r.name} ${r.from}${r.action === 'moved' ? ` → ${r.to}` : ''}`); - const unreadable = rows.filter((r) => r.action === 'unreadable'); - if (unreadable.length) throw new Error(`${unreadable.length} range(s) above name no minimum this step can read; nothing was written.`); - const moved = rows.filter((r) => r.action === 'moved'); - for (const name of new Set(moved.map((r) => r.name))) { - if (npm(['view', `${name}@${floor}`, 'version']) !== floor) throw new Error(`npm does not carry ${name}@${floor}; nothing was written.`); - } - if (!dryRun) { - for (const pkg of new Set(moved.map((r) => r.pkg))) { - const mine = moved.filter((r) => r.pkg === pkg); - for (const r of mine) pkg.manifest[r.field][r.name] = r.to; - fs.writeFileSync(path.join(root, pkg.dir, 'package.json'), `${JSON.stringify(pkg.manifest, null, 2)}\n`); - const changelog = path.join(root, pkg.dir, 'CHANGELOG.md'); - if (fs.existsSync(changelog)) fs.writeFileSync(changelog, withFloorEntry(fs.readFileSync(changelog, 'utf8'), pkg.manifest.version, mine, floor, pin)); - } - } - console.log(`${moved.length} range(s) moved to ^${floor}, ${rows.length - moved.length} left at or above it${dryRun ? ' (--dry-run: nothing written)' : ''}.`); - return 0; - } catch (error) { - console.error(`✗ objectstack-floor: ${error instanceof Error ? error.message : String(error)}`); - return 1; - } finally { - fs.rmSync(scratch, { recursive: true, force: true }); - } -} - -if (isEntrypoint(import.meta.url)) process.exitCode = main(); diff --git a/ui/scripts/objectstack-pin.mjs b/ui/scripts/objectstack-pin.mjs deleted file mode 100644 index 07e72c7a6b8..00000000000 --- a/ui/scripts/objectstack-pin.mjs +++ /dev/null @@ -1,1119 +0,0 @@ -#!/usr/bin/env node -/** - * objectstack-pin -- objectui consumes `@objectstack/*` BY COMMIT, not by npm - * version (ruling B on objectui#12955, executed by objectui#12957). - * - * node scripts/objectstack-pin.mjs build # `pnpm objectstack:build`; `pnpm run setup` = this, then a frozen install - * node scripts/objectstack-pin.mjs build --write-manifests # also commit-ready manifests for a moved pin (bump-objectstack.sh) - * node scripts/objectstack-pin.mjs status # what the cache holds, against the pin (exit 0 = built at the pin) - * node scripts/objectstack-pin.mjs check # the root `preinstall`: refuses an install with no build beside the manifests - * OBJECTSTACK_ROOT=../objectstack node scripts/objectstack-pin.mjs build # an existing tree stands in - * - * ## The contract - * - * `.objectstack-sha` names one objectstack `main` commit (`scripts/bump-objectstack.sh` - * moves it). The root `package.json` overrides EVERY `@objectstack/*` package the - * workspace resolves to a directory: - * - * "@objectstack/spec": "file:.objectstack-pin/spec" - * - * Each such directory holds ONE committed file, the package's `package.json` as - * `pnpm pack` packs it at the pinned commit (`canonicalManifest`). `build` builds - * that commit into `node_modules/.cache/objectstack` -- the path CI caches by the - * pin, whose stamp turbo hashes -- and links every other packed file beside the - * manifest (git-ignored; a copy where a link fails). So no `@objectstack/*` package - * resolves from npm; npm ranges in the manifests matter only when objectui itself - * publishes (objectui#12958). - * - * ## Why the manifests are committed (objectui#13008) - * - * Dependabot resolves on a fresh clone with no `node_modules` (its job for run - * 39180936608 ran `pnpm update next@16.3.8 --lockfile-only --no-save -r`). Measured - * on clean clones: that command exits 0 with only the committed manifests, and 1 - * (ERR_PNPM_LINKED_PKG_DIR_NOT_FOUND) when the overrides name a directory only - * `build` creates; a lockfile-only run does not run the root `preinstall`. pnpm - * HARD-LINKS a directory dependency's files at install time (same inode), so the - * build must be beside the manifests before `pnpm install`, and the lockfile does - * not change when it appears (byte-identical after a frozen install). - * - * The committed manifests must BE the pin's, since Dependabot resolves them: a - * build at the pin REFUSES a packed manifest that differs from the committed one - * (a pin moved without its manifests, a hand edit) and never writes over it; - * `--write-manifests` writes them, and bump-objectstack.sh commits them with the - * pin. A stand-in installs its OWN manifests (its exports may differ) and records - * in its stamp the ones it replaced; the next build at the pin restores exactly - * those, and nothing else. - * - * ## Why `file:` DIRECTORIES -- the resolution form, measured on objectui#12957 - * - * Three forms were installed against the same pinned build, pnpm 10.31.0: - * - * - `file:.tgz` tarballs: the lockfile records each tarball's `integrity` - * and `version`, and a frozen install on a clean checkout failed with - * ERR_PNPM_TARBALL_INTEGRITY -- for a substituted tree AND for the same tree. - * A tarball pins its BYTES into `pnpm-lock.yaml`, so no other tree can stand in. - * - `link:`: the lockfile stays stable, but pnpm installs nothing a linked - * package depends on. Importing the packed `@objectstack/spec` failed with - * `Cannot find package 'zod'`; linking a source tree instead brings that tree's - * own zod, a second physical copy -- the objectstack#21108 split. - * - `file:`: the lockfile records `resolution: {directory: …, type: directory}` - * with NO integrity and NO version, and the package's dependencies resolve from - * objectui's own lockfile (one zod). A frozen install re-links a directory whose - * content changed (a marker written into a rebuilt dir reached `node_modules`). - * - * So the lockfile depends only on the packed manifests' dependencies, peers and - * engines -- which is what lets another tree stand in without a lockfile change. - * - * The built files beside the manifests are git-ignored and listed in ESLint's - * ignores (the spec packs its TypeScript `src/`). Removing `node_modules` removes - * the build: the next `pnpm install` then refuses in the root `preinstall` (`check`) - * until `pnpm run setup` runs again -- without it, an install would exit 0 and link - * packages that hold nothing but a manifest. - * - * ## OBJECTSTACK_ROOT -- an existing objectstack tree stands in (objectstack#24306) - * - * With `OBJECTSTACK_ROOT` set, `build` packs the same packages from THAT tree into - * the same directories, instead of building the pin. Nothing in it is installed, - * built or regenerated (`pnpm pack` runs with scripts off): the tree must already be - * installed (`pnpm pack` reads each `workspace:` dependency's version from the - * installed links) and built, and a missing entry point refuses with the command - * that builds it. The lockfile does not change; the committed manifests in - * `.objectstack-pin` do, until the next build at the pin restores them. - * - * ## One build per machine (objectui#12972) - * - * A build at the pin is also kept once per machine, outside every checkout, in - * `$OBJECTSTACK_PIN_STORE` (default `$XDG_CACHE_HOME/objectstack-pin`, else - * `~/.cache/objectstack-pin`) as `PIN-HASH`, HASH over this script's bytes: CI's - * cache key inputs. A checkout whose cache is not the pin COPIES the entry instead of - * building -- never links it: the cache is hard-linked into `.objectstack-pin` and - * `node_modules`, so a linked entry would carry one checkout's write into a built file - * to every checkout. Only a build this run made is published (a current cache may - * have been written to), copied, hashed (`.entry.json`) and renamed into place: a - * reader finds no entry or a whole one, and a concurrent run that renamed first wins. - * The COPY is judged before use, by the stamp check and the hash. A stand-in never - * reads or writes the store; it keeps STORE_KEEP entries; a failed write only warns. - * - * ## The lockfile must record what the build declares -- and pnpm will not check it - * - * Measured on objectui#12957: with a pinned package's manifest given a dependency - * the lockfile does not record, `pnpm install --frozen-lockfile` exited 0 and did - * NOT install it -- a frozen install re-links a directory's files but resolves its - * dependencies from the lockfile alone. So a pin (or a stand-in) whose packages - * declare a different dependency set would install silently without it. `build` - * therefore compares every packed manifest's dependency NAMES with the ones the - * lockfile's `snapshots:` entry records for that package, after the cache is in - * place, and refuses on any difference -- naming the remedy (`pnpm install` - * rewrites the lockfile, reading the directories just written). ⚠️ Names, not - * ranges: a range that moved while its name stayed is not compared here. - * - * ## Refusals (exit 1, nothing written) - * - * - `.objectstack-sha` is not one full sha; - * - an `@objectstack/*` override is not the `file:.objectstack-pin/NAME` - * form, or a package objectui declares has no override (it would resolve from npm); - * - at the pin, a packed manifest differs from the committed one (see above); - * - the packages being packed need an `@objectstack/*` package with no override, or - * an override names a package nothing reaches -- the override set must equal the - * closure, so a pin that adds a dependency is a loud stop, never an npm fallback; - * - a packed package's `main`/`module`/`types`/`exports` target is missing. - * - * Every write goes to a staging directory that replaces the cache only once all - * packages packed and every manifest was judged, so a failed run leaves the - * previous build in place. One refusal comes AFTER the write, on purpose: the - * lockfile-drift one above, whose remedy (`pnpm install`) needs the new build on - * disk to read. `--write-manifests` skips it: a moved pin's lockfile is rewritten - * next, by that same `pnpm install`. - * - * ⛔ Node builtins only: CI runs this BEFORE `pnpm install` - * (`scripts/check-pre-install-import-graph.mjs` holds that). - */ - -import { spawnSync } from 'node:child_process'; -import crypto from 'node:crypto'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { isEntrypoint } from './invoked-as.mjs'; - -export const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -export const PIN_FILE = '.objectstack-sha'; -export const CACHE_DIR = 'node_modules/.cache/objectstack'; -/** The committed manifests, and the build linked beside them: what the overrides name. */ -export const PIN_DIR = '.objectstack-pin'; -export const STAMP_FILE = '.stamp.json'; -export const OVERRIDE_PREFIX = `file:${PIN_DIR}/`; -/** Bump when what `build` writes changes shape, so an older cache is rebuilt rather than trusted. */ -export const RECIPE = 1; -/** objectui#12972: the store's variable, an entry's record, how many entries it keeps. */ -export const STORE_ENV = 'OBJECTSTACK_PIN_STORE'; -export const ENTRY_FILE = '.entry.json'; -export const STORE_KEEP = 4; -export const DEFAULT_REPO_URL = 'https://github.com/objectstack-ai/objectstack.git'; - -const SCOPE = '@objectstack/'; -const SHA_RE = /^[0-9a-f]{40}$/; -const NAME_RE = /^@objectstack\/[a-z0-9][a-z0-9-]*$/; -const PROD_FIELDS = ['dependencies', 'optionalDependencies', 'peerDependencies']; -const ALL_FIELDS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']; - -/** A refusal: printed as-is, exit 1. Anything else that throws is a crash. */ -export class PinRefusal extends Error {} - -// ── reading the contract ──────────────────────────────────────────────────── - -/** - * The pinned sha. The file holds one full lowercase sha and, optionally, one - * trailing newline -- nothing else, so a half-written or hand-mangled pin refuses. - * - * @param {string} root - * @returns {string} - */ -export function readPin(root) { - let text; - try { - text = fs.readFileSync(path.join(root, PIN_FILE), 'utf8'); - } catch { - throw new PinRefusal(`${PIN_FILE} is missing at ${root}. It names the objectstack commit this workspace builds; scripts/bump-objectstack.sh writes it.`); - } - const sha = text.endsWith('\n') ? text.slice(0, -1) : text; - if (!SHA_RE.test(sha)) { - throw new PinRefusal(`${PIN_FILE} must hold exactly one full 40-character lowercase sha; it holds ${JSON.stringify(text.slice(0, 80))}.`); - } - return sha; -} - -/** - * The `@objectstack/*` overrides of the root manifest, as name -> cache directory. - * Every one must be the pinned-build form; anything else would resolve some - * `@objectstack/*` package from somewhere other than the pin. - * - * @param {{ pnpm?: { overrides?: Record } }} manifest - * @returns {Map} - */ -export function readOverrides(manifest) { - const overrides = manifest?.pnpm?.overrides ?? {}; - const map = new Map(); - for (const [key, value] of Object.entries(overrides)) { - if (!key.startsWith(SCOPE)) continue; - if (!NAME_RE.test(key)) { - throw new PinRefusal(`pnpm.overrides key ${JSON.stringify(key)} is not a bare @objectstack/* package name; under the pin every @objectstack/* package is overridden whole, with no version selector.`); - } - const dir = key.slice(SCOPE.length); - const expected = `${OVERRIDE_PREFIX}${dir}`; - if (value !== expected) { - throw new PinRefusal(`pnpm.overrides["${key}"] is ${JSON.stringify(value)}; under the pin it must be "${expected}".`); - } - map.set(key, dir); - } - if (map.size === 0) { - throw new PinRefusal(`the root package.json declares no @objectstack/* override, so nothing would resolve to the pinned build.`); - } - return map; -} - -/** - * The `packages:` globs of a `pnpm-workspace.yaml`. Deliberately not a YAML - * parser (this runs before install): it reads the one list it needs and refuses - * a glob shape it does not understand rather than silently skipping it. - * - * @param {string} text - * @returns {string[]} - */ -export function workspaceGlobs(text) { - const globs = []; - let inPackages = false; - for (const raw of text.split('\n')) { - const line = raw.replace(/\s+#.*$/, '').replace(/^#.*$/, ''); - if (/^packages:\s*$/.test(line)) { - inPackages = true; - continue; - } - if (!inPackages || line.trim() === '') continue; - const item = line.match(/^\s+-\s+(['"]?)(.+?)\1\s*$/); - if (item) { - globs.push(item[2]); - continue; - } - if (/^\S/.test(line)) inPackages = false; - } - return globs; -} - -/** - * Every workspace project of a pnpm workspace, the root included, as - * `{ dir, manifest }` with `dir` relative to `root`. Supports the two glob shapes - * both repositories use -- `a/b` and `a/*` -- and refuses any other. - * - * @param {string} root - * @returns {Array<{ dir: string, manifest: Record }>} - */ -export function readWorkspace(root) { - const yamlPath = path.join(root, 'pnpm-workspace.yaml'); - if (!fs.existsSync(yamlPath)) throw new PinRefusal(`${root} has no pnpm-workspace.yaml, so it is not a pnpm workspace.`); - const projects = [{ dir: '.', manifest: readJson(path.join(root, 'package.json')) }]; - for (const glob of workspaceGlobs(fs.readFileSync(yamlPath, 'utf8'))) { - if (glob.startsWith('!')) throw new PinRefusal(`unsupported workspace glob ${JSON.stringify(glob)} in ${yamlPath} (negations are not read here).`); - const star = glob.endsWith('/*'); - const base = star ? glob.slice(0, -2) : glob; - if (/[*?[\]{}]/.test(base)) throw new PinRefusal(`unsupported workspace glob ${JSON.stringify(glob)} in ${yamlPath}; only "dir" and "dir/*" are read here.`); - const dirs = star - ? safeReaddir(path.join(root, base)).map((name) => path.posix.join(base, name)) - : [base]; - for (const dir of dirs) { - const manifestPath = path.join(root, dir, 'package.json'); - if (fs.existsSync(manifestPath)) projects.push({ dir, manifest: readJson(manifestPath) }); - } - } - return projects; -} - -/** - * Every `@objectstack/*` name the given projects declare, in any dependency field. - * - * @param {Array<{ manifest: Record }>} projects - * @returns {Set} - */ -export function declaredObjectstackNames(projects) { - const names = new Set(); - for (const { manifest } of projects) { - for (const field of ALL_FIELDS) { - for (const name of Object.keys(manifest[field] ?? {})) if (name.startsWith(SCOPE)) names.add(name); - } - } - return names; -} - -/** - * The `@objectstack/*` closure of `roots` inside an objectstack workspace, over - * RUNTIME edges only (dependencies, optional, peer) -- what an installer of the - * published packages receives. devDependencies are excluded on purpose: they pull - * in the drivers, runtime and services a package's own tests use (measured on - * objectui#12957 at objectstack main `2ee5861b0f`: 36 projects with them, 7 without). - * - * @param {Map }>} byName - * @param {Iterable} roots - * @returns {Set} - */ -export function runtimeClosure(byName, roots) { - const seen = new Set(); - const queue = [...roots]; - while (queue.length) { - const name = queue.shift(); - if (seen.has(name)) continue; - const project = byName.get(name); - if (!project) throw new PinRefusal(`${name} is not a package of the objectstack workspace being built, so it cannot come from the pin.`); - seen.add(name); - for (const field of PROD_FIELDS) { - for (const dep of Object.keys(project.manifest[field] ?? {})) if (dep.startsWith(SCOPE)) queue.push(dep); - } - } - return seen; -} - -/** - * The override set must equal what objectui needs from the pin -- exactly. - * - * @param {{ overrides: Map, declared: Set, byName: Map }} input - * @returns {string[]} the packages to pack, sorted - */ -export function checkOverrideSet({ overrides, declared, byName }) { - const missing = [...declared].filter((n) => !overrides.has(n)).sort(); - if (missing.length) { - throw new PinRefusal( - `objectui declares ${missing.join(', ')} but the root package.json does not override ${missing.length === 1 ? 'it' : 'them'}, ` + - `so ${missing.length === 1 ? 'it' : 'they'} would resolve from npm. Add ` + - missing.map((n) => `"${n}": "${OVERRIDE_PREFIX}${n.slice(SCOPE.length)}"`).join(', ') + - ' to pnpm.overrides.', - ); - } - const needed = runtimeClosure(byName, declared); - const unoverridden = [...needed].filter((n) => !overrides.has(n)).sort(); - if (unoverridden.length) { - throw new PinRefusal( - `the packages objectui uses need ${unoverridden.join(', ')} at this objectstack commit, and the root package.json does not override ` + - `${unoverridden.length === 1 ? 'it' : 'them'} -- ${unoverridden.length === 1 ? 'it' : 'they'} would resolve from npm. Add ` + - unoverridden.map((n) => `"${n}": "${OVERRIDE_PREFIX}${n.slice(SCOPE.length)}"`).join(', ') + - ' to pnpm.overrides.', - ); - } - const stale = [...overrides.keys()].filter((n) => !needed.has(n)).sort(); - if (stale.length) { - throw new PinRefusal( - `pnpm.overrides names ${stale.join(', ')}, which nothing objectui uses reaches at this objectstack commit. Delete ` + - `${stale.length === 1 ? 'that override' : 'those overrides'}: a declared override nothing resolves through reads as a dependency that is not there.`, - ); - } - return [...needed].sort(); -} - -/** - * Every file a manifest's entry fields point at: `main`, `module`, `types`, - * `typings`, and every string leaf of `exports` that is not a pattern. - * - * @param {Record} manifest - * @returns {string[]} - */ -export function entryTargets(manifest) { - const targets = new Set(); - for (const field of ['main', 'module', 'types', 'typings']) { - if (typeof manifest[field] === 'string') targets.add(manifest[field]); - } - const walk = (node) => { - if (typeof node === 'string') { - if (!node.includes('*')) targets.add(node); - } else if (node && typeof node === 'object') { - for (const value of Object.values(node)) walk(value); - } - }; - walk(manifest.exports); - return [...new Set([...targets].map((t) => t.replace(/^\.\//, '')))].sort(); -} - -/** - * @param {string} dir a packed package directory - * @returns {string[]} entry targets the directory does not contain - */ -export function missingEntryTargets(dir) { - const manifest = readJson(path.join(dir, 'package.json')); - return entryTargets(manifest).filter((t) => !fs.existsSync(path.join(dir, t))); -} - -/** - * The dependency names the lockfile records for each pinned-build package: the - * `dependencies` and `optionalDependencies` of every `snapshots:` entry keyed - * `'@objectstack/@file:…'`, unioned across peer contexts. - * - * @param {string} lockText - * @returns {Map>} - */ -export function lockfileDependencyNames(lockText) { - const recorded = new Map(); - const lines = lockText.split('\n'); - const start = lines.indexOf('snapshots:'); - if (start === -1) return recorded; - let current = null; - let inDeps = false; - for (let i = start + 1; i < lines.length; i++) { - const line = lines[i]; - if (/^\S/.test(line)) break; - const key = line.match(/^ {2}'?(@objectstack\/[a-z0-9-]+)@file:[^:]*?'?:(?:\s.*)?$/); - if (key) { - current = key[1]; - if (!recorded.has(current)) recorded.set(current, new Set()); - inDeps = false; - continue; - } - if (/^ {2}\S/.test(line)) { - current = null; - continue; - } - if (!current) continue; - const block = line.match(/^ {4}(\w+):\s*$/); - if (block) { - inDeps = block[1] === 'dependencies' || block[1] === 'optionalDependencies'; - continue; - } - const dep = inDeps && line.match(/^ {6}'?([^'\s:]+)'?:/); - if (dep) recorded.get(current).add(dep[1]); - } - return recorded; -} - -/** - * Packages whose packed manifest declares a dependency set the lockfile does not - * record. A declared peer may appear in the lockfile (pnpm installs peers); a - * runtime dependency the lockfile lacks, or one it records that nothing declares, - * is drift. - * - * @param {Map>} recorded - * @param {Map>} manifests package name -> packed manifest - * @returns {Array<{ name: string, missing: string[], extra: string[], absent: boolean }>} - */ -export function dependencyDrift(recorded, manifests) { - const drift = []; - for (const [name, manifest] of [...manifests].sort(([a], [b]) => (a < b ? -1 : 1))) { - const runtime = new Set([...Object.keys(manifest.dependencies ?? {}), ...Object.keys(manifest.optionalDependencies ?? {})]); - const peers = new Set(Object.keys(manifest.peerDependencies ?? {})); - const inLock = recorded.get(name); - if (!inLock) { - drift.push({ name, missing: [...runtime].sort(), extra: [], absent: true }); - continue; - } - const missing = [...runtime].filter((d) => !inLock.has(d)).sort(); - const extra = [...inLock].filter((d) => !runtime.has(d) && !peers.has(d)).sort(); - if (missing.length || extra.length) drift.push({ name, missing, extra, absent: false }); - } - return drift; -} - -/** - * Refuse when the cache and the lockfile disagree about dependencies. - * - * @param {string} root - * @param {Map} overrides - */ -export function assertLockfileRecordsBuild(root, overrides) { - const manifests = new Map([...overrides].map(([name, dir]) => [name, readJson(path.join(root, PIN_DIR, dir, 'package.json'))])); - const drift = dependencyDrift(lockfileDependencyNames(fs.readFileSync(path.join(root, 'pnpm-lock.yaml'), 'utf8')), manifests); - if (!drift.length) return; - const lines = drift.map((d) => - d.absent - ? ` ${d.name}: pnpm-lock.yaml has no pinned-build entry for it` - : ` ${d.name}:${d.missing.length ? ` declares ${d.missing.join(', ')}, which the lockfile does not record;` : ''}${d.extra.length ? ` the lockfile records ${d.extra.join(', ')}, which it no longer declares;` : ''}`, - ); - throw new PinRefusal( - `pnpm-lock.yaml does not record the dependencies the build in ${PIN_DIR} declares:\n${lines.join('\n')}\n` + - 'A frozen install would link this build WITHOUT them and exit 0 -- pnpm resolves a directory dependency from the lockfile alone. ' + - 'The build is in place: run `pnpm install` to rewrite the lockfile from it, and commit pnpm-lock.yaml.', - ); -} - -// ── the cache ─────────────────────────────────────────────────────────────── - -/** - * @param {string} root - * @returns {Record | null} - */ -export function readStamp(root) { - try { - return JSON.parse(fs.readFileSync(path.join(root, CACHE_DIR, STAMP_FILE), 'utf8')); - } catch { - return null; - } -} - -/** - * Whether the cache already holds the pin's build of exactly these packages. - * - * @param {Record | null} stamp - * @param {{ pin: string, overrides: Map, root?: string, cache?: string }} want `cache` defaults to root's - * @returns {{ current: boolean, why: string }} - */ -export function judgeStamp(stamp, { pin, overrides, root, cache = path.join(root, CACHE_DIR) }) { - if (!stamp) return { current: false, why: 'no build in the cache' }; - if (stamp.recipe !== RECIPE) return { current: false, why: `the cache was written by recipe ${stamp.recipe}, this script is recipe ${RECIPE}` }; - if (stamp.source !== 'pin') return { current: false, why: `the cache holds a stand-in from ${stamp.root ?? 'an external tree'} (${String(stamp.sha).slice(0, 12)}), not the pin` }; - if (stamp.sha !== pin) return { current: false, why: `the cache holds ${String(stamp.sha).slice(0, 12)}, the pin is ${pin.slice(0, 12)}` }; - const want = [...overrides.keys()].sort(); - const have = Object.keys(stamp.packages ?? {}).sort(); - if (want.join() !== have.join()) return { current: false, why: `the cache holds ${have.join(', ') || 'no package'}, the overrides name ${want.join(', ')}` }; - for (const [name, dir] of overrides) { - const manifest = path.join(cache, dir, 'package.json'); - if (!fs.existsSync(manifest) || readJson(manifest).name !== name) return { current: false, why: `${path.join(cache, dir)} does not hold ${name}` }; - } - return { current: true, why: `built at the pin ${pin.slice(0, 12)}` }; -} - -/** - * A content hash over every file of the packed directories, so a stand-in's stamp - * changes whenever its bytes do (turbo hashes the stamp; see turbo.json). - * - * @param {string} dir - * @returns {string} - */ -export function hashTree(dir) { - const hash = crypto.createHash('sha256'); - const walk = (rel) => { - const abs = path.join(dir, rel); - for (const entry of fs.readdirSync(abs, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) { - const child = path.posix.join(rel, entry.name); - if (entry.isDirectory()) walk(child); - else if (entry.isFile() && child !== STAMP_FILE) { - hash.update(`${child}\0`); - hash.update(fs.readFileSync(path.join(dir, child))); - hash.update('\0'); - } - } - }; - walk(''); - return `sha256-${hash.digest('hex')}`; -} - -// ── the committed manifests, and the build beside them (objectui#13008) ───── - -const sha256 = (bytes) => `sha256-${crypto.createHash('sha256').update(bytes).digest('hex')}`; -const DEPENDENCY_MAPS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies', 'peerDependenciesMeta']; - -/** - * A packed manifest as it is committed. `pnpm pack` rewrites `workspace:` ranges - * asynchronously, so two packs of ONE commit order a dependency map's keys - * differently with every value equal (measured on objectui#13008: client and lint - * differed between two builds of the pin). Those maps are sorted; nothing else - * moves -- the order of `exports` conditions carries meaning. - * - * @param {string | Buffer} text - * @returns {string | null} null when the text is not a JSON object - */ -export function canonicalManifest(text) { - let manifest; - try { - manifest = JSON.parse(String(text)); - } catch { - return null; - } - if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) return null; - for (const field of DEPENDENCY_MAPS) { - const map = manifest[field]; - if (map && typeof map === 'object') manifest[field] = Object.fromEntries(Object.entries(map).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))); - } - return `${JSON.stringify(manifest, null, 2)}\n`; -} - -/** Hard-link (copy where a link fails) every file under `from` into `to`, except a top-level `package.json`. */ -function linkTree(from, to, rel = '') { - for (const entry of fs.readdirSync(path.join(from, rel), { withFileTypes: true })) { - const child = path.join(rel, entry.name); - if (child === 'package.json') continue; - if (entry.isDirectory()) { - fs.mkdirSync(path.join(to, child), { recursive: true }); - linkTree(from, to, child); - } else if (entry.isFile()) { - try { - fs.linkSync(path.join(from, child), path.join(to, child)); - } catch { - fs.copyFileSync(path.join(from, child), path.join(to, child)); - } - } - } -} - -/** - * Put a packed build (`from`: one directory per package) beside the committed - * manifests in PIN_DIR. Every manifest is judged, in its canonical form, before - * anything is written: - * - * - `pin`: the packed manifest must equal the one on disk, or be the one a - * stand-in replaced (`replaced`, from its stamp); anything else refuses, and - * an equal manifest is left byte for byte; - * - `write` (`--write-manifests`) and `stand-in`: the packed manifest is written. - * - * @param {string} root - * @param {Map} overrides - * @param {{ from: string, mode: 'pin' | 'write' | 'stand-in', replaced?: Record | null }} opts - * @returns {Record} the hash of each manifest that was on disk before - */ -export function materialize(root, overrides, { from, mode, replaced = null }) { - const plan = [...overrides].map(([name, dir]) => { - const target = path.join(root, PIN_DIR, dir); - const manifest = path.join(target, 'package.json'); - const before = fs.existsSync(manifest) ? fs.readFileSync(manifest, 'utf8') : null; - const packed = canonicalManifest(fs.readFileSync(path.join(from, dir, 'package.json'))); - if (packed === null) throw new PinRefusal(`the packed manifest of ${name} in ${from} is not a JSON object.`); - const same = before !== null && canonicalManifest(before) === packed; - return { name, dir, target, before, packed, same, write: mode === 'pin' ? !same : before !== packed }; - }); - const stale = plan.filter((p) => !p.same && replaced?.[p.name] !== sha256(p.packed)); - if (mode === 'pin' && stale.length) { - throw new PinRefusal( - `the manifests committed in ${PIN_DIR} are not the ones the pin packs:\n` + - stale.map((p) => ` ${PIN_DIR}/${p.dir}/package.json ${p.before ? 'differs from' : 'is missing:'} the packed manifest of ${p.name}`).join('\n') + - '\nDependabot resolves the lockfile from these files, so they must be the pin\'s. scripts/bump-objectstack.sh writes them ' + - 'with the pin; `node scripts/objectstack-pin.mjs build --write-manifests` writes them for the pin as it stands. Commit them.', - ); - } - for (const p of plan) { - fs.mkdirSync(p.target, { recursive: true }); - for (const entry of fs.readdirSync(p.target)) { - if (entry !== 'package.json') fs.rmSync(path.join(p.target, entry), { recursive: true, force: true }); - } - linkTree(path.join(from, p.dir), p.target); - if (p.write) { - const tmp = path.join(p.target, '.package.json.tmp'); - fs.writeFileSync(tmp, p.packed); - fs.renameSync(tmp, path.join(p.target, 'package.json')); - } - } - return Object.fromEntries(plan.filter((p) => p.before !== null).map((p) => [p.name, sha256(canonicalManifest(p.before) ?? p.before)])); -} - -/** @returns {string[]} the packages whose directory in PIN_DIR holds the manifest but not the build */ -export function hollowPackages(root, overrides) { - return [...overrides].filter(([, dir]) => missingEntryTargets(path.join(root, PIN_DIR, dir)).length > 0).map(([name]) => name); -} - -/** - * The root `preinstall`. Without the build beside the manifests, `pnpm install` - * exits 0 and links packages holding nothing but a manifest; this refuses instead. - * A lockfile-only resolution (Dependabot's) does not run it. - */ -export function check({ root = REPO_ROOT } = {}) { - const hollow = hollowPackages(root, readOverrides(readJson(path.join(root, 'package.json')))); - if (hollow.length) { - throw new PinRefusal( - `${PIN_DIR} holds the committed manifests of ${hollow.join(', ')} but not the pinned build, so this install would ` + - 'link packages with no code in them. Run `pnpm run setup`: it builds the pin, then installs.', - ); - } -} - -// ── one build per machine (objectui#12972) ───────────────────────────────── - -/** - * @param {NodeJS.ProcessEnv} env - * @returns {string} the store - */ -export function storeDir(env) { - if (env[STORE_ENV]) return path.resolve(env[STORE_ENV]); - return path.join(env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'), 'objectstack-pin'); -} - -/** - * @param {string} pin - * @param {string} [script] whose bytes key the entry: this one - * @returns {string} `PIN-HASH` - */ -export function storeKey(pin, script = fileURLToPath(import.meta.url)) { - return `${pin}-${sha256(fs.readFileSync(script)).slice(7, 23)}`; -} - -/** - * Copy an entry into an empty `staging` and judge the COPY, so a prune or a write racing it shows. - * - * @param {string} entry - * @param {string} staging - * @param {{ pin: string, overrides: Map }} want - * @returns {string | null} why it was not taken (`staging` left empty), or null - */ -export function takeFromStore(entry, staging, { pin, overrides }) { - if (!fs.existsSync(entry)) return 'no entry for this pin and script'; - let why = null; - try { - fs.cpSync(entry, staging, { recursive: true }); - const { contentHash } = readJson(path.join(staging, ENTRY_FILE)); - fs.rmSync(path.join(staging, ENTRY_FILE)); - const verdict = judgeStamp(readJson(path.join(staging, STAMP_FILE)), { pin, overrides, cache: staging }); - if (!verdict.current) why = `the entry is not the pin: ${verdict.why}`; - else if (hashTree(staging) !== contentHash) why = "the entry's files are not the ones it was published with"; - else fs.utimesSync(entry, new Date(), new Date()); // "recently used", for the prune - } catch (error) { - why = `the entry could not be read: ${error}`; - } - if (why !== null) { - fs.rmSync(staging, { recursive: true, force: true }); - fs.mkdirSync(staging); - } - return why; -} - -/** - * Publish a cache this run built: copy, hash, rename -- a reader finds no entry or a whole one. - * - * @param {string} cache - * @param {string} entry - * @param {number} [keep] - * @returns {'published' | 'present'} `present`: another run's entry was there first - */ -export function publishToStore(cache, entry, keep = STORE_KEEP) { - if (fs.existsSync(entry)) return 'present'; - fs.mkdirSync(path.dirname(entry), { recursive: true }); - const tmp = fs.mkdtempSync(path.join(path.dirname(entry), '.tmp-')); - try { - fs.cpSync(cache, tmp, { recursive: true }); - fs.writeFileSync(path.join(tmp, ENTRY_FILE), `${JSON.stringify({ contentHash: hashTree(tmp) })}\n`); - fs.renameSync(tmp, entry); - } catch (error) { - if (fs.existsSync(entry)) return 'present'; - throw error; - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - pruneStore(path.dirname(entry), keep, path.basename(entry)); - return 'published'; -} - -/** - * Keep `current` and the `keep - 1` most recently used others, each removal renamed - * away first; a temporary directory an hour old is a dead run's. - * - * @param {string} store - * @param {number} keep - * @param {string} current - */ -export function pruneStore(store, keep, current) { - const dirs = fs.readdirSync(store, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name !== current); - const aged = dirs.map((e) => ({ name: e.name, mtime: fs.statSync(path.join(store, e.name)).mtimeMs })).sort((a, b) => b.mtime - a.mtime); - const doomed = [ - ...aged.filter((e) => !e.name.startsWith('.')).slice(Math.max(keep - 1, 0)), - ...aged.filter((e) => e.name.startsWith('.') && e.mtime < Date.now() - 3600_000), - ]; - for (const { name } of doomed) { - const gone = name.startsWith('.') ? name : `.gone-${name}-${process.pid}`; - try { - fs.renameSync(path.join(store, name), path.join(store, gone)); - fs.rmSync(path.join(store, gone), { recursive: true, force: true }); - } catch {} - } -} - -// ── running things ────────────────────────────────────────────────────────── - -/** - * Network settings an `npm_config_*` variable may carry from the machine itself - * (a proxy, a registry, a CA): kept, because the objectstack install needs them. - */ -const AMBIENT_NPM_CONFIG = /^npm_config_(https?_proxy|proxy|no_?proxy|registry|ca|cafile|strict_ssl|store_dir)$/i; - -/** - * The environment for every pnpm call made IN THE OBJECTSTACK TREE: this repo's - * `npm_config_*` settings removed, the machine's network ones kept. - * - * `pnpm run` exports the invoking workspace's `.npmrc` to the script as - * `npm_config_*`, and a nested pnpm reads them as its own config. Measured on - * objectui#12957: through `pnpm objectstack:build`, objectui's `engine-strict=true` - * reached the objectstack install and refused it (`ERR_PNPM_UNSUPPORTED_ENGINE`, - * a dev dependency of objectstack's asking a newer Node 22 patch), while the same - * command run as `node scripts/objectstack-pin.mjs build` installed. The - * objectstack tree is configured by its own `.npmrc`, not by this repository's. - * - * @param {NodeJS.ProcessEnv} env - * @returns {NodeJS.ProcessEnv} - */ -export function objectstackEnv(env) { - return Object.fromEntries( - Object.entries(env).filter(([key]) => !/^npm_config_/i.test(key) || AMBIENT_NPM_CONFIG.test(key)), - ); -} - -/** - * Run a command; a non-zero exit is a refusal naming it. - * - * With `logTo`, the command's output goes to that file instead of the terminal, - * and a failure carries the file's last lines in the refusal -- so the cause - * reaches the place a refusal is read (a CI annotation, see the CLI below) and - * not only a job log. - * - * @param {string} cmd - * @param {string[]} args - * @param {{ cwd: string, capture?: boolean, env?: NodeJS.ProcessEnv, logTo?: string }} opts - * @returns {string} captured stdout (empty unless `capture`) - */ -function run(cmd, args, { cwd, capture = false, env = process.env, logTo }) { - const fd = logTo ? fs.openSync(logTo, 'a') : null; - let result; - try { - result = spawnSync(cmd, args, { - cwd, - env, - encoding: 'utf8', - stdio: fd !== null ? ['ignore', fd, fd] : capture ? ['ignore', 'pipe', 'inherit'] : 'inherit', - maxBuffer: 256 * 1024 * 1024, - }); - } finally { - if (fd !== null) fs.closeSync(fd); - } - if (result.error) throw new PinRefusal(`could not run \`${cmd} ${args.join(' ')}\` in ${cwd}: ${result.error.message}`); - if (result.status !== 0) { - let said = capture && result.stdout ? `\n${result.stdout.trim().slice(-2000)}` : ''; - if (logTo) said = `\n--- its last output lines ---\n${tailLines(fs.readFileSync(logTo, 'utf8'), 40)}`; - throw new PinRefusal(`\`${cmd} ${args.join(' ')}\` exited ${result.status} in ${cwd}.${said}`); - } - return capture ? result.stdout : ''; -} - -/** - * @param {string} text - * @param {number} n - * @returns {string} - */ -export function tailLines(text, n) { - return text.trimEnd().split('\n').slice(-n).join('\n'); -} - -/** - * A refusal as a GitHub Actions error annotation: annotations are readable - * through the checks API, where a job's raw log may not be. - * - * @param {string} message - * @returns {string} - */ -export function annotation(message) { - const escaped = message.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'); - return `::error title=objectstack-pin::${escaped}`; -} - -function step(label, fn) { - const started = Date.now(); - console.log(`→ ${label}`); - const value = fn(); - console.log(` ${label}: ${((Date.now() - started) / 1000).toFixed(1)}s`); - return value; -} - -/** - * Fetch exactly the pinned commit into an empty directory. - * - * @param {string} dir - * @param {string} sha - * @param {string} url - * @param {string} logTo - */ -export function checkoutPin(dir, sha, url, logTo) { - run('git', ['init', '-q', dir], { cwd: os.tmpdir() }); - run('git', ['remote', 'add', 'origin', url], { cwd: dir }); - run('git', ['fetch', '-q', '--depth=1', '--no-tags', 'origin', sha], { cwd: dir, logTo }); - run('git', ['checkout', '-q', '--detach', 'FETCH_HEAD'], { cwd: dir }); - const head = run('git', ['rev-parse', 'HEAD'], { cwd: dir, capture: true }).trim(); - if (head !== sha) throw new PinRefusal(`fetched ${head} from ${url}, but the pin is ${sha}.`); - // HERMETIC: the remote goes once the commit is in. objectstack's spec build - // anchors its schema ratchets on `origin/main` and fetches it when it can, so - // with a remote the build of a fixed commit judged it against wherever main - // was THAT minute. Measured on objectui#12957: the 18.0.0-next.3 commit built - // fine against main as it was earlier and failed in 13 of 25 CI jobs once main - // had added two schemas (`❌ 2 schema(s) left the published set`). With no - // remote it anchors on the commit's own committed baseline, so the build is a - // function of the pin alone. - run('git', ['remote', 'remove', 'origin'], { cwd: dir }); -} - -/** - * `pnpm pack` one workspace package (scripts off, so nothing in the tree is - * regenerated) and unpack it into `dest`. - * - * @param {string} projectDir - * @param {string} tarballs - * @param {string} dest - * @param {NodeJS.ProcessEnv} env - * @returns {{ name: string, version: string }} - */ -function packInto(projectDir, tarballs, dest, env) { - const out = run('pnpm', ['pack', '--json', '--pack-destination', tarballs, '--config.ignore-scripts=true'], { cwd: projectDir, capture: true, env }); - const json = JSON.parse(out.slice(out.indexOf('{'))); - fs.mkdirSync(dest, { recursive: true }); - run('tar', ['-xzf', json.filename, '-C', dest, '--strip-components=1'], { cwd: projectDir }); - fs.rmSync(json.filename, { force: true }); - return { name: json.name, version: json.version }; -} - -/** - * @param {string} tree an objectstack workspace - * @returns {{ sha: string, dirty: boolean | null }} - */ -function describeTree(tree) { - const head = spawnSync('git', ['-C', tree, 'rev-parse', 'HEAD'], { encoding: 'utf8' }); - if (head.status !== 0) return { sha: 'unknown', dirty: null }; - const status = spawnSync('git', ['-C', tree, 'status', '--porcelain', '--untracked-files=no'], { encoding: 'utf8' }); - return { sha: head.stdout.trim(), dirty: status.status === 0 ? status.stdout.trim() !== '' : null }; -} - -/** - * Build (or stand in) and pack every overridden package into the cache, then put - * it beside the committed manifests. - * - * @param {{ root?: string, env?: NodeJS.ProcessEnv, writeManifests?: boolean }} [opts] - * @returns {{ skipped: boolean, reused: boolean, stamp: Record }} `reused`: copied from the store - */ -export function build({ root = REPO_ROOT, env = process.env, writeManifests = false } = {}) { - const manifest = readJson(path.join(root, 'package.json')); - const overrides = readOverrides(manifest); - const declared = declaredObjectstackNames(readWorkspace(root)); - const external = env.OBJECTSTACK_ROOT ? path.resolve(root, env.OBJECTSTACK_ROOT) : null; - const pin = readPin(root); - if (external && writeManifests) throw new PinRefusal('--write-manifests writes the PIN\'s manifests; it does not combine with OBJECTSTACK_ROOT.'); - const previous = readStamp(root); - // A stand-in's stamp names the committed manifests it replaced: what a build at the pin may restore. - const replaced = previous?.source === 'external' ? (previous.replaced ?? null) : null; - const mode = external ? 'stand-in' : writeManifests ? 'write' : 'pin'; - const settle = () => { - if (writeManifests) console.log(`✓ ${PIN_DIR}/*/package.json are the pin's packed manifests. Run \`pnpm install\`, and commit them with pnpm-lock.yaml.`); - else assertLockfileRecordsBuild(root, overrides); - }; - - // objectui#12972: the machine-wide entry for this pin and script; a stand-in has none. - const entry = external ? null : path.join(storeDir(env), storeKey(pin)); - if (!external) { - const verdict = judgeStamp(previous, { pin, overrides, root }); - if (verdict.current) { - console.log(`✓ ${CACHE_DIR} is ${verdict.why} -- nothing to build.`); - materialize(root, overrides, { from: path.join(root, CACHE_DIR), mode }); - settle(); - return { skipped: true, reused: false, stamp: previous }; - } - console.log(`→ ${CACHE_DIR}: ${verdict.why}.`); - } else { - console.log(`→ OBJECTSTACK_ROOT is set: packing @objectstack/* from ${external}, standing in for the pin ${pin.slice(0, 12)}.`); - } - - const cacheParent = path.dirname(path.join(root, CACHE_DIR)); - fs.mkdirSync(cacheParent, { recursive: true }); - const staging = fs.mkdtempSync(path.join(cacheParent, 'objectstack.staging-')); - const scratch = fs.mkdtempSync(path.join(scratchBase(env), 'ospin-')); - const target = path.join(root, CACHE_DIR); - try { - const why = entry && takeFromStore(entry, staging, { pin, overrides }); - if (entry && why === null) { - materialize(root, overrides, { from: staging, mode, replaced }); - fs.rmSync(target, { recursive: true, force: true }); - fs.renameSync(staging, target); - console.log(`✓ ${CACHE_DIR} now holds the build of ${pin.slice(0, 12)} copied from ${entry}, linked into ${PIN_DIR}. Run \`pnpm install\` so node_modules links it.`); - settle(); - return { skipped: false, reused: true, stamp: readStamp(root) }; - } - if (entry) console.log(`→ ${entry}: ${why}. Building @objectstack/* at ${pin}.`); - let tree = external; - if (!external) { - tree = path.join(scratch, 'src'); - step(`fetch objectstack ${pin.slice(0, 12)}`, () => checkoutPin(tree, pin, env.OBJECTSTACK_REPO_URL || DEFAULT_REPO_URL, path.join(scratch, 'fetch.log'))); - } - const projects = readWorkspace(tree); - /** @type {Map }>} */ - const byName = new Map(projects.filter((p) => p.manifest.name).map((p) => [p.manifest.name, p])); - const names = checkOverrideSet({ overrides, declared, byName }); - const filters = names.flatMap((n) => ['--filter-prod', `${n}...`]); - - if (!external) { - const rootName = projects[0].manifest.name; - if (!rootName) throw new PinRefusal(`the objectstack root package.json at ${pin.slice(0, 12)} has no name to select it by.`); - // The workspace root carries the build toolchain (tsup, tsx); the filters - // carry the packages and their runtime closure. - const treeEnv = objectstackEnv(env); - step('install the build closure', () => run('pnpm', ['install', '--frozen-lockfile', '--filter', rootName, ...filters], { cwd: tree, env: treeEnv, logTo: path.join(scratch, 'install.log') })); - step(`build ${names.join(', ')}`, () => run('pnpm', [...filters, 'run', 'build'], { cwd: tree, env: treeEnv, logTo: path.join(scratch, 'build.log') })); - } - - const tarballs = path.join(scratch, 'tarballs'); - fs.mkdirSync(tarballs, { recursive: true }); - const packages = {}; - step(`pack ${names.length} package(s)`, () => { - for (const name of names) { - const dest = path.join(staging, overrides.get(name)); - const packed = packInto(path.join(tree, byName.get(name).dir), tarballs, dest, objectstackEnv(env)); - if (packed.name !== name) throw new PinRefusal(`packing ${byName.get(name).dir} produced ${packed.name}, not ${name}.`); - const missing = missingEntryTargets(dest); - if (missing.length) { - throw new PinRefusal( - `${name} is not built in ${tree}: its manifest points at ${missing.join(', ')}, which ${missing.length === 1 ? 'is' : 'are'} not there.` + - (external - ? ` Build it in that tree first:\n (cd ${tree} && env -u OS_SKIP_DTS pnpm ${filters.join(' ')} run build)` + - // Under OS_SKIP_DTS the packages build with no declarations and this refusal repeats. - (env.OS_SKIP_DTS ? `\n OS_SKIP_DTS is set in this shell: the command above builds without it, so the declarations this check needs are emitted.` : '') - : ''), - ); - } - packages[name] = packed.version; - } - }); - - const before = materialize(root, overrides, { from: staging, mode, replaced }); - const stamp = external - ? { recipe: RECIPE, source: 'external', root: external, ...describeTree(external), packages, contentHash: hashTree(staging), replaced: replaced ?? before } - : { recipe: RECIPE, source: 'pin', sha: pin, packages }; - fs.writeFileSync(path.join(staging, STAMP_FILE), `${JSON.stringify(stamp, null, 2)}\n`); - fs.rmSync(target, { recursive: true, force: true }); - fs.renameSync(staging, target); - if (entry) { - try { - const published = publishToStore(target, entry); - console.log(` ${entry}: ${published === 'published' ? 'published for every checkout on this machine' : 'another run published it first'}`); - } catch (error) { - console.warn(`⚠ objectstack-pin: could not publish the build to ${entry}: ${error instanceof Error ? error.message : String(error)}`); - } - } - console.log( - `✓ ${CACHE_DIR} now holds ${names.join(', ')} ` + - (external ? `from ${external} (${String(stamp.sha).slice(0, 12)}${stamp.dirty ? ', with uncommitted changes' : ''})` : `built at ${pin.slice(0, 12)}`) + - `, linked into ${PIN_DIR}${external ? ' with its own manifests (the next build at the pin restores the committed ones)' : ''}. Run \`pnpm install\` so node_modules links it.`, - ); - settle(); - return { skipped: false, reused: false, stamp }; - } finally { - // Cleanup never speaks for the build: a removal that fails (a child process - // still writing in the tree) is a warning, not an exception that would replace - // the build's own outcome. - for (const dir of [staging, scratch]) { - try { - fs.rmSync(dir, { recursive: true, force: true, maxRetries: 3 }); - } catch (error) { - console.warn(`⚠ objectstack-pin: could not remove ${dir}: ${error instanceof Error ? error.message : String(error)}`); - } - } - } -} - -/** - * @param {{ root?: string, env?: NodeJS.ProcessEnv }} [opts] - * @returns {number} exit code - */ -export function status({ root = REPO_ROOT, env = process.env } = {}) { - const pin = readPin(root); - const overrides = readOverrides(readJson(path.join(root, 'package.json'))); - const stamp = readStamp(root); - const verdict = judgeStamp(stamp, { pin, overrides, root }); - const hollow = hollowPackages(root, overrides); - console.log(`pin ${pin} (${PIN_FILE})`); - console.log(`cache ${stamp ? `${stamp.source} ${stamp.sha}${stamp.root ? ` from ${stamp.root}` : ''}` : 'empty'} (${CACHE_DIR})`); - const entry = path.join(storeDir(env), storeKey(pin)); - console.log(`store ${entry} ${fs.existsSync(entry) ? 'holds this pin and script' : 'is absent'} (${STORE_ENV})`); - console.log(`${verdict.current ? '✓' : '✗'} ${verdict.why}${verdict.current ? '' : ' -- run `pnpm run setup`'}`); - if (hollow.length) console.log(`✗ ${PIN_DIR} holds no build for ${hollow.join(', ')} -- run \`pnpm run setup\``); - return verdict.current && !hollow.length ? 0 : 1; -} - -// ── helpers ───────────────────────────────────────────────────────────────── - -/** - * Where the pinned commit is checked out and built. SHORT on purpose: the spec's - * build runs `tsx`, which listens on a unix socket under the package's - * `node_modules/.tmp/`, and a socket path has a hard length limit (104 bytes on - * macOS, 108 on Linux). Measured on objectui#12957: an install under a long - * scratch directory died in the spec's `prepare` with `listen EADDRINUSE` on that - * socket path. macOS's `os.tmpdir()` (`/var/folders/…/T/`) alone spends about half - * the budget, so `/tmp` is preferred wherever it exists; `OBJECTSTACK_BUILD_TMP` - * overrides both. - * - * @param {NodeJS.ProcessEnv} env - * @returns {string} - */ -export function scratchBase(env) { - if (env.OBJECTSTACK_BUILD_TMP) return env.OBJECTSTACK_BUILD_TMP; - if (process.platform !== 'win32' && fs.existsSync('/tmp')) return '/tmp'; - return os.tmpdir(); -} - -function readJson(file) { - return JSON.parse(fs.readFileSync(file, 'utf8')); -} - -function safeReaddir(dir) { - try { - return fs - .readdirSync(dir, { withFileTypes: true }) - .filter((e) => e.isDirectory()) - .map((e) => e.name) - .sort(); - } catch { - return []; - } -} - -// ── CLI ───────────────────────────────────────────────────────────────────── - -if (isEntrypoint(import.meta.url)) { - const [command, ...flags] = process.argv.slice(2); - try { - if (command === 'build' && flags.every((f) => f === '--write-manifests')) { - build({ writeManifests: flags.length > 0 }); - process.exit(0); - } else if (command === 'status' && !flags.length) { - process.exit(status()); - } else if (command === 'check' && !flags.length) { - check(); - process.exit(0); - } else { - console.error('usage: node scripts/objectstack-pin.mjs build [--write-manifests] | status | check'); - process.exit(2); - } - } catch (error) { - // Every failure, a refusal or a crash, is said once on stderr and -- under - // GitHub Actions -- once as an annotation, written synchronously before exit. - const message = - error instanceof PinRefusal - ? error.message - : `crashed: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`; - fs.writeSync(2, `✗ objectstack-pin: ${message}\n`); - if (process.env.GITHUB_ACTIONS === 'true') fs.writeSync(2, `${annotation(message)}\n`); - process.exit(1); - } -} diff --git a/ui/scripts/release-github-releases.mjs b/ui/scripts/release-github-releases.mjs deleted file mode 100644 index 083b5ea3813..00000000000 --- a/ui/scripts/release-github-releases.mjs +++ /dev/null @@ -1,1939 +0,0 @@ -#!/usr/bin/env node -// ported from objectstack@7d0781482dbb6502aa33c29bec96ca03636f7df9 scripts/release-github-releases.mjs -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -/** - * release-github-releases.mjs — create (or update) one GitHub Release per - * published package, with a body that CANNOT exceed the Releases API's - * 125,000-character limit. - * - * ## Why this exists in this repository (objectui#11596) - * - * `.github/workflows/changeset-release.yml` publishes through - * `changesets/action@v1`, and v1 learns WHAT was published by parsing the - * publish script's stdout for `New tag: @` lines. Only for the - * packages it finds there does it push the git tag (`git.pushTag`) and create - * the GitHub Release (`runPublish` in the action's `src/run.ts`, v1.9.0). - * - * objectui#5296 moved this repository to `@changesets/cli` v3, whose - * `changeset publish` reports through `@clack/prompts` and an output report - * instead, and prints no `New tag` line at all. v1 therefore saw zero - * published packages, pushed no tag and created no Release — and failed - * nothing, because the publish lane's npm check reads npm only. 17.6.0 and - * 17.7.0 reached npm with neither. - * - * `changeset publish` still CREATES the tags on the runner; the workflow's - * "Push the release tags" step pushes them (using `--print-tags` below for the - * set it owes), and its "Create GitHub Releases" step runs this script. Neither - * depends on the action's stdout parse any more. - * - * ## Why the body limit matters here too (objectstack#4900) - * - * The action posted each package's raw CHANGELOG section as the Release body, - * and the Releases API rejects a body over 125,000 characters: - * - * HttpError: Validation Failed: - * {"resource":"Release","code":"custom","field":"body", - * "message":"body is too long (maximum is 125000 characters)"} - * - * objectstack hit that first, which is why it wrote this script. This - * repository hit it too, unnoticed: measured when this port was written, - * `@object-ui/app-shell`'s 17.5.0 section was 158,203 characters, and - * `@object-ui/app-shell@17.5.0` has a tag but no Release while its 38 - * siblings have both. The 17.7.0 sections are larger still — - * `packages/types/CHANGELOG.md`'s is past a million characters. ⛔ Those are - * one day's readings, not live figures: the self-test below re-measures the - * types section on every run and prints it. - * - * ## Contract - * - * Faithful to what `changesets/action` produced, minus the failure: same tag - * (`@`), same release name, same `prerelease` rule, and the same - * changelog-entry body — extracted with a direct port of the action's own - * `getChangelogEntry`, so an under-limit body is byte-identical to what the - * action would have posted. - * - * Beyond that it adds the properties the action's version lacked: - * - * - **Bounded.** A body over the limit is truncated at a line boundary, with - * an unbalanced code fence closed so the notice renders as markdown, and a - * link to the full entry in CHANGELOG.md at this exact commit. - * - **Idempotent.** Looks the release up by tag first: PATCH when it exists, - * POST when it does not. A re-run is a no-op-shaped update, never an - * `already_exists` 422 — which matters because a partial failure is the - * normal state to recover from, and because the backfill of 17.6.0 and - * 17.7.0 runs this same script by hand. - * - **Safe under a concurrent writer.** The read and the POST are two - * requests, and nothing excludes a second writer of one version's - * Releases: a re-run of the workflow, a hand-run backfill, or the - * action's own Release creation should its stdout parse ever find a - * package again (it is dormant under CLI v3, not removed). objectstack - * measured the race on its 17.6.0: nine POSTs answered - * `422 {"code":"already_exists","field":"tag_name"}` because the other - * writer created the release between the read and the POST. That 422 now - * means what it says — the release exists — so the script re-reads it by - * tag and PATCHes it, and two writers converge on one release per tag. Any - * OTHER 422 (the body limit above) still fails the release exactly as - * before. - * - **Duplicates are reported, never deleted.** The same race proved the API - * will ACCEPT two creates for one tag inside one second. No request - * sequence on this side can prevent that, so after its writes the script - * reads the Releases list and names every tag it released that has more - * than one. A duplicate this run CREATED one of fails the run - * (`::error::`) — the race was this run's, and without the failure the - * convergence above would turn a red race into a green run with a - * duplicate in it. A duplicate that predates this run's writes is a - * `::warning::`: it was the creating run's to fail. Either way the report - * names each Release id, its assets and the id the by-tag endpoint - * resolves to. Deleting a Release is a release act and the maintainer's - * call — this script never sends a DELETE. - * - **Per-package isolation.** The action ran the whole set through one - * `Promise.all`, so the first rejection abandoned the rest. This runs them - * sequentially, collects failures, and still exits non-zero — one - * package's oversized changelog can no longer cost its siblings their - * releases. - * - * Sequential is also deliberate for the writes: objectstack#2191 is the - * standing lesson that bursts of concurrent ref-creating requests race - * GitHub's backend. - * - * ⚠️ A POST for a tag that is NOT on the remote makes the API create it — a - * lightweight tag at `target_commitish` (this run's `GITHUB_SHA`). That is why - * the workflow pushes the annotated tags `changeset publish` created BEFORE - * this script runs, and why a run that could not push them still gets its - * Releases on the commit it published from, rather than none. - * - * Run: - * node scripts/release-github-releases.mjs # from changeset-release.yml - * node scripts/release-github-releases.mjs --self-test # verify the logic - * node scripts/release-github-releases.mjs --dry-run # plan + sizes only, no API call - * node scripts/release-github-releases.mjs --print-tags # the `@` tags owed, one per line - * - * Env: - * RELEASE_VERSION the version the publish lane shipped. Every publishable - * workspace package is released at this version (the - * Changesets `fixed` group bumps in lockstep). This is the - * input changeset-release.yml passes. - * PUBLISHED a changesets/action `publishedPackages` JSON; takes - * precedence when non-empty. Kept from the port, and NOT - * passed by this repository's workflow: under CLI v3 the - * action's parse reports `[]`, which is this card's defect. - * GITHUB_TOKEN repo-scoped token with `contents: write`. - * GITHUB_REPOSITORY / GITHUB_SHA / GITHUB_API_URL / GITHUB_SERVER_URL - * standard Actions context. A hand-run backfill sets - * GITHUB_SHA to the commit the version was PUBLISHED from. - */ - -import { execFileSync } from 'node:child_process'; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join, relative, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { isEntrypoint } from './invoked-as.mjs'; -import { workspacePackageDirs } from './check-side-effects-array.mjs'; - -// ── The self-test's own battery roster and floor (objectstack#13489) ─────── -// -// `failures.length === 0` used to be this self-test's ONLY success condition, so -// "every case held" and "the cases never ran" printed the same line. What is -// pinned is the registered NAMES, not a number. Every section opens with -// `battery('')`, every assertion is attributed to the battery most -// recently opened, and the floor requires the OPENED set to equal the DECLARED -// set with each battery at or above its own count. -// -// ⛔ A pinned TOTAL is not the repair: a battery dropping from 9 cases to 3 -// keeps a total "right" the moment a sibling grows. -// -// The counts are a FLOOR, not an equality — adding cases is ordinary work and -// must not red. A battery BELOW its floor means cases stopped running; the -// remedy is to find what stopped registering. -const SELF_TEST_BATTERIES = Object.freeze({ - '1. The real oversized entry: types\' 17.7.0 section': 9, - '2. A short entry is passed through untouched': 3, - '3. Fence balancing when the cut lands inside a code block': 3, - '4. Surrogate pairs are never split': 3, - '5. Anchors and fence-aware heading parsing': 7, - '6. Target resolution: both producers, and neither': 9, - '7. Planning is per package, and a missing entry is loud': 6, - '8. Every package gets a release; existing ones are updated, not retried ─': 7, - '9. Idempotent re-run': 2, - '10. One package\'s failure does not abandon the others': 3, - '11. A racing writer\'s create converges instead of failing (422 already_exists)': 8, - '12. Two concurrent invocations leave exactly one release per tag': 6, - '13. Any other 422 still fails; a racer the read cannot see fails loudly, bounded': 8, - '14. Duplicate releases for one tag are reported, never deleted': 14, - '15. The tags the push step owes are exactly the release set': 5, -}); - -// DELETING an entry silences that battery's floor exactly as effectively as -// zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 15; - -// The key an assertion is filed under when no battery is open. It is not a -// declared battery, so it reds by the same set difference rather than silently -// inflating whichever battery happened to run last. -const UNATTRIBUTED_BATTERY = '(no battery open)'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = resolve(__dirname, '..'); - -/** - * The GitHub Releases API's documented maximum body length, as quoted verbatim - * in the 422 that objectstack#4900 is about: "body is too long (maximum is 125000 - * characters)". - */ -export const BODY_LIMIT = 125_000; - -/** - * Headroom held back from the limit. Absorbs the closing fence `balanceFences` - * may append and any accounting slip; the caller asserts the final body against - * the real limit regardless, so this only decides how far under we land. - */ -const SAFETY_MARGIN = 1_024; - -/** - * How many characters a string costs against the limit. - * - * GitHub counts CHARACTERS, not bytes — objectstack's failing spec section was - * 342,910 characters but 359,636 UTF-8 bytes, and the API quoted the former. We measure - * with JS `.length` (UTF-16 code units), which is >= the code-point count for - * every string and equal for everything outside the astral planes. So it can - * only ever over-estimate the cost, never under-estimate it, whichever of the - * two "character" definitions the API applies. - * - * @param {string} text - * @returns {number} - */ -export function measure(text) { - return text.length; -} - -/** - * Extract the changelog section for `version`. - * - * Direct port of `getChangelogEntry` from changesets/action's `src/utils.ts` - * (v1), including its code-fence skipping — a changeset body can legitimately - * contain `##` headings, and this repo's do (`## FROM → TO` inside migration - * notes). Ported rather than imported: the action is a bundled GitHub Action, - * not an npm dependency of this repo. - * - * @param {string} changelog full CHANGELOG.md text - * @param {string} version exact version string, e.g. `17.0.0-rc.2` - * @returns {string | null} the section content (trimmed), or null when absent - */ -export function getChangelogEntry(changelog, version) { - /** @type {{ index: number; depth: number } | undefined} */ - let headingStartInfo; - /** @type {number | undefined} */ - let endIndex; - - const regex = /^(#{1,6})\s(.*)$|^(`{3,})/gm; - /** @type {RegExpExecArray | null} */ - let match; - while ((match = regex.exec(changelog)) != null) { - // Skip over code blocks so headings inside them never match. - if (match[3]) { - const endOfCodeBlockRegex = new RegExp(`^${match[3]}`, 'gm'); - endOfCodeBlockRegex.lastIndex = regex.lastIndex; - const endMatch = endOfCodeBlockRegex.exec(changelog); - if (endMatch) { - regex.lastIndex = endOfCodeBlockRegex.lastIndex; - continue; - } - break; // unterminated fence — malformed changelog - } - - const headingDepth = match[1].length; - const headingText = match[2].trim(); - - if (headingText === version) { - headingStartInfo = { index: regex.lastIndex, depth: headingDepth }; - continue; - } - - if (headingStartInfo && headingDepth === headingStartInfo.depth) { - endIndex = match.index; - break; - } - } - - if (!headingStartInfo) return null; - return changelog.slice(headingStartInfo.index, endIndex).trim(); -} - -/** - * GitHub's heading slug for an in-file anchor: lowercase, drop punctuation, - * spaces to hyphens. `17.0.0-rc.2` -> `1700-rc2`. - * - * Cross-checked against `github-slugger` (the implementation GitHub's own - * renderer uses) over the version shapes this can see — `17.0.0-rc.2`, - * `16.1.0`, `17.0.0`, `1.2.3-beta.10` — which all agree. Kept as four lines - * rather than a dependency: the input domain here is semver strings, not - * arbitrary heading text. - * - * @param {string} text - * @returns {string} - */ -export function headingAnchor(text) { - return text - .trim() - .toLowerCase() - .replace(/[^\w\- ]+/g, '') - .replace(/ /g, '-'); -} - -/** - * Slice without ever splitting a surrogate pair — a lone surrogate is not valid - * UTF-8 and would be mangled or rejected on the way to the API. - * - * @param {string} text - * @param {number} max in UTF-16 code units - * @returns {string} - */ -export function sliceSafely(text, max) { - if (text.length <= max) return text; - let end = Math.max(0, max); - const last = text.charCodeAt(end - 1); - if (last >= 0xd800 && last <= 0xdbff) end -= 1; // high surrogate: drop it - return text.slice(0, end); -} - -/** - * Close a code fence left open by truncation. Without this the notice and the - * CHANGELOG link render INSIDE the code block — i.e. the one thing truncation - * owes the reader is exactly what gets swallowed. - * - * @param {string} text - * @returns {string} - */ -export function balanceFences(text) { - /** @type {string | null} */ - let open = null; - for (const line of text.split('\n')) { - const m = /^ {0,3}(`{3,}|~{3,})/.exec(line); - if (!m) continue; - const marker = m[1]; - if (open === null) open = marker; - else if (marker[0] === open[0] && marker.length >= open.length) open = null; - } - return open === null ? text : `${text}\n${open}`; -} - -/** - * Build the Release body for one package: the changelog entry when it fits, a - * truncated entry framed by a notice and a link to the complete one when it - * does not. - * - * @param {object} opts - * @param {string} opts.entry changelog section content - * @param {string} opts.tagName e.g. `@object-ui/types@17.7.0` - * @param {string} opts.changelogLabel repo-relative path shown to the reader - * @param {string} opts.changelogHref link to the full entry - * @param {number} [opts.limit] - * @returns {{ body: string; truncated: boolean; originalLength: number }} - */ -export function buildReleaseBody({ entry, tagName, changelogLabel, changelogHref, limit = BODY_LIMIT }) { - const originalLength = measure(entry); - if (originalLength <= limit) { - return { body: entry, truncated: false, originalLength }; - } - - const n = (v) => v.toLocaleString('en-US'); - const link = `[\`${changelogLabel}\`](${changelogHref})`; - - const notice = - [ - '> [!IMPORTANT]', - `> **This release note is truncated.** The changelog entry for \`${tagName}\` is`, - `> ${n(originalLength)} characters; the GitHub Releases API rejects any body over`, - `> ${n(limit)}. The complete entry is in ${link}.`, - '', - '---', - '', - ].join('\n') + '\n'; - - const footer = `\n\n---\n\n**Truncated here.** The rest of this entry is in ${link}.\n`; - - const budget = limit - measure(notice) - measure(footer) - SAFETY_MARGIN; - if (budget <= 0) { - throw new Error(`release body limit ${limit} is too small to hold even the truncation notice`); - } - - // Cut on a line boundary so markdown structures stay whole where possible. - /** @type {string[]} */ - const kept = []; - let used = 0; - for (const line of entry.split('\n')) { - const cost = kept.length === 0 ? measure(line) : measure(line) + 1; - if (used + cost > budget) break; - kept.push(line); - used += cost; - } - - // A single line longer than the whole budget still has to be cut somewhere. - let head = kept.length > 0 ? kept.join('\n') : sliceSafely(entry, budget); - head = balanceFences(head.replace(/\s+$/, '')); - - const body = notice + head + footer; - if (measure(body) > limit) { - throw new Error(`truncation produced ${measure(body)} characters, over the ${limit} limit`); - } - return { body, truncated: true, originalLength }; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Workspace discovery -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Every non-private workspace package, by name. - * - * Membership comes from `workspacePackageDirs` in - * `scripts/check-side-effects-array.mjs` — an existing parse of - * `pnpm-workspace.yaml` that THROWS on a glob shape it does not understand, - * rather than a new private copy of that parse. The objectstack original reads - * objectstack's own shared enumerator, which this repository does not have. - * - * `private: true` is the same line `changeset publish` draws: a private - * package is never published, and `.changeset/config.json` sets - * `privatePackages.tag: false`, so it never gets a tag either. Here that is - * `object-ui` (`packages/vscode-extension`), a member of the `fixed` group that - * is versioned with it but released nowhere. - * - * @param {string} [root] - * @returns {Map} - */ -export function listWorkspacePackages(root = REPO_ROOT) { - /** @type {Map} */ - const byName = new Map(); - for (const dir of workspacePackageDirs(root)) { - let manifest; - try { - manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); - } catch { - continue; // a workspace glob also matches directories that are not packages - } - if (!manifest.name || manifest.private === true) continue; - if (byName.has(manifest.name)) continue; - byName.set(manifest.name, { name: manifest.name, version: manifest.version, dir }); - } - return byName; -} - -/** - * The tag `changeset publish` creates for one package, and the name its - * Release takes: `@`. - * - * @param {{ name: string; version: string }} target - * @returns {string} - */ -export function tagNameOf(target) { - return `${target.name}@${target.version}`; -} - -/** - * Decide which `@` releases this run owes. - * - * `PUBLISHED` (the action's own `publishedPackages`) is authoritative when - * present. `RELEASE_VERSION` is the input this repository's workflow passes, - * because the action's `publishedPackages` is exactly what CLI v3 emptied (see - * the header): the release set is the whole publishable workspace at one - * version — correct here precisely because the Changesets `fixed` group bumps - * every public package in lockstep (`scripts/check-changeset-fixed.mjs` is the - * gate that keeps it true). - * - * @param {object} opts - * @param {string} [opts.publishedJson] - * @param {string} [opts.releaseVersion] - * @param {Map} opts.packages - * @returns {{ name: string; version: string; dir: string }[]} - */ -export function resolveReleaseTargets({ publishedJson, releaseVersion, packages }) { - const trimmed = (publishedJson ?? '').trim(); - if (trimmed && trimmed !== '[]') { - /** @type {{ name: string; version: string }[]} */ - let parsed; - try { - parsed = JSON.parse(trimmed); - } catch (err) { - throw new Error(`PUBLISHED is not valid JSON: ${err instanceof Error ? err.message : err}`); - } - if (!Array.isArray(parsed) || parsed.length === 0) { - throw new Error('PUBLISHED parsed to an empty or non-array value'); - } - return parsed.map(({ name, version }) => { - const pkg = packages.get(name); - if (!pkg) throw new Error(`published package "${name}" is not in the workspace`); - return { name, version, dir: pkg.dir }; - }); - } - - if (releaseVersion) { - return [...packages.values()].map((pkg) => ({ ...pkg, version: releaseVersion })); - } - - return []; -} - -/** - * Assemble the Release payload for one target. Returns null (with a reason) - * when the package ships no CHANGELOG.md — changesets/action skips those too. - * - * @param {object} opts - * @param {{ name: string; version: string; dir: string }} opts.target - * @param {string} opts.serverUrl - * @param {string} opts.repository `owner/repo` - * @param {string} opts.ref commit-ish for the CHANGELOG permalink - * @param {string} [opts.root] - * @returns {{ tagName: string; body: string; prerelease: boolean; truncated: boolean; originalLength: number } | { skipped: string }} - */ -export function planRelease({ target, serverUrl, repository, ref, root = REPO_ROOT }) { - const tagName = tagNameOf(target); - const changelogPath = join(target.dir, 'CHANGELOG.md'); - let changelog; - try { - changelog = readFileSync(changelogPath, 'utf8'); - } catch { - return { skipped: `${target.name} ships no CHANGELOG.md` }; - } - - const entry = getChangelogEntry(changelog, target.version); - if (entry === null) { - throw new Error(`no changelog entry for ${tagName} in ${relative(root, changelogPath)}`); - } - - const changelogLabel = relative(root, changelogPath).split('\\').join('/'); - const changelogHref = `${serverUrl}/${repository}/blob/${ref}/${changelogLabel}#${headingAnchor(target.version)}`; - - const { body, truncated, originalLength } = buildReleaseBody({ - entry, - tagName, - changelogLabel, - changelogHref, - }); - - return { - tagName, - body, - // Same rule as changesets/action: any version carrying a prerelease tag. - prerelease: target.version.includes('-'), - truncated, - originalLength, - }; -} - -// ───────────────────────────────────────────────────────────────────────────── -// GitHub API -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Thrown by `create` when the POST answered `422` because a Release for the tag - * already exists — `{"code":"already_exists","field":"tag_name"}` in the - * response's `errors[]`, exactly as objectstack's 17.6.0 runs logged it. Nothing else - * raises it: a 422 for any other field or code stays a plain failure. - */ -export class ReleaseAlreadyExistsError extends Error { - /** - * @param {string} tagName - * @param {string} detail the API's answer, for the log - */ - constructor(tagName, detail) { - super(`POST release ${tagName} failed: ${detail}`); - this.name = 'ReleaseAlreadyExistsError'; - this.tagName = tagName; - } -} - -/** - * Whether a failed POST's answer is the Releases API saying the tag already - * has a Release. Judged on the structured `errors[]` entry, never on prose. - * - * @param {number} status - * @param {string} text the response body - * @returns {boolean} - */ -export function isTagAlreadyExists(status, text) { - if (status !== 422) return false; - let parsed; - try { - parsed = JSON.parse(text); - } catch { - return false; - } - const errors = Array.isArray(parsed?.errors) ? parsed.errors : []; - return errors.some((e) => e && e.code === 'already_exists' && e.field === 'tag_name'); -} - -/** - * Thin Releases client. `fetchImpl` is injected so the self-test drives the - * real request/response handling without a network. - * - * @param {object} opts - * @param {string} opts.apiUrl - * @param {string} opts.repository `owner/repo` - * @param {string} opts.token - * @param {typeof fetch} [opts.fetchImpl] - */ -export function createReleasesClient({ apiUrl, repository, token, fetchImpl = fetch }) { - const base = `${apiUrl}/repos/${repository}/releases`; - const headers = { - accept: 'application/vnd.github+json', - authorization: `Bearer ${token}`, - 'content-type': 'application/json', - 'x-github-api-version': '2022-11-28', - }; - - const readText = async (res) => { - try { - return await res.text(); - } catch { - return ''; /* body already consumed or unreadable */ - } - }; - const describe = (res, text) => `${res.status} ${res.statusText || ''} ${text.slice(0, 500)}`.trim(); - const readError = async (res) => describe(res, await readText(res)); - - return { - /** - * Look a release up by tag. The tag contains `/` and `@`; both must be - * percent-encoded to survive the path segment (verified against the live - * API: `%40object-ui%2Fcore%4017.5.0` resolves). - * - * @param {string} tagName - * @returns {Promise<{ id: number } | null>} - */ - async findByTag(tagName) { - const res = await fetchImpl(`${base}/tags/${encodeURIComponent(tagName)}`, { headers }); - if (res.status === 404) return null; - if (!res.ok) throw new Error(`GET release by tag ${tagName} failed: ${await readError(res)}`); - return await res.json(); - }, - - /** - * @param {{ tagName: string; body: string; prerelease: boolean; targetCommitish: string }} rel - * @throws {ReleaseAlreadyExistsError} when the tag already has a Release - */ - async create({ tagName, body, prerelease, targetCommitish }) { - const res = await fetchImpl(base, { - method: 'POST', - headers, - body: JSON.stringify({ - tag_name: tagName, - name: tagName, - body, - prerelease, - target_commitish: targetCommitish, - }), - }); - if (!res.ok) { - const text = await readText(res); - if (isTagAlreadyExists(res.status, text)) throw new ReleaseAlreadyExistsError(tagName, describe(res, text)); - throw new Error(`POST release ${tagName} failed: ${describe(res, text)}`); - } - return await res.json(); - }, - - /** - * One page of the repository's Releases, newest first. The list orders by - * `created_at`, which for a Release on a pushed tag is the TAG's date — - * measured on objectstack's 17.6.0: all 74 Release objects carry 02:49:42Z, the tagger - * date `changeset publish` wrote, while their `published_at` spans two - * minutes — so one version's Releases sit in one contiguous run of the - * list, duplicates beside their twins. - * - * @param {{ page: number; perPage: number }} opts - * @returns {Promise<{ id: number; tag_name: string; assets?: { name: string }[] }[]>} - */ - async listPage({ page, perPage }) { - const res = await fetchImpl(`${base}?per_page=${perPage}&page=${page}`, { headers }); - if (!res.ok) throw new Error(`GET releases page ${page} failed: ${await readError(res)}`); - return await res.json(); - }, - - /** - * @param {{ id: number; tagName: string; body: string; prerelease: boolean }} rel - */ - async update({ id, tagName, body, prerelease }) { - const res = await fetchImpl(`${base}/${id}`, { - method: 'PATCH', - headers, - body: JSON.stringify({ name: tagName, body, prerelease }), - }); - if (!res.ok) throw new Error(`PATCH release ${tagName} failed: ${await readError(res)}`); - return await res.json(); - }, - }; -} - -/** - * After a `422 already_exists`, how long to wait before each by-tag re-read. - * The 422 proves the other writer's Release is in the store; the first read is - * immediate, and the two later ones absorb a read path that has not caught up - * with that write yet. Bounded: a Release the by-tag read still cannot see - * after the last one fails that package loudly rather than retrying forever. - */ -export const RACE_REREAD_DELAYS_MS = Object.freeze([0, 1_000, 3_000]); - -/** - * Create or update every release in `plans`, sequentially, isolating failures. - * - * A POST that answers `422 already_exists` lost a race with a concurrent - * writer (see the header): the Release it meant to create now exists, so the - * package converges by re-reading it by tag and PATCHing it, and is counted - * as updated (and in `converged`), not failed. - * - * @param {object} opts - * @param {ReturnType} opts.client - * @param {{ tagName: string; body: string; prerelease: boolean; truncated: boolean; originalLength: number }[]} opts.plans - * @param {string} opts.targetCommitish - * @param {(msg: string) => void} [opts.log] - * @param {(ms: number) => Promise} [opts.sleep] injected so the self-test does not wait - * @param {readonly number[]} [opts.rereadDelaysMs] - * @returns {Promise<{ created: string[]; updated: string[]; converged: string[]; createdIds: number[]; failed: { tagName: string; error: string }[] }>} - */ -export async function publishReleases({ - client, - plans, - targetCommitish, - log = console.log, - sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms)), - rereadDelaysMs = RACE_REREAD_DELAYS_MS, -}) { - const created = []; - const updated = []; - const converged = []; - /** @type {number[]} */ - const createdIds = []; - const failed = []; - - for (const plan of plans) { - const size = plan.truncated - ? `truncated ${plan.originalLength} -> ${measure(plan.body)} chars` - : `${measure(plan.body)} chars`; - try { - const existing = await client.findByTag(plan.tagName); - if (existing) { - await client.update({ id: existing.id, ...plan }); - updated.push(plan.tagName); - log(`updated ${plan.tagName} (${size})`); - continue; - } - let release; - try { - release = await client.create({ ...plan, targetCommitish }); - } catch (err) { - if (!(err instanceof ReleaseAlreadyExistsError)) throw err; - // Another writer created this tag's Release between the read above and - // this POST. It exists, so this package's job is now an update of it. - let racing = null; - for (const delay of rereadDelaysMs) { - if (delay > 0) await sleep(delay); - racing = await client.findByTag(plan.tagName); - if (racing) break; - } - if (!racing) { - throw new Error( - `${err.message} — the tag already has a Release, but ${rereadDelaysMs.length} by-tag ` + - 're-read(s) still answered 404, so there is nothing this run can update.', - ); - } - await client.update({ id: racing.id, ...plan }); - updated.push(plan.tagName); - converged.push(plan.tagName); - log( - `updated ${plan.tagName} (${size}) — a concurrent writer created release ${racing.id} between ` + - "this run's read and its POST (422 already_exists); converged onto it", - ); - continue; - } - created.push(plan.tagName); - if (release && typeof release.id === 'number') createdIds.push(release.id); - log(`created ${plan.tagName} (${size})`); - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - failed.push({ tagName: plan.tagName, error }); - log(`::error::GitHub Release for ${plan.tagName} failed: ${error}`); - } - } - - return { created, updated, converged, createdIds, failed }; -} - -/** Releases per list page the duplicate audit asks for — the API's maximum. */ -export const AUDIT_PER_PAGE = 100; - -/** - * How many list pages the duplicate audit reads at most: a thousand Releases, - * about fourteen versions of 69 packages back from the newest. - */ -export const AUDIT_MAX_PAGES = 10; - -/** - * Find every tag in `tagNames` that carries more than one Release object. - * - * Reads the Releases list newest first. One version's Releases are one - * contiguous run of that list (see `listPage`), so the walk stops at the first - * page holding none of `tagNames` after one that did, or at the end of the - * list, and is `complete` only when it stopped for one of those two reasons. - * Every tag it never saw is returned in `unseen`: the audit cannot vouch for - * those. Read-only — it never deletes, and nothing in this file does. - * - * @param {object} opts - * @param {ReturnType} opts.client - * @param {string[]} opts.tagNames - * @param {number[]} [opts.createdIds] ids this run's own POSTs created - * @param {number} [opts.perPage] - * @param {number} [opts.maxPages] - * @returns {Promise<{ - * duplicates: { tagName: string; releases: { id: number; assets: string[] }[]; byTagId: number | null; createdByThisRun: number[] }[]; - * unseen: string[]; - * pagesRead: number; - * complete: boolean; - * }>} - */ -export async function auditDuplicateReleases({ - client, - tagNames, - createdIds = [], - perPage = AUDIT_PER_PAGE, - maxPages = AUDIT_MAX_PAGES, -}) { - const wanted = new Set(tagNames); - /** @type {Map} */ - const byTag = new Map(); - let seenAny = false; - let complete = false; - let pagesRead = 0; - - for (let page = 1; page <= maxPages; page += 1) { - const releases = await client.listPage({ page, perPage }); - pagesRead += 1; - let hits = 0; - for (const rel of Array.isArray(releases) ? releases : []) { - if (!wanted.has(rel.tag_name)) continue; - hits += 1; - const list = byTag.get(rel.tag_name) ?? []; - list.push({ id: rel.id, assets: (rel.assets ?? []).map((a) => a.name) }); - byTag.set(rel.tag_name, list); - } - if (hits > 0) seenAny = true; - else if (seenAny) { - complete = true; // walked past this version's run of the list - break; - } - if (!Array.isArray(releases) || releases.length < perPage) { - complete = true; // the end of the list - break; - } - } - - const ours = new Set(createdIds); - const duplicates = []; - for (const tagName of tagNames) { - const releases = byTag.get(tagName); - if (!releases || releases.length < 2) continue; - releases.sort((a, b) => a.id - b.id); - // Which one `gh release view` / `gh release upload` resolve the tag to, - // and so where an asset uploaded by tag lands: the report's cleanup anchor. - let byTagId = null; - try { - byTagId = (await client.findByTag(tagName))?.id ?? null; - } catch { - byTagId = null; - } - duplicates.push({ - tagName, - releases, - byTagId, - createdByThisRun: releases.filter((r) => ours.has(r.id)).map((r) => r.id), - }); - } - - return { duplicates, unseen: tagNames.filter((t) => !byTag.has(t)), pagesRead, complete }; -} - -/** - * Turn an audit into the lines `main` prints: `errors` fail the run, - * `warnings` do not. A duplicate this run created one of is an error; one that - * predates this run's writes is a warning (the header says why). - * - * @param {Awaited>} audit - * @returns {{ errors: string[]; warnings: string[] }} - */ -export function describeDuplicates(audit) { - const errors = []; - const warnings = []; - for (const d of audit.duplicates) { - const list = d.releases - .map((r) => `${r.id} (${r.assets.length ? `assets: ${r.assets.join(', ')}` : 'no assets'})`) - .join(', '); - const anchor = - d.byTagId === null - ? 'The by-tag endpoint could not be read, so which one `gh release view` resolves the tag to is unknown.' - : `The by-tag endpoint — what \`gh release view\` and \`gh release upload\` resolve the tag to, and so ` + - `where an asset uploaded by tag is attached — answers ${d.byTagId}; the other(s), ` + - `${d.releases.filter((r) => r.id !== d.byTagId).map((r) => r.id).join(', ')}, are the surplus.`; - const head = `${d.tagName} has ${d.releases.length} GitHub Releases: ${list}. ${anchor}`; - const tail = - 'This script never deletes a Release; deleting the surplus is a release act and the maintainer\'s call.'; - if (d.createdByThisRun.length > 0) { - errors.push( - `${head} This run created ${d.createdByThisRun.join(', ')}: the Releases API accepted a second create ` + - `for one tag from a concurrent writer. ${tail}`, - ); - } else { - warnings.push( - `${head} None of them was created by this run, so it is reported, not failed: the run that created ` + - `them was the place to fail. ${tail}`, - ); - } - } - if (audit.unseen.length > 0 || !audit.complete) { - const unseen = audit.unseen.length - ? `${audit.unseen.length} tag(s) this run released never appeared in it (${audit.unseen.slice(0, 5).join(', ')}` + - `${audit.unseen.length > 5 ? ', …' : ''})` - : `it stopped at its ${audit.pagesRead}-page cap before walking past this version's Releases`; - warnings.push( - `the duplicate-Release audit read ${audit.pagesRead} page(s) of the Releases list and ${unseen}; ` + - 'whether those tags carry more than one Release is UNVERIFIED by this run.', - ); - } - return { errors, warnings }; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Entry point -// ───────────────────────────────────────────────────────────────────────────── - -/** - * The release set this invocation owes, from the environment. ⛔ Never empty: - * the objectstack original treated "no input" as a quiet no-op, but here the - * only caller is the publish lane, which always passes `RELEASE_VERSION` — so - * an empty set means the input was lost on the way, and a step that reported - * success over nothing would be objectui#11596's silent failure one step later. - * - * @returns {{ name: string; version: string; dir: string }[]} - */ -function targetsFromEnv() { - const targets = resolveReleaseTargets({ - publishedJson: process.env.PUBLISHED, - releaseVersion: process.env.RELEASE_VERSION, - packages: listWorkspacePackages(), - }); - if (targets.length === 0) { - throw new Error( - 'neither PUBLISHED nor RELEASE_VERSION names a release, so there is no tag and no Release to ' + - 'make. Refusing to report success over nothing (objectui#11596).', - ); - } - return targets; -} - -/** - * `--print-tags`: the `@` tags the release set owes, one per - * line — what the workflow's "Push the release tags" step pushes. No CHANGELOG - * read and no API call: a package that ships no CHANGELOG.md gets no Release - * (`planRelease` skips it) but `changeset publish` still tags it, so it is - * listed here. - */ -function printTags() { - for (const target of targetsFromEnv()) console.log(tagNameOf(target)); -} - -async function main({ dryRun = false } = {}) { - const repository = process.env.GITHUB_REPOSITORY; - if (!repository) throw new Error('GITHUB_REPOSITORY is required'); - const ref = process.env.GITHUB_SHA || 'main'; - const serverUrl = process.env.GITHUB_SERVER_URL || 'https://github.com'; - const apiUrl = process.env.GITHUB_API_URL || 'https://api.github.com'; - - const targets = targetsFromEnv(); - - /** @type {{ tagName: string; body: string; prerelease: boolean; truncated: boolean; originalLength: number }[]} */ - const plans = []; - /** @type {{ tagName: string; error: string }[]} */ - const planFailures = []; - for (const target of targets) { - try { - const plan = planRelease({ target, serverUrl, repository, ref }); - if ('skipped' in plan) { - console.log(`skipped ${plan.skipped}`); - continue; - } - plans.push(plan); - } catch (err) { - const error = err instanceof Error ? err.message : String(err); - planFailures.push({ tagName: tagNameOf(target), error }); - console.log(`::error::cannot build a release body for ${tagNameOf(target)}: ${error}`); - } - } - - if (dryRun) { - console.log(`dry run: no API call. ${repository}, CHANGELOG links at ${ref}. tag -> release body:`); - for (const plan of plans) { - console.log( - `${plan.tagName}\t${measure(plan.body)} chars${plan.truncated ? `\t(truncated from ${plan.originalLength})` : ''}` + - `${plan.prerelease ? '\tprerelease' : ''}`, - ); - } - const truncated = plans.filter((p) => p.truncated).length; - console.log( - `\n${plans.length} release(s) planned (${truncated} truncated to fit the ${BODY_LIMIT}-character limit), ` + - `${planFailures.length} failed to plan.`, - ); - if (planFailures.length) process.exit(1); - return; - } - - const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; - if (!token) throw new Error('GITHUB_TOKEN is required'); - - const client = createReleasesClient({ apiUrl, repository, token }); - const { created, updated, converged, createdIds, failed } = await publishReleases({ - client, - plans, - targetCommitish: ref, - }); - - // After the writes, never instead of them: a duplicate is made by a - // concurrent writer's create, and this run's last POST may be the one that - // completed the pair. - /** @type {{ errors: string[]; warnings: string[] }} */ - let duplicateReport = { errors: [], warnings: [] }; - try { - const audit = await auditDuplicateReleases({ client, tagNames: plans.map((p) => p.tagName), createdIds }); - duplicateReport = describeDuplicates(audit); - } catch (err) { - duplicateReport.warnings.push( - `could not read the Releases list to audit for duplicate Releases ` + - `(${err instanceof Error ? err.message : String(err)}); whether any tag this run released carries ` + - 'more than one Release is UNVERIFIED by this run.', - ); - } - for (const w of duplicateReport.warnings) console.log(`::warning::${w}`); - for (const e of duplicateReport.errors) console.log(`::error::${e}`); - - const truncatedCount = plans.filter((p) => p.truncated).length; - console.log( - `\n${created.length} created, ${updated.length} updated ` + - `(${converged.length} after a concurrent writer's create), ${failed.length + planFailures.length} failed, ` + - `${duplicateReport.errors.length} duplicated by this run ` + - `(${truncatedCount} body/bodies truncated to fit the ${BODY_LIMIT}-character limit).`, - ); - - const allFailures = [...planFailures, ...failed]; - if (allFailures.length || duplicateReport.errors.length) { - if (allFailures.length) console.error(`::error::${allFailures.length} GitHub Release(s) could not be published.`); - if (duplicateReport.errors.length) { - console.error( - `::error::${duplicateReport.errors.length} tag(s) carry more than one GitHub Release after this run's ` + - 'creates (named above). Nothing was deleted.', - ); - } - process.exit(1); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Self-test -// ───────────────────────────────────────────────────────────────────────────── - -/** - * A `Response`-shaped stub, so the client's real request/response handling — - * status branching, error text, JSON decoding — is what gets exercised. - * - * @param {number} status - * @param {unknown} [payload] - */ -function stubResponse(status, payload) { - const text = JSON.stringify(payload ?? {}); - return { - status, - statusText: String(status), - ok: status >= 200 && status < 300, - async json() { - return JSON.parse(text); - }, - async text() { - return text; - }, - }; -} - -/** The tag date every stub Release of the version under test carries. */ -const STUB_TAG_DATE = '2026-10-02T02:49:42Z'; - -/** - * A stateful Releases store behind a `fetch`: records every call, answers the - * by-tag read, the list, POST and PATCH from the store, and enforces the tag's - * uniqueness the way the API usually does — a POST for a tag that already has - * a Release answers `422 already_exists`. - * - * @param {object} opts - * @param {Record} [opts.existing] tag -> release id, present before the run - * @param {Set} [opts.failCreateFor] POST answers the body-limit 422 (a non-race 422) - * @param {Set} [opts.raceCreateFor] a concurrent writer creates the tag's Release - * between this run's by-tag read and its POST - * @param {Set} [opts.acceptDuplicateCreateFor] the API accepts a second create for the - * tag instead of answering 422 — the same-second case objectstack's 17.6.0 measured - * @param {Map} [opts.hideAfterRace] the racing writer's Release stays invisible - * to this many by-tag reads after the race (Infinity: never visible to them) - * @param {{ id: number; tag_name: string; created_at: string; assets?: { name: string }[] }[]} [opts.seed] - * further Releases already in the store (older versions, pre-existing duplicates) - * @param {boolean} [opts.tick] yield a macrotask per request, so two concurrent runs interleave - */ -function stubFetch({ - existing = {}, - failCreateFor = new Set(), - raceCreateFor = new Set(), - acceptDuplicateCreateFor = new Set(), - hideAfterRace = new Map(), - seed = [], - tick = false, -} = {}) { - /** @type {{ method: string; url: string; body: any }[]} */ - const calls = []; - /** @type {{ id: number; tag_name: string; created_at: string; assets: { name: string }[]; by: string; name?: string; body?: string; prerelease?: boolean }[]} */ - const releases = []; - let nextId = 5000; - const insert = (tagName, by, extra = {}) => { - const rel = { id: nextId, tag_name: tagName, created_at: STUB_TAG_DATE, assets: [], by, ...extra }; - nextId += 1; - releases.push(rel); - return rel; - }; - for (const [tag, id] of Object.entries(existing)) releases.push({ id, tag_name: tag, created_at: STUB_TAG_DATE, assets: [], by: 'before the run' }); - for (const rel of seed) releases.push({ assets: [], by: 'seed', ...rel }); - const forTag = (tagName) => releases.filter((r) => r.tag_name === tagName).sort((a, b) => a.id - b.id); - const hidden = new Map(); - - const impl = async (url, init = {}) => { - if (tick) await new Promise((resolveTick) => setImmediate(resolveTick)); - const method = init.method ?? 'GET'; - const body = init.body ? JSON.parse(init.body) : undefined; - const u = String(url); - calls.push({ method, url: u, body }); - - if (method === 'GET') { - const byTag = /\/releases\/tags\/(.+)$/.exec(u); - if (byTag) { - const tag = decodeURIComponent(byTag[1]); - if ((hidden.get(tag) ?? 0) > 0) { - hidden.set(tag, hidden.get(tag) - 1); - return stubResponse(404, { message: 'Not Found' }); - } - // The lowest id — what the live by-tag endpoint answered for all three - // objectstack 17.6.0 duplicate pairs read while that script was written. - const [first] = forTag(tag); - return first ? stubResponse(200, first) : stubResponse(404, { message: 'Not Found' }); - } - const list = /\/releases\?per_page=(\d+)&page=(\d+)$/.exec(u); - if (list) { - const perPage = Number(list[1]); - const page = Number(list[2]); - const ordered = [...releases].sort((a, b) => b.created_at.localeCompare(a.created_at) || b.id - a.id); - return stubResponse(200, ordered.slice((page - 1) * perPage, page * perPage)); - } - return stubResponse(404, { message: 'Not Found' }); - } - if (method === 'POST') { - const tag = body.tag_name; - if (failCreateFor.has(tag)) { - return stubResponse(422, { - message: 'Validation Failed', - errors: [{ resource: 'Release', code: 'custom', field: 'body', message: 'body is too long (maximum is 125000 characters)' }], - }); - } - if (raceCreateFor.has(tag) && forTag(tag).length === 0) { - insert(tag, 'racing writer'); - if (hideAfterRace.has(tag)) hidden.set(tag, hideAfterRace.get(tag)); - } - if (forTag(tag).length > 0 && !acceptDuplicateCreateFor.has(tag)) { - return stubResponse(422, { - message: 'Validation Failed', - errors: [{ resource: 'Release', code: 'already_exists', field: 'tag_name' }], - }); - } - return stubResponse(201, insert(tag, 'this run', { name: body.name, body: body.body, prerelease: body.prerelease })); - } - if (method === 'PATCH') { - const id = Number(/\/releases\/(\d+)$/.exec(u)?.[1]); - const rel = releases.find((r) => r.id === id); - if (!rel) return stubResponse(404, { message: 'Not Found' }); - Object.assign(rel, { name: body.name, body: body.body, prerelease: body.prerelease }); - return stubResponse(200, rel); - } - return stubResponse(405, { message: `stub: ${method} is not a request this script may send` }); - }; - return { impl, calls, releases, forTag }; -} - -// Returned by `selfTest()` only after its verdict is printed. The dispatch -// refuses anything else: a `return` that leaves the function above that line -// prints nothing and still exits 0 — a self-test that never finished, reported -// as one that passed (#13798). -const SELF_TEST_VERDICT = 'release-github-releases self-test reached its verdict'; - -async function selfTest() { - // The battery ledger this self-test's floor is evaluated against (#13489). - // `battery()` opens a battery; every assertion below is attributed to the one - // most recently opened, so a section that stops running stops registering and - // names ITSELF at the floor rather than going quiet. - const batterySeen = new Map(); - let openBattery = null; - const battery = (name) => { - openBattery = name; - }; - const registerCase = () => { - const b = openBattery ?? UNATTRIBUTED_BATTERY; - batterySeen.set(b, (batterySeen.get(b) ?? 0) + 1); - }; - - /** @type {string[]} */ - const failures = []; - let assertions = 0; - const assert = (cond, msg) => { - registerCase(); - assertions += 1; - if (!cond) failures.push(msg); - }; - - const CTX = { - serverUrl: 'https://github.com', - repository: 'objectstack-ai/objectui', - ref: 'deadbeef', - }; - // The oversized repro is real, not synthetic: `@object-ui/types`'s 17.7.0 - // section, the largest entry in the release that had no Releases at all - // (objectui#11596). A CHANGELOG entry is history, so it stays the size it is. - const typesChangelog = readFileSync(join(REPO_ROOT, 'packages/types/CHANGELOG.md'), 'utf8'); - const coreChangelog = readFileSync(join(REPO_ROOT, 'packages/core/CHANGELOG.md'), 'utf8'); - - // ── 1. The real oversized entry: types' 17.7.0 section ───────────────────── - battery('1. The real oversized entry: types\' 17.7.0 section'); - const typesEntry = getChangelogEntry(typesChangelog, '17.7.0'); - assert(typesEntry !== null, 'the 17.7.0 entry is found in packages/types/CHANGELOG.md'); - assert( - typesEntry !== null && measure(typesEntry) > 1_000_000, - `types' 17.7.0 section is still the oversized repro (measured ${typesEntry === null ? 'n/a' : measure(typesEntry)}, expected >1,000,000)`, - ); - - const typesLabel = 'packages/types/CHANGELOG.md'; - const typesHref = `${CTX.serverUrl}/${CTX.repository}/blob/${CTX.ref}/${typesLabel}#1770`; - const big = buildReleaseBody({ - entry: typesEntry ?? '', - tagName: '@object-ui/types@17.7.0', - changelogLabel: typesLabel, - changelogHref: typesHref, - }); - assert(big.truncated, 'the oversized entry is reported as truncated'); - assert( - measure(big.body) <= BODY_LIMIT, - `the truncated body fits the API limit (got ${measure(big.body)}, limit ${BODY_LIMIT})`, - ); - assert(big.body.includes(typesHref), 'the truncated body links the full CHANGELOG entry, anchor included'); - assert(big.body.includes('truncated'), 'the truncated body says it was truncated'); - assert( - big.body.trimEnd().endsWith(`${typesLabel}\`](${typesHref}).`), - 'the closing pointer to CHANGELOG.md is the last thing in the body', - ); - assert( - big.body.split('\n').filter((l) => /^ {0,3}```/.test(l)).length % 2 === 0, - 'the truncated body leaves no code fence open', - ); - assert( - !/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? `line ${i} ${'x'.repeat(44)}`), - '```ts', - ...Array.from({ length: 400 }, (_, i) => `const v${i} = ${'y'.repeat(40)};`), - '```', - ].join('\n'); - const fencedBuilt = buildReleaseBody({ - entry: fenced, - tagName: 'pkg@1.0.0', - changelogLabel: typesLabel, - changelogHref: typesHref, - limit: 4_000, - }); - assert(fencedBuilt.truncated, 'the fenced fixture is large enough to truncate'); - assert(measure(fencedBuilt.body) <= 4_000, 'the fenced fixture respects the limit it was given'); - assert( - fencedBuilt.body.split('\n').filter((l) => /^ {0,3}```/.test(l)).length % 2 === 0, - 'a cut inside a code fence is closed so the notice renders as markdown', - ); - - // ── 4. Surrogate pairs are never split ───────────────────────────────────── - battery('4. Surrogate pairs are never split'); - const astral = '🚀'.repeat(5_000); - const astralBuilt = buildReleaseBody({ - entry: astral, - tagName: 'pkg@1.0.0', - changelogLabel: typesLabel, - changelogHref: typesHref, - limit: 3_000, - }); - assert( - !/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?= [...astral].length, 'measure() never under-counts against code points'); - - // ── 5. Anchors and fence-aware heading parsing ───────────────────────────── - // Expectations below are github-slugger's own output for these inputs. - battery('5. Anchors and fence-aware heading parsing'); - assert(headingAnchor('17.0.0-rc.2') === '1700-rc2', 'the version anchor matches GitHub heading slugs'); - assert(headingAnchor('16.1.0') === '1610', 'a stable version anchor drops its dots'); - assert(headingAnchor('17.7.0') === '1770', 'this repository\'s own release heading anchors as GitHub renders it'); - assert(headingAnchor('1.2.3-beta.10') === '123-beta10', 'a prerelease anchor keeps only its hyphen'); - const tricky = ['## 1.0.0', '', '```md', '## 0.9.0', '```', '', 'real content', '', '## 0.9.0', '', 'older'].join( - '\n', - ); - assert( - getChangelogEntry(tricky, '1.0.0')?.includes('real content') === true, - 'a `##` inside a fenced block does not end the entry', - ); - assert(getChangelogEntry(tricky, '0.9.0') === 'older', 'the following entry is still found after a fenced decoy'); - assert(getChangelogEntry(tricky, '2.0.0') === null, 'a missing version yields null rather than a wrong section'); - - // ── 6. Target resolution: both producers, and neither ────────────────────── - battery('6. Target resolution: both producers, and neither'); - const packages = listWorkspacePackages(); - assert(packages.has('@object-ui/core'), 'the workspace scan finds @object-ui/core'); - /** @type {Set} */ - const privateNames = new Set(); - for (const dir of workspacePackageDirs(REPO_ROOT)) { - try { - const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); - if (manifest.private === true && manifest.name) privateNames.add(manifest.name); - } catch { - /* not a package directory */ - } - } - // `object-ui` (the vscode extension) is the control: a `fixed` member that is - // private, so a scan that admitted private packages would release it. - assert( - privateNames.has('object-ui') && [...privateNames].every((n) => !packages.has(n)), - `private packages are never release targets (private: ${[...privateNames].sort().join(', ')})`, - ); - // The release set is re-derived against a second source rather than pinned - // as a count: `.changeset/config.json`'s `fixed` group, minus its private - // members, IS the set `changeset publish` ships at one version. - const fixedConfig = JSON.parse(readFileSync(join(REPO_ROOT, '.changeset/config.json'), 'utf8')); - const fixedMembers = new Set((fixedConfig.fixed ?? []).flat()); - const fixedPublic = [...fixedMembers].filter((n) => !privateNames.has(n)).sort(); - assert( - fixedPublic.length > 0 && JSON.stringify([...packages.keys()].sort()) === JSON.stringify(fixedPublic), - `the release set is exactly the fixed group's public members, in both directions (workspace ${packages.size}, fixed-and-public ${fixedPublic.length})`, - ); - const fromPublished = resolveReleaseTargets({ - publishedJson: JSON.stringify([ - { name: '@object-ui/core', version: '17.7.0' }, - { name: '@object-ui/types', version: '17.7.0' }, - ]), - packages, - }); - assert(fromPublished.length === 2, 'PUBLISHED drives exactly the packages it lists'); - assert( - fromPublished.every((t) => typeof t.dir === 'string' && t.dir.length > 0), - 'each published package resolves to its workspace directory', - ); - const fromVersion = resolveReleaseTargets({ releaseVersion: '17.9.0', packages }); - assert( - fromVersion.length === packages.size && fromVersion.length > 0, - `RELEASE_VERSION covers the whole publishable workspace (got ${fromVersion.length})`, - ); - assert( - fromVersion.every((t) => t.version === '17.9.0'), - 'RELEASE_VERSION releases every package at the one fixed-group version', - ); - assert(resolveReleaseTargets({ packages }).length === 0, 'no input resolves to no targets, not a crash'); - assert( - resolveReleaseTargets({ publishedJson: '[]', releaseVersion: '1.2.3', packages }).length === packages.size, - 'an empty PUBLISHED array — what v1 reports under CLI v3 — falls through to RELEASE_VERSION', - ); - - // ── 7. Planning is per package, and a missing entry is loud ──────────────── - battery('7. Planning is per package, and a missing entry is loud'); - const typesPlan = planRelease({ - target: { name: '@object-ui/types', version: '17.7.0', dir: join(REPO_ROOT, 'packages/types') }, - ...CTX, - }); - assert('tagName' in typesPlan && typesPlan.tagName === '@object-ui/types@17.7.0', 'the tag is `@`'); - assert('truncated' in typesPlan && typesPlan.truncated === true, 'the types plan truncates'); - assert( - 'body' in typesPlan && measure(typesPlan.body) <= BODY_LIMIT, - 'the planned types body is within the API limit end to end', - ); - assert('prerelease' in typesPlan && typesPlan.prerelease === false, 'a GA version is not marked prerelease'); - // No CHANGELOG here carries a prerelease heading, so the rule is exercised on - // a fixture written to a throwaway directory and removed again. - const rcDir = mkdtempSync(join(tmpdir(), 'release-github-releases-')); - try { - writeFileSync(join(rcDir, 'CHANGELOG.md'), '# pkg\n\n## 1.0.0-rc.1\n\n- a change\n'); - const rcPlan = planRelease({ target: { name: '@object-ui/pkg', version: '1.0.0-rc.1', dir: rcDir }, ...CTX }); - assert('prerelease' in rcPlan && rcPlan.prerelease === true, 'an rc version is marked prerelease'); - } finally { - rmSync(rcDir, { recursive: true, force: true }); - } - let threw = false; - try { - planRelease({ - target: { name: '@object-ui/core', version: '99.99.99', dir: join(REPO_ROOT, 'packages/core') }, - ...CTX, - }); - } catch { - threw = true; - } - assert(threw, 'a version with no changelog entry fails loudly instead of releasing an empty body'); - - // ── 8. Every package gets a release; existing ones are updated, not retried ─ - battery('8. Every package gets a release; existing ones are updated, not retried ─'); - const plans = ['@object-ui/types', '@object-ui/core', '@object-ui/react'].map((name) => { - const plan = planRelease({ target: { name, version: '17.7.0', dir: packages.get(name).dir }, ...CTX }); - if (!('tagName' in plan)) throw new Error(`fixture package ${name} produced no plan`); - return plan; - }); - const mixed = stubFetch({ existing: { '@object-ui/core@17.7.0': 4242 } }); - const mixedResult = await publishReleases({ - client: createReleasesClient({ - apiUrl: 'https://api.github.com', - repository: CTX.repository, - token: 't', - fetchImpl: mixed.impl, - }), - plans, - targetCommitish: CTX.ref, - log: () => {}, - }); - assert( - mixedResult.created.length === 2 && mixedResult.updated.length === 1 && mixedResult.failed.length === 0, - `every package is released — 2 created, 1 updated (got ${mixedResult.created.length}/${mixedResult.updated.length}/${mixedResult.failed.length})`, - ); - assert( - mixedResult.updated[0] === '@object-ui/core@17.7.0', - 'the package that already had a release is the one updated', - ); - assert( - mixed.calls.some((c) => c.method === 'PATCH' && c.url.endsWith('/releases/4242')), - 'an existing release is PATCHed by id rather than re-POSTed', - ); - assert( - mixed.calls.filter((c) => c.method === 'POST').length === 2, - 'exactly the two absent releases are POSTed', - ); - assert( - mixed.calls.every((c) => c.method !== 'GET' || c.url.includes('%2F')), - 'the by-tag lookup percent-encodes the slash in a scoped package tag', - ); - assert( - mixed.calls - .filter((c) => c.method !== 'GET') - .every((c) => measure(c.body.body) <= BODY_LIMIT), - 'no request ever carries a body over the API limit', - ); - assert( - mixed.calls.filter((c) => c.method === 'POST').every((c) => c.body.target_commitish === CTX.ref), - 'a created release is pinned to the release commit', - ); - - // ── 9. Idempotent re-run ─────────────────────────────────────────────────── - battery('9. Idempotent re-run'); - const allExist = stubFetch({ - existing: Object.fromEntries(plans.map((p, i) => [p.tagName, 100 + i])), - }); - const rerun = await publishReleases({ - client: createReleasesClient({ - apiUrl: 'https://api.github.com', - repository: CTX.repository, - token: 't', - fetchImpl: allExist.impl, - }), - plans, - targetCommitish: CTX.ref, - log: () => {}, - }); - assert( - rerun.created.length === 0 && rerun.updated.length === 3 && rerun.failed.length === 0, - 're-running against fully-created releases updates all and fails none', - ); - assert( - allExist.calls.every((c) => c.method !== 'POST'), - 'a re-run never POSTs, so it cannot hit `already_exists`', - ); - - // ── 10. One package's failure does not abandon the others ────────────────── - battery('10. One package\'s failure does not abandon the others'); - const partial = stubFetch({ failCreateFor: new Set(['@object-ui/core@17.7.0']) }); - const partialResult = await publishReleases({ - client: createReleasesClient({ - apiUrl: 'https://api.github.com', - repository: CTX.repository, - token: 't', - fetchImpl: partial.impl, - }), - plans, - targetCommitish: CTX.ref, - log: () => {}, - }); - assert( - partialResult.created.length === 2 && partialResult.failed.length === 1, - 'a rejected release does not stop the remaining packages (the Promise.all defect)', - ); - assert( - partialResult.created.includes('@object-ui/types@17.7.0'), - '@object-ui/types still gets its release — a sibling\'s failure costs it nothing', - ); - assert( - partialResult.failed[0].error.includes('422'), - 'the failure carries the API status through to the log', - ); - - const clientOver = (stub) => - createReleasesClient({ apiUrl: 'https://api.github.com', repository: CTX.repository, token: 't', fetchImpl: stub.impl }); - const noSleep = async () => {}; - const [typesTag, coreTag] = plans.map((p) => p.tagName); - - // ── 11. A racing writer's create converges instead of failing ─────────── - // objectstack's 17.6.0 shape: this run reads the tag (404), the other writer's create - // lands, this run's POST answers 422 already_exists on tag_name. - battery('11. A racing writer\'s create converges instead of failing (422 already_exists)'); - const raced = stubFetch({ raceCreateFor: new Set([coreTag]) }); - /** @type {string[]} */ - const racedLog = []; - const racedResult = await publishReleases({ - client: clientOver(raced), - plans, - targetCommitish: CTX.ref, - log: (m) => racedLog.push(m), - sleep: noSleep, - }); - const [racer] = raced.forTag(coreTag); - assert( - raced.calls.some((c) => c.method === 'POST' && c.body.tag_name === coreTag), - 'the fixture really raced: this run POSTed the tag the other writer created first', - ); - assert( - racedResult.failed.length === 0, - `a 422 already_exists from a racing writer fails no release (got ${racedResult.failed.length}: ${racedResult.failed.map((f) => f.error).join(' | ')})`, - ); - assert( - racedResult.converged.length === 1 && racedResult.converged[0] === coreTag, - `the raced tag is reported as converged (got ${JSON.stringify(racedResult.converged)})`, - ); - assert( - racedResult.created.length === 2 && racedResult.updated.length === 1 && racedResult.updated[0] === coreTag, - 'the two unraced tags are created and the raced one is counted as an update', - ); - assert( - raced.calls.some((c) => c.method === 'PATCH' && c.url.endsWith(`/releases/${racer?.id}`)), - "it converges by PATCHing the racing writer's own release, found by a by-tag re-read", - ); - assert( - plans.every((p) => raced.forTag(p.tagName).length === 1), - 'the run converges to exactly one release per tag', - ); - assert( - racer?.body === plans[1].body && racer?.by === 'racing writer', - "the racing writer's release now carries this run's body", - ); - assert( - racedLog.some((l) => l.includes(coreTag) && l.includes('422 already_exists') && l.includes(String(racer?.id))), - 'the log names the convergence, the 422 and the release it converged onto', - ); - - // ── 12. Two concurrent invocations leave exactly one release per tag ──── - // Both writers over ONE store, interleaved request by request — a workflow re-run's - // publish-job step and push-lane backfill, minus the clock. - battery('12. Two concurrent invocations leave exactly one release per tag'); - const shared = stubFetch({ tick: true }); - const writer = () => - publishReleases({ client: clientOver(shared), plans, targetCommitish: CTX.ref, log: () => {}, sleep: noSleep }); - const [writerA, writerB] = await Promise.all([writer(), writer()]); - assert( - writerA.failed.length + writerB.failed.length === 0, - `neither concurrent writer fails a release (got ${writerA.failed.length} + ${writerB.failed.length}: ` + - `${[...writerA.failed, ...writerB.failed].map((f) => f.error).join(' | ')})`, - ); - assert( - plans.every((p) => shared.forTag(p.tagName).length === 1), - `two concurrent invocations leave exactly one release per tag (got ${plans.map((p) => shared.forTag(p.tagName).length).join('/')})`, - ); - assert( - writerA.converged.length + writerB.converged.length > 0, - 'the two invocations really raced — at least one POST answered 422 already_exists (otherwise this battery proves nothing)', - ); - assert( - writerA.created.length + writerB.created.length === plans.length, - 'across both writers every tag is created exactly once', - ); - assert( - writerA.created.length + writerA.updated.length === plans.length && - writerB.created.length + writerB.updated.length === plans.length, - 'each writer accounts for every tag as created or updated', - ); - assert( - shared.calls.every((c) => c.method !== 'DELETE'), - 'converging never deletes anything', - ); - - // ── 13. Any other 422 still fails; an invisible racer fails loudly ────── - battery('13. Any other 422 still fails; a racer the read cannot see fails loudly, bounded'); - const tooLong = stubFetch({ failCreateFor: new Set([coreTag]) }); - const tooLongResult = await publishReleases({ - client: clientOver(tooLong), - plans, - targetCommitish: CTX.ref, - log: () => {}, - sleep: noSleep, - }); - assert( - tooLongResult.failed.length === 1 && - tooLongResult.failed[0].tagName === coreTag && - tooLongResult.failed[0].error.includes('422') && - tooLongResult.failed[0].error.includes('"field":"body"'), - 'the body-limit 422 still fails that package, with the API answer in the error', - ); - assert(tooLongResult.converged.length === 0, 'the body-limit 422 is not mistaken for a race'); - assert( - tooLong.calls.filter((c) => c.method === 'GET' && c.url.includes(encodeURIComponent(coreTag))).length === 1 && - tooLong.calls.every((c) => c.method !== 'PATCH'), - 'the body-limit 422 triggers no re-read and no PATCH', - ); - assert( - isTagAlreadyExists(422, JSON.stringify({ errors: [{ code: 'already_exists', field: 'tag_name' }] })), - 'the classifier recognises objectstack\'s 17.6.0 answer, 422 already_exists on tag_name', - ); - assert( - !isTagAlreadyExists(422, JSON.stringify({ errors: [{ code: 'already_exists', field: 'name' }] })) && - !isTagAlreadyExists(409, JSON.stringify({ errors: [{ code: 'already_exists', field: 'tag_name' }] })) && - !isTagAlreadyExists(422, 'already_exists tag_name'), - 'only the structured tag_name entry on a 422 counts — another field, another status or bare prose does not', - ); - /** @type {number[]} */ - const slept = []; - const ghost = stubFetch({ raceCreateFor: new Set([coreTag]), hideAfterRace: new Map([[coreTag, Infinity]]) }); - const ghostResult = await publishReleases({ - client: clientOver(ghost), - plans, - targetCommitish: CTX.ref, - log: () => {}, - sleep: async (ms) => { - slept.push(ms); - }, - }); - assert( - ghostResult.failed.length === 1 && - ghostResult.failed[0].tagName === coreTag && - ghostResult.failed[0].error.includes('already_exists') && - ghostResult.failed[0].error.includes('404'), - 'a 422 already_exists whose release no by-tag re-read can see fails that package, saying both', - ); - assert( - ghost.calls.filter((c) => c.method === 'GET' && c.url.includes(encodeURIComponent(coreTag))).length === - 1 + RACE_REREAD_DELAYS_MS.length && - JSON.stringify(slept) === JSON.stringify(RACE_REREAD_DELAYS_MS.filter((d) => d > 0)), - `the re-read is bounded: ${RACE_REREAD_DELAYS_MS.length} attempts after the first read, waiting the declared delays`, - ); - const lagging = stubFetch({ raceCreateFor: new Set([coreTag]), hideAfterRace: new Map([[coreTag, 1]]) }); - const laggingResult = await publishReleases({ - client: clientOver(lagging), - plans, - targetCommitish: CTX.ref, - log: () => {}, - sleep: noSleep, - }); - assert( - laggingResult.failed.length === 0 && laggingResult.converged.length === 1 && lagging.forTag(coreTag).length === 1, - 'a racer the first re-read misses is found by a later one, and the run converges', - ); - - // ── 14. Duplicate releases are reported, never deleted ────────────────── - // objectstack's other 17.6.0 shape: the API ACCEPTED both creates. Older versions' - // Releases sit below this version's in the list, so the walk has to stop. - battery('14. Duplicate releases for one tag are reported, never deleted'); - const OLDER_TAG_DATE = '2026-09-29T07:54:50Z'; - const olderVersion = Array.from({ length: 12 }, (_, i) => ({ - id: 100 + i, - tag_name: `@object-ui/older-${i}@17.5.0`, - created_at: OLDER_TAG_DATE, - })); - const dup = stubFetch({ - raceCreateFor: new Set([typesTag]), - acceptDuplicateCreateFor: new Set([typesTag]), - seed: olderVersion, - }); - const dupResult = await publishReleases({ - client: clientOver(dup), - plans, - targetCommitish: CTX.ref, - log: () => {}, - sleep: noSleep, - }); - // An asset uploaded by tag attaches onto whichever release the tag resolves to. - const [typesFirst, typesSecond] = dup.forTag(typesTag); - typesFirst.assets = [{ name: 'release-asset.json' }]; - const dupAudit = await auditDuplicateReleases({ - client: clientOver(dup), - tagNames: plans.map((p) => p.tagName), - createdIds: dupResult.createdIds, - perPage: 2, - }); - assert( - dupResult.failed.length === 0 && dup.forTag(typesTag).length === 2, - 'the fixture really holds a duplicate: the API accepted a second create for the types tag', - ); - assert( - dupAudit.duplicates.length === 1 && dupAudit.duplicates[0].tagName === typesTag, - `the audit names exactly the duplicated tag (got ${JSON.stringify(dupAudit.duplicates.map((d) => d.tagName))})`, - ); - assert( - JSON.stringify(dupAudit.duplicates[0]?.releases.map((r) => r.id)) === JSON.stringify([typesFirst.id, typesSecond.id]), - 'the audit lists both release ids of the pair', - ); - assert( - dupAudit.duplicates[0]?.byTagId === typesFirst.id, - 'the audit names the id the by-tag endpoint resolves to — where an asset uploaded by tag lands', - ); - assert( - JSON.stringify(dupAudit.duplicates[0]?.createdByThisRun) === JSON.stringify([typesSecond.id]), - 'the audit knows which release of the pair this run created', - ); - assert( - dupAudit.unseen.length === 0 && dupAudit.complete, - 'every tag this run released was seen and the walk completed', - ); - assert( - dupAudit.pagesRead === 3, - `the walk stops at the first page past this version's releases, never reading the whole list (read ${dupAudit.pagesRead} of 8 pages)`, - ); - const dupReport = describeDuplicates(dupAudit); - assert( - dupReport.errors.length === 1 && - dupReport.errors[0].includes(typesTag) && - dupReport.errors[0].includes(`${typesFirst.id} (assets: release-asset.json)`) && - dupReport.errors[0].includes(`${typesSecond.id} (no assets)`) && - dupReport.errors[0].includes(`are the surplus`), - 'a duplicate this run created one of is an ERROR naming both ids, their assets and the surplus', - ); - assert( - dupReport.errors[0]?.includes('never deletes') && dupReport.warnings.length === 0, - 'the error says nothing was deleted, and there is no stray warning', - ); - assert( - [...shared.calls, ...dup.calls, ...raced.calls, ...ghost.calls].every((c) => ['GET', 'POST', 'PATCH'].includes(c.method)), - 'no request this script sends is anything but GET, POST or PATCH — it never DELETEs a release', - ); - const preexisting = stubFetch({ - seed: [ - { id: 7001, tag_name: typesTag, created_at: STUB_TAG_DATE, assets: [{ name: 'release-asset.json' }] }, - { id: 7002, tag_name: typesTag, created_at: STUB_TAG_DATE }, - ...olderVersion, - ], - }); - const preResult = await publishReleases({ - client: clientOver(preexisting), - plans, - targetCommitish: CTX.ref, - log: () => {}, - sleep: noSleep, - }); - const preReport = describeDuplicates( - await auditDuplicateReleases({ - client: clientOver(preexisting), - tagNames: plans.map((p) => p.tagName), - createdIds: preResult.createdIds, - }), - ); - assert( - preResult.updated.includes(typesTag) && - preexisting.calls.some((c) => c.method === 'PATCH' && c.url.endsWith('/releases/7001')) && - preexisting.calls.every((c) => c.method !== 'PATCH' || !c.url.endsWith('/releases/7002')), - 'a run over a pre-existing pair updates the one the tag resolves to and leaves the other alone', - ); - assert( - preReport.errors.length === 0 && preReport.warnings.length === 1 && preReport.warnings[0].includes('7002'), - 'a duplicate that predates this run is a WARNING naming the surplus, not a failure', - ); - const capped = describeDuplicates( - await auditDuplicateReleases({ - client: clientOver(dup), - tagNames: plans.map((p) => p.tagName), - createdIds: dupResult.createdIds, - perPage: 1, - maxPages: 2, - }), - ); - assert( - capped.warnings.some((w) => w.includes('UNVERIFIED')), - 'an audit that ran out of pages before seeing every tag says what it could not verify', - ); - assert( - describeDuplicates( - await auditDuplicateReleases({ client: clientOver(stubFetch({ seed: olderVersion })), tagNames: [typesTag], perPage: 5 }), - ).warnings.some((w) => w.includes('never appeared')), - 'a tag the list never shows is reported unseen, not silently cleared', - ); - - // ── 15. The tags the push step owes are exactly the release set ───────── - // changeset-release.yml's "Push the release tags" step reads `--print-tags` - // and pushes what it names, so the list has to be the release set: a tag the - // list omits is never pushed, and its Release would then be POSTed against a - // tag the remote does not have (the API creates a lightweight one). - battery('15. The tags the push step owes are exactly the release set'); - assert( - tagNameOf({ name: '@object-ui/core', version: '17.7.0' }) === '@object-ui/core@17.7.0', - 'a tag is `@`, the spelling `changeset publish` creates', - ); - const owed = resolveReleaseTargets({ releaseVersion: '17.9.0', packages }).map(tagNameOf); - assert( - owed.length === packages.size && new Set(owed).size === owed.length && owed.every((t) => t.endsWith('@17.9.0')), - 'one tag per publishable package, none twice, every one at the release version', - ); - /** @type {string[]} */ - const plannedTags = []; - for (const target of resolveReleaseTargets({ releaseVersion: '17.7.0', packages })) { - try { - const plan = planRelease({ target, ...CTX }); - if ('tagName' in plan) plannedTags.push(plan.tagName); - } catch { - /* a package with no 17.7.0 entry plans nothing, and so releases nothing */ - } - } - const owed177 = new Set(resolveReleaseTargets({ releaseVersion: '17.7.0', packages }).map(tagNameOf)); - assert( - plannedTags.length >= 3 && plannedTags.every((t) => owed177.has(t)), - `every Release this script would create is on a tag the push step pushes (${plannedTags.length} planned for 17.7.0)`, - ); - const self = fileURLToPath(import.meta.url); - const childEnv = { ...process.env }; - delete childEnv.PUBLISHED; - delete childEnv.RELEASE_VERSION; - let printed = ''; - try { - printed = execFileSync(process.execPath, [self, '--print-tags'], { - encoding: 'utf8', - env: { ...childEnv, RELEASE_VERSION: '17.9.0' }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - } catch (err) { - printed = `exit ${err?.status}: ${err?.stderr ?? ''}`; - } - assert( - JSON.stringify(printed.trim().split('\n')) === JSON.stringify(owed), - `\`--print-tags\` prints exactly that list, one per line (got ${JSON.stringify(printed.slice(0, 200))})`, - ); - let refusal = { status: 0, stderr: '' }; - try { - execFileSync(process.execPath, [self, '--print-tags'], { - encoding: 'utf8', - env: childEnv, - stdio: ['ignore', 'pipe', 'pipe'], - }); - } catch (err) { - refusal = { status: err?.status ?? -1, stderr: String(err?.stderr ?? '') }; - } - assert( - refusal.status !== 0 && refusal.stderr.includes('Refusing to report success over nothing'), - `with no RELEASE_VERSION the tag list refuses instead of printing nothing (exit ${refusal.status})`, - ); - - // ── The floor: every declared battery RAN, and ran its cases (#13489) ─── - // - // Evaluated after every battery has had its chance and BEFORE the verdict, so - // the success line below can only be printed by a run in which the set of - // batteries that registered assertions EQUALS the set declared. A set - // difference names WHICH battery stopped; a count says only that something did. - const floorFailure = (message) => { - failures.push(message); - }; - const declaredBatteries = Object.keys(SELF_TEST_BATTERIES); - let floorBreached = false; - if (declaredBatteries.length < SELF_TEST_BATTERY_FLOOR) { - floorBreached = true; - floorFailure( - `SELF_TEST_BATTERIES declares ${declaredBatteries.length} batteries, below the pinned ` + - `${SELF_TEST_BATTERY_FLOOR} — a battery deleted from the roster takes its own floor with it.`, - ); - } - for (const [name, count] of batterySeen) { - if (declaredBatteries.includes(name)) continue; - floorBreached = true; - floorFailure( - `self-test battery "${name}" registered ${count} case(s) but is not declared in ` + - 'SELF_TEST_BATTERIES — an assertion attributed to no declared battery is one nothing floors.', - ); - } - for (const name of declaredBatteries) { - const count = batterySeen.get(name) ?? 0; - if (count >= SELF_TEST_BATTERIES[name]) continue; - floorBreached = true; - floorFailure( - count === 0 - ? `self-test battery "${name}" DID NOT RUN — 0 cases registered, ${SELF_TEST_BATTERIES[name]} pinned. ` + - 'The verdict below would have claimed those cases hold.' - : `self-test battery "${name}" registered ${count} case(s), below its pinned floor of ` + - `${SELF_TEST_BATTERIES[name]} — cases that used to run no longer do.`, - ); - } - if (floorBreached) { - floorFailure( - 'A battery at or below its floor means cases STOPPED RUNNING — the battery is the bug, not the ' + - 'number. Find what stopped registering (an early return, a deleted block, a guard that now ' + - 'skips) and restore it.', - ); - } - - if (failures.length) { - console.error(`✗ release-github-releases --self-test — ${failures.length} of ${assertions} assertion(s) failed\n`); - for (const f of failures) console.error(` • ${f}`); - process.exit(1); - } - console.log( - `✓ release-github-releases --self-test: ${assertions} assertions ` + - `(real packages/types/CHANGELOG.md 17.7.0 section = ${measure(typesEntry ?? '')} chars -> ${measure(big.body)}, limit ${BODY_LIMIT})`, - ); - - return SELF_TEST_VERDICT; -} - -if (isEntrypoint(import.meta.url)) { - try { - if (process.argv.includes('--print-tags')) { - printTags(); - } else if (process.argv.includes('--self-test')) { - if ((await selfTest()) !== SELF_TEST_VERDICT) { - console.error( - '\n✗ release-github-releases self-test: selfTest() returned without reaching its verdict,\n' - + 'so no success line was printed. Exiting 0 here would report a self-test\n' - + 'that never finished as a self-test that passed.\n', - ); - process.exit(1); - } - } else { - await main({ dryRun: process.argv.includes('--dry-run') }); - } - } catch (err) { - // A stack trace in an Actions log buries the one line that matters. - console.error(`::error::${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } -} diff --git a/ui/scripts/setup.sh b/ui/scripts/setup.sh index 442a909d91c..507e54aa7b3 100755 --- a/ui/scripts/setup.sh +++ b/ui/scripts/setup.sh @@ -71,12 +71,11 @@ if [ "$(printf '%s\n%s\n' "$PNPM_FLOOR" "$(pnpm -v)" | sort -V | head -n1)" != " fi echo -e "${GREEN}✓ pnpm $(pnpm -v) detected${NC}" -# Install dependencies. Every @objectstack/* package resolves to a build of the -# objectstack commit `.objectstack-sha` pins (objectui#12957), so `setup` builds -# that first and then installs frozen -- a bare `pnpm install` on a clean -# checkout fails on the missing build directory. (`pnpm run setup`, never -# `pnpm setup`: the latter is pnpm's own built-in command.) -echo -e "\n${YELLOW}📦 Building @objectstack/* at the pin, then installing dependencies...${NC}" +# Install dependencies. Every @objectstack/* package is a `workspace:^` link to +# this monorepo's own packages (objectstack#25711), so `setup` is a frozen +# install and nothing else. (`pnpm run setup`, never `pnpm setup`: the latter is +# pnpm's own built-in command.) +echo -e "\n${YELLOW}📦 Installing dependencies...${NC}" require_root_script setup pnpm run setup diff --git a/ui/scripts/sync-quick-reference-release.mjs b/ui/scripts/sync-quick-reference-release.mjs deleted file mode 100644 index 6cfba477861..00000000000 --- a/ui/scripts/sync-quick-reference-release.mjs +++ /dev/null @@ -1,426 +0,0 @@ -#!/usr/bin/env node -/** - * Rewrites `QUICK_REFERENCE.md`'s "Current Release" rows from the manifests that - * anchor them, so the block cannot fossilise when an anchor moves. - * - * ## Why this exists (objectui#5394 — the third instance of one mechanism) - * - * `scripts/__tests__/quick-reference-current-release-4143.test.ts` derives the - * legal version literals from the manifests and fails on any literal in the block - * that nothing derives. It is correct and stays exactly as it is; this script is - * not a way to make it quieter, it is the thing that keeps the doc true so the - * gate has nothing to report. - * - * The gate's own preamble states the intended workflow — "edit the anchor and that - * test tells you to edit this block". That assumes a human is holding the anchor - * when it moves. **At release time nobody is.** `.github/workflows/changeset-release.yml` - * runs `pnpm changeset:version`, which rewrites every manifest in the `fixed` group, - * and the changesets action commits the result to `changeset-release/main`. Nothing - * in that path had ever touched this doc, so the block was guaranteed to fossilise - * once per release: - * - * - objectui#4642 — doc said 17.4.0 after the 17.5.0 release commit - * - objectui#4977 — the GA-pin residue of the same family - * - objectui#5394 — doc said 17.5.0 after the 17.6.0 release commit - * - * And the release PR is not a backstop, which was measured rather than assumed: - * every `ci.yml` run on branch `changeset-release/main` carries - * `conclusion: "action_required"` with `created_at == run_started_at` — the - * workflow never executes there, because the PR is authored by the bot with - * `GITHUB_TOKEN`. So the gate cannot see the release until it is already on `main`, - * which is why `main` (not a PR) goes red once per release. A fix that only makes - * the doc DERIVABLE changes nothing about that; the derivation has to RUN at the - * moment the anchor moves, and the only such moment in the release path is - * `changeset:version`. That is where `package.json` invokes this file, and - * `scripts/__tests__/sync-quick-reference-release.test.ts` pins the wiring so the - * next person cannot quietly unhook it. - * - * ## What it rewrites, and what it refuses to - * - * Only version LITERALS, only inside `## Current Release`, only on rows that carry - * an anchor. The hand-written prose around them — including each row's note naming - * its own anchor — is preserved verbatim, because those notes are the reason the - * rows are readable and the gate's header calls them out as deliberately rewordable. - * - * A row is rewritten only when its literal COUNT still matches its anchor's. If a - * row states three literals where the anchor derives two (the React row grows a - * major, say), positional substitution would be a guess, so this refuses that row - * and exits non-zero. Loud refusal fails the release step; the alternative — guess - * and commit — is how a doc ends up confidently wrong. The gate stays the judge - * either way. - * - * The `TypeScript` row is untouched on purpose: it has no anchor (no workspace - * manifest declares a `typescript` range), the gate allowlists it by exact text and - * fails the moment an anchor appears. Nothing here can derive it, so nothing here - * pretends to. - * - * Run: node scripts/sync-quick-reference-release.mjs # rewrite in place - * node scripts/sync-quick-reference-release.mjs --check # report only - * Exit: 0 = in sync (or synced), 1 = out of sync (--check) or a row was refused - */ - -import { readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs'; -import { resolve, dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { isEntrypoint } from './invoked-as.mjs'; - -const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); - -/** The document this script owns a few literals inside of. */ -export const DOC_REL = 'QUICK_REFERENCE.md'; - -/** - * A version literal, in the spellings this block uses. - * - * Deliberately character-for-character the same source text as `VERSION_LITERAL` - * in `scripts/__tests__/quick-reference-current-release-4143.test.ts`. Two copies - * would be free to drift — one tokenizer writing and a different one judging is - * how a "synced" doc still fails the gate — so the test file asserts the two - * spellings are identical rather than trusting this comment. - */ -export const VERSION_LITERAL = /(?:\^|~|>=|<=|>|<|≥|≤)\s*v?\d[\w.-]*|\bv?\d+\.[A-Za-z0-9][\w.-]*/g; - -/** Whitespace-insensitive comparison key, matching the gate's `normalize`. */ -export const normalize = (literal) => literal.replace(/\s+/g, ''); - -/** - * @typedef {object} Anchors - * @property {string} version the one version the `fixed` group carries - * @property {string} spec the `@objectstack/spec` range - * @property {string} client the `@objectstack/client` range - * @property {string} nodeFloor version floor out of root `engines.node` - * @property {string} pnpmFloor version floor out of root `engines.pnpm` - * @property {string} pnpmPinned the version half of root `packageManager` - * @property {string[]} reactMajors the majors named by the react peer range - */ - -function manifest(root, rel) { - return JSON.parse(readFileSync(join(root, rel), 'utf8')); -} - -/** - * The majors named by a peer range like `^18.0.0 || ^19.0.0`. - * - * Split on the `||` comparators first and take the leading number of each. One - * global `\d+\.` sweep instead returns `[18, 0, 19, 0]` — the patch segments match - * it too — and here that mistake is silent: an extra major only adds a literal the - * row does not state, and the count check would then refuse the row for the wrong - * reason. Same reasoning as the gate's copy of this function. - */ -export function peerMajors(range) { - return range - .split('||') - .map((comparator) => comparator.trim().match(/^[\^~>=<\s]*(\d+)\./)?.[1]) - .filter((major) => major !== undefined); -} - -/** - * The floor an `engines`-style range states, as the WHOLE version string — not - * just its leading integer group. - * - * objectui#6313: the previous derivation, `match(/(\d+)/)?.[1]`, kept only the - * leading digits, so `>=22.11` produced a floor of `22` and this script would - * regenerate `≥ 22`, silently discarding the `.11` the anchor actually states. - * Stripping the comparator and keeping the remainder whole means the row this - * script writes and the anchor it reads can no longer disagree at any segment - * the anchor carries. Same derivation as the gate's copy of this function. - */ -export function versionFloor(range) { - return range.match(/^[\^~>=<\s]*(.+)$/)?.[1]; -} - -/** - * Reads every anchor the block quotes, from the manifests that own them. - * - * Throws — rather than defaulting — when an anchor is missing or when two - * manifests a row names as its joint anchor disagree. A row that names two files - * cannot state one value while they disagree, and the release path must stop - * rather than pick one: the same judgement the gate makes, made before the write - * instead of after it. - * - * @param {string} [root] - * @returns {Anchors} - */ -export function readAnchors(root = repoRoot) { - const rootManifest = manifest(root, 'package.json'); - const consoleManifest = manifest(root, 'apps/console/package.json'); - const dataObjectstack = manifest(root, 'packages/data-objectstack/package.json'); - const reactManifest = manifest(root, 'packages/react/package.json'); - - const changesetConfig = JSON.parse(readFileSync(join(root, '.changeset/config.json'), 'utf8')); - const fixedGroup = changesetConfig.fixed?.[0] ?? []; - if (fixedGroup.length <= 1) { - throw new Error('.changeset/config.json must still declare one `fixed` group of packages'); - } - - // The version is a fact about the WORKSPACE, not about whichever manifest we - // happened to read: assert the group still carries one version before quoting - // it, exactly as the gate does. A split group makes a single `**Version:**` row - // inexpressible, and this must say so rather than pin an arbitrary member. - const versions = new Map(); - for (const rel of ['packages', 'apps']) { - for (const entry of readdirSync(join(root, rel), { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const pkgRel = `${rel}/${entry.name}/package.json`; - if (!existsSync(join(root, pkgRel))) continue; - const pkg = manifest(root, pkgRel); - if (!pkg.name || !fixedGroup.includes(pkg.name)) continue; - versions.set(pkg.name, pkg.version ?? ''); - } - } - const distinct = [...new Set(versions.values())]; - if (distinct.length !== 1) { - throw new Error( - `the changeset \`fixed\` group must carry ONE version; got ${JSON.stringify([...versions])}`, - ); - } - - const specFromRoot = rootManifest.devDependencies?.['@objectstack/spec']; - const specFromConsole = consoleManifest.devDependencies?.['@objectstack/spec']; - if (!specFromRoot || !specFromConsole) throw new Error('@objectstack/spec is no longer declared where the Spec row says it is'); - if (specFromRoot !== specFromConsole) { - throw new Error( - `root and apps/console declare different @objectstack/spec ranges (${specFromRoot} vs ${specFromConsole}) — ` + - `${DOC_REL}'s Spec row names both as its anchor and can no longer state one value`, - ); - } - - const clientFromConsole = consoleManifest.devDependencies?.['@objectstack/client']; - const clientFromAdapter = dataObjectstack.dependencies?.['@objectstack/client']; - if (!clientFromConsole || !clientFromAdapter) throw new Error('@objectstack/client is no longer declared where the Client row says it is'); - if (clientFromConsole !== clientFromAdapter) { - throw new Error( - `apps/console and packages/data-objectstack declare different @objectstack/client ranges ` + - `(${clientFromConsole} vs ${clientFromAdapter}) — ${DOC_REL}'s Client row names both as its anchor`, - ); - } - - const nodeFloor = rootManifest.engines?.node && versionFloor(rootManifest.engines.node); - const pnpmFloor = rootManifest.engines?.pnpm && versionFloor(rootManifest.engines.pnpm); - const pnpmPinned = rootManifest.packageManager?.split('@').pop(); - if (!nodeFloor) throw new Error('root package.json must still declare a version-floor engines.node'); - if (!pnpmFloor) throw new Error('root package.json must still declare a version-floor engines.pnpm'); - if (!pnpmPinned) throw new Error('root package.json must still declare packageManager'); - - const peer = reactManifest.peerDependencies?.react; - if (!peer) throw new Error('packages/react must still declare a react peer range'); - const reactMajors = peerMajors(peer); - if (reactMajors.length === 0) throw new Error(`could not read majors out of the react peer range "${peer}"`); - - return { - version: distinct[0], - spec: specFromRoot, - client: clientFromConsole, - nodeFloor, - pnpmFloor, - pnpmPinned, - reactMajors, - }; -} - -/** - * The literals each anchored row must state, **in the order the row states them**. - * - * Order matters here and nowhere else: when more than one literal on a row changes - * at once, `syncDoc` fills the leftover slots left-to-right, so this array is what - * decides which value lands in which slot. The pnpm row reads "floor, then pinned", - * and so does this. - * - * @param {Anchors} anchors - * @returns {Map} - */ -export function derivedLiteralsByRow(anchors) { - return new Map([ - ['Version', [anchors.version]], - ['Spec', [anchors.spec]], - ['Client', [anchors.client]], - ['Node.js', [`≥ ${anchors.nodeFloor}`]], - ['pnpm', [`≥ ${anchors.pnpmFloor}`, anchors.pnpmPinned]], - ['React', anchors.reactMajors.map((major) => `${major}.x`)], - ]); -} - -const SECTION_HEADING = '## Current Release'; - -/** - * Line spans of the anchored rows inside `## Current Release`. - * - * A row runs from its `- **Label:**` line up to the next bullet, blank line or - * block quote — the same extent the gate reads, so a literal that a future editor - * pushes onto a continuation line is inside both or outside both. - * - * @returns {{ label: string, start: number, end: number }[]} - */ -function rowSpans(lines) { - const start = lines.findIndex((line) => line.trim() === SECTION_HEADING); - if (start === -1) throw new Error(`${DOC_REL} must still have a "${SECTION_HEADING}" heading`); - - const after = lines.slice(start + 1); - const relEnd = after.findIndex((line) => /^## /.test(line)); - const sectionEnd = relEnd === -1 ? lines.length : start + 1 + relEnd; - - const spans = []; - for (let i = start + 1; i < sectionEnd; i += 1) { - const head = lines[i].match(/^- \*\*(.+?):\*\*/); - if (!head) continue; - let end = i + 1; - while (end < sectionEnd) { - const next = lines[end]; - if (next.trim() === '' || /^- \*\*/.test(next) || /^>/.test(next)) break; - end += 1; - } - spans.push({ label: head[1], start: i, end }); - } - return spans; -} - -/** - * Assigns each derived literal to a slot in the row, identity first. - * - * Literals already correct keep their own slot, so a row where only one of two - * values moved (pnpm's `packageManager` bumping while the floor holds) is edited - * in exactly one place. Only what is left over is filled positionally, which is - * the single spot where the order of `derivedLiteralsByRow` is load-bearing. - * - * @param {string[]} stated normalized literals, in order of appearance - * @param {string[]} derived literals to end up with, in row order - * @returns {(string | null)[]} per slot: the replacement text, or null to keep - */ -export function assignSlots(stated, derived) { - const remaining = [...derived]; - const plan = stated.map((value) => { - const hit = remaining.findIndex((candidate) => normalize(candidate) === value); - if (hit === -1) return undefined; - remaining.splice(hit, 1); - return null; // already correct — leave the row's own spelling alone - }); - return plan.map((slot) => (slot === null ? null : remaining.shift() ?? null)); -} - -/** - * @typedef {object} SyncResult - * @property {string} doc the document, rewritten where it was safe to - * @property {{ label: string, from: string, to: string }[]} changes - * @property {{ label: string, stated: string[], derived: string[], reason: string }[]} refusals - */ - -/** - * Rewrites the anchored rows of `doc` to state `derived`. - * - * @param {string} doc - * @param {Map} derived - * @returns {SyncResult} - */ -export function syncDoc(doc, derived) { - const lines = doc.split('\n'); - const changes = []; - const refusals = []; - - for (const { label, start, end } of rowSpans(lines)) { - const want = derived.get(label); - if (!want) continue; // unanchored (the TypeScript row) — not ours to touch - - const rowLines = lines.slice(start, end); - - // Per-line scanning is what makes the rewrite safe (a replacement never has - // to be split across a newline), but the gate reads the row JOINED. The two - // views can disagree in one place: `≥\n 22` is one literal joined and none - // per line, because the regex's `\s*` matches a newline. Counting both ways - // and refusing on disagreement means this never edits a row it is reading - // differently from the gate that will judge it. - const perLine = rowLines.flatMap((line) => [...line.matchAll(VERSION_LITERAL)].map((m) => m[0])); - const joined = [...rowLines.join(' ').matchAll(VERSION_LITERAL)].map((m) => m[0]); - if (perLine.length !== joined.length) { - refusals.push({ - label, - stated: joined.map(normalize), - derived: want, - reason: 'a version literal spans a line break, so this row reads differently line-by-line than the gate reads it joined', - }); - continue; - } - - const stated = perLine.map(normalize); - const wantKeys = want.map(normalize); - const same = - stated.length === wantKeys.length && [...stated].sort().join('|') === [...wantKeys].sort().join('|'); - if (same) continue; - - if (stated.length !== want.length) { - refusals.push({ - label, - stated, - derived: want, - reason: `the row states ${stated.length} version literal(s) and its anchor derives ${want.length}; ` + - 'which slot gets which value would be a guess', - }); - continue; - } - - const plan = assignSlots(stated, want); - let slot = 0; - for (let i = 0; i < rowLines.length; i += 1) { - rowLines[i] = rowLines[i].replace(VERSION_LITERAL, (match) => { - const replacement = plan[slot]; - const current = match; - slot += 1; - if (replacement === null) return current; - changes.push({ label, from: current, to: replacement }); - return replacement; - }); - } - lines.splice(start, end - start, ...rowLines); - } - - return { doc: lines.join('\n'), changes, refusals }; -} - -export function main(argv = process.argv.slice(2), root = repoRoot) { - const checkOnly = argv.includes('--check'); - const docPath = join(root, DOC_REL); - const before = readFileSync(docPath, 'utf8'); - - let result; - try { - result = syncDoc(before, derivedLiteralsByRow(readAnchors(root))); - } catch (error) { - console.error(`❌ Cannot sync ${DOC_REL}'s "Current Release" block:\n\n ${error.message}\n`); - return 1; - } - - for (const refusal of result.refusals) { - console.error( - `❌ Refusing to rewrite the "${refusal.label}" row of ${DOC_REL}:\n` + - ` ${refusal.reason}\n` + - ` the row states ${JSON.stringify(refusal.stated)}; its anchor derives ${JSON.stringify(refusal.derived)}\n` + - ' Edit the row by hand, then re-run this script.\n', - ); - } - - if (result.doc === before) { - if (result.refusals.length > 0) return 1; - console.log(`✅ ${DOC_REL}'s "Current Release" block already states every anchor.`); - return 0; - } - - for (const change of result.changes) { - console.log(` ${change.label}: ${change.from} → ${change.to}`); - } - - if (checkOnly) { - console.error( - `\n❌ ${DOC_REL}'s "Current Release" block has drifted from its anchors.\n` + - ' Run: node scripts/sync-quick-reference-release.mjs\n', - ); - return 1; - } - - writeFileSync(docPath, result.doc); - console.log(`\n✅ Rewrote ${DOC_REL}'s "Current Release" block from its anchors.`); - return result.refusals.length > 0 ? 1 : 0; -} - -// Only run when invoked as a script — the tests import the functions above. -if (isEntrypoint(import.meta.url)) { - process.exit(main()); -} diff --git a/ui/scripts/vite-objectstack-client-dist.ts b/ui/scripts/vite-objectstack-client-dist.ts deleted file mode 100644 index b11b8ce7f41..00000000000 --- a/ui/scripts/vite-objectstack-client-dist.ts +++ /dev/null @@ -1,313 +0,0 @@ -// `OBJECTSTACK_CLIENT_DIST` — resolve `@objectstack/client` at a locally built -// client instead of the one the lockfile installed, and REFUSE the build when -// that override cannot work. -// -// ## What was missing (objectui#6094) -// -// The hook itself is old: `apps/console/vite.config.ts` has aliased -// `@objectstack/client` at `path.resolve(process.env.OBJECTSTACK_CLIENT_DIST)` -// for as long as a developer has needed to exercise a client before it ships. -// Nothing validated that value — no existence check, no manifest read, no -// dependency check — and the hook is aimed, BY DESIGN, at a freshly built, -// not-yet-installed tree, which is exactly the situation where a reachable -// `node_modules` is absent or incomplete. -// -// Measured on `a100f77d3` (vite 8.2.1 + rolldown 1.2.3) with the override -// pointed at a copy of the installed `@objectstack/client@17.2.0` placed -// outside the workspace: `vite build` prints `✓ 8615 modules transformed`, -// renders every chunk, writes `dist/` and lists its gzip sizes. The client's -// three own bare specifiers — `@objectstack/core/logger`, `@objectstack/spec/api`, -// `@objectstack/spec/data` — survive into `assets/framework-*.js` as calls to -// rolldown's `require` shim, which throws in a browser. The build then dies for -// an unrelated reason (`ineffective-dynamic-import-ledger`, objectui#6093); -// with that plugin out of the array, rolldown's own resolve error surfaces -// instead. NEITHER failure names `OBJECTSTACK_CLIENT_DIST`, and both arrive a -// whole build after the value that caused them was read. -// -// That is the same failure mode objectui#5391 was filed about on the SPEC hook, -// where PR #5995 answered it with a fail-fast dependency check. -// -// ## Why this is a sibling module and not a shared helper -// -// Ruled on objectui#6094: the two hooks are not symmetrical. The spec hook -// derives 18 aliases from the override's `exports` map, because a bare prefix -// alias cannot express a map that redirects every subpath into `dist/`. The -// client hook is ONE alias — `@objectstack/client` exports a single entry — -// and its accepted input is wider: a package directory, its `dist/`, or a -// built entry file inside it. Only the dependency check transfers, so it is -// re-stated here rather than extracted; a helper bent across both shapes would -// impose the spec hook's assumptions on this one. If a third hook ever appears, -// that is the moment to abstract. -// -// ## The shape ambiguity is the substance -// -// A dependency check is parameterised over a package DIRECTORY, and this hook -// may be handed a FILE. Detecting that wrongly is not a milder bug than the one -// being fixed — a check that silently does nothing when pointed at -// `dist/index.mjs` is the same defect class, with a green build to hide behind. -// So the resolution is one upward walk (below) that is indifferent to which of -// the three spellings arrived, and the shape is carried into every message so -// the reader can see which one this hook believed it got. - -import fs from 'node:fs'; -import path from 'node:path'; - -/** The package this hook overrides. Also the guard against a mis-aimed path. */ -export const CLIENT_PACKAGE_NAME = '@objectstack/client'; - -/** What the caller wires into its Vite config when the override is set. */ -export interface ClientDistInjection { - /** - * The `resolve.alias` target for `@objectstack/client`. - * - * `path.resolve(raw)` — the value the config has always used, deliberately - * NOT the realpath below. This card adds validation; it does not move the - * alias, the module ids that follow from it, or the chunk membership those - * ids decide. - */ - aliasTarget: string; - /** - * Realpath'd directory of the overriding client package — the root the - * dependency walk runs from, and the only place a realpath is required. - * - * Not cosmetic. Under pnpm the installed client is reached through - * `apps/console/node_modules/@objectstack/client`, a symlink into the store, - * and its dependencies live beside the store copy - * (`.pnpm/@objectstack+client@17.2.0_…/node_modules/@objectstack/{core,spec}`). - * Walking up from the SYMLINK path never passes that directory: it climbs - * `apps/console/node_modules` → `apps/console` → the repo root, where neither - * `@objectstack/core` nor a nested `node_modules` for it exists. Measured on - * this repo: a valid, installed-package override is REJECTED without the - * realpath — the check would fail the one input it must accept. - */ - packageDir: string; - /** Directories the dev server must be allowed to read (out-of-workspace). */ - fsAllow: string[]; -} - -/** - * How the raw value pointed at the package — carried into messages so a refusal - * says which spelling this hook resolved, not just that it refused. - */ -type OverrideShape = 'directory' | 'entry file'; - -// A function DECLARATION, not a `const` arrow: TypeScript only narrows on a -// never-returning call when the callee is declared this way, and the callers -// below rely on that narrowing to keep their own return types honest. -function fail(message: string): never { - throw new Error(`OBJECTSTACK_CLIENT_DIST: ${message}`); -} - -/** - * Whether an ancestor `node_modules` of `startDir` contains a directory named - * `name` — an upward directory walk, aimed at `node_modules/`. - * - * Deliberately NOT `require.resolve(name, { paths: [startDir] })`, though that - * reads as the obvious tool. `Module.globalPaths` is consulted REGARDLESS of an - * explicit `paths` list, and this repo's own `node_modules/.bin/vite` shim - * exports `NODE_PATH` at pnpm's flat hoist directory — so `require.resolve` - * reports an override's MISSING dependency as resolved, silently, every time - * the hook runs through the real `vite` CLI rather than a bare `node`. The same - * trap is documented at length on the spec hook's copy of this walk - * (`scripts/vite-objectstack-spec-dist.ts`), which is where it was measured. - * A plain directory walk consults nothing global. - */ -function packageResolvesFrom(startDir: string, name: string): boolean { - let dir = startDir; - for (;;) { - if (fs.existsSync(path.join(dir, 'node_modules', name))) return true; - const parent = path.dirname(dir); - if (parent === dir) return false; - dir = parent; - } -} - -/** - * The `@objectstack/client` package directory a raw override value names. - * - * One walk covers all three accepted spellings, which is the point: a directory - * starts the walk at itself, a file starts it at its own directory, and the - * walk stops at the nearest ancestor whose `package.json` IS the client. So - * `…/client`, `…/client/dist` and `…/client/dist/index.mjs` all resolve to - * `…/client`, and none of them can quietly resolve to "nothing to check". - * - * The `shape` branch below states where the walk starts; it is not what makes - * the file spelling work. Measured by ablation (see the suite's header): with - * the branch removed the walk still resolves an entry file correctly, because - * its first probe — `…/index.mjs/package.json` — simply misses and the next - * iteration lands on the containing directory. Kept because a reader should not - * have to derive that, and because the walk's shape is not a contract. - * - * The name match is load-bearing rather than decorative. Without it the walk - * would stop at whatever manifest happens to sit above the value — a framework - * monorepo root, or this repo — and then dutifully validate THAT package's - * dependencies: a check that always passes, on the wrong subject, which is the - * failure this hook exists to end. - */ -function findClientPackageDir( - raw: string, - resolved: string, - shape: OverrideShape -): string { - let dir = shape === 'directory' ? resolved : path.dirname(resolved); - const inspected: string[] = []; - for (;;) { - const manifestPath = path.join(dir, 'package.json'); - if (fs.existsSync(manifestPath)) { - inspected.push(manifestPath); - let name: unknown; - try { - name = (JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { name?: unknown }).name; - } catch (error) { - fail(`\`${manifestPath}\` is not readable JSON (${(error as Error).message})`); - } - if (name === CLIENT_PACKAGE_NAME) { - try { - return fs.realpathSync(dir); - } catch { - return dir; - } - } - } - const parent = path.dirname(dir); - if (parent === dir) break; - dir = parent; - } - return fail( - `\`${raw}\` is not inside a \`${CLIENT_PACKAGE_NAME}\` package — resolved to the ${shape} ` + - `\`${resolved}\` and walked up from there, finding ` + - `${inspected.length ? inspected.join(', ') : 'no package.json'}. Point the variable at a ` + - `built \`${CLIENT_PACKAGE_NAME}\` (its directory, its \`dist/\`, or an entry file inside it)` - ); -} - -/** - * Confirms the override's own `dependencies` resolve from `packageDir`. - * - * The check that makes the difference between failing HERE, naming the - * variable, and failing a whole build later naming a specifier the reader has - * no reason to connect to an override they set in their shell. - * - * Deliberately narrow, matching the spec hook's precedent: only the manifest's - * own `dependencies`. Not transitive ones — this is not a dependency-graph - * validator, and a broken transitive package fails the same way one frame - * further in, still at build time. Not `peerDependencies` either: a peer is BY - * DESIGN supplied by the consuming app rather than living in the override's own - * tree, so demanding it resolve from `packageDir` would fail a correctly built - * override and report a non-problem. - */ -function assertClientDependenciesResolve(packageDir: string, shape: OverrideShape): void { - const manifestPath = path.join(packageDir, 'package.json'); - // Already proven to be valid JSON at this exact path by `findClientPackageDir`, - // which is the only caller's only route here, so this re-read does not repeat - // that try/catch. - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { dependencies?: unknown }; - const dependencies = manifest.dependencies; - if (dependencies === null || typeof dependencies !== 'object' || Array.isArray(dependencies)) return; - - const unresolved = Object.keys(dependencies as Record).filter( - (name) => !packageResolvesFrom(packageDir, name) - ); - if (unresolved.length === 0) return; - - const isSingular = unresolved.length === 1; - fail( - `\`${manifestPath}\` declares ${isSingular ? 'a dependency' : 'dependencies'} that ` + - `${isSingular ? 'does' : 'do'} not resolve from \`${packageDir}\`: ${unresolved.join(', ')} — ` + - `the override (given as ${a(shape)}) points at a client build with no reachable ` + - `\`node_modules\` for ${isSingular ? 'it' : 'them'}. Every specifier the client imports from ` + - `${isSingular ? 'that package' : 'those packages'} would be left unresolved in a browser ` + - `bundle, which this build reports — when it reports it at all — as a failure naming the ` + - `specifier and never this variable. Install the override's own dependencies (or point at a ` + - `build where they are reachable) before setting \`OBJECTSTACK_CLIENT_DIST\`` - ); -} - -/** `'directory'` → `'a directory'`, `'entry file'` → `'an entry file'`. */ -function a(shape: OverrideShape): string { - return shape === 'entry file' ? `an ${shape}` : `a ${shape}`; -} - -/** - * The `vendor-objectstack` group test, widened so an injected client stays in - * that group — or `vendorChunkTest` itself, unchanged, when nothing is injected. - * - * An injected client resolves OUTSIDE `node_modules`, so the baseline test — - * written against `node_modules/@objectstack/` and pnpm's `@objectstack+` store - * paths — stops matching it, exactly as it stops matching an injected spec (the - * spec hook widens the same test for the same reason). Left unmatched, the - * client lands wherever its importers pull it. Measured on objectui `9f0c84a44` - * built by objectstack's `build-console.sh` (which always injects its own - * client): `chunk-membership.json` put the client's one module in `framework`, - * while `data-adapter` — no longer recursive since #9488 — imports it, and - * `framework` imports `data-adapter` back (`packages/react`'s - * `error-message.ts` → `isApiAccessDeniedError`). The client ships as CJS, so - * `data-adapter`'s top-level `require` runs before `framework` has defined it - * and the console dies at boot with `TypeError: u is not a function`. Before - * #9488 the recursive `data-adapter` group swallowed the client and hid the gap. - * With the client in `vendor-objectstack` the cycle is gone and the console - * boots; `chunk-membership.json` then reads `client: { 'vendor-objectstack': 1 }`. - * - * Widened, never replaced — the baseline stays whole and the override's - * directory joins it as one more alternative, matched on a path-separator - * boundary so a sibling directory sharing the prefix is not claimed. - * - * @param vendorChunkTest the config's `vendor-objectstack` group test so far - * @param injection the resolved override, or `null` when unset - */ -export function widenVendorChunkTestForClient( - vendorChunkTest: RegExp, - injection: ClientDistInjection | null, -): RegExp { - if (!injection) return vendorChunkTest; - const posixDir = injection.packageDir.split(path.sep).join('/'); - const escaped = posixDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - return new RegExp(`${vendorChunkTest.source}|${escaped}[\\\\/]`); -} - -/** - * Resolve the override, or `null` when it is unset. - * - * Inert when unset, exactly as the plain string alias it replaces was: `null` - * leaves the caller's alias table and `server.fs.allow` at their baseline - * values, so a production or CI build is byte-identical to one made before this - * check existed. - * - * Loud when set and wrong. Every way the override can fail — path absent, not a - * `@objectstack/client` package, an unreadable manifest, a declared dependency - * that cannot resolve from where the package sits — throws with the offending - * value named and `OBJECTSTACK_CLIENT_DIST` in the message. There is no - * tolerant fallback to the installed client on purpose: falling back would - * rebuild the exact silent skew the hook exists to expose, and the developer - * who set the variable could not tell the difference. - * - * @param raw the `OBJECTSTACK_CLIENT_DIST` value, unset or empty for none - */ -export function resolveClientDistInjection(raw: string | undefined): ClientDistInjection | null { - if (!raw || !raw.trim()) return null; - - const trimmed = raw.trim(); - const resolved = path.resolve(trimmed); - if (!fs.existsSync(resolved)) { - fail( - `\`${trimmed}\` does not exist (resolved to \`${resolved}\`) — nothing would be aliased at ` + - `\`${CLIENT_PACKAGE_NAME}\` and the build would fail later, naming the specifier instead ` + - `of this variable` - ); - } - - const shape: OverrideShape = fs.statSync(resolved).isDirectory() ? 'directory' : 'entry file'; - const packageDir = findClientPackageDir(trimmed, resolved, shape); - assertClientDependenciesResolve(packageDir, shape); - - return { - aliasTarget: resolved, - packageDir, - // Unchanged from the pre-check config, deliberately: `path.dirname` of the - // resolved value plus its parent, which covers `…//dist/index.mjs` → - // `…/` for the entry-file spelling. The dev server serves the override - // from outside the workspace root, which Vite's default `fs.allow` answers - // with a 403 and a blank page rather than an error. - fsAllow: [path.dirname(resolved), path.resolve(path.dirname(resolved), '..')], - }; -} diff --git a/ui/scripts/vite-objectstack-spec-dist.ts b/ui/scripts/vite-objectstack-spec-dist.ts deleted file mode 100644 index a92edf825de..00000000000 --- a/ui/scripts/vite-objectstack-spec-dist.ts +++ /dev/null @@ -1,892 +0,0 @@ -// `OBJECTSTACK_SPEC_DIST` — resolve `@objectstack/spec` at a locally built spec -// package instead of the one the lockfile installed. -// -// ## Why the hook exists (objectui#4854, ruled on objectstack#8134) -// -// The framework's `scripts/build-console.sh` already injects its own -// `@objectstack/client` into the console build, so a framework release never -// ships a console bundled against a stale published client. The same class of -// skew exists for `@objectstack/spec` and is currently SILENT: an authorable key -// added to the framework's `packages/spec` after the last spec publish is -// accepted and round-tripped by the server, while the Studio designer — bundled -// against the published spec — rejects it as an unrecognized key. Nothing asks -// whether the vendored spec carries the surface the framework declares. -// -// ## Why the client hook does not transfer as-is -// -// The client override is one prefix alias to a package directory, which is safe -// because `@objectstack/client`'s exports map has exactly one entry and nothing -// imports a subpath. `@objectstack/spec` is a different shape — measured on the -// installed 17.0.0-rc.6: 18 exports entries, 17 of them reached by this -// repository's own imports, across 29 packages. Every entry redirects into -// `dist/`, so a bare prefix alias rewrites `@objectstack/spec/ui` to -// `SPEC_PKG/ui`, a path that does not exist. A Vite string alias is prefix -// replacement; it never consults the target's exports map. -// -// So this module reads the exports map OF THE OVERRIDE and emits one alias per -// entry. Deriving beats prescribing here, and the map itself says why: the -// obvious rule ("subpath NAME lives at dist/NAME/index.mjs") is wrong for -// `./openapi.json`, which the map redirects to `json-schema/openapi.json` — a -// hand-written table would have shipped a hook that mis-resolves it. -// -// ## Whose precedence wins (objectui#9408) -// -// A conditional exports value expresses precedence ONE way: the ORDER OF ITS -// OWN KEYS. Node and every bundler match by walking that order and taking the -// first key the caller's condition set satisfies. The caller's side of the -// contract is the SET — "these are the conditions I satisfy" — never a ranking -// of its own, because ranking is the package's to declare. -// -// This module used to walk its own `['import', 'module', 'browser', 'default']` -// array instead, so the LAST word on precedence belonged to the consumer. The -// spec's map declares `browser` FIRST on five entries (`.`, `./data`, -// `./system`, `./kernel`, `./cloud` on 17.4.0), so the array's `import`-before- -// `browser` ranking silently overrode a deliberate upstream declaration and the -// `browser` arm became unreachable. Measured on 17.4.0, both sides in one run: -// this module returned `dist/index.mjs` for all five while Vite's own resolver -// — the resolver this hook exists to MODEL — returned `dist/browser/index.mjs`. -// -// That divergence is the bug, and it is the hook's own charter that condemns -// it: an override build is supposed to differ from a normal build in WHICH SPEC -// it bundles, never in WHICH ARM of that spec it picks. Reordering the array -// would have swapped one consumer-side ranking for another and broken any -// caller that genuinely wants the Node arm; honouring the map's key order is -// correct for every caller, because each one then says what it satisfies and -// the package says what it prefers. -// -// ## Three properties this module holds on purpose -// -// - **Loud, never lenient.** Every way the override can be wrong — path absent, -// not the spec package, an exports entry naming a file the built package does -// not contain, one of its own declared dependencies unresolvable from where it -// sits — throws with the offending value named. A tolerant fallback to -// the installed spec would silently rebuild the exact skew the hook exists to -// kill, and the framework guard could not tell the difference. -// - **Audible, never silent.** Every entry carries the condition path that -// chose it (`browser > import > default`), and `formatConditionReport` renders -// the table the caller prints. A resolver that picks an arm without saying so -// fails the only way that matters here: the first symptom of a wrong pick is a -// bundler error at some later pin bump, in a package nobody connects to this -// file. The report is what makes the pick falsifiable at the moment it happens -// rather than a build-length later. -// - **Inert when unset.** `resolveSpecDistInjection(undefined, …)` returns -// `null` and the caller's config keeps every baseline value, identity -// included. -// -// ## One zod instance (objectui#11327) -// -// The injected spec is a different INSTALL TREE, not only a different spec. Its -// bare `zod` import resolves from where the override sits — the framework's own -// `node_modules` — so an injected console bundled TWO zod instances: the -// console's (in `vendor-zod`) and the spec's (folded into `vendor-objectstack`). -// A schema built by one and walked by the other is only as safe as the two -// copies' internals happen to agree. At the console pin objectui#11327 was -// reported against (zod 4.4.3, spec built with 4.6.1) they did not: the -// metadata-admin schema modules call the console's `z.toJSONSchema` over the -// spec's `ManifestSchema`, the spec's copy hands it a processor that pushes onto -// a context field the console's copy never created, the walk throws, and the -// Studio's spec-derived forms lose their fields (the New Package dialog could -// not create a package). Two copies that happen to agree — a later console -// zod walked those same schemas without throwing — are still two instances, so -// the rule is ONE, not "matching". -// -// `resolve.dedupe` cannot deliver it here, measured on objectui#11327 against -// the real console config: Vite resolves a deduped package from its ROOT, the -// console's root (`apps/console`) declares no `zod`, the root lookup finds -// nothing, and Vite silently falls back to the importer's own copy — a build -// with `zod` added to `dedupe` emitted the same two copies, byte-identical. -// So the injection carries its own redirect (`pinZodToConsoleCopy`): every bare -// `zod` specifier is resolved from a declared consumer of zod inside this -// workspace, the console's copy, and `assertSpecZodParity` refuses the build -// when that copy is outside the range the injected spec declares — handing the -// spec an older zod than it requires must never be silent either. -// -// `assertSingleZodInstance` then MEASURES the outcome on the emitted chunks -// rather than trusting the mechanism, in every console build, injected or not: -// the redirect above exists because a mechanism that read as correct (dedupe) -// did nothing, and the next one is owed the same distrust. - -import fs from 'node:fs'; -import path from 'node:path'; -import type { Plugin } from 'vite'; - -/** The package this hook overrides. Also the guard against a mis-aimed path. */ -export const SPEC_PACKAGE_NAME = '@objectstack/spec'; - -/** - * The conditions a browser/ESM bundler SATISFIES. A set, never a ranking. - * - * Membership only: this answers "would the console's bundler match this key", - * and the exports map answers "which matching key wins" with its own key order. - * Listing them in a ranked-looking order here is what caused objectui#9408, so - * the declaration order below carries NO meaning — sorted alphabetically to - * keep it that way. - * - * `types` is deliberately absent, and under key-order resolution that omission - * became load-bearing rather than incidental: `types` sits FIRST inside every - * condition object in the spec's map, so a walk in declaration order reaches it - * before anything else and would alias every subpath at a `.d.mts` file. It is - * excluded because a bundler emitting JavaScript does not satisfy it — the same - * reason `require` is absent — which is exactly the shape of question this set - * is supposed to answer. - */ -const IMPORT_CONDITIONS: ReadonlySet = new Set(['browser', 'default', 'import', 'module']); - -/** Character class matching either path separator, for a generated `RegExp`. */ -const SEPARATOR_CLASS = '[\\\\/]'; - -/** What the caller wires into its Vite config when the override is set. */ -export interface SpecDistInjection { - /** Absolute (realpath'd) directory of the overriding spec package. */ - packageDir: string; - /** - * `resolve.alias` entries, SUBPATHS FIRST and the bare specifier last. - * - * The order is load-bearing. Vite matches a string `find` when the specifier - * equals it or starts with it plus a slash, first match wins, so a bare - * `@objectstack/spec` entry placed first would swallow every subpath. Last, it - * still catches specifiers the override's map does NOT declare and rewrites - * them to a path that cannot exist — a resolve error naming the specifier, - * which is the intended outcome: an undeclared subpath must not quietly fall - * through to the installed spec. - */ - aliases: Record; - /** Directories the dev server must be allowed to read (out-of-workspace). */ - fsAllow: string[]; - /** - * Every exports entry with the condition arm that chose it — the audible half - * of objectui#9408. - * - * Carried on the injection rather than logged from inside the resolver so the - * module stays a pure function, and so the caller decides when and where the - * table appears. `formatConditionReport` renders it. - */ - resolutions: SpecExportResolution[]; - /** `advancedChunks` test that keeps the injected spec in the vendor chunk. */ - vendorChunkTest: RegExp; - /** - * Module-id test that still RECOGNISES the injected spec as the spec. - * - * Distinct from `vendorChunkTest` on purpose. That one answers "which modules - * belong in the `vendor-objectstack` group" — the whole scope minus the - * linter — and an eager `@objectstack/client` satisfies it. This one answers - * "which modules ARE `@objectstack/spec`", which is what the console's - * build-time counter-probe (`assertLazyLinterStaysLazy`) has to be able to - * find before it trusts its own graph walk. - * - * Publishing it here rather than letting that consumer hardcode its own is the - * whole lesson of objectui#5388: the injection rewrites all 18 spec specifiers - * to absolute paths in the overriding tree — ids with no `@objectstack` - * segment anywhere — so a private `/@objectstack[\\/+]spec/` matched zero - * modules, the counter-probe correctly refused a verdict, and every build made - * with the override set died in `generateBundle` (measured from the consumer - * side in objectstack#10136). Both consumers now read one producer. - */ - specModuleTest: RegExp; - /** - * The console's own zod — the one copy every bare `zod` import in the - * injected build resolves to, already checked against the range the injected - * spec declares (objectui#11327). - */ - consoleZod: ConsoleZod; - /** - * The resolver plugin that pins every bare `zod` specifier — the injected - * spec's included — to `consoleZod`. The caller registers it; see - * `pinZodToConsoleCopy` for why `resolve.dedupe` is not a substitute. - */ - singleZodPlugin: Plugin; -} - -/** Escapes a literal string for embedding in a `RegExp` source. */ -export function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -/** One exports-map entry, resolved, with the condition path that chose it. */ -export interface SpecExportResolution { - /** The bare or subpath specifier, e.g. `@objectstack/spec/data`. */ - specifier: string; - /** The exports-map key it came from, e.g. `./data`. */ - exportKey: string; - /** Absolute path of the file the specifier resolves to. */ - target: string; - /** - * Conditions entered, outermost first — `['browser', 'import', 'default']`. - * - * Empty for an entry whose value is a bare string, which names its file with - * no condition at all (`./package.json` in the real map). - */ - conditionPath: string[]; - /** - * Sibling keys this module ALSO satisfies but that the map ranked lower, in - * the map's order. Non-empty only where the package expressed a real - * preference — the entries where getting the order wrong is observable, and - * so the ones worth reading in a build log. - */ - passedOver: string[]; -} - -/** A resolved leaf plus how it was reached. */ -interface PickedTarget { - target: string; - conditionPath: string[]; - passedOver: string[]; -} - -/** - * The leaf one exports-map value resolves to for a browser/ESM bundler. - * - * Walks the value's OWN key order and takes the first key `IMPORT_CONDITIONS` - * satisfies — the algorithm Node and every bundler implement, and the reason - * objectui#9408 was a bug and not a preference. The consumer contributes the - * SET; the package contributes the ORDER. - * - * `Object.keys` is insertion order here, which is the map's declared order: - * exports keys are condition names and `./`-prefixed subpaths, never the - * integer-like keys JavaScript would hoist to the front of the enumeration. - * - * Returns `null` for an entry that resolves to nothing a bundler could take - * (e.g. one exported only under `require`), so the caller can name it. - */ -function pickImportTarget(value: unknown, trail: string[] = []): PickedTarget | null { - if (typeof value === 'string') return { target: value, conditionPath: trail, passedOver: [] }; - if (value === null || typeof value !== 'object') return null; - if (Array.isArray(value)) { - // A fallback array is the one place the PACKAGE ranks alternatives itself, - // so first-that-resolves is its declared order, not ours. - for (const candidate of value) { - const hit = pickImportTarget(candidate, trail); - if (hit) return hit; - } - return null; - } - const conditions = value as Record; - const satisfiable = Object.keys(conditions).filter((key) => IMPORT_CONDITIONS.has(key)); - for (let i = 0; i < satisfiable.length; i += 1) { - const condition = satisfiable[i]; - const hit = pickImportTarget(conditions[condition], [...trail, condition]); - if (!hit) continue; - // Report the OUTERMOST level that had a real choice. That is the level - // where a precedence mistake is observable — `browser` vs `import` on the - // entry itself, not `default` vs nothing three levels in — so an outer - // level with alternatives outranks whatever an inner level recorded. - const lower = satisfiable.slice(i + 1); - return { ...hit, passedOver: lower.length > 0 ? lower : hit.passedOver }; - } - return null; -} - -// A function DECLARATION, not a `const` arrow: TypeScript only narrows on a -// never-returning call when the callee is declared this way, and every caller -// below relies on the narrowing to keep its own return type honest. -function fail(message: string): never { - throw new Error(`OBJECTSTACK_SPEC_DIST: ${message}`); -} - -/** - * The spec package directory a raw override value names. - * - * Accepts what the client hook accepts and a little more — the package dir, its - * `dist/`, or a built entry file inside it — by walking up to the nearest - * `package.json` and requiring it to BE the spec package. Realpath'd, because a - * symlinked override would otherwise produce alias targets whose module ids - * (which Vite realpaths) never match the vendor-chunk test built from them. - */ -function findSpecPackageDir(raw: string): string { - const start = path.resolve(raw); - if (!fs.existsSync(start)) { - fail(`\`${raw}\` does not exist (resolved to \`${start}\`)`); - } - let dir = fs.statSync(start).isDirectory() ? start : path.dirname(start); - const inspected: string[] = []; - for (;;) { - const manifestPath = path.join(dir, 'package.json'); - if (fs.existsSync(manifestPath)) { - inspected.push(manifestPath); - let name: unknown; - try { - name = (JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { name?: unknown }).name; - } catch (error) { - fail(`\`${manifestPath}\` is not readable JSON (${(error as Error).message})`); - } - if (name === SPEC_PACKAGE_NAME) { - try { - return fs.realpathSync(dir); - } catch { - return dir; - } - } - } - const parent = path.dirname(dir); - if (parent === dir) break; - dir = parent; - } - return fail( - `\`${raw}\` is not inside a \`${SPEC_PACKAGE_NAME}\` package — ` + - `walked up from \`${start}\` and found ${inspected.length ? inspected.join(', ') : 'no package.json'}` - ); -} - -/** - * Every specifier the package's exports map declares, resolved to the absolute - * file a browser/ESM bundler takes, with the condition path that chose it. - * - * Cross-checked in `scripts/__tests__/vite-objectstack-spec-dist.test.ts` - * against two resolvers, because no single one covers the map: Node's own - * (`import.meta.resolve`) for the entries it can express, and a REAL Vite - * build for the `browser`-carrying ones, which Node cannot express at all — - * Node does not satisfy `browser`, so it answers with the Node arm by - * construction. Vite is the resolver this hook models, so it is the oracle - * that counts where the two disagree. Either way this is not a second opinion - * about the map; it agrees with the algorithm. - */ -export function readSpecExportResolutions(packageDir: string): SpecExportResolution[] { - const manifestPath = path.join(packageDir, 'package.json'); - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { exports?: unknown }; - const exportsMap = manifest.exports; - if (exportsMap === null || typeof exportsMap !== 'object' || Array.isArray(exportsMap)) { - fail(`\`${manifestPath}\` declares no exports map, so no subpath can be resolved`); - } - - const resolutions: SpecExportResolution[] = []; - for (const [key, value] of Object.entries(exportsMap as Record)) { - if (!key.startsWith('.')) { - fail(`\`${manifestPath}\` exports key \`${key}\` is a condition, not a subpath — unsupported`); - } - if (key.includes('*')) { - // A pattern cannot be expressed as one exact alias, and guessing would - // reintroduce the silent half-injection this hook exists to prevent. - fail(`\`${manifestPath}\` exports key \`${key}\` is a wildcard pattern — unsupported by this hook`); - } - const picked = pickImportTarget(value); - if (!picked) { - fail( - `\`${manifestPath}\` exports key \`${key}\` resolves to nothing under ` + - `${[...IMPORT_CONDITIONS].join('/')}` - ); - } - const absolute = path.resolve(packageDir, picked.target); - if (!fs.existsSync(absolute)) { - fail( - `\`${manifestPath}\` exports key \`${key}\` names \`${picked.target}\`, which the built package does not contain ` + - `(expected \`${absolute}\`) — build the spec package before injecting it` - ); - } - const specifier = key === '.' ? SPEC_PACKAGE_NAME : `${SPEC_PACKAGE_NAME}/${key.slice(2)}`; - resolutions.push({ - specifier, - exportKey: key, - target: absolute, - conditionPath: picked.conditionPath, - passedOver: picked.passedOver, - }); - } - if (!resolutions.some((r) => r.specifier === SPEC_PACKAGE_NAME)) { - fail(`\`${manifestPath}\` exports map has no \`.\` entry, so the bare specifier cannot be resolved`); - } - return resolutions; -} - -/** - * Every specifier the package's exports map declares, mapped to its file. - * - * The shape callers that only need the table want; `readSpecExportResolutions` - * is the same pass with the condition path each choice took kept. - */ -export function readSpecExportTargets(packageDir: string): Map { - return new Map(readSpecExportResolutions(packageDir).map((r) => [r.specifier, r.target])); -} - -/** - * The build-log table: which condition arm each specifier came from. - * - * Lives beside the resolver rather than in the console config for the reason - * `specModuleTest` does — one producer, so a second consumer cannot drift into - * reporting something the resolver did not actually do. Entries where the map - * offered an alternative this module ALSO satisfies are marked, because those - * are the only ones where precedence was decided rather than forced. - */ -export function formatConditionReport(injection: SpecDistInjection): string[] { - const width = Math.max(...injection.resolutions.map((r) => r.specifier.length)); - const lines = [ - `OBJECTSTACK_SPEC_DIST: ${injection.packageDir}`, - ` ${injection.resolutions.length} exports entries aliased; condition arm chosen per entry:`, - ]; - for (const r of [...injection.resolutions].sort((a, b) => a.specifier.localeCompare(b.specifier))) { - const arm = r.conditionPath.length > 0 ? r.conditionPath.join(' > ') : '(unconditional)'; - const over = r.passedOver.length > 0 ? ` [ranked above: ${r.passedOver.join(', ')}]` : ''; - const file = path.relative(injection.packageDir, r.target); - lines.push(` ${r.specifier.padEnd(width)} ${arm} -> ${file}${over}`); - } - return lines; -} - -/** - * Whether an ancestor `node_modules` of `startDir` contains a directory named - * `name` — the same upward walk `findSpecPackageDir` does for `package.json`, - * aimed at `node_modules/` instead. - * - * Deliberately NOT `require.resolve(name, { paths: [startDir] })`, though that - * reads as the obvious tool for the job. Measured against this monorepo's own - * `apps/console/node_modules/.bin/vite` shim: it exports `NODE_PATH` pointing - * at pnpm's flat `.pnpm/node_modules` hoist directory, and Node's `paths` - * option does not suppress `NODE_PATH` — `Module.globalPaths` is consulted - * REGARDLESS of an explicit `paths` list. Every workspace dependency pnpm has - * ever hoisted lives there, `zod` included, so `require.resolve` reported the - * override's own missing `zod` as resolved — checked from `packageDir`, - * skipped straight over the failure this function exists to catch, silently, - * every time this hook runs through the real `vite` CLI rather than a bare - * `node` invocation. A plain directory walk consults nothing global. - */ -function packageResolvesFrom(startDir: string, name: string): boolean { - let dir = startDir; - for (;;) { - if (fs.existsSync(path.join(dir, 'node_modules', name))) return true; - const parent = path.dirname(dir); - if (parent === dir) return false; - dir = parent; - } -} - -/** - * Confirms the override's own `dependencies` resolve from `packageDir` — - * the check `readSpecExportTargets` does NOT do. - * - * That function validates the override's own files: every exports entry - * names something the built package contains. It says nothing about what - * those files `import`. A spec dist built without a reachable `node_modules` - * passes it cleanly and only fails once a consuming bundler tries to resolve - * a bare specifier deep inside the injected build — a build-length after the - * override was read, in an error that names the missing package (`zod`, - * observed) and never `OBJECTSTACK_SPEC_DIST`. - * - * Checked with `packageResolvesFrom`'s directory walk, not `require.resolve` - * — see that function's own comment for why the obvious choice is wrong here. - * Either way the cost is the same: directory-existence checks, no module - * evaluation, nowhere near the cost of a bundler resolve pass. - * - * Deliberately narrow: only the manifest's own `dependencies` are checked — - * not transitive dependencies (this is not a dependency-graph validator, and - * a broken transitive package fails the same way, just one frame further - * into that package's own resolution — still at build time, still before any - * bundling), and not `peerDependencies`. A peer dependency is BY DESIGN - * supplied by the consuming app rather than living inside the override's own - * tree, so requiring it to resolve from `packageDir` would fail a correctly - * built override and misreport a non-problem as one. - */ -function assertSpecDependenciesResolve(packageDir: string): void { - const manifestPath = path.join(packageDir, 'package.json'); - // Already proven to be valid JSON at this exact path by `findSpecPackageDir` - // (the caller of this function), so — matching `readSpecExportTargets` next - // to it — this re-read does not repeat that try/catch. - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { dependencies?: unknown }; - const dependencies = manifest.dependencies; - if (dependencies === null || typeof dependencies !== 'object' || Array.isArray(dependencies)) return; - - const unresolved = Object.keys(dependencies as Record).filter( - (name) => !packageResolvesFrom(packageDir, name) - ); - if (unresolved.length > 0) { - const isSingular = unresolved.length === 1; - fail( - `\`${manifestPath}\` declares ${isSingular ? 'a dependency' : 'dependencies'} that ` + - `${isSingular ? 'does' : 'do'} not resolve from \`${packageDir}\`: ${unresolved.join(', ')} — ` + - `the override points at a spec dist with no reachable \`node_modules\` for ` + - `${isSingular ? 'it' : 'them'}. Install the override's own dependencies (or point at a build ` + - `where they are reachable) before setting \`OBJECTSTACK_SPEC_DIST\`` - ); - } -} - -/* -------------------------------------------------------------------------- */ -/* One zod instance (objectui#11327) — see the file header for the why. */ -/* -------------------------------------------------------------------------- */ - -/** The zod the console's own code resolves: the ONE copy a console build may carry. */ -export interface ConsoleZod { - /** The workspace directory the copy was resolved FROM — a declared consumer of zod. */ - anchorDir: string; - /** Realpath of the resolved `zod` package directory. */ - packageDir: string; - /** That package's own `version`. */ - version: string; -} - -/** - * The `zod` package `anchorDir` resolves, found by the same upward directory - * walk `packageResolvesFrom` does, and for the same reason: `require.resolve` - * consults the `NODE_PATH` pnpm's `vite` shim exports, which reaches a hoisted - * zod from anywhere and would answer for a directory that declares none. - */ -export function resolveConsoleZod(anchorDir: string): ConsoleZod { - const start = path.resolve(anchorDir); - for (let dir = start; ; ) { - const candidate = path.join(dir, 'node_modules', 'zod'); - const manifestPath = path.join(candidate, 'package.json'); - if (fs.existsSync(manifestPath)) { - let version: unknown; - try { - version = (JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { version?: unknown }).version; - } catch (error) { - fail(`\`${manifestPath}\` is not readable JSON (${(error as Error).message})`); - } - if (typeof version !== 'string' || version.length === 0) { - fail(`\`${manifestPath}\` declares no \`version\`, so the console's zod cannot be compared with the spec's range`); - } - return { anchorDir: start, packageDir: fs.realpathSync(candidate), version }; - } - const parent = path.dirname(dir); - if (parent === dir) break; - dir = parent; - } - return fail( - `the console's own \`zod\` does not resolve from \`${start}\` — no \`node_modules/zod\` on the walk ` + - `up from it. The anchor must be a workspace package that declares \`zod\`, because its copy is the ` + - `one every \`zod\` import in an injected build is pinned to` - ); -} - -/** A plain `X.Y.Z` release, and nothing else — no prerelease, no build metadata. */ -const PLAIN_RELEASE = /^(\d+)\.(\d+)\.(\d+)$/; - -/** One comparator of a range: an optional operator and a plain release. */ -const RANGE_COMPARATOR = /^(\^|~|>=|<=|>|<|=)?(\d+)\.(\d+)\.(\d+)$/; - -type Release = [number, number, number]; - -function compareReleases(a: Release, b: Release): number { - for (let i = 0; i < 3; i += 1) { - if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1; - } - return 0; -} - -function satisfiesComparator(version: Release, comparator: string, range: string): boolean { - const match = RANGE_COMPARATOR.exec(comparator); - if (!match) { - throw new Error(`range \`${range}\` contains \`${comparator}\`, which this check does not read`); - } - const bound: Release = [Number(match[2]), Number(match[3]), Number(match[4])]; - const order = compareReleases(version, bound); - switch (match[1] ?? '=') { - case '=': - return order === 0; - case '>': - return order > 0; - case '>=': - return order >= 0; - case '<': - return order < 0; - case '<=': - return order <= 0; - case '~': - return order >= 0 && version[0] === bound[0] && version[1] === bound[1]; - default: - // `^`: the left-most non-zero component is fixed. - if (order < 0) return false; - if (bound[0] > 0) return version[0] === bound[0]; - if (bound[1] > 0) return version[0] === 0 && version[1] === bound[1]; - return order === 0; - } -} - -/** - * Whether `version` satisfies `range`, read narrowly. - * - * Hand-written rather than the `semver` package, which the repository root does - * not depend on — the same trade `minVersionOf` in - * `scripts/check-spec-range-floors.mjs` makes. The narrowness is the safety - * property: `X.Y.Z`, `=`, `^`, `~`, `>`, `>=`, `<`, `<=`, space-separated - * comparator sets and `||` unions are read; any other spelling (`4.x`, `*`, - * `workspace:`, a prerelease) THROWS, so an unreadable range can never fall - * through to "satisfied". - */ -export function versionSatisfiesRange(version: string, range: string): boolean { - const parsed = PLAIN_RELEASE.exec(version.trim()); - if (!parsed) throw new Error(`\`${version}\` is not a plain X.Y.Z release`); - const release: Release = [Number(parsed[1]), Number(parsed[2]), Number(parsed[3])]; - const text = range.trim(); - if (text.length === 0) throw new Error('an empty range names nothing to satisfy'); - return text.split('||').some((branch) => { - const comparators = branch.trim().split(/\s+/).filter(Boolean); - if (comparators.length === 0) throw new Error(`range \`${text}\` has an empty \`||\` branch`); - return comparators.every((comparator) => satisfiesComparator(release, comparator, text)); - }); -} - -/** - * Refuses an override whose declared `zod` range the console's zod does not - * satisfy. - * - * Under the injection the spec's schemas are built and walked by the console's - * zod — that is what `pinZodToConsoleCopy` does — so the console's copy has to - * be one the spec accepts. Without this check the redirect would quietly hand a - * spec that requires `^4.6.1` a 4.4.3 (the console pin objectui#11327 was - * reported against), and nothing in the build would say so. Both runtime - * fields are read; a range in either that the console's version misses fails. - * An override that declares no `zod` has no range to compare, and its bundle - * is still held to one instance by the redirect and `assertSingleZodInstance`. - */ -function assertSpecZodParity(packageDir: string, consoleZod: ConsoleZod): void { - const manifestPath = path.join(packageDir, 'package.json'); - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Record; - for (const field of ['dependencies', 'peerDependencies'] as const) { - const declared = manifest[field]; - if (declared === null || typeof declared !== 'object' || Array.isArray(declared)) continue; - const range = (declared as Record).zod; - if (typeof range !== 'string') continue; - let satisfied: boolean; - try { - satisfied = versionSatisfiesRange(consoleZod.version, range); - } catch (error) { - fail( - `\`${manifestPath}\` declares \`zod\` as \`${range}\` in \`${field}\`, which cannot be read here ` + - `(${(error as Error).message}) — refusing to guess whether the console's zod ` + - `${consoleZod.version} satisfies it` - ); - } - if (!satisfied) { - fail( - `\`${manifestPath}\` declares \`zod\` \`${range}\` in \`${field}\`, but the console's zod is ` + - `${consoleZod.version} (\`${consoleZod.packageDir}\`, resolved from \`${consoleZod.anchorDir}\`). ` + - `An injected build pins every \`zod\` import to that one copy, so the spec's schemas would be ` + - `built and walked by a zod outside the range they declare. Move objectui's zod into ` + - `\`${range}\` (its lockfile), or inject a spec whose range admits ${consoleZod.version}` - ); - } - } -} - -/** Every bare `zod` specifier — the package itself and its exports-map subpaths. */ -const ZOD_SPECIFIER = /^zod(?:\/|$)/; - -/** The name `pinZodToConsoleCopy`'s plugin registers under. */ -export const SINGLE_ZOD_PLUGIN_NAME = 'objectstack-spec-dist-single-zod'; - -/** Whether `file` lies inside `dir` (both absolute). */ -function isInside(file: string, dir: string): boolean { - const relative = path.relative(dir, file); - return relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative); -} - -/** - * A pre-resolver that pins every bare `zod` specifier to the console's copy: - * `resolve.dedupe` with a root that can actually resolve the package. - * - * Each `zod` / `zod/…` import — from the injected spec's out-of-tree files, from - * the workspace, from a dependency — is re-resolved through the rest of the - * resolver chain AS IF imported from `consoleZod.anchorDir`, so the package's - * own exports map and the import's own kind still decide which file a subpath - * lands on; only the install tree it is looked up in changes. `enforce: 'pre'` - * is load-bearing: an ordinary plugin's `resolveId` runs after Vite's own - * resolver, which would already have answered with the importer's copy. - * - * Loud on both ways it can go wrong: a specifier that no longer resolves from - * the anchor, and one that resolves OUTSIDE the anchor's zod (a resolver change - * that quietly stops honouring the importer) each fail the build by name. - */ -export function pinZodToConsoleCopy(consoleZod: ConsoleZod): Plugin { - // Any existing file in the anchor directory serves: the resolver reads only - // its directory. A package's manifest is the one file it is certain to have. - const anchorImporter = path.join(consoleZod.anchorDir, 'package.json'); - return { - name: SINGLE_ZOD_PLUGIN_NAME, - enforce: 'pre', - resolveId: { - filter: { id: ZOD_SPECIFIER }, - async handler(source, _importer, options) { - if (!ZOD_SPECIFIER.test(source)) return null; - const resolved = await this.resolve(source, anchorImporter, { kind: options.kind, skipSelf: true }); - if (!resolved) { - this.error( - `[${SINGLE_ZOD_PLUGIN_NAME}] \`${source}\` does not resolve from the console's zod anchor ` + - `\`${consoleZod.anchorDir}\` (objectui#11327)` - ); - } - const file = resolved.id.replace(/^\0/, '').split('?')[0]; - if (!isInside(file, consoleZod.packageDir)) { - this.error( - `[${SINGLE_ZOD_PLUGIN_NAME}] \`${source}\` resolved from \`${consoleZod.anchorDir}\` to ` + - `\`${resolved.id}\`, outside the console's zod \`${consoleZod.packageDir}\` — the redirect ` + - `no longer pins zod to one copy (objectui#11327)` - ); - } - return resolved; - }, - }, - }; -} - -/** The name `assertSingleZodInstance`'s plugin registers under. */ -export const SINGLE_ZOD_GUARD_NAME = 'assert-single-zod-instance'; - -/** - * The `zod` package directory a module id lives in, or `null`. - * - * The LAST `node_modules/zod/` segment wins, so pnpm's store path - * (`…/.pnpm/zod@4.6.5/node_modules/zod/v4/core/core.js`) and a copy nested - * under another package both answer with the directory that IS the package. - * A sibling such as `zod-to-json-schema` does not match: the segment must be - * exactly `zod`. A leading `\0` (a virtual-module marker) and a query are - * dropped first, so one copy reached two ways is not counted twice. - */ -export function zodPackageDirOf(id: string): string | null { - const match = /^(.*[\\/]node_modules[\\/]zod)[\\/]/.exec(id.replace(/^\0/, '').split('?')[0]); - return match ? match[1] : null; -} - -/** The minimal slice of a bundle `assertSingleZodInstance` reads. */ -interface GuardOutput { - type: string; - fileName: string; - modules?: Record; -} - -/** The minimal slice of the plugin context `assertSingleZodInstance` reads. */ -interface GuardContext { - error(message: string): never; - getModuleInfo?(id: string): { importers?: readonly string[] } | null; -} - -/** - * Build-time guard: the console bundle carries exactly ONE zod instance. - * - * Counted on what the build EMITTED — every module id rendered into a chunk, - * grouped by the `zod` package directory it lives in — never on how the - * resolver was configured, because the configuration is exactly what fooled - * the first fix attempt (see the file header). Two directories fail the build - * naming each copy, its version, the chunks it landed in and who imported it; - * two directories holding the SAME version fail too, since an instance is what - * the crash keys on, not a version. - * - * Zero directories fail as well, as a counter-probe: the console always bundles - * zod (the `@object-ui/types` validators, the metadata-admin schema modules), so - * zero means this matcher has gone blind, and a negative guard that cannot see - * its subject would read green forever — the shape `assertLazyLinterStaysLazy` - * in the console config refuses for the same reason. - */ -export function assertSingleZodInstance(): Plugin { - return { - name: SINGLE_ZOD_GUARD_NAME, - apply: 'build', - generateBundle(_options, bundle) { - const context = this as unknown as GuardContext; - const copies = new Map; importers: Set }>(); - let chunkCount = 0; - for (const output of Object.values(bundle as Record)) { - if (output.type !== 'chunk') continue; - chunkCount += 1; - for (const id of Object.keys(output.modules ?? {})) { - const dir = zodPackageDirOf(id); - if (!dir) continue; - const copy = copies.get(dir) ?? { chunks: new Set(), importers: new Set() }; - copies.set(dir, copy); - copy.chunks.add(output.fileName); - for (const importer of context.getModuleInfo?.(id)?.importers ?? []) { - if (zodPackageDirOf(importer) !== dir) copy.importers.add(importer); - } - } - } - - if (copies.size === 0) { - context.error( - `[${SINGLE_ZOD_GUARD_NAME}] counter-probe failed: none of the ${chunkCount} chunks holds a module ` + - `under a \`node_modules/zod/\` directory, so this guard can no longer fail. The console always ` + - `bundles zod (the \`@object-ui/types\` validators, the metadata-admin schema modules), so zero ` + - `is a statement about this matcher — zod reached through a path without that segment — never ` + - `a clean bundle (objectui#11327)` - ); - } - if (copies.size > 1) { - const lines = [...copies.entries()].map(([dir, copy]) => { - let version = '?'; - try { - version = String( - (JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')) as { version?: unknown }).version - ); - } catch { - // The listing still names the directory; the version is a courtesy. - } - const importers = [...copy.importers].slice(0, 3); - return ( - ` - zod ${version} at \`${dir}\` — in ${[...copy.chunks].join(', ')}` + - (importers.length > 0 ? `; imported by ${importers.join(', ')}` : '') - ); - }); - context.error( - `[${SINGLE_ZOD_GUARD_NAME}] the console bundle carries ${copies.size} zod instances and must ` + - `carry exactly one (objectui#11327):\n${lines.join('\n')}\n` + - `A schema built by one instance and walked by another holds only while their internals happen ` + - `to agree — when they did not, \`z.toJSONSchema\` over a spec schema threw and the Studio's ` + - `spec-derived forms lost their fields — and two copies that agree, even on a version, are ` + - `still two instances. If \`OBJECTSTACK_SPEC_DIST\` is set, the single-zod redirect ` + - `(\`pinZodToConsoleCopy\` in \`scripts/vite-objectstack-spec-dist.ts\`) did not take. Otherwise ` + - `objectui's own lockfile resolves zod twice; dedupe the lockfile. Adding \`zod\` to ` + - `\`resolve.dedupe\` is not a fix: the console root declares no zod, so Vite falls back to each ` + - `importer's own copy without a word.` - ); - } - }, - }; -} - -/** - * Resolve the override, or `null` when it is unset. - * - * @param raw the `OBJECTSTACK_SPEC_DIST` value, unset or empty for none - * @param vendorChunkTest the config's baseline `vendor-objectstack` group test, - * widened (never replaced) with the override's location - * @param specModuleTest the config's baseline "this module id is the spec" test, - * widened the same way — see `SpecDistInjection` - * @param consoleZodFrom a workspace package that declares `zod`; the copy it - * resolves is the one the injected build is pinned to - * (objectui#11327). Required, so a caller cannot forget - * it and silently bundle the spec's own zod. - */ -export function resolveSpecDistInjection( - raw: string | undefined, - { - vendorChunkTest, - specModuleTest, - consoleZodFrom, - }: { vendorChunkTest: RegExp; specModuleTest: RegExp; consoleZodFrom: string } -): SpecDistInjection | null { - if (!raw || !raw.trim()) return null; - - const packageDir = findSpecPackageDir(raw.trim()); - assertSpecDependenciesResolve(packageDir); - const consoleZod = resolveConsoleZod(consoleZodFrom); - assertSpecZodParity(packageDir, consoleZod); - const resolutions = readSpecExportResolutions(packageDir); - const targets = new Map(resolutions.map((r) => [r.specifier, r.target])); - - // Subpaths first (sorted for a stable, reviewable table), bare specifier last - // — see `SpecDistInjection.aliases` for why the order decides correctness. - const aliases: Record = {}; - const subpaths = [...targets.keys()].filter((s) => s !== SPEC_PACKAGE_NAME).sort(); - for (const specifier of subpaths) aliases[specifier] = targets.get(specifier)!; - aliases[SPEC_PACKAGE_NAME] = targets.get(SPEC_PACKAGE_NAME)!; - - const posixDir = packageDir.split(path.sep).join('/'); - - // One widening rule, applied to every module-id test the caller hands in: the - // baseline stays whole and the override's location joins it as an extra - // alternative. Widened, never replaced — an injected build still resolves - // plenty of installed `@objectstack/*` through node_modules, and a test that - // only knew the override would stop seeing those. Keeping it a single local - // function is deliberate too: two tests widened by two hand-written copies of - // this expression is how the consumers drift apart in the first place. - const widen = (baseline: RegExp): RegExp => - new RegExp(`${baseline.source}|${escapeRegExp(posixDir)}${SEPARATOR_CLASS}`); - - return { - packageDir, - aliases, - fsAllow: [packageDir], - resolutions, - vendorChunkTest: widen(vendorChunkTest), - specModuleTest: widen(specModuleTest), - consoleZod, - singleZodPlugin: pinZodToConsoleCopy(consoleZod), - }; -} diff --git a/ui/scripts/vite-single-zod.ts b/ui/scripts/vite-single-zod.ts new file mode 100644 index 00000000000..9f24058c42e --- /dev/null +++ b/ui/scripts/vite-single-zod.ts @@ -0,0 +1,139 @@ +// Build-time guard: the console bundle carries exactly ONE zod instance +// (objectui#11327). +// +// Two zod instances split zod's internals: a spec schema built by one and walked +// by the other (`z.toJSONSchema`) throws, and every spec-derived Studio form lost +// its fields — at the console pin objectui#11327 was reported against, the New +// Package dialog could not create a package. Two copies that happen to agree, +// even on a version, are still two instances, so the rule is ONE, not +// "matching". +// +// `resolve.dedupe` cannot deliver it, measured on objectui#11327 against the +// real console config: Vite resolves a deduped package from its ROOT, the +// console's root (`apps/console`) declares no `zod`, the root lookup finds +// nothing, and Vite silently falls back to the importer's own copy. So this +// guard MEASURES the outcome on the emitted chunks rather than trusting any +// mechanism, in every console build. +// +// Moved out of `vite-objectstack-spec-dist.ts` when that module's +// `OBJECTSTACK_SPEC_DIST` injection left with the objectstack pin +// (objectstack#25711): in the objectstack workspace the console's +// `@objectstack/*` are `workspace:^` links, so there is no out-of-tree spec to +// inject and no second install tree for zod to come from — but one lockfile can +// still resolve two zod versions, and this guard is what says so. + +import fs from 'node:fs'; +import path from 'node:path'; +import type { Plugin } from 'vite'; + +/** The name `assertSingleZodInstance`'s plugin registers under. */ +export const SINGLE_ZOD_GUARD_NAME = 'assert-single-zod-instance'; + +/** + * The `zod` package directory a module id lives in, or `null`. + * + * The LAST `node_modules/zod/` segment wins, so pnpm's store path + * (`…/.pnpm/zod@4.6.5/node_modules/zod/v4/core/core.js`) and a copy nested + * under another package both answer with the directory that IS the package. + * A sibling such as `zod-to-json-schema` does not match: the segment must be + * exactly `zod`. A leading `\0` (a virtual-module marker) and a query are + * dropped first, so one copy reached two ways is not counted twice. + */ +export function zodPackageDirOf(id: string): string | null { + const match = /^(.*[\\/]node_modules[\\/]zod)[\\/]/.exec(id.replace(/^\0/, '').split('?')[0]); + return match ? match[1] : null; +} + +/** The minimal slice of a bundle `assertSingleZodInstance` reads. */ +interface GuardOutput { + type: string; + fileName: string; + modules?: Record; +} + +/** The minimal slice of the plugin context `assertSingleZodInstance` reads. */ +interface GuardContext { + error(message: string): never; + getModuleInfo?(id: string): { importers?: readonly string[] } | null; +} + +/** + * Build-time guard: the console bundle carries exactly ONE zod instance. + * + * Counted on what the build EMITTED — every module id rendered into a chunk, + * grouped by the `zod` package directory it lives in — never on how the + * resolver was configured, because the configuration is exactly what fooled + * the first fix attempt (see the file header). Two directories fail the build + * naming each copy, its version, the chunks it landed in and who imported it; + * two directories holding the SAME version fail too, since an instance is what + * the crash keys on, not a version. + * + * Zero directories fail as well, as a counter-probe: the console always bundles + * zod (the `@object-ui/types` validators, the metadata-admin schema modules), so + * zero means this matcher has gone blind, and a negative guard that cannot see + * its subject would read green forever — the shape `assertLazyLinterStaysLazy` + * in the console config refuses for the same reason. + */ +export function assertSingleZodInstance(): Plugin { + return { + name: SINGLE_ZOD_GUARD_NAME, + apply: 'build', + generateBundle(_options, bundle) { + const context = this as unknown as GuardContext; + const copies = new Map; importers: Set }>(); + let chunkCount = 0; + for (const output of Object.values(bundle as Record)) { + if (output.type !== 'chunk') continue; + chunkCount += 1; + for (const id of Object.keys(output.modules ?? {})) { + const dir = zodPackageDirOf(id); + if (!dir) continue; + const copy = copies.get(dir) ?? { chunks: new Set(), importers: new Set() }; + copies.set(dir, copy); + copy.chunks.add(output.fileName); + for (const importer of context.getModuleInfo?.(id)?.importers ?? []) { + if (zodPackageDirOf(importer) !== dir) copy.importers.add(importer); + } + } + } + + if (copies.size === 0) { + context.error( + `[${SINGLE_ZOD_GUARD_NAME}] counter-probe failed: none of the ${chunkCount} chunks holds a module ` + + `under a \`node_modules/zod/\` directory, so this guard can no longer fail. The console always ` + + `bundles zod (the \`@object-ui/types\` validators, the metadata-admin schema modules), so zero ` + + `is a statement about this matcher — zod reached through a path without that segment — never ` + + `a clean bundle (objectui#11327)` + ); + } + if (copies.size > 1) { + const lines = [...copies.entries()].map(([dir, copy]) => { + let version = '?'; + try { + version = String( + (JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')) as { version?: unknown }).version + ); + } catch { + // The listing still names the directory; the version is a courtesy. + } + const importers = [...copy.importers].slice(0, 3); + return ( + ` - zod ${version} at \`${dir}\` — in ${[...copy.chunks].join(', ')}` + + (importers.length > 0 ? `; imported by ${importers.join(', ')}` : '') + ); + }); + context.error( + `[${SINGLE_ZOD_GUARD_NAME}] the console bundle carries ${copies.size} zod instances and must ` + + `carry exactly one (objectui#11327):\n${lines.join('\n')}\n` + + `A schema built by one instance and walked by another holds only while their internals happen ` + + `to agree — when they did not, \`z.toJSONSchema\` over a spec schema threw and the Studio's ` + + `spec-derived forms lost their fields — and two copies that agree, even on a version, are ` + + `still two instances. The workspace lockfile resolves zod twice for the console's closure; ` + + `dedupe it. Adding \`zod\` to ` + + `\`resolve.dedupe\` is not a fix: the console root declares no zod, so Vite falls back to each ` + + `importer's own copy without a word.` + ); + } + }, + }; +} diff --git a/ui/scripts/workspace-root.d.mts b/ui/scripts/workspace-root.d.mts index da7f3ad1723..ee6a559ca93 100644 --- a/ui/scripts/workspace-root.d.mts +++ b/ui/scripts/workspace-root.d.mts @@ -10,6 +10,7 @@ export declare const WORKSPACE_MANIFEST: 'pnpm-workspace.yaml'; export declare const LOCKFILE: 'pnpm-lock.yaml'; export declare const ROOT_MANIFEST: 'package.json'; export declare const TURBO_CONFIG: 'turbo.json'; +export declare const CHANGESET_CONFIG: '.changeset/config.json'; export declare const PARKED_TURBO_CONFIG: 'turbo.objectui.json'; export declare const TURBO_ROOT_PREFIX: '$TURBO_ROOT$/'; export declare const VIRTUAL_STORE: '.pnpm'; @@ -37,6 +38,7 @@ export declare function parseWorkspaceGlobs(text: string): string[]; export declare function workspaceGlobs(root?: string): string[]; /** The nearest `turbo.json` upward: what turbo hashes from. */ export declare function turboConfigPath(root?: string): string; +export declare function changesetConfigPath(root?: string): string; /** objectui's own task declaration: `turbo.json` at `root`, else the parked copy. */ export declare function uiTurboConfigPath(root?: string): string; diff --git a/ui/scripts/workspace-root.mjs b/ui/scripts/workspace-root.mjs index bdffb350ad6..7ad238a44a8 100644 --- a/ui/scripts/workspace-root.mjs +++ b/ui/scripts/workspace-root.mjs @@ -113,6 +113,8 @@ export const WORKSPACE_MANIFEST = 'pnpm-workspace.yaml'; export const LOCKFILE = 'pnpm-lock.yaml'; export const ROOT_MANIFEST = 'package.json'; export const TURBO_CONFIG = 'turbo.json'; +/** The Changesets config, relative to the root that holds it. */ +export const CHANGESET_CONFIG = '.changeset/config.json'; /** Where the cutover parks objectui's own `turbo.json` (a nested one must `extends` the root). */ export const PARKED_TURBO_CONFIG = 'turbo.objectui.json'; export const TURBO_ROOT_PREFIX = '$TURBO_ROOT$/'; @@ -293,6 +295,26 @@ export function turboConfigPath(root = uiRoot()) { throw new Error(`no ${TURBO_CONFIG} at ${own} or at ${parent} (objectstack#25723).`); } +/** + * The Changesets config that versions this tree: `.changeset/config.json` at + * `root` when it holds one (standalone), else the one at its parent — the + * monorepo's, whose one `fixed` group carries `@object-ui/*` and + * `@objectstack/*` together (objectstack#25711, ruling D1 「同一个版本号组」; + * changesets reads one config per repository). A reader that asks "which of + * this tree's packages are published" intersects that group with this tree's + * own manifests, so the monorepo's wider group answers the same question. + * + * @param {string} [root] + * @returns {string} + */ +export function changesetConfigPath(root = uiRoot()) { + const own = path.resolve(root); + if (fs.existsSync(path.join(own, CHANGESET_CONFIG))) return path.join(own, CHANGESET_CONFIG); + const parent = path.dirname(own); + if (parent !== own && fs.existsSync(path.join(parent, CHANGESET_CONFIG))) return path.join(parent, CHANGESET_CONFIG); + throw new Error(`no ${CHANGESET_CONFIG} at ${own} or at ${parent} (objectstack#25711).`); +} + /** * objectui's OWN task declaration, for a test that pins objectui's task shapes * (`$TURBO_ROOT$` inputs relative to the objectui tree, `//#` root tasks of its diff --git a/ui/turbo.objectui.json b/ui/turbo.objectui.json index 24c4aaef68d..0996d5a3bd3 100644 --- a/ui/turbo.objectui.json +++ b/ui/turbo.objectui.json @@ -2,9 +2,7 @@ "$schema": "https://turbo.build/schema.json", "globalEnv": ["NODE_ENV"], "globalDependencies": [ - "node_modules/@objectstack/spec/.spec-main-shape-gate.json", - ".objectstack-sha", - "node_modules/.cache/objectstack/.stamp.json" + "node_modules/@objectstack/spec/.spec-main-shape-gate.json" ], "tasks": { "build": { @@ -16,7 +14,7 @@ "**/*.tsbuildinfo", "!**/node_modules/**" ], - "env": ["NODE_ENV", "VITE_BASE_PATH", "OBJECTSTACK_CLIENT_DIST", "OBJECTSTACK_SPEC_DIST"], + "env": ["NODE_ENV", "VITE_BASE_PATH"], "inputs": [ "$TURBO_DEFAULT$", "$TURBO_ROOT$/tsconfig.json",