From ad642abba2811bbadc08014c625fc6d4e946eb98 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 21 Jun 2026 14:47:35 +0800 Subject: [PATCH] =?UTF-8?q?ci(spec):=20signature-level=20snapshot=20for=20?= =?UTF-8?q?defineX=20factories=20=E2=80=94=20catch=20narrowing=20(#2035)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The api-surface snapshot (#2092) catches add/remove/rename but not NARROWING — a `.default()` that flips an input field to required, or a `string` tightened to an enum, keeps the same `name (kind)`. The downstream-contract fixtures catch narrowing only for the fields they happen to set. Adds api-surface-signatures.json: a stable hash of each defineX factory's resolved signature (27 factories). The factory signature embeds the full accepted authoring shape, so any narrowing of what a domain accepts flips its hash. Scoped to the factories — the authoring contract — to stay low-noise: the breadth file (4251 exports) is unchanged, and full per-export signatures would churn on every internal type tweak. Rides the existing check:api-surface CI step (one command checks both). Verified: check clean, deterministic across regens (code-unit/structural, no locale), breadth snapshot byte-identical to main, and a flipped hash is reported as a breaking "signature changed" with exit 1. Co-Authored-By: Claude Opus 4.8 --- packages/spec/api-surface-signatures.json | 29 ++++ packages/spec/scripts/build-api-surface.ts | 171 +++++++++++++-------- 2 files changed, 136 insertions(+), 64 deletions(-) create mode 100644 packages/spec/api-surface-signatures.json diff --git a/packages/spec/api-surface-signatures.json b/packages/spec/api-surface-signatures.json new file mode 100644 index 0000000000..bba6ab1e20 --- /dev/null +++ b/packages/spec/api-surface-signatures.json @@ -0,0 +1,29 @@ +{ + "defineAction": "sha256:2965269f1fa49863", + "defineAgent": "sha256:130533285b3deea4", + "defineApp": "sha256:bef0d76d5076259a", + "defineBook": "sha256:07a6e25c6be3f3bd", + "defineConnector": "sha256:827b4a4bb56a83b5", + "defineCube": "sha256:635855b04c960946", + "defineDatasource": "sha256:e0ec3b5f9db26aca", + "defineEmailTemplateDefinition": "sha256:a50aa92001c427ea", + "defineFlow": "sha256:54b60bb867083f61", + "defineForm": "sha256:e009563c8667cfc9", + "defineJob": "sha256:04331c2df9a572eb", + "defineMapping": "sha256:04c233ba6d0f5ab6", + "defineObjectExtension": "sha256:5286253308912bb1", + "definePage": "sha256:e80363c256809a11", + "definePermissionSet": "sha256:67b188ae181994cd", + "definePolicy": "sha256:f66d2be4a3d5fe98", + "defineReport": "sha256:f7e85ba0996a5824", + "defineRole": "sha256:40b8e11786c4ecbb", + "defineSharingRule": "sha256:03347645bbda2880", + "defineSkill": "sha256:4476c343f35b1521", + "defineStack": "sha256:4d36d9603c011c44", + "defineTheme": "sha256:2684b50ef808d236", + "defineTool": "sha256:47ab5254a14f1cf5", + "defineTranslationBundle": "sha256:2716798bbd575af2", + "defineView": "sha256:a6abada0cf819dac", + "defineViewItem": "sha256:7043cc1e7214215d", + "defineWebhook": "sha256:1469ced0bffbddca" +} diff --git a/packages/spec/scripts/build-api-surface.ts b/packages/spec/scripts/build-api-surface.ts index 65a0c5669c..1e303586f2 100644 --- a/packages/spec/scripts/build-api-surface.ts +++ b/packages/spec/scripts/build-api-surface.ts @@ -1,32 +1,40 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** - * build-api-surface.ts — snapshot the PUBLIC export surface of @objectstack/spec. + * build-api-surface.ts — snapshot the PUBLIC API of @objectstack/spec. * * For a metadata-driven platform the spec package IS the third-party API. A - * silently removed or renamed export breaks every downstream consumer pinned to - * a published release the moment they upgrade — the #2023 class of break, which - * no in-repo consumer can catch because they all co-evolve with the spec. + * silently removed/renamed export, or a narrowed authoring signature, breaks + * every consumer pinned to a published release the moment they upgrade (the + * #2023 class) — and no in-repo consumer catches it, because they all co-evolve + * with the spec in the same commit. See ADR-0059. * - * This records, per public entry point (`.`, `./ui`, `./data`, …), every - * exported `name (kind)` from the built `.d.ts`. The snapshot is committed at - * `packages/spec/api-surface.json`. + * Two committed artifacts, both checked in CI: + * - api-surface.json — every exported `name (kind)` per public entry + * point (breadth: did an export disappear?). + * - api-surface-signatures.json — a stable hash of each `defineX` factory's + * resolved signature (depth: did the accepted + * authoring shape narrow?). Scoped to the + * factories — the authoring contract — to stay + * low-noise; full per-export signatures would + * churn on every internal type tweak. * * pnpm --filter @objectstack/spec gen:api-surface # regenerate + write * pnpm --filter @objectstack/spec check:api-surface # CI: fail on any drift * - * A REMOVED/renamed/kind-changed export is breaking (bump major). An ADDED - * export is safe but still requires regenerating the snapshot — so every change - * to the public surface is deliberate, never silent. Reads the built dist, so - * run after `pnpm --filter @objectstack/spec build`. + * A REMOVED export or a CHANGED factory signature is breaking (bump major). An + * ADDED export still requires regenerating, so every change is deliberate. Reads + * the built dist — run after `pnpm --filter @objectstack/spec build`. */ import ts from 'typescript'; +import { createHash } from 'node:crypto'; import { readFileSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const PKG_DIR = resolve(fileURLToPath(new URL('.', import.meta.url)), '..'); -const SNAPSHOT = resolve(PKG_DIR, 'api-surface.json'); +const SURFACE_SNAPSHOT = resolve(PKG_DIR, 'api-surface.json'); +const SIG_SNAPSHOT = resolve(PKG_DIR, 'api-surface-signatures.json'); const CHECK = process.argv.includes('--check'); /** Public entry points → their built CJS `.d.ts`, read from the exports map. */ @@ -52,77 +60,112 @@ function kindOf(flags: ts.SymbolFlags): string { return 'other'; } +const entries = collectEntries(); +const program = ts.createProgram(Object.values(entries), { + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + skipLibCheck: true, + noEmit: true, +}); +const checker = program.getTypeChecker(); + +function moduleExports(file: string, sub: string): ts.Symbol[] { + const sf = program.getSourceFile(file); + const sym = sf && checker.getSymbolAtLocation(sf); + if (!sym) throw new Error(`Could not resolve module symbol for ${sub} (${file}). Is the package built?`); + return checker.getExportsOfModule(sym); +} + +const unalias = (s: ts.Symbol): ts.Symbol => + s.getFlags() & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(s) : s; + +/** Breadth: `name (kind)` per entry point. */ function buildSurface(): Record { - const entries = collectEntries(); - const program = ts.createProgram(Object.values(entries), { - module: ts.ModuleKind.NodeNext, - moduleResolution: ts.ModuleResolutionKind.NodeNext, - skipLibCheck: true, - noEmit: true, - }); - const checker = program.getTypeChecker(); const surface: Record = {}; for (const [sub, file] of Object.entries(entries)) { - const sf = program.getSourceFile(file); - const sym = sf && checker.getSymbolAtLocation(sf); - if (!sym) { - throw new Error(`Could not resolve module symbol for ${sub} (${file}). Is the package built?`); - } - surface[sub] = checker - .getExportsOfModule(sym) - .map((s) => { - // Re-exported symbols (`export { x } from './y'`) are alias symbols; - // resolve to the target so the kind reflects the real declaration. - const resolved = s.getFlags() & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(s) : s; - return `${s.getName()} (${kindOf(resolved.getFlags())})`; - }) + surface[sub] = moduleExports(file, sub) + .map((s) => `${s.getName()} (${kindOf(unalias(s).getFlags())})`) // Code-unit sort (NOT localeCompare): deterministic across CI platforms. .sort(); } return surface; } -const current = buildSurface(); -const serialized = JSON.stringify(current, null, 2) + '\n'; +/** Depth: hash of each `defineX` factory's resolved signature (from root). */ +function buildSignatures(): Record { + const out: Record = {}; + for (const s of moduleExports(entries['.'], '.')) { + const name = s.getName(); + if (!/^define[A-Z]/.test(name)) continue; + const resolved = unalias(s); + if (!(resolved.getFlags() & ts.SymbolFlags.Function)) continue; + const decl = resolved.valueDeclaration ?? resolved.declarations?.[0]; + if (!decl) continue; + const type = checker.getTypeOfSymbolAtLocation(resolved, decl); + const str = checker.typeToString(type, decl, ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.InTypeAlias); + out[name] = 'sha256:' + createHash('sha256').update(str).digest('hex').slice(0, 16); + } + return Object.fromEntries(Object.keys(out).sort().map((k) => [k, out[k]])); +} + +const surface = buildSurface(); +const signatures = buildSignatures(); +const surfaceStr = JSON.stringify(surface, null, 2) + '\n'; +const sigStr = JSON.stringify(signatures, null, 2) + '\n'; if (!CHECK) { - writeFileSync(SNAPSHOT, serialized); - const total = Object.values(current).reduce((n, a) => n + a.length, 0); - console.log(`Wrote ${SNAPSHOT} — ${Object.keys(current).length} entries, ${total} exports.`); + writeFileSync(SURFACE_SNAPSHOT, surfaceStr); + writeFileSync(SIG_SNAPSHOT, sigStr); + const total = Object.values(surface).reduce((n, a) => n + a.length, 0); + console.log(`Wrote api-surface.json (${Object.keys(surface).length} entries, ${total} exports) and api-surface-signatures.json (${Object.keys(signatures).length} factories).`); process.exit(0); } -let previous = ''; -try { - previous = readFileSync(SNAPSHOT, 'utf8'); -} catch { - console.error('No api-surface.json snapshot found. Run `pnpm --filter @objectstack/spec gen:api-surface` and commit it.'); - process.exit(1); +function read(path: string, hint: string): string { + try { + return readFileSync(path, 'utf8'); + } catch { + console.error(`No snapshot at ${path}. Run \`pnpm --filter @objectstack/spec gen:api-surface\` and commit it (${hint}).`); + process.exit(1); + } } -if (previous === serialized) { - console.log('@objectstack/spec public API surface unchanged ✓'); - process.exit(0); +let breaking = 0; +let additions = 0; + +// Breadth check. +const prevSurface: Record = JSON.parse(read(SURFACE_SNAPSHOT, 'breadth')); +if (JSON.stringify(prevSurface, null, 2) + '\n' !== surfaceStr) { + for (const sub of new Set([...Object.keys(prevSurface), ...Object.keys(surface)])) { + const before = new Set(prevSurface[sub] ?? []); + const after = new Set(surface[sub] ?? []); + const gone = [...before].filter((x) => !after.has(x)); + const fresh = [...after].filter((x) => !before.has(x)); + if (!gone.length && !fresh.length) continue; + console.error(`\n ${sub}`); + for (const g of gone) { console.error(` - ${g}`); breaking++; } + for (const f of fresh) { console.error(` + ${f}`); additions++; } + } } -// Drift — report removed (breaking) and added (review) per entry point. -const prev: Record = JSON.parse(previous); -let removed = 0; -let added = 0; -for (const sub of new Set([...Object.keys(prev), ...Object.keys(current)])) { - const before = new Set(prev[sub] ?? []); - const after = new Set(current[sub] ?? []); - const gone = [...before].filter((x) => !after.has(x)); - const fresh = [...after].filter((x) => !before.has(x)); - if (!gone.length && !fresh.length) continue; - console.error(`\n ${sub}`); - for (const g of gone) { console.error(` - ${g}`); removed++; } - for (const f of fresh) { console.error(` + ${f}`); added++; } +// Depth check (factory signatures). +const prevSig: Record = JSON.parse(read(SIG_SNAPSHOT, 'depth')); +if (JSON.stringify(prevSig, null, 2) + '\n' !== sigStr) { + for (const name of new Set([...Object.keys(prevSig), ...Object.keys(signatures)])) { + if (!(name in signatures)) { console.error(`\n signature removed: ${name}`); breaking++; } + else if (!(name in prevSig)) { console.error(`\n signature added: ${name}`); additions++; } + else if (prevSig[name] !== signatures[name]) { console.error(`\n signature changed: ${name} (${prevSig[name]} → ${signatures[name]})`); breaking++; } + } +} + +if (breaking === 0 && additions === 0) { + console.log('@objectstack/spec public API surface + factory signatures unchanged ✓'); + process.exit(0); } -console.error(`\n@objectstack/spec public API surface changed: ${removed} removed, ${added} added.`); -if (removed > 0) { - console.error('REMOVED/renamed exports are a BREAKING change for third parties — bump @objectstack/spec to a new major (or restore the export).'); +console.error(`\n@objectstack/spec public API changed: ${breaking} breaking (removed/narrowed), ${additions} added.`); +if (breaking > 0) { + console.error('A REMOVED export or a CHANGED factory signature is a BREAKING change for third parties — bump @objectstack/spec to a new major (or restore it).'); } -console.error('If this is intentional, run `pnpm --filter @objectstack/spec gen:api-surface` and commit the updated snapshot.'); +console.error('If intentional, run `pnpm --filter @objectstack/spec gen:api-surface` and commit the updated snapshots.'); process.exit(1);