@defold-typescript/transpiler 0.52.0 → 0.54.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.
@@ -5,6 +5,10 @@ export interface EmittedRequireSite {
5
5
  readonly start: number;
6
6
  /** Offset one past its closing delimiter. */
7
7
  readonly end: number;
8
+ /** Offset of the `require` keyword, when the whole call is recoverable. */
9
+ readonly callStart?: number;
10
+ /** Offset one past the closing paren, when the whole call is recoverable. */
11
+ readonly callEnd?: number;
8
12
  }
9
13
  /**
10
14
  * Every code-position `require` of a string literal in an emitted Lua chunk, in
@@ -0,0 +1,20 @@
1
+ import type { Plugin } from "typescript-to-lua";
2
+ export declare const GLOBAL_TABLE_REQUIRE_MARKER = "@defold-typescript:global-table:";
3
+ export declare const nativeGlobalModulesPlugin: Plugin;
4
+ /**
5
+ * Replace every marker require the scanner can span with the global name it
6
+ * encodes: `local p = require("<marker>pixels")` becomes `local p = _G.pixels`,
7
+ * rooted in `_G` so no local in scope — including one named `pixels` — can
8
+ * shadow the global. A marker site without a recoverable call span is left in
9
+ * place for `findGlobalAliasMarkerLeak` to fail the build on.
10
+ */
11
+ export declare function rewriteGlobalAliasRequires(lua: string): string;
12
+ /**
13
+ * The global name of the first marker require surviving in a chunk after the
14
+ * swap, or undefined when the chunk is clean. Only require paths the scanner
15
+ * yields count — marker text in an ordinary string or comment is user data,
16
+ * not a leak. A survivor means an emit shape the swap cannot reach; the output
17
+ * would fail to load in Defold, so the caller turns it into a build error
18
+ * rather than shipping it.
19
+ */
20
+ export declare function findGlobalAliasMarkerLeak(lua: string): string | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defold-typescript/transpiler",
3
- "version": "0.52.0",
3
+ "version": "0.54.0",
4
4
  "description": "TypeScript-to-Lua build pipeline tuned for Defold's runtime.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,9 +35,12 @@
35
35
  "test": "bun test"
36
36
  },
37
37
  "dependencies": {
38
- "@defold-typescript/types": "0.52.0",
38
+ "@defold-typescript/types": "0.54.0",
39
39
  "@typescript-to-lua/language-extensions": "1.19.0",
40
40
  "typescript": "6.0.2",
41
41
  "typescript-to-lua": "^1.36.0"
42
+ },
43
+ "devDependencies": {
44
+ "lua-wasm-bindings": "0.5.3"
42
45
  }
43
46
  }
@@ -1,4 +1,5 @@
1
1
  import * as ts from "typescript";
2
+ import { findManagedBootstrap } from "./debug-bootstrap";
2
3
  import { resolvesToFactoryExport } from "./lifecycle-erasure";
3
4
 
4
5
  /**
@@ -49,6 +50,13 @@ export interface CompanionClosure {
49
50
  * companion takes its own `require` of the same module instead.
50
51
  */
51
52
  readonly importBindings: readonly string[];
53
+ /**
54
+ * The unedited `setup-debug` managed block's statements, in source order, or
55
+ * empty. They are exempt from the order rule because the emit runs them ahead
56
+ * of the companion's `require`, so the debugger starts before anything the
57
+ * companion loads.
58
+ */
59
+ readonly bootstrap: readonly ts.Statement[];
52
60
  /** Shapes a split cannot preserve. Empty means the source is splittable. */
53
61
  readonly violations: readonly ClosureViolation[];
54
62
  }
@@ -717,11 +725,20 @@ export function computeCompanionClosure(
717
725
  });
718
726
  }
719
727
 
728
+ const managed = findManagedBootstrap(sourceFile);
729
+ const bootstrap = managed?.canonical === true ? managed.statements : [];
730
+ const editedBlock = new Set(managed?.canonical === false ? managed.statements : []);
731
+ const exempt = new Set(bootstrap);
732
+
720
733
  // The companion loads as a unit, so the constraint is pairwise: any effectful
