11#!/usr/bin/env node
22
33/**
4- This script uses the JSON file api-snapshot /api.json to validate that previously present symbols
4+ This script uses the JSON file scripts/validation /api.json to validate that previously present symbols
55are still exported by the packages within the assessed group.
66
77Data may only be deleted from api.json in an intentional backwards-incompatible change.
88 */
99
1010const fs = require ( "node:fs" ) ;
1111const path = require ( "node:path" ) ;
12+ const ts = require ( "typescript" ) ;
1213
1314const root = path . join ( __dirname , ".." , ".." ) ;
1415const dataPath = path . join ( root , "scripts" , "validation" , "api.json" ) ;
@@ -23,50 +24,150 @@ const packageDirs = [
2324] ;
2425const errors = [ ] ;
2526
27+ // Collect all .d.ts entry points upfront.
28+ const dtsEntries = [ ] ; // { dtsPath, name, version, cjsPath }
29+
2630for ( const packageRoot of packageDirs ) {
2731 const pkgJsonPath = path . join ( packageRoot , "package.json" ) ;
2832 const cjsPath = path . join ( packageRoot , "dist-cjs" , "index.js" ) ;
2933
3034 if ( fs . existsSync ( pkgJsonPath ) && fs . existsSync ( cjsPath ) ) {
3135 const packageJson = require ( pkgJsonPath ) ;
3236 const { name, version } = packageJson ;
33- const module = require ( cjsPath ) ;
3437
35- for ( const key of Object . keys ( module ) ) {
36- if ( module [ key ] === undefined ) {
37- console . warn ( `symbol ${ key } in ${ name } @${ version } has a value of undefined.` ) ;
38+ const dtsPath = path . join ( packageRoot , packageJson . types || "dist-types/index.d.ts" ) ;
39+ if ( fs . existsSync ( dtsPath ) ) {
40+ dtsEntries . push ( { dtsPath, name, version, cjsPath } ) ;
41+ }
42+
43+ if ( packageJson . exports ) {
44+ for ( const [ exportPath , exportConfig ] of Object . entries ( packageJson . exports ) ) {
45+ if ( exportPath === "." || exportPath === "./package.json" ) continue ;
46+ const subCjsPath = path . join ( packageRoot , exportConfig . require || exportConfig . node ) ;
47+ const subDtsPath = path . join ( packageRoot , exportConfig . types || "" ) ;
48+ if ( fs . existsSync ( subCjsPath ) && fs . existsSync ( subDtsPath ) ) {
49+ const subName = `${ name } /${ exportPath . replace ( "./" , "" ) } ` ;
50+ dtsEntries . push ( { dtsPath : subDtsPath , name : subName , version, cjsPath : subCjsPath } ) ;
51+ }
3852 }
3953 }
54+ }
55+ }
4056
41- if ( ! api [ name ] ) {
42- api [ name ] = { } ;
43- for ( const key of Object . keys ( module ) ) {
44- api [ name ] [ key ] = [ typeof module [ key ] , `<=${ version } ` ] . join ( ", since " ) ;
57+ // Create a single TypeScript program with all entry points.
58+ const allDtsPaths = dtsEntries . map ( ( e ) => e . dtsPath ) ;
59+ const program = ts . createProgram ( allDtsPaths , {
60+ moduleResolution : ts . ModuleResolutionKind . NodeJs ,
61+ baseUrl : root ,
62+ } ) ;
63+ const checker = program . getTypeChecker ( ) ;
64+
65+ /**
66+ * Extract type-only exports from a .d.ts entry point using the shared program.
67+ */
68+ function getTypeExports ( dtsPath ) {
69+ const typeExports = new Map ( ) ;
70+ const sourceFile = program . getSourceFile ( dtsPath ) ;
71+ if ( ! sourceFile ) return typeExports ;
72+
73+ const moduleSymbol = checker . getSymbolAtLocation ( sourceFile ) ;
74+ if ( ! moduleSymbol ) return typeExports ;
75+
76+ const exports = checker . getExportsOfModule ( moduleSymbol ) ;
77+ for ( const sym of exports ) {
78+ let resolved = sym ;
79+ if ( resolved . flags & ts . SymbolFlags . Alias ) {
80+ resolved = checker . getAliasedSymbol ( resolved ) ;
81+ }
82+ const flags = resolved . flags ;
83+ const isTypeOnly =
84+ ! ! ( flags & ts . SymbolFlags . Interface ) ||
85+ ! ! ( flags & ts . SymbolFlags . TypeAlias ) ||
86+ ( ! ! ( flags & ts . SymbolFlags . Enum ) && ! ( flags & ts . SymbolFlags . RegularEnum ) ) ;
87+ if ( isTypeOnly ) {
88+ let kind = "type" ;
89+ if ( flags & ts . SymbolFlags . Interface ) {
90+ kind = "type(interface)" ;
91+ } else if ( flags & ts . SymbolFlags . TypeAlias ) {
92+ const type = checker . getDeclaredTypeOfSymbol ( resolved ) ;
93+ if ( type . isUnion ( ) ) kind = "type(union)" ;
94+ else if ( type . isIntersection ( ) ) kind = "type(intersection)" ;
95+ else if ( type . flags & ts . TypeFlags . String ) kind = "type(string)" ;
96+ else if ( type . flags & ts . TypeFlags . Number ) kind = "type(number)" ;
97+ else if ( type . flags & ts . TypeFlags . Boolean ) kind = "type(boolean)" ;
98+ else if ( type . flags & ts . TypeFlags . Object ) kind = "type(object)" ;
99+ else kind = "type(alias)" ;
100+ } else {
101+ kind = "type(enum)" ;
45102 }
46- } else {
47- for ( const symbol of [ ...new Set ( [ ...Object . keys ( api [ name ] ) , ...Object . keys ( module ) ] ) ] ) {
48- if ( symbol in module && ! ( symbol in api [ name ] ) ) {
49- errors . push ( `You must commit changes in api.json.` ) ;
50- api [ name ] [ symbol ] = [ typeof module [ symbol ] , version ] . join ( ", since " ) ;
51- }
52- if ( ! ( symbol in module ) && symbol in api [ name ] ) {
53- errors . push ( `Symbol [${ symbol } ] is missing from ${ name } , (${ api [ name ] [ symbol ] } ).` ) ;
54- }
55- if ( symbol in module && symbol in api [ name ] ) {
56- if ( api [ name ] [ symbol ] . split ( ", " ) [ 0 ] !== typeof module [ symbol ] ) {
57- errors . push (
58- `Symbol [${ symbol } ] has a different type than expected in ${ name } , actual=${ typeof module [
59- symbol
60- ] } expected=${ api [ name ] [ symbol ] } .`
61- ) ;
62- }
103+ typeExports . set ( sym . getName ( ) , kind ) ;
104+ }
105+ }
106+ return typeExports ;
107+ }
108+
109+ // Process each entry.
110+ for ( const { dtsPath, name, version, cjsPath } of dtsEntries ) {
111+ const module = require ( cjsPath ) ;
112+ const typeExports = getTypeExports ( dtsPath ) ;
113+ checkModule ( name , version , module , typeExports ) ;
114+ }
115+
116+ function checkModule ( name , version , module , typeExports ) {
117+ for ( const key of Object . keys ( module ) ) {
118+ if ( module [ key ] === undefined ) {
119+ console . warn ( `symbol ${ key } in ${ name } @${ version } has a value of undefined.` ) ;
120+ }
121+ }
122+
123+ // Merge runtime keys and type-only keys into a combined snapshot.
124+ const allSymbols = new Set ( [ ...Object . keys ( module ) , ...typeExports . keys ( ) ] ) ;
125+
126+ if ( ! api [ name ] ) {
127+ api [ name ] = { } ;
128+ for ( const key of allSymbols ) {
129+ api [ name ] [ key ] = key in module ? typeof module [ key ] : typeExports . get ( key ) ;
130+ }
131+ } else {
132+ for ( const symbol of [ ...new Set ( [ ...Object . keys ( api [ name ] ) , ...allSymbols ] ) ] ) {
133+ const inRuntime = symbol in module ;
134+ const inTypes = typeExports . has ( symbol ) ;
135+ const inSnapshot = symbol in api [ name ] ;
136+ const inCurrent = inRuntime || inTypes ;
137+
138+ if ( inCurrent && ! inSnapshot ) {
139+ const kind = inRuntime ? typeof module [ symbol ] : typeExports . get ( symbol ) ;
140+ errors . push ( `You must commit changes in api.json adding ${ symbol } (${ kind } ) to ${ name } .` ) ;
141+ api [ name ] [ symbol ] = kind ;
142+ }
143+ if ( ! inCurrent && inSnapshot ) {
144+ errors . push ( `Symbol [${ symbol } ] is missing from ${ name } , (${ api [ name ] [ symbol ] } ).` ) ;
145+ }
146+ if ( inCurrent && inSnapshot ) {
147+ const expectedKind = api [ name ] [ symbol ] ;
148+ const actualKind = inRuntime ? typeof module [ symbol ] : typeExports . get ( symbol ) ;
149+ if ( expectedKind !== actualKind ) {
150+ errors . push (
151+ `Symbol [${ symbol } ] has a different type than expected in ${ name } , actual=${ actualKind } expected=${ expectedKind } .`
152+ ) ;
63153 }
64154 }
65155 }
66156 }
67157}
68158
69- fs . writeFileSync ( dataPath , JSON . stringify ( api , null , 2 ) + "\n" ) ;
159+ const sorted = Object . fromEntries (
160+ Object . entries ( api )
161+ . sort ( ( [ a ] , [ b ] ) => a . localeCompare ( b ) )
162+ . map ( ( [ k , v ] ) => [
163+ k ,
164+ typeof v === "object" && v !== null
165+ ? Object . fromEntries ( Object . entries ( v ) . sort ( ( [ a ] , [ b ] ) => a . localeCompare ( b ) ) )
166+ : v ,
167+ ] )
168+ ) ;
169+
170+ fs . writeFileSync ( dataPath , JSON . stringify ( sorted , null , 2 ) + "\n" ) ;
70171
71172if ( errors . length ) {
72173 throw new Error ( errors . join ( "\n" ) ) ;
0 commit comments