|
| 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); |
0 commit comments