721
734
  // script statement with any closure declaration below it is reordered by the
722
735
  // split, whatever the first member's position.
723
736
  for (const statement of sourceFile.statements) {
724
- if (closureStatements.has(statement) || isEffectFreeStatement(statement, checker)) {
737
+ if (
738
+ closureStatements.has(statement) ||
739
+ exempt.has(statement) ||
740
+ isEffectFreeStatement(statement, checker)
741
+ ) {
725
742
  continue;
726
743
  }
727
744
  const start = statement.getStart(sourceFile);
@@ -735,14 +752,18 @@ export function computeCompanionClosure(
735
752
  siteOf(statement, "runs in the script"),
736
753
  siteOf(hoisted.declaration.node, "moves into the companion"),
737
754
  ];
755
+ const name = hoisted.declaration.name;
738
756
  violations.push({
739
757
  kind: "initialization-order",
740
- member: hoisted.declaration.name,
758
+ member: name,
741
759
  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.",
760
+ message: editedBlock.has(statement)
761
+ ? `the setup-debug managed block was edited and now runs top-level work before "${name}", ` +
762
+ `which the companion would run first (${formatSites(sites)}). ` +
763
+ "Run `defold-typescript setup-debug` to restore the block."
764
+ : `top-level work precedes "${name}", which the companion would run first, ` +
765
+ `reversing their order (${formatSites(sites)}). ` +
766
+ "Move that work below the companion's declarations, or into a lifecycle hook.",
746
767
  });
747
768
  }
748
769
 
@@ -756,6 +777,7 @@ export function computeCompanionClosure(
756
777
  internals: ordered
757
778
  .filter(({ symbol }) => !exportSet.has(symbol) && scriptReferences.has(symbol))
758
779
  .map(({ declaration }) => declaration.name),
780
+ bootstrap,
759
781
  violations,
760
782
  };
761
783
  }
