Skip to content

Commit c189745

Browse files
xuyushun441-sysos-zhuangclaude
authored
ci(spec): signature-level snapshot for defineX factories — catch narrowing (#2035) (#2100)
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: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6ca20b3 commit c189745

2 files changed

Lines changed: 136 additions & 64 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"defineAction": "sha256:2965269f1fa49863",
3+
"defineAgent": "sha256:130533285b3deea4",
4+
"defineApp": "sha256:bef0d76d5076259a",
5+
"defineBook": "sha256:07a6e25c6be3f3bd",
6+
"defineConnector": "sha256:827b4a4bb56a83b5",
7+
"defineCube": "sha256:635855b04c960946",
8+
"defineDatasource": "sha256:e0ec3b5f9db26aca",
9+
"defineEmailTemplateDefinition": "sha256:a50aa92001c427ea",
10+
"defineFlow": "sha256:54b60bb867083f61",
11+
"defineForm": "sha256:e009563c8667cfc9",
12+
"defineJob": "sha256:04331c2df9a572eb",
13+
"defineMapping": "sha256:04c233ba6d0f5ab6",
14+
"defineObjectExtension": "sha256:5286253308912bb1",
15+
"definePage": "sha256:e80363c256809a11",
16+
"definePermissionSet": "sha256:67b188ae181994cd",
17+
"definePolicy": "sha256:f66d2be4a3d5fe98",
18+
"defineReport": "sha256:f7e85ba0996a5824",
19+
"defineRole": "sha256:40b8e11786c4ecbb",
20+
"defineSharingRule": "sha256:03347645bbda2880",
21+
"defineSkill": "sha256:4476c343f35b1521",
22+
"defineStack": "sha256:4d36d9603c011c44",
23+
"defineTheme": "sha256:2684b50ef808d236",
24+
"defineTool": "sha256:47ab5254a14f1cf5",
25+
"defineTranslationBundle": "sha256:2716798bbd575af2",
26+
"defineView": "sha256:a6abada0cf819dac",
27+
"defineViewItem": "sha256:7043cc1e7214215d",
28+
"defineWebhook": "sha256:1469ced0bffbddca"
29+
}
Lines changed: 107 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,40 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* build-api-surface.ts — snapshot the PUBLIC export surface of @objectstack/spec.
4+
* build-api-surface.ts — snapshot the PUBLIC API of @objectstack/spec.
55
*
66
* For a metadata-driven platform the spec package IS the third-party API. A
7-
* silently removed or renamed export breaks every downstream consumer pinned to
8-
* a published release the moment they upgrade — the #2023 class of break, which
9-
* no in-repo consumer can catch because they all co-evolve with the spec.
7+
* silently removed/renamed export, or a narrowed authoring signature, breaks
8+
* every consumer pinned to a published release the moment they upgrade (the
9+
* #2023 class) — and no in-repo consumer catches it, because they all co-evolve
10+
* with the spec in the same commit. See ADR-0059.
1011
*
11-
* This records, per public entry point (`.`, `./ui`, `./data`, …), every
12-
* exported `name (kind)` from the built `.d.ts`. The snapshot is committed at
13-
* `packages/spec/api-surface.json`.
12+
* Two committed artifacts, both checked in CI:
13+
* - api-surface.json — every exported `name (kind)` per public entry
14+
* point (breadth: did an export disappear?).
15+
* - api-surface-signatures.json — a stable hash of each `defineX` factory's
16+
* resolved signature (depth: did the accepted
17+
* authoring shape narrow?). Scoped to the
18+
* factories — the authoring contract — to stay
19+
* low-noise; full per-export signatures would
20+
* churn on every internal type tweak.
1421
*
1522
* pnpm --filter @objectstack/spec gen:api-surface # regenerate + write
1623
* pnpm --filter @objectstack/spec check:api-surface # CI: fail on any drift
1724
*
18-
* A REMOVED/renamed/kind-changed export is breaking (bump major). An ADDED
19-
* export is safe but still requires regenerating the snapshot — so every change
20-
* to the public surface is deliberate, never silent. Reads the built dist, so
21-
* run after `pnpm --filter @objectstack/spec build`.
25+
* A REMOVED export or a CHANGED factory signature is breaking (bump major). An
26+
* ADDED export still requires regenerating, so every change is deliberate. Reads
27+
* the built dist — run after `pnpm --filter @objectstack/spec build`.
2228
*/
2329
import ts from 'typescript';
30+
import { createHash } from 'node:crypto';
2431
import { readFileSync, writeFileSync } from 'node:fs';
2532
import { resolve } from 'node:path';
2633
import { fileURLToPath } from 'node:url';
2734

2835
const PKG_DIR = resolve(fileURLToPath(new URL('.', import.meta.url)), '..');
29-
const SNAPSHOT = resolve(PKG_DIR, 'api-surface.json');
36+
const SURFACE_SNAPSHOT = resolve(PKG_DIR, 'api-surface.json');
37+
const SIG_SNAPSHOT = resolve(PKG_DIR, 'api-surface-signatures.json');
3038
const CHECK = process.argv.includes('--check');
3139

3240
/** Public entry points → their built CJS `.d.ts`, read from the exports map. */
@@ -52,77 +60,112 @@ function kindOf(flags: ts.SymbolFlags): string {
5260
return 'other';
5361
}
5462

63+
const entries = collectEntries();
64+
const program = ts.createProgram(Object.values(entries), {
65+
module: ts.ModuleKind.NodeNext,
66+
moduleResolution: ts.ModuleResolutionKind.NodeNext,
67+
skipLibCheck: true,
68+
noEmit: true,
69+
});
70+
const checker = program.getTypeChecker();
71+
72+
function moduleExports(file: string, sub: string): ts.Symbol[] {
73+
const sf = program.getSourceFile(file);
74+
const sym = sf && checker.getSymbolAtLocation(sf);
75+
if (!sym) throw new Error(`Could not resolve module symbol for ${sub} (${file}). Is the package built?`);
76+
return checker.getExportsOfModule(sym);
77+
}
78+
79+
const unalias = (s: ts.Symbol): ts.Symbol =>
80+
s.getFlags() & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(s) : s;
81+
82+
/** Breadth: `name (kind)` per entry point. */
5583
function buildSurface(): Record<string, string[]> {
56-
const entries = collectEntries();
57-
const program = ts.createProgram(Object.values(entries), {
58-
module: ts.ModuleKind.NodeNext,
59-
moduleResolution: ts.ModuleResolutionKind.NodeNext,
60-
skipLibCheck: true,
61-
noEmit: true,
62-
});
63-
const checker = program.getTypeChecker();
6484
const surface: Record<string, string[]> = {};
6585
for (const [sub, file] of Object.entries(entries)) {
66-
const sf = program.getSourceFile(file);
67-
const sym = sf && checker.getSymbolAtLocation(sf);
68-
if (!sym) {
69-
throw new Error(`Could not resolve module symbol for ${sub} (${file}). Is the package built?`);
70-
}
71-
surface[sub] = checker
72-
.getExportsOfModule(sym)
73-
.map((s) => {
74-
// Re-exported symbols (`export { x } from './y'`) are alias symbols;
75-
// resolve to the target so the kind reflects the real declaration.
76-
const resolved = s.getFlags() & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(s) : s;
77-
return `${s.getName()} (${kindOf(resolved.getFlags())})`;
78-
})
86+
surface[sub] = moduleExports(file, sub)
87+
.map((s) => `${s.getName()} (${kindOf(unalias(s).getFlags())})`)
7988
// Code-unit sort (NOT localeCompare): deterministic across CI platforms.
8089
.sort();
8190
}
8291
return surface;
8392
}
8493

85-
const current = buildSurface();
86-
const serialized = JSON.stringify(current, null, 2) + '\n';
94+
/** Depth: hash of each `defineX` factory's resolved signature (from root). */
95+
function buildSignatures(): Record<string, string> {
96+
const out: Record<string, string> = {};
97+
for (const s of moduleExports(entries['.'], '.')) {
98+
const name = s.getName();
99+
if (!/^define[A-Z]/.test(name)) continue;
100+
const resolved = unalias(s);
101+
if (!(resolved.getFlags() & ts.SymbolFlags.Function)) continue;
102+
const decl = resolved.valueDeclaration ?? resolved.declarations?.[0];
103+
if (!decl) continue;
104+
const type = checker.getTypeOfSymbolAtLocation(resolved, decl);
105+
const str = checker.typeToString(type, decl, ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.InTypeAlias);
106+
out[name] = 'sha256:' + createHash('sha256').update(str).digest('hex').slice(0, 16);
107+
}
108+
return Object.fromEntries(Object.keys(out).sort().map((k) => [k, out[k]]));
109+
}
110+
111+
const surface = buildSurface();
112+
const signatures = buildSignatures();
113+
const surfaceStr = JSON.stringify(surface, null, 2) + '\n';
114+
const sigStr = JSON.stringify(signatures, null, 2) + '\n';
87115

88116
if (!CHECK) {
89-
writeFileSync(SNAPSHOT, serialized);
90-
const total = Object.values(current).reduce((n, a) => n + a.length, 0);
91-
console.log(`Wrote ${SNAPSHOT}${Object.keys(current).length} entries, ${total} exports.`);
117+
writeFileSync(SURFACE_SNAPSHOT, surfaceStr);
118+
writeFileSync(SIG_SNAPSHOT, sigStr);
119+
const total = Object.values(surface).reduce((n, a) => n + a.length, 0);
120+
console.log(`Wrote api-surface.json (${Object.keys(surface).length} entries, ${total} exports) and api-surface-signatures.json (${Object.keys(signatures).length} factories).`);
92121
process.exit(0);
93122
}
94123

95-
let previous = '';
96-
try {
97-
previous = readFileSync(SNAPSHOT, 'utf8');
98-
} catch {
99-
console.error('No api-surface.json snapshot found. Run `pnpm --filter @objectstack/spec gen:api-surface` and commit it.');
100-
process.exit(1);
124+
function read(path: string, hint: string): string {
125+
try {
126+
return readFileSync(path, 'utf8');
127+
} catch {
128+
console.error(`No snapshot at ${path}. Run \`pnpm --filter @objectstack/spec gen:api-surface\` and commit it (${hint}).`);
129+
process.exit(1);
130+
}
101131
}
102132

103-
if (previous === serialized) {
104-
console.log('@objectstack/spec public API surface unchanged ✓');
105-
process.exit(0);
133+
let breaking = 0;
134+
let additions = 0;
135+
136+
// Breadth check.
137+
const prevSurface: Record<string, string[]> = JSON.parse(read(SURFACE_SNAPSHOT, 'breadth'));
138+
if (JSON.stringify(prevSurface, null, 2) + '\n' !== surfaceStr) {
139+
for (const sub of new Set([...Object.keys(prevSurface), ...Object.keys(surface)])) {
140+
const before = new Set(prevSurface[sub] ?? []);
141+
const after = new Set(surface[sub] ?? []);
142+
const gone = [...before].filter((x) => !after.has(x));
143+
const fresh = [...after].filter((x) => !before.has(x));
144+
if (!gone.length && !fresh.length) continue;
145+
console.error(`\n ${sub}`);
146+
for (const g of gone) { console.error(` - ${g}`); breaking++; }
147+
for (const f of fresh) { console.error(` + ${f}`); additions++; }
148+
}
106149
}
107150

108-
// Drift — report removed (breaking) and added (review) per entry point.
109-
const prev: Record<string, string[]> = JSON.parse(previous);
110-
let removed = 0;
111-
let added = 0;
112-
for (const sub of new Set([...Object.keys(prev), ...Object.keys(current)])) {
113-
const before = new Set(prev[sub] ?? []);
114-
const after = new Set(current[sub] ?? []);
115-
const gone = [...before].filter((x) => !after.has(x));
116-
const fresh = [...after].filter((x) => !before.has(x));
117-
if (!gone.length && !fresh.length) continue;
118-
console.error(`\n ${sub}`);
119-
for (const g of gone) { console.error(` - ${g}`); removed++; }
120-
for (const f of fresh) { console.error(` + ${f}`); added++; }
151+
// Depth check (factory signatures).
152+
const prevSig: Record<string, string> = JSON.parse(read(SIG_SNAPSHOT, 'depth'));
153+
if (JSON.stringify(prevSig, null, 2) + '\n' !== sigStr) {
154+
for (const name of new Set([...Object.keys(prevSig), ...Object.keys(signatures)])) {
155+
if (!(name in signatures)) { console.error(`\n signature removed: ${name}`); breaking++; }
156+
else if (!(name in prevSig)) { console.error(`\n signature added: ${name}`); additions++; }
157+
else if (prevSig[name] !== signatures[name]) { console.error(`\n signature changed: ${name} (${prevSig[name]}${signatures[name]})`); breaking++; }
158+
}
159+
}
160+
161+
if (breaking === 0 && additions === 0) {
162+
console.log('@objectstack/spec public API surface + factory signatures unchanged ✓');
163+
process.exit(0);
121164
}
122165

123-
console.error(`\n@objectstack/spec public API surface changed: ${removed} removed, ${added} added.`);
124-
if (removed > 0) {
125-
console.error('REMOVED/renamed exports are a BREAKING change for third parties — bump @objectstack/spec to a new major (or restore the export).');
166+
console.error(`\n@objectstack/spec public API changed: ${breaking} breaking (removed/narrowed), ${additions} added.`);
167+
if (breaking > 0) {
168+
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).');
126169
}
127-
console.error('If this is intentional, run `pnpm --filter @objectstack/spec gen:api-surface` and commit the updated snapshot.');
170+
console.error('If intentional, run `pnpm --filter @objectstack/spec gen:api-surface` and commit the updated snapshots.');
128171
process.exit(1);

0 commit comments

Comments
 (0)