tsrx-typegen 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,594 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path'
5
+
6
+ const moduleSpecifierNodes = (ts, sourceFile) => {
7
+ const nodes = []
8
+ const visit = (node) => {
9
+ if (
10
+ (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
11
+ node.moduleSpecifier &&
12
+ ts.isStringLiteralLike(node.moduleSpecifier)
13
+ ) {
14
+ nodes.push(node.moduleSpecifier)
15
+ } else if (ts.isImportTypeNode(node)) {
16
+ const argument = node.argument
17
+ if (ts.isLiteralTypeNode(argument) && ts.isStringLiteralLike(argument.literal)) {
18
+ nodes.push(argument.literal)
19
+ }
20
+ } else if (
21
+ ts.isExternalModuleReference(node) &&
22
+ node.expression &&
23
+ ts.isStringLiteral(node.expression)
24
+ ) {
25
+ nodes.push(node.expression)
26
+ }
27
+
28
+ ts.forEachChild(node, visit)
29
+ }
30
+
31
+ visit(sourceFile)
32
+ return nodes
33
+ }
34
+
35
+ const pathInside = (root, file) => {
36
+ const rel = relative(root, file)
37
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))
38
+ }
39
+
40
+ const stringsIn = (value) => {
41
+ if (typeof value === 'string') {
42
+ return [value]
43
+ }
44
+
45
+ if (!value || typeof value !== 'object') {
46
+ return []
47
+ }
48
+
49
+ return Object.values(value).flatMap(stringsIn)
50
+ }
51
+
52
+ const codeTarget = (target) =>
53
+ /\.(?:[cm]?[jt]sx?|tsrx)$/.test(target) && !/\.d\.[cm]?ts$/.test(target)
54
+
55
+ function exportBranches(exportsMap, fallbackTypes = []) {
56
+ const branches = []
57
+ for (const [subpath, mapping] of Object.entries(exportsMap ?? {})) {
58
+ const visit = (value, inheritedTypes) => {
59
+ if (typeof value === 'string') {
60
+ if (codeTarget(value)) {
61
+ branches.push({ subpath, runtime: value, types: inheritedTypes })
62
+ }
63
+
64
+ return
65
+ }
66
+
67
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
68
+ return
69
+ }
70
+
71
+ const types = Object.hasOwn(value, 'types') ? stringsIn(value.types) : inheritedTypes
72
+ for (const [condition, child] of Object.entries(value)) {
73
+ if (condition !== 'types') {
74
+ visit(child, types)
75
+ }
76
+ }
77
+ }
78
+
79
+ visit(mapping, fallbackTypes)
80
+ }
81
+
82
+ return branches
83
+ }
84
+
85
+ function packageFiles(root) {
86
+ const files = []
87
+ const visit = (dir) => {
88
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
89
+ if (entry.name === 'node_modules') {
90
+ continue
91
+ }
92
+
93
+ const file = join(dir, entry.name)
94
+ if (entry.isDirectory()) {
95
+ visit(file)
96
+ } else {
97
+ files.push(file)
98
+ }
99
+ }
100
+ }
101
+
102
+ visit(root)
103
+ return files
104
+ }
105
+
106
+ function expandTarget(target, files, packageRoot) {
107
+ if (!target.includes('*')) {
108
+ return [{ path: target, capture: undefined }]
109
+ }
110
+
111
+ if ((target.match(/\*/g) ?? []).length !== 1) {
112
+ throw new Error(`packed export target uses more than one wildcard: ${target}`)
113
+ }
114
+
115
+ if (!target.startsWith('./')) {
116
+ throw new Error(`packed export target must be package-relative: ${target}`)
117
+ }
118
+
119
+ const pattern = target.slice(2)
120
+ const marker = pattern.indexOf('*')
121
+ const prefix = pattern.slice(0, marker)
122
+ const suffix = pattern.slice(marker + 1)
123
+ const matches = files.flatMap((file) => {
124
+ const path = relative(packageRoot, file).replaceAll('\\', '/')
125
+ if (
126
+ !path.startsWith(prefix) ||
127
+ !path.endsWith(suffix) ||
128
+ path.length < prefix.length + suffix.length
129
+ ) {
130
+ return []
131
+ }
132
+
133
+ const capture = path.slice(prefix.length, path.length - suffix.length)
134
+ return [{ path: `./${path}`, capture }]
135
+ })
136
+
137
+ if (!matches.length) {
138
+ throw new Error(`packed export target matches no files: ${target}`)
139
+ }
140
+
141
+ return matches
142
+ }
143
+
144
+ function packedPath(packageRoot, target) {
145
+ if (typeof target !== 'string' || !target.startsWith('./') || target.includes('*')) {
146
+ throw new Error(`packed export target is not a concrete package-relative path: ${target}`)
147
+ }
148
+
149
+ const file = resolve(packageRoot, target)
150
+ if (!pathInside(packageRoot, file)) {
151
+ throw new Error(`packed export target escapes the package: ${target}`)
152
+ }
153
+
154
+ try {
155
+ if (statSync(file).isFile()) {
156
+ return file
157
+ }
158
+ } catch {}
159
+
160
+ throw new Error(`packed export target is missing from the tarball: ${target}`)
161
+ }
162
+
163
+ function sourceFile(ts, file) {
164
+ const extension = extname(file).toLowerCase()
165
+ const scriptKind =
166
+ extension === '.tsx'
167
+ ? ts.ScriptKind.TSX
168
+ : extension === '.jsx'
169
+ ? ts.ScriptKind.JSX
170
+ : extension === '.tsrx'
171
+ ? ts.ScriptKind.Deferred
172
+ : ts.ScriptKind.TS
173
+
174
+ return ts.createSourceFile(
175
+ file,
176
+ readFileSync(file, 'utf8'),
177
+ ts.ScriptTarget.Latest,
178
+ true,
179
+ scriptKind,
180
+ )
181
+ }
182
+
183
+ function localSourceModule(file, specifier) {
184
+ if (!specifier.startsWith('.')) {
185
+ return null
186
+ }
187
+
188
+ const target = resolve(dirname(file), specifier)
189
+ const extension = extname(target)
190
+ const candidates = [target]
191
+ if (extension === '.js' || extension === '.mjs' || extension === '.cjs') {
192
+ const stem = target.slice(0, -extension.length)
193
+ candidates.push(`${stem}.ts`, `${stem}.tsx`, `${stem}.tsrx`)
194
+ } else if (!['.ts', '.tsx', '.tsrx'].includes(extension)) {
195
+ candidates.push(`${target}.ts`, `${target}.tsx`, `${target}.tsrx`)
196
+ candidates.push(join(target, 'index.ts'), join(target, 'index.tsx'), join(target, 'index.tsrx'))
197
+ }
198
+
199
+ for (const candidate of candidates) {
200
+ try {
201
+ if (statSync(candidate).isFile()) {
202
+ return candidate
203
+ }
204
+ } catch {}
205
+ }
206
+
207
+ return null
208
+ }
209
+
210
+ function staticRuntimeExports(ts, file, seen = new Set()) {
211
+ const absolute = resolve(file)
212
+ if (seen.has(absolute)) {
213
+ return new Set()
214
+ }
215
+
216
+ seen.add(absolute)
217
+
218
+ const source = sourceFile(ts, absolute)
219
+ const names = new Set()
220
+ const addBindingName = (name) => {
221
+ if (ts.isIdentifier(name)) {
222
+ names.add(name.text)
223
+ } else if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) {
224
+ for (const element of name.elements) {
225
+ if (element && ts.isBindingElement(element)) {
226
+ addBindingName(element.name)
227
+ }
228
+ }
229
+ }
230
+ }
231
+
232
+ for (const statement of source.statements) {
233
+ const modifiers = ts.canHaveModifiers(statement) ? (ts.getModifiers(statement) ?? []) : []
234
+ const exported = modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)
235
+ const isDefault = modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword)
236
+ if (isDefault) {
237
+ names.add('default')
238
+ }
239
+
240
+ if (exported) {
241
+ if (
242
+ ts.isFunctionDeclaration(statement) ||
243
+ ts.isClassDeclaration(statement) ||
244
+ ts.isEnumDeclaration(statement) ||
245
+ ts.isModuleDeclaration(statement)
246
+ ) {
247
+ if (statement.name) {
248
+ names.add(statement.name.text)
249
+ }
250
+ }
251
+
252
+ if (ts.isVariableStatement(statement)) {
253
+ for (const declaration of statement.declarationList.declarations) {
254
+ addBindingName(declaration.name)
255
+ }
256
+ }
257
+ }
258
+
259
+ if (ts.isExportDeclaration(statement) && !statement.isTypeOnly) {
260
+ if (!statement.exportClause && statement.moduleSpecifier && ts.isStringLiteralLike(statement.moduleSpecifier)) {
261
+ const target = localSourceModule(absolute, statement.moduleSpecifier.text)
262
+ if (target) {
263
+ for (const name of staticRuntimeExports(ts, target, seen)) {
264
+ if (name !== 'default') {
265
+ names.add(name)
266
+ }
267
+ }
268
+ }
269
+ } else if (statement.exportClause && ts.isNamedExports(statement.exportClause)) {
270
+ for (const element of statement.exportClause.elements) {
271
+ if (!element.isTypeOnly) {
272
+ names.add(element.name.text)
273
+ }
274
+ }
275
+ } else if (statement.exportClause && ts.isNamespaceExport(statement.exportClause)) {
276
+ names.add(statement.exportClause.name.text)
277
+ }
278
+ }
279
+
280
+ if (ts.isExportAssignment(statement)) {
281
+ names.add('default')
282
+ }
283
+ }
284
+
285
+ return names
286
+ }
287
+
288
+ function compilerExports(ts, program, file) {
289
+ const source = program.getSourceFile(file)
290
+ if (!source) {
291
+ return undefined
292
+ }
293
+
294
+ const checker = program.getTypeChecker()
295
+ const module = checker.getSymbolAtLocation(source)
296
+ if (!module) {
297
+ return undefined
298
+ }
299
+
300
+ const all = new Set()
301
+ const values = new Set()
302
+ for (const symbol of checker.getExportsOfModule(module)) {
303
+ all.add(symbol.name)
304
+ let resolved = symbol
305
+ if (symbol.flags & ts.SymbolFlags.Alias) {
306
+ try {
307
+ resolved = checker.getAliasedSymbol(symbol)
308
+ } catch {}
309
+ }
310
+
311
+ if (resolved.flags & ts.SymbolFlags.Value) {
312
+ values.add(symbol.name)
313
+ }
314
+ }
315
+
316
+ return { all, values }
317
+ }
318
+
319
+ function declarationFiles(packageRoot, files) {
320
+ return files.filter((file) => /\.d\.[cm]?ts$/.test(relative(packageRoot, file)))
321
+ }
322
+
323
+ function moduleResolutionOptions(ts) {
324
+ return {
325
+ allowImportingTsExtensions: true,
326
+ module: ts.ModuleKind.NodeNext,
327
+ moduleResolution: ts.ModuleResolutionKind.NodeNext,
328
+ noEmit: true,
329
+ skipLibCheck: true,
330
+ target: ts.ScriptTarget.Latest,
331
+ }
332
+ }
333
+
334
+ function verifyDeclarationClosure(ts, packageRoot, files, manifest, failures) {
335
+ const options = moduleResolutionOptions(ts)
336
+ const host = ts.sys
337
+ const declared = new Set([
338
+ manifest.name,
339
+ ...Object.keys(manifest.dependencies ?? {}),
340
+ ...Object.keys(manifest.optionalDependencies ?? {}),
341
+ ...Object.keys(manifest.peerDependencies ?? {}),
342
+ ...(manifest.bundledDependencies ?? []),
343
+ ])
344
+
345
+ for (const file of declarationFiles(packageRoot, files)) {
346
+ const source = sourceFile(ts, file)
347
+ for (const node of moduleSpecifierNodes(ts, source)) {
348
+ const specifier = node.text
349
+ if (specifier.startsWith('.') || specifier.startsWith('#')) {
350
+ const resolved = ts.resolveModuleName(specifier, file, options, host).resolvedModule
351
+ if (!resolved) {
352
+ failures.push(
353
+ `${relative(packageRoot, file)} has unresolved declaration reference ${specifier}`,
354
+ )
355
+ } else if (!pathInside(packageRoot, resolve(resolved.resolvedFileName))) {
356
+ failures.push(`${relative(packageRoot, file)} resolves ${specifier} outside the package`)
357
+ }
358
+
359
+ continue
360
+ }
361
+
362
+ if (specifier.startsWith('node:')) {
363
+ if (!declared.has('@types/node')) {
364
+ failures.push(
365
+ `${relative(packageRoot, file)} imports ${specifier}; declare @types/node as a dependency or peer`,
366
+ )
367
+ }
368
+
369
+ continue
370
+ }
371
+
372
+ if (specifier.startsWith('/')) {
373
+ failures.push(
374
+ `${relative(packageRoot, file)} has absolute declaration reference ${specifier}`,
375
+ )
376
+
377
+ continue
378
+ }
379
+
380
+ const name = specifier.startsWith('@')
381
+ ? specifier.split('/').slice(0, 2).join('/')
382
+ : specifier.split('/')[0]
383
+
384
+ if (!declared.has(name)) {
385
+ failures.push(
386
+ `${relative(packageRoot, file)} imports ${specifier}; declare ${name} in dependencies or peerDependencies`,
387
+ )
388
+ }
389
+ }
390
+ }
391
+ }
392
+
393
+ function verifyExportMap(ts, packageRoot, files, manifest, exportsMap, failures, options) {
394
+ const fallbackTypes = [manifest.types ?? manifest.typings].filter(Boolean)
395
+ const branches = exportBranches(exportsMap, fallbackTypes)
396
+ if (!branches.length) {
397
+ failures.push('package exports contain no runtime entrypoints to verify')
398
+ return
399
+ }
400
+
401
+ const pairs = []
402
+ for (const branch of branches) {
403
+ if (!branch.types.length) {
404
+ failures.push(`${branch.subpath} runtime ${branch.runtime} has no types condition`)
405
+ continue
406
+ }
407
+
408
+ let runtimeMatches
409
+ try {
410
+ runtimeMatches = expandTarget(branch.runtime, files, packageRoot)
411
+ } catch (error) {
412
+ failures.push(`${branch.subpath}: ${error.message}`)
413
+ continue
414
+ }
415
+
416
+ for (const runtimeMatch of runtimeMatches) {
417
+ let runtimeFile
418
+ try {
419
+ runtimeFile = packedPath(packageRoot, runtimeMatch.path)
420
+ } catch (error) {
421
+ failures.push(`${branch.subpath}: ${error.message}`)
422
+ continue
423
+ }
424
+
425
+ for (const typeTarget of branch.types) {
426
+ const concreteType =
427
+ typeTarget.includes('*') && runtimeMatch.capture !== undefined
428
+ ? typeTarget.replace('*', runtimeMatch.capture)
429
+ : typeTarget
430
+
431
+ let typeFile
432
+ try {
433
+ typeFile = packedPath(packageRoot, concreteType)
434
+ } catch (error) {
435
+ failures.push(`${branch.subpath}: ${error.message}`)
436
+ continue
437
+ }
438
+
439
+ pairs.push({ branch, runtimeFile, runtimePath: runtimeMatch.path, typeFile, concreteType })
440
+ }
441
+ }
442
+ }
443
+
444
+ const programRoots = [
445
+ ...new Set(pairs.flatMap(({ runtimeFile, typeFile }) => [runtimeFile, typeFile])),
446
+ ].filter((file) => !file.endsWith('.tsrx'))
447
+
448
+ const program = ts.createProgram(programRoots, options)
449
+ for (const pair of pairs) {
450
+ const declarationExports = compilerExports(ts, program, pair.typeFile)
451
+ const runtimeExports = compilerExports(ts, program, pair.runtimeFile)
452
+ const declaredValues = declarationExports?.values ?? new Set()
453
+ let runtimeValues
454
+ if (
455
+ pair.runtimeFile.endsWith('.ts') ||
456
+ pair.runtimeFile.endsWith('.tsx') ||
457
+ pair.runtimeFile.endsWith('.tsrx')
458
+ ) {
459
+ runtimeValues = staticRuntimeExports(ts, pair.runtimeFile)
460
+ for (const name of declarationExports?.all ?? []) {
461
+ if (!declaredValues.has(name)) {
462
+ runtimeValues.delete(name)
463
+ }
464
+ }
465
+ } else {
466
+ runtimeValues = runtimeExports?.values ?? staticRuntimeExports(ts, pair.runtimeFile)
467
+ }
468
+
469
+ const missing = [...runtimeValues].filter((name) => !declaredValues.has(name)).sort()
470
+ const extra = [...declaredValues].filter((name) => !runtimeValues.has(name)).sort()
471
+ if (missing.length || extra.length) {
472
+ failures.push(
473
+ `${pair.branch.subpath} runtime/type value exports differ for ${pair.runtimePath} → ${pair.concreteType}` +
474
+ `${missing.length ? `; missing declarations: ${missing.join(', ')}` : ''}` +
475
+ `${extra.length ? `; declarations without runtime values: ${extra.join(', ')}` : ''}`,
476
+ )
477
+ }
478
+ }
479
+ }
480
+
481
+ export function verifyPackedPackage(ts, packageRoot) {
482
+ const failures = []
483
+ const manifestPath = join(packageRoot, 'package.json')
484
+ let manifest
485
+ try {
486
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
487
+ } catch (error) {
488
+ throw new Error(`packed package.json: ${error.message}`)
489
+ }
490
+
491
+ const files = packageFiles(packageRoot)
492
+ const maps = [manifest.exports, manifest.publishConfig?.exports].filter(
493
+ (value, index, all) => value && all.indexOf(value) === index,
494
+ )
495
+
496
+ if (maps.length) {
497
+ for (const exportsMap of maps) {
498
+ verifyExportMap(
499
+ ts,
500
+ packageRoot,
501
+ files,
502
+ manifest,
503
+ exportsMap,
504
+ failures,
505
+ moduleResolutionOptions(ts),
506
+ )
507
+ }
508
+ } else if (manifest.main || manifest.module) {
509
+ verifyExportMap(
510
+ ts,
511
+ packageRoot,
512
+ files,
513
+ manifest,
514
+ { '.': manifest.module ?? manifest.main },
515
+ failures,
516
+ moduleResolutionOptions(ts),
517
+ )
518
+ } else {
519
+ failures.push('package has no exports, main, or module entrypoint')
520
+ }
521
+
522
+ verifyDeclarationClosure(ts, packageRoot, files, manifest, failures)
523
+ if (failures.length) {
524
+ throw new Error(failures.join('\n'))
525
+ }
526
+ }
527
+
528
+ export function checkPackedPackage(projectRoot, ts) {
529
+ const temporary = mkdtempSync(join(tmpdir(), 'tsrx-typegen-pack-'))
530
+ const packOutput = join(temporary, 'pack')
531
+ const extractedRoot = join(temporary, 'extracted')
532
+ mkdirSync(packOutput)
533
+ mkdirSync(extractedRoot)
534
+ try {
535
+ verifyPackedPackage(ts, projectRoot)
536
+ console.log('tsrx-typegen: workspace exports and declaration graph are valid')
537
+ const packed = spawnSync(
538
+ 'pnpm',
539
+ ['--config.ignore-scripts=true', 'pack', '--pack-destination', packOutput],
540
+ { cwd: projectRoot, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 },
541
+ )
542
+
543
+ if (packed.stdout) {
544
+ process.stdout.write(packed.stdout)
545
+ }
546
+
547
+ if (packed.stderr) {
548
+ process.stderr.write(packed.stderr)
549
+ }
550
+
551
+ if (packed.error) {
552
+ throw packed.error
553
+ }
554
+
555
+ if (packed.status !== 0) {
556
+ throw new Error(`pnpm pack exited with ${packed.status}`)
557
+ }
558
+
559
+ const tarballs = readdirSync(packOutput).filter((name) => name.endsWith('.tgz'))
560
+ if (tarballs.length !== 1) {
561
+ throw new Error(`expected one package tarball; found ${tarballs.length}`)
562
+ }
563
+
564
+ const extracted = spawnSync(
565
+ 'tar',
566
+ ['-xzf', join(packOutput, tarballs[0]), '-C', extractedRoot],
567
+ {
568
+ encoding: 'utf8',
569
+ maxBuffer: 16 * 1024 * 1024,
570
+ },
571
+ )
572
+
573
+ if (extracted.stdout) {
574
+ process.stdout.write(extracted.stdout)
575
+ }
576
+
577
+ if (extracted.stderr) {
578
+ process.stderr.write(extracted.stderr)
579
+ }
580
+
581
+ if (extracted.error) {
582
+ throw extracted.error
583
+ }
584
+
585
+ if (extracted.status !== 0) {
586
+ throw new Error(`tar extraction exited with ${extracted.status}`)
587
+ }
588
+
589
+ verifyPackedPackage(ts, join(extractedRoot, 'package'))
590
+ console.log('tsrx-typegen: packed runtime exports and declaration graph are valid')
591
+ } finally {
592
+ rmSync(temporary, { recursive: true, force: true })
593
+ }
594
+ }