@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.
- package/dist/config-key-index.d.ts +1 -0
- package/dist/editor-script-erasure.d.ts +2 -1
- package/dist/gui-node-index.d.ts +5 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +915 -27
- package/dist/input-action-index.d.ts +1 -0
- package/dist/output-paths.d.ts +11 -0
- package/dist/project-resources.d.ts +2 -0
- package/dist/scene-component-index.d.ts +5 -0
- package/dist/scene-object-path-index.d.ts +6 -0
- package/dist/scene-text-format.d.ts +9 -0
- package/dist/sprite-animation-index.d.ts +10 -0
- package/dist/url-address-slots.d.ts +19 -0
- package/dist/url-fragment-reachability.d.ts +23 -0
- package/package.json +2 -2
- package/src/config-key-index.ts +34 -0
- package/src/editor-script-erasure.ts +44 -13
- package/src/gui-node-index.ts +71 -0
- package/src/index.ts +33 -0
- package/src/input-action-index.ts +41 -0
- package/src/output-paths.ts +69 -0
- package/src/project-resources.ts +20 -0
- package/src/scene-component-index.ts +80 -0
- package/src/scene-object-path-index.ts +148 -0
- package/src/scene-text-format.ts +171 -0
- package/src/sprite-animation-index.ts +241 -0
- package/src/transpile.ts +5 -0
- package/src/url-address-slots.ts +290 -0
- package/src/url-fragment-reachability.ts +175 -0
|
@@ -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
|
+
}
|