Skip to content

Commit fc89098

Browse files
os-steveclaude
andauthored
fix(devx): bundle THIS tree's @objectstack/spec into the vendored Console SPA (#8134) (#9660)
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. Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja Co-authored-by: Claude <noreply@anthropic.com>
1 parent 34392a1 commit fc89098

3 files changed

Lines changed: 332 additions & 5 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
"@objectstack/console": patch
3+
---
4+
5+
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)
6+
7+
`scripts/build-console.sh` injected only `OBJECTSTACK_CLIENT_DIST`. The console's
8+
`@objectstack/spec` therefore always came from objectui's own lockfile, resolved
9+
under `pnpm install --frozen-lockfile` — which means the **published** spec, never
10+
this workspace's.
11+
12+
That made a whole class of change silently unreachable: an authorable key added to
13+
`packages/spec` after the last spec publish is accepted and round-tripped by the
14+
server, while the Studio designer — bundled against the published spec — rejects it
15+
as an unrecognized key and refuses to auto-save. The framework-side card closes
16+
green the whole time, because `packages/spec`'s own pins pass. Reaching the key took
17+
three ordered cross-repo steps: spec publishes, objectui refreshes its lockfile, the
18+
console pin moves.
19+
20+
The skew was not hypothetical at the time of this change: **102** schema description
21+
strings declared in this tree's `packages/spec` were absent from the
22+
`@objectstack/spec@17.0.0` the pinned objectui lockfile installs.
23+
24+
`build-console.sh` now exports `OBJECTSTACK_SPEC_DIST` alongside the client
25+
injection, mirroring it including its preflight:
26+
27+
- a **hook-presence guard** that refuses the build, naming the pin, when the pinned
28+
objectui predates the `OBJECTSTACK_SPEC_DIST` hook — an unguarded injection would
29+
quietly rebuild the exact silent skew this change exists to end;
30+
- a **build guard** that builds `packages/spec` when it is not built, keyed on both
31+
`dist/index.mjs` and `json-schema/openapi.json`, because the spec's exports map
32+
has one entry (`./openapi.json`) that a different generator produces;
33+
- a **bundle assertion** that proves the injection actually landed.
34+
35+
The assertion is deliberately not a frozen literal like the client's canary. It
36+
derives a witness on every run — a description string this tree's spec has and the
37+
vendored one lacks — and pairs it with a control string both carry, so an absent
38+
witness is told apart from an unbundled entry. A frozen literal would be carried by
39+
the published spec within one release and pass forever while proving nothing, which
40+
is the same silent-pass failure this change removes.
41+
42+
Consumers see no API change; the shipped console simply matches the framework
43+
release it is published with.
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
#!/usr/bin/env node
2+
// Assert that OBJECTSTACK_SPEC_DIST actually landed in the built console bundle.
3+
//
4+
// ## Why this is not a frozen literal like the client's BUNDLE_CANARY
5+
//
6+
// build-console.sh asserts the injected *client* with a fixed string
7+
// ('import/jobs'). That works because the question is static: "is the client in
8+
// here new enough to have the import-job API?". The spec question is not static.
9+
// What must be true is "the bundle carries the surface the framework declares
10+
// NOW", and any literal frozen today is carried by the published spec too within
11+
// one release — after which the canary passes forever while proving nothing. A
12+
// self-staling assertion is the exact silent-pass failure objectstack#8134 exists
13+
// to end, so it must not be the fix for it.
14+
//
15+
// So both probes are DERIVED, on every run, from the two specs actually on disk:
16+
//
17+
// injected = this framework tree's packages/spec (what must be bundled)
18+
// vendored = the @objectstack/spec objectui's own lockfile installed
19+
// (what gets bundled when the injection is missing or broken)
20+
//
21+
// ## The test is two-sided, because one side is not enough
22+
//
23+
// Measured while building this check: asserting only "a string unique to the
24+
// injected spec appears in the bundle" PASSES even with no injection at all. The
25+
// console bundle already contains a second, transitive copy of this tree's spec,
26+
// dragged in by the injected @objectstack/client — it lands in a different chunk
27+
// from the console's own `@objectstack/spec` imports. A one-sided probe reads
28+
// that copy and reports success while the designer still runs on the published
29+
// schemas. So:
30+
//
31+
// FRESH WITNESS — text only the injected spec has; must be PRESENT.
32+
// STALE DETECTOR — text only the vendored spec has; must be ABSENT.
33+
//
34+
// The stale detector is the one that actually catches this card's defect: it is
35+
// positive evidence that the published spec is still in the bundle. The fresh
36+
// witness alone cannot distinguish "injection worked" from "some other copy".
37+
//
38+
// ## Substring safety
39+
//
40+
// A probe is only usable if a literal search can tell the two specs apart, so
41+
// each candidate is checked against the ENTIRE other spec's built output, not
42+
// against a string set. Descriptions are routinely REWORDED by appending a
43+
// clause, which makes the old text a prefix of the new one — three of the first
44+
// candidates measured here were exactly that, and a set-difference check called
45+
// them unique when a substring search would have matched both.
46+
//
47+
// ## When the two specs agree
48+
//
49+
// If neither side has text the other lacks, there is nothing to detect and the
50+
// check reports "no skew" and exits 0. That is a real state — the build right
51+
// after a spec publish — not a failure.
52+
//
53+
// Usage:
54+
// node scripts/assert-console-spec-injection.mjs \
55+
// --injected <framework packages/spec> \
56+
// --vendored <objectui build tree node_modules/@objectstack/spec> \
57+
// --assets <built console dist/assets>
58+
//
59+
// Exit: 0 = injection proven (or no skew to prove) · 1 = injection failed
60+
// 2 = inconclusive / cannot run
61+
62+
import fs from 'node:fs';
63+
import path from 'node:path';
64+
65+
/** Export conditions a browser/ESM bundler picks, in preference order.
66+
* `types` is deliberately absent — it sits first in each condition object and
67+
* would resolve every subpath at a `.d.mts` file. */
68+
const IMPORT_CONDITIONS = ['import', 'module', 'browser', 'default'];
69+
70+
function fail(message) {
71+
console.error(`✗ assert-console-spec-injection: ${message}`);
72+
process.exit(2);
73+
}
74+
75+
function parseArgs(argv) {
76+
const out = {};
77+
for (let i = 2; i < argv.length; i += 2) {
78+
const key = argv[i];
79+
if (!key.startsWith('--')) fail(`unexpected argument \`${key}\``);
80+
if (argv[i + 1] === undefined) fail(`\`${key}\` has no value`);
81+
out[key.slice(2)] = argv[i + 1];
82+
}
83+
for (const required of ['injected', 'vendored', 'assets']) {
84+
if (!out[required]) fail(`--${required} is required`);
85+
}
86+
return out;
87+
}
88+
89+
function pickImportTarget(value) {
90+
if (typeof value === 'string') return value;
91+
if (value === null || typeof value !== 'object') return null;
92+
if (Array.isArray(value)) {
93+
for (const candidate of value) {
94+
const hit = pickImportTarget(candidate);
95+
if (hit) return hit;
96+
}
97+
return null;
98+
}
99+
for (const condition of IMPORT_CONDITIONS) {
100+
if (!Object.hasOwn(value, condition)) continue;
101+
const hit = pickImportTarget(value[condition]);
102+
if (hit) return hit;
103+
}
104+
return null;
105+
}
106+
107+
/** Every JS file a package's exports map resolves to, concatenated once. */
108+
function readSpecBlob(packageDir, label) {
109+
const manifestPath = path.join(packageDir, 'package.json');
110+
if (!fs.existsSync(manifestPath)) fail(`${label} spec has no package.json at \`${manifestPath}\``);
111+
let exportsMap;
112+
try {
113+
exportsMap = JSON.parse(fs.readFileSync(manifestPath, 'utf8')).exports;
114+
} catch (error) {
115+
fail(`${label} \`${manifestPath}\` is not readable JSON (${error.message})`);
116+
}
117+
if (!exportsMap || typeof exportsMap !== 'object') fail(`${label} spec declares no exports map`);
118+
119+
const chunks = [];
120+
for (const value of Object.values(exportsMap)) {
121+
const target = pickImportTarget(value);
122+
if (!target || !/\.(js|mjs|cjs)$/.test(target)) continue;
123+
const absolute = path.resolve(packageDir, target);
124+
if (!fs.existsSync(absolute)) continue;
125+
chunks.push(fs.readFileSync(absolute, 'utf8'));
126+
}
127+
if (chunks.length === 0) fail(`${label} spec at \`${packageDir}\` has no built JavaScript to compare`);
128+
return chunks.join('\n');
129+
}
130+
131+
/**
132+
* Candidate probe strings: Zod `.describe()` arguments.
133+
*
134+
* They are prose written by spec authors, which makes them stable across a
135+
* bundler (plain string literals, preserved by minification) and specific enough
136+
* that a match is not a coincidence — the property objectstack#8134's own
137+
* measurement relied on, and the reason a bare key name like `object` is
138+
* unusable here (`optionsFrom.object` false-positives).
139+
*/
140+
function describeCandidates(blob) {
141+
const found = new Set();
142+
const pattern = /\.describe\(\s*(["'])((?:\\.|(?!\1)[^\\])*)\1\s*\)/g;
143+
for (const match of blob.matchAll(pattern)) {
144+
const text = match[2];
145+
// Long enough to be unique, short enough to survive intact, and free of
146+
// escapes and line breaks so a literal search means what it says.
147+
if (text.length < 32 || text.length > 160) continue;
148+
if (/[\\\r\n]/.test(text)) continue;
149+
found.add(text);
150+
}
151+
return [...found].sort();
152+
}
153+
154+
/** First candidate present in `mine` and absent from `theirs`, as raw text. */
155+
function pickProbe(candidates, theirs) {
156+
for (const candidate of candidates) {
157+
if (!theirs.includes(candidate)) return candidate;
158+
}
159+
return null;
160+
}
161+
162+
const args = parseArgs(process.argv);
163+
164+
const assetsDir = path.resolve(args.assets);
165+
if (!fs.existsSync(assetsDir)) fail(`assets dir \`${assetsDir}\` does not exist`);
166+
const assetChunks = [];
167+
for (const entry of fs.readdirSync(assetsDir, { withFileTypes: true })) {
168+
if (entry.isFile() && /\.(js|mjs|cjs)$/.test(entry.name)) {
169+
assetChunks.push(fs.readFileSync(path.join(assetsDir, entry.name), 'utf8'));
170+
}
171+
}
172+
if (assetChunks.length === 0) fail(`no JavaScript assets under \`${assetsDir}\``);
173+
const bundle = assetChunks.join('\n');
174+
175+
const injectedBlob = readSpecBlob(path.resolve(args.injected), 'injected');
176+
const vendoredBlob = readSpecBlob(path.resolve(args.vendored), 'vendored');
177+
178+
const freshWitness = pickProbe(describeCandidates(injectedBlob), vendoredBlob);
179+
const staleDetector = pickProbe(describeCandidates(vendoredBlob), injectedBlob);
180+
181+
if (!freshWitness && !staleDetector) {
182+
console.log('✓ Injected and vendored @objectstack/spec declare the same descriptions');
183+
console.log(' — no observable skew, so nothing for this check to assert.');
184+
process.exit(0);
185+
}
186+
187+
const freshPresent = freshWitness ? bundle.includes(freshWitness) : null;
188+
const stalePresent = staleDetector ? bundle.includes(staleDetector) : null;
189+
190+
// Neither probe anywhere in the bundle means the spec is not in this build at
191+
// all — the check cannot speak to an injection it cannot see.
192+
if (freshPresent !== true && stalePresent !== true) {
193+
console.error('✗ Neither spec appears in the built console — no @objectstack/spec');
194+
console.error(' content matched. The injection is UNVERIFIED by this check.');
195+
process.exit(2);
196+
}
197+
198+
if (stalePresent === true) {
199+
console.error("✗ Built console still carries the PUBLISHED @objectstack/spec.");
200+
console.error(' The console resolved spec from objectui\'s lockfile, so any authorable');
201+
console.error(' key this framework declared after the last spec publish is unreachable');
202+
console.error(' in the Studio designer — the defect objectstack#8134 exists to end.');
203+
console.error('');
204+
console.error(' Text found in the bundle that ONLY the vendored spec has:');
205+
console.error(` "${staleDetector}"`);
206+
if (freshPresent === true) {
207+
console.error('');
208+
console.error(' Note: text unique to this tree\'s spec is ALSO in the bundle —');
209+
console.error(' a second, transitive copy (via the injected @objectstack/client).');
210+
console.error(' That copy is not what the designer imports; both must not coexist.');
211+
}
212+
process.exit(1);
213+
}
214+
215+
if (freshPresent !== true) {
216+
console.error('✗ The published spec is gone from the bundle, but nothing unique to');
217+
console.error(" this tree's spec was found either — the build is in an unexpected");
218+
console.error(' state and the injection is UNVERIFIED.');
219+
console.error(` expected: "${freshWitness}"`);
220+
process.exit(2);
221+
}
222+
223+
console.log("✓ Console bundle carries THIS tree's @objectstack/spec, and only it.");
224+
console.log(` present (injected only): "${freshWitness}"`);
225+
if (staleDetector) console.log(` absent (vendored only): "${staleDetector}"`);
226+
process.exit(0);

scripts/build-console.sh

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,11 @@ fi
4343

4444
REPO_URL="${OBJECTUI_REPO_URL:-https://github.com/objectstack-ai/objectui.git}"
4545
# The console app itself must NOT build through turbo: turbo v2 runs tasks in
46-
# strict env mode and strips undeclared vars, so OBJECTSTACK_CLIENT_DIST
47-
# (exported below) never reaches vite unless the pinned objectui SHA happens
48-
# to declare it in turbo.json. Build the workspace deps through turbo
49-
# (cacheable, env-independent), then invoke the console's own build script
50-
# directly so the env survives.
46+
# strict env mode and strips undeclared vars, so OBJECTSTACK_CLIENT_DIST and
47+
# OBJECTSTACK_SPEC_DIST (both exported below) never reach vite unless the pinned
48+
# objectui SHA happens to declare them in turbo.json. Build the workspace deps
49+
# through turbo (cacheable, env-independent), then invoke the console's own build
50+
# script directly so the env survives.
5151
DEPS_BUILD_CMD="${OBJECTUI_DEPS_BUILD_CMD:-pnpm exec turbo run build --filter=@object-ui/console^...}"
5252
BUILD_CMD="${OBJECTUI_BUILD_CMD:-pnpm --filter @object-ui/console run build}"
5353
# Post-build canary: a literal that only exists in an up-to-date bundled
@@ -161,6 +161,50 @@ fi
161161
export OBJECTSTACK_CLIENT_DIST="$CLIENT_PKG"
162162
echo "→ Console will bundle @objectstack/client from ${CLIENT_PKG}"
163163

164+
# ── Bundle THIS framework's spec ─────────────────────────────────────
165+
# The same class of skew as the client above, one level quieter. The console SPA
166+
# inlines @objectstack/spec, and left to itself the objectui build resolves it
167+
# from objectui's own lockfile under --frozen-lockfile — the last PUBLISHED spec,
168+
# never this workspace. So an authorable key added to packages/spec after that
169+
# publish is accepted and round-tripped by the server while the Studio designer
170+
# rejects it as an unrecognized key and refuses to auto-save, and the
171+
# framework-side card closes green because packages/spec's own pins all pass.
172+
# Reaching the key took three ordered cross-repo steps: spec publishes, objectui
173+
# refreshes its lockfile, this pin moves. Injecting this tree's spec collapses
174+
# all three (objectstack#8134, hook added in objectui#4854).
175+
#
176+
# objectui honors OBJECTSTACK_SPEC_DIST in apps/console/vite.config.ts; fail hard
177+
# if the pinned SHA predates that hook rather than silently drift — an unguarded
178+
# injection would quietly rebuild the exact silent skew it exists to end.
179+
SPEC_PKG="${FRAMEWORK_ROOT}/packages/spec"
180+
if ! grep -q "OBJECTSTACK_SPEC_DIST" "${BUILD_ROOT}/apps/console/vite.config.ts"; then
181+
echo "✗ objectui@${PINNED_SHA:0:12} has no OBJECTSTACK_SPEC_DIST hook in apps/console/vite.config.ts —"
182+
echo " the bundled spec would come from objectui's lockfile, not this framework, so"
183+
echo " any key this tree declares since the last spec publish would be unreachable"
184+
echo " in the Studio designer."
185+
echo " Bump .objectui-sha to a commit that includes the hook."
186+
exit 1
187+
fi
188+
# The hook resolves EVERY entry of the spec's exports map and refuses any whose
189+
# target is missing, so the package must be built before it is injected. Two
190+
# sentinels, because two different generators produce those targets:
191+
# dist/index.mjs is tsup's, and json-schema/openapi.json is `gen:openapi`'s — the
192+
# one export entry that does not live under dist/, is not committed, and is wiped
193+
# by a later `gen:schema` run. A guard keyed on dist/ alone sails past a tree
194+
# where that happened, and the hook then throws in the middle of the console build.
195+
#
196+
# Unlike the client's guard this deliberately does NOT key on a declaration file:
197+
# the hook resolves the `import` condition only, so a spec whose DTS pass never
198+
# ran (OS_SKIP_DTS, or a DTS crash) is still complete for the injection.
199+
SPEC_ESM="${SPEC_PKG}/dist/index.mjs"
200+
SPEC_OPENAPI="${SPEC_PKG}/json-schema/openapi.json"
201+
if [[ ! -f "$SPEC_ESM" || ! -f "$SPEC_OPENAPI" ]]; then
202+
echo "→ @objectstack/spec dist absent or incomplete — building it and its deps first..."
203+
(cd "$FRAMEWORK_ROOT" && pnpm exec turbo run build --filter=@objectstack/spec)
204+
fi
205+
export OBJECTSTACK_SPEC_DIST="$SPEC_PKG"
206+
echo "→ Console will bundle @objectstack/spec from ${SPEC_PKG}"
207+
164208
pushd "$BUILD_ROOT" > /dev/null
165209

166210
# 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
203247
fi
204248
echo "✓ Bundle canary '${BUNDLE_CANARY}' present — framework client is in the bundle."
205249

250+
# Assert the injected SPEC landed too. Deliberately NOT a frozen literal like
251+
# BUNDLE_CANARY above: "does the bundle carry the surface the framework declares
252+
# now" is a moving target, and any string pinned here would be carried by the
253+
# published spec within one release — after which it passes forever while proving
254+
# nothing, which is the same silent pass this injection exists to remove. The
255+
# script derives its probes from the two specs on disk on every run, and tests
256+
# BOTH directions: the console bundle also holds a second, transitive copy of
257+
# this tree's spec (pulled in through the injected client above), which makes a
258+
# one-sided "is the new text present" probe pass even with no injection at all.
259+
node "${FRAMEWORK_ROOT}/scripts/assert-console-spec-injection.mjs" \
260+
--injected "$SPEC_PKG" \
261+
--vendored "${BUILD_ROOT}/node_modules/@objectstack/spec" \
262+
--assets "${TARGET}/assets"
263+
206264
BYTES="$(du -sk "$TARGET" 2>/dev/null | awk '{print $1}')"
207265
echo "✓ @objectstack/console dist ready (${BYTES} KB) from objectui@${PINNED_SHA:0:12}"
208266

0 commit comments

Comments
 (0)