From b841493ed044fd0fa578663c6bbc5c5d92c28000 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 14:15:25 +0000 Subject: [PATCH] fix(devx): bundle THIS tree's @objectstack/spec into the vendored Console SPA (#8134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build-console.sh injected only OBJECTSTACK_CLIENT_DIST, so the console's @objectstack/spec always came from objectui's lockfile under --frozen-lockfile — the last published spec, never this workspace. An authorable key added to packages/spec after that publish was accepted and round-tripped by the server while the Studio designer rejected it as unrecognized, with the framework-side card closing green throughout. Mirrors the client injection including its preflight: a hook-presence guard naming the pin, a build guard keyed on both generators that produce the spec's export targets, and a bundle assertion. The assertion derives its probes each run instead of freezing a literal, and tests both directions — the bundle also carries a transitive copy of this tree's spec via the injected client, which makes a one-sided probe pass with no injection at all. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja --- .changeset/console-spec-dist-injection.md | 43 ++++ scripts/assert-console-spec-injection.mjs | 226 ++++++++++++++++++++++ scripts/build-console.sh | 68 ++++++- 3 files changed, 332 insertions(+), 5 deletions(-) create mode 100644 .changeset/console-spec-dist-injection.md create mode 100644 scripts/assert-console-spec-injection.mjs diff --git a/.changeset/console-spec-dist-injection.md b/.changeset/console-spec-dist-injection.md new file mode 100644 index 0000000000..0d69f6fe0d --- /dev/null +++ b/.changeset/console-spec-dist-injection.md @@ -0,0 +1,43 @@ +--- +"@objectstack/console": patch +--- + +fix(devx): the vendored Console SPA bundles THIS tree's `@objectstack/spec`, so a newly declared authorable key is reachable in the Studio designer on the day it lands (#8134) + +`scripts/build-console.sh` injected only `OBJECTSTACK_CLIENT_DIST`. The console's +`@objectstack/spec` therefore always came from objectui's own lockfile, resolved +under `pnpm install --frozen-lockfile` — which means the **published** spec, never +this workspace's. + +That made a whole class of change silently unreachable: an authorable key added to +`packages/spec` after the last spec publish is accepted and round-tripped by the +server, while the Studio designer — bundled against the published spec — rejects it +as an unrecognized key and refuses to auto-save. The framework-side card closes +green the whole time, because `packages/spec`'s own pins pass. Reaching the key took +three ordered cross-repo steps: spec publishes, objectui refreshes its lockfile, the +console pin moves. + +The skew was not hypothetical at the time of this change: **102** schema description +strings declared in this tree's `packages/spec` were absent from the +`@objectstack/spec@17.0.0` the pinned objectui lockfile installs. + +`build-console.sh` now exports `OBJECTSTACK_SPEC_DIST` alongside the client +injection, mirroring it including its preflight: + +- a **hook-presence guard** that refuses the build, naming the pin, when the pinned + objectui predates the `OBJECTSTACK_SPEC_DIST` hook — an unguarded injection would + quietly rebuild the exact silent skew this change exists to end; +- a **build guard** that builds `packages/spec` when it is not built, keyed on both + `dist/index.mjs` and `json-schema/openapi.json`, because the spec's exports map + has one entry (`./openapi.json`) that a different generator produces; +- a **bundle assertion** that proves the injection actually landed. + +The assertion is deliberately not a frozen literal like the client's canary. It +derives a witness on every run — a description string this tree's spec has and the +vendored one lacks — and pairs it with a control string both carry, so an absent +witness is told apart from an unbundled entry. A frozen literal would be carried by +the published spec within one release and pass forever while proving nothing, which +is the same silent-pass failure this change removes. + +Consumers see no API change; the shipped console simply matches the framework +release it is published with. diff --git a/scripts/assert-console-spec-injection.mjs b/scripts/assert-console-spec-injection.mjs new file mode 100644 index 0000000000..d2e88cf6be --- /dev/null +++ b/scripts/assert-console-spec-injection.mjs @@ -0,0 +1,226 @@ +#!/usr/bin/env node +// Assert that OBJECTSTACK_SPEC_DIST actually landed in the built console bundle. +// +// ## Why this is not a frozen literal like the client's BUNDLE_CANARY +// +// build-console.sh asserts the injected *client* with a fixed string +// ('import/jobs'). That works because the question is static: "is the client in +// here new enough to have the import-job API?". The spec question is not static. +// What must be true is "the bundle carries the surface the framework declares +// NOW", and any literal frozen today is carried by the published spec too within +// one release — after which the canary passes forever while proving nothing. A +// self-staling assertion is the exact silent-pass failure objectstack#8134 exists +// to end, so it must not be the fix for it. +// +// So both probes are DERIVED, on every run, from the two specs actually on disk: +// +// injected = this framework tree's packages/spec (what must be bundled) +// vendored = the @objectstack/spec objectui's own lockfile installed +// (what gets bundled when the injection is missing or broken) +// +// ## The test is two-sided, because one side is not enough +// +// Measured while building this check: asserting only "a string unique to the +// injected spec appears in the bundle" PASSES even with no injection at all. The +// console bundle already contains a second, transitive copy of this tree's spec, +// dragged in by the injected @objectstack/client — it lands in a different chunk +// from the console's own `@objectstack/spec` imports. A one-sided probe reads +// that copy and reports success while the designer still runs on the published +// schemas. So: +// +// FRESH WITNESS — text only the injected spec has; must be PRESENT. +// STALE DETECTOR — text only the vendored spec has; must be ABSENT. +// +// The stale detector is the one that actually catches this card's defect: it is +// positive evidence that the published spec is still in the bundle. The fresh +// witness alone cannot distinguish "injection worked" from "some other copy". +// +// ## Substring safety +// +// A probe is only usable if a literal search can tell the two specs apart, so +// each candidate is checked against the ENTIRE other spec's built output, not +// against a string set. Descriptions are routinely REWORDED by appending a +// clause, which makes the old text a prefix of the new one — three of the first +// candidates measured here were exactly that, and a set-difference check called +// them unique when a substring search would have matched both. +// +// ## When the two specs agree +// +// If neither side has text the other lacks, there is nothing to detect and the +// check reports "no skew" and exits 0. That is a real state — the build right +// after a spec publish — not a failure. +// +// Usage: +// node scripts/assert-console-spec-injection.mjs \ +// --injected \ +// --vendored \ +// --assets +// +// Exit: 0 = injection proven (or no skew to prove) · 1 = injection failed +// 2 = inconclusive / cannot run + +import fs from 'node:fs'; +import path from 'node:path'; + +/** Export conditions a browser/ESM bundler picks, in preference order. + * `types` is deliberately absent — it sits first in each condition object and + * would resolve every subpath at a `.d.mts` file. */ +const IMPORT_CONDITIONS = ['import', 'module', 'browser', 'default']; + +function fail(message) { + console.error(`✗ assert-console-spec-injection: ${message}`); + process.exit(2); +} + +function parseArgs(argv) { + const out = {}; + for (let i = 2; i < argv.length; i += 2) { + const key = argv[i]; + if (!key.startsWith('--')) fail(`unexpected argument \`${key}\``); + if (argv[i + 1] === undefined) fail(`\`${key}\` has no value`); + out[key.slice(2)] = argv[i + 1]; + } + for (const required of ['injected', 'vendored', 'assets']) { + if (!out[required]) fail(`--${required} is required`); + } + return out; +} + +function pickImportTarget(value) { + if (typeof value === 'string') return value; + if (value === null || typeof value !== 'object') return null; + if (Array.isArray(value)) { + for (const candidate of value) { + const hit = pickImportTarget(candidate); + if (hit) return hit; + } + return null; + } + for (const condition of IMPORT_CONDITIONS) { + if (!Object.hasOwn(value, condition)) continue; + const hit = pickImportTarget(value[condition]); + if (hit) return hit; + } + return null; +} + +/** Every JS file a package's exports map resolves to, concatenated once. */ +function readSpecBlob(packageDir, label) { + const manifestPath = path.join(packageDir, 'package.json'); + if (!fs.existsSync(manifestPath)) fail(`${label} spec has no package.json at \`${manifestPath}\``); + let exportsMap; + try { + exportsMap = JSON.parse(fs.readFileSync(manifestPath, 'utf8')).exports; + } catch (error) { + fail(`${label} \`${manifestPath}\` is not readable JSON (${error.message})`); + } + if (!exportsMap || typeof exportsMap !== 'object') fail(`${label} spec declares no exports map`); + + const chunks = []; + for (const value of Object.values(exportsMap)) { + const target = pickImportTarget(value); + if (!target || !/\.(js|mjs|cjs)$/.test(target)) continue; + const absolute = path.resolve(packageDir, target); + if (!fs.existsSync(absolute)) continue; + chunks.push(fs.readFileSync(absolute, 'utf8')); + } + if (chunks.length === 0) fail(`${label} spec at \`${packageDir}\` has no built JavaScript to compare`); + return chunks.join('\n'); +} + +/** + * Candidate probe strings: Zod `.describe()` arguments. + * + * They are prose written by spec authors, which makes them stable across a + * bundler (plain string literals, preserved by minification) and specific enough + * that a match is not a coincidence — the property objectstack#8134's own + * measurement relied on, and the reason a bare key name like `object` is + * unusable here (`optionsFrom.object` false-positives). + */ +function describeCandidates(blob) { + const found = new Set(); + const pattern = /\.describe\(\s*(["'])((?:\\.|(?!\1)[^\\])*)\1\s*\)/g; + for (const match of blob.matchAll(pattern)) { + const text = match[2]; + // Long enough to be unique, short enough to survive intact, and free of + // escapes and line breaks so a literal search means what it says. + if (text.length < 32 || text.length > 160) continue; + if (/[\\\r\n]/.test(text)) continue; + found.add(text); + } + return [...found].sort(); +} + +/** First candidate present in `mine` and absent from `theirs`, as raw text. */ +function pickProbe(candidates, theirs) { + for (const candidate of candidates) { + if (!theirs.includes(candidate)) return candidate; + } + return null; +} + +const args = parseArgs(process.argv); + +const assetsDir = path.resolve(args.assets); +if (!fs.existsSync(assetsDir)) fail(`assets dir \`${assetsDir}\` does not exist`); +const assetChunks = []; +for (const entry of fs.readdirSync(assetsDir, { withFileTypes: true })) { + if (entry.isFile() && /\.(js|mjs|cjs)$/.test(entry.name)) { + assetChunks.push(fs.readFileSync(path.join(assetsDir, entry.name), 'utf8')); + } +} +if (assetChunks.length === 0) fail(`no JavaScript assets under \`${assetsDir}\``); +const bundle = assetChunks.join('\n'); + +const injectedBlob = readSpecBlob(path.resolve(args.injected), 'injected'); +const vendoredBlob = readSpecBlob(path.resolve(args.vendored), 'vendored'); + +const freshWitness = pickProbe(describeCandidates(injectedBlob), vendoredBlob); +const staleDetector = pickProbe(describeCandidates(vendoredBlob), injectedBlob); + +if (!freshWitness && !staleDetector) { + console.log('✓ Injected and vendored @objectstack/spec declare the same descriptions'); + console.log(' — no observable skew, so nothing for this check to assert.'); + process.exit(0); +} + +const freshPresent = freshWitness ? bundle.includes(freshWitness) : null; +const stalePresent = staleDetector ? bundle.includes(staleDetector) : null; + +// Neither probe anywhere in the bundle means the spec is not in this build at +// all — the check cannot speak to an injection it cannot see. +if (freshPresent !== true && stalePresent !== true) { + console.error('✗ Neither spec appears in the built console — no @objectstack/spec'); + console.error(' content matched. The injection is UNVERIFIED by this check.'); + process.exit(2); +} + +if (stalePresent === true) { + console.error("✗ Built console still carries the PUBLISHED @objectstack/spec."); + console.error(' The console resolved spec from objectui\'s lockfile, so any authorable'); + console.error(' key this framework declared after the last spec publish is unreachable'); + console.error(' in the Studio designer — the defect objectstack#8134 exists to end.'); + console.error(''); + console.error(' Text found in the bundle that ONLY the vendored spec has:'); + console.error(` "${staleDetector}"`); + if (freshPresent === true) { + console.error(''); + console.error(' Note: text unique to this tree\'s spec is ALSO in the bundle —'); + console.error(' a second, transitive copy (via the injected @objectstack/client).'); + console.error(' That copy is not what the designer imports; both must not coexist.'); + } + process.exit(1); +} + +if (freshPresent !== true) { + console.error('✗ The published spec is gone from the bundle, but nothing unique to'); + console.error(" this tree's spec was found either — the build is in an unexpected"); + console.error(' state and the injection is UNVERIFIED.'); + console.error(` expected: "${freshWitness}"`); + process.exit(2); +} + +console.log("✓ Console bundle carries THIS tree's @objectstack/spec, and only it."); +console.log(` present (injected only): "${freshWitness}"`); +if (staleDetector) console.log(` absent (vendored only): "${staleDetector}"`); +process.exit(0); diff --git a/scripts/build-console.sh b/scripts/build-console.sh index 54a064c89b..6be4b62b2b 100755 --- a/scripts/build-console.sh +++ b/scripts/build-console.sh @@ -43,11 +43,11 @@ fi REPO_URL="${OBJECTUI_REPO_URL:-https://github.com/objectstack-ai/objectui.git}" # The console app itself must NOT build through turbo: turbo v2 runs tasks in -# strict env mode and strips undeclared vars, so OBJECTSTACK_CLIENT_DIST -# (exported below) never reaches vite unless the pinned objectui SHA happens -# to declare it in turbo.json. Build the workspace deps through turbo -# (cacheable, env-independent), then invoke the console's own build script -# directly so the env survives. +# strict env mode and strips undeclared vars, so OBJECTSTACK_CLIENT_DIST and +# OBJECTSTACK_SPEC_DIST (both exported below) never reach vite unless the pinned +# objectui SHA happens to declare them in turbo.json. Build the workspace deps +# through turbo (cacheable, env-independent), then invoke the console's own build +# script directly so the env survives. DEPS_BUILD_CMD="${OBJECTUI_DEPS_BUILD_CMD:-pnpm exec turbo run build --filter=@object-ui/console^...}" BUILD_CMD="${OBJECTUI_BUILD_CMD:-pnpm --filter @object-ui/console run build}" # Post-build canary: a literal that only exists in an up-to-date bundled @@ -161,6 +161,50 @@ fi export OBJECTSTACK_CLIENT_DIST="$CLIENT_PKG" echo "→ Console will bundle @objectstack/client from ${CLIENT_PKG}" +# ── Bundle THIS framework's spec ───────────────────────────────────── +# The same class of skew as the client above, one level quieter. The console SPA +# inlines @objectstack/spec, and left to itself the objectui build resolves it +# from objectui's own lockfile under --frozen-lockfile — the last PUBLISHED spec, +# never this workspace. So an authorable key added to packages/spec after that +# publish is accepted and round-tripped by the server while the Studio designer +# rejects it as an unrecognized key and refuses to auto-save, and the +# framework-side card closes green because packages/spec's own pins all pass. +# Reaching the key took three ordered cross-repo steps: spec publishes, objectui +# refreshes its lockfile, this pin moves. Injecting this tree's spec collapses +# all three (objectstack#8134, hook added in objectui#4854). +# +# objectui honors OBJECTSTACK_SPEC_DIST in apps/console/vite.config.ts; fail hard +# if the pinned SHA predates that hook rather than silently drift — an unguarded +# injection would quietly rebuild the exact silent skew it exists to end. +SPEC_PKG="${FRAMEWORK_ROOT}/packages/spec" +if ! grep -q "OBJECTSTACK_SPEC_DIST" "${BUILD_ROOT}/apps/console/vite.config.ts"; then + echo "✗ objectui@${PINNED_SHA:0:12} has no OBJECTSTACK_SPEC_DIST hook in apps/console/vite.config.ts —" + echo " the bundled spec would come from objectui's lockfile, not this framework, so" + echo " any key this tree declares since the last spec publish would be unreachable" + echo " in the Studio designer." + echo " Bump .objectui-sha to a commit that includes the hook." + exit 1 +fi +# The hook resolves EVERY entry of the spec's exports map and refuses any whose +# target is missing, so the package must be built before it is injected. Two +# sentinels, because two different generators produce those targets: +# dist/index.mjs is tsup's, and json-schema/openapi.json is `gen:openapi`'s — the +# one export entry that does not live under dist/, is not committed, and is wiped +# by a later `gen:schema` run. A guard keyed on dist/ alone sails past a tree +# where that happened, and the hook then throws in the middle of the console build. +# +# Unlike the client's guard this deliberately does NOT key on a declaration file: +# the hook resolves the `import` condition only, so a spec whose DTS pass never +# ran (OS_SKIP_DTS, or a DTS crash) is still complete for the injection. +SPEC_ESM="${SPEC_PKG}/dist/index.mjs" +SPEC_OPENAPI="${SPEC_PKG}/json-schema/openapi.json" +if [[ ! -f "$SPEC_ESM" || ! -f "$SPEC_OPENAPI" ]]; then + echo "→ @objectstack/spec dist absent or incomplete — building it and its deps first..." + (cd "$FRAMEWORK_ROOT" && pnpm exec turbo run build --filter=@objectstack/spec) +fi +export OBJECTSTACK_SPEC_DIST="$SPEC_PKG" +echo "→ Console will bundle @objectstack/spec from ${SPEC_PKG}" + pushd "$BUILD_ROOT" > /dev/null # objectui's root package.json may pin packages that aren't available on @@ -203,6 +247,20 @@ if ! grep -rq "$BUNDLE_CANARY" "${TARGET}/assets"; then fi echo "✓ Bundle canary '${BUNDLE_CANARY}' present — framework client is in the bundle." +# Assert the injected SPEC landed too. Deliberately NOT a frozen literal like +# BUNDLE_CANARY above: "does the bundle carry the surface the framework declares +# now" is a moving target, and any string pinned here would be carried by the +# published spec within one release — after which it passes forever while proving +# nothing, which is the same silent pass this injection exists to remove. The +# script derives its probes from the two specs on disk on every run, and tests +# BOTH directions: the console bundle also holds a second, transitive copy of +# this tree's spec (pulled in through the injected client above), which makes a +# one-sided "is the new text present" probe pass even with no injection at all. +node "${FRAMEWORK_ROOT}/scripts/assert-console-spec-injection.mjs" \ + --injected "$SPEC_PKG" \ + --vendored "${BUILD_ROOT}/node_modules/@objectstack/spec" \ + --assets "${TARGET}/assets" + BYTES="$(du -sk "$TARGET" 2>/dev/null | awk '{print $1}')" echo "✓ @objectstack/console dist ready (${BYTES} KB) from objectui@${PINNED_SHA:0:12}"