From a501c8bb1c564d03baa2d5283ea3997ce186c823 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 11:28:29 +0000 Subject: [PATCH 1/9] feat(tooling): resolve @objectstack/* from a build of the commit .objectstack-sha pins .objectstack-sha pins objectstack main 2ee5861b0f (it contains 586705d85d, 1e001c7df and e15897c0f1). scripts/objectstack-pin.mjs builds the seven packages the workspace resolves (spec, core, types, client, formula, sdui-parser, lint) at that commit and packs each into node_modules/.cache/objectstack/; the root manifest overrides every @objectstack/* package to that directory with file:, so the lockfile records directory resolutions and no @objectstack/* package resolves from npm. `pnpm run setup` builds then installs frozen; OBJECTSTACK_ROOT packs an existing objectstack tree into the same directories instead. Claude-Session: https://claude.ai/code/session_0188TsgPV8kQXfuRuKmkj2ks Co-authored-by: Claude --- .objectstack-sha | 1 + package.json | 12 +- pnpm-lock.yaml | 221 ++++++------- scripts/objectstack-pin.mjs | 602 ++++++++++++++++++++++++++++++++++++ 4 files changed, 728 insertions(+), 108 deletions(-) create mode 100644 .objectstack-sha create mode 100644 scripts/objectstack-pin.mjs diff --git a/.objectstack-sha b/.objectstack-sha new file mode 100644 index 0000000000..a46996956d --- /dev/null +++ b/.objectstack-sha @@ -0,0 +1 @@ +2ee5861b0f918b5d256e5872dcf48d01426c1d04 diff --git a/package.json b/package.json index b4703fd2d3..477d9681d2 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,9 @@ "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", "dev": "pnpm --filter @object-ui/console dev", "start": "pnpm --filter @object-ui/console preview", "build": "turbo run build --filter=!@object-ui/site", @@ -196,7 +199,14 @@ "qs@<6.15.2": "^6.15.2", "shell-quote@<1.8.4": "^1.8.4", "tmp@<0.2.6": "^0.2.6", - "uuid@<11.1.1": "^11.1.1" + "uuid@<11.1.1": "^11.1.1", + "@objectstack/client": "file:node_modules/.cache/objectstack/client", + "@objectstack/core": "file:node_modules/.cache/objectstack/core", + "@objectstack/formula": "file:node_modules/.cache/objectstack/formula", + "@objectstack/lint": "file:node_modules/.cache/objectstack/lint", + "@objectstack/sdui-parser": "file:node_modules/.cache/objectstack/sdui-parser", + "@objectstack/spec": "file:node_modules/.cache/objectstack/spec", + "@objectstack/types": "file:node_modules/.cache/objectstack/types" }, "patchedDependencies": { "@radix-ui/react-focus-scope": "patches/@radix-ui__react-focus-scope.patch" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a57d5e6b7..a12ba1bc14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,13 @@ overrides: shell-quote@<1.8.4: ^1.8.4 tmp@<0.2.6: ^0.2.6 uuid@<11.1.1: ^11.1.1 + '@objectstack/client': file:node_modules/.cache/objectstack/client + '@objectstack/core': file:node_modules/.cache/objectstack/core + '@objectstack/formula': file:node_modules/.cache/objectstack/formula + '@objectstack/lint': file:node_modules/.cache/objectstack/lint + '@objectstack/sdui-parser': file:node_modules/.cache/objectstack/sdui-parser + '@objectstack/spec': file:node_modules/.cache/objectstack/spec + '@objectstack/types': file:node_modules/.cache/objectstack/types patchedDependencies: '@radix-ui/react-focus-scope': @@ -34,8 +41,8 @@ importers: specifier: workspace:* version: link:packages/test-support '@objectstack/spec': - specifier: ^18.0.0-next.1 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:node_modules/.cache/objectstack/spec + version: file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) '@playwright/test': specifier: ^1.62.1 version: 1.62.1 @@ -272,14 +279,14 @@ importers: specifier: workspace:* version: link:../../packages/types '@objectstack/client': - specifier: ^18.0.0-next.1 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/client + version: file:node_modules/.cache/objectstack/client(ai@7.0.65(zod@4.6.7)) '@objectstack/lint': - specifier: ^18.0.0-next.1 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/lint + version: file:node_modules/.cache/objectstack/lint(ai@7.0.65(zod@4.6.7)) '@objectstack/spec': - specifier: ^18.0.0-next.1 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/spec + version: file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) '@tailwindcss/postcss': specifier: ^4.3.3 version: 4.3.3 @@ -416,8 +423,8 @@ importers: specifier: workspace:* version: link:../../packages/types '@objectstack/spec': - specifier: ^18.0.0-next.1 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/spec + version: file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) fumadocs-core: specifier: 16.15.4 version: 16.15.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.43.0(react@19.2.8))(next@16.3.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(zod@4.6.7) @@ -774,14 +781,14 @@ importers: specifier: workspace:* version: link:../types '@objectstack/formula': - specifier: ^18.0.0-next.1 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/formula + version: file:node_modules/.cache/objectstack/formula(ai@7.0.65(zod@4.6.7)) '@objectstack/lint': - specifier: ^18.0.0-next.1 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/lint + version: file:node_modules/.cache/objectstack/lint(ai@7.0.65(zod@4.6.7)) '@objectstack/spec': - specifier: ^18.0.0-next.3 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/spec + version: file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) '@sentry/react': specifier: ^10.70.0 version: 10.70.0(react@19.2.8) @@ -871,8 +878,8 @@ importers: packages/auth: dependencies: '@objectstack/spec': - specifier: ^18.0.0-next.1 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/spec + version: file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) better-auth: specifier: ^1.7.2 version: 1.7.2(@opentelemetry/api@1.9.1)(better-sqlite3@12.9.0)(mongodb@7.2.0(socks@2.8.9))(next@16.3.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.10) @@ -902,8 +909,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^18.0.0-next.1 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/spec + version: file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) '@tailwindcss/postcss': specifier: ^4.3.3 version: 4.3.3 @@ -970,8 +977,8 @@ importers: version: link:../types devDependencies: '@objectstack/spec': - specifier: ^18.0.0-next.1 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/spec + version: file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -1006,8 +1013,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^18.0.0-next.2 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/spec + version: file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) '@radix-ui/react-accordion': specifier: ^1.2.20 version: 1.2.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -1190,11 +1197,11 @@ importers: specifier: workspace:* version: link:../types '@objectstack/formula': - specifier: ^18.0.0-next.1 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/formula + version: file:node_modules/.cache/objectstack/formula(ai@7.0.65(zod@4.6.7)) '@objectstack/spec': - specifier: ^18.0.0-next.1 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/spec + version: file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) devDependencies: '@object-ui/test-support': specifier: workspace:* @@ -1249,11 +1256,11 @@ importers: specifier: workspace:* version: link:../types '@objectstack/client': - specifier: ^18.0.0-next.1 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/client + version: file:node_modules/.cache/objectstack/client(ai@7.0.65(zod@4.6.7)) '@objectstack/spec': - specifier: ^18.0.0-next.1 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/spec + version: file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) devDependencies: '@object-ui/test-support': specifier: workspace:* @@ -1292,8 +1299,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^18.0.0-next.1 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/spec + version: file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) lucide-react: specifier: ^1.43.0 version: 1.43.0(react@19.2.8) @@ -1396,8 +1403,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^18.0.0-next.1 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/spec + version: file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) lucide-react: specifier: ^1.43.0 version: 1.43.0(react@19.2.8) @@ -1437,8 +1444,8 @@ importers: version: link:../types devDependencies: '@objectstack/spec': - specifier: ^18.0.0-next.1 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/spec + version: file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -1458,8 +1465,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^18.0.0-next.1 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/spec + version: file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) devDependencies: '@types/react': specifier: 19.2.18 @@ -1621,8 +1628,8 @@ importers: specifier: workspace:* version: link:../test-support '@objectstack/spec': - specifier: ^18.0.0-next.1 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/spec + version: file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -1663,8 +1670,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^18.0.0-next.1 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/spec + version: file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) '@radix-ui/react-slot': specifier: ^1.3.3 version: 1.3.3(@types/react@19.2.18)(react@19.2.8) @@ -1754,8 +1761,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^18.0.0-next.1 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/spec + version: file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) lucide-react: specifier: ^1.43.0 version: 1.43.0(react@19.2.8) @@ -1824,8 +1831,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^18.0.0-next.1 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/spec + version: file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -1876,8 +1883,8 @@ importers: specifier: workspace:* version: link:../i18n '@objectstack/spec': - specifier: ^18.0.0-next.3 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/spec + version: file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) lucide-react: specifier: ^1.43.0 version: 1.43.0(react@19.2.8) @@ -2001,8 +2008,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^18.0.0-next.1 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/spec + version: file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) lucide-react: specifier: ^1.43.0 version: 1.43.0(react@19.2.8) @@ -2062,8 +2069,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^18.0.0-next.1 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/spec + version: file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) lucide-react: specifier: ^1.43.0 version: 1.43.0(react@19.2.8) @@ -2132,8 +2139,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^18.0.0-next.1 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/spec + version: file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) '@tanstack/react-virtual': specifier: ^3.14.9 version: 3.14.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -2268,6 +2275,9 @@ importers: packages/plugin-list: dependencies: + '@objectstack/spec': + specifier: file:../../node_modules/.cache/objectstack/spec + version: file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) lucide-react: specifier: ^1.43.0 version: 1.43.0(react@19.2.8) @@ -2305,9 +2315,6 @@ importers: '@object-ui/types': specifier: workspace:* version: link:../types - '@objectstack/spec': - specifier: ^18.0.0-next.1 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -2483,8 +2490,8 @@ importers: specifier: workspace:* version: link:../test-support '@objectstack/spec': - specifier: ^18.0.0-next.1 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/spec + version: file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) '@types/node': specifier: ^26.2.0 version: 26.2.0 @@ -2531,8 +2538,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^18.0.0-next.1 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/spec + version: file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2601,8 +2608,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^18.0.0-next.1 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/spec + version: file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) lucide-react: specifier: ^1.43.0 version: 1.43.0(react@19.2.8) @@ -2674,8 +2681,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^18.0.0-next.1 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/spec + version: file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2739,8 +2746,8 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^18.0.0-next.1 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/spec + version: file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) react: specifier: 19.2.8 version: 19.2.8 @@ -2804,8 +2811,8 @@ importers: version: 1.0.7(tailwindcss@4.3.3) devDependencies: '@objectstack/spec': - specifier: ^18.0.0-next.1 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/spec + version: file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) '@tailwindcss/postcss': specifier: ^4.3.3 version: 4.3.3 @@ -2846,14 +2853,14 @@ importers: specifier: workspace:* version: link:../types '@objectstack/spec': - specifier: ^18.0.0-next.1 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/spec + version: file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) packages/test-support: devDependencies: '@objectstack/spec': - specifier: ^18.0.0-next.1 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/spec + version: file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) '@types/node': specifier: ^26.2.0 version: 26.2.0 @@ -2867,8 +2874,8 @@ importers: packages/types: dependencies: '@objectstack/spec': - specifier: ^18.0.0-next.2 - version: 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + specifier: file:../../node_modules/.cache/objectstack/spec + version: file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) zod: specifier: ^4.6.1 version: 4.6.7 @@ -4232,26 +4239,26 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@objectstack/client@18.0.0-next.3': - resolution: {integrity: sha512-eiIPhI/SCf5uVJGoc6Yt4/UPrR/yj7DRSCzLqjZMST7jp/wCE8OkHbVNXwOJ3A3hVUrf/+O0swU53QwLfLP5Hw==} + '@objectstack/client@file:node_modules/.cache/objectstack/client': + resolution: {directory: node_modules/.cache/objectstack/client, type: directory} engines: {node: '>=22.0.0'} - '@objectstack/core@18.0.0-next.3': - resolution: {integrity: sha512-9C9VR/T1EC2ai46T3CSMync/1v99hmHmoHg1sCZKvfs/eske9Hnt5Eqp8B+pTOqm1w3a8PESx8GEAZe8ct7aVA==} + '@objectstack/core@file:node_modules/.cache/objectstack/core': + resolution: {directory: node_modules/.cache/objectstack/core, type: directory} engines: {node: '>=22.0.0'} - '@objectstack/formula@18.0.0-next.3': - resolution: {integrity: sha512-IAQVp6nxpHym+nJ2Sk1mTcIu01Qez6ak9TJNfjh4jwwFKQk1TahwB+0bB8JLXGDUnsdAegH5IQzEM+5CY3T9aA==} + '@objectstack/formula@file:node_modules/.cache/objectstack/formula': + resolution: {directory: node_modules/.cache/objectstack/formula, type: directory} - '@objectstack/lint@18.0.0-next.3': - resolution: {integrity: sha512-aoAncO2ohd9qc9k7Py2Cw4+oIbWDXMVqAe+wL5zJT3rWZqjb0IfilHn6tfscTDZVWzN29d9GQR28KcbPQOYvsQ==} + '@objectstack/lint@file:node_modules/.cache/objectstack/lint': + resolution: {directory: node_modules/.cache/objectstack/lint, type: directory} engines: {node: '>=22.0.0'} - '@objectstack/sdui-parser@18.0.0-next.3': - resolution: {integrity: sha512-EI9FhJPACp8Hq2QY9duargzuyyoA3eRKSxGDBg3XbgI0oEIVaC9/InhhK1yNHz5/rzsFpxzLN8Aw5oteD/11Jg==} + '@objectstack/sdui-parser@file:node_modules/.cache/objectstack/sdui-parser': + resolution: {directory: node_modules/.cache/objectstack/sdui-parser, type: directory} - '@objectstack/spec@18.0.0-next.3': - resolution: {integrity: sha512-dX3SO5l9rsKLv0N8XUFPg5SH4hV6/J+JYjcczFGuGAQuFN5sOx+C97WKw8qKLv4TrWW9hNoM8dnKsvbMEHxqjQ==} + '@objectstack/spec@file:node_modules/.cache/objectstack/spec': + resolution: {directory: node_modules/.cache/objectstack/spec, type: directory} engines: {node: '>=22.0.0'} peerDependencies: ai: ^7.0.0 @@ -4259,8 +4266,8 @@ packages: ai: optional: true - '@objectstack/types@18.0.0-next.3': - resolution: {integrity: sha512-wbW4PFH7MJDDzionDnK9TDUrhjD+wD0xwytc4l/JHy3J5aeyzo6mDrPSlL3MLN48uracZ/MSER8E7grg4w4Qdg==} + '@objectstack/types@file:node_modules/.cache/objectstack/types': + resolution: {directory: node_modules/.cache/objectstack/types, type: directory} engines: {node: '>=22.0.0'} '@open-draft/deferred-promise@2.2.0': @@ -12337,33 +12344,33 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@objectstack/client@18.0.0-next.3(ai@7.0.65(zod@4.6.7))': + '@objectstack/client@file:node_modules/.cache/objectstack/client(ai@7.0.65(zod@4.6.7))': dependencies: - '@objectstack/core': 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) - '@objectstack/spec': 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + '@objectstack/core': file:node_modules/.cache/objectstack/core(ai@7.0.65(zod@4.6.7)) + '@objectstack/spec': file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) transitivePeerDependencies: - ai - '@objectstack/core@18.0.0-next.3(ai@7.0.65(zod@4.6.7))': + '@objectstack/core@file:node_modules/.cache/objectstack/core(ai@7.0.65(zod@4.6.7))': dependencies: - '@objectstack/spec': 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) - '@objectstack/types': 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + '@objectstack/spec': file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) + '@objectstack/types': file:node_modules/.cache/objectstack/types(ai@7.0.65(zod@4.6.7)) zod: 4.6.7 transitivePeerDependencies: - ai - '@objectstack/formula@18.0.0-next.3(ai@7.0.65(zod@4.6.7))': + '@objectstack/formula@file:node_modules/.cache/objectstack/formula(ai@7.0.65(zod@4.6.7))': dependencies: '@marcbachmann/cel-js': 8.0.0 - '@objectstack/spec': 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + '@objectstack/spec': file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) transitivePeerDependencies: - ai - '@objectstack/lint@18.0.0-next.3(ai@7.0.65(zod@4.6.7))': + '@objectstack/lint@file:node_modules/.cache/objectstack/lint(ai@7.0.65(zod@4.6.7))': dependencies: - '@objectstack/formula': 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) - '@objectstack/sdui-parser': 18.0.0-next.3 - '@objectstack/spec': 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + '@objectstack/formula': file:node_modules/.cache/objectstack/formula(ai@7.0.65(zod@4.6.7)) + '@objectstack/sdui-parser': file:node_modules/.cache/objectstack/sdui-parser + '@objectstack/spec': file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) sucrase: 3.35.1 @@ -12371,18 +12378,18 @@ snapshots: transitivePeerDependencies: - ai - '@objectstack/sdui-parser@18.0.0-next.3': {} + '@objectstack/sdui-parser@file:node_modules/.cache/objectstack/sdui-parser': {} - '@objectstack/spec@18.0.0-next.3(ai@7.0.65(zod@4.6.7))': + '@objectstack/spec@file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7))': dependencies: pg-connection-string: 2.14.1 zod: 4.6.7 optionalDependencies: ai: 7.0.65(zod@4.6.7) - '@objectstack/types@18.0.0-next.3(ai@7.0.65(zod@4.6.7))': + '@objectstack/types@file:node_modules/.cache/objectstack/types(ai@7.0.65(zod@4.6.7))': dependencies: - '@objectstack/spec': 18.0.0-next.3(ai@7.0.65(zod@4.6.7)) + '@objectstack/spec': file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7)) transitivePeerDependencies: - ai diff --git a/scripts/objectstack-pin.mjs b/scripts/objectstack-pin.mjs new file mode 100644 index 0000000000..64c56527e1 --- /dev/null +++ b/scripts/objectstack-pin.mjs @@ -0,0 +1,602 @@ +#!/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 status # what the cache holds, against the pin (exit 0 = built at the pin) + * 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:node_modules/.cache/objectstack/spec" + * + * and `build` fills each such directory with that package exactly as `pnpm pack` + * would publish it -- built from the pinned commit's source. So no `@objectstack/*` + * package resolves from npm; npm ranges in the manifests matter only when objectui + * itself publishes (objectui#12958). + * + * ## 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. + * + * Why under `node_modules/.cache/`: every tool here already ignores `node_modules` + * (git, ESLint's `**\/node_modules`, vitest, tsc, turbo's inputs), so a packed + * objectstack tree there is invisible to every gate that walks the checkout. The + * cost: removing `node_modules` removes the build too, and the next install fails + * with ENOENT on that directory until `pnpm run setup` runs again. + * + * ## 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 + * built, and a missing entry point refuses with the command that builds it. The + * lockfile does not change; a frozen install stays valid as long as that tree's + * packages declare the dependencies the lockfile records -- when they do not, the + * frozen install fails and names the mismatch. + * + * ## Refusals (exit 1, nothing written) + * + * - `.objectstack-sha` is not one full sha; + * - an `@objectstack/*` override is not the `file:node_modules/.cache/objectstack/` + * form, or a package objectui declares has no override (it would resolve from npm); + * - 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, so a failed run leaves the previous build in place. + * + * ⛔ 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'; +export const STAMP_FILE = '.stamp.json'; +export const OVERRIDE_PREFIX = `file:${CACHE_DIR}/`; +/** Bump when what `build` writes changes shape, so an older cache is rebuilt rather than trusted. */ +export const RECIPE = 1; +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 at the + * first pin: 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 [...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 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 }} want + * @returns {{ current: boolean, why: string }} + */ +export function judgeStamp(stamp, { pin, overrides, root }) { + 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(root, CACHE_DIR, dir, 'package.json'); + if (!fs.existsSync(manifest) || readJson(manifest).name !== name) return { current: false, why: `${CACHE_DIR}/${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')}`; +} + +// ── running things ────────────────────────────────────────────────────────── + +/** + * Run a command with inherited output; a non-zero exit is a refusal naming it. + * + * @param {string} cmd + * @param {string[]} args + * @param {{ cwd: string, capture?: boolean, env?: NodeJS.ProcessEnv }} opts + * @returns {string} captured stdout (empty unless `capture`) + */ +function run(cmd, args, { cwd, capture = false, env = process.env }) { + const result = spawnSync(cmd, args, { + cwd, + env, + encoding: 'utf8', + stdio: capture ? ['ignore', 'pipe', 'inherit'] : 'inherit', + maxBuffer: 256 * 1024 * 1024, + }); + if (result.error) throw new PinRefusal(`could not run \`${cmd} ${args.join(' ')}\` in ${cwd}: ${result.error.message}`); + if (result.status !== 0) throw new PinRefusal(`\`${cmd} ${args.join(' ')}\` exited ${result.status} in ${cwd}.`); + return capture ? result.stdout : ''; +} + +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 + */ +function checkoutPin(dir, sha, url) { + 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 }); + 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}.`); +} + +/** + * `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 + * @returns {{ name: string, version: string }} + */ +function packInto(projectDir, tarballs, dest) { + const out = run('pnpm', ['pack', '--json', '--pack-destination', tarballs, '--config.ignore-scripts=true'], { cwd: projectDir, capture: true }); + 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. + * + * @param {{ root?: string, env?: NodeJS.ProcessEnv }} [opts] + * @returns {{ skipped: boolean, stamp: Record }} + */ +export function build({ root = REPO_ROOT, env = process.env } = {}) { + 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) { + const verdict = judgeStamp(readStamp(root), { pin, overrides, root }); + if (verdict.current) { + console.log(`✓ ${CACHE_DIR} is ${verdict.why} -- nothing to build.`); + return { skipped: true, stamp: readStamp(root) }; + } + console.log(`→ ${CACHE_DIR}: ${verdict.why}. Building @objectstack/* at ${pin}.`); + } 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(os.tmpdir(), 'objectstack-pin-')); + try { + let tree = external; + if (!external) { + tree = path.join(scratch, 'objectstack'); + step(`fetch objectstack ${pin.slice(0, 12)}`, () => checkoutPin(tree, pin, env.OBJECTSTACK_REPO_URL || DEFAULT_REPO_URL)); + } + 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. + step('install the build closure', () => run('pnpm', ['install', '--frozen-lockfile', '--filter', rootName, ...filters], { cwd: tree, env })); + step(`build ${names.join(', ')}`, () => run('pnpm', [...filters, 'run', 'build'], { cwd: tree, env })); + } + + 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); + 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} && pnpm ${filters.join(' ')} run build)` + : ''), + ); + } + packages[name] = packed.version; + } + }); + + const stamp = external + ? { recipe: RECIPE, source: 'external', root: external, ...describeTree(external), packages, contentHash: hashTree(staging) } + : { recipe: RECIPE, source: 'pin', sha: pin, packages }; + fs.writeFileSync(path.join(staging, STAMP_FILE), `${JSON.stringify(stamp, null, 2)}\n`); + const target = path.join(root, CACHE_DIR); + fs.rmSync(target, { recursive: true, force: true }); + fs.renameSync(staging, target); + 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)}`) + + '. Run `pnpm install` so node_modules links it.', + ); + return { skipped: false, stamp }; + } finally { + fs.rmSync(staging, { recursive: true, force: true }); + fs.rmSync(scratch, { recursive: true, force: true }); + } +} + +/** + * @param {{ root?: string }} [opts] + * @returns {number} exit code + */ +export function status({ root = REPO_ROOT } = {}) { + const pin = readPin(root); + const overrides = readOverrides(readJson(path.join(root, 'package.json'))); + const stamp = readStamp(root); + const verdict = judgeStamp(stamp, { pin, overrides, root }); + console.log(`pin ${pin} (${PIN_FILE})`); + console.log(`cache ${stamp ? `${stamp.source} ${stamp.sha}${stamp.root ? ` from ${stamp.root}` : ''}` : 'empty'} (${CACHE_DIR})`); + console.log(`${verdict.current ? '✓' : '✗'} ${verdict.why}${verdict.current ? '' : ' -- run `pnpm run setup`'}`); + return verdict.current ? 0 : 1; +} + +// ── helpers ───────────────────────────────────────────────────────────────── + +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] = process.argv.slice(2); + try { + if (command === 'build') { + build(); + process.exit(0); + } else if (command === 'status') { + process.exit(status()); + } else { + console.error('usage: node scripts/objectstack-pin.mjs build | status'); + process.exit(2); + } + } catch (error) { + if (error instanceof PinRefusal) { + console.error(`✗ objectstack-pin: ${error.message}`); + process.exit(1); + } + throw error; + } +} From 8fbf974bd988d50ad4dcac198c58a7b1775380d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 11:46:35 +0000 Subject: [PATCH 2/9] feat(tooling): bump-objectstack.sh, and the lockfile and pin gates read the pinned build - scripts/bump-objectstack.sh moves .objectstack-sha. It refuses a commit it cannot resolve or read before writing anything, and warns (three answers) when the commit is not on objectstack origin/main -- the refusal and warning contract of objectstack's bump-objectui.sh. - objectstack-pin.mjs refuses when the lockfile does not record the dependency names the build declares (a frozen install links a directory dependency without re-reading its manifest), and builds in a short scratch path (tsx's IPC socket path overflowed under a long one). - check-lockfile-integrity: rule 1 (an @objectstack/* version moving backward) is retired; the lockfile carries no @objectstack/* version now. Rule 2 still names a registry copy beside the pinned build. - check-lockfile-dedupe: the pinned build is a named precondition. - check-installed-pin-claims and the live-e2e backend pin follow the pinned build's file: resolution to the version its manifest declares. - turbo.json hashes .objectstack-sha and the build stamp, so a pin bump invalidates cached verdicts and widens the type-check scope. Claude-Session: https://claude.ai/code/session_0188TsgPV8kQXfuRuKmkj2ks Co-authored-by: Claude --- .github/workflows/lockfile-integrity.yml | 6 +- content/docs/guide/ci-cd-pipeline.md | 9 +- scripts/__tests__/bump-objectstack.test.ts | 182 +++++++++ .../check-installed-spec-pin-claims.test.ts | 20 + .../__tests__/check-lockfile-dedupe.test.ts | 28 +- .../check-lockfile-integrity.test.ts | 80 ++-- scripts/__tests__/ci-cd-pipeline-doc.test.ts | 16 +- scripts/__tests__/objectstack-pin.test.ts | 381 ++++++++++++++++++ scripts/bump-objectstack.sh | 264 ++++++++++++ scripts/check-installed-spec-pin-claims.mjs | 53 ++- scripts/check-lockfile-dedupe.mjs | 36 ++ scripts/check-lockfile-integrity.mjs | 169 +++----- scripts/dependabot-merge-gate.mjs | 2 +- scripts/objectstack-pin.mjs | 146 ++++++- turbo.json | 6 +- 15 files changed, 1211 insertions(+), 187 deletions(-) create mode 100644 scripts/__tests__/bump-objectstack.test.ts create mode 100644 scripts/__tests__/objectstack-pin.test.ts create mode 100755 scripts/bump-objectstack.sh diff --git a/.github/workflows/lockfile-integrity.yml b/.github/workflows/lockfile-integrity.yml index 678e68424d..5852643e34 100644 --- a/.github/workflows/lockfile-integrity.yml +++ b/.github/workflows/lockfile-integrity.yml @@ -1,8 +1,10 @@ name: Lockfile Integrity # objectui#8326 — a pull request's `pnpm-lock.yaml` may not silently DUPLICATE a -# dependency the workspace declares, or move an `@objectstack/*` identity -# BACKWARD. The mechanism, both incident causes and every boundary this gate +# dependency the workspace declares. (Its second rule, an `@objectstack/*` +# identity moving BACKWARD, was retired by objectui#12957: `@objectstack/*` now +# resolves to the build `.objectstack-sha` pins, with no version in the lockfile +# to move.) The mechanism, both incident causes and every boundary this gate # keeps are documented at length in `scripts/check-lockfile-integrity.mjs`. # # ── Why it is its own workflow ─────────────────────────────────────────────── diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index be719508b2..32e4c0b90c 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -551,8 +551,13 @@ the list. The lane grows the allowlist a few proven specs at a time (see the wor comment), so every promotion would stale a hand-copied enumeration here; it already did (objectui#3488). -Backend pins live in `e2e/live/ci/backend.env` and must match the `@objectstack/spec` version in -`pnpm-lock.yaml` — bump both in the same PR, or the run proves nothing. +Backend pins live in `e2e/live/ci/backend.env` and must match the `@objectstack/spec` version the +pinned build in `pnpm-lock.yaml` declares — bump both in the same PR, or the run proves nothing. +Since objectui#12957 the lockfile resolves `@objectstack/spec` to a directory built from the commit +`.objectstack-sha` names (`node_modules/.cache/objectstack/spec`), and the version is the one that +directory's `package.json` carries. ⚠️ objectstack moves that string only when it releases, so a +pin between two releases matches the earlier release's backend by number while the console carries +later commits; this lane still runs the published backend, and nothing here closes that gap. `OBJECTSTACK_VERSION` is the only `@objectstack` value that file declares, and moving it is the only move. The showcase-app commit the lane sparse-checks-out is **derived**, not pinned: diff --git a/scripts/__tests__/bump-objectstack.test.ts b/scripts/__tests__/bump-objectstack.test.ts new file mode 100644 index 0000000000..42bafb4eed --- /dev/null +++ b/scripts/__tests__/bump-objectstack.test.ts @@ -0,0 +1,182 @@ +/** + * 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 cases that pin a commit are the firing controls of the refusal cases: a + * guard that refused everything would fail them. + */ +import { afterAll, beforeAll, 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); + +let scratch = ''; + +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', +}; + +/** A fresh objectui-shaped directory holding the REAL script and an old pin. */ +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, '.objectstack-sha'), `${OLD_PIN}\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, 'README.md'), 'one\n'); + git(upstream, 'add', '.'); + git(upstream, 'commit', '-q', '-m', 'feat(spec): first'); + fs.writeFileSync(path.join(upstream, 'README.md'), '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, 'README.md'), '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 spawnSync('bash', [path.join(objectui, 'scripts/bump-objectstack.sh'), ...args], { + cwd: objectui, + encoding: 'utf8', + env: { ...GIT_ENV, OBJECTSTACK_ROOT: objectstack }, + }); +} + +const pinOf = (objectui: string) => fs.readFileSync(path.join(objectui, '.objectstack-sha'), 'utf8'); + +beforeAll(() => { + scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'bump-objectstack-')); +}); +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 only the pin', () => { + 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'); + expect(git(objectui, 'log', '-1', '--format=%s')).toBe(`chore: bump objectstack to ${mainTip.slice(0, 12)}`); + expect(git(objectui, 'show', '--name-only', '--format=', 'HEAD')).toBe('.objectstack-sha'); + }); + + 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 unstaged'); + expect(git(objectui, 'status', '--porcelain')).toBe('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(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/scripts/__tests__/check-installed-spec-pin-claims.test.ts b/scripts/__tests__/check-installed-spec-pin-claims.test.ts index be56642ec6..e460d5e48f 100644 --- a/scripts/__tests__/check-installed-spec-pin-claims.test.ts +++ b/scripts/__tests__/check-installed-spec-pin-claims.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -202,6 +203,25 @@ describe('the pin, read from two faces', () => { 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([]); diff --git a/scripts/__tests__/check-lockfile-dedupe.test.ts b/scripts/__tests__/check-lockfile-dedupe.test.ts index 4c83ff8bb0..fa0e7df362 100644 --- a/scripts/__tests__/check-lockfile-dedupe.test.ts +++ b/scripts/__tests__/check-lockfile-dedupe.test.ts @@ -114,7 +114,8 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import { DEDUPE_SENTINEL } from '../check-lockfile-dedupe.mjs'; +import { DEDUPE_SENTINEL, pinnedBuildMissing } from '../check-lockfile-dedupe.mjs'; +import { CACHE_DIR, STAMP_FILE } from '../objectstack-pin.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'; @@ -327,6 +328,31 @@ 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('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 (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-lockfile-integrity.test.ts b/scripts/__tests__/check-lockfile-integrity.test.ts index 94810bcb5c..b45c7c6212 100644 --- a/scripts/__tests__/check-lockfile-integrity.test.ts +++ b/scripts/__tests__/check-lockfile-integrity.test.ts @@ -2,13 +2,15 @@ * objectui#8326 — the lockfile-integrity gate, pinned against the two causes it * was built for and against the three ways it could be worthless. * - * The gate's own logic is exercised by `--self-test` inside the script (29 - * cases, synthetic lockfiles, no git). This file adds the three things a - * synthetic corpus cannot say: + * The gate's own logic is exercised by `--self-test` inside the script + * (synthetic lockfiles, no git; it prints its own case count). This file adds the + * three things a synthetic corpus cannot say: * * 1. it is GREEN on this repository's real `pnpm-lock.yaml`; * 2. it is RED on the two real incident samples, built from that same real - * lockfile by a transformation this file asserts actually landed; + * lockfile by a transformation this file asserts actually landed (the + * first, since objectui#12957 retired the version rule, in its pinned-build + * form); * 3. it is wired — the workflow runs it, and the Dependabot merge gate * classifies the check it produces. * @@ -85,29 +87,21 @@ describe('green on main — the negative control', () => { }); /** - * Sample (a) — objectui#8326's own scenario. - * - * ⛔ It cannot be produced by running the resolver, and that impossibility is - * the card's central claim: every workspace range on `@objectstack/*` is a - * floating range that a fresh resolve satisfies with the newest release — `^18.0.0-next.1` - * resolving 18.0.0-next.3 today (objectui#12030, objectui#12370, objectui#12626, objectui#12873; `^17.x` and 17.4.0 when this sample was - * written), so no fresh resolve can pick 17.2.0. The sample is therefore a transformation of the real lockfile - * reproducing the identity facts measured on PRs #7053 / #7058 — the family - * down to 17.2.0, and `@objectstack/spec` resolved at BOTH versions. + * Sample (a) — what is left of objectui#8326 once `@objectstack/*` resolves by + * commit (objectui#12957). The downgrade on #7058 split the workspace across two + * physical spec copies; the version half of that card (rule 1) is retired, + * because the pinned build carries no version for anything to move backward. + * The duplication half is what a partial fall-back off the pin looks like now: one + * importer resolving the REGISTRY spec beside the pinned build is two snapshot + * keys under one name. Built from the real lockfile by a transformation this file + * asserts landed. */ +const PINNED_SPEC = " '@objectstack/spec@file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7))':"; function sampleA(): string { - let out = realLock; - for (const pkg of ['client', 'core', 'formula', 'lint', 'sdui-parser']) { - out = out.replace(new RegExp(`(@objectstack/${pkg}@)18\\.0\\.0-next\\.3`, 'g'), '$117.2.0'); - } - const specKey = " '@objectstack/spec@18.0.0-next.3(ai@7.0.65(zod@4.6.7))':"; - expect(out, 'the spec snapshot key moved — rebuild this sample before trusting it').toContain( - specKey, - ); - return out.replace( - specKey, - " '@objectstack/spec@17.2.0(ai@7.0.65(zod@4.6.7))':\n dependencies:\n zod: 4.6.7\n" + - specKey, + expect(realLock, 'the pinned spec snapshot key moved — rebuild this sample before trusting it').toContain(PINNED_SPEC); + return realLock.replace( + PINNED_SPEC, + " '@objectstack/spec@18.0.0-next.3(ai@7.0.65(zod@4.6.7))':\n dependencies:\n zod: 4.6.7\n" + PINNED_SPEC, ); } @@ -124,8 +118,8 @@ function sampleA(): string { function sampleB(): string { let out = realLock; const forked = [ - " '@objectstack/spec@18.0.0-next.3(ai@7.0.65(zod@4.6.7))':", - " '@objectstack/formula@18.0.0-next.3(ai@7.0.65(zod@4.6.7))':", + PINNED_SPEC, + " '@objectstack/formula@file:node_modules/.cache/objectstack/formula(ai@7.0.65(zod@4.6.7))':", " ai@7.0.65(zod@4.6.7):", ]; for (const key of forked) { @@ -137,29 +131,22 @@ function sampleB(): string { return out.replace(zodKey, `${zodKey}\n zod@4.5.4: {}`); } -describe('cause 1 — an @objectstack/* identity moves backward (objectui#8326)', () => { +describe('cause 1 — a registry @objectstack/* copy beside the pinned build (objectui#8326, objectui#12957)', () => { const reading = compareLockfiles(indexLockfile(realLock, 'base'), indexLockfile(sampleA(), 'a')); it('the sample really differs from the base (the mutation landed)', () => { expect(sampleA()).not.toEqual(realLock); - expect(sampleA()).toContain('@objectstack/client@17.2.0'); + expect(sampleA()).toContain("'@objectstack/spec@18.0.0-next.3(ai@7.0.65(zod@4.6.7))':"); }); - it('goes red, naming every family member that moved', () => { - const names = reading.backward.map((f) => f.name); - expect(names).toEqual( - expect.arrayContaining([ - '@objectstack/client', - '@objectstack/core', - '@objectstack/formula', - '@objectstack/lint', - '@objectstack/sdui-parser', - ]), - ); + it('goes red, naming the second physical spec', () => { + const spec = reading.duplicated.find((f) => f.name === '@objectstack/spec'); + expect(spec?.headCount).toBe(2); + expect(spec?.added).toEqual(['@objectstack/spec@18.0.0-next.3(ai@7.0.65(zod@4.6.7))']); }); - it('also names the duplication the downgrade created', () => { - expect(reading.duplicated.map((f) => f.name)).toContain('@objectstack/spec'); + it('⛔ no longer orders versions: the retired rule 1 reports nothing of its own', () => { + expect(Object.keys(reading)).toEqual(['findings', 'duplicated']); }); }); @@ -171,10 +158,6 @@ describe('cause 2 — a declared dependency forks with no version moving (object expect(sampleB()).toContain('zod@4.5.4'); }); - it('⭐ moves no @objectstack/* identity at all — so rule 1 is blind to it', () => { - expect(reading.backward).toEqual([]); - }); - it('⭐ still goes red: zod gained a copy, though it was never single-copy', () => { const zod = reading.duplicated.find((f) => f.name === 'zod'); expect(zod, 'the gate must not be blind to objectui#8333').toBeTruthy(); @@ -190,8 +173,9 @@ describe('cause 2 — a declared dependency forks with no version moving (object const spec = reading.duplicated.find((f) => f.name === '@objectstack/spec'); expect(spec?.headCount).toBe(2); // This is #8326's exact bundling mechanism — two real paths, nothing - // dedupes them — arriving with no version change anywhere. - expect(spec?.head.every((k) => k.includes('@18.0.0-next.3'))).toBe(true); + // dedupes them — arriving with no version change anywhere: both are the + // pinned build, under two peer contexts. + expect(spec?.head.every((k) => k.startsWith('@objectstack/spec@file:node_modules/.cache/objectstack/spec('))).toBe(true); }); }); diff --git a/scripts/__tests__/ci-cd-pipeline-doc.test.ts b/scripts/__tests__/ci-cd-pipeline-doc.test.ts index cbbd8cdeae..44937d3937 100644 --- a/scripts/__tests__/ci-cd-pipeline-doc.test.ts +++ b/scripts/__tests__/ci-cd-pipeline-doc.test.ts @@ -1801,7 +1801,7 @@ const startBackend = fs.readFileSync(startBackendPath, 'utf8'); /** The page's statement of the rule, whitespace-normalised because the source wraps it. */ const PIN_RULE_SENTENCE = - /Backend pins live in `e2e\/live\/ci\/backend\.env` and must match the `@objectstack\/spec` version in `pnpm-lock\.yaml`/; + /Backend pins live in `e2e\/live\/ci\/backend\.env` and must match the `@objectstack\/spec` version the pinned build in `pnpm-lock\.yaml` declares/; const readEnvKey = (key: string): string | null => backendEnv.match(new RegExp('^' + key + '=(.+)$', 'm'))?.[1].trim() ?? null; @@ -1813,11 +1813,25 @@ const readEnvKey = (key: string): string | null => * `snapshots:` and the bare one under `packages:`; the importer entries carry the same * value on their `version:` line. Each is truncated at the first `(` so the three * spellings collapse to one comparable number. + * + * Since objectui#12957 the lockfile resolves the spec to a DIRECTORY of the pinned + * build (`'@objectstack/spec@file:node_modules/.cache/objectstack/spec(ai@…)'`) and + * records no version for it; that resolution is followed to the version the + * directory's manifest declares. A directory that is not built answers a value no + * `OBJECTSTACK_VERSION` can equal, naming the missing build. */ function resolvedSpecVersions(): string[] { const lock = fs.readFileSync(lockfilePath, 'utf8'); const found = new Set(); + for (const m of lock.matchAll(/^\s{0,4}'?@objectstack\/spec@file:([^'(\s:]+)/gm)) { + try { + found.add(JSON.parse(fs.readFileSync(path.join(repoRoot, m[1], 'package.json'), 'utf8')).version); + } catch { + found.add(`(pinned build absent: ${m[1]} — run \`pnpm objectstack:build\`)`); + } + } + for (const m of lock.matchAll(/^\s{0,4}'?@objectstack\/spec@([0-9][^'(\s:]*)/gm)) { found.add(m[1]); } diff --git a/scripts/__tests__/objectstack-pin.test.ts b/scripts/__tests__/objectstack-pin.test.ts new file mode 100644 index 0000000000..2f4a9a60d6 --- /dev/null +++ b/scripts/__tests__/objectstack-pin.test.ts @@ -0,0 +1,381 @@ +/** + * 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: the root manifest's overrides cover every `@objectstack/*` + * name the workspace declares, 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, beforeAll, 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'; + +import { + CACHE_DIR, + OVERRIDE_PREFIX, + PinRefusal, + RECIPE, + STAMP_FILE, + build, + checkOverrideSet, + declaredObjectstackNames, + dependencyDrift, + entryTargets, + hashTree, + judgeStamp, + lockfileDependencyNames, + readOverrides, + readPin, + readWorkspace, + runtimeClosure, + workspaceGlobs, +} from '../objectstack-pin.mjs'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const SHA = '0123456789abcdef0123456789abcdef01234567'; + +let scratch = ''; +beforeAll(() => { + scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'objectstack-pin-test-')); +}); +afterAll(() => { + 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:node_modules\/\.cache\/objectstack\/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:node_modules/.cache/objectstack/core(ai@7.0.65(zod@4.6.7))':", + ' dependencies:', + " '@objectstack/spec': file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7))", + ' zod: 4.6.7', + ' transitivePeerDependencies:', + ' - ai', + '', + " '@objectstack/spec@file:node_modules/.cache/objectstack/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:node_modules/.cache/objectstack/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 real tree (objectui#12957)', () => { + it('overrides every @objectstack/* package the workspace declares, so none resolves from npm', () => { + const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); + const overrides = readOverrides(manifest); + const declared = declaredObjectstackNames(readWorkspace(repoRoot)); + expect(declared.size, 'the workspace scan found no @objectstack/* declaration at all').toBeGreaterThan(0); + expect([...declared].filter((n) => !overrides.has(n))).toEqual([]); + }); + + it('pins one full objectstack sha', () => { + expect(readPin(repoRoot)).toMatch(/^[0-9a-f]{40}$/); + }); +}); + +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:node_modules/.cache/objectstack/core':", + ' dependencies:', + " '@objectstack/spec': file:node_modules/.cache/objectstack/spec", + " '@objectstack/spec@file:node_modules/.cache/objectstack/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 result = build({ root, env: { ...process.env, OBJECTSTACK_ROOT: tree } }); + expect(result.skipped).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}$/); + // 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('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); + }); +}); diff --git a/scripts/bump-objectstack.sh b/scripts/bump-objectstack.sh new file mode 100755 index 0000000000..4954c99f28 --- /dev/null +++ b/scripts/bump-objectstack.sh @@ -0,0 +1,264 @@ +#!/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 .objectstack-sha only, do not commit +# +# After the bump -- the second half, printed on the way out: +# pnpm objectstack:build # builds @objectstack/* at the NEW pin into node_modules/.cache/objectstack +# 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 -- build the new pin and let pnpm link it:" + echo " pnpm objectstack:build # node_modules/.cache/objectstack at ${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 objectstack happens below this +# point. Everything that can fail on such a read already ran, and refused, above. +echo "$NEW_SHA" > "$PIN_FILE" +echo "→ objectstack pin: ${OLD_SHA:0:12} → ${SHORT}${REACH_TAG}" +echo " ${SUBJECT_LINE}" + +if [[ "$NO_COMMIT" -eq 1 ]]; then + echo "→ --no-commit: leaving .objectstack-sha unstaged${REACH_RECALL}." + print_next_step + exit 0 +fi + +git -C "$ROOT" add .objectstack-sha +git -C "$ROOT" commit -m "chore: bump objectstack to ${SHORT} + +${SUBJECT_LINE} + +objectstack@${NEW_SHA}" -- .objectstack-sha +echo "✓ Committed${REACH_RECALL}. Push with: git push" +print_next_step diff --git a/scripts/check-installed-spec-pin-claims.mjs b/scripts/check-installed-spec-pin-claims.mjs index a61e502676..a340393f50 100644 --- a/scripts/check-installed-spec-pin-claims.mjs +++ b/scripts/check-installed-spec-pin-claims.mjs @@ -34,7 +34,8 @@ * 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. + * 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 @@ -308,19 +309,57 @@ export function recogniseLine(line, bareMajors = DEFAULT_BARE_MAJORS) { * 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:node_modules/.cache/objectstack/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) { +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) continue; - if (!ANCHOR_PACKAGES.includes(m[1])) continue; - if (!found.has(m[1])) found.set(m[1], new Set()); - found.get(m[1]).add(m[2]); + 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 @@ -1060,7 +1099,7 @@ 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(join(root, 'pnpm-lock.yaml'), 'utf8')); + const lockPins = pinsFromLockfile(readFileSync(join(root, 'pnpm-lock.yaml'), 'utf8'), root); const treePins = pinsFromResolvedTree(root, workspaceDirs(root)); const { pins, conflicts, unlinked } = reconcilePins(lockPins, treePins); const bareMajors = bareMajorsFor(pins, ledger); diff --git a/scripts/check-lockfile-dedupe.mjs b/scripts/check-lockfile-dedupe.mjs index 55c986d823..b38ddadf65 100644 --- a/scripts/check-lockfile-dedupe.mjs +++ b/scripts/check-lockfile-dedupe.mjs @@ -86,6 +86,17 @@ * 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. @@ -112,6 +123,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { isEntrypoint } from './invoked-as.mjs'; +import { CACHE_DIR, STAMP_FILE } from './objectstack-pin.mjs'; const REPO_ROOT = path.resolve(fileURLToPath(import.meta.url), '..', '..'); @@ -309,6 +321,16 @@ 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)); +} + /** * @param {string[]} argv * @returns {number} process exit code @@ -319,6 +341,17 @@ export function main(argv = process.argv.slice(2)) { const annotate = (message) => console.error(`::${reportOnly ? 'warning' : 'error'} title=Lockfile dedupe::${message}`); + if (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; + } + const run = spawnSync('pnpm', ['dedupe', '--check'], { cwd: REPO_ROOT, encoding: 'utf8', @@ -417,6 +450,9 @@ export function selfTest() { t('does not report the ADDED identity as removed', !names.includes('zod-validation-error')); 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-lockfile-integrity.mjs b/scripts/check-lockfile-integrity.mjs index 7512dd8c6d..c44727d628 100644 --- a/scripts/check-lockfile-integrity.mjs +++ b/scripts/check-lockfile-integrity.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /** * check-lockfile-integrity -- a pull request's `pnpm-lock.yaml` may not silently - * DUPLICATE a dependency or move an `@objectstack/*` identity BACKWARD. + * DUPLICATE a dependency. * * node scripts/check-lockfile-integrity.mjs # head = worktree, base = merge-base with origin/main * node scripts/check-lockfile-integrity.mjs --base-ref origin/main @@ -33,16 +33,33 @@ * a finding is a decision (re-lock from a clean base, pin, accept), and the * 2026-09-08 triage ruling on #8326 kept that decision out of the gate. * - * ## The two causes it must catch, and why one rule is not enough + * ## The two causes it was built for, and the rule that is left * * cause 1 an `@objectstack/*` identity moves BACKWARD (objectui#8326) * cause 2 a dependency gains a physical COPY (objectui#8333) * - * ⭐ objectui#8333 is the measured proof that cause 1 alone is blind. Floating + * ⛔ RULE 1 IS RETIRED (objectui#12957). It judged cause 1 by comparing + * `@objectstack/*` VERSIONS in the snapshot keys, base against head. Since ruling B + * on objectui#12955 no `@objectstack/*` package resolves to a registry version: + * each resolves to the pinned build, a `file:node_modules/.cache/objectstack/` + * directory, and the lockfile records it with no version at all + * (`'@objectstack/spec@file:node_modules/.cache/objectstack/spec(ai@…)'`). So the + * rule had nothing left to order and could never fire again. The identity it + * guarded is now `.objectstack-sha` -- a one-line text file that merges, conflicts + * and reviews as text, and that no re-lock can carry backward across a rebase, + * which was #8326's whole mechanism. Whether every `@objectstack/*` package is + * overridden to that build is refused at build time by `scripts/objectstack-pin.mjs` + * (`checkOverrideSet`), which every installing CI job runs before `pnpm install`. + * What is left of cause 1 is its DUPLICATION half -- the downgrade on #7058 also + * left the workspace on two physical spec copies -- and rule 2 below reads that + * unchanged: an `@objectstack/*` package resolving both the pinned build and a + * registry version is two snapshot keys under one name. + * + * ⭐ objectui#8333 is the measured proof that cause 1 alone was blind. Floating * `better-auth` from 1.7.2 to 1.7.3 -- re-measured on `da5e4f69e`, 2026-09-08 -- * moves NO `@objectstack/*` identity at all: the delta is the better-auth * family, `@mongodb-js/saslprep` 1.5.0->1.5.2, `seroval`(-plugins) 1.6.4->1.6.7, - * every one of them FORWARD, plus one ADDED `zod@4.5.4`. Rule 1 is green on it. + * every one of them FORWARD, plus one ADDED `zod@4.5.4`. Rule 1 was green on it. * * ⭐ And rule 2 is not "a single-copy dependency forks", which is the wording * both cards use. Measured on `da5e4f69e`: zod is ALREADY two copies on `main` @@ -81,12 +98,9 @@ * * - A lockfile that is ALREADY duplicated stays green here as long as the * change does not make it worse. This is a ratchet, not an audit. - * - A backward move outside `@objectstack/*` is not judged. Third-party - * downgrades are legitimate (a revert, a yanked release, a security pin). - * `@objectstack/*` is scoped in because every workspace range on it is a - * floating `^17.x` against a registry whose latest is 17.3.0 -- so a - * backward move there cannot be the result of any fresh resolution, which - * is #8326's impossibility argument. + * - A backward move is not judged, for any package. Third-party downgrades + * are legitimate (a revert, a yanked release, a security pin), and + * `@objectstack/*` has no version here to move (rule 1, retired above). * - It says nothing about bytes. `Bundle Analysis` measures those; this names * the cause so that reading is not attributed to the dependency. * @@ -112,7 +126,12 @@ export const EXIT_CLEAN = 0; export const EXIT_FINDINGS = 1; export const EXIT_CANNOT_RUN = 2; -/** The scope whose backward movement is structurally impossible from a fresh resolve. */ +/** + * The first-party scope. Rule 2 judges it whether or not the workspace declares + * the name directly: `@objectstack/types` and `@objectstack/core` arrive only + * through `@objectstack/client`, and a second copy of either is still two real + * paths in the bundle. + */ export const FIRST_PARTY_SCOPE = '@objectstack/'; // ── parsing ───────────────────────────────────────────────────────────────── @@ -123,10 +142,10 @@ export const FIRST_PARTY_SCOPE = '@objectstack/'; * `'@objectstack/spec@17.3.0(ai@7.0.65(zod@4.4.3))'` -> * { name: '@objectstack/spec', version: '17.3.0', peers: '(ai@7.0.65(zod@4.4.3))' } * - * A key whose version does not start with a digit (a git or URL dependency) is - * returned with `version: null`. Such a key still counts as a physical copy — - * it is a directory like any other — it is only excluded from the ordering - * comparison in rule 1, which has nothing to compare it with. + * A key whose version does not start with a digit (a git or URL dependency, or + * the pinned `@objectstack/*` build's `file:` directory) is returned with + * `version: null`. Such a key still counts as a physical copy — it is a directory + * like any other. * * @param {string} key * @returns {{ name: string, version: string|null, peers: string }} @@ -231,96 +250,32 @@ export function readDeclaredNames(text) { /** * @param {string} text * @param {string} label - * @returns {{ label: string, keys: string[], declared: Set, copies: Map, versions: Map }} + * @returns {{ label: string, keys: string[], declared: Set, copies: Map }} */ export function indexLockfile(text, label) { const keys = readSnapshotKeys(text); const declared = readDeclaredNames(text); /** @type {Map} */ const copies = new Map(); - /** @type {Map} */ const versions = new Map(); for (const key of keys) { - const { name, version } = parseSnapshotKey(key); + const { name } = parseSnapshotKey(key); if (!copies.has(name)) copies.set(name, []); copies.get(name).push(key); - if (version) { - if (!versions.has(name)) versions.set(name, []); - const seen = versions.get(name); - if (!seen.includes(version)) seen.push(version); - } - } - return { label, keys, declared, copies, versions }; -} - -// ── version ordering ──────────────────────────────────────────────────────── - -/** - * Compare two semver-ish versions. Numeric segments compare numerically; a - * prerelease sorts below the release it belongs to. This is an ORDERING only — - * the gate needs "is head lower than base", never range satisfaction. - * - * @param {string} a - * @param {string} b - * @returns {number} negative if a < b, 0 if equal, positive if a > b - */ -export function compareVersions(a, b) { - const split = (v) => { - const [core, pre = ''] = String(v).split('-', 2); - return { core: core.split('.').map((n) => Number.parseInt(n, 10) || 0), pre }; - }; - const x = split(a); - const y = split(b); - for (let i = 0; i < Math.max(x.core.length, y.core.length); i++) { - const d = (x.core[i] ?? 0) - (y.core[i] ?? 0); - if (d !== 0) return d; } - if (x.pre === y.pre) return 0; - if (!x.pre) return 1; // a release outranks its own prerelease - if (!y.pre) return -1; - return x.pre < y.pre ? -1 : 1; + return { label, keys, declared, copies }; } -const maxVersion = (list) => list.reduce((hi, v) => (compareVersions(v, hi) > 0 ? v : hi), list[0]); - -// ── the two rules ─────────────────────────────────────────────────────────── +// ── the rule ──────────────────────────────────────────────────────────────── /** - * @typedef {{ rule: 'backward', name: string, base: string[], head: string[], baseline: string, regressions: string[] }} BackwardFinding * @typedef {{ rule: 'duplicated', name: string, baseCount: number, headCount: number, base: string[], head: string[], added: string[] }} DuplicationFinding */ /** * @param {ReturnType} base * @param {ReturnType} head - * @returns {{ findings: Array, backward: BackwardFinding[], duplicated: DuplicationFinding[] }} + * @returns {{ findings: DuplicationFinding[], duplicated: DuplicationFinding[] }} */ export function compareLockfiles(base, head) { - /** Rule 1 — an `@objectstack/*` identity moved backward. @type {BackwardFinding[]} */ - const backward = []; - for (const [name, headVersions] of head.versions) { - if (!name.startsWith(FIRST_PARTY_SCOPE)) continue; - const baseVersions = base.versions.get(name); - if (!baseVersions?.length) continue; - const baseline = maxVersion(baseVersions); - // ⚠️ `!baseVersions.includes(v)` is load-bearing, not defensive. Without it an - // ALREADY-split base (17.2.0 + 17.3.0, unchanged by this pull request) reports its - // own 17.2.0 as a regression on every PR after it — the gate would red on changes - // that touched nothing. This is a delta gate: only a version the base did not - // resolve can have been introduced here. - const regressions = headVersions.filter( - (v) => compareVersions(v, baseline) < 0 && !baseVersions.includes(v), - ); - if (regressions.length) { - backward.push({ - rule: 'backward', - name, - base: baseVersions.slice().sort(compareVersions), - head: headVersions.slice().sort(compareVersions), - baseline, - regressions: regressions.sort(compareVersions), - }); - } - } - /** * Rule 2 — a package this repository declares gained a physical copy. * @@ -348,9 +303,8 @@ export function compareLockfiles(base, head) { }); } - backward.sort((a, b) => a.name.localeCompare(b.name)); duplicated.sort((a, b) => a.name.localeCompare(b.name)); - return { findings: [...backward, ...duplicated], backward, duplicated }; + return { findings: [...duplicated], duplicated }; } // ── reporting ─────────────────────────────────────────────────────────────── @@ -360,30 +314,17 @@ export function compareLockfiles(base, head) { * the incident cost two seats a day each because the red they got named the * wrong subject. * - * @param {{ backward: BackwardFinding[], duplicated: DuplicationFinding[] }} reading + * @param {{ duplicated: DuplicationFinding[] }} reading * @param {{ base: string, head: string, baseKeys: number, headKeys: number }} sources * @returns {string} */ export function renderReading(reading, sources) { const lines = []; - const total = reading.backward.length + reading.duplicated.length; + const total = reading.duplicated.length; lines.push( `base: ${sources.base} (${sources.baseKeys} resolutions) head: ${sources.head} (${sources.headKeys} resolutions)`, ); - for (const f of reading.backward) { - lines.push(''); - lines.push(`✗ ${f.name} moved BACKWARD: ${f.baseline} -> ${f.regressions.join(', ')}`); - lines.push(` base resolved ${f.base.join(', ')}`); - lines.push(` head resolves ${f.head.join(', ')}`); - lines.push( - ' Every workspace range on this scope is a floating `^17.x`, so no fresh resolution', - ); - lines.push( - ' can produce this. It is a stale resolution carried across a rebase (objectui#8326).', - ); - } - for (const f of reading.duplicated) { lines.push(''); lines.push(`✗ ${f.name} gained a physical copy: ${f.baseCount} -> ${f.headCount}`); @@ -394,12 +335,11 @@ export function renderReading(reading, sources) { lines.push(''); if (total === 0) { lines.push( - `VERDICT clean — no @objectstack/* identity moved backward and no package gained a copy.`, + `VERDICT clean — no package gained a copy.`, ); } else { lines.push( - `VERDICT ${total} lockfile-integrity finding(s): ` + - `${reading.backward.length} backward move(s), ${reading.duplicated.length} duplication(s).`, + `VERDICT ${total} lockfile-integrity finding(s): ${total} duplication(s).`, ); lines.push( 'Two physical copies of one package are two real paths, and nothing dedupes those — a', @@ -536,21 +476,22 @@ export function selfTest() { t('the peer suffix survives', parsed.peers === '(ai@7.0.65(zod@4.4.3))'); t('unscoped key splits', parseSnapshotKey('zod@4.4.3').name === 'zod'); t('a non-numeric version is not ordered', parseSnapshotKey('pkg@github:o/r#abc').version === null); + const pinned = parseSnapshotKey('@objectstack/spec@file:node_modules/.cache/objectstack/spec(ai@7.0.65(zod@4.6.7))'); + t('the pinned build key keeps its name and has no version (objectui#12957)', pinned.name === '@objectstack/spec' && pinned.version === null); t('sub-keys are not read as snapshots', readSnapshotKeys("snapshots:\n zod@4.4.3:\n dependencies:\n foo: 1.0.0\n").join() === 'zod@4.4.3'); t('the section ends at the next top-level key', readSnapshotKeys("snapshots:\n zod@4.4.3: {}\nother:\n nope@1.0.0: {}\n").join() === 'zod@4.4.3'); - // ── ordering ────────────────────────────────────────────────────────────── - t('17.2.0 < 17.3.0', compareVersions('17.2.0', '17.3.0') < 0); - t('17.10.0 > 17.9.0 (numeric, not lexical)', compareVersions('17.10.0', '17.9.0') > 0); - t('a prerelease sorts below its release', compareVersions('17.3.0-rc.1', '17.3.0') < 0); - - // ── cause 1: objectui#8326's downgrade ──────────────────────────────────── + // ── cause 1: what is left of objectui#8326's downgrade (rule 1 retired) ── const a = run( ['@objectstack/spec@17.3.0', '@objectstack/client@17.3.0'], ['@objectstack/spec@17.2.0', '@objectstack/spec@17.3.0', '@objectstack/client@17.2.0'], ); - t('cause 1 — the backward move is named', a.backward.some((f) => f.name === '@objectstack/client' && f.regressions.includes('17.2.0'))); - t('cause 1 — the duplication is named too', a.duplicated.some((f) => f.name === '@objectstack/spec' && f.headCount === 2)); + t('cause 1 — the duplication the downgrade left is named', a.duplicated.some((f) => f.name === '@objectstack/spec' && f.headCount === 2)); + t('cause 1 — a version moving alone is no longer judged (rule 1 retired)', !a.duplicated.some((f) => f.name === '@objectstack/client')); + const PIN = '@objectstack/spec@file:node_modules/.cache/objectstack/spec'; + const p = run([PIN], [PIN, '@objectstack/spec@18.0.0-next.3']); + t('pin — a registry copy beside the pinned build is a duplication (objectui#12957)', p.duplicated.some((f) => f.name === '@objectstack/spec' && f.added.includes('@objectstack/spec@18.0.0-next.3'))); + t('pin — moving from a registry version onto the pinned build is clean', run(['@objectstack/spec@18.0.0-next.3'], [PIN]).findings.length === 0); // ── cause 2: objectui#8333, no @objectstack/* movement at all ───────────── const b = run( @@ -564,7 +505,6 @@ export function selfTest() { 'better-auth@1.7.3', ], ); - t('cause 2 — no @objectstack/* identity moved (rule 1 is blind here)', b.backward.length === 0); t('cause 2 — zod 2 -> 3 is a finding, though it was never single-copy', b.duplicated.some((f) => f.name === 'zod' && f.baseCount === 2 && f.headCount === 3)); t('cause 2 — the peer-forked spec is a finding at one version', b.duplicated.some((f) => f.name === '@objectstack/spec' && f.headCount === 2)); t('cause 2 — the gate is red on it', b.findings.length > 0); @@ -598,7 +538,6 @@ export function selfTest() { const sources = { base: 'b', head: 'h', baseKeys: 1, headKeys: 1 }; t('a clean reading says clean', renderReading(c, sources).includes('VERDICT clean')); t('a finding names the package in the report', renderReading(b, sources).includes('zod gained a physical copy: 2 -> 3')); - t('a finding names the backward move', renderReading(a, sources).includes('moved BACKWARD')); const failed = cases.filter((c2) => !c2.ok); for (const c2 of failed) console.error(` ✗ ${c2.name}${c2.detail ? ` — ${c2.detail}` : ''}`); @@ -608,7 +547,7 @@ export function selfTest() { } console.log( `✓ check-lockfile-integrity self-test: ${cases.length} cases pass ` + - "(both incident causes, the peer-fork that moves no version, and the controls a red-on-everything gate would fail).", + "(the duplication half of both incident causes, the peer-fork that moves no version, the pinned build, and the controls a red-on-everything gate would fail).", ); return 0; } diff --git a/scripts/dependabot-merge-gate.mjs b/scripts/dependabot-merge-gate.mjs index 68610ab4ad..a045dbba50 100644 --- a/scripts/dependabot-merge-gate.mjs +++ b/scripts/dependabot-merge-gate.mjs @@ -310,7 +310,7 @@ export const NOT_A_GATE = Object.freeze({ 'Changeset Claim Re-read': "changeset-presence.yml's second job is REPORT-ONLY with no enforcing switch at all (objectui#9003): it names the pending `.changeset/*.md` bodies that spell a file this change touches, so the seat whose diff might have falsified a pending release note re-reads the paragraph before it publishes verbatim. \"A pending changeset names a file you edited\" is usually still TRUE, so the finding is a request to read and never a verdict on the change — it exits 0 whatever it finds, and goes red only when it cannot resolve its base, which is a fact about the checkout. It carries NO path filter, deliberately (the falsifying change is an ordinary source change under no obligation to touch `.changeset/**`), so unlike its sibling report it DOES run on a Dependabot bump — and classifying it here rather than requiring it is what keeps a report-only check out of the merge decision.", 'Lockfile Integrity Check': - "lockfile-integrity.yml (objectui#8326) reports a lockfile DELTA — an `@objectstack/*` identity moving backward, or a workspace-declared dependency gaining a physical copy. ⛔ It is deliberately NOT a blocking context: enrolling it changes what stops the merge queue, which is a maintainer decision the #8326 dispatch reserved rather than took, and its pull request writes up the cost as input (measured: it would have blocked 3 of the 40 most recent lockfile-changing commits on `main`, each for a real duplication of a runtime-declared package). Its pull_request trigger is also path-filtered to `pnpm-lock.yaml` and its own two files, so it cannot be REQUIRED under #3523's rule while that filter stands — promoting it means removing the filter as well.", + "lockfile-integrity.yml (objectui#8326) reports a lockfile DELTA — a workspace-declared or `@objectstack/*` dependency gaining a physical copy (its backward-version rule was retired by objectui#12957: `@objectstack/*` resolves to the pinned build, which has no version to move). ⛔ It is deliberately NOT a blocking context: enrolling it changes what stops the merge queue, which is a maintainer decision the #8326 dispatch reserved rather than took, and its pull request writes up the cost as input (measured: it would have blocked 3 of the 40 most recent lockfile-changing commits on `main`, each for a real duplication of a runtime-declared package). Its pull_request trigger is also path-filtered to `pnpm-lock.yaml` and its own two files, so it cannot be REQUIRED under #3523's rule while that filter stands — promoting it means removing the filter as well.", 'Live half-state sweep': 'half-state-patrol.yml is REPORT-ONLY by ruling (objectui#5791): a completed sweep exits 0 whether it found 0 half-states or 40, and the job gates no branch and blocks no queue. It goes red only when the sweep could not RUN — the patrol reporting its own death, which is a fact about the patrol, not a verdict on the pull request. Its pull_request trigger is also path-filtered to the workflow file (the sweeper runs from an objectstack checkout since objectui#10208), so a Dependabot bump never produces this check at all.', 'Line Citation Gate': diff --git a/scripts/objectstack-pin.mjs b/scripts/objectstack-pin.mjs index 64c56527e1..1344ddb7fb 100644 --- a/scripts/objectstack-pin.mjs +++ b/scripts/objectstack-pin.mjs @@ -51,10 +51,22 @@ * 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 - * built, and a missing entry point refuses with the command that builds it. The - * lockfile does not change; a frozen install stays valid as long as that tree's - * packages declare the dependencies the lockfile records -- when they do not, the - * frozen install fails and names the mismatch. + * 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 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) * @@ -67,7 +79,9 @@ * - 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, so a failed run leaves the previous build in place. + * packages packed, 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. * * ⛔ Node builtins only: CI runs this BEFORE `pnpm install` * (`scripts/check-pre-install-import-graph.mjs` holds that). @@ -307,7 +321,7 @@ export function entryTargets(manifest) { } }; walk(manifest.exports); - return [...targets].map((t) => t.replace(/^\.\//, '')).sort(); + return [...new Set([...targets].map((t) => t.replace(/^\.\//, '')))].sort(); } /** @@ -319,6 +333,96 @@ export function missingEntryTargets(dir) { 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, CACHE_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 ${CACHE_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 ─────────────────────────────────────────────────────────────── /** @@ -399,7 +503,10 @@ function run(cmd, args, { cwd, capture = false, env = process.env }) { maxBuffer: 256 * 1024 * 1024, }); if (result.error) throw new PinRefusal(`could not run \`${cmd} ${args.join(' ')}\` in ${cwd}: ${result.error.message}`); - if (result.status !== 0) throw new PinRefusal(`\`${cmd} ${args.join(' ')}\` exited ${result.status} in ${cwd}.`); + if (result.status !== 0) { + const said = capture && result.stdout ? `\n${result.stdout.trim().slice(-2000)}` : ''; + throw new PinRefusal(`\`${cmd} ${args.join(' ')}\` exited ${result.status} in ${cwd}.${said}`); + } return capture ? result.stdout : ''; } @@ -473,6 +580,7 @@ export function build({ root = REPO_ROOT, env = process.env } = {}) { const verdict = judgeStamp(readStamp(root), { pin, overrides, root }); if (verdict.current) { console.log(`✓ ${CACHE_DIR} is ${verdict.why} -- nothing to build.`); + assertLockfileRecordsBuild(root, overrides); return { skipped: true, stamp: readStamp(root) }; } console.log(`→ ${CACHE_DIR}: ${verdict.why}. Building @objectstack/* at ${pin}.`); @@ -483,11 +591,11 @@ export function build({ root = REPO_ROOT, env = process.env } = {}) { 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(os.tmpdir(), 'objectstack-pin-')); + const scratch = fs.mkdtempSync(path.join(scratchBase(env), 'ospin-')); try { let tree = external; if (!external) { - tree = path.join(scratch, 'objectstack'); + tree = path.join(scratch, 'src'); step(`fetch objectstack ${pin.slice(0, 12)}`, () => checkoutPin(tree, pin, env.OBJECTSTACK_REPO_URL || DEFAULT_REPO_URL)); } const projects = readWorkspace(tree); @@ -538,6 +646,7 @@ export function build({ root = REPO_ROOT, env = process.env } = {}) { (external ? `from ${external} (${String(stamp.sha).slice(0, 12)}${stamp.dirty ? ', with uncommitted changes' : ''})` : `built at ${pin.slice(0, 12)}`) + '. Run `pnpm install` so node_modules links it.', ); + assertLockfileRecordsBuild(root, overrides); return { skipped: false, stamp }; } finally { fs.rmSync(staging, { recursive: true, force: true }); @@ -562,6 +671,25 @@ export function status({ root = REPO_ROOT } = {}) { // ── 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')); } diff --git a/turbo.json b/turbo.json index fda8870ea3..24c4aaef68 100644 --- a/turbo.json +++ b/turbo.json @@ -1,7 +1,11 @@ { "$schema": "https://turbo.build/schema.json", "globalEnv": ["NODE_ENV"], - "globalDependencies": ["node_modules/@objectstack/spec/.spec-main-shape-gate.json"], + "globalDependencies": [ + "node_modules/@objectstack/spec/.spec-main-shape-gate.json", + ".objectstack-sha", + "node_modules/.cache/objectstack/.stamp.json" + ], "tasks": { "build": { "dependsOn": ["^build"], From 3455c1ed424e23b484017dfe969747ab954fd11a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 12:06:39 +0000 Subject: [PATCH 3/9] ci(tooling): every installing job restores or builds the pinned @objectstack build; AGENTS.md and the setup docs name the pin - Each of the 18 jobs that install (or, in lockfile-dedupe, resolve) restores node_modules/.cache/objectstack from a cache keyed by .objectstack-sha and the script that builds it, runs `node scripts/objectstack-pin.mjs build` (a no-op on a hit), and saves it as its last step only when it built it -- bounded and non-fatal, per objectui#6577/#7048. An unchanged pin never rebuilds. - performance-budget.yml triggers on .objectstack-sha (the bundle moves with the pin); its and lockfile-dedupe.yml's filters list the new script. - AGENTS.md: a dependency and lockfile section (the pin, the bump, the stand-in, the frozen-install caveat), `pnpm run setup` in the worktree rule and the local dev runbook. - README, CONTRIBUTING, QUICK_REFERENCE, scripts/setup.sh and two docs pages: a clean checkout starts with `pnpm run setup`. - Tests that assumed one cache per job, an exact action-ref list, or no continue-on-error in shadcn-check now read the pinned build's steps too; ci-cd-pipeline.md names the new command in every section that runs it. Claude-Session: https://claude.ai/code/session_0188TsgPV8kQXfuRuKmkj2ks Co-authored-by: Claude --- .github/workflows/changeset-release.yml | 28 +++ .github/workflows/ci.yml | 208 ++++++++++++++++++ .github/workflows/docs-deploy.yml | 28 +++ .github/workflows/lint.yml | 30 +++ .github/workflows/live-e2e.yml | 28 +++ .github/workflows/lockfile-dedupe.yml | 31 +++ .github/workflows/node-esm-load-gate.yml | 28 +++ .github/workflows/performance-budget.yml | 38 ++++ .github/workflows/published-dist-gate.yml | 28 +++ .github/workflows/repo-gates.yml | 30 +++ .github/workflows/shadcn-check.yml | 28 +++ .github/workflows/spec-range-floors.yml | 28 +++ AGENTS.md | 13 +- CONTRIBUTING.md | 9 +- QUICK_REFERENCE.md | 3 +- README.md | 4 +- content/docs/guide/ci-cd-pipeline.md | 36 ++- content/docs/guide/console.md | 2 +- content/docs/utilities/runner.mdx | 6 +- scripts/__tests__/bump-objectstack.test.ts | 15 +- .../check-action-ref-convention.test.ts | 3 + scripts/__tests__/objectstack-pin.test.ts | 9 +- .../__tests__/render-budget-comment.test.ts | 10 +- scripts/__tests__/report-turbo-cache.test.ts | 7 +- scripts/__tests__/shadcn-check-report.test.ts | 11 +- .../__tests__/turbo-cache-save-bound.test.ts | 10 +- scripts/setup.sh | 11 +- 27 files changed, 639 insertions(+), 43 deletions(-) diff --git a/.github/workflows/changeset-release.yml b/.github/workflows/changeset-release.yml index b1e60c0f50..533cf01fe5 100644 --- a/.github/workflows/changeset-release.yml +++ b/.github/workflows/changeset-release.yml @@ -727,6 +727,21 @@ jobs: 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 @@ -1505,3 +1520,16 @@ jobs: 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/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6fac29fe3..bc8af276c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -253,6 +253,23 @@ jobs: if: steps.relevant.outputs.should_run == 'true' run: node scripts/check-type-check-coverage.mjs + # 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 + if: steps.relevant.outputs.should_run == 'true' + 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 + if: steps.relevant.outputs.should_run == 'true' + run: node scripts/objectstack-pin.mjs build + - name: Install dependencies if: steps.relevant.outputs.should_run == 'true' run: pnpm install --frozen-lockfile @@ -921,6 +938,19 @@ jobs: path: .turbo/cache key: turbo-${{ runner.os }}-type-check-${{ 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.relevant.outputs.should_run == 'true' && 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') }} + # PRs run the suite split across the runners of the `shard:` matrix below. # The suite is dominated by fixed per-file cost rather than by the # assertions: a PR run reported 495s wall @@ -1175,6 +1205,23 @@ jobs: 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 + if: steps.relevant.outputs.should_run == 'true' + 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 + if: steps.relevant.outputs.should_run == 'true' + run: node scripts/objectstack-pin.mjs build + - name: Install dependencies if: steps.relevant.outputs.should_run == 'true' run: pnpm install --frozen-lockfile @@ -1285,6 +1332,19 @@ jobs: retention-days: 7 if-no-files-found: warn + # 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.relevant.outputs.should_run == 'true' && 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') }} + # ── The built-artifact pin lane, lifted out of the shard matrix ────────── # (objectui#7183 for the lane itself; objectui#9499 for this job.) # @@ -1396,6 +1456,23 @@ jobs: 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 + if: steps.relevant.outputs.should_run == 'true' + 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 + if: steps.relevant.outputs.should_run == 'true' + run: node scripts/objectstack-pin.mjs build + - name: Install dependencies if: steps.relevant.outputs.should_run == 'true' run: pnpm install --frozen-lockfile @@ -1404,6 +1481,19 @@ jobs: if: steps.relevant.outputs.should_run == 'true' run: pnpm test:dist + # 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.relevant.outputs.should_run == 'true' && 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') }} + # ── ⭐ The single required test context (objectui#9499, ruling letter C) ── # # Until this job the required-check set named the matrix legs one by one -- @@ -1611,6 +1701,21 @@ jobs: 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 @@ -1728,6 +1833,19 @@ jobs: retention-days: 7 if-no-files-found: error + # 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') }} + # The other half of objectui#5403: merge every shard's blob report into ONE # complete coverage report, publish it, and — whatever happens — say out loud # whether this lane actually delivered for the commit. @@ -1826,6 +1944,23 @@ jobs: 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 + if: needs.test-coverage.result == 'success' + 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 + if: needs.test-coverage.result == 'success' + run: node scripts/objectstack-pin.mjs build + - name: Install dependencies if: needs.test-coverage.result == 'success' run: pnpm install --frozen-lockfile @@ -2007,6 +2142,19 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" exit 1 + # 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() && needs.test-coverage.result == 'success' && 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') }} + # Build + E2E merged into one job: avoids artifact upload/download (~30s) # and a duplicate `pnpm install` (~60s). This job only needs the console SPA # artifact for Playwright; package build and package-size checks are covered @@ -2079,6 +2227,23 @@ jobs: 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 + if: steps.relevant.outputs.should_run == 'true' + 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 + if: steps.relevant.outputs.should_run == 'true' + run: node scripts/objectstack-pin.mjs build + - name: Install dependencies if: steps.relevant.outputs.should_run == 'true' run: pnpm install --frozen-lockfile @@ -2285,6 +2450,19 @@ jobs: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }} + # 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.relevant.outputs.should_run == 'true' && 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') }} + docs: name: Build Docs runs-on: ubuntu-latest @@ -2521,6 +2699,23 @@ jobs: restore-keys: | turbo-${{ runner.os }}-docs- + # 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 + if: steps.docs-changes.outputs.should_run == 'true' + 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 + if: steps.docs-changes.outputs.should_run == 'true' + run: node scripts/objectstack-pin.mjs build + - name: Install dependencies if: steps.docs-changes.outputs.should_run == 'true' run: pnpm install --frozen-lockfile @@ -2641,3 +2836,16 @@ jobs: with: path: .turbo/cache key: turbo-${{ runner.os }}-docs-${{ 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.docs-changes.outputs.should_run == 'true' && 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/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 7fccd94f5d..1d92570823 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -116,6 +116,21 @@ jobs: 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 the Vercel CLI run: npm install --global "vercel@${VERCEL_CLI_VERSION}" @@ -136,3 +151,16 @@ jobs: echo "### Docs deployed to production" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" echo "${url} (commit ${GITHUB_SHA})" >> "$GITHUB_STEP_SUMMARY" + + # 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/.github/workflows/lint.yml b/.github/workflows/lint.yml index 5b659f41d0..f1f3abe016 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -344,6 +344,23 @@ jobs: restore-keys: | turbo-${{ runner.os }}-lint- + # 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 + if: steps.relevant.outputs.should_run == 'true' + 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 + if: steps.relevant.outputs.should_run == 'true' + run: node scripts/objectstack-pin.mjs build + - name: Install dependencies if: steps.relevant.outputs.should_run == 'true' run: pnpm install --frozen-lockfile @@ -603,3 +620,16 @@ jobs: with: path: .turbo/cache key: turbo-${{ runner.os }}-lint-${{ 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.relevant.outputs.should_run == 'true' && 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/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml index b1eca59f9d..ef3558f66a 100644 --- a/.github/workflows/live-e2e.yml +++ b/.github/workflows/live-e2e.yml @@ -116,6 +116,21 @@ jobs: 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 @@ -346,3 +361,16 @@ jobs: # Ephemeral runner — no teardown needed; stop-backend.sh exists for # local runs of the same scripts. + + # 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/.github/workflows/lockfile-dedupe.yml b/.github/workflows/lockfile-dedupe.yml index 4dc47d0e77..d42da0280e 100644 --- a/.github/workflows/lockfile-dedupe.yml +++ b/.github/workflows/lockfile-dedupe.yml @@ -98,6 +98,9 @@ on: - 'scripts/invoked-as.mjs' # The toolchain step this job runs before pnpm is on PATH. - 'scripts/ci-setup-pnpm.sh' + # The pinned @objectstack/* build this job restores or builds before the + # check, and which the checker imports for its path (objectui#12957). + - 'scripts/objectstack-pin.mjs' workflow_dispatch: concurrency: @@ -163,6 +166,21 @@ jobs: - name: Self-test the checker run: node scripts/check-lockfile-dedupe.mjs --self-test + # 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 + # No `pnpm install`: `pnpm dedupe --check` resolves and reports without # writing a lockfile or a node_modules tree (verified on objectui#8333 — # the lockfile's sha256 is unchanged across repeated runs). @@ -174,3 +192,16 @@ jobs: # the real failures of this same check. - name: Check the committed lockfile is deduped run: node scripts/check-lockfile-dedupe.mjs --report-only + + # 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/.github/workflows/node-esm-load-gate.yml b/.github/workflows/node-esm-load-gate.yml index a37ccb766f..ef067111b8 100644 --- a/.github/workflows/node-esm-load-gate.yml +++ b/.github/workflows/node-esm-load-gate.yml @@ -84,6 +84,21 @@ jobs: 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 @@ -95,3 +110,16 @@ jobs: # entries actually evaluated. - name: Build every published package and import its entry under plain Node run: pnpm check:node-esm-load + + # 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/.github/workflows/performance-budget.yml b/.github/workflows/performance-budget.yml index 915fb71525..aec17a4c3c 100644 --- a/.github/workflows/performance-budget.yml +++ b/.github/workflows/performance-budget.yml @@ -7,6 +7,11 @@ on: - 'packages/**' - 'apps/console/**' - 'pnpm-lock.yaml' + # The console bundles @objectstack/* from the build of the commit this file + # pins, so a pin bump moves the bundle without touching the lockfile + # (objectui#12957); the script that builds it runs in this job. + - '.objectstack-sha' + - 'scripts/objectstack-pin.mjs' # This file itself (objectui#6245). Without it a change to this gate ships # to `main` having never run once: the PR that edits it touches no # `packages/**` path, so `Bundle Analysis` never appears on it, and a @@ -67,6 +72,11 @@ on: - 'packages/**' - 'apps/console/**' - 'pnpm-lock.yaml' + # The console bundles @objectstack/* from the build of the commit this file + # pins, so a pin bump moves the bundle without touching the lockfile + # (objectui#12957); the script that builds it runs in this job. + - '.objectstack-sha' + - 'scripts/objectstack-pin.mjs' # This file itself (objectui#6245). Without it a change to this gate ships # to `main` having never run once: the PR that edits it touches no # `packages/**` path, so `Bundle Analysis` never appears on it, and a @@ -252,6 +262,21 @@ jobs: restore-keys: | turbo-${{ runner.os }}-perf- + # 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 @@ -710,3 +735,16 @@ jobs: with: path: .turbo/cache key: turbo-${{ runner.os }}-perf-${{ 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/.github/workflows/published-dist-gate.yml b/.github/workflows/published-dist-gate.yml index 22edfbc5e2..3250a0b0ab 100644 --- a/.github/workflows/published-dist-gate.yml +++ b/.github/workflows/published-dist-gate.yml @@ -80,6 +80,21 @@ jobs: 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 @@ -91,3 +106,16 @@ jobs: # skipped. - name: Build every published package and scan its tarball run: pnpm check:published-dist + + # 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/.github/workflows/repo-gates.yml b/.github/workflows/repo-gates.yml index f4c959cc77..27d01d77cf 100644 --- a/.github/workflows/repo-gates.yml +++ b/.github/workflows/repo-gates.yml @@ -308,6 +308,23 @@ jobs: if: ${{ !cancelled() }} run: pnpm check:docs-route-closure + # 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 + if: ${{ !cancelled() }} + 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 + if: ${{ !cancelled() }} + run: node scripts/objectstack-pin.mjs build + # ── The gates that read the built type surface ─────────────────────── - name: Install dependencies id: install @@ -463,3 +480,16 @@ jobs: - name: README Export Check — Check every README self-import against the real export surface if: ${{ !cancelled() && steps.build.outcome == 'success' }} run: pnpm check:readme-exports + + # 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/.github/workflows/shadcn-check.yml b/.github/workflows/shadcn-check.yml index 997e8697d0..2cdc1295d3 100644 --- a/.github/workflows/shadcn-check.yml +++ b/.github/workflows/shadcn-check.yml @@ -68,6 +68,21 @@ jobs: 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 @@ -220,3 +235,16 @@ jobs: labels: ['maintenance', 'shadcn-sync', 'dependencies'], }); } + + # 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/.github/workflows/spec-range-floors.yml b/.github/workflows/spec-range-floors.yml index 899068e4e0..90b0ec0ebf 100644 --- a/.github/workflows/spec-range-floors.yml +++ b/.github/workflows/spec-range-floors.yml @@ -84,6 +84,21 @@ jobs: 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 @@ -100,3 +115,16 @@ jobs: - name: Verify every declared spec floor carries the symbols its artifact uses 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/AGENTS.md b/AGENTS.md index 31e36ec14f..081a16097d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -272,7 +272,7 @@ AGENTS.md 的「只跑受影响的包」指的是**用上面的路径过滤缩 - 失败现场会直接指认根因:dump 里若仍是 Suspense fallback(如 `Loading report renderer…`),就是本条;**hook 超时表现为「失败的*文件* + 0 个失败*测试*」**(其余全部 skipped),别误读成断言失败。 - 顺手体检:别把 `keysOf(x)` 这类整体计算写在 `.filter()` 谓词里(每个元素重算一遍)。`all-locales-key-parity` 曾因此 7.51s,提升出谓词后 **25ms**。 - **i18n 取证纪律:凡按「排版/字符记法」做普查或断言的正则,一律 `u` flag + `\p{L}` 之类的 Unicode 属性类,禁止 ASCII `\w`/`[a-z]`。** JS 的 `\w` 不论加不加 `u` 都等于 `[A-Za-z0-9_]`,匹配不到西里尔/阿拉伯/CJK 字母 —— 本仓十个包里 **五个是非拉丁文字**,所以这类正则的结论对它们**恒为「零命中」**,而且失败方向是最坏的那种:普查报告「该包不用这种记法」,断言绿着但**从不检查任何东西**。两种形态都实际发生过(objectui#3866):PR #3847 用 `/\(\w{1,4}\)/` 普查括号复数记法,给 ru 判了「全包 0 处」,实测 `\p{L}` 是 12 处、其中就有和被查 key 同构的 `{{count}} поле(й)`;同一写法写进断言后,对 zh/ja/ko/ru/ar 五包永假空转。**改法**:`/\([\p{L}]{1,4}\)/u`,并把反例连同旧写法一起钉住(`expect(RE.test('поле(й)')).toBe(true)` + `expect(/\(\w{1,4}\)/.test('поле(й)')).toBe(false)`),否则下一个人看不出两种写法不等价。**顺带记住属性类自己的边界**:`\p{L}` 只有字母(数字括号如 `(Pos1)` 不在内),`\(` 只有 ASCII 括号(全角 U+FF08/U+FF09 不在内,zh/ja 用得不少)—— 按你要查的记法选类,查不到的那半要在 PR 里写明是界外,别默认为零。 -- **本地一片 parity/schema 测试失败,先怀疑 stale install**(`node_modules` 里的 `@objectstack/spec` 版本落后于 lockfile),不是回归 —— CI 每次全新安装,永远不会命中这个。 +- **本地一片 parity/schema 测试失败,先怀疑 stale install**(`node_modules` 里的 `@objectstack/*` 不是 `.objectstack-sha` 钉的那份构建),不是回归 —— CI 每次按 pin 恢复或构建再全新安装,永远不会命中这个。`pnpm objectstack:status` 答「缓存里是不是 pin」,不是就 `pnpm run setup`(见下方「依赖与锁文件」)。 - **别用 `prettier` 给改动做收尾检查 —— 它对未改动内容就是红的。** 本仓没有格式化门禁:没有 `.prettierrc` / `prettier.config.*` / `.prettierignore` / `.editorconfig`,没有 workflow 跑它,`eslint.config.js` 里也没有任何格式规则(全是正确性/ratchet 规则);那条从未接线的 devDependency 已随 objectui#3657 / PR #3681 删掉,仓内(排除 `pnpm-lock.yaml`)已 grep 不到 prettier。**但命令仍然跑得通** —— 容器镜像在 `/opt/node22/lib/node_modules/` 预装了一份全局 prettier(实测 3.8.1),仓内解析不到时 `pnpm exec` 会沿 PATH 兜底,任何装有全局 prettier 的机器同理。没有配置就按 prettier **默认值**(双引号、`printWidth: 80`)判定,而本仓是单引号、行宽更宽,于是**逐字节等于 `origin/main` 的文件照样报 `exit=1`**:根因是「默认配置 ≠ 本仓约定」,**不是**「`main` 没格式化」,也不是你改坏了。**禁止**据此 `--write` —— 未改动的 `scripts/check-doc-links.mjs` 单个文件就是 389 行重排(它的测试文件 1646 行),内容是 `'x'` → `"x"` 与 80 列回绕这类与你无关的 diff,会一起混进你的 PR。正确动作:忽略这份输出;本仓真接了线的检查是 `pnpm lint` / `pnpm test` 与根 `package.json` 里的 `check:*` 那几条。前情 objectui#3657、#3682。 前情:objectui#3010(一次修掉五个文件,含一个已被「超时调到 15s」糊过、满负载下依然 15021ms 超时的例子)。 @@ -285,12 +285,20 @@ AGENTS.md 的「只跑受影响的包」指的是**用上面的路径过滤缩 - **这一条现在由 CI 机械强制** —— `scripts/check-changeset-no-major.mjs` 在任一 changeset 声明 `major` 时退出非零,由 `.github/workflows/changeset-guard.yml` 跑(它是唯一以 `.changeset/**` 为**触发**路径的 workflow。这里曾写「`ci.yml`/`lint.yml` 都把 `**/*.md` 和 `.changeset/**` 列进 `paths-ignore`,只加 changeset 的 PR 不会启动任何 workflow」——**已不成立,别照它做判断**:objectui#3523 step 2 把 `paths-ignore` 从这两个 workflow 的 `pull_request` 上删掉了,今天它**只**留在 `push` 触发上;PR 的路径判断改在 job 内做 —— `ci.yml` 的 `Decide whether this change needs a full run` 步骤,`lint.yml` 有同名的一份。所以 md-only / changeset-only 的 PR **照常启动**这两个 workflow 并产出 required context —— 实测 PR #3856(只改一个 `.md`)起了 16 个 check、PR #4339(只给 AGENTS.md 加一行)起了 17 个。真正被跳过的是重活:那个 job 内开关的排除表逐条**等于** `push` 的 `paths-ignore`(仍含 `**/*.md` 与 `.changeset/**`),changeset-only 的 PR 里这两个 workflow 每一步都 skip。这才是 changeset-guard.yml 今天仍必须独立、且以 `.changeset/**` **反向**触发的理由:它跑在那个 job 内开关之外,免安装免构建,是唯一判得到 changeset-only PR 的东西。见 objectui#3523 step 2 / objectui#3857),`pnpm test` 里另有一条仓库状态断言兜底。跟随 objectstack 跨 major 的那一次同步升级由脚本按 manifest 自行判定放行,不靠开关:只有 fixed 组成员上的 `major`、且该组当前 major + 1 正好等于组内 manifest 在 `dependencies`/`peerDependencies`/`optionalDependencies` 里声明的 `@objectstack/spec` 范围的 major 时才放行,其余照旧拦(维护者裁决,objectui#12030 评论 6149296149;判据的权威表述是该脚本的文件头);Changesets pre 模式下已被 `changeset version` 消费、挪进 `.changeset/pre/` 的 changeset 不再读。前情:objectui#3161/#3159/#3160/#3225 四个 changeset 在 17.x 期间标了 `major`(17 个包条目),足以把 39 个包发成 `18.0.0`。 - 这是约定优先于 semver 纯粹性的取舍(为可维护/好记),因此 objectui 的 major 不代表「它自身 API 的破坏性变更次数」。`@object-ui/site` 与 `@object-ui/example-*` 在 `ignore` 列表,不随组联动。 +### 依赖与锁文件:`@objectstack/*` 按提交钉(objectui#12955 裁决 B,objectui#12957) + +- **pin**:仓根 `.objectstack-sha` 钉一个 objectstack `main` 提交。根 `package.json` 的 `pnpm.overrides` 把**每一个** `@objectstack/*` 指向 `file:node_modules/.cache/objectstack/<名>` —— 那是该提交源码构建后、按 `pnpm pack` 的发布形状解包的目录。没有任何 `@objectstack/*` 从 npm 解析;各清单里的 `@objectstack/*` 范围只在 objectui 自己发版时起作用(objectui#12958),⛔ 别为「对齐 pin」去改它们。机制、为什么是 `file:` 目录而不是 tarball 或 `link:`、以及它拒绝什么,全在 `scripts/objectstack-pin.mjs` 文件头,本节不复述。 +- **本地 setup**:干净 checkout(含每个新 worktree)跑 **`pnpm run setup`** = `pnpm objectstack:build`(构建 pin 的 `@objectstack/*` 进上面那个目录;已是 pin 就跳过)再 `pnpm install --frozen-lockfile`。⛔ 不是 `pnpm setup`:那是 pnpm 的内建命令,会去改你 shell 的 rc 文件,根本不跑这个脚本。冷构建是重活,按下面「重验证先经那把锁」的规矩跑。⚠️ 删 `node_modules` 也就删了这份构建,之后的 `pnpm install` 以 `ENOENT`/`ERR_PNPM_LINKED_PKG_DIR_NOT_FOUND` 指着 `node_modules/.cache/objectstack/<名>` 失败 —— 重跑 `pnpm run setup`。 +- **换 pin**:`scripts/bump-objectstack.sh []`(读 `../objectstack`,`OBJECTSTACK_ROOT` 可改;`--help` 是它的说明)→ `pnpm objectstack:build` → `pnpm install`,`pnpm-lock.yaml` 变了就一起提交。它对解析不了、读不出的提交**拒绝且什么都不写**,对不在 `origin/main` 上的提交**只警告**。 +- **用一棵现成的 objectstack 树代替 pin**:`OBJECTSTACK_ROOT=<树> pnpm objectstack:build && pnpm install --frozen-lockfile` —— 那棵树须已 install 且已 build;锁文件不变,冻结安装照样成立(objectstack#24306 的 `build-console.sh` 用的就是这个输入)。用完跑一次 `pnpm objectstack:build` 回到 pin。 +- **锁文件规则**:`pnpm-lock.yaml` 里 `@objectstack/*` 只有 `file:` 目录解析、没有版本号。⚠️ 冻结安装**不核对**目录清单的依赖 —— 构建声明了锁文件没记录的依赖,`pnpm install --frozen-lockfile` 照样退出 0、就是不装它;所以 `pnpm objectstack:build` 在两者的依赖名不一致时拒绝,让你跑一次 `pnpm install` 改写锁文件。⛔ 别删 override 让某个 `@objectstack/*` 回到 npm:构建脚本拒绝 override 集合与依赖闭包不相等的任何情形。 + ### 多 agent 协作纪律(并行修改本仓库,务必遵守) 本仓库有**多个 agent 并行**修改 —— 分支会被切换、共享文件会在你工作时被改动(正常现象,不是 bug): - **只改你任务需要的文件**;别去"修"无关的 diff、回退或别人的在途编辑,也别管整棵工作树。 -- **必须一个任务一个 git worktree**(`git fetch origin main && git worktree add --no-track ../objectui- -b origin/main`,新树里跑 `pnpm install`)做物理隔离 —— 这是强制而非「首选」。共享的 `main` checkout **不是**可用退路:HEAD 会被别的 agent 切换、你刚写的文件会在操作中途被 reset 掉。一个 **PreToolUse 钩子**(`.claude/hooks/guard-main-checkout.sh`)**强制**此规则:除非被编辑文件位于专属 **worktree** 否则拦截 `Edit`/`Write`/`NotebookEdit`——在共享 checkout 上开 feature 分支**也不行**(仍会被切走),且按**被编辑文件所属的仓库**判断(sibling 仓 `framework`/`cloud` 一并守住)(确属非任务的临时改动用 `OS_ALLOW_MAIN_EDITS=1` 放行)。即便在自己的 worktree 里,下面这些防御性条款仍然适用。 +- **必须一个任务一个 git worktree**(`git fetch origin main && git worktree add --no-track ../objectui- -b origin/main`,新树里跑 `pnpm run setup` —— 见上方「依赖与锁文件」,裸 `pnpm install` 在没有 pin 构建的新树里会失败)做物理隔离 —— 这是强制而非「首选」。共享的 `main` checkout **不是**可用退路:HEAD 会被别的 agent 切换、你刚写的文件会在操作中途被 reset 掉。一个 **PreToolUse 钩子**(`.claude/hooks/guard-main-checkout.sh`)**强制**此规则:除非被编辑文件位于专属 **worktree** 否则拦截 `Edit`/`Write`/`NotebookEdit`——在共享 checkout 上开 feature 分支**也不行**(仍会被切走),且按**被编辑文件所属的仓库**判断(sibling 仓 `framework`/`cloud` 一并守住)(确属非任务的临时改动用 `OS_ALLOW_MAIN_EDITS=1` 放行)。即便在自己的 worktree 里,下面这些防御性条款仍然适用。 - **绝不 `git stash` —— stash 栈不在上一条的 worktree 隔离范围内。** `git stash` 把栈存在**共享 `.git` 目录**里的 `refs/stash`,**每个 worktree 共用同一个 LIFO 栈**:上一条的物理隔离**不覆盖它**。两个 agent 各自在自己的 worktree 里 stash,push/pop 的是同一个栈 —— 你的 `pop` 取回的是对方刚压进去的改动,你自己的改动留在栈上等着被对方取走;而 `pop` **报成功**,唯一的症状是别人的文件出现在你的 `git status` 里,随后一次 `git add -A` 就把对方的在途工作合进了你的 PR。不是假想:objectui#3430 两个并行 agent 在反向验证中间踩实,双方在途改动都丢了(只能作为 unreachable commit 捞回)。替代做法只有下面这些 —— 都不碰共享状态,都在你自己的 worktree 内,且**首选先 commit 再还原**(上面反向验证那条已把它定为标准写法): ```bash @@ -573,6 +581,7 @@ sha pin **退休**之后这条**更重、不是更轻**(维护者 2026-09-04 裁 ### Local dev — console UI ↔ backend (read before debugging UI) +- **首次 / 换 pin 之后**:仓根 `pnpm run setup`(构建 `.objectstack-sha` 钉的 `@objectstack/*`,再冻结安装;见 §9「依赖与锁文件」)。console 编进去的 `@objectstack/spec`/`client` 就是这份构建,不是 npm 上的发布版。 - **启动前端**:仓根 `pnpm --filter @object-ui/console dev`(Vite,固定 **:5180**,见 `apps/console/vite.config.ts`)。 - **后端默认连 `:3000`**:vite `/api` proxy → `DEV_PROXY_TARGET || http://localhost:3000`。**要测哪个后端就把它跑在 :3000**(framework 仓:`PORT=3000 pnpm dev:crm`,或 `PORT=3000 pnpm dev` = showcase)。经 `pnpm --filter @object-ui/console dev` 传 `DEV_PROXY_TARGET` env **不**可靠(不一定透传到 vite 子进程);要把 console 指向别的后端端口,`cd apps/console` 后内联设 env 才灵(已实测——见下「每个 agent 独立测试栈」)。 - `framework` 的 `:3001/_console` 服务的是**已发布的** console(`packages/console/dist`),**不是本仓 src**;改 src 必须用上面的 :5180 dev 服务验证(或在 framework 跑 `pnpm objectui:refresh` 重新拉构建——慢)。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1e5428c845..2a9138dfee 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,10 +46,15 @@ Thank you for your interest in contributing to Object UI! This document provides # Install pnpm if you haven't npm install -g pnpm -# Install project dependencies -pnpm install +# Build @objectstack/* at the commit .objectstack-sha pins, then install +# (`pnpm run setup` -- not `pnpm setup`, which is pnpm's own built-in command) +pnpm run setup ``` +objectui resolves every `@objectstack/*` package from a build of the objectstack +commit that `.objectstack-sha` names, not from npm. `scripts/bump-objectstack.sh` +moves that pin; the dependency and lockfile section of `AGENTS.md` has the whole procedure. + ### Configure Git Merge Driver for pnpm-lock.yaml To prevent merge conflicts in `pnpm-lock.yaml`, configure the custom merge driver: diff --git a/QUICK_REFERENCE.md b/QUICK_REFERENCE.md index 7f8b2b1014..0a51aef962 100644 --- a/QUICK_REFERENCE.md +++ b/QUICK_REFERENCE.md @@ -7,7 +7,8 @@ A one-page cheat-sheet for working in the `objectui` monorepo. ### Install & Build ```bash -pnpm install # Install all workspace dependencies +pnpm run setup # Build @objectstack/* at .objectstack-sha, then install (clean checkout) +pnpm install # Install all workspace dependencies (once the pinned build exists) pnpm build # Build every package (turbo, parallel & cached) pnpm type-check # Run tsc --noEmit across the workspace (turbo) pnpm lint # Run eslint across the workspace diff --git a/README.md b/README.md index e11d7bb8bc..067ce5d910 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,7 @@ Edit `app.json` to build your UI. ### Run the examples from this repo ```bash -pnpm install +pnpm run setup # builds @objectstack/* at .objectstack-sha, then installs pnpm -w build cd examples/console-starter # or examples/byo-backend-console @@ -436,7 +436,7 @@ Contributions are welcome — read the [Contributing Guide](./CONTRIBUTING.md) f ```bash git clone https://github.com/objectstack-ai/objectui.git cd objectui -./scripts/setup.sh # or: pnpm install && pnpm build +./scripts/setup.sh # or: pnpm run setup && pnpm build pnpm dev # development site pnpm test ``` diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index 32e4c0b90c..8507a23b23 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -249,14 +249,14 @@ it green — which is how two of `type-check`'s gates came to be missing from th | Job key | Appears as | What it runs | When | |---|---|---|---| | `changeset-check` | Changeset Fixed Group Check | `scripts/check-changeset-fixed.mjs` — every workspace package must be in the changeset `fixed` group or explicitly ignored. It checks group *membership*; it does **not** check whether the PR added a changeset. | Every run | -| `type-check` | Type Check | `scripts/check-type-check-coverage.mjs`, then `pnpm check:phantom-deps`, then `pnpm check:self-import`, then `pnpm check:unreferenced-sources`, then `pnpm check:doc-example-readers`, then `pnpm check:handler-key-reads`, then `pnpm check:metadata-write-doors`, then `pnpm check:published-tsconfig-exclude`, then `pnpm check:side-effects-array`, then `pnpm check:element-data-source-declaration`, then `pnpm check:esm-specifiers`, then `pnpm check:spec-symbols`, then `pnpm check:action-forward-parity`, then `pnpm check:designer-field-key-parity`, then `pnpm check:icon-record-names`, then `pnpm check:i18n-keys`, then `pnpm check:i18n-drift`, then `pnpm check:i18n-designer-parity`, then `pnpm type-check:scripts`, then `pnpm type-check:vitest-config`, then `scripts/select-type-check-scope.mjs`, then `pnpm type-check`, then — on the affected speed only — `turbo run build` of the packages the root setup program imports, then `pnpm type-check:vitest-setup`, then — bookkeeping only, after the verdict — `scripts/report-turbo-cache.mjs`. The coverage guard runs first because turbo silently skips packages that have no `type-check` script, so a package without one would otherwise read as passing (#2911). `pnpm check:phantom-deps` fails when a released package imports a bare specifier its own `package.json` does not declare — a *phantom dependency*, invisible locally because the workspace root's `devDependencies` sit on the upward resolution path from every package directory and on no consumer's, so `require.resolve('react', { paths: ['packages/core/src'] })` succeeds while `@object-ui/core` declares react in no field at all ([#4394](https://github.com/objectstack-ai/objectui/issues/4394)). `pnpm check:self-import` runs next because it reuses that gate's parser: it fails when a file inside a package names its OWN package, a specifier that resolves through the package's `exports` map to `dist/` while `type-check` waits on `^build` — the *dependencies'* builds, never the package's own — so on a cold cache the declarations do not exist yet and the file fails with `TS2307`. Locally it is always green, because every local workflow builds before it type-checks and leaves a `dist/` behind; PR #4789's first run was red on exactly one such line ([#4801](https://github.com/objectstack-ai/objectui/issues/4801)). `pnpm check:unreferenced-sources` runs next, reusing the same parser again: it fails when a covered package ships a source file that nothing reaches — not the package's declared entry, and not its build config. Until [#7515](https://github.com/objectstack-ai/objectui/issues/7515) no gate here could see one: `check-dist-completeness` asks whether `dist/` holds what `tsc` emits, `check-readme-exports` compares documented exports against shipped ones, and a file that is in the tarball while being reachable from nothing is outside both — so the detection mechanism was a human reading unrelated code, which is how both instances found in one week were found ([#7319](https://github.com/objectstack-ai/objectui/issues/7319), [#7397](https://github.com/objectstack-ai/objectui/issues/7397)). The hazard is not the bytes: the file #7319 removed carried the same export name as a live engine one package over and evaluated no predicate, so name-completion alone could have wired a silently wrong renderer into a published package. Reachability has TWO roots, and the second is the whole difficulty — `packages/components` reaches its two `use-sync-external-store` shims only through `vite.config.ts` `resolve.alias` entries whose importer is a bundled dependency no source file names, so a walk that skips that leg reports exactly those two live files as dead on its first run, and a gate that cries wolf gets switched off rather than fixed. Scope is DECLARED per package in `COVERED_PACKAGES` and the uncovered remainder is printed as a count derived from the workspace on every run, because the alias mechanisms differ per package and a gate that covers one package correctly beats one that covers forty with false positives. An alias expression it cannot evaluate is a FINDING rather than a skip, since skipping one would make it accuse whatever file that alias points at. `pnpm check:doc-example-readers` runs next, on the same parser again: it fails when an exported symbol's own JSDoc `@example` hand-spells a resolution that its REAL call sites obtain by calling a shared reader. A doc comment is what the next call site is copied from, so prose that outlives the ruling it encoded re-seeds every later copy — measured at two cards and three copied call sites, both cards closed by pointing the prose at `resolveRecordSourceObjectName` (`b041b9c0c`, `2ce2612df`). Nothing here could see either one, and `check-spec-symbol-derivation` was credited with the class twice — in the second card's body and then in the dispatch that repeated it — while its rule 4 judges `@objectstack/spec` citations at member granularity and says nothing about prose prescribing a LOCAL spelling ([#7652](https://github.com/objectstack-ai/objectui/issues/7652)). It fires on four conditions at once — the example calls the symbol it documents, a real call site fills the same argument slot by calling an exported single-`return` reader, the example does not, and what the example writes there is that reader's own return expression or one of the rungs it resolves between — which is what keeps it off the literals and placeholders an example legitimately carries. It does NOT judge whether a prescribed spelling is correct: on the day either card was filed the prose and every copy of it agreed, and no gate reading only the tree can know a ruling. What it catches is the state right after, when the call sites move and the prose does not. `pnpm check:handler-key-reads` runs next, on the same parser again: it fails when an `on*` handler key that a REGISTERED renderer reads off the authored document is not a declared member of the zod arm for the type it is registered under. `BaseSchema` is `.passthrough()`, so an undeclared key is not refused — it stops being judged and the value is KEPT, then reaches the renderer that reads it; measured on the built dist, `{ type: 'kanban', columns: [], onCardClick: { action: 'toast' } }` went from REFUSED to ACCEPTED with the object surviving into the parsed output ([#7664](https://github.com/objectstack-ai/objectui/issues/7664)). Every gate stayed green, because the [#6124](https://github.com/objectstack-ai/objectui/issues/6124) ledger's population is two hand-written arrays of tuples and that change re-keyed the arm by SUBSTITUTION — so its length assertion held, and a count ratchet would have been green too, which is why `72498f257`, the commit that landed this gate, rejected that option on the instance itself. This gate derives BOTH populations: the arms from every `type: z.literal(…)` in `packages/types/src/zod`, and the read sites from every real `ComponentRegistry.register(…)` call — read off the AST, because one types file NAMES that call in prose eleven times and registers nothing. It follows the document one component at a time rather than every JSX child, because most children are handed a DIFFERENT document (a dashboard's widgets each get their own), and the chain it must reach is four hops long: `register('kanban', ObjectKanbanRenderer)` names a component, that component is an HOC, the document arrives at `ObjectKanban` through a render-prop parameter and at `KanbanRenderer` through an object spread. It says nothing about keys that reach a renderer only through a `{...props}` spread onto a Radix root or a DOM listener slot — there is no read site to derive from — nor about the ledger's `?: never` tombstones, which have no read site by construction; `KNOWN_UNDECLARED_READS` is an exemption list that only shrinks, each row naming the card that owns the fix, and a row whose read site the gate can no longer find fails it. It lives in `scripts/` because the read sites are spread across `@object-ui/plugin-*` and `packages/components`, which `@object-ui/types` may not import — `check:phantom-deps` rejects it and it would close a cycle. `pnpm check:metadata-write-doors` runs next, on the same parser again: it fails when an in-repo DOOR that can PUT an object-metadata document does not apply the object-metadata write guard. [#7714](https://github.com/objectstack-ai/objectui/issues/7714) ruled one client behaviour — a half-filled relationship is held client-side and the PUT body never carries one without a non-empty `reference` — and its PR implemented that ruling by ENUMERATING the writers it knew of, which were two. The identical defect was then reproduced on a THIRD writer neither guard covered, in that card's own required dogfood (recorded in `9662aca56`, the commit that landed this gate), and [#8676](https://github.com/objectstack-ai/objectui/issues/8676) swept and found nine more. The half that outlives the count is that the sweep the question is naturally asked in cannot see its own subject: `git grep 'client\.save('` returns ZERO over the file that third writer lives in, because the call is `client.save(type, …)` and the generic argument sits between the name and the paren — and a hand-rolled `fetch` PUT to `/api/v1/meta/object/:name` is not that spelling at all. So this gate enumerates DOORS, never writers: the writer set is OPEN and nobody has to announce a new member, while the transport set is CLOSED and this repository owns it, so guarding the doors covers every writer past and future without a list anywhere. It derives every call carrying a `method: 'PUT'` literal whose URL RESOLVES to a `/meta` path — resolved through templates, fields and helper return values rather than read, because the repo's central door spells its URL three hops from the call and says nothing at the call site — plus every call to the SDK's `meta.saveItem`, which lives in a package this repo does not own and so cannot be guarded from the inside. A door whose type is a string literal other than `object` is exempt; a door whose type is a runtime value is judged CAPABLE, which is the fail-closed direction. It answers COVERAGE only — whether a guarded door's body is correct is the guard's own pins — and it refuses to report OK unless it found at least one door of each kind and at least one guarded door, so a renamed transport turns it red rather than green. Same placement rationale as the gates around it: it parses the sources with `typescript`, so it needs the install and nothing built. `pnpm check:published-tsconfig-exclude` follows, config reads only: it fails when a published package's build `tsconfig.json` excludes tooling by FILE NAME (`*.test.ts`) without also excluding the tooling DIRECTORIES (`**/__tests__/**` and its two siblings, derived from `TOOLING_FILE` rather than retyped). A name-only exclude stops the files that happen to be named that way and nothing else, so the first shared helper added to a `__tests__/` directory becomes a program input and an emitting program writes it into the published `dist` — three times so far, each found by a human and never by a gate ([#4006](https://github.com/objectstack-ai/objectui/issues/4006), [#4836](https://github.com/objectstack-ai/objectui/issues/4836), and a third in the same package as the first, all three recorded in `6aeba673c`, the commit that landed this gate). [#7212](https://github.com/objectstack-ai/objectui/issues/7212) measured the standing exposure — 29 published packages carrying the name form with ZERO offending files, green because nobody had added such a helper yet — and the gate landed together with their conversion so `main` was green on merge. It reads `exclude` arrays and nothing else: no build, no artifact, no emit model, which is the narrower scope that keeps it clear of the modelling [#4846](https://github.com/objectstack-ai/objectui/issues/4846) declined for the artifact-level gate. Six published packages are named carve-outs, each re-proving its own reason on every run: `cli`, `create-plugin` and `data-objectstack` emit from a `tsup` entry graph, `plugin-charts` keeps its tooling exclude in the `dts()` options, and `console` and `runner` are Vite applications with `noEmit: true` and no `dts()` plugin. `pnpm check:side-effects-array` runs next, sources only and no build: it fails when a package's `sideEffects` ARRAY and its module bodies disagree in either direction — a module that registers something at load time and is not named (a bundler drops it, and the registration is gone from a *consumer's* app with no error, no warning and exit 0), or a name whose module no longer registers anything. `@object-ui/app-shell` declares such an array because both simpler answers are measurably wrong for it: omitting the field makes the whole package unshakeable, and `"sideEffects": false` silently drops three live SDUI widget registrations to zero chunks ([#6535](https://github.com/objectstack-ai/objectui/issues/6535), [#6683](https://github.com/objectstack-ai/objectui/issues/6683)). The enumeration is re-derived from the module bodies on every run rather than listed, so there is no second copy to rot. The artifact half of the same contract — do those registrations survive a real bundler — cannot run in this job at all: it needs a built console, so it lives in the SDUI registration pin step of `performance-budget.yml`. `pnpm check:element-data-source-declaration` runs next, sources only and no build: it fails when a source that consumes `ElementDataSourceGate` does not also pass through `elementDataSourceBlock()`, the seam that declares the `dataSource` key the gate reads. A block that wraps the gate off-seam publishes an authoring surface missing the one key its own runtime honours, and the html tier reports that key with the same `unknown-prop` warning it gives the spellings that do nothing ([#6678](https://github.com/objectstack-ai/objectui/issues/6678)). `pnpm check:esm-specifiers` follows it for the same reason — sources only, no build: it fails when a published package whose build preserves import specifiers (a bare emitting `tsc`, which never rewrites them) writes a relative specifier with no file extension. Node's ESM resolver does not extension-search relative specifiers, so such a specifier makes the published entry unloadable outside a bundler; `@object-ui/react`'s entry died with `ERR_MODULE_NOT_FOUND` while every bundler-based consumer, the whole test suite and CI stayed green ([#4538](https://github.com/objectstack-ai/objectui/issues/4538)). The half that actually *imports* each built entry needs a full build and runs in `node-esm-load-gate.yml`. `pnpm check:action-forward-parity` fails when an action renderer's forward whitelist drops a key the action runtime reads — the class that shipped six times one key at a time, each time green, because the key parses and publishes while the payload is dropped one hop before the runner ([#4050](https://github.com/objectstack-ai/objectui/issues/4050)). `pnpm check:designer-field-key-parity` fails when one of the field designers' statically declared payload shapes (`FieldMetadataPayload`, `ServerFieldSchema`, `DesignerFieldDefinition`) declares a key the installed `@objectstack/spec` `FieldSchema` refuses by NAME. Such a key makes `PUT /api/v1/meta/object/:name` return a hard 422 `INVALID_METADATA` that blocks *every subsequent save* of that object, and the author cannot tell from the designer UI which key did it — the class had been filed three times, each closed with a per-key tombstone written after the instance was found in production, with nothing detecting the next one ([#4644](https://github.com/objectstack-ai/objectui/issues/4644) `indexed`, [#4687](https://github.com/objectstack-ai/objectui/issues/4687) `distance_metric`, [#4676](https://github.com/objectstack-ai/objectui/issues/4676) `placeholder`, gated by [#5761](https://github.com/objectstack-ai/objectui/issues/5761)). It reads the accept set off the schema itself rather than from a list, and it covers a deliberately documented *subset* of the write path: a key that reaches the payload only through a `patchDef` spread or an index signature is outside its reach, and the boundary is stated in the script's own docblock. Its draft-I/O half — the `readFields`/`writeFields` round-trip, which has no declared shape to read — runs in the test suite as `object-fields-io.spec-keys.test.ts`. Same placement rationale as the gates around it: it parses the sources with `typescript` and imports the installed spec, so it needs the install and nothing built. `pnpm check:icon-record-names` fails when an authored icon NAME that reaches a resolver reading lucide's runtime `icons` record is not a live key of that record. lucide retires a spelling by dropping it from that record while keeping it as a deprecated named export, so the retired name still imports, still type-checks and still renders wherever it is used as a *component* — `Edit === SquarePen` is true — and resolves to nothing wherever it is used as a *string*: nothing goes red in either direction, which is why the class was repaired twice in two packages before anyone gated it ([#5586](https://github.com/objectstack-ai/objectui/issues/5586), [#5622](https://github.com/objectstack-ai/objectui/issues/5622), [#5633](https://github.com/objectstack-ai/objectui/issues/5633)). It carries no list of retired spellings — the record itself is the judgement — and it re-discovers the resolver population from source on every run, which is how its first pass found four record-reading resolvers nobody had catalogued. It sits here because it parses the sources with `typescript` and reads the installed lucide: the install, and nothing built. The three locale gates sit in the middle because all of them parse the sources with `typescript`: they need the install and nothing built. `pnpm check:i18n-keys` fails when a `t()` call site asks for a key the `en` pack does not define ([#3530](https://github.com/objectstack-ai/objectui/issues/3530)); `pnpm check:i18n-drift` fails when a change to an `en` string is not accompanied by the nine translation packs ([#3650](https://github.com/objectstack-ai/objectui/issues/3650)), and it is why this job's checkout sets `fetch-depth: 0` — it diffs against the merge base, which a depth-1 clone cannot resolve. `pnpm check:i18n-designer-parity` fails when the metadata-admin designer's own module-local string tables come apart — an `en` row with no `zh` row, or a shared row whose two values carry different `{placeholders}` ([#8834](https://github.com/objectstack-ai/objectui/issues/8834)). The two gates before it are blind to that file by construction — the first classifies the module `module-local table` by declaration and skips it, the second read only the ten locale packs and this table is not one of them — so the same card also gave `pnpm check:i18n-drift` that table as a SECOND population, which is the half that catches a changed `en` value whose `zh` row did not follow. `pnpm type-check:scripts` (`tsconfig.scripts.json`) covers `scripts/**/*.ts`, which `pnpm type-check` cannot reach at all — `scripts/` has no package.json, so turbo never walks it, and the coverage guard decides coverage per *package*. Until [#3494](https://github.com/objectstack-ai/objectui/issues/3494) that left the pin tests in `scripts/__tests__/` — including the one pinning this very page — compiled by nothing. `pnpm type-check:vitest-config` runs `apps/console/tsconfig.node.json` directly — the program that already lists `../../vitest.config.mts` and `apps/console/vitest.config.ts` ([#3476](https://github.com/objectstack-ai/objectui/issues/3476)) — because until [#7328](https://github.com/objectstack-ai/objectui/issues/7328) the only thing that ran it was the console's own `type-check` script, reached through the task runner, whose `type-check` task waits on `^build`. (Named in prose rather than as a code span on purpose: the pin below reads this cell as this job's gate list, so spelling that invocation out would credit the job with a command it does not run.) The cheapest compiler that reads the root Vitest config was therefore reachable only through the most expensive job here, and PR #7291 paid for it: a conditionally spread `dist` project whose literal `extends: true` widened to `boolean` degraded the whole `projects` array to `never[]`, every gate its author ran was green, and CI reported three errors, two of them at `../../vitest.config.mts`. It sits in the cheap half beside `pnpm type-check:scripts` for the same measured reason — nothing in its program imports an `@object-ui/*` package, so it needs the install and nothing built. `pnpm type-check:vitest-setup` (`tsconfig.vitest-setup.json`) closes the same gap for the four repo-root `vitest.setup.*` files, uncovered until [#3515](https://github.com/objectstack-ai/objectui/issues/3515); it runs *last*, after `pnpm type-check`, because `vitest.setup.dom.tsx` side-effect-imports four `@object-ui/*` packages and resolves them through the declarations that turbo's `^build` produces. On the affected speed that run may build none of them — a change outside every package selects no package at all — so a step between the two builds them with `turbo run build`, naming the packages `scripts/select-type-check-scope.mjs --root-builds` reads out of `tsconfig.vitest-setup.json`'s `paths` on every run, and replaying each from the restored cache when its hash did not move ([#12272](https://github.com/objectstack-ai/objectui/issues/12272)). `scripts/select-type-check-scope.mjs` decides how much of the workspace `pnpm type-check` covers on a pull request or a queue build — the affected packages, or every package when a root file the package graph cannot see changed — as the two-speeds paragraph above describes; it fails open to the whole workspace, and `push` and `schedule` never ask it. `scripts/report-turbo-cache.mjs` checks nothing: `pnpm type-check` runs with `TURBO_RUN_SUMMARY=true`, and the script reads that run summary and prints, per task, what the restored `.turbo/cache` replayed and what re-ran — each re-run task with the tasks it depends on, since a task's hash folds theirs in — and emits the counts as one `::notice` annotation, which the check-runs API serves to readers who cannot open the job log ([#12222](https://github.com/objectstack-ai/objectui/issues/12222)). It runs after the last checking step, bounded and `continue-on-error`, like the cache save after it. On `push` and `schedule` only, the same script runs again with `--prune` and deletes every `.turbo/cache` entry the run did not use, and only those two events save the cache. On this job's first measured pull-request run the restore step's bare `turbo-Linux-` key matched another job's cache, saved minutes earlier on the same pull request (about 2 GB, most of it unused), and `actions/cache` tries every key on the run's own ref before it looks at `main` — so `main`'s type-check cache was never reached. The restore step now keeps only the job's own `turbo-Linux-type-check-` prefix, pull requests and queue builds save nothing, and the cache is written by the forced full battery alone, holding exactly the current verdicts and build outputs that every pull request and queue build restores ([#12222](https://github.com/objectstack-ai/objectui/issues/12222)). | Every run; on a PR the steps short-circuit when only ignored paths changed. `pnpm type-check` covers the affected packages (or, on a root change, the workspace) on pull requests and merge-queue builds, replaying unchanged ones from the cache; every package, nothing replayed, on `push` and the hourly `schedule` | -| `test` | Test (shard N/16) | When a pull request changed nothing outside the exclusion list, the decision step runs `scripts/markdown-test-inputs.mjs` before anything else, and the job runs in full when the answer is yes ([#8861](https://github.com/objectstack-ai/objectui/issues/8861)). The exclusions drop every markdown path, and a markdown document can be a TEST'S INPUT: [#8857](https://github.com/objectstack-ai/objectui/issues/8857) changed one package README, this job reported success in ten seconds having run nothing, and the merge-queue build then failed the same shard in 907 seconds and dequeued it. That script carries the derived class — which documents a test reads, and which test reads each — and audits itself against the tree. It is this job's stage only, because this is the job that runs those tests. [#9096](https://github.com/objectstack-ai/objectui/issues/9096) then added `scripts` to that scan, because the gate tests under `scripts/__tests__` read this repository's documentation as data and run in these same shards — measured by running all 46 of them under an fs trace, **every** tracked markdown file in the tree is opened by at least one of them. ⇒ for this job the markdown half of the exclusion list is now inert: **any markdown-only pull request runs the shards**. Priced over the 513 first-parent commits available then: 36 reached this stage, 21 already ran, and the widening moves 14 of the remaining 15 from skip to run. The rest of the exclusion list still pays — a non-markdown change under `content/**`, `docs/**` or `apps/site/**` skips as before. On a pull request or a queue build the job then runs `scripts/select-affected-tests.mjs` against the event's base sha and, when it answers `mode=affected`, `vitest run` over the filters it wrote, sharded the same way; a selector that cannot decide means the full suite, never a skip ([#12214](https://github.com/objectstack-ai/objectui/issues/12214)). Otherwise — and always on `push` and `schedule` — `pnpm test --shard=N/16` across a 16-runner matrix with `fail-fast: false`, so every shard reports its own failures. No coverage instrumentation — v8 adds 40–100% overhead. **4 → 8 shards, and the built-artifact pin step moved out of this job entirely, in [#9499](https://github.com/objectstack-ai/objectui/issues/9499).** At four shards this job had grown into its own `timeout-minutes: 20`: measured over the 14 most recent `merge_group` runs and the 14 most recent `pull_request` runs, the longest *succeeding* shard-1 job was 1199 s against the 1200 s ceiling — a one-second margin — and two runs crossed it and were **cancelled**, one of them dequeuing a pull request whose tests had passed, because the merge queue cannot tell `cancelled` from `failure`. The ceiling is **not** raised, no test is skipped and the exclusion list is not widened; the work is spread over twice the runners, and the one step that was never sharded at all is now the `test-dist-pins` job below. **8 → 12 shards in [#12208](https://github.com/objectstack-ai/objectui/issues/12208)** (its first pull request, folded into [#12214](https://github.com/objectstack-ai/objectui/issues/12214)'s): at eight the job was back against the same ceiling — 14.4 min median, 17.4 min p95 and 20.3 min max over the 1,728 shard jobs of one week, measured on that card — and the same three refusals hold: the ceiling is not raised, no test is skipped, the relevance gate is not widened. **12 → 16 shards in [#12384](https://github.com/objectstack-ai/objectui/issues/12384)**, by the same ruling's width rule (the width derived from the total measured duration at about 10 minutes per shard): balanced, the twelve legs ran 11.3 min median and 12.6 min p99, but in the 50 hours after the balanced plan landed the ceiling cancelled legs in 12 of 279 runs, with no hang, each on a VM that ran every file 1.6x to 2.0x slower than a normal one — episodically, and with no runner label that tells such a VM apart. Twelve legs left a p99 leg 1.59x of slack under the ceiling; sixteen carry 0.75 of the load each and move that to about 2.1x. The same three refusals hold, and no runner is pinned. Both speeds use the same legs. No shard is a required context any more — `test-aggregate` reports for all of them. **Which files a leg runs is decided by measured per-file duration** ([#12208](https://github.com/objectstack-ai/objectui/issues/12208)'s second pull request): vitest's own `--shard` hashed each path and cut equal *file counts*, so which leg drew the heavy files was luck. The root vitest config's `sequence.sequencer` (the `test-shard-sequencer` module beside this job's scripts, not a command this job runs) bin-packs the files heaviest first from the committed `scripts/test-shard-timings.json`; a file the dataset does not name is weighed at its median. On the full speed the job also passes `scripts/test-file-timings.mjs` as a third reporter (beside `default` and `github-actions`, which a CLI `--reporter` would otherwise displace), which records each file's whole time on its worker, and `push` and `schedule` runs upload it as the `test-file-timings-N` artifact that the weekly refresh reads. | Every event: the affected set on pull requests and merge-queue builds, the whole suite on `push` and the hourly `schedule`; steps short-circuit on a PR that changed only ignored paths | -| `test-dist-pins` | Test (dist pins) | The same decision step as `test` above, `scripts/markdown-test-inputs.mjs` stage included — deliberately verbatim, because this lane used to run whenever that job's gate said run, and a narrower gate here would silently stop running the built-artifact pins on the markdown-only pull requests [#9096](https://github.com/objectstack-ai/objectui/issues/9096) brought into scope. Then `pnpm test:dist` — the built-artifact lane ([#7183](https://github.com/objectstack-ai/objectui/issues/7183)). It delegates to a turbo task scoped to the one package that holds built-artifact pins; that task depends on the package's OWN build (`dependsOn: ["build"]`, not `^build`), so the bundle exists before the pins read it, and then runs the `dist` vitest project, whose pins import a package's BUILT bundle instead of its `src` — a claim the source-aliased suite above is structurally unable to make, since the root config aliases every workspace package to `src`. **Why it is a job and not a step on shard 1** ([#9499](https://github.com/objectstack-ai/objectui/issues/9499)): it was never sharded, so it made shard 1 structurally the longest leg of a matrix bounded by a 20-minute ceiling, and in **both** observed ceiling kills the job died in this step with the test step already green — the tests passed and the check reported `cancelled`. Measured across 28 full CI runs it costs 31–58 s (median ≈53 s); a separate job pays for one more runner and takes the asymmetry out of the matrix without touching a budget. | Every event (the `push` guard went with the shard matrix's in [#12214](https://github.com/objectstack-ai/objectui/issues/12214)); steps short-circuit on a PR that changed only ignored paths | +| `type-check` | Type Check | `scripts/check-type-check-coverage.mjs`, then `scripts/objectstack-pin.mjs` (restores or builds the pinned `@objectstack/*` before the install, objectui#12957), then `pnpm check:phantom-deps`, then `pnpm check:self-import`, then `pnpm check:unreferenced-sources`, then `pnpm check:doc-example-readers`, then `pnpm check:handler-key-reads`, then `pnpm check:metadata-write-doors`, then `pnpm check:published-tsconfig-exclude`, then `pnpm check:side-effects-array`, then `pnpm check:element-data-source-declaration`, then `pnpm check:esm-specifiers`, then `pnpm check:spec-symbols`, then `pnpm check:action-forward-parity`, then `pnpm check:designer-field-key-parity`, then `pnpm check:icon-record-names`, then `pnpm check:i18n-keys`, then `pnpm check:i18n-drift`, then `pnpm check:i18n-designer-parity`, then `pnpm type-check:scripts`, then `pnpm type-check:vitest-config`, then `scripts/select-type-check-scope.mjs`, then `pnpm type-check`, then — on the affected speed only — `turbo run build` of the packages the root setup program imports, then `pnpm type-check:vitest-setup`, then — bookkeeping only, after the verdict — `scripts/report-turbo-cache.mjs`. The coverage guard runs first because turbo silently skips packages that have no `type-check` script, so a package without one would otherwise read as passing (#2911). `pnpm check:phantom-deps` fails when a released package imports a bare specifier its own `package.json` does not declare — a *phantom dependency*, invisible locally because the workspace root's `devDependencies` sit on the upward resolution path from every package directory and on no consumer's, so `require.resolve('react', { paths: ['packages/core/src'] })` succeeds while `@object-ui/core` declares react in no field at all ([#4394](https://github.com/objectstack-ai/objectui/issues/4394)). `pnpm check:self-import` runs next because it reuses that gate's parser: it fails when a file inside a package names its OWN package, a specifier that resolves through the package's `exports` map to `dist/` while `type-check` waits on `^build` — the *dependencies'* builds, never the package's own — so on a cold cache the declarations do not exist yet and the file fails with `TS2307`. Locally it is always green, because every local workflow builds before it type-checks and leaves a `dist/` behind; PR #4789's first run was red on exactly one such line ([#4801](https://github.com/objectstack-ai/objectui/issues/4801)). `pnpm check:unreferenced-sources` runs next, reusing the same parser again: it fails when a covered package ships a source file that nothing reaches — not the package's declared entry, and not its build config. Until [#7515](https://github.com/objectstack-ai/objectui/issues/7515) no gate here could see one: `check-dist-completeness` asks whether `dist/` holds what `tsc` emits, `check-readme-exports` compares documented exports against shipped ones, and a file that is in the tarball while being reachable from nothing is outside both — so the detection mechanism was a human reading unrelated code, which is how both instances found in one week were found ([#7319](https://github.com/objectstack-ai/objectui/issues/7319), [#7397](https://github.com/objectstack-ai/objectui/issues/7397)). The hazard is not the bytes: the file #7319 removed carried the same export name as a live engine one package over and evaluated no predicate, so name-completion alone could have wired a silently wrong renderer into a published package. Reachability has TWO roots, and the second is the whole difficulty — `packages/components` reaches its two `use-sync-external-store` shims only through `vite.config.ts` `resolve.alias` entries whose importer is a bundled dependency no source file names, so a walk that skips that leg reports exactly those two live files as dead on its first run, and a gate that cries wolf gets switched off rather than fixed. Scope is DECLARED per package in `COVERED_PACKAGES` and the uncovered remainder is printed as a count derived from the workspace on every run, because the alias mechanisms differ per package and a gate that covers one package correctly beats one that covers forty with false positives. An alias expression it cannot evaluate is a FINDING rather than a skip, since skipping one would make it accuse whatever file that alias points at. `pnpm check:doc-example-readers` runs next, on the same parser again: it fails when an exported symbol's own JSDoc `@example` hand-spells a resolution that its REAL call sites obtain by calling a shared reader. A doc comment is what the next call site is copied from, so prose that outlives the ruling it encoded re-seeds every later copy — measured at two cards and three copied call sites, both cards closed by pointing the prose at `resolveRecordSourceObjectName` (`b041b9c0c`, `2ce2612df`). Nothing here could see either one, and `check-spec-symbol-derivation` was credited with the class twice — in the second card's body and then in the dispatch that repeated it — while its rule 4 judges `@objectstack/spec` citations at member granularity and says nothing about prose prescribing a LOCAL spelling ([#7652](https://github.com/objectstack-ai/objectui/issues/7652)). It fires on four conditions at once — the example calls the symbol it documents, a real call site fills the same argument slot by calling an exported single-`return` reader, the example does not, and what the example writes there is that reader's own return expression or one of the rungs it resolves between — which is what keeps it off the literals and placeholders an example legitimately carries. It does NOT judge whether a prescribed spelling is correct: on the day either card was filed the prose and every copy of it agreed, and no gate reading only the tree can know a ruling. What it catches is the state right after, when the call sites move and the prose does not. `pnpm check:handler-key-reads` runs next, on the same parser again: it fails when an `on*` handler key that a REGISTERED renderer reads off the authored document is not a declared member of the zod arm for the type it is registered under. `BaseSchema` is `.passthrough()`, so an undeclared key is not refused — it stops being judged and the value is KEPT, then reaches the renderer that reads it; measured on the built dist, `{ type: 'kanban', columns: [], onCardClick: { action: 'toast' } }` went from REFUSED to ACCEPTED with the object surviving into the parsed output ([#7664](https://github.com/objectstack-ai/objectui/issues/7664)). Every gate stayed green, because the [#6124](https://github.com/objectstack-ai/objectui/issues/6124) ledger's population is two hand-written arrays of tuples and that change re-keyed the arm by SUBSTITUTION — so its length assertion held, and a count ratchet would have been green too, which is why `72498f257`, the commit that landed this gate, rejected that option on the instance itself. This gate derives BOTH populations: the arms from every `type: z.literal(…)` in `packages/types/src/zod`, and the read sites from every real `ComponentRegistry.register(…)` call — read off the AST, because one types file NAMES that call in prose eleven times and registers nothing. It follows the document one component at a time rather than every JSX child, because most children are handed a DIFFERENT document (a dashboard's widgets each get their own), and the chain it must reach is four hops long: `register('kanban', ObjectKanbanRenderer)` names a component, that component is an HOC, the document arrives at `ObjectKanban` through a render-prop parameter and at `KanbanRenderer` through an object spread. It says nothing about keys that reach a renderer only through a `{...props}` spread onto a Radix root or a DOM listener slot — there is no read site to derive from — nor about the ledger's `?: never` tombstones, which have no read site by construction; `KNOWN_UNDECLARED_READS` is an exemption list that only shrinks, each row naming the card that owns the fix, and a row whose read site the gate can no longer find fails it. It lives in `scripts/` because the read sites are spread across `@object-ui/plugin-*` and `packages/components`, which `@object-ui/types` may not import — `check:phantom-deps` rejects it and it would close a cycle. `pnpm check:metadata-write-doors` runs next, on the same parser again: it fails when an in-repo DOOR that can PUT an object-metadata document does not apply the object-metadata write guard. [#7714](https://github.com/objectstack-ai/objectui/issues/7714) ruled one client behaviour — a half-filled relationship is held client-side and the PUT body never carries one without a non-empty `reference` — and its PR implemented that ruling by ENUMERATING the writers it knew of, which were two. The identical defect was then reproduced on a THIRD writer neither guard covered, in that card's own required dogfood (recorded in `9662aca56`, the commit that landed this gate), and [#8676](https://github.com/objectstack-ai/objectui/issues/8676) swept and found nine more. The half that outlives the count is that the sweep the question is naturally asked in cannot see its own subject: `git grep 'client\.save('` returns ZERO over the file that third writer lives in, because the call is `client.save(type, …)` and the generic argument sits between the name and the paren — and a hand-rolled `fetch` PUT to `/api/v1/meta/object/:name` is not that spelling at all. So this gate enumerates DOORS, never writers: the writer set is OPEN and nobody has to announce a new member, while the transport set is CLOSED and this repository owns it, so guarding the doors covers every writer past and future without a list anywhere. It derives every call carrying a `method: 'PUT'` literal whose URL RESOLVES to a `/meta` path — resolved through templates, fields and helper return values rather than read, because the repo's central door spells its URL three hops from the call and says nothing at the call site — plus every call to the SDK's `meta.saveItem`, which lives in a package this repo does not own and so cannot be guarded from the inside. A door whose type is a string literal other than `object` is exempt; a door whose type is a runtime value is judged CAPABLE, which is the fail-closed direction. It answers COVERAGE only — whether a guarded door's body is correct is the guard's own pins — and it refuses to report OK unless it found at least one door of each kind and at least one guarded door, so a renamed transport turns it red rather than green. Same placement rationale as the gates around it: it parses the sources with `typescript`, so it needs the install and nothing built. `pnpm check:published-tsconfig-exclude` follows, config reads only: it fails when a published package's build `tsconfig.json` excludes tooling by FILE NAME (`*.test.ts`) without also excluding the tooling DIRECTORIES (`**/__tests__/**` and its two siblings, derived from `TOOLING_FILE` rather than retyped). A name-only exclude stops the files that happen to be named that way and nothing else, so the first shared helper added to a `__tests__/` directory becomes a program input and an emitting program writes it into the published `dist` — three times so far, each found by a human and never by a gate ([#4006](https://github.com/objectstack-ai/objectui/issues/4006), [#4836](https://github.com/objectstack-ai/objectui/issues/4836), and a third in the same package as the first, all three recorded in `6aeba673c`, the commit that landed this gate). [#7212](https://github.com/objectstack-ai/objectui/issues/7212) measured the standing exposure — 29 published packages carrying the name form with ZERO offending files, green because nobody had added such a helper yet — and the gate landed together with their conversion so `main` was green on merge. It reads `exclude` arrays and nothing else: no build, no artifact, no emit model, which is the narrower scope that keeps it clear of the modelling [#4846](https://github.com/objectstack-ai/objectui/issues/4846) declined for the artifact-level gate. Six published packages are named carve-outs, each re-proving its own reason on every run: `cli`, `create-plugin` and `data-objectstack` emit from a `tsup` entry graph, `plugin-charts` keeps its tooling exclude in the `dts()` options, and `console` and `runner` are Vite applications with `noEmit: true` and no `dts()` plugin. `pnpm check:side-effects-array` runs next, sources only and no build: it fails when a package's `sideEffects` ARRAY and its module bodies disagree in either direction — a module that registers something at load time and is not named (a bundler drops it, and the registration is gone from a *consumer's* app with no error, no warning and exit 0), or a name whose module no longer registers anything. `@object-ui/app-shell` declares such an array because both simpler answers are measurably wrong for it: omitting the field makes the whole package unshakeable, and `"sideEffects": false` silently drops three live SDUI widget registrations to zero chunks ([#6535](https://github.com/objectstack-ai/objectui/issues/6535), [#6683](https://github.com/objectstack-ai/objectui/issues/6683)). The enumeration is re-derived from the module bodies on every run rather than listed, so there is no second copy to rot. The artifact half of the same contract — do those registrations survive a real bundler — cannot run in this job at all: it needs a built console, so it lives in the SDUI registration pin step of `performance-budget.yml`. `pnpm check:element-data-source-declaration` runs next, sources only and no build: it fails when a source that consumes `ElementDataSourceGate` does not also pass through `elementDataSourceBlock()`, the seam that declares the `dataSource` key the gate reads. A block that wraps the gate off-seam publishes an authoring surface missing the one key its own runtime honours, and the html tier reports that key with the same `unknown-prop` warning it gives the spellings that do nothing ([#6678](https://github.com/objectstack-ai/objectui/issues/6678)). `pnpm check:esm-specifiers` follows it for the same reason — sources only, no build: it fails when a published package whose build preserves import specifiers (a bare emitting `tsc`, which never rewrites them) writes a relative specifier with no file extension. Node's ESM resolver does not extension-search relative specifiers, so such a specifier makes the published entry unloadable outside a bundler; `@object-ui/react`'s entry died with `ERR_MODULE_NOT_FOUND` while every bundler-based consumer, the whole test suite and CI stayed green ([#4538](https://github.com/objectstack-ai/objectui/issues/4538)). The half that actually *imports* each built entry needs a full build and runs in `node-esm-load-gate.yml`. `pnpm check:action-forward-parity` fails when an action renderer's forward whitelist drops a key the action runtime reads — the class that shipped six times one key at a time, each time green, because the key parses and publishes while the payload is dropped one hop before the runner ([#4050](https://github.com/objectstack-ai/objectui/issues/4050)). `pnpm check:designer-field-key-parity` fails when one of the field designers' statically declared payload shapes (`FieldMetadataPayload`, `ServerFieldSchema`, `DesignerFieldDefinition`) declares a key the installed `@objectstack/spec` `FieldSchema` refuses by NAME. Such a key makes `PUT /api/v1/meta/object/:name` return a hard 422 `INVALID_METADATA` that blocks *every subsequent save* of that object, and the author cannot tell from the designer UI which key did it — the class had been filed three times, each closed with a per-key tombstone written after the instance was found in production, with nothing detecting the next one ([#4644](https://github.com/objectstack-ai/objectui/issues/4644) `indexed`, [#4687](https://github.com/objectstack-ai/objectui/issues/4687) `distance_metric`, [#4676](https://github.com/objectstack-ai/objectui/issues/4676) `placeholder`, gated by [#5761](https://github.com/objectstack-ai/objectui/issues/5761)). It reads the accept set off the schema itself rather than from a list, and it covers a deliberately documented *subset* of the write path: a key that reaches the payload only through a `patchDef` spread or an index signature is outside its reach, and the boundary is stated in the script's own docblock. Its draft-I/O half — the `readFields`/`writeFields` round-trip, which has no declared shape to read — runs in the test suite as `object-fields-io.spec-keys.test.ts`. Same placement rationale as the gates around it: it parses the sources with `typescript` and imports the installed spec, so it needs the install and nothing built. `pnpm check:icon-record-names` fails when an authored icon NAME that reaches a resolver reading lucide's runtime `icons` record is not a live key of that record. lucide retires a spelling by dropping it from that record while keeping it as a deprecated named export, so the retired name still imports, still type-checks and still renders wherever it is used as a *component* — `Edit === SquarePen` is true — and resolves to nothing wherever it is used as a *string*: nothing goes red in either direction, which is why the class was repaired twice in two packages before anyone gated it ([#5586](https://github.com/objectstack-ai/objectui/issues/5586), [#5622](https://github.com/objectstack-ai/objectui/issues/5622), [#5633](https://github.com/objectstack-ai/objectui/issues/5633)). It carries no list of retired spellings — the record itself is the judgement — and it re-discovers the resolver population from source on every run, which is how its first pass found four record-reading resolvers nobody had catalogued. It sits here because it parses the sources with `typescript` and reads the installed lucide: the install, and nothing built. The three locale gates sit in the middle because all of them parse the sources with `typescript`: they need the install and nothing built. `pnpm check:i18n-keys` fails when a `t()` call site asks for a key the `en` pack does not define ([#3530](https://github.com/objectstack-ai/objectui/issues/3530)); `pnpm check:i18n-drift` fails when a change to an `en` string is not accompanied by the nine translation packs ([#3650](https://github.com/objectstack-ai/objectui/issues/3650)), and it is why this job's checkout sets `fetch-depth: 0` — it diffs against the merge base, which a depth-1 clone cannot resolve. `pnpm check:i18n-designer-parity` fails when the metadata-admin designer's own module-local string tables come apart — an `en` row with no `zh` row, or a shared row whose two values carry different `{placeholders}` ([#8834](https://github.com/objectstack-ai/objectui/issues/8834)). The two gates before it are blind to that file by construction — the first classifies the module `module-local table` by declaration and skips it, the second read only the ten locale packs and this table is not one of them — so the same card also gave `pnpm check:i18n-drift` that table as a SECOND population, which is the half that catches a changed `en` value whose `zh` row did not follow. `pnpm type-check:scripts` (`tsconfig.scripts.json`) covers `scripts/**/*.ts`, which `pnpm type-check` cannot reach at all — `scripts/` has no package.json, so turbo never walks it, and the coverage guard decides coverage per *package*. Until [#3494](https://github.com/objectstack-ai/objectui/issues/3494) that left the pin tests in `scripts/__tests__/` — including the one pinning this very page — compiled by nothing. `pnpm type-check:vitest-config` runs `apps/console/tsconfig.node.json` directly — the program that already lists `../../vitest.config.mts` and `apps/console/vitest.config.ts` ([#3476](https://github.com/objectstack-ai/objectui/issues/3476)) — because until [#7328](https://github.com/objectstack-ai/objectui/issues/7328) the only thing that ran it was the console's own `type-check` script, reached through the task runner, whose `type-check` task waits on `^build`. (Named in prose rather than as a code span on purpose: the pin below reads this cell as this job's gate list, so spelling that invocation out would credit the job with a command it does not run.) The cheapest compiler that reads the root Vitest config was therefore reachable only through the most expensive job here, and PR #7291 paid for it: a conditionally spread `dist` project whose literal `extends: true` widened to `boolean` degraded the whole `projects` array to `never[]`, every gate its author ran was green, and CI reported three errors, two of them at `../../vitest.config.mts`. It sits in the cheap half beside `pnpm type-check:scripts` for the same measured reason — nothing in its program imports an `@object-ui/*` package, so it needs the install and nothing built. `pnpm type-check:vitest-setup` (`tsconfig.vitest-setup.json`) closes the same gap for the four repo-root `vitest.setup.*` files, uncovered until [#3515](https://github.com/objectstack-ai/objectui/issues/3515); it runs *last*, after `pnpm type-check`, because `vitest.setup.dom.tsx` side-effect-imports four `@object-ui/*` packages and resolves them through the declarations that turbo's `^build` produces. On the affected speed that run may build none of them — a change outside every package selects no package at all — so a step between the two builds them with `turbo run build`, naming the packages `scripts/select-type-check-scope.mjs --root-builds` reads out of `tsconfig.vitest-setup.json`'s `paths` on every run, and replaying each from the restored cache when its hash did not move ([#12272](https://github.com/objectstack-ai/objectui/issues/12272)). `scripts/select-type-check-scope.mjs` decides how much of the workspace `pnpm type-check` covers on a pull request or a queue build — the affected packages, or every package when a root file the package graph cannot see changed — as the two-speeds paragraph above describes; it fails open to the whole workspace, and `push` and `schedule` never ask it. `scripts/report-turbo-cache.mjs` checks nothing: `pnpm type-check` runs with `TURBO_RUN_SUMMARY=true`, and the script reads that run summary and prints, per task, what the restored `.turbo/cache` replayed and what re-ran — each re-run task with the tasks it depends on, since a task's hash folds theirs in — and emits the counts as one `::notice` annotation, which the check-runs API serves to readers who cannot open the job log ([#12222](https://github.com/objectstack-ai/objectui/issues/12222)). It runs after the last checking step, bounded and `continue-on-error`, like the cache save after it. On `push` and `schedule` only, the same script runs again with `--prune` and deletes every `.turbo/cache` entry the run did not use, and only those two events save the cache. On this job's first measured pull-request run the restore step's bare `turbo-Linux-` key matched another job's cache, saved minutes earlier on the same pull request (about 2 GB, most of it unused), and `actions/cache` tries every key on the run's own ref before it looks at `main` — so `main`'s type-check cache was never reached. The restore step now keeps only the job's own `turbo-Linux-type-check-` prefix, pull requests and queue builds save nothing, and the cache is written by the forced full battery alone, holding exactly the current verdicts and build outputs that every pull request and queue build restores ([#12222](https://github.com/objectstack-ai/objectui/issues/12222)). | Every run; on a PR the steps short-circuit when only ignored paths changed. `pnpm type-check` covers the affected packages (or, on a root change, the workspace) on pull requests and merge-queue builds, replaying unchanged ones from the cache; every package, nothing replayed, on `push` and the hourly `schedule` | +| `test` | Test (shard N/16) | Before its install, `scripts/objectstack-pin.mjs` restores or builds the pinned `@objectstack/*` (objectui#12957). When a pull request changed nothing outside the exclusion list, the decision step runs `scripts/markdown-test-inputs.mjs` before anything else, and the job runs in full when the answer is yes ([#8861](https://github.com/objectstack-ai/objectui/issues/8861)). The exclusions drop every markdown path, and a markdown document can be a TEST'S INPUT: [#8857](https://github.com/objectstack-ai/objectui/issues/8857) changed one package README, this job reported success in ten seconds having run nothing, and the merge-queue build then failed the same shard in 907 seconds and dequeued it. That script carries the derived class — which documents a test reads, and which test reads each — and audits itself against the tree. It is this job's stage only, because this is the job that runs those tests. [#9096](https://github.com/objectstack-ai/objectui/issues/9096) then added `scripts` to that scan, because the gate tests under `scripts/__tests__` read this repository's documentation as data and run in these same shards — measured by running all 46 of them under an fs trace, **every** tracked markdown file in the tree is opened by at least one of them. ⇒ for this job the markdown half of the exclusion list is now inert: **any markdown-only pull request runs the shards**. Priced over the 513 first-parent commits available then: 36 reached this stage, 21 already ran, and the widening moves 14 of the remaining 15 from skip to run. The rest of the exclusion list still pays — a non-markdown change under `content/**`, `docs/**` or `apps/site/**` skips as before. On a pull request or a queue build the job then runs `scripts/select-affected-tests.mjs` against the event's base sha and, when it answers `mode=affected`, `vitest run` over the filters it wrote, sharded the same way; a selector that cannot decide means the full suite, never a skip ([#12214](https://github.com/objectstack-ai/objectui/issues/12214)). Otherwise — and always on `push` and `schedule` — `pnpm test --shard=N/16` across a 16-runner matrix with `fail-fast: false`, so every shard reports its own failures. No coverage instrumentation — v8 adds 40–100% overhead. **4 → 8 shards, and the built-artifact pin step moved out of this job entirely, in [#9499](https://github.com/objectstack-ai/objectui/issues/9499).** At four shards this job had grown into its own `timeout-minutes: 20`: measured over the 14 most recent `merge_group` runs and the 14 most recent `pull_request` runs, the longest *succeeding* shard-1 job was 1199 s against the 1200 s ceiling — a one-second margin — and two runs crossed it and were **cancelled**, one of them dequeuing a pull request whose tests had passed, because the merge queue cannot tell `cancelled` from `failure`. The ceiling is **not** raised, no test is skipped and the exclusion list is not widened; the work is spread over twice the runners, and the one step that was never sharded at all is now the `test-dist-pins` job below. **8 → 12 shards in [#12208](https://github.com/objectstack-ai/objectui/issues/12208)** (its first pull request, folded into [#12214](https://github.com/objectstack-ai/objectui/issues/12214)'s): at eight the job was back against the same ceiling — 14.4 min median, 17.4 min p95 and 20.3 min max over the 1,728 shard jobs of one week, measured on that card — and the same three refusals hold: the ceiling is not raised, no test is skipped, the relevance gate is not widened. **12 → 16 shards in [#12384](https://github.com/objectstack-ai/objectui/issues/12384)**, by the same ruling's width rule (the width derived from the total measured duration at about 10 minutes per shard): balanced, the twelve legs ran 11.3 min median and 12.6 min p99, but in the 50 hours after the balanced plan landed the ceiling cancelled legs in 12 of 279 runs, with no hang, each on a VM that ran every file 1.6x to 2.0x slower than a normal one — episodically, and with no runner label that tells such a VM apart. Twelve legs left a p99 leg 1.59x of slack under the ceiling; sixteen carry 0.75 of the load each and move that to about 2.1x. The same three refusals hold, and no runner is pinned. Both speeds use the same legs. No shard is a required context any more — `test-aggregate` reports for all of them. **Which files a leg runs is decided by measured per-file duration** ([#12208](https://github.com/objectstack-ai/objectui/issues/12208)'s second pull request): vitest's own `--shard` hashed each path and cut equal *file counts*, so which leg drew the heavy files was luck. The root vitest config's `sequence.sequencer` (the `test-shard-sequencer` module beside this job's scripts, not a command this job runs) bin-packs the files heaviest first from the committed `scripts/test-shard-timings.json`; a file the dataset does not name is weighed at its median. On the full speed the job also passes `scripts/test-file-timings.mjs` as a third reporter (beside `default` and `github-actions`, which a CLI `--reporter` would otherwise displace), which records each file's whole time on its worker, and `push` and `schedule` runs upload it as the `test-file-timings-N` artifact that the weekly refresh reads. | Every event: the affected set on pull requests and merge-queue builds, the whole suite on `push` and the hourly `schedule`; steps short-circuit on a PR that changed only ignored paths | +| `test-dist-pins` | Test (dist pins) | Before its install, `scripts/objectstack-pin.mjs` restores or builds the pinned `@objectstack/*` (objectui#12957). The same decision step as `test` above, `scripts/markdown-test-inputs.mjs` stage included — deliberately verbatim, because this lane used to run whenever that job's gate said run, and a narrower gate here would silently stop running the built-artifact pins on the markdown-only pull requests [#9096](https://github.com/objectstack-ai/objectui/issues/9096) brought into scope. Then `pnpm test:dist` — the built-artifact lane ([#7183](https://github.com/objectstack-ai/objectui/issues/7183)). It delegates to a turbo task scoped to the one package that holds built-artifact pins; that task depends on the package's OWN build (`dependsOn: ["build"]`, not `^build`), so the bundle exists before the pins read it, and then runs the `dist` vitest project, whose pins import a package's BUILT bundle instead of its `src` — a claim the source-aliased suite above is structurally unable to make, since the root config aliases every workspace package to `src`. **Why it is a job and not a step on shard 1** ([#9499](https://github.com/objectstack-ai/objectui/issues/9499)): it was never sharded, so it made shard 1 structurally the longest leg of a matrix bounded by a 20-minute ceiling, and in **both** observed ceiling kills the job died in this step with the test step already green — the tests passed and the check reported `cancelled`. Measured across 28 full CI runs it costs 31–58 s (median ≈53 s); a separate job pays for one more runner and takes the asymmetry out of the matrix without touching a budget. | Every event (the `push` guard went with the shard matrix's in [#12214](https://github.com/objectstack-ai/objectui/issues/12214)); steps short-circuit on a PR that changed only ignored paths | | `test-aggregate` | Test | `scripts/check-test-shard-results.mjs` — the **single required test context** ([#9499](https://github.com/objectstack-ai/objectui/issues/9499), director ruling letter C). It `needs` the shard matrix and `test-dist-pins`, and runs `if: always()` so a red, skipped or cancelled shard still produces a verdict here instead of skipping this job — a skipped required context counts as *success* in branch protection, which on the one context that speaks for the whole lane is the failure mode itself. ⛔ It does **not** gate on `needs.test.result`: a matrix job's `needs..result` is a single rollup over every leg, and a rollup reads `success` when some legs succeeded and the rest were **skipped**, so an aggregator built on that expression is a check reporting success having run nothing — [#8857](https://github.com/objectstack-ai/objectui/issues/8857) and [#3523](https://github.com/objectstack-ai/objectui/issues/3523) in one line of YAML. The gate reads this run's own job list from the Actions API instead (`actions: read`, nothing written) and asserts each shard **by name and by its own conclusion**; `skipped` is not a pass. It fails closed — an unreadable answer is exit 2 and a red context — and refuses any verdict over a job list that does not contain this very job, which is the control that the reading reached the right run at all. The dist-pin job is asserted from `needs` directly, since a non-matrix job's result is its own. **Why one name:** naming the legs made the shard *count* a member of a repository-settings surface no workflow edit can reach — widening the matrix renamed four live required contexts at once. Last, `scripts/shard-headroom.mjs` prints the longest shard's wall against the `test` job's `timeout-minutes` and the width this run's measured total implies at a 10-minute target per shard ([#12208](https://github.com/objectstack-ai/objectui/issues/12208)). It is **report-only**: `if: always()` and `continue-on-error: true`, it exits 0 whatever it reads, and an unreadable job list prints NOT MEASURED rather than a number. `scripts/__tests__/shard-headroom.test.ts` pins its `--shards` and `--cap-minutes` to the matrix and the job it reads. | Every event — on `push` and `schedule` it is the full battery's test verdict | -| `test-coverage` | Test (coverage shard N/8) | `pnpm test:coverage --reporter=blob --reporter=default --reporter=github-actions --shard=N/8` across an 8-runner matrix with `fail-fast: false`. Each shard writes `.vitest-reports/blob-N-8.json` — raw coverage and test results in one file — and uploads it as an artifact even when the shard is red (vitest deletes `coverage/` on a red run unless `coverage.reportOnFailure` is set, [#5402](https://github.com/objectstack-ai/objectui/issues/5402)). The two reporters after the blob are what keep a red shard *readable*: a CLI `--reporter` replaces the default reporter set rather than adding to it, so `--reporter=blob` on its own ended a failing shard's log at `blob report written to …` with no test name and no assertion text, leaving the download-only artifact as the only copy of the failure ([#9177](https://github.com/objectstack-ai/objectui/issues/9177)); `--reporter=default` restores the log and `--reporter=github-actions` restores the per-test `::error` annotation. The flags are pinned by `scripts/__tests__/coverage-shard-reporter-readability.test.ts`. The configured coverage thresholds are neutralised on the shard legs, because a slice of the suite judged against a whole-suite threshold is not a defect signal; they are enforced once, on the merged report, by the job below ([#5403](https://github.com/objectstack-ai/objectui/issues/5403)). **4 → 8 shards in [#12256](https://github.com/objectstack-ai/objectui/issues/12256)** (folded into [#12208](https://github.com/objectstack-ai/objectui/issues/12208)): at four, a shard was cancelled at this job's 40-minute ceiling on 7 of 25 `push` runs measured on that card, and every shard of the runs that finished sat within one to three minutes of it. The width is derived from those walls at a 20-minute target per shard; the ceiling is not raised, no test is skipped, and the thresholds are unchanged. The width is spelled in this job's matrix, name and `--shard` flag and in `coverage-report`'s `COVERAGE_SHARDS`, and `scripts/__tests__/coverage-shard-width.test.ts` holds every spelling to the matrix. Its legs are split by the same duration-balanced sequencer as `test`. | **Push and the hourly schedule only** | -| `coverage-report` | Test (coverage) | Downloads every shard's blob report, refuses to continue unless all of them arrived (the count it expects is the job's `COVERAGE_SHARDS`), merges them with `pnpm test:coverage --merge-reports` into one complete report — which is where the configured coverage thresholds are enforced, over the whole merged map, the shard legs having overridden them to zero — and publishes that report as the `coverage-report` artifact (kept 7 days, the same as the blobs it is derived from). Its last step runs on every path and states the outcome: the job is **red, with an error annotation**, whenever the gate did not run for the commit — before [#5403](https://github.com/objectstack-ai/objectui/issues/5403) the final step carried the implicit `success()` and was silently skipped by 311 of 373 coverage jobs, which is how four days of a 100%-failing coverage job went unnoticed. A breach of the thresholds is reported *separately* from a lane that never delivered, because the two call for opposite actions. ⛔ It never merges a report from fewer than every shard: a wrong coverage number is worse than a missing one. The Codecov upload this job used to carry was retired by [#5436](https://github.com/objectstack-ai/objectui/issues/5436) — `CODECOV_TOKEN` was never set, so it failed on every push; the trend dashboard and PR coverage comments are gone with it, the gate is not. Before the verdict, `scripts/shard-headroom.mjs` prints the longest coverage shard's wall against the 40-minute cap and the width the run's measured total implies at a 20-minute target per shard. It is report-only in the same way as in `test-aggregate`. On a red lane it reads `scripts/` through a sparse checkout of its own, because the main checkout is skipped there. | **Push and the hourly schedule only** | -| `e2e` | Build & E2E | Builds the console with `vite build` (`VITE_BASE_PATH=/console/`), verifies the artifact, then `pnpm test:e2e --project=chromium`. On failure it uploads `test-results/` — the screenshots, traces and `error-context.md` Playwright writes for failing specs — as the `e2e-failure-artifacts` upload; the `github` reporter this lane runs on CI writes annotations, so there is no HTML report in it. | Every run; on a PR the steps short-circuit when only ignored paths changed | -| `docs` | Build Docs | `turbo run build --filter='@object-ui/site'`. On a PR it first diffs against the base and skips the build only when nothing that build consumes changed. ⛔ The path set is **not enumerated here**, deliberately: it is derived rather than curated — from the workspace packages turbo builds on the way to `@object-ui/site`, the root-level inputs `turbo.json` declares for the `build` task, and the workspace manifests, plus the workflow file itself so a change to this gate is validated by the gate — and a path list copied into prose is a stale list the moment the closure moves, which is the class [#8629](https://github.com/objectstack-ai/objectui/issues/8629) and [#7448](https://github.com/objectstack-ai/objectui/issues/7448) each record. `scripts/__tests__/docs-build-trigger.test.ts` re-derives all three populations on every PR and executes the step's own shell against them, so the live answer is the pathspec in the step and a red test is what happens when it stops covering them. Until [#8647](https://github.com/objectstack-ai/objectui/issues/8647) the filter named the site's **output** surface only — the docs content and the site app — while its **input** surface is everything turbo builds before `next build` runs, so a pull request touching only `packages/**` skipped the build and still reported `success`, and a skipped build and a passed build are the same green to every reader downstream. The merge-queue leg always built, so what the filter cost was early detection rather than the guarantee at merge time. Then `scripts/check-doc-expression-carriage.mjs`, which is **report-only**: it censuses every `json` fence on the surface `check:doc-types` walks (`content/docs/**`, every `apps//docs/**` tree, and the root `README.md` — widened from `content/docs/**` alone in `59a3a233d`) for a `${…}` authored on a key `SchemaRenderer` never evaluates — the class that reached `main` four times under green gates, because `check:doc-types` judges the `type` literal only and `check:doc-snippets` compiles the ts/tsx blocks only ([#7851](https://github.com/objectstack-ai/objectui/issues/7851)). It prints its findings and **exits 0 regardless**, so it can block no merge; it exits 1 only when the instrument itself is broken — a derivation that matched nothing, a missing `@objectstack/spec` artifact, or a failed built-in control — because a check that runs, goes green and looked at nothing is worse than none. Report-only is a ruling, not an oversight: three cards of the class it reports ([#7440](https://github.com/objectstack-ai/objectui/issues/7440), [#7444](https://github.com/objectstack-ai/objectui/issues/7444), [#7838](https://github.com/objectstack-ai/objectui/issues/7838)) are open and each fixes its own sites. Then — bookkeeping only, after the verdict — `scripts/report-turbo-cache.mjs`, which posts the restored cache key and what the build replayed as one notice, and on `push` and `schedule` prunes the cache to what the build used before it is saved; a pull request or queue build restores `main`'s entry under this job's own key prefix and saves nothing ([#12276](https://github.com/objectstack-ai/objectui/issues/12276)). It does **not** check docs links any more — that moved to a workflow of its own (#3448), because this workflow's `paths-ignore` then hid exactly the docs-only PRs a link check needs to see — today the Internal Docs Link Check step of `repo-gates.yml` (#12214). #3523 has since removed that filter from the `pull_request` trigger, but the check stays out of this job: `repo-gates.yml` still runs where this workflow does not (a docs-only push to `main`), and one gate with one home was the point of #3448. | Every run (build itself conditional) | +| `test-coverage` | Test (coverage shard N/8) | Before its install, `scripts/objectstack-pin.mjs` restores or builds the pinned `@objectstack/*` (objectui#12957). `pnpm test:coverage --reporter=blob --reporter=default --reporter=github-actions --shard=N/8` across an 8-runner matrix with `fail-fast: false`. Each shard writes `.vitest-reports/blob-N-8.json` — raw coverage and test results in one file — and uploads it as an artifact even when the shard is red (vitest deletes `coverage/` on a red run unless `coverage.reportOnFailure` is set, [#5402](https://github.com/objectstack-ai/objectui/issues/5402)). The two reporters after the blob are what keep a red shard *readable*: a CLI `--reporter` replaces the default reporter set rather than adding to it, so `--reporter=blob` on its own ended a failing shard's log at `blob report written to …` with no test name and no assertion text, leaving the download-only artifact as the only copy of the failure ([#9177](https://github.com/objectstack-ai/objectui/issues/9177)); `--reporter=default` restores the log and `--reporter=github-actions` restores the per-test `::error` annotation. The flags are pinned by `scripts/__tests__/coverage-shard-reporter-readability.test.ts`. The configured coverage thresholds are neutralised on the shard legs, because a slice of the suite judged against a whole-suite threshold is not a defect signal; they are enforced once, on the merged report, by the job below ([#5403](https://github.com/objectstack-ai/objectui/issues/5403)). **4 → 8 shards in [#12256](https://github.com/objectstack-ai/objectui/issues/12256)** (folded into [#12208](https://github.com/objectstack-ai/objectui/issues/12208)): at four, a shard was cancelled at this job's 40-minute ceiling on 7 of 25 `push` runs measured on that card, and every shard of the runs that finished sat within one to three minutes of it. The width is derived from those walls at a 20-minute target per shard; the ceiling is not raised, no test is skipped, and the thresholds are unchanged. The width is spelled in this job's matrix, name and `--shard` flag and in `coverage-report`'s `COVERAGE_SHARDS`, and `scripts/__tests__/coverage-shard-width.test.ts` holds every spelling to the matrix. Its legs are split by the same duration-balanced sequencer as `test`. | **Push and the hourly schedule only** | +| `coverage-report` | Test (coverage) | Before its install, `scripts/objectstack-pin.mjs` restores or builds the pinned `@objectstack/*` (objectui#12957). Downloads every shard's blob report, refuses to continue unless all of them arrived (the count it expects is the job's `COVERAGE_SHARDS`), merges them with `pnpm test:coverage --merge-reports` into one complete report — which is where the configured coverage thresholds are enforced, over the whole merged map, the shard legs having overridden them to zero — and publishes that report as the `coverage-report` artifact (kept 7 days, the same as the blobs it is derived from). Its last step runs on every path and states the outcome: the job is **red, with an error annotation**, whenever the gate did not run for the commit — before [#5403](https://github.com/objectstack-ai/objectui/issues/5403) the final step carried the implicit `success()` and was silently skipped by 311 of 373 coverage jobs, which is how four days of a 100%-failing coverage job went unnoticed. A breach of the thresholds is reported *separately* from a lane that never delivered, because the two call for opposite actions. ⛔ It never merges a report from fewer than every shard: a wrong coverage number is worse than a missing one. The Codecov upload this job used to carry was retired by [#5436](https://github.com/objectstack-ai/objectui/issues/5436) — `CODECOV_TOKEN` was never set, so it failed on every push; the trend dashboard and PR coverage comments are gone with it, the gate is not. Before the verdict, `scripts/shard-headroom.mjs` prints the longest coverage shard's wall against the 40-minute cap and the width the run's measured total implies at a 20-minute target per shard. It is report-only in the same way as in `test-aggregate`. On a red lane it reads `scripts/` through a sparse checkout of its own, because the main checkout is skipped there. | **Push and the hourly schedule only** | +| `e2e` | Build & E2E | Before its install, `scripts/objectstack-pin.mjs` restores or builds the pinned `@objectstack/*` (objectui#12957). Builds the console with `vite build` (`VITE_BASE_PATH=/console/`), verifies the artifact, then `pnpm test:e2e --project=chromium`. On failure it uploads `test-results/` — the screenshots, traces and `error-context.md` Playwright writes for failing specs — as the `e2e-failure-artifacts` upload; the `github` reporter this lane runs on CI writes annotations, so there is no HTML report in it. | Every run; on a PR the steps short-circuit when only ignored paths changed | +| `docs` | Build Docs | Before its install, `scripts/objectstack-pin.mjs` restores or builds the pinned `@objectstack/*` (objectui#12957). `turbo run build --filter='@object-ui/site'`. On a PR it first diffs against the base and skips the build only when nothing that build consumes changed. ⛔ The path set is **not enumerated here**, deliberately: it is derived rather than curated — from the workspace packages turbo builds on the way to `@object-ui/site`, the root-level inputs `turbo.json` declares for the `build` task, and the workspace manifests, plus the workflow file itself so a change to this gate is validated by the gate — and a path list copied into prose is a stale list the moment the closure moves, which is the class [#8629](https://github.com/objectstack-ai/objectui/issues/8629) and [#7448](https://github.com/objectstack-ai/objectui/issues/7448) each record. `scripts/__tests__/docs-build-trigger.test.ts` re-derives all three populations on every PR and executes the step's own shell against them, so the live answer is the pathspec in the step and a red test is what happens when it stops covering them. Until [#8647](https://github.com/objectstack-ai/objectui/issues/8647) the filter named the site's **output** surface only — the docs content and the site app — while its **input** surface is everything turbo builds before `next build` runs, so a pull request touching only `packages/**` skipped the build and still reported `success`, and a skipped build and a passed build are the same green to every reader downstream. The merge-queue leg always built, so what the filter cost was early detection rather than the guarantee at merge time. Then `scripts/check-doc-expression-carriage.mjs`, which is **report-only**: it censuses every `json` fence on the surface `check:doc-types` walks (`content/docs/**`, every `apps//docs/**` tree, and the root `README.md` — widened from `content/docs/**` alone in `59a3a233d`) for a `${…}` authored on a key `SchemaRenderer` never evaluates — the class that reached `main` four times under green gates, because `check:doc-types` judges the `type` literal only and `check:doc-snippets` compiles the ts/tsx blocks only ([#7851](https://github.com/objectstack-ai/objectui/issues/7851)). It prints its findings and **exits 0 regardless**, so it can block no merge; it exits 1 only when the instrument itself is broken — a derivation that matched nothing, a missing `@objectstack/spec` artifact, or a failed built-in control — because a check that runs, goes green and looked at nothing is worse than none. Report-only is a ruling, not an oversight: three cards of the class it reports ([#7440](https://github.com/objectstack-ai/objectui/issues/7440), [#7444](https://github.com/objectstack-ai/objectui/issues/7444), [#7838](https://github.com/objectstack-ai/objectui/issues/7838)) are open and each fixes its own sites. Then — bookkeeping only, after the verdict — `scripts/report-turbo-cache.mjs`, which posts the restored cache key and what the build replayed as one notice, and on `push` and `schedule` prunes the cache to what the build used before it is saved; a pull request or queue build restores `main`'s entry under this job's own key prefix and saves nothing ([#12276](https://github.com/objectstack-ai/objectui/issues/12276)). It does **not** check docs links any more — that moved to a workflow of its own (#3448), because this workflow's `paths-ignore` then hid exactly the docs-only PRs a link check needs to see — today the Internal Docs Link Check step of `repo-gates.yml` (#12214). #3523 has since removed that filter from the `pull_request` trigger, but the check stays out of this job: `repo-gates.yml` still runs where this workflow does not (a docs-only push to `main`), and one gate with one home was the point of #3448. | Every run (build itself conditional) | Uses: Node 22.x, pnpm via `corepack`, `actions/cache` over `.turbo/cache`. @@ -291,6 +291,8 @@ for them there is a dead end: ## Lint (`lint.yml`) +Before its install, the job restores the pinned `@objectstack/*` build from the cache keyed by `.objectstack-sha`, or builds it on a miss with `scripts/objectstack-pin.mjs` — every `@objectstack/*` package resolves to that build, not to npm (objectui#12957; the mechanism is that script's header). + **Triggers:** **Every** PR to `main`/`develop` (no path filter), every merge-queue build, pushes to `main`/`develop` under the same `paths-ignore` as `ci.yml` minus `apps/site/**`, plus manual dispatch. As in `ci.yml`, the path list moved into the job ([#3523](https://github.com/objectstack-ai/objectui/issues/3523)): the `Lint` @@ -414,6 +416,8 @@ owns that arm and is open. ## Performance Budget (`performance-budget.yml`) +Before its install, the job restores the pinned `@objectstack/*` build from the cache keyed by `.objectstack-sha`, or builds it on a miss with `scripts/objectstack-pin.mjs` — every `@objectstack/*` package resolves to that build, not to npm (objectui#12957; the mechanism is that script's header). + **Triggers:** Push and PR when changes touch `packages/`, `apps/console/`, or `pnpm-lock.yaml`. Its display name in the checks list is **Bundle Analysis**. @@ -503,6 +507,8 @@ reviewers; exceeding any of them turns no check red and blocks no merge: ## Live E2E (`live-e2e.yml`) +Before its install, the job restores the pinned `@objectstack/*` build from the cache keyed by `.objectstack-sha`, or builds it on a miss with `scripts/objectstack-pin.mjs` — every `@objectstack/*` package resolves to that build, not to npm (objectui#12957; the mechanism is that script's header). + **Trigger:** PRs to `main` / `develop` (same code-path filter as `ci.yml` — docs-only and changeset-only PRs skip it), a nightly cron (`30 6 * * *`) on `main`, and manual dispatch. @@ -594,6 +600,8 @@ on it made the lane test a combination nobody ships, and the seeded sign-in neve ## Repo Gates (`repo-gates.yml`) +Before its install, the job restores the pinned `@objectstack/*` build from the cache keyed by `.objectstack-sha`, or builds it on a miss with `scripts/objectstack-pin.mjs` — every `@objectstack/*` package resolves to that build, not to npm (objectui#12957; the mechanism is that script's header). + **Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — with **no path filter at all**. **Appears as:** Repo Gates. @@ -2029,6 +2037,8 @@ pull request as input. ## Lockfile Dedupe (`lockfile-dedupe.yml`) +Before the check, the job restores the pinned `@objectstack/*` build from the cache keyed by `.objectstack-sha`, or builds it on a miss with `scripts/objectstack-pin.mjs`: `pnpm dedupe --check` re-reads the `file:` directories every `@objectstack/*` package resolves to, and the checker reports a reading it could not take when they are missing (objectui#12957). + **Trigger:** Pull requests to `main` and `develop` that touch `pnpm-lock.yaml`, this gate's own runtime closure (`scripts/check-lockfile-dedupe.mjs`, `scripts/invoked-as.mjs`, `scripts/ci-setup-pnpm.sh`) or the workflow file itself, plus manual dispatch. It runs as the **Lockfile Dedupe Check** step of the **Repo Gates** job. @@ -2171,6 +2181,8 @@ those cards and in git history. ### Changeset Release (`changeset-release.yml`) +Before its install, the job restores the pinned `@objectstack/*` build from the cache keyed by `.objectstack-sha`, or builds it on a miss with `scripts/objectstack-pin.mjs` — every `@objectstack/*` package resolves to that build, not to npm (objectui#12957; the mechanism is that script's header). + **Trigger:** Push to `main` — the **publish** half. Cron `0 */6 * * *` and manual dispatch with `refresh_version_pr` — the **version-PR refresh** half. @@ -2341,6 +2353,8 @@ below for the half of that mechanism which is still live. ### Published Dist Gate (`published-dist-gate.yml`) +Before its install, the job restores the pinned `@objectstack/*` build from the cache keyed by `.objectstack-sha`, or builds it on a miss with `scripts/objectstack-pin.mjs` — every `@objectstack/*` package resolves to that build, not to npm (objectui#12957; the mechanism is that script's header). + **Trigger:** Nightly cron `41 3 * * *`; push to `main` that touches the gate script or this workflow; manual. **It carries no `pull_request` trigger, on purpose.** @@ -2377,6 +2391,8 @@ Three things about it are easy to get wrong and are written down in the script's ### Spec Range Floors (`spec-range-floors.yml`) +Before its install, the job restores the pinned `@objectstack/*` build from the cache keyed by `.objectstack-sha`, or builds it on a miss with `scripts/objectstack-pin.mjs` — every `@objectstack/*` package resolves to that build, not to npm (objectui#12957; the mechanism is that script's header). + **Trigger:** Nightly cron `11 4 * * *`; push to `main` that touches the gate script or this workflow; manual. **It carries no `pull_request` trigger, on purpose.** @@ -2420,6 +2436,8 @@ demand floor bumps nothing published justifies. ### Node ESM Load Gate (`node-esm-load-gate.yml`) +Before its install, the job restores the pinned `@objectstack/*` build from the cache keyed by `.objectstack-sha`, or builds it on a miss with `scripts/objectstack-pin.mjs` — every `@objectstack/*` package resolves to that build, not to npm (objectui#12957; the mechanism is that script's header). + **Trigger:** Nightly cron `17 4 * * *`; push to `main` that touches the gate script or this workflow; manual. **It carries no `pull_request` trigger, on purpose.** @@ -2713,6 +2731,8 @@ each release commit are the source of truth for granular and current history. ### Docs Deploy (`docs-deploy.yml`) +Before `vercel build` (whose `installCommand` is a `pnpm i`), the job restores the pinned `@objectstack/*` build from the cache keyed by `.objectstack-sha`, or builds it on a miss with `scripts/objectstack-pin.mjs` — every `@objectstack/*` package resolves to that build, not to npm (objectui#12957). + **Trigger:** a 6-hourly cron (`43 5,11,17,23 * * *`) and manual dispatch. It has no `push` or pull-request leg. @@ -3212,6 +3232,8 @@ instead of quietly dropping out of the wait. ### Shadcn Component Check (`shadcn-check.yml`) +Before its install, the job restores the pinned `@objectstack/*` build from the cache keyed by `.objectstack-sha`, or builds it on a miss with `scripts/objectstack-pin.mjs` — every `@objectstack/*` package resolves to that build, not to npm (objectui#12957; the mechanism is that script's header). + **Trigger:** Weekly on Monday at 9:00 AM UTC, or manual dispatch. Nothing here blocks a merge — it runs on a schedule, and its alarm is an issue rather than the job colour. diff --git a/content/docs/guide/console.md b/content/docs/guide/console.md index 4563ec282f..6df7334270 100644 --- a/content/docs/guide/console.md +++ b/content/docs/guide/console.md @@ -11,7 +11,7 @@ The **Console** is the reference application for [ObjectUI](/docs/guide). It ren ```bash # From the repository root -pnpm install +pnpm run setup # builds @objectstack/* at .objectstack-sha, then installs pnpm dev # starts the console dev server (Vite) ``` diff --git a/content/docs/utilities/runner.mdx b/content/docs/utilities/runner.mdx index 347f0811aa..bcf4bc36aa 100644 --- a/content/docs/utilities/runner.mdx +++ b/content/docs/utilities/runner.mdx @@ -28,8 +28,8 @@ The Runner is a complete ObjectUI application that demonstrates best practices a git clone https://github.com/objectstack-ai/objectui.git cd objectui -# Install dependencies -pnpm install +# Build @objectstack/* at .objectstack-sha, then install dependencies +pnpm run setup # Run the runner cd packages/runner @@ -611,7 +611,7 @@ Clear cache and rebuild: ```bash rm -rf node_modules dist .vite -pnpm install +pnpm run setup # removing node_modules removed the pinned @objectstack/* build too pnpm build ``` diff --git a/scripts/__tests__/bump-objectstack.test.ts b/scripts/__tests__/bump-objectstack.test.ts index 42bafb4eed..e173438d83 100644 --- a/scripts/__tests__/bump-objectstack.test.ts +++ b/scripts/__tests__/bump-objectstack.test.ts @@ -15,7 +15,7 @@ * The cases that pin a commit are the firing controls of the refusal cases: a * guard that refused everything would fail them. */ -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, describe, expect, it } from 'vitest'; import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; @@ -26,7 +26,9 @@ 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); -let scratch = ''; +// 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 }); @@ -62,10 +64,10 @@ function makeObjectstack(name: string, { withOrigin = true } = {}) { const upstream = path.join(base, 'upstream'); fs.mkdirSync(upstream, { recursive: true }); git(upstream, 'init', '-q', '-b', 'main'); - fs.writeFileSync(path.join(upstream, 'README.md'), 'one\n'); + 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, 'README.md'), 'two\n'); + 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) { @@ -76,7 +78,7 @@ function makeObjectstack(name: string, { withOrigin = true } = {}) { } const mainTip = git(checkout, 'rev-parse', 'HEAD'); git(checkout, 'checkout', '-q', '-b', 'feature'); - fs.writeFileSync(path.join(checkout, 'README.md'), 'three\n'); + 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'); @@ -93,9 +95,6 @@ function bump(objectui: string, objectstack: string, ...args: string[]) { const pinOf = (objectui: string) => fs.readFileSync(path.join(objectui, '.objectstack-sha'), 'utf8'); -beforeAll(() => { - scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'bump-objectstack-')); -}); afterAll(() => { fs.rmSync(scratch, { recursive: true, force: true }); }); diff --git a/scripts/__tests__/check-action-ref-convention.test.ts b/scripts/__tests__/check-action-ref-convention.test.ts index 4b8c6d40dd..64759d6ab2 100644 --- a/scripts/__tests__/check-action-ref-convention.test.ts +++ b/scripts/__tests__/check-action-ref-convention.test.ts @@ -268,6 +268,9 @@ describe('prose cannot be mistaken for a reference', () => { expect(parseRefs(source, 'repo-gates.yml').map((r: { ref: string }) => r.ref)).toEqual([ 'actions/checkout@v7', 'actions/setup-node@v7', + // The pinned @objectstack build's restore and save (objectui#12957). + 'actions/cache/restore@v6', + 'actions/cache/save@v6', ]); }); diff --git a/scripts/__tests__/objectstack-pin.test.ts b/scripts/__tests__/objectstack-pin.test.ts index 2f4a9a60d6..c62c902e51 100644 --- a/scripts/__tests__/objectstack-pin.test.ts +++ b/scripts/__tests__/objectstack-pin.test.ts @@ -10,7 +10,7 @@ * objectstack-shaped workspace packed by the real `pnpm pack` — the input * objectstack#24306 consumes. No network: nothing is fetched or installed. */ -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, describe, expect, it } from 'vitest'; import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; @@ -41,10 +41,9 @@ import { const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const SHA = '0123456789abcdef0123456789abcdef01234567'; -let scratch = ''; -beforeAll(() => { - scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'objectstack-pin-test-')); -}); +// 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-')); afterAll(() => { fs.rmSync(scratch, { recursive: true, force: true }); }); diff --git a/scripts/__tests__/render-budget-comment.test.ts b/scripts/__tests__/render-budget-comment.test.ts index a5f048b70e..03127a317e 100644 --- a/scripts/__tests__/render-budget-comment.test.ts +++ b/scripts/__tests__/render-budget-comment.test.ts @@ -456,9 +456,11 @@ describe('performance-budget.yml contract', () => { }); it('gates the size report, the renderer and the comment on !cancelled()', () => { - // Four: those three, plus the turbo cache report (objectui#12276), which is - // bookkeeping after the verdict and reports on a red run for the same reason. - expect(conditions.filter((c) => c.includes('!cancelled()'))).toHaveLength(4); + // Five: those three, plus the turbo cache report (objectui#12276), which is + // bookkeeping after the verdict and reports on a red run for the same reason, + // plus the pinned @objectstack build's save (objectui#12957), bookkeeping after + // the verdict that keeps the build whatever the verdict was. + expect(conditions.filter((c) => c.includes('!cancelled()'))).toHaveLength(5); }); it('generates the size report only from a complete package build', () => { @@ -578,6 +580,8 @@ describe('performance-budget.yml contract', () => { 'scripts/invoked-as.mjs', // The turbo cache report and prune the job runs after its verdict (objectui#12276). 'scripts/report-turbo-cache.mjs', + // The pinned @objectstack build the job restores or builds before install (objectui#12957). + 'scripts/objectstack-pin.mjs', ]; it('triggers on its whole runtime closure, so no part of this gate ships unexercised', () => { diff --git a/scripts/__tests__/report-turbo-cache.test.ts b/scripts/__tests__/report-turbo-cache.test.ts index 8d543dacef..90a6d53dc1 100644 --- a/scripts/__tests__/report-turbo-cache.test.ts +++ b/scripts/__tests__/report-turbo-cache.test.ts @@ -197,19 +197,20 @@ describe('report-turbo-cache (objectui#12222)', () => { describe('ci.yml — only the full battery writes the type-check cache, pruned first (objectui#12222)', () => { const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const workflow = parseYaml(fs.readFileSync(path.join(repoRoot, '.github/workflows/ci.yml'), 'utf8')) as { - jobs: Record }>; + jobs: Record }> }>; }; const steps = workflow.jobs['type-check'].steps; const FULL_BATTERY = "(github.event_name == 'push' || github.event_name == 'schedule')"; it('saves only on push and schedule, whose run covers every task', () => { - const save = steps.find((s) => (s.uses ?? '').startsWith('actions/cache/save@')); + // By path: the job's other save is the pinned @objectstack build's (objectui#12957). + const save = steps.find((s) => (s.uses ?? '').startsWith('actions/cache/save@') && s.with?.path === '.turbo/cache'); expect(save?.if ?? '').toContain(FULL_BATTERY); }); it('prunes before it saves, on the same events, and never on a pull request or a queue build', () => { const pruneAt = steps.findIndex((s) => s.run === 'node scripts/report-turbo-cache.mjs --prune'); - const saveAt = steps.findIndex((s) => (s.uses ?? '').startsWith('actions/cache/save@')); + const saveAt = steps.findIndex((s) => (s.uses ?? '').startsWith('actions/cache/save@') && s.with?.path === '.turbo/cache'); expect(pruneAt, 'the prune step is gone').toBeGreaterThan(-1); expect(pruneAt).toBeLessThan(saveAt); expect(steps[pruneAt].if ?? '').toContain(FULL_BATTERY); diff --git a/scripts/__tests__/shadcn-check-report.test.ts b/scripts/__tests__/shadcn-check-report.test.ts index a124213119..e4c8566b57 100644 --- a/scripts/__tests__/shadcn-check-report.test.ts +++ b/scripts/__tests__/shadcn-check-report.test.ts @@ -358,7 +358,16 @@ describe('shadcn-check.yml — the gaps, read off the workflow itself', () => { it('gap ③: the analyze step no longer hides its exit code behind continue-on-error', () => { expect(workflowCode).toContain('pnpm shadcn:analyze'); - expect(workflowCode).not.toContain('continue-on-error'); + // Read per STEP: the one cache save this job carries (the pinned @objectstack + // build, objectui#12957) must be non-fatal by objectui#7048's rule, so the + // key may exist in the file -- never on the analyze step, never on a command. + const steps = workflowCode.split(/\n(?=\s+- (?:name|uses|run):)/); + const analyze = steps.filter((s) => s.includes('pnpm shadcn:analyze')); + expect(analyze).toHaveLength(1); + expect(analyze[0]).not.toContain('continue-on-error'); + for (const s of steps.filter((x) => x.includes('continue-on-error'))) { + expect(s, 'only a cache save may be non-fatal here').toMatch(/uses: actions\/cache\/save@/); + } // Captured explicitly, the way #3497 captured the check step's. expect(workflowCode).toMatch(/pnpm shadcn:analyze 2>&1 \| tee/); }); diff --git a/scripts/__tests__/turbo-cache-save-bound.test.ts b/scripts/__tests__/turbo-cache-save-bound.test.ts index d6f8c39df9..2bf9bdf8a8 100644 --- a/scripts/__tests__/turbo-cache-save-bound.test.ts +++ b/scripts/__tests__/turbo-cache-save-bound.test.ts @@ -71,7 +71,11 @@ function typeCheckJob(): Job { } const saveStep = (job: Job): Step => { - const found = job.steps.filter((s) => (s.uses ?? '').startsWith('actions/cache/save@')); + // By PATH: the job also saves the pinned @objectstack build (objectui#12957), + // a second cache this file is not about. + const found = job.steps.filter( + (s) => (s.uses ?? '').startsWith('actions/cache/save@') && s.with?.path === '.turbo/cache', + ); expect( found.length, 'the `type-check` job must save `.turbo/cache` through exactly one `actions/cache/save` ' + @@ -164,7 +168,9 @@ describe('ci.yml — the type-check cache save is bounded and non-fatal (objectu // action itself decided to skip a save after an exact primary-key hit. const job = typeCheckJob(); const save = saveStep(job); - const restores = job.steps.filter((s) => (s.uses ?? '').startsWith('actions/cache/restore@')); + const restores = job.steps.filter( + (s) => (s.uses ?? '').startsWith('actions/cache/restore@') && s.with?.path === '.turbo/cache', + ); expect(restores.length, 'the `type-check` job must restore `.turbo/cache`').toBe(1); const restore = restores[0]; diff --git a/scripts/setup.sh b/scripts/setup.sh index c9f2f98bcb..442a909d91 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -71,9 +71,14 @@ 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 -echo -e "\n${YELLOW}📦 Installing dependencies...${NC}" -pnpm install +# 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}" +require_root_script setup +pnpm run setup # Build the packages # From 1fa4f1defd8e08239e43c5a21e224d1a81a32741 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 12:48:12 +0000 Subject: [PATCH 4/9] docs(agents): the backend runbook says `pnpm run setup`, not pnpm's built-in `pnpm setup` `pnpm setup` runs pnpm's own built-in command (it writes PNPM_HOME into the shell rc) and never reaches the workspace's `setup` script; `pnpm run setup` does. Measured on objectui#12957 in a throwaway workspace. Claude-Session: https://claude.ai/code/session_0188TsgPV8kQXfuRuKmkj2ks Co-authored-by: Claude --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 081a16097d..9cc8e265a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -605,7 +605,7 @@ sha pin **退休**之后这条**更重、不是更轻**(维护者 2026-09-04 裁 # CRM: cd ../objectstack/examples/app-crm && pnpm exec objectstack dev --seed-admin --fresh -p # 要持久库(跨重启保留):去掉 --fresh,改用 --database "file:/tmp/agent-.db"(或 OS_DATABASE_URL) ``` - 干净 checkout 首次需先 `pnpm setup`(build `@objectstack/spec`);已装过的直接可跑。 + 干净 checkout 首次需先 `pnpm run setup`(build `@objectstack/spec`;⛔ 不是 `pnpm setup`,那是 pnpm 的内建命令,只改 shell rc、不跑这个脚本);已装过的直接可跑。 2. **Console(你的 objectui worktree)—— 自选端口 + 指向你的后端**: ```bash From 30f8df523654202153eb44a466dcce0955986331 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 14:00:34 +0000 Subject: [PATCH 5/9] chore(tooling): the first pin is objectstack's 18.0.0-next.3 release commit; the objectstack install reads its own npm config Seat-directed scope change on objectui#12957 (item 6 amended): the first pin is 2c9bf9430a, the commit @objectstack/spec@18.0.0-next.3 was released from, so the pinned build reproduces what objectui resolved from npm and CI proves the mechanism with no contract drift. Moved with scripts/bump-objectstack.sh; `pnpm install` leaves pnpm-lock.yaml unchanged. Moving to a commit with 586705d85d, 1e001c7df and e15897c0f1 is a separate pin-bump card. objectstack-pin.mjs no longer passes this repository's npm_config_* settings to pnpm in the objectstack tree: `pnpm run` exports objectui's engine-strict=true, which refused the objectstack install through `pnpm objectstack:build` while the direct `node` call installed. Claude-Session: https://claude.ai/code/session_0188TsgPV8kQXfuRuKmkj2ks Co-authored-by: Claude --- .objectstack-sha | 2 +- scripts/__tests__/objectstack-pin.test.ts | 16 ++++++++++ scripts/objectstack-pin.mjs | 39 ++++++++++++++++++++--- 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/.objectstack-sha b/.objectstack-sha index a46996956d..f928c13fd3 100644 --- a/.objectstack-sha +++ b/.objectstack-sha @@ -1 +1 @@ -2ee5861b0f918b5d256e5872dcf48d01426c1d04 +2c9bf9430a1198bfaa5c0de9ff4a811838c6869c diff --git a/scripts/__tests__/objectstack-pin.test.ts b/scripts/__tests__/objectstack-pin.test.ts index c62c902e51..31ba178c09 100644 --- a/scripts/__tests__/objectstack-pin.test.ts +++ b/scripts/__tests__/objectstack-pin.test.ts @@ -31,6 +31,7 @@ import { hashTree, judgeStamp, lockfileDependencyNames, + objectstackEnv, readOverrides, readPin, readWorkspace, @@ -238,6 +239,21 @@ describe('the lockfile must record what the build declares (objectui#12957)', () }); }); +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('the real tree (objectui#12957)', () => { it('overrides every @objectstack/* package the workspace declares, so none resolves from npm', () => { const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); diff --git a/scripts/objectstack-pin.mjs b/scripts/objectstack-pin.mjs index 1344ddb7fb..35dbb49a65 100644 --- a/scripts/objectstack-pin.mjs +++ b/scripts/objectstack-pin.mjs @@ -486,6 +486,33 @@ export function hashTree(dir) { // ── 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 with inherited output; a non-zero exit is a refusal naming it. * @@ -541,10 +568,11 @@ function checkoutPin(dir, sha, url) { * @param {string} projectDir * @param {string} tarballs * @param {string} dest + * @param {NodeJS.ProcessEnv} env * @returns {{ name: string, version: string }} */ -function packInto(projectDir, tarballs, dest) { - const out = run('pnpm', ['pack', '--json', '--pack-destination', tarballs, '--config.ignore-scripts=true'], { cwd: projectDir, capture: true }); +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 }); @@ -609,8 +637,9 @@ export function build({ root = REPO_ROOT, env = process.env } = {}) { 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. - step('install the build closure', () => run('pnpm', ['install', '--frozen-lockfile', '--filter', rootName, ...filters], { cwd: tree, env })); - step(`build ${names.join(', ')}`, () => run('pnpm', [...filters, 'run', 'build'], { cwd: tree, env })); + const treeEnv = objectstackEnv(env); + step('install the build closure', () => run('pnpm', ['install', '--frozen-lockfile', '--filter', rootName, ...filters], { cwd: tree, env: treeEnv })); + step(`build ${names.join(', ')}`, () => run('pnpm', [...filters, 'run', 'build'], { cwd: tree, env: treeEnv })); } const tarballs = path.join(scratch, 'tarballs'); @@ -619,7 +648,7 @@ export function build({ root = REPO_ROOT, env = process.env } = {}) { 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); + 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) { From 185772ba02f9100bc6e2607140acfb1b38b01a52 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 14:01:01 +0000 Subject: [PATCH 6/9] docs(tooling): date the devDependency closure measurement in objectstack-pin.mjs Claude-Session: https://claude.ai/code/session_0188TsgPV8kQXfuRuKmkj2ks Co-authored-by: Claude --- scripts/objectstack-pin.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/objectstack-pin.mjs b/scripts/objectstack-pin.mjs index 35dbb49a65..71c6a92ae8 100644 --- a/scripts/objectstack-pin.mjs +++ b/scripts/objectstack-pin.mjs @@ -242,8 +242,8 @@ export function declaredObjectstackNames(projects) { * 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 at the - * first pin: 36 projects with them, 7 without). + * 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 From c659d235a97bad924291b45d645c30f92caba8b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 14:14:10 +0000 Subject: [PATCH 7/9] fix(tooling): a failed pinned build names its cause in a GitHub annotation The fetch, install and build output of the objectstack tree goes to a log in the build's scratch directory; a failure carries that log's last lines, and under GitHub Actions the refusal is also printed as an `::error::` annotation. 8 of 25 jobs on 185772ba failed the build step about 40 s in, and the job log -- the only place their cause was -- is not readable from the checks API. Claude-Session: https://claude.ai/code/session_0188TsgPV8kQXfuRuKmkj2ks Co-authored-by: Claude --- scripts/__tests__/objectstack-pin.test.ts | 12 ++++ scripts/objectstack-pin.mjs | 67 +++++++++++++++++------ 2 files changed, 63 insertions(+), 16 deletions(-) diff --git a/scripts/__tests__/objectstack-pin.test.ts b/scripts/__tests__/objectstack-pin.test.ts index 31ba178c09..614ab1f231 100644 --- a/scripts/__tests__/objectstack-pin.test.ts +++ b/scripts/__tests__/objectstack-pin.test.ts @@ -32,6 +32,8 @@ import { judgeStamp, lockfileDependencyNames, objectstackEnv, + annotation, + tailLines, readOverrides, readPin, readWorkspace, @@ -254,6 +256,16 @@ describe('the objectstack tree is configured by its own .npmrc (objectui#12957)' }); }); +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 real tree (objectui#12957)', () => { it('overrides every @objectstack/* package the workspace declares, so none resolves from npm', () => { const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); diff --git a/scripts/objectstack-pin.mjs b/scripts/objectstack-pin.mjs index 71c6a92ae8..de91335f7e 100644 --- a/scripts/objectstack-pin.mjs +++ b/scripts/objectstack-pin.mjs @@ -514,29 +514,62 @@ export function objectstackEnv(env) { } /** - * Run a command with inherited output; a non-zero exit is a refusal naming it. + * 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 }} opts + * @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 }) { - const result = spawnSync(cmd, args, { - cwd, - env, - encoding: 'utf8', - stdio: capture ? ['ignore', 'pipe', 'inherit'] : 'inherit', - maxBuffer: 256 * 1024 * 1024, - }); +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) { - const said = capture && result.stdout ? `\n${result.stdout.trim().slice(-2000)}` : ''; + 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}`); @@ -551,11 +584,12 @@ function step(label, fn) { * @param {string} dir * @param {string} sha * @param {string} url + * @param {string} logTo */ -function checkoutPin(dir, sha, url) { +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 }); + 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}.`); @@ -624,7 +658,7 @@ export function build({ root = REPO_ROOT, env = process.env } = {}) { 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)); + 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 }>} */ @@ -638,8 +672,8 @@ export function build({ root = REPO_ROOT, env = process.env } = {}) { // 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 })); - step(`build ${names.join(', ')}`, () => run('pnpm', [...filters, 'run', 'build'], { cwd: tree, env: treeEnv })); + 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'); @@ -752,6 +786,7 @@ if (isEntrypoint(import.meta.url)) { } catch (error) { if (error instanceof PinRefusal) { console.error(`✗ objectstack-pin: ${error.message}`); + if (process.env.GITHUB_ACTIONS === 'true') console.error(annotation(error.message)); process.exit(1); } throw error; From db2b72395f4b8f9e5976c74ec3dbeeda5065be45 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 14:38:35 +0000 Subject: [PATCH 8/9] fix(tooling): every pinned-build failure is annotated, and cleanup never masks it On c659d235, 13 of 25 cold jobs failed the build step about 50 s in with no annotation, so the failure was not a refusal: an exception escaped. The CLI now writes any failure, refusal or crash with its stack, synchronously to stderr and as a GitHub annotation; removing the scratch tree is retried and only warns. Claude-Session: https://claude.ai/code/session_0188TsgPV8kQXfuRuKmkj2ks Co-authored-by: Claude --- scripts/objectstack-pin.mjs | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/scripts/objectstack-pin.mjs b/scripts/objectstack-pin.mjs index de91335f7e..3ac5e94819 100644 --- a/scripts/objectstack-pin.mjs +++ b/scripts/objectstack-pin.mjs @@ -712,8 +712,16 @@ export function build({ root = REPO_ROOT, env = process.env } = {}) { assertLockfileRecordsBuild(root, overrides); return { skipped: false, stamp }; } finally { - fs.rmSync(staging, { recursive: true, force: true }); - fs.rmSync(scratch, { recursive: true, force: true }); + // 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)}`); + } + } } } @@ -784,11 +792,14 @@ if (isEntrypoint(import.meta.url)) { process.exit(2); } } catch (error) { - if (error instanceof PinRefusal) { - console.error(`✗ objectstack-pin: ${error.message}`); - if (process.env.GITHUB_ACTIONS === 'true') console.error(annotation(error.message)); - process.exit(1); - } - throw 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); } } From 4bd617b0af9220dc6357da9e234582b3ce9d547a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 14:44:59 +0000 Subject: [PATCH 9/9] fix(tooling): the pinned objectstack checkout drops its remote, so its build is a function of the pin objectstack's spec build anchors its schema ratchets on origin/main and fetches it when it can. With the remote in place, building the 18.0.0-next.3 commit was judged against wherever main was that minute: it built earlier and then failed in 13 of 25 CI jobs on db2b7239 once main had added two schemas (the annotation named them). With no remote the spec build anchors on the commit's own committed baseline ("origin/main is not resolvable in this build environment"). Claude-Session: https://claude.ai/code/session_0188TsgPV8kQXfuRuKmkj2ks Co-authored-by: Claude --- scripts/__tests__/objectstack-pin.test.ts | 31 +++++++++++++++++++++++ scripts/objectstack-pin.mjs | 11 +++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/scripts/__tests__/objectstack-pin.test.ts b/scripts/__tests__/objectstack-pin.test.ts index 614ab1f231..a481bf1153 100644 --- a/scripts/__tests__/objectstack-pin.test.ts +++ b/scripts/__tests__/objectstack-pin.test.ts @@ -25,6 +25,7 @@ import { STAMP_FILE, build, checkOverrideSet, + checkoutPin, declaredObjectstackNames, dependencyDrift, entryTargets, @@ -266,6 +267,36 @@ describe('a refusal reaches the checks API (objectui#12957)', () => { }); }); +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(''); + }); +}); + describe('the real tree (objectui#12957)', () => { it('overrides every @objectstack/* package the workspace declares, so none resolves from npm', () => { const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); diff --git a/scripts/objectstack-pin.mjs b/scripts/objectstack-pin.mjs index 3ac5e94819..faa7191cf9 100644 --- a/scripts/objectstack-pin.mjs +++ b/scripts/objectstack-pin.mjs @@ -586,13 +586,22 @@ function step(label, fn) { * @param {string} url * @param {string} logTo */ -function checkoutPin(dir, sha, url, 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 }); } /**