Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions packages/spec/api-surface-signatures.json
Original file line number Diff line number Diff line change
@@ -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"
}
171 changes: 107 additions & 64 deletions packages/spec/scripts/build-api-surface.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand All @@ -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<string, string[]> {
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<string, string[]> = {};
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<string, string> {
const out: Record<string, string> = {};
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<string, string[]> = 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<string, string[]> = 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<string, string> = 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);
Loading