@@ -66,6 +66,8 @@ function isRuntimeScriptSource(sourceFile: ts.SourceFile, checker: ts.TypeChecke
66
66
  interface CompanionPlan {
67
67
  /** Full start of each top-level statement the companion takes over. */
68
68
  readonly closureStarts: ReadonlySet<number>;
69
+ /** Full start of each unedited `setup-debug` block statement. */
70
+ readonly bootstrapStarts: ReadonlySet<number>;
69
71
  /** Members the script reaches by bare name, which the companion must expose. */
70
72
  readonly internals: readonly string[];
71
73
  /** Import bindings the companion needs its own `require` of. */
@@ -86,6 +88,7 @@ function planFor(sourceFile: ts.SourceFile, checker: ts.TypeChecker): CompanionP
86
88
  }
87
89
  return {
88
90
  closureStarts: new Set(closure.statements.map((statement) => statement.pos)),
91
+ bootstrapStarts: new Set(closure.bootstrap.map((statement) => statement.pos)),
89
92
  internals: closure.internals,
90
93
  importBindings: closure.importBindings,
91
94
  requirePath: requirePathForRel(sourceFile.fileName),
@@ -466,6 +469,10 @@ interface SplitHalves {
466
469
  * first. The run stops at the first statement that reads the companion, and at
467
470
  * the first non-declaration — a lifecycle hook closes over the internals it
468
471
  * reads, so its declaration must precede the hook's.
472
+ *
473
+ * The `setup-debug` block's gate is taken out of `script` before this runs and
474
+ * placed immediately ahead of the `require`, so the debugger starts before any
475
+ * companion code does while `lldebugger` itself is still required above it.
469
476
  */
470
477
  function requireInsertionPoint(script: readonly Statement[], reads: ReadonlySet<string>): number {
471
478
  let index = 0;
@@ -524,6 +531,7 @@ export interface CompanionEmitter {
524
531
  export function createCompanionEmitPlugin(): CompanionEmitter {
525
532
  const plans = new Map<string, CompanionPlan | undefined>();
526
533
  const owned = new Map<string, Set<Statement>>();
534
+ const bootstraps = new Map<string, Set<Statement>>();
527
535
  const companions = new Map<string, string>();
528
536
 
529
537
  function planOf(sourceFile: ts.SourceFile, checker: ts.TypeChecker): CompanionPlan | undefined {
@@ -552,6 +560,24 @@ export function createCompanionEmitPlugin(): CompanionEmitter {
552
560
  return statements;
553
561
  }
554
562
 
563
+ function captureBootstrap(node: ts.IfStatement, context: TransformationContext): Statement[] {
564
+ const statements = context.superTransformStatements(node);
565
+ const sourceFile = context.sourceFile;
566
+ const plan = planOf(sourceFile, context.checker);
567
+ if (plan === undefined || !plan.bootstrapStarts.has(node.pos)) {
568
+ return statements;
569
+ }
570
+ let bucket = bootstraps.get(sourceFile.fileName);
571
+ if (bucket === undefined) {
572
+ bucket = new Set();
573
+ bootstraps.set(sourceFile.fileName, bucket);
574
+ }
575
+ for (const statement of statements) {
576
+ bucket.add(statement);
577
+ }
578
+ return statements;
579
+ }
580
+
555
581
  const visitors: Plugin["visitors"] = {
556
582
  [ts.SyntaxKind.VariableStatement]: capture,
557
583
  [ts.SyntaxKind.FunctionDeclaration]: capture,
@@ -561,6 +587,7 @@ export function createCompanionEmitPlugin(): CompanionEmitter {
561
587
  // because TSTL emits its `require` bindings through its own hoisting. The
562
588
  // companion takes a copy of that prelude instead — see `copiedPrelude`.
563
589
  [ts.SyntaxKind.ExportDeclaration]: capture,
590
+ [ts.SyntaxKind.IfStatement]: captureBootstrap,
564
591
  };
565
592
 
566
593
  let configuredNoResolvePaths: readonly string[] = [];
@@ -582,7 +609,8 @@ export function createCompanionEmitPlugin(): CompanionEmitter {
582
609
  if (plan === undefined || bucket === undefined || bucket.size === 0) {
583
610
  continue;
584
611
  }
585
- emitCompanion(program, emitHost, file, sourceFile, plan, bucket, companions);
612
+ const bootstrap = bootstraps.get(sourceFile.fileName) ?? new Set();
613
+ emitCompanion(program, emitHost, file, sourceFile, plan, bucket, bootstrap, companions);
586
614
  emitted.push(plan.requirePath);
587
615
  }
588
616
  // The companion is written by the CLI, not by TSTL's emit plan, so TSTL's
@@ -606,6 +634,7 @@ function emitCompanion(
606
634
  sourceFile: ts.SourceFile,
607
635
  plan: CompanionPlan,
608
636
  owned: ReadonlySet<Statement>,
637
+ bootstrap: ReadonlySet<Statement>,
609
638
  companions: Map<string, string>,
610
639
  ): void {
611
640
  const luaAst = file.luaAst;
@@ -644,9 +673,11 @@ function emitCompanion(
644
673
  ];
645
674
 
646
675
  const reads = new Set([EXPORTS_NAME, ...plan.internals]);
647
- const insertAt = requireInsertionPoint(halves.script, reads);
676
+ const rest = halves.script.filter((statement) => !bootstrap.has(statement));
677
+ const insertAt = requireInsertionPoint(rest, reads);
648
678
  const scriptStatements: Statement[] = [
649
- ...halves.script.slice(0, insertAt),
679
+ ...rest.slice(0, insertAt),
680
+ ...halves.script.filter((statement) => bootstrap.has(statement)),
650
681
  createVariableDeclarationStatement(
651
682
  exportsIdentifier(),
652
683
  createCallExpression(createIdentifier("require"), [createStringLiteral(plan.requirePath)]),
@@ -654,7 +685,7 @@ function emitCompanion(
654
685
  ...plan.internals.map((name) =>
655
686
  createVariableDeclarationStatement(createIdentifier(name), internalsField(name)),
656
687
  ),
657
- ...halves.script.slice(insertAt),
688
+ ...rest.slice(insertAt),
658
689
  createReturnStatement([exportsIdentifier()]),
659
690
  ];
660
691
 
@@ -0,0 +1,88 @@
1
+ import * as ts from "typescript";
2
+
3
+ // Managed-block sentinels. A re-run locates the single BEGIN…END pair and
4
+ // refreshes the enclosed text if it drifted, so wording changes self-heal.
5
+ // They are `//` line comments, stripped by TSTL, so they never reach the
6
+ // emitted `.ts.script`.
7
+ export const DEBUG_BLOCK_BEGIN =
8
+ "// defold-typescript:setup-debug BEGIN — managed block, do not edit";
9
+ export const DEBUG_BLOCK_END = "// defold-typescript:setup-debug END";
10
+
11
+ // The canonical managed block: import + gated start, marker-free, no
12
+ // `declare module` (that lives in the ambient `.d.ts`). Mirrors the snippet in
13
+ // `packages/docs/guide/debugging.md` exactly; a guide-comparison test locks the two.
14
+ // The blank line before END keeps Biome's organizeImports assist from gluing
15
+ // the END comment onto a following import as its leading comment (which would
16
+ // then demand a blank line before the whole group). With blank lines on both
17
+ // sides of END, an import injected above the user's own imports stays clean.
18
+ export const DEBUG_MANAGED_BLOCK = `${DEBUG_BLOCK_BEGIN}
19
+ import * as lldebugger from "lldebugger.debug";
20
+
21
+ if (sys.get_engine_info().is_debug) {
22
+ lldebugger.start();
23
+ }
24
+
25
+ ${DEBUG_BLOCK_END}`;
26
+
27
+ export interface ManagedBootstrap {
28
+ /** The top-level statements wholly between the sentinels, in source order. */
29
+ readonly statements: readonly ts.Statement[];
30
+ /**
31
+ * Whether the text between the sentinels is the canonical body token for
32
+ * token, so line endings, quote style, comments and optional semicolons never
33
+ * decide it.
34
+ */
35
+ readonly canonical: boolean;
36
+ }
37
+
38
+ function occurrences(haystack: string, needle: string): number {
39
+ return haystack.split(needle).length - 1;
40
+ }
41
+
42
+ function bodyTokens(text: string): string[] {
43
+ const scanner = ts.createScanner(ts.ScriptTarget.Latest, true, ts.LanguageVariant.Standard, text);
44
+ const tokens: string[] = [];
45
+ for (let kind = scanner.scan(); kind !== ts.SyntaxKind.EndOfFileToken; kind = scanner.scan()) {
46
+ if (kind === ts.SyntaxKind.SemicolonToken) {
47
+ continue;
48
+ }
49
+ const spelling =
50
+ kind === ts.SyntaxKind.StringLiteral ? scanner.getTokenValue() : scanner.getTokenText();
51
+ tokens.push(`${kind}:${spelling}`);
52
+ }
53
+ return tokens;
54
+ }
55
+
56
+ const CANONICAL_BODY_TOKENS = bodyTokens(
57
+ DEBUG_MANAGED_BLOCK.slice(
58
+ DEBUG_BLOCK_BEGIN.length,
59
+ DEBUG_MANAGED_BLOCK.length - DEBUG_BLOCK_END.length,
60
+ ),
61
+ );
62
+
63
+ /**
64
+ * The `setup-debug` managed block in a source, located the way
65
+ * `upsertManagedBlock` locates it: exactly one BEGIN and one END, BEGIN first.
66
+ * Any other sentinel shape is one `setup-debug` itself refuses to edit, so it
67
+ * is not a managed block at all.
68
+ */
69
+ export function findManagedBootstrap(sourceFile: ts.SourceFile): ManagedBootstrap | undefined {
70
+ const text = sourceFile.text;
71
+ if (occurrences(text, DEBUG_BLOCK_BEGIN) !== 1 || occurrences(text, DEBUG_BLOCK_END) !== 1) {
72
+ return undefined;
73
+ }
74
+ const beginAt = text.indexOf(DEBUG_BLOCK_BEGIN);
75
+ const endAt = text.indexOf(DEBUG_BLOCK_END);
76
+ if (endAt < beginAt) {
77
+ return undefined;
78
+ }
79
+ const bodyStart = beginAt + DEBUG_BLOCK_BEGIN.length;
80
+ const statements = sourceFile.statements.filter(
81
+ (statement) => statement.getStart(sourceFile) >= bodyStart && statement.end <= endAt,
82
+ );
83
+ const tokens = bodyTokens(text.slice(bodyStart, endAt));
84
+ const canonical =
85
+ tokens.length === CANONICAL_BODY_TOKENS.length &&
86
+ tokens.every((token, index) => token === CANONICAL_BODY_TOKENS[index]);
87
+ return { statements, canonical };
88
+ }
package/src/index.ts CHANGED
@@ -11,6 +11,13 @@ export {
11
11
  componentIdOfSameObjectAddress,
12
12
  } from "./component-animation-index";
13
13
  export { buildConfigKeyIndex, readGameProjectSetting } from "./config-key-index";
14
+ export type { ManagedBootstrap } from "./debug-bootstrap";
15
+ export {
16
+ DEBUG_BLOCK_BEGIN,
17
+ DEBUG_BLOCK_END,
18
+ DEBUG_MANAGED_BLOCK,
19
+ findManagedBootstrap,
20
+ } from "./debug-bootstrap";
14
21
  export { findEmittedRequires, LUALIB_REQUIRE_NAME } from "./emitted-requires";
15
22
  export type { GuiFlipbookIndex } from "./gui-flipbook-index";
16
23
  export { buildGuiFlipbookIndex } from "./gui-flipbook-index";
Binary file
@@ -0,0 +1,183 @@
1
+ import * as ts from "typescript";
2
+ import type { Plugin } from "typescript-to-lua";
3
+ import { scanEmittedRequires } from "./lua-require-scan";
4
+
5
+ const GLOBAL_TABLE_TAG = "globalTable";
6
+
7
+ // Survives into the emitted require path, so it must collide with no real
8
+ // module path a build can own: `@` and `:` are impossible in one.
9
+ export const GLOBAL_TABLE_REQUIRE_MARKER = "@defold-typescript:global-table:";
10
+
11
+ // `@NoResolution:` tells TSTL's resolver to leave the path alone (the module
12
+ // has no source file in the program); the resolver strips the prefix, leaving
13
+ // the marker path for the post-pass swap.
14
+ const NO_RESOLUTION_PREFIX = "@NoResolution:";
15
+
16
+ const LUA_GLOBAL_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
17
+
18
+ type TagResolution =
19
+ | { readonly kind: "untagged" }
20
+ | { readonly kind: "tagged"; readonly globalName: string }
21
+ | { readonly kind: "invalid"; readonly globalName: string; readonly moduleId: string };
22
+
23
+ // The tag is consulted only when the specifier resolves to an ambient
24
+ // `declare module "<id>"`; a real source file sharing the id resolves to the
25
+ // file and stays on the ordinary require lane.
26
+ function resolveGlobalTableTag(specifier: ts.Expression, checker: ts.TypeChecker): TagResolution {
27
+ if (!ts.isStringLiteral(specifier)) {
28
+ return { kind: "untagged" };
29
+ }
30
+ const symbol = checker.getSymbolAtLocation(specifier);
31
+ for (const declaration of symbol?.declarations ?? []) {
32
+ if (!ts.isModuleDeclaration(declaration) || !ts.isStringLiteral(declaration.name)) {
33
+ continue;
34
+ }
35
+ const tag = ts.getJSDocTags(declaration).find((t) => t.tagName.text === GLOBAL_TABLE_TAG);
36
+ if (tag === undefined) {
37
+ continue;
38
+ }
39
+ const comment = ts.getTextOfJSDocComment(tag.comment)?.trim();
40
+ const globalName =
41
+ comment === undefined || comment === ""
42
+ ? specifier.text
43
+ : (comment.split(/\s+/)[0] ?? specifier.text);
44
+ if (!LUA_GLOBAL_NAME_RE.test(globalName)) {
45
+ return { kind: "invalid", globalName, moduleId: specifier.text };
46
+ }
47
+ return { kind: "tagged", globalName };
48
+ }
49
+ return { kind: "untagged" };
50
+ }
51
+
52
+ function markerRequirePath(globalName: string): string {
53
+ return `${NO_RESOLUTION_PREFIX}${GLOBAL_TABLE_REQUIRE_MARKER}${globalName}`;
54
+ }
55
+
56
+ function invalidGlobalName(
57
+ node: ts.Node,
58
+ resolution: {
59
+ readonly globalName: string;
60
+ readonly moduleId: string;
61
+ },
62
+ ): ts.Diagnostic {
63
+ const file = node.getSourceFile();
64
+ const start = node.getStart(file);
65
+ return {
66
+ file,
67
+ start,
68
+ length: node.getEnd() - start,
69
+ category: ts.DiagnosticCategory.Error,
70
+ code: 0,
71
+ messageText: `@${GLOBAL_TABLE_TAG} on module "${resolution.moduleId}" names \`${resolution.globalName}\`, which is not a valid Lua global name; use dotted identifiers (\`pixels\`, \`b2d.body\`).`,
72
+ };
73
+ }
74
+
75
+ export const nativeGlobalModulesPlugin: Plugin = {
76
+ visitors: {
77
+ [ts.SyntaxKind.ImportDeclaration]: (node, context) => {
78
+ const resolution = resolveGlobalTableTag(node.moduleSpecifier, context.checker);
79
+ if (resolution.kind === "invalid") {
80
+ context.diagnostics.push(invalidGlobalName(node.moduleSpecifier, resolution));
81
+ return context.superTransformStatements(node);
82
+ }
83
+ if (resolution.kind === "untagged") {
84
+ return context.superTransformStatements(node);
85
+ }
86
+ if (node.importClause === undefined) {
87
+ // A side-effect import of a runtime global loads nothing.
88
+ return [];
89
+ }
90
+ const rewritten = ts.factory.updateImportDeclaration(
91
+ node,
92
+ node.modifiers,
93
+ node.importClause,
94
+ ts.factory.createStringLiteral(markerRequirePath(resolution.globalName)),
95
+ node.attributes,
96
+ );
97
+ return context.superTransformStatements(rewritten);
98
+ },
99
+ [ts.SyntaxKind.ExportDeclaration]: (node, context) => {
100
+ const specifier = node.moduleSpecifier;
101
+ if (specifier === undefined) {
102
+ return context.superTransformStatements(node);
103
+ }
104
+ const resolution = resolveGlobalTableTag(specifier, context.checker);
105
+ if (resolution.kind === "invalid") {
106
+ context.diagnostics.push(invalidGlobalName(specifier, resolution));
107
+ return context.superTransformStatements(node);
108
+ }
109
+ if (resolution.kind === "untagged") {
110
+ return context.superTransformStatements(node);
111
+ }
112
+ const rewritten = ts.factory.updateExportDeclaration(
113
+ node,
114
+ node.modifiers,
115
+ node.isTypeOnly,
116
+ node.exportClause,
117
+ ts.factory.createStringLiteral(markerRequirePath(resolution.globalName)),
118
+ node.attributes,
119
+ );
120
+ return context.superTransformStatements(rewritten);
121
+ },
122
+ [ts.SyntaxKind.CallExpression]: (node, context) => {
123
+ const specifier = node.arguments[0];
124
+ if (node.expression.kind !== ts.SyntaxKind.ImportKeyword || specifier === undefined) {
125
+ return context.superTransformExpression(node);
126
+ }
127
+ const resolution = resolveGlobalTableTag(specifier, context.checker);
128
+ if (resolution.kind === "invalid") {
129
+ context.diagnostics.push(invalidGlobalName(specifier, resolution));
130
+ return context.superTransformExpression(node);
131
+ }
132
+ if (resolution.kind === "untagged") {
133
+ return context.superTransformExpression(node);
134
+ }
135
+ const rewritten = ts.factory.updateCallExpression(node, node.expression, node.typeArguments, [
136
+ ts.factory.createStringLiteral(markerRequirePath(resolution.globalName)),
137
+ ...node.arguments.slice(1),
138
+ ]);
139
+ return context.superTransformExpression(rewritten);
140
+ },
141
+ },
142
+ };
143
+
144
+ /**
145
+ * Replace every marker require the scanner can span with the global name it
146
+ * encodes: `local p = require("<marker>pixels")` becomes `local p = _G.pixels`,
147
+ * rooted in `_G` so no local in scope — including one named `pixels` — can
148
+ * shadow the global. A marker site without a recoverable call span is left in
149
+ * place for `findGlobalAliasMarkerLeak` to fail the build on.
150
+ */
151
+ export function rewriteGlobalAliasRequires(lua: string): string {
152
+ let out = "";
153
+ let cursor = 0;
154
+ for (const site of scanEmittedRequires(lua)) {
155
+ if (!site.path.startsWith(GLOBAL_TABLE_REQUIRE_MARKER)) {
156
+ continue;
157
+ }
158
+ if (site.callStart === undefined || site.callEnd === undefined) {
159
+ continue;
160
+ }
161
+ out += lua.slice(cursor, site.callStart);
162
+ out += `_G.${site.path.slice(GLOBAL_TABLE_REQUIRE_MARKER.length)}`;
163
+ cursor = site.callEnd;
164
+ }
165
+ return out + lua.slice(cursor);
166
+ }
167
+
168
+ /**
169
+ * The global name of the first marker require surviving in a chunk after the
170
+ * swap, or undefined when the chunk is clean. Only require paths the scanner
171
+ * yields count — marker text in an ordinary string or comment is user data,
172
+ * not a leak. A survivor means an emit shape the swap cannot reach; the output
173
+ * would fail to load in Defold, so the caller turns it into a build error
174
+ * rather than shipping it.
175
+ */
176
+ export function findGlobalAliasMarkerLeak(lua: string): string | undefined {
177
+ for (const site of scanEmittedRequires(lua)) {
178
+ if (site.path.startsWith(GLOBAL_TABLE_REQUIRE_MARKER)) {
179
+ return site.path.slice(GLOBAL_TABLE_REQUIRE_MARKER.length);
180
+ }
181
+ }
182
+ return undefined;
183
+ }
package/src/session.ts CHANGED
@@ -9,6 +9,7 @@ import { hsmLoweringPlugin } from "./hsm-builtin";
9
9
  import { lifecycleErasurePlugin } from "./lifecycle-erasure";
10
10
  import { messageDispatchLoweringPlugin } from "./message-dispatch-lowering";
11
11
  import { messageGuardLoweringPlugin } from "./message-guard-lowering";
12
+ import { nativeGlobalModulesPlugin } from "./native-global-modules";
12
13
  import { physicsEventGuardLoweringPlugin } from "./physics-event-guard-lowering";
13
14
  import { timersLoweringPlugin } from "./timers-lowering";
14
15
  import { AMBIENT_FILES, collectOutputs, type TranspileProjectResult } from "./transpile";
@@ -54,6 +55,7 @@ function compilerOptions(companionEmit: CompanionEmitter): tstl.CompilerOptions
54
55
  { plugin: physicsEventGuardLoweringPlugin },
55
56
  { plugin: messageDispatchLoweringPlugin },
56
57
  { plugin: timersLoweringPlugin },
58
+ { plugin: nativeGlobalModulesPlugin },
57
59
  { plugin: hsmLoweringPlugin },
58
60
  { plugin: typeApplicationErasurePlugin },
59
61
  { plugin: companionEmit.plugin },
package/src/transpile.ts CHANGED
@@ -21,6 +21,11 @@ import { lifecycleErasurePlugin } from "./lifecycle-erasure";
21
21
  import { scanEmittedRequires } from "./lua-require-scan";
22
22
  import { messageDispatchLoweringPlugin } from "./message-dispatch-lowering";
23
23
  import { messageGuardLoweringPlugin } from "./message-guard-lowering";
24
+ import {
25
+ findGlobalAliasMarkerLeak,
26
+ nativeGlobalModulesPlugin,
27
+ rewriteGlobalAliasRequires,
28
+ } from "./native-global-modules";
24
29
  import { physicsEventGuardLoweringPlugin } from "./physics-event-guard-lowering";
25
30
  import { importsTimersModule, timersLoweringPlugin } from "./timers-lowering";
26
31
  import { TIMERS_RUNTIME } from "./timers-runtime";
@@ -258,6 +263,7 @@ export function collectOutputs(
258
263
  const lua: Record<string, string> = {};
259
264
  const sourceMaps: Record<string, string> = {};
260
265
  const companions: Record<string, string> = {};
266
+ const markerLeaks: { file: string; globalName: string }[] = [];
261
267
  let lualib: string | undefined;
262
268
  for (const file of transpiledFiles) {
263
269
  if (isLualibBundle(file) && typeof file.lua === "string") {
@@ -268,13 +274,25 @@ export function collectOutputs(
268
274
  if (!userSource || typeof file.lua !== "string") {
269
275
  continue;
270
276
  }
271
- lua[userSource.fileName] = file.lua;
277
+ // Column offsets after a swap shift, as they already do under the CLI's
278
+ // require respelling — accepted.
279
+ const swapped = rewriteGlobalAliasRequires(file.lua);
280
+ lua[userSource.fileName] = swapped;
281
+ const leaked = findGlobalAliasMarkerLeak(swapped);
282
+ if (leaked !== undefined) {
283
+ markerLeaks.push({ file: userSource.fileName, globalName: leaked });
284
+ }
272
285
  if (typeof file.luaSourceMap === "string") {
273
286
  sourceMaps[userSource.fileName] = file.luaSourceMap;
274
287
  }
275
288
  const companion = emittedCompanions.get(userSource.fileName);
276
289
  if (companion !== undefined) {
277
- companions[userSource.fileName] = companion;
290
+ const swappedCompanion = rewriteGlobalAliasRequires(companion);
291
+ companions[userSource.fileName] = swappedCompanion;
292
+ const companionLeak = findGlobalAliasMarkerLeak(swappedCompanion);
293
+ if (companionLeak !== undefined) {
294
+ markerLeaks.push({ file: userSource.fileName, globalName: companionLeak });
295
+ }
278
296
  }
279
297
  }
280
298
 
@@ -291,6 +309,15 @@ export function collectOutputs(
291
309
  return { file: fileName, message, line: line + 1, column: character + 1 };
292
310
  });
293
311
 
312
+ // A surviving marker is a build error, not a fallback: the alternative —
313
+ // leaving the original require — fails silently at load time in Defold.
314
+ for (const leak of markerLeaks) {
315
+ collectedDiagnostics.push({
316
+ file: leak.file,
317
+ message: `@globalTable lowering left an unrewritten require for \`${leak.globalName}\` in the emitted chunk; the output would fail to load in Defold.`,
318
+ });
319
+ }
320
+
294
321
  // Advisory scan of the user TypeScript AST for the deprecated direct
295
322
  // `go.property` call, plus detection of the timers polyfill import. Run here
296
323
  // (shared by transpileProject and the watch session) so both paths report it
@@ -378,6 +405,7 @@ function transpileVirtual(
378
405
  { plugin: physicsEventGuardLoweringPlugin },
379
406
  { plugin: messageDispatchLoweringPlugin },
380
407
  { plugin: timersLoweringPlugin },
408
+ { plugin: nativeGlobalModulesPlugin },
381
409
  { plugin: hsmLoweringPlugin },
382
410
  { plugin: typeApplicationErasurePlugin },
383
411
  { plugin: companionEmit.plugin },