tsrx-typegen 0.0.0 → 0.2.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,658 @@
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`, `${stem}.d.ts`)
194
+ } else if (!['.ts', '.tsx', '.tsrx'].includes(extension)) {
195
+ candidates.push(`${target}.ts`, `${target}.tsx`, `${target}.tsrx`, `${target}.d.ts`)
196
+ candidates.push(
197
+ join(target, 'index.ts'),
198
+ join(target, 'index.tsx'),
199
+ join(target, 'index.tsrx'),
200
+ join(target, 'index.d.ts'),
201
+ )
202
+ }
203
+
204
+ for (const candidate of candidates) {
205
+ try {
206
+ if (statSync(candidate).isFile()) {
207
+ return candidate
208
+ }
209
+ } catch {}
210
+ }
211
+
212
+ return null
213
+ }
214
+
215
+ function staticRuntimeExports(ts, file, seen = new Set()) {
216
+ const absolute = resolve(file)
217
+ if (seen.has(absolute)) {
218
+ return new Set()
219
+ }
220
+
221
+ seen.add(absolute)
222
+
223
+ const source = sourceFile(ts, absolute)
224
+ const names = new Set()
225
+ const addBindingName = (name) => {
226
+ if (ts.isIdentifier(name)) {
227
+ names.add(name.text)
228
+ } else if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) {
229
+ for (const element of name.elements) {
230
+ if (element && ts.isBindingElement(element)) {
231
+ addBindingName(element.name)
232
+ }
233
+ }
234
+ }
235
+ }
236
+
237
+ for (const statement of source.statements) {
238
+ const modifiers = ts.canHaveModifiers(statement) ? (ts.getModifiers(statement) ?? []) : []
239
+ const exported = modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)
240
+ const isDefault = modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword)
241
+ if (isDefault) {
242
+ names.add('default')
243
+ }
244
+
245
+ if (exported) {
246
+ if (
247
+ ts.isFunctionDeclaration(statement) ||
248
+ ts.isClassDeclaration(statement) ||
249
+ ts.isEnumDeclaration(statement) ||
250
+ ts.isModuleDeclaration(statement)
251
+ ) {
252
+ if (statement.name) {
253
+ names.add(statement.name.text)
254
+ }
255
+ }
256
+
257
+ if (ts.isVariableStatement(statement)) {
258
+ for (const declaration of statement.declarationList.declarations) {
259
+ addBindingName(declaration.name)
260
+ }
261
+ }
262
+ }
263
+
264
+ if (ts.isExportDeclaration(statement) && !statement.isTypeOnly) {
265
+ if (!statement.exportClause && statement.moduleSpecifier && ts.isStringLiteralLike(statement.moduleSpecifier)) {
266
+ const target = localSourceModule(absolute, statement.moduleSpecifier.text)
267
+ if (target) {
268
+ for (const name of staticRuntimeExports(ts, target, seen)) {
269
+ if (name !== 'default') {
270
+ names.add(name)
271
+ }
272
+ }
273
+ }
274
+ } else if (statement.exportClause && ts.isNamedExports(statement.exportClause)) {
275
+ for (const element of statement.exportClause.elements) {
276
+ if (!element.isTypeOnly) {
277
+ names.add(element.name.text)
278
+ }
279
+ }
280
+ } else if (statement.exportClause && ts.isNamespaceExport(statement.exportClause)) {
281
+ names.add(statement.exportClause.name.text)
282
+ }
283
+ }
284
+
285
+ if (ts.isExportAssignment(statement)) {
286
+ names.add('default')
287
+ }
288
+ }
289
+
290
+ return names
291
+ }
292
+
293
+ function compilerExports(ts, program, file, cache = new Map(), pending = new Set()) {
294
+ const absolute = resolve(file)
295
+ if (cache.has(absolute)) {
296
+ return cache.get(absolute)
297
+ }
298
+
299
+ if (pending.has(absolute)) {
300
+ return { all: new Set(), values: new Set() }
301
+ }
302
+
303
+ const source = program.getSourceFile(file)
304
+ if (!source) {
305
+ return undefined
306
+ }
307
+
308
+ const checker = program.getTypeChecker()
309
+ const module = checker.getSymbolAtLocation(source)
310
+ if (!module) {
311
+ return undefined
312
+ }
313
+
314
+ const all = new Set()
315
+ const values = new Set()
316
+ for (const symbol of checker.getExportsOfModule(module)) {
317
+ all.add(symbol.name)
318
+ let resolved = symbol
319
+ if (symbol.flags & ts.SymbolFlags.Alias) {
320
+ try {
321
+ resolved = checker.getAliasedSymbol(symbol)
322
+ } catch {}
323
+ }
324
+
325
+ if (resolved.flags & ts.SymbolFlags.Value) {
326
+ values.add(symbol.name)
327
+ }
328
+ }
329
+
330
+ // Type-only edges (`export type *`, `export type { X }`) re-list the
331
+ // target's symbols with their original flags — an ambient `declare const`
332
+ // still reads as a value even though no value binding crosses the edge.
333
+ // Subtract names whose only export edges are type-only.
334
+ const typeOnly = new Set()
335
+ for (const statement of source.statements) {
336
+ if (!ts.isExportDeclaration(statement) || !statement.isTypeOnly) {
337
+ continue
338
+ }
339
+
340
+ if (
341
+ !statement.exportClause &&
342
+ statement.moduleSpecifier &&
343
+ ts.isStringLiteralLike(statement.moduleSpecifier)
344
+ ) {
345
+ pending.add(absolute)
346
+ const target = ts.resolveModuleName(
347
+ statement.moduleSpecifier.text,
348
+ file,
349
+ program.getCompilerOptions(),
350
+ ts.sys,
351
+ ).resolvedModule?.resolvedFileName
352
+
353
+ const targetExports =
354
+ target && compilerExports(ts, program, target, cache, pending)
355
+
356
+ pending.delete(absolute)
357
+ for (const name of targetExports?.all ?? []) {
358
+ typeOnly.add(name)
359
+ }
360
+ } else if (statement.exportClause && ts.isNamedExports(statement.exportClause)) {
361
+ for (const element of statement.exportClause.elements) {
362
+ typeOnly.add(element.name.text)
363
+ }
364
+ } else if (statement.exportClause && ts.isNamespaceExport(statement.exportClause)) {
365
+ typeOnly.add(statement.exportClause.name.text)
366
+ }
367
+ }
368
+
369
+ if (typeOnly.size) {
370
+ const valueEdges = staticRuntimeExports(ts, file)
371
+ for (const name of values) {
372
+ if (typeOnly.has(name) && !valueEdges.has(name)) {
373
+ values.delete(name)
374
+ }
375
+ }
376
+ }
377
+
378
+ const result = { all, values }
379
+ cache.set(absolute, result)
380
+ return result
381
+ }
382
+
383
+ function declarationFiles(packageRoot, files) {
384
+ return files.filter((file) => /\.d\.[cm]?ts$/.test(relative(packageRoot, file)))
385
+ }
386
+
387
+ function moduleResolutionOptions(ts) {
388
+ return {
389
+ allowImportingTsExtensions: true,
390
+ module: ts.ModuleKind.NodeNext,
391
+ moduleResolution: ts.ModuleResolutionKind.NodeNext,
392
+ noEmit: true,
393
+ skipLibCheck: true,
394
+ target: ts.ScriptTarget.Latest,
395
+ }
396
+ }
397
+
398
+ function verifyDeclarationClosure(ts, packageRoot, files, manifest, failures) {
399
+ const options = moduleResolutionOptions(ts)
400
+ const host = ts.sys
401
+ const declared = new Set([
402
+ manifest.name,
403
+ ...Object.keys(manifest.dependencies ?? {}),
404
+ ...Object.keys(manifest.optionalDependencies ?? {}),
405
+ ...Object.keys(manifest.peerDependencies ?? {}),
406
+ ...(manifest.bundledDependencies ?? []),
407
+ ])
408
+
409
+ for (const file of declarationFiles(packageRoot, files)) {
410
+ const source = sourceFile(ts, file)
411
+ for (const node of moduleSpecifierNodes(ts, source)) {
412
+ const specifier = node.text
413
+ if (specifier.startsWith('.') || specifier.startsWith('#')) {
414
+ const resolved = ts.resolveModuleName(specifier, file, options, host).resolvedModule
415
+ if (!resolved) {
416
+ failures.push(
417
+ `${relative(packageRoot, file)} has unresolved declaration reference ${specifier}`,
418
+ )
419
+ } else if (!pathInside(packageRoot, resolve(resolved.resolvedFileName))) {
420
+ failures.push(`${relative(packageRoot, file)} resolves ${specifier} outside the package`)
421
+ }
422
+
423
+ continue
424
+ }
425
+
426
+ if (specifier.startsWith('node:')) {
427
+ if (!declared.has('@types/node')) {
428
+ failures.push(
429
+ `${relative(packageRoot, file)} imports ${specifier}; declare @types/node as a dependency or peer`,
430
+ )
431
+ }
432
+
433
+ continue
434
+ }
435
+
436
+ if (specifier.startsWith('/')) {
437
+ failures.push(
438
+ `${relative(packageRoot, file)} has absolute declaration reference ${specifier}`,
439
+ )
440
+
441
+ continue
442
+ }
443
+
444
+ const name = specifier.startsWith('@')
445
+ ? specifier.split('/').slice(0, 2).join('/')
446
+ : specifier.split('/')[0]
447
+
448
+ if (!declared.has(name)) {
449
+ failures.push(
450
+ `${relative(packageRoot, file)} imports ${specifier}; declare ${name} in dependencies or peerDependencies`,
451
+ )
452
+ }
453
+ }
454
+ }
455
+ }
456
+
457
+ function verifyExportMap(ts, packageRoot, files, manifest, exportsMap, failures, options) {
458
+ const fallbackTypes = [manifest.types ?? manifest.typings].filter(Boolean)
459
+ const branches = exportBranches(exportsMap, fallbackTypes)
460
+ if (!branches.length) {
461
+ failures.push('package exports contain no runtime entrypoints to verify')
462
+ return
463
+ }
464
+
465
+ const pairs = []
466
+ for (const branch of branches) {
467
+ if (!branch.types.length) {
468
+ failures.push(`${branch.subpath} runtime ${branch.runtime} has no types condition`)
469
+ continue
470
+ }
471
+
472
+ let runtimeMatches
473
+ try {
474
+ runtimeMatches = expandTarget(branch.runtime, files, packageRoot)
475
+ } catch (error) {
476
+ failures.push(`${branch.subpath}: ${error.message}`)
477
+ continue
478
+ }
479
+
480
+ for (const runtimeMatch of runtimeMatches) {
481
+ let runtimeFile
482
+ try {
483
+ runtimeFile = packedPath(packageRoot, runtimeMatch.path)
484
+ } catch (error) {
485
+ failures.push(`${branch.subpath}: ${error.message}`)
486
+ continue
487
+ }
488
+
489
+ for (const typeTarget of branch.types) {
490
+ const concreteType =
491
+ typeTarget.includes('*') && runtimeMatch.capture !== undefined
492
+ ? typeTarget.replace('*', runtimeMatch.capture)
493
+ : typeTarget
494
+
495
+ let typeFile
496
+ try {
497
+ typeFile = packedPath(packageRoot, concreteType)
498
+ } catch (error) {
499
+ failures.push(`${branch.subpath}: ${error.message}`)
500
+ continue
501
+ }
502
+
503
+ pairs.push({ branch, runtimeFile, runtimePath: runtimeMatch.path, typeFile, concreteType })
504
+ }
505
+ }
506
+ }
507
+
508
+ const programRoots = [
509
+ ...new Set(pairs.flatMap(({ runtimeFile, typeFile }) => [runtimeFile, typeFile])),
510
+ ].filter((file) => !file.endsWith('.tsrx'))
511
+
512
+ const program = ts.createProgram(programRoots, options)
513
+ for (const pair of pairs) {
514
+ const declarationExports = compilerExports(ts, program, pair.typeFile)
515
+ const runtimeExports = compilerExports(ts, program, pair.runtimeFile)
516
+ const declaredValues = declarationExports?.values ?? new Set()
517
+ let runtimeValues
518
+ if (
519
+ pair.runtimeFile.endsWith('.ts') ||
520
+ pair.runtimeFile.endsWith('.tsx') ||
521
+ pair.runtimeFile.endsWith('.tsrx')
522
+ ) {
523
+ runtimeValues = staticRuntimeExports(ts, pair.runtimeFile)
524
+ for (const name of declarationExports?.all ?? []) {
525
+ if (!declaredValues.has(name)) {
526
+ runtimeValues.delete(name)
527
+ }
528
+ }
529
+ } else {
530
+ runtimeValues = runtimeExports?.values ?? staticRuntimeExports(ts, pair.runtimeFile)
531
+ }
532
+
533
+ const missing = [...runtimeValues].filter((name) => !declaredValues.has(name)).sort()
534
+ const extra = [...declaredValues].filter((name) => !runtimeValues.has(name)).sort()
535
+ if (missing.length || extra.length) {
536
+ failures.push(
537
+ `${pair.branch.subpath} runtime/type value exports differ for ${pair.runtimePath} → ${pair.concreteType}` +
538
+ `${missing.length ? `; missing declarations: ${missing.join(', ')}` : ''}` +
539
+ `${extra.length ? `; declarations without runtime values: ${extra.join(', ')}` : ''}`,
540
+ )
541
+ }
542
+ }
543
+ }
544
+
545
+ export function verifyPackedPackage(ts, packageRoot) {
546
+ const failures = []
547
+ const manifestPath = join(packageRoot, 'package.json')
548
+ let manifest
549
+ try {
550
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
551
+ } catch (error) {
552
+ throw new Error(`packed package.json: ${error.message}`)
553
+ }
554
+
555
+ const files = packageFiles(packageRoot)
556
+ const maps = [manifest.exports, manifest.publishConfig?.exports].filter(
557
+ (value, index, all) => value && all.indexOf(value) === index,
558
+ )
559
+
560
+ if (maps.length) {
561
+ for (const exportsMap of maps) {
562
+ verifyExportMap(
563
+ ts,
564
+ packageRoot,
565
+ files,
566
+ manifest,
567
+ exportsMap,
568
+ failures,
569
+ moduleResolutionOptions(ts),
570
+ )
571
+ }
572
+ } else if (manifest.main || manifest.module) {
573
+ verifyExportMap(
574
+ ts,
575
+ packageRoot,
576
+ files,
577
+ manifest,
578
+ { '.': manifest.module ?? manifest.main },
579
+ failures,
580
+ moduleResolutionOptions(ts),
581
+ )
582
+ } else {
583
+ failures.push('package has no exports, main, or module entrypoint')
584
+ }
585
+
586
+ verifyDeclarationClosure(ts, packageRoot, files, manifest, failures)
587
+ if (failures.length) {
588
+ throw new Error(failures.join('\n'))
589
+ }
590
+ }
591
+
592
+ export function checkPackedPackage(projectRoot, ts) {
593
+ const temporary = mkdtempSync(join(tmpdir(), 'tsrx-typegen-pack-'))
594
+ const packOutput = join(temporary, 'pack')
595
+ const extractedRoot = join(temporary, 'extracted')
596
+ mkdirSync(packOutput)
597
+ mkdirSync(extractedRoot)
598
+ try {
599
+ verifyPackedPackage(ts, projectRoot)
600
+ console.log('tsrx-typegen: workspace exports and declaration graph are valid')
601
+ const packed = spawnSync(
602
+ 'pnpm',
603
+ ['--config.ignore-scripts=true', 'pack', '--pack-destination', packOutput],
604
+ { cwd: projectRoot, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 },
605
+ )
606
+
607
+ if (packed.stdout) {
608
+ process.stdout.write(packed.stdout)
609
+ }
610
+
611
+ if (packed.stderr) {
612
+ process.stderr.write(packed.stderr)
613
+ }
614
+
615
+ if (packed.error) {
616
+ throw packed.error
617
+ }
618
+
619
+ if (packed.status !== 0) {
620
+ throw new Error(`pnpm pack exited with ${packed.status}`)
621
+ }
622
+
623
+ const tarballs = readdirSync(packOutput).filter((name) => name.endsWith('.tgz'))
624
+ if (tarballs.length !== 1) {
625
+ throw new Error(`expected one package tarball; found ${tarballs.length}`)
626
+ }
627
+
628
+ const extracted = spawnSync(
629
+ 'tar',
630
+ ['-xzf', join(packOutput, tarballs[0]), '-C', extractedRoot],
631
+ {
632
+ encoding: 'utf8',
633
+ maxBuffer: 16 * 1024 * 1024,
634
+ },
635
+ )
636
+
637
+ if (extracted.stdout) {
638
+ process.stdout.write(extracted.stdout)
639
+ }
640
+
641
+ if (extracted.stderr) {
642
+ process.stderr.write(extracted.stderr)
643
+ }
644
+
645
+ if (extracted.error) {
646
+ throw extracted.error
647
+ }
648
+
649
+ if (extracted.status !== 0) {
650
+ throw new Error(`tar extraction exited with ${extracted.status}`)
651
+ }
652
+
653
+ verifyPackedPackage(ts, join(extractedRoot, 'package'))
654
+ console.log('tsrx-typegen: packed runtime exports and declaration graph are valid')
655
+ } finally {
656
+ rmSync(temporary, { recursive: true, force: true })
657
+ }
658
+ }