@defold-typescript/transpiler 0.24.0 → 0.25.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,290 @@
1
+ // Type-only, and deliberately so: `@defold-typescript/types` resolves its
2
+ // runtime `exports` target to TypeScript source, so importing a *value* from it
3
+ // here would survive `--packages=external` into the packed CLI and fail under
4
+ // plain node exactly the way bug-88 did. The table arrives as a parameter and
5
+ // the lookup below stands in for `classifyUrlParameter`.
6
+ import type {
7
+ UrlParameterClass,
8
+ UrlParameterEntry,
9
+ UrlParameterTable,
10
+ } from "@defold-typescript/types";
11
+ import * as ts from "typescript";
12
+
13
+ // A string literal sitting in an argument slot the classification table
14
+ // classifies as anything but `none`. Offsets are absolute source offsets:
15
+ // `textStart` is the first character inside the quotes, and `fragmentStart` is
16
+ // the first character after the last `#`, or -1 when the literal carries no
17
+ // fragment. `addressText` is the statically-known text of the sibling argument
18
+ // the entry names as its address companion — present only for a class that
19
+ // declares one, and only when that argument is a plain string literal.
20
+ // `resourceExtensions` is the same shape for the file extensions a resource slot
21
+ // accepts: present only when the entry declares them.
22
+ export interface ClassifiedSlot {
23
+ readonly class: UrlParameterClass;
24
+ readonly text: string;
25
+ readonly textStart: number;
26
+ readonly fragmentStart: number;
27
+ readonly addressText?: string;
28
+ readonly resourceExtensions?: readonly string[];
29
+ }
30
+
31
+ // The classes that name something in the scene graph by address. A `gui-node`
32
+ // is classified but is not one: its `#` is part of a node's name, so treating it
33
+ // as an address would read that name as an unresolvable component fragment. The
34
+ // exhaustive `satisfies` makes a future class a type error here rather than a
35
+ // silent membership decision, which is why the set is spelled out instead of
36
+ // tested with `!== "none"`. Local by necessity — see the bug-88 note above.
37
+ const ADDRESS_CLASSES = {
38
+ "game-object": true,
39
+ component: true,
40
+ either: true,
41
+ "gui-node": false,
42
+ animation: false,
43
+ "resource-path": false,
44
+ "config-key": false,
45
+ "action-id": false,
46
+ none: false,
47
+ } satisfies Record<UrlParameterClass, boolean>;
48
+
49
+ export function isAddressClass(parameterClass: UrlParameterClass): boolean {
50
+ return ADDRESS_CLASSES[parameterClass];
51
+ }
52
+
53
+ // Which of an address literal's two universes a caret stands in: component ids
54
+ // from `fragmentStart` on, game-object paths everywhere before it — including a
55
+ // literal carrying no `#` at all, which is all path. `>=` not `>`: at
56
+ // `fragmentStart` the fragment is merely empty, which is where it is most often
57
+ // typed. Stated once here so the entry builders and the provenance resolver
58
+ // cannot drift apart about where the boundary sits.
59
+ export function isFragmentCaret(slot: ClassifiedSlot, position: number): boolean {
60
+ return slot.fragmentStart !== -1 && position >= slot.fragmentStart;
61
+ }
62
+
63
+ // `getFullyQualifiedName` reports a symbol declared in an ambient `declare
64
+ // global` block as `global.msg.post` / `global.hash`, while the table is keyed
65
+ // on the Lua-side `msg.post` / `hash`. The prefix is therefore the evidence that
66
+ // the callee *is* the Defold global the entry describes: a project's own `hash`,
67
+ // or a local object named `gui`, resolves prefixless and must not be keyed.
68
+ function tableKey(
69
+ checker: ts.TypeChecker,
70
+ callee: ts.PropertyAccessExpression | ts.Identifier,
71
+ ): string | undefined {
72
+ const symbol = checker.getSymbolAtLocation(ts.isIdentifier(callee) ? callee : callee.name);
73
+ if (!symbol) return undefined;
74
+ const fqn = checker.getFullyQualifiedName(symbol);
75
+ return fqn.startsWith("global.") ? fqn.slice("global.".length) : undefined;
76
+ }
77
+
78
+ // The parameter this literal occupies, or `undefined` when the call does not
79
+ // resolve to a signature with a named parameter at that index. A rest parameter
80
+ // and a missing one both count as unresolved: neither can be classified, and
81
+ // guessing would report a fragment the project may well declare.
82
+ function parameterName(
83
+ checker: ts.TypeChecker,
84
+ call: ts.CallExpression,
85
+ index: number,
86
+ ): string | undefined {
87
+ const declaration = checker.getResolvedSignature(call)?.declaration;
88
+ if (!declaration || !ts.isFunctionLike(declaration)) return undefined;
89
+ const parameter = declaration.parameters[index];
90
+ if (!parameter || parameter.dotDotDotToken || !ts.isIdentifier(parameter.name)) return undefined;
91
+ return parameter.name.text;
92
+ }
93
+
94
+ const EQUALITY_OPERATORS = new Set<ts.SyntaxKind>([
95
+ ts.SyntaxKind.EqualsEqualsEqualsToken,
96
+ ts.SyntaxKind.ExclamationEqualsEqualsToken,
97
+ ts.SyntaxKind.EqualsEqualsToken,
98
+ ts.SyntaxKind.ExclamationEqualsToken,
99
+ ]);
100
+
101
+ // Whether `call` sits in a comparison against the parameter the entry names. An
102
+ // entry declaring no `comparedParameter` is unscoped and always satisfied; for
103
+ // one that does, the class applies only where its universe can appear —
104
+ // `hash("…")` is a prefixless global a project writes for component ids, message
105
+ // ids and property names alike, and only the comparison against `on_input`'s
106
+ // action id says this one names an action.
107
+ //
108
+ // The other operand must *resolve* to a parameter declaration of that name.
109
+ // Matching the identifier's text instead would accept a local that merely reads
110
+ // like the hook's, whose value came from somewhere else entirely.
111
+ function comparisonSatisfied(
112
+ checker: ts.TypeChecker,
113
+ entry: UrlParameterEntry,
114
+ call: ts.CallExpression,
115
+ ): boolean {
116
+ if (entry.comparedParameter === undefined) return true;
117
+ let node: ts.Node = call;
118
+ while (ts.isParenthesizedExpression(node.parent)) node = node.parent;
119
+ const comparison = node.parent;
120
+ if (!ts.isBinaryExpression(comparison)) return false;
121
+ if (!EQUALITY_OPERATORS.has(comparison.operatorToken.kind)) return false;
122
+ const other = comparison.left === node ? comparison.right : comparison.left;
123
+ const symbol = checker.getSymbolAtLocation(other);
124
+ return (symbol?.declarations ?? []).some(
125
+ (declaration) =>
126
+ ts.isParameter(declaration) &&
127
+ ts.isIdentifier(declaration.name) &&
128
+ declaration.name.text === entry.comparedParameter,
129
+ );
130
+ }
131
+
132
+ // The table entry governing the argument slot this expression occupies, together
133
+ // with the call it sits in — `undefined` when the call, the parameter, or the
134
+ // lookup does not resolve. The judgment is the table's alone: the parameter's
135
+ // declared type never participates, because an address slot and a `mesh_id`
136
+ // carry the same triple.
137
+ //
138
+ // Any argument expression, not only a string literal: a hoisted `const SPRITE =
139
+ // hash("#sprite")` occupies the slot exactly as the written literal does, and
140
+ // driving both off this one lookup is what stops the two from ever disagreeing
141
+ // about whether a slot addresses.
142
+ function classifiedEntryOfArgument(
143
+ checker: ts.TypeChecker,
144
+ table: UrlParameterTable,
145
+ argument: ts.Expression,
146
+ ): { entry: UrlParameterEntry; call: ts.CallExpression } | undefined {
147
+ const call = argument.parent;
148
+ if (!ts.isCallExpression(call)) return undefined;
149
+ const argumentIndex = call.arguments.indexOf(argument);
150
+ if (argumentIndex === -1) return undefined;
151
+ const callee = call.expression;
152
+ if (!ts.isPropertyAccessExpression(callee) && !ts.isIdentifier(callee)) return undefined;
153
+ const fqn = tableKey(checker, callee);
154
+ if (fqn === undefined) return undefined;
155
+ const parameter = parameterName(checker, call, argumentIndex);
156
+ if (parameter === undefined) return undefined;
157
+ for (const entry of table) {
158
+ if (entry.fqn !== fqn || entry.parameter !== parameter) continue;
159
+ // Inside the lookup, so `addressClassOfArgument` and
160
+ // `resolveClassifiedSlotAtPosition` can never disagree about whether a
161
+ // scoped entry applies.
162
+ return comparisonSatisfied(checker, entry, call) ? { entry, call } : undefined;
163
+ }
164
+ return undefined;
165
+ }
166
+
167
+ // The entry governing the slot a literal *wrapped in* `hash(…)` occupies: the
168
+ // hashed name addresses whatever the enclosing call's slot addresses, so
169
+ // `msg.post(hash("#sprite"), …)` reads as the bare literal does. Handing the
170
+ // `hash(…)` call itself to the lookup above is what keeps the parameter search,
171
+ // the comparison scoping and the companion argument in one place.
172
+ //
173
+ // One level, and the stop is enforced rather than argued: a resolved entry that
174
+ // is itself the `hash` entry means the literal was doubly wrapped, and the
175
+ // compared outer `hash` of `action_id === hash(hash(""))` is exactly the shape
176
+ // that satisfies that entry — so prose about a scope it "cannot satisfy" was
177
+ // never enough to keep action ids off the inner literal.
178
+ //
179
+ // Only the ambient `hash` ascends. A project's own is prefixless, so `tableKey`
180
+ // declines it exactly as it declines it for an action id.
181
+ function slotThroughHashedCall(
182
+ checker: ts.TypeChecker,
183
+ table: UrlParameterTable,
184
+ literal: ts.StringLiteralLike,
185
+ ): { entry: UrlParameterEntry; call: ts.CallExpression } | undefined {
186
+ const call = literal.parent;
187
+ if (!ts.isCallExpression(call)) return undefined;
188
+ const callee = call.expression;
189
+ if (!ts.isPropertyAccessExpression(callee) && !ts.isIdentifier(callee)) return undefined;
190
+ if (tableKey(checker, callee) !== "hash") return undefined;
191
+ const ascended = classifiedEntryOfArgument(checker, table, call);
192
+ return ascended?.entry.fqn === "hash" ? undefined : ascended;
193
+ }
194
+
195
+ // How the committed table classifies the argument slot this expression occupies
196
+ // — `"none"` when nothing resolves, which is also the answer for any node that
197
+ // is not a call argument at all.
198
+ export function addressClassOfArgument(
199
+ checker: ts.TypeChecker,
200
+ table: UrlParameterTable,
201
+ argument: ts.Expression,
202
+ ): UrlParameterClass {
203
+ return classifiedEntryOfArgument(checker, table, argument)?.entry.class ?? "none";
204
+ }
205
+
206
+ // The text of the sibling argument the entry names as its address companion.
207
+ // The parameter is found by *name* in the resolved signature — the companion is
208
+ // not at a fixed offset from the classified slot — and only a plain string
209
+ // literal counts: a variable, a `msg.url(…)` call and a substituted template
210
+ // are all unknown until runtime, and guessing would scope the candidates to the
211
+ // wrong component.
212
+ function addressTextOf(
213
+ checker: ts.TypeChecker,
214
+ entry: UrlParameterEntry,
215
+ call: ts.CallExpression,
216
+ ): string | undefined {
217
+ if (entry.addressParameter === undefined) return undefined;
218
+ const declaration = checker.getResolvedSignature(call)?.declaration;
219
+ if (!declaration || !ts.isFunctionLike(declaration)) return undefined;
220
+ const index = declaration.parameters.findIndex(
221
+ (parameter) =>
222
+ !parameter.dotDotDotToken &&
223
+ ts.isIdentifier(parameter.name) &&
224
+ parameter.name.text === entry.addressParameter,
225
+ );
226
+ if (index === -1) return undefined;
227
+ const argument = call.arguments[index];
228
+ return argument && ts.isStringLiteralLike(argument) ? argument.text : undefined;
229
+ }
230
+
231
+ function innermostLiteralAt(
232
+ sourceFile: ts.SourceFile,
233
+ position: number,
234
+ ): ts.StringLiteralLike | undefined {
235
+ let found: ts.StringLiteralLike | undefined;
236
+ const visit = (node: ts.Node): void => {
237
+ if (position < node.getStart(sourceFile) || position > node.getEnd()) return;
238
+ if (ts.isStringLiteralLike(node)) found = node;
239
+ ts.forEachChild(node, visit);
240
+ };
241
+ ts.forEachChild(sourceFile, visit);
242
+ return found;
243
+ }
244
+
245
+ // The classified slot a cursor sits in, or `undefined` when it sits anywhere
246
+ // else. The cursor must be inside the quotes — including the append position at
247
+ // the very end of the text, which is where a fragment is most often typed.
248
+ export function resolveClassifiedSlotAtPosition(input: {
249
+ program: ts.Program;
250
+ table: UrlParameterTable;
251
+ fileName: string;
252
+ position: number;
253
+ }): ClassifiedSlot | undefined {
254
+ const { program, table, fileName, position } = input;
255
+ const sourceFile = program.getSourceFile(fileName);
256
+ if (!sourceFile) return undefined;
257
+
258
+ const literal = innermostLiteralAt(sourceFile, position);
259
+ if (!literal) return undefined;
260
+
261
+ // An escape sequence or an implicit concatenation makes the cooked text
262
+ // narrower than its source span, so text offsets would no longer be source
263
+ // offsets and every span this returns would be wrong. Fail closed.
264
+ if (literal.getWidth(sourceFile) !== literal.text.length + 2) return undefined;
265
+
266
+ const textStart = literal.getStart(sourceFile) + 1;
267
+ if (position < textStart || position > textStart + literal.text.length) return undefined;
268
+
269
+ const checker = program.getTypeChecker();
270
+ // fallback-only ascent: a literal whose own slot classifies — the action-id
271
+ // caret inside a compared `hash` — never reaches it, so no shipped kind can
272
+ // change meaning here.
273
+ const own = classifiedEntryOfArgument(checker, table, literal);
274
+ const classified =
275
+ own && own.entry.class !== "none" ? own : slotThroughHashedCall(checker, table, literal);
276
+ if (!classified || classified.entry.class === "none") return undefined;
277
+
278
+ const hash = literal.text.lastIndexOf("#");
279
+ const addressText = addressTextOf(checker, classified.entry, classified.call);
280
+ return {
281
+ class: classified.entry.class,
282
+ text: literal.text,
283
+ textStart,
284
+ fragmentStart: hash === -1 ? -1 : textStart + hash + 1,
285
+ ...(addressText === undefined ? {} : { addressText }),
286
+ ...(classified.entry.resourceExtensions === undefined
287
+ ? {}
288
+ : { resourceExtensions: classified.entry.resourceExtensions }),
289
+ };
290
+ }
@@ -0,0 +1,175 @@
1
+ // Type-only, and deliberately so: `@defold-typescript/types` resolves its
2
+ // runtime `exports` target to TypeScript source, so importing a *value* from it
3
+ // here would survive `--packages=external` into the packed CLI and fail under
4
+ // plain node exactly the way bug-88 did. The table arrives as a parameter and
5
+ // the lookup below stands in for `classifyUrlParameter`.
6
+ import type { UrlParameterTable } from "@defold-typescript/types";
7
+ import * as ts from "typescript";
8
+ import type { SceneComponentIndex } from "./scene-component-index";
9
+ import { addressClassOfArgument, isAddressClass } from "./url-address-slots";
10
+
11
+ export interface UrlFragmentFinding {
12
+ readonly fileName: string;
13
+ readonly start: number;
14
+ readonly length: number;
15
+ readonly fragment: string;
16
+ readonly message: string;
17
+ }
18
+
19
+ export type UrlFragmentReport =
20
+ | { readonly kind: "suppressed"; readonly reasons: readonly string[] }
21
+ | { readonly kind: "checked"; readonly findings: readonly UrlFragmentFinding[] };
22
+
23
+ function isAmbient(fileName: string): boolean {
24
+ return /[\\/]node_modules[\\/]/.test(fileName) || /(^|[\\/])lib\.[^\\/]*\.d\.ts$/.test(fileName);
25
+ }
26
+
27
+ // The symbol a generic type reference was instantiated from — `undefined` for
28
+ // anything that is not one.
29
+ function referenceTargetSymbol(type: ts.Type): ts.Symbol | undefined {
30
+ if ((type.flags & ts.TypeFlags.Object) === 0) return undefined;
31
+ if (((type as ts.ObjectType).objectFlags & ts.ObjectFlags.Reference) === 0) return undefined;
32
+ return (type as ts.TypeReference).target.symbol;
33
+ }
34
+
35
+ // What the global `Hash` *type* name means in scope at the anchor, as symbols.
36
+ // The shipped `type Hash<S extends string = string> = Core.Hash<S>` yields the
37
+ // `Core.Hash` symbol; a project's `Project.Hash` is not in global scope under
38
+ // the bare name and never appears, and a project cannot add a second global
39
+ // `Hash` type without a duplicate-identifier error — so unlike the *name*, this
40
+ // anchor cannot be shadowed.
41
+ function ambientHashAliasTargets(checker: ts.TypeChecker, anchor: ts.Node): ReadonlySet<ts.Symbol> {
42
+ const targets = new Set<ts.Symbol>();
43
+ for (const symbol of checker.getSymbolsInScope(anchor, ts.SymbolFlags.Type)) {
44
+ if (symbol.name !== "Hash") continue;
45
+ const target = referenceTargetSymbol(checker.getDeclaredTypeOfSymbol(symbol));
46
+ if (target) targets.add(target);
47
+ }
48
+ return targets;
49
+ }
50
+
51
+ // The declarations *the* `Hash` can come from, resolved from the two anchors the
52
+ // shipped ambient declarations carry one line apart rather than from the file
53
+ // `Hash` happens to be declared in. A path test answers differently for an
54
+ // installed `node_modules/@defold-typescript/types/…` and a workspace-resolved
55
+ // `packages/types/…` copy of the very same declarations, which silently emptied
56
+ // this report for anyone consuming the package through a `paths` mapping or a
57
+ // symlink (bug-121); a symbol identity holds under both.
58
+ //
59
+ // The answer is the *intersection* of the two anchors. The `hash` function is
60
+ // the scope-and-fail-closed anchor: its scope anchor is an ambient file so that
61
+ // no user module's own `hash` participates, and every call signature is walked
62
+ // because a project's global-script `function hash(s: string): string` *merges*
63
+ // into this symbol rather than replacing it — the declarations then span both
64
+ // files and a fully-qualified-name test would reject the real one. The global
65
+ // `Hash` alias is the identity anchor: the function alone is not enough, because
66
+ // a merged project overload contributes its own signatures, and testing what
67
+ // they return by *name* admits any project type called `Hash` and then reports a
68
+ // type argument that was never an address (bug-122). Asking the checker instead
69
+ // of the filesystem is the same discipline as `tableKey` in
70
+ // `url-address-slots.ts`.
71
+ function canonicalHashSymbols(
72
+ checker: ts.TypeChecker,
73
+ program: ts.Program,
74
+ ): ReadonlySet<ts.Symbol> {
75
+ const files = program.getSourceFiles();
76
+ const anchor = files.find((file) => isAmbient(file.fileName)) ?? files[0];
77
+ const canonical = new Set<ts.Symbol>();
78
+ if (!anchor) return canonical;
79
+
80
+ const aliased = ambientHashAliasTargets(checker, anchor);
81
+
82
+ for (const symbol of checker.getSymbolsInScope(anchor, ts.SymbolFlags.Function)) {
83
+ if (symbol.name !== "hash") continue;
84
+ const declaration = symbol.declarations?.[0];
85
+ if (!declaration) continue;
86
+ for (const signature of checker
87
+ .getTypeOfSymbolAtLocation(symbol, declaration)
88
+ .getCallSignatures()) {
89
+ const target = referenceTargetSymbol(signature.getReturnType());
90
+ if (target && aliased.has(target)) canonical.add(target);
91
+ }
92
+ }
93
+ return canonical;
94
+ }
95
+
96
+ // The string a `Hash` was hashed from, recovered from its type argument —
97
+ // `undefined` for anything else, including a bare `Hash`, a `Url`, a `string`,
98
+ // and a union. `hash` records the argument in `Hash<S>` (see the `Hash` JSDoc in
99
+ // `@defold-typescript/types`), so `const SPRITE = hash("#sprite")` carries the
100
+ // text on the constant's type and no reassignment tracking is needed.
101
+ //
102
+ // The interface must be *the* `Hash`: a project's own generic type named `Hash`
103
+ // resolves to its own declaration, and reading a type argument off it would
104
+ // report a fragment that was never an address. `canonical` is what settles that.
105
+ function hashedSourceOfType(
106
+ checker: ts.TypeChecker,
107
+ type: ts.Type,
108
+ canonical: ReadonlySet<ts.Symbol>,
109
+ ): string | undefined {
110
+ const target = referenceTargetSymbol(type);
111
+ if (!target || !canonical.has(target)) return undefined;
112
+ const [source] = checker.getTypeArguments(type as ts.TypeReference);
113
+ return source?.isStringLiteral() ? source.value : undefined;
114
+ }
115
+
116
+ // Report every address-slot expression whose text is statically known — a
117
+ // quoted or backtick-quoted literal, or a value whose `Hash` type still carries
118
+ // the string it was hashed from — whose `#fragment` names a component no
119
+ // `.go`/`.collection` in the project declares. A substituted template is out
120
+ // because its fragment is only known at runtime, so no absence in the project's
121
+ // scene files can contradict it, and so is any value whose type carries no
122
+ // literal (a bare `Hash`, a `Url`, a `string`). A path is never reported:
123
+ // `factory.create` can produce a game object at any path, but it can never
124
+ // invent a component, which is what makes only the fragment decidable.
125
+ export function checkUrlFragmentReachability(input: {
126
+ program: ts.Program;
127
+ table: UrlParameterTable;
128
+ index: SceneComponentIndex;
129
+ sourceFiles?: readonly ts.SourceFile[];
130
+ }): UrlFragmentReport {
131
+ const { program, table, index, sourceFiles } = input;
132
+ if (index.incomplete.length > 0) {
133
+ return { kind: "suppressed", reasons: index.incomplete };
134
+ }
135
+
136
+ const checker = program.getTypeChecker();
137
+ // Resolved once per call — measured at ~0.1 ms, so no laziness is warranted.
138
+ // An empty set reports nothing from the type-directed branch and leaves inline
139
+ // literals untouched, which is the answer for a program carrying no ambient
140
+ // `hash` and the module's existing fail-closed posture.
141
+ const canonical = canonicalHashSymbols(checker, program);
142
+ const findings: UrlFragmentFinding[] = [];
143
+ const targets = sourceFiles ?? program.getSourceFiles();
144
+
145
+ for (const sourceFile of targets) {
146
+ if (isAmbient(sourceFile.fileName)) continue;
147
+
148
+ const visit = (node: ts.Node): void => {
149
+ if (ts.isExpression(node) && isAddressClass(addressClassOfArgument(checker, table, node))) {
150
+ const text = ts.isStringLiteralLike(node)
151
+ ? node.text
152
+ : hashedSourceOfType(checker, checker.getTypeAtLocation(node), canonical);
153
+ if (text !== undefined) {
154
+ const hash = text.indexOf("#");
155
+ const fragment = hash === -1 ? "" : text.slice(hash + 1);
156
+ if (fragment !== "" && !index.ids.has(fragment)) {
157
+ findings.push({
158
+ fileName: sourceFile.fileName,
159
+ start: node.getStart(sourceFile),
160
+ length: node.getWidth(sourceFile),
161
+ fragment,
162
+ message:
163
+ `no \`.go\` or \`.collection\` in this project declares a component with the id ` +
164
+ `"${fragment}", so this address cannot resolve at runtime`,
165
+ });
166
+ }
167
+ }
168
+ }
169
+ ts.forEachChild(node, visit);
170
+ };
171
+ ts.forEachChild(sourceFile, visit);
172
+ }
173
+
174
+ return { kind: "checked", findings };
175
+ }