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 */
2329import ts from 'typescript' ;
30+ import { createHash } from 'node:crypto' ;
2431import { readFileSync , writeFileSync } from 'node:fs' ;
2532import { resolve } from 'node:path' ;
2633import { fileURLToPath } from 'node:url' ;
2734
2835const 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' ) ;
3038const 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. */
5583function 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 ( ! / ^ d e f i n e [ 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
88116if ( ! 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 .' ) ;
128171process . exit ( 1 ) ;
0 commit comments