@defold-typescript/transpiler 0.35.0 → 0.37.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,761 @@
1
+ import * as ts from "typescript";
2
+ import { resolvesToFactoryExport } from "./lifecycle-erasure";
3
+
4
+ /**
5
+ * The exports-table field a companion exposes its private members on. Reserving
6
+ * one name keeps the field collision-safe without mangling every internal name,
7
+ * which is why a source exporting it is rejected rather than shadowed.
8
+ */
9
+ export const COMPANION_INTERNAL_FIELD = "____closure";
10
+
11
+ export type ClosureViolationKind =
12
+ | "shared-mutable"
13
+ | "initialization-order"
14
+ | "reserved-internal-name";
15
+
16
+ export interface ClosureSite {
17
+ /** What this position is — the phrase the message uses for it. */
18
+ readonly role: string;
19
+ /** 1-based. */
20
+ readonly line: number;
21
+ /** 1-based. */
22
+ readonly column: number;
23
+ }
24
+
25
+ export interface ClosureViolation {
26
+ readonly kind: ClosureViolationKind;
27
+ /** The declaration the split cannot preserve. */
28
+ readonly member: string;
29
+ readonly sites: readonly ClosureSite[];
30
+ readonly message: string;
31
+ }
32
+
33
+ export interface CompanionClosure {
34
+ /** Top-level declarations that would move into the companion, in source order. */
35
+ readonly members: readonly string[];
36
+ /** The runtime value exports that open the companion, in source order. */
37
+ readonly exports: readonly string[];
38
+ /** Members outside `exports` the script still references, in source order. */
39
+ readonly internals: readonly string[];
40
+ /**
41
+ * The top-level statements carrying those members, in source order. A
42
+ * statement, not a name, is what the emit actually moves: one `const a = 1, b
43
+ * = 2;` is two members and one unit of code.
44
+ */
45
+ readonly statements: readonly ts.Statement[];
46
+ /**
47
+ * Import bindings the closure reaches, in source order. An import is copyable
48
+ * rather than movable — its statement never appears in `statements` — so the
49
+ * companion takes its own `require` of the same module instead.
50
+ */
51
+ readonly importBindings: readonly string[];
52
+ /** Shapes a split cannot preserve. Empty means the source is splittable. */
53
+ readonly violations: readonly ClosureViolation[];
54
+ }
55
+
56
+ /**
57
+ * One runtime value export. The public `name` is what a consumer sees and what
58
+ * the reserved-field rule judges; `symbol` is the local binding the dependency
59
+ * closure walks, and the two differ under an alias.
60
+ */
61
+ interface ExportEntry {
62
+ readonly symbol: ts.Symbol;
63
+ readonly name: string;
64
+ /** Where the diagnostic points. */
65
+ readonly nameNode: ts.Node;
66
+ }
67
+
68
+ interface TopLevelDecl {
69
+ readonly name: string;
70
+ /** The whole statement, which moves or stays as a unit. */
71
+ readonly statement: ts.Statement;
72
+ /** The individual declaration, walked for the references it reaches. */
73
+ readonly node: ts.Node;
74
+ readonly order: number;
75
+ }
76
+
77
+ function isAssignmentOperator(kind: ts.SyntaxKind): boolean {
78
+ return kind >= ts.SyntaxKind.FirstAssignment && kind <= ts.SyntaxKind.LastAssignment;
79
+ }
80
+
81
+ // A type annotation naming a value import must not drag it into the closure:
82
+ // the annotation is erased, so it can reach nothing at runtime.
83
+ function isTypePosition(node: ts.Node): boolean {
84
+ let current = node.parent;
85
+ while (current !== undefined && !ts.isSourceFile(current)) {
86
+ if (ts.isTypeNode(current)) {
87
+ return true;
88
+ }
89
+ current = current.parent;
90
+ }
91
+ return false;
92
+ }
93
+
94
+ function isDeclarationName(node: ts.Identifier): boolean {
95
+ const parent = node.parent;
96
+ if (parent === undefined) {
97
+ return false;
98
+ }
99
+ if (ts.isPropertyAccessExpression(parent)) {
100
+ return parent.name === node;
101
+ }
102
+ if (ts.isQualifiedName(parent)) {
103
+ return parent.right === node;
104
+ }
105
+ if (
106
+ (ts.isBindingElement(parent) || ts.isImportSpecifier(parent) || ts.isExportSpecifier(parent)) &&
107
+ parent.propertyName === node
108
+ ) {
109
+ return true;
110
+ }
111
+ const named = parent as ts.Node & { readonly name?: ts.Node };
112
+ if (named.name !== node) {
113
+ return false;
114
+ }
115
+ return (
116
+ ts.isVariableDeclaration(parent) ||
117
+ ts.isFunctionDeclaration(parent) ||
118
+ ts.isClassDeclaration(parent) ||
119
+ ts.isEnumDeclaration(parent) ||
120
+ ts.isEnumMember(parent) ||
121
+ ts.isParameter(parent) ||
122
+ ts.isBindingElement(parent) ||
123
+ ts.isImportClause(parent) ||
124
+ ts.isImportSpecifier(parent) ||
125
+ ts.isNamespaceImport(parent) ||
126
+ ts.isPropertyAssignment(parent) ||
127
+ ts.isPropertyDeclaration(parent) ||
128
+ ts.isPropertySignature(parent) ||
129
+ ts.isMethodDeclaration(parent) ||
130
+ ts.isMethodSignature(parent) ||
131
+ ts.isTypeAliasDeclaration(parent) ||
132
+ ts.isInterfaceDeclaration(parent) ||
133
+ ts.isGetAccessor(parent) ||
134
+ ts.isSetAccessor(parent)
135
+ );
136
+ }
137
+
138
+ function forEachReference(
139
+ root: ts.Node,
140
+ checker: ts.TypeChecker,
141
+ visit: (node: ts.Identifier, symbol: ts.Symbol) => void,
142
+ ): void {
143
+ const walk = (node: ts.Node): void => {
144
+ if (ts.isIdentifier(node) && !isDeclarationName(node) && !isTypePosition(node)) {
145
+ const symbol = resolveReferencedSymbol(node, checker);
146
+ if (symbol !== undefined) {
147
+ visit(node, symbol);
148
+ }
149
+ }
150
+ ts.forEachChild(node, walk);
151
+ };
152
+ walk(root);
153
+ }
154
+
155
+ // `getSymbolAtLocation` on a shorthand property's name yields the *property*
156
+ // symbol, so `{ count }` would name neither the binding it reads nor the one a
157
+ // destructuring assignment rebinds.
158
+ function resolveReferencedSymbol(
159
+ node: ts.Identifier,
160
+ checker: ts.TypeChecker,
161
+ ): ts.Symbol | undefined {
162
+ const parent = node.parent;
163
+ if (parent !== undefined && ts.isShorthandPropertyAssignment(parent) && parent.name === node) {
164
+ return checker.getShorthandAssignmentValueSymbol(parent) ?? checker.getSymbolAtLocation(node);
165
+ }
166
+ return checker.getSymbolAtLocation(node);
167
+ }
168
+
169
+ /**
170
+ * Every position the source writes to, classified structurally. Recursing
171
+ * through parentheses and destructuring patterns is what separates a rebinding
172
+ * — which a split cannot carry — from a property or element write, which is
173
+ * shared by reference and stays safe.
174
+ */
175
+ function forEachAssignmentTarget(
176
+ root: ts.Node,
177
+ checker: ts.TypeChecker,
178
+ visit: (node: ts.Identifier, symbol: ts.Symbol) => void,
179
+ ): void {
180
+ const classify = (target: ts.Node): void => {
181
+ if (ts.isParenthesizedExpression(target)) {
182
+ classify(target.expression);
183
+ return;
184
+ }
185
+ if (ts.isPropertyAccessExpression(target) || ts.isElementAccessExpression(target)) {
186
+ return;
187
+ }
188
+ if (ts.isArrayLiteralExpression(target)) {
189
+ for (const element of target.elements) {
190
+ if (ts.isOmittedExpression(element)) {
191
+ continue;
192
+ }
193
+ if (ts.isSpreadElement(element)) {
194
+ classify(element.expression);
195
+ } else if (
196
+ ts.isBinaryExpression(element) &&
197
+ element.operatorToken.kind === ts.SyntaxKind.EqualsToken
198
+ ) {
199
+ classify(element.left);
200
+ } else {
201
+ classify(element);
202
+ }
203
+ }
204
+ return;
205
+ }
206
+ if (ts.isObjectLiteralExpression(target)) {
207
+ for (const property of target.properties) {
208
+ if (ts.isPropertyAssignment(property)) {
209
+ classify(property.initializer);
210
+ } else if (ts.isSpreadAssignment(property)) {
211
+ classify(property.expression);
212
+ } else if (ts.isShorthandPropertyAssignment(property)) {
213
+ const symbol = checker.getShorthandAssignmentValueSymbol(property);
214
+ if (symbol !== undefined) {
215
+ visit(property.name, symbol);
216
+ }
217
+ }
218
+ }
219
+ return;
220
+ }
221
+ if (ts.isIdentifier(target)) {
222
+ const symbol = checker.getSymbolAtLocation(target);
223
+ if (symbol !== undefined) {
224
+ visit(target, symbol);
225
+ }
226
+ }
227
+ };
228
+
229
+ const walk = (node: ts.Node): void => {
230
+ if (ts.isBinaryExpression(node) && isAssignmentOperator(node.operatorToken.kind)) {
231
+ classify(node.left);
232
+ } else if (
233
+ (ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) &&
234
+ (node.operator === ts.SyntaxKind.PlusPlusToken ||
235
+ node.operator === ts.SyntaxKind.MinusMinusToken)
236
+ ) {
237
+ classify(node.operand);
238
+ } else if (
239
+ (ts.isForInStatement(node) || ts.isForOfStatement(node)) &&
240
+ !ts.isVariableDeclarationList(node.initializer)
241
+ ) {
242
+ classify(node.initializer);
243
+ }
244
+ ts.forEachChild(node, walk);
245
+ };
246
+ walk(root);
247
+ }
248
+
249
+ function collectTopLevel(
250
+ sourceFile: ts.SourceFile,
251
+ checker: ts.TypeChecker,
252
+ ): Map<ts.Symbol, TopLevelDecl> {
253
+ const declarations = new Map<ts.Symbol, TopLevelDecl>();
254
+ let order = 0;
255
+
256
+ const add = (name: ts.Node | undefined, statement: ts.Statement, node: ts.Node): void => {
257
+ if (name === undefined || !ts.isIdentifier(name)) {
258
+ return;
259
+ }
260
+ const symbol = checker.getSymbolAtLocation(name);
261
+ if (symbol === undefined || declarations.has(symbol)) {
262
+ return;
263
+ }
264
+ declarations.set(symbol, { name: name.text, statement, node, order: order++ });
265
+ };
266
+
267
+ const addBinding = (name: ts.BindingName, statement: ts.Statement, node: ts.Node): void => {
268
+ if (ts.isIdentifier(name)) {
269
+ add(name, statement, node);
270
+ return;
271
+ }
272
+ for (const element of name.elements) {
273
+ if (ts.isBindingElement(element)) {
274
+ addBinding(element.name, statement, node);
275
+ }
276
+ }
277
+ };
278
+
279
+ for (const statement of sourceFile.statements) {
280
+ if (ts.isVariableStatement(statement)) {
281
+ for (const declaration of statement.declarationList.declarations) {
282
+ addBinding(declaration.name, statement, declaration);
283
+ }
284
+ } else if (
285
+ ts.isFunctionDeclaration(statement) ||
286
+ ts.isClassDeclaration(statement) ||
287
+ ts.isEnumDeclaration(statement)
288
+ ) {
289
+ add(statement.name, statement, statement);
290
+ } else if (ts.isImportDeclaration(statement)) {
291
+ const clause = statement.importClause;
292
+ if (clause === undefined) {
293
+ continue;
294
+ }
295
+ add(clause.name, statement, statement);
296
+ const bindings = clause.namedBindings;
297
+ if (bindings === undefined) {
298
+ continue;
299
+ }
300
+ if (ts.isNamespaceImport(bindings)) {
301
+ add(bindings.name, statement, statement);
302
+ } else {
303
+ for (const element of bindings.elements) {
304
+ add(element.name, statement, statement);
305
+ }
306
+ }
307
+ }
308
+ }
309
+
310
+ return declarations;
311
+ }
312
+
313
+ function hasExportModifier(statement: ts.Statement): boolean {
314
+ return (
315
+ ts.canHaveModifiers(statement) &&
316
+ (ts.getModifiers(statement) ?? []).some(
317
+ (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword,
318
+ )
319
+ );
320
+ }
321
+
322
+ // Runtime value meaning, not the `export` keyword: `export type` and
323
+ // `export interface` are erased before emit and never open a companion.
324
+ function isValueSymbol(symbol: ts.Symbol | undefined, checker: ts.TypeChecker): boolean {
325
+ if (symbol === undefined) {
326
+ return false;
327
+ }
328
+ const resolved = symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol;
329
+ return (resolved.flags & ts.SymbolFlags.Value) !== 0;
330
+ }
331
+
332
+ /**
333
+ * The source's runtime value exports, in source order. A default export is
334
+ * excluded: on a script-kind source it is the lifecycle factory result, which
335
+ * erasure removes and no companion can carry.
336
+ */
337
+ function collectExports(
338
+ sourceFile: ts.SourceFile,
339
+ checker: ts.TypeChecker,
340
+ topLevel: Map<ts.Symbol, TopLevelDecl>,
341
+ ): ExportEntry[] {
342
+ const exported: ExportEntry[] = [];
343
+ const seen = new Set<string>();
344
+
345
+ const take = (symbol: ts.Symbol | undefined, name: string, nameNode: ts.Node): void => {
346
+ const declaration = symbol && topLevel.get(symbol);
347
+ if (symbol === undefined || declaration === undefined) {
348
+ return;
349
+ }
350
+ if (!isValueSymbol(symbol, checker)) {
351
+ return;
352
+ }
353
+ // The pair, not the symbol: `export { value as a, value as b }` is two
354
+ // exports of one member.
355
+ const key = `${declaration.order}${name}`;
356
+ if (seen.has(key)) {
357
+ return;
358
+ }
359
+ seen.add(key);
360
+ exported.push({ symbol, name, nameNode });
361
+ };
362
+
363
+ const takeLocal = (symbol: ts.Symbol | undefined, name: ts.Identifier): void => {
364
+ const declaration = symbol && topLevel.get(symbol);
365
+ if (declaration === undefined) {
366
+ return;
367
+ }
368
+ take(symbol, name.text, declaration.node);
369
+ };
370
+
371
+ for (const statement of sourceFile.statements) {
372
+ if (ts.isExportDeclaration(statement)) {
373
+ if (statement.moduleSpecifier !== undefined || statement.isTypeOnly) {
374
+ continue;
375
+ }
376
+ const clause = statement.exportClause;
377
+ if (clause === undefined || !ts.isNamedExports(clause)) {
378
+ continue;
379
+ }
380
+ for (const element of clause.elements) {
381
+ if (element.isTypeOnly) {
382
+ continue;
383
+ }
384
+ take(checker.getExportSpecifierLocalTargetSymbol(element), element.name.text, element.name);
385
+ }
386
+ continue;
387
+ }
388
+ if (!hasExportModifier(statement)) {
389
+ continue;
390
+ }
391
+ if (ts.isVariableStatement(statement)) {
392
+ for (const declaration of statement.declarationList.declarations) {
393
+ forEachDeclaredName(declaration.name, (name) =>
394
+ takeLocal(checker.getSymbolAtLocation(name), name),
395
+ );
396
+ }
397
+ } else if (
398
+ ts.isFunctionDeclaration(statement) ||
399
+ ts.isClassDeclaration(statement) ||
400
+ ts.isEnumDeclaration(statement)
401
+ ) {
402
+ if (statement.name !== undefined) {
403
+ takeLocal(checker.getSymbolAtLocation(statement.name), statement.name);
404
+ }
405
+ }
406
+ }
407
+
408
+ return exported;
409
+ }
410
+
411
+ function forEachDeclaredName(name: ts.BindingName, visit: (name: ts.Identifier) => void): void {
412
+ if (ts.isIdentifier(name)) {
413
+ visit(name);
414
+ return;
415
+ }
416
+ for (const element of name.elements) {
417
+ if (ts.isBindingElement(element)) {
418
+ forEachDeclaredName(element.name, visit);
419
+ }
420
+ }
421
+ }
422
+
423
+ function isLifecycleFactoryCall(expression: ts.Expression, checker: ts.TypeChecker): boolean {
424
+ return ts.isCallExpression(expression) && resolvesToFactoryExport(expression.expression, checker);
425
+ }
426
+
427
+ function isEffectFreeExpression(expression: ts.Expression | undefined): boolean {
428
+ if (expression === undefined) {
429
+ return true;
430
+ }
431
+ if (
432
+ ts.isParenthesizedExpression(expression) ||
433
+ ts.isAsExpression(expression) ||
434
+ ts.isTypeAssertionExpression(expression) ||
435
+ ts.isNonNullExpression(expression)
436
+ ) {
437
+ return isEffectFreeExpression(expression.expression);
438
+ }
439
+ if (ts.isIdentifier(expression) || ts.isLiteralExpression(expression)) {
440
+ return true;
441
+ }
442
+ switch (expression.kind) {
443
+ case ts.SyntaxKind.TrueKeyword:
444
+ case ts.SyntaxKind.FalseKeyword:
445
+ case ts.SyntaxKind.NullKeyword:
446
+ case ts.SyntaxKind.ThisKeyword:
447
+ return true;
448
+ default:
449
+ break;
450
+ }
451
+ if (ts.isFunctionExpression(expression) || ts.isArrowFunction(expression)) {
452
+ return true;
453
+ }
454
+ if (ts.isPrefixUnaryExpression(expression)) {
455
+ return (
456
+ expression.operator !== ts.SyntaxKind.PlusPlusToken &&
457
+ expression.operator !== ts.SyntaxKind.MinusMinusToken &&
458
+ isEffectFreeExpression(expression.operand)
459
+ );
460
+ }
461
+ if (ts.isBinaryExpression(expression)) {
462
+ return (
463
+ !isAssignmentOperator(expression.operatorToken.kind) &&
464
+ isEffectFreeExpression(expression.left) &&
465
+ isEffectFreeExpression(expression.right)
466
+ );
467
+ }
468
+ if (ts.isConditionalExpression(expression)) {
469
+ return (
470
+ isEffectFreeExpression(expression.condition) &&
471
+ isEffectFreeExpression(expression.whenTrue) &&
472
+ isEffectFreeExpression(expression.whenFalse)
473
+ );
474
+ }
475
+ if (ts.isArrayLiteralExpression(expression)) {
476
+ return expression.elements.every(isEffectFreeExpression);
477
+ }
478
+ if (ts.isObjectLiteralExpression(expression)) {
479
+ return expression.properties.every((property) => {
480
+ if (ts.isPropertyAssignment(property)) {
481
+ return isEffectFreeExpression(property.initializer);
482
+ }
483
+ return ts.isShorthandPropertyAssignment(property) || ts.isMethodDeclaration(property);
484
+ });
485
+ }
486
+ return false;
487
+ }
488
+
489
+ /**
490
+ * Whether a top-level statement can be reordered across the companion's load
491
+ * without changing what the source does. Judged syntactically and
492
+ * conservatively: anything not recognized is assumed effectful.
493
+ *
494
+ * Statements the emit erases are effect-free because they cannot reach the
495
+ * output at all. That covers a factory-only import, elided by
496
+ * `isFactoryOnlyImport`, and the lifecycle factory call itself, which erasure
497
+ * rewrites into hook function declarations — plain assignments that no
498
+ * companion load can observe. Classifying either as effectful would report the
499
+ * ordinary opening of essentially every source this analysis serves.
500
+ */
501
+ function isEffectFreeStatement(statement: ts.Statement, checker: ts.TypeChecker): boolean {
502
+ if (
503
+ ts.isImportDeclaration(statement) ||
504
+ ts.isInterfaceDeclaration(statement) ||
505
+ ts.isTypeAliasDeclaration(statement) ||
506
+ ts.isFunctionDeclaration(statement) ||
507
+ ts.isExportDeclaration(statement)
508
+ ) {
509
+ return true;
510
+ }
511
+ if (ts.isVariableStatement(statement)) {
512
+ return statement.declarationList.declarations.every((declaration) =>
513
+ isEffectFreeExpression(declaration.initializer),
514
+ );
515
+ }
516
+ if (ts.isExpressionStatement(statement)) {
517
+ return (
518
+ isLifecycleFactoryCall(statement.expression, checker) ||
519
+ isEffectFreeExpression(statement.expression)
520
+ );
521
+ }
522
+ if (ts.isExportAssignment(statement) && !statement.isExportEquals) {
523
+ return (
524
+ isLifecycleFactoryCall(statement.expression, checker) ||
525
+ isEffectFreeExpression(statement.expression)
526
+ );
527
+ }
528
+ return false;
529
+ }
530
+
531
+ function siteOf(node: ts.Node, role: string): ClosureSite {
532
+ const sourceFile = node.getSourceFile();
533
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
534
+ return { role, line: line + 1, column: character + 1 };
535
+ }
536
+
537
+ function formatSites(sites: readonly ClosureSite[]): string {
538
+ return sites.map((site) => `${site.role} at ${site.line}:${site.column}`).join(", ");
539
+ }
540
+
541
+ function bySourcePosition(a: ClosureSite, b: ClosureSite): number {
542
+ return a.line === b.line ? a.column - b.column : a.line - b.line;
543
+ }
544
+
545
+ /**
546
+ * Which of a script-kind source's top-level declarations would move into a
547
+ * companion module, and the shapes a split cannot preserve.
548
+ *
549
+ * The companion holds the transitive closure of top-level declarations
550
+ * reachable from the source's runtime value exports; everything else stays in
551
+ * the script and runs there once. Two shapes break that split — a binding
552
+ * reassigned on one side and read on the other, which a Lua local cannot carry,
553
+ * and effectful top-level work the split would reorder — and both are reported
554
+ * against the source rather than emitted.
555
+ */
556
+ export function computeCompanionClosure(
557
+ sourceFile: ts.SourceFile,
558
+ checker: ts.TypeChecker,
559
+ ): CompanionClosure {
560
+ const topLevel = collectTopLevel(sourceFile, checker);
561
+ const exportEntries = collectExports(sourceFile, checker, topLevel);
562
+
563
+ // Which statement declares a binding is what the emit moves, so the walk
564
+ // reaches its fixpoint over statements rather than names: moving one makes
565
+ // every binding it declares a member, and those bindings' own references
566
+ // extend the closure again. Without that, `const a = 1, b = p + 1` carries `b`
567
+ // across while leaving `p` behind, and the companion reads an unbound global.
568
+ const siblingsOf = new Map<ts.Statement, ts.Symbol[]>();
569
+ for (const [symbol, declaration] of topLevel) {
570
+ siblingsOf.set(declaration.statement, [
571
+ ...(siblingsOf.get(declaration.statement) ?? []),
572
+ symbol,
573
+ ]);
574
+ }
575
+
576
+ const memberSymbols = new Set<ts.Symbol>();
577
+ const importBindingSymbols = new Set<ts.Symbol>();
578
+ const closureStatements = new Set<ts.Statement>();
579
+ const queue: ts.Symbol[] = exportEntries.map((entry) => entry.symbol);
580
+ while (queue.length > 0) {
581
+ const symbol = queue.pop();
582
+ if (symbol === undefined || memberSymbols.has(symbol)) {
583
+ continue;
584
+ }
585
+ memberSymbols.add(symbol);
586
+ const declaration = topLevel.get(symbol);
587
+ if (declaration === undefined) {
588
+ continue;
589
+ }
590
+ // An import is copyable, not movable: TSTL emits its `require` bindings
591
+ // through its own hoisting, so there is no statement here to move.
592
+ if (ts.isImportDeclaration(declaration.statement)) {
593
+ importBindingSymbols.add(symbol);
594
+ continue;
595
+ }
596
+ if (!closureStatements.has(declaration.statement)) {
597
+ closureStatements.add(declaration.statement);
598
+ for (const sibling of siblingsOf.get(declaration.statement) ?? []) {
599
+ if (!memberSymbols.has(sibling)) {
600
+ queue.push(sibling);
601
+ }
602
+ }
603
+ }
604
+ forEachReference(declaration.node, checker, (_node, referenced) => {
605
+ if (topLevel.has(referenced) && !memberSymbols.has(referenced)) {
606
+ queue.push(referenced);
607
+ }
608
+ });
609
+ }
610
+
611
+ // An export declaration is the machinery that opens the companion, so it
612
+ // travels with what it reads: leaving it behind makes the script assign a
613
+ // local it no longer binds, into a table every importer has already loaded.
614
+ const splits = closureStatements.size > 0;
615
+ for (const statement of sourceFile.statements) {
616
+ if (!ts.isExportDeclaration(statement) || statement.isTypeOnly) {
617
+ continue;
618
+ }
619
+ if (statement.moduleSpecifier !== undefined) {
620
+ // A re-export's whole emitted block is export machinery, with no local
621
+ // half to keep, so it moves whenever the source splits at all.
622
+ if (splits) {
623
+ closureStatements.add(statement);
624
+ }
625
+ continue;
626
+ }
627
+ const clause = statement.exportClause;
628
+ if (clause === undefined || !ts.isNamedExports(clause)) {
629
+ continue;
630
+ }
631
+ const readsMember = clause.elements.some((element) => {
632
+ if (element.isTypeOnly) {
633
+ return false;
634
+ }
635
+ const local = checker.getExportSpecifierLocalTargetSymbol(element);
636
+ return local !== undefined && memberSymbols.has(local);
637
+ });
638
+ if (readsMember) {
639
+ closureStatements.add(statement);
640
+ }
641
+ }
642
+
643
+ // Reassignment anywhere in the source, the companion side included: that is
644
+ // what the `increment()` shape turns on, where nothing in the script assigns.
645
+ const mutations = new Map<ts.Symbol, ts.Identifier[]>();
646
+ forEachAssignmentTarget(sourceFile, checker, (node, symbol) => {
647
+ if (!memberSymbols.has(symbol)) {
648
+ return;
649
+ }
650
+ mutations.set(symbol, [...(mutations.get(symbol) ?? []), node]);
651
+ });
652
+
653
+ // The script is every top-level statement outside the closure. An export
654
+ // declaration is neither: it is the machinery that opens the companion, not
655
+ // code that runs in the script.
656
+ const scriptReferences = new Map<ts.Symbol, ts.Identifier[]>();
657
+ for (const statement of sourceFile.statements) {
658
+ if (closureStatements.has(statement) || ts.isExportDeclaration(statement)) {
659
+ continue;
660
+ }
661
+ forEachReference(statement, checker, (node, symbol) => {
662
+ if (memberSymbols.has(symbol)) {
663
+ scriptReferences.set(symbol, [...(scriptReferences.get(symbol) ?? []), node]);
664
+ }
665
+ });
666
+ }
667
+
668
+ const ordered = [...memberSymbols]
669
+ .flatMap((symbol) => {
670
+ const declaration = topLevel.get(symbol);
671
+ return declaration === undefined ? [] : [{ symbol, declaration }];
672
+ })
673
+ .sort((a, b) => a.declaration.order - b.declaration.order);
674
+
675
+ const exportSet = new Set(exportEntries.map((entry) => entry.symbol));
676
+ const violations: ClosureViolation[] = [];
677
+
678
+ for (const { symbol, declaration } of ordered) {
679
+ const mutated = mutations.get(symbol);
680
+ const referenced = scriptReferences.get(symbol);
681
+ if (mutated === undefined || referenced === undefined) {
682
+ continue;
683
+ }
684
+ const sites = [
685
+ ...mutated.map((node) => siteOf(node, "reassigned")),
686
+ ...referenced.map((node) => siteOf(node, "read by the script")),
687
+ ]
688
+ .filter(
689
+ (site, index, all) =>
690
+ all.findIndex((other) => other.line === site.line && other.column === site.column) ===
691
+ index,
692
+ )
693
+ .sort(bySourcePosition);
694
+ violations.push({
695
+ kind: "shared-mutable",
696
+ member: declaration.name,
697
+ sites,
698
+ message:
699
+ `"${declaration.name}" is reassigned and is also used outside the companion, ` +
700
+ `so the script's copy would never see the update (${formatSites(sites)}). ` +
701
+ "Move that state into a source with no lifecycle factory.",
702
+ });
703
+ }
704
+
705
+ for (const entry of exportEntries) {
706
+ if (entry.name !== COMPANION_INTERNAL_FIELD) {
707
+ continue;
708
+ }
709
+ const sites = [siteOf(entry.nameNode, "exported")];
710
+ violations.push({
711
+ kind: "reserved-internal-name",
712
+ member: entry.name,
713
+ sites,
714
+ message:
715
+ `"${COMPANION_INTERNAL_FIELD}" is reserved for the companion's own internals ` +
716
+ `and cannot be exported (${formatSites(sites)}). Rename the export.`,
717
+ });
718
+ }
719
+
720
+ // The companion loads as a unit, so the constraint is pairwise: any effectful
721
+ // script statement with any closure declaration below it is reordered by the
722
+ // split, whatever the first member's position.
723
+ for (const statement of sourceFile.statements) {
724
+ if (closureStatements.has(statement) || isEffectFreeStatement(statement, checker)) {
725
+ continue;
726
+ }
727
+ const start = statement.getStart(sourceFile);
728
+ const hoisted = ordered.find(
729
+ (entry) => entry.declaration.statement.getStart(sourceFile) > start,
730
+ );
731
+ if (hoisted === undefined) {
732
+ continue;
733
+ }
734
+ const sites = [
735
+ siteOf(statement, "runs in the script"),
736
+ siteOf(hoisted.declaration.node, "moves into the companion"),
737
+ ];
738
+ violations.push({
739
+ kind: "initialization-order",
740
+ member: hoisted.declaration.name,
741
+ sites,
742
+ message:
743
+ `top-level work precedes "${hoisted.declaration.name}", which the companion would run first, ` +
744
+ `reversing their order (${formatSites(sites)}). ` +
745
+ "Move that work below the companion's declarations, or into a lifecycle hook.",
746
+ });
747
+ }
748
+
749
+ return {
750
+ members: ordered.map(({ declaration }) => declaration.name),
751
+ exports: exportEntries.map((entry) => entry.name),
752
+ statements: sourceFile.statements.filter((statement) => closureStatements.has(statement)),
753
+ importBindings: ordered
754
+ .filter(({ symbol }) => importBindingSymbols.has(symbol))
755
+ .map(({ declaration }) => declaration.name),
756
+ internals: ordered
757
+ .filter(({ symbol }) => !exportSet.has(symbol) && scriptReferences.has(symbol))
758
+ .map(({ declaration }) => declaration.name),
759
+ violations,
760
+ };
761
+ }