@defold-typescript/transpiler 0.30.0 → 0.32.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/animation-sources.d.ts +6 -0
- package/dist/{sprite-animation-index.d.ts → component-animation-index.d.ts} +4 -4
- package/dist/config-key-index.d.ts +1 -0
- package/dist/gui-flipbook-index.d.ts +8 -0
- package/dist/gui-node-index.d.ts +21 -0
- package/dist/index.d.ts +19 -9
- package/dist/index.js +1326 -396
- package/dist/library-dependencies.d.ts +47 -0
- package/dist/scene-address-declaration.d.ts +4 -2
- package/dist/scene-collection-roles.d.ts +30 -0
- package/dist/scene-component-index.d.ts +20 -0
- package/dist/scene-documents.d.ts +3 -0
- package/dist/scene-naming-context.d.ts +66 -0
- package/dist/scene-object-path-index.d.ts +4 -1
- package/dist/url-address-literals.d.ts +10 -0
- package/dist/url-address-slots.d.ts +3 -1
- package/dist/url-cross-world-address.d.ts +41 -0
- package/dist/url-fragment-reachability.d.ts +3 -0
- package/package.json +2 -2
- package/src/animation-sources.ts +90 -0
- package/src/component-animation-index.ts +399 -0
- package/src/config-key-index.ts +28 -2
- package/src/gui-flipbook-index.ts +91 -0
- package/src/gui-node-index.ts +42 -4
- package/src/index.ts +52 -10
- package/src/library-dependencies.ts +337 -0
- package/src/scene-address-declaration.ts +29 -12
- package/src/scene-collection-roles.ts +267 -0
- package/src/scene-component-index.ts +74 -6
- package/src/scene-documents.ts +66 -6
- package/src/scene-naming-context.ts +130 -0
- package/src/scene-object-path-index.ts +172 -33
- package/src/transpile.ts +3 -0
- package/src/url-address-literals.ts +115 -0
- package/src/url-address-slots.ts +23 -2
- package/src/url-cross-world-address.ts +109 -0
- package/src/url-fragment-reachability.ts +72 -93
- package/src/sprite-animation-index.ts +0 -241
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { SceneCollectionRoles } from "./scene-collection-roles";
|
|
2
|
+
import { collectComponentDeclarations } from "./scene-component-index";
|
|
1
3
|
import { parseSceneTextFormat, type SceneMessage, SceneTextFormatError } from "./scene-text-format";
|
|
2
4
|
|
|
3
5
|
// Every game-object path the project declares, `/`-prefixed and composed the way
|
|
@@ -5,14 +7,38 @@ import { parseSceneTextFormat, type SceneMessage, SceneTextFormatError } from ".
|
|
|
5
7
|
// settle. Shaped like `SceneComponentIndex`: a non-empty `incomplete` means the
|
|
6
8
|
// universe is not provably whole, so no consumer may conclude a path is absent.
|
|
7
9
|
export interface SceneObjectPathIndex {
|
|
10
|
+
// Two axes, in one key: which world, then which path inside it. A bare
|
|
11
|
+
// `/enemy` is the bootstrap world; `mylevel:/enemy` is the proxy world the
|
|
12
|
+
// proxied collection's `name:` opened. A collection factory's prototype has
|
|
13
|
+
// no static address at all, so it contributes neither form.
|
|
8
14
|
readonly paths: ReadonlySet<string>;
|
|
9
15
|
// The documents declaring each path's *leaf* segment, sorted — the files an
|
|
10
16
|
// author would open to rename that object. A composed address is attributed to
|
|
11
17
|
// the collection that names the object, never to the ones that prefixed it.
|
|
12
18
|
readonly declaredIn: ReadonlyMap<string, readonly string[]>;
|
|
19
|
+
// The component ids the object *at* each path owns, sorted — the join that
|
|
20
|
+
// turns two id universes into one address. A path whose prototype could not be
|
|
21
|
+
// read carries no entry at all rather than an empty one: an empty array is
|
|
22
|
+
// indistinguishable from an object that genuinely owns no component, and the
|
|
23
|
+
// reason is named in `incomplete` instead.
|
|
24
|
+
readonly componentsOf: ReadonlyMap<string, readonly string[]>;
|
|
25
|
+
// For each object, the resource each of its *referenced* components names —
|
|
26
|
+
// the `component: "/src/player.ts.script"` edge that says this object hosts
|
|
27
|
+
// that script. An *embedded* component is absent by construction rather than
|
|
28
|
+
// withheld: its payload is the component, so it names no resource. Withheld
|
|
29
|
+
// for exactly the keys `componentsOf` withholds, on the same rule.
|
|
30
|
+
readonly componentResourcesOf: ReadonlyMap<string, ReadonlyMap<string, string>>;
|
|
13
31
|
readonly incomplete: readonly string[];
|
|
14
32
|
}
|
|
15
33
|
|
|
34
|
+
// One composed path's leaf: the document that declared it, and the components
|
|
35
|
+
// its prototype owns — `undefined` where that prototype could not be read.
|
|
36
|
+
interface PathLeaf {
|
|
37
|
+
readonly declarer: string;
|
|
38
|
+
readonly components: readonly string[] | undefined;
|
|
39
|
+
readonly resources: ReadonlyMap<string, string> | undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
16
42
|
// The two blocks whose `id` is a leaf segment. `collection_instances` is handled
|
|
17
43
|
// apart, because its id is a namespace rather than an object.
|
|
18
44
|
const LEAF_BLOCKS = ["instances", "embedded_instances"];
|
|
@@ -36,18 +62,31 @@ function childrenOf(message: SceneMessage, name: string): readonly SceneMessage[
|
|
|
36
62
|
// display paths, values are file text. Pure the same way `buildSceneComponentIndex`
|
|
37
63
|
// is — the filesystem walk belongs to the caller.
|
|
38
64
|
//
|
|
65
|
+
// `roles` says which world each collection is, and is required rather than
|
|
66
|
+
// defaulted: an optional parameter falling back to today's every-collection-is-a-
|
|
67
|
+
// root behaviour would keep the wrong-world suggestion reachable from any call
|
|
68
|
+
// site that forgot to pass it.
|
|
69
|
+
//
|
|
39
70
|
// A `children:` edge is deliberately not a path segment. Defold ids are unique
|
|
40
71
|
// inside one collection and a child object is still addressed `/child`, so
|
|
41
72
|
// parenting is a transform relation; nesting comes from a collection instanced
|
|
42
73
|
// inside another.
|
|
43
74
|
export function buildSceneObjectPathIndex(
|
|
44
75
|
documents: ReadonlyMap<string, string>,
|
|
76
|
+
roles: SceneCollectionRoles,
|
|
45
77
|
): SceneObjectPathIndex {
|
|
46
78
|
const incomplete: string[] = [];
|
|
47
79
|
|
|
48
80
|
if (documents.size === 0) {
|
|
49
81
|
incomplete.push("no scene sources were read, so no game-object path can be proven absent");
|
|
50
|
-
|
|
82
|
+
incomplete.push(...roles.incomplete);
|
|
83
|
+
return {
|
|
84
|
+
paths: new Set(),
|
|
85
|
+
declaredIn: new Map(),
|
|
86
|
+
componentsOf: new Map(),
|
|
87
|
+
componentResourcesOf: new Map(),
|
|
88
|
+
incomplete,
|
|
89
|
+
};
|
|
51
90
|
}
|
|
52
91
|
|
|
53
92
|
const parsed = new Map<string, SceneMessage>();
|
|
@@ -60,26 +99,36 @@ export function buildSceneObjectPathIndex(
|
|
|
60
99
|
}
|
|
61
100
|
}
|
|
62
101
|
|
|
63
|
-
// A collection another document instances is not a world of its own: at
|
|
64
|
-
// runtime its objects only exist under that instance's id, so contributing its
|
|
65
|
-
// own unprefixed paths as well would offer addresses that resolve to nothing.
|
|
66
|
-
const instanced = new Set<string>();
|
|
67
|
-
for (const document of parsed.values()) {
|
|
68
|
-
for (const block of childrenOf(document, "collection_instances")) {
|
|
69
|
-
const collection = firstField(block, "collection");
|
|
70
|
-
if (collection !== undefined) instanced.add(resourceKey(collection));
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
102
|
// Memo keyed by display path, so a collection instanced twice is read once;
|
|
75
103
|
// `inProgress` is the cycle guard, without which two collections instancing
|
|
76
104
|
// each other would recur until the stack ran out. Each entry maps a composed
|
|
77
105
|
// path to the document that declared its leaf, so the attribution is carried
|
|
78
106
|
// by the same walk that builds the path rather than recovered afterwards.
|
|
79
|
-
const composed = new Map<string, ReadonlyMap<string,
|
|
107
|
+
const composed = new Map<string, ReadonlyMap<string, PathLeaf>>();
|
|
80
108
|
const inProgress = new Set<string>();
|
|
81
109
|
|
|
82
|
-
|
|
110
|
+
// A `.go` instanced twenty times is read once: the components it declares are
|
|
111
|
+
// a property of the prototype, not of the instance that named it.
|
|
112
|
+
interface PrototypeDeclarations {
|
|
113
|
+
readonly components: readonly string[];
|
|
114
|
+
readonly resources: ReadonlyMap<string, string>;
|
|
115
|
+
}
|
|
116
|
+
const prototypeDeclarations = new Map<string, PrototypeDeclarations | undefined>();
|
|
117
|
+
|
|
118
|
+
function declarationsOfPrototype(key: string): PrototypeDeclarations | undefined {
|
|
119
|
+
const done = prototypeDeclarations.get(key);
|
|
120
|
+
if (done !== undefined || prototypeDeclarations.has(key)) return done;
|
|
121
|
+
const document = parsed.get(key);
|
|
122
|
+
let declarations: PrototypeDeclarations | undefined;
|
|
123
|
+
if (document !== undefined) {
|
|
124
|
+
const collected = collectComponentDeclarations(document, key, incomplete);
|
|
125
|
+
declarations = { components: [...collected.ids].sort(), resources: collected.resources };
|
|
126
|
+
}
|
|
127
|
+
prototypeDeclarations.set(key, declarations);
|
|
128
|
+
return declarations;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function pathsOf(displayPath: string): ReadonlyMap<string, PathLeaf> {
|
|
83
132
|
const done = composed.get(displayPath);
|
|
84
133
|
if (done !== undefined) return done;
|
|
85
134
|
if (inProgress.has(displayPath)) {
|
|
@@ -92,12 +141,48 @@ export function buildSceneObjectPathIndex(
|
|
|
92
141
|
if (document === undefined) return new Map();
|
|
93
142
|
|
|
94
143
|
inProgress.add(displayPath);
|
|
95
|
-
const paths = new Map<string,
|
|
144
|
+
const paths = new Map<string, PathLeaf>();
|
|
96
145
|
|
|
97
146
|
for (const blockName of LEAF_BLOCKS) {
|
|
98
147
|
for (const block of childrenOf(document, blockName)) {
|
|
99
148
|
const id = firstField(block, "id");
|
|
100
|
-
if (id
|
|
149
|
+
if (id === undefined || id === "") continue;
|
|
150
|
+
let components: readonly string[] | undefined;
|
|
151
|
+
let resources: ReadonlyMap<string, string> | undefined;
|
|
152
|
+
if (blockName === "embedded_instances") {
|
|
153
|
+
// The payload declares the object outright, so the same collector the
|
|
154
|
+
// flat index uses reads it — and names the document itself when the
|
|
155
|
+
// escaped text will not parse.
|
|
156
|
+
const before = incomplete.length;
|
|
157
|
+
const declarations = collectComponentDeclarations(
|
|
158
|
+
block,
|
|
159
|
+
displayPath,
|
|
160
|
+
incomplete,
|
|
161
|
+
blockName,
|
|
162
|
+
);
|
|
163
|
+
if (incomplete.length === before) {
|
|
164
|
+
components = [...declarations.ids].sort();
|
|
165
|
+
resources = declarations.resources;
|
|
166
|
+
}
|
|
167
|
+
} else {
|
|
168
|
+
const prototype = firstField(block, "prototype");
|
|
169
|
+
if (prototype === undefined) {
|
|
170
|
+
// Distinct from an unresolvable resource: nothing was named, so
|
|
171
|
+
// there is no document to go looking for.
|
|
172
|
+
incomplete.push(
|
|
173
|
+
`${displayPath}: the instance "${id}" declares no prototype, so the components it owns cannot be read`,
|
|
174
|
+
);
|
|
175
|
+
} else if (parsed.has(resourceKey(prototype))) {
|
|
176
|
+
const declarations = declarationsOfPrototype(resourceKey(prototype));
|
|
177
|
+
components = declarations?.components;
|
|
178
|
+
resources = declarations?.resources;
|
|
179
|
+
} else {
|
|
180
|
+
incomplete.push(
|
|
181
|
+
`${displayPath}: the instance "${id}" names ${prototype}, which is not among the project's readable scene documents`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
paths.set(`/${id}`, { declarer: displayPath, components, resources });
|
|
101
186
|
}
|
|
102
187
|
}
|
|
103
188
|
|
|
@@ -112,8 +197,8 @@ export function buildSceneObjectPathIndex(
|
|
|
112
197
|
);
|
|
113
198
|
continue;
|
|
114
199
|
}
|
|
115
|
-
for (const [nested,
|
|
116
|
-
paths.set(`/${id}${nested}`,
|
|
200
|
+
for (const [nested, leaf] of pathsOf(key)) {
|
|
201
|
+
paths.set(`/${id}${nested}`, leaf);
|
|
117
202
|
}
|
|
118
203
|
}
|
|
119
204
|
|
|
@@ -122,27 +207,81 @@ export function buildSceneObjectPathIndex(
|
|
|
122
207
|
return paths;
|
|
123
208
|
}
|
|
124
209
|
|
|
125
|
-
// Composed for every document, contributed from the
|
|
126
|
-
//
|
|
127
|
-
//
|
|
210
|
+
// Composed for every document, contributed from the worlds alone: a cycle or a
|
|
211
|
+
// dangling reference reached only from a collection no world opens is still a
|
|
212
|
+
// gap the caller has to hear about.
|
|
213
|
+
for (const displayPath of parsed.keys()) pathsOf(displayPath);
|
|
214
|
+
|
|
215
|
+
// The bootstrap world's addresses are bare; every proxy world's are prefixed
|
|
216
|
+
// by the socket its collection's `name:` declares. A collection with neither
|
|
217
|
+
// role contributes nothing — including a factory prototype, whose objects only
|
|
218
|
+
// ever exist under a runtime-generated prefix.
|
|
219
|
+
const worlds: [string, string][] = [];
|
|
220
|
+
if (roles.bootstrap !== undefined) worlds.push(["", roles.bootstrap]);
|
|
221
|
+
for (const [displayPath, socket] of roles.sockets) worlds.push([`${socket}:`, displayPath]);
|
|
222
|
+
|
|
128
223
|
const paths = new Set<string>();
|
|
129
224
|
const declaredIn = new Map<string, string[]>();
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
225
|
+
const componentsOf = new Map<string, string[]>();
|
|
226
|
+
const componentResourcesOf = new Map<string, Map<string, string>>();
|
|
227
|
+
// An address two leaves compose is only as provable as its weakest leaf: one
|
|
228
|
+
// unread prototype withholds the claim for the whole key rather than letting
|
|
229
|
+
// the readable half stand in for both.
|
|
230
|
+
const withheld = new Set<string>();
|
|
231
|
+
for (const [prefix, displayPath] of worlds) {
|
|
232
|
+
for (const [path, leaf] of pathsOf(displayPath)) {
|
|
233
|
+
const key = `${prefix}${path}`;
|
|
234
|
+
paths.add(key);
|
|
235
|
+
const declarers = declaredIn.get(key);
|
|
136
236
|
if (declarers === undefined) {
|
|
137
|
-
declaredIn.set(
|
|
138
|
-
} else if (!declarers.includes(declarer)) {
|
|
139
|
-
// Two
|
|
140
|
-
//
|
|
141
|
-
|
|
237
|
+
declaredIn.set(key, [leaf.declarer]);
|
|
238
|
+
} else if (!declarers.includes(leaf.declarer)) {
|
|
239
|
+
// Two worlds sharing one socket compose the same address from two
|
|
240
|
+
// different leaves, which the roles already name as a hole — both
|
|
241
|
+
// declaring files still answer for it.
|
|
242
|
+
declarers.push(leaf.declarer);
|
|
243
|
+
}
|
|
244
|
+
if (leaf.components === undefined || leaf.resources === undefined) {
|
|
245
|
+
withheld.add(key);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const owned = componentsOf.get(key);
|
|
249
|
+
if (owned === undefined) {
|
|
250
|
+
componentsOf.set(key, [...leaf.components]);
|
|
251
|
+
} else {
|
|
252
|
+
for (const id of leaf.components) if (!owned.includes(id)) owned.push(id);
|
|
253
|
+
}
|
|
254
|
+
const referenced = componentResourcesOf.get(key);
|
|
255
|
+
if (referenced === undefined) {
|
|
256
|
+
componentResourcesOf.set(key, new Map(leaf.resources));
|
|
257
|
+
} else {
|
|
258
|
+
// Same union rule as the ids above: the first leaf to name a component's
|
|
259
|
+
// resource answers for it, so two worlds sharing one socket — already a
|
|
260
|
+
// hole the roles name — cannot silently overwrite each other.
|
|
261
|
+
for (const [id, resource] of leaf.resources) {
|
|
262
|
+
if (!referenced.has(id)) referenced.set(id, resource);
|
|
263
|
+
}
|
|
142
264
|
}
|
|
143
265
|
}
|
|
144
266
|
}
|
|
145
267
|
for (const declarers of declaredIn.values()) declarers.sort();
|
|
268
|
+
for (const key of withheld) {
|
|
269
|
+
componentsOf.delete(key);
|
|
270
|
+
componentResourcesOf.delete(key);
|
|
271
|
+
}
|
|
272
|
+
for (const owned of componentsOf.values()) owned.sort();
|
|
273
|
+
|
|
274
|
+
incomplete.push(...roles.incomplete);
|
|
146
275
|
|
|
147
|
-
|
|
276
|
+
// The roles walk opens the same escaped payloads this one does, so an
|
|
277
|
+
// unparseable `data:` reaches the list twice by two honest routes. Identical
|
|
278
|
+
// strings carry identical information, and a reader shown the same reason
|
|
279
|
+
// twice learns nothing the second time.
|
|
280
|
+
return {
|
|
281
|
+
paths,
|
|
282
|
+
declaredIn,
|
|
283
|
+
componentsOf,
|
|
284
|
+
componentResourcesOf,
|
|
285
|
+
incomplete: [...new Set(incomplete)],
|
|
286
|
+
};
|
|
148
287
|
}
|
package/src/transpile.ts
CHANGED
|
@@ -118,6 +118,9 @@ function buildAmbientFiles(): Record<string, string> {
|
|
|
118
118
|
readAmbient("src/engine-globals.d.ts"),
|
|
119
119
|
"node_modules/@defold-typescript/types/src/go-overloads.d.ts":
|
|
120
120
|
readAmbient("src/go-overloads.d.ts"),
|
|
121
|
+
"node_modules/@defold-typescript/types/src/custom-messages.d.ts": readAmbient(
|
|
122
|
+
"src/custom-messages.d.ts",
|
|
123
|
+
),
|
|
121
124
|
"node_modules/@defold-typescript/types/src/msg-overloads.d.ts":
|
|
122
125
|
readAmbient("src/msg-overloads.d.ts"),
|
|
123
126
|
"node_modules/@defold-typescript/types/src/message-guard.d.ts":
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import * as ts from "typescript";
|
|
2
|
+
|
|
3
|
+
// Shared by both address checks, so neither can drift about which slot
|
|
4
|
+
// expressions carry a statically-known address: the walk's ambient filter, the
|
|
5
|
+
// canonical `Hash` identity, and the recovery of the string a `Hash` was hashed
|
|
6
|
+
// from all live here and are imported, never re-derived.
|
|
7
|
+
|
|
8
|
+
export function isAmbient(fileName: string): boolean {
|
|
9
|
+
return /[\\/]node_modules[\\/]/.test(fileName) || /(^|[\\/])lib\.[^\\/]*\.d\.ts$/.test(fileName);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// The symbol a generic type reference was instantiated from — `undefined` for
|
|
13
|
+
// anything that is not one.
|
|
14
|
+
function referenceTargetSymbol(type: ts.Type): ts.Symbol | undefined {
|
|
15
|
+
if ((type.flags & ts.TypeFlags.Object) === 0) return undefined;
|
|
16
|
+
if (((type as ts.ObjectType).objectFlags & ts.ObjectFlags.Reference) === 0) return undefined;
|
|
17
|
+
return (type as ts.TypeReference).target.symbol;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// What the global `Hash` *type* name means in scope at the anchor, as symbols.
|
|
21
|
+
// The shipped `type Hash<S extends string = string> = Core.Hash<S>` yields the
|
|
22
|
+
// `Core.Hash` symbol; a project's `Project.Hash` is not in global scope under
|
|
23
|
+
// the bare name and never appears, and a project cannot add a second global
|
|
24
|
+
// `Hash` type without a duplicate-identifier error — so unlike the *name*, this
|
|
25
|
+
// anchor cannot be shadowed.
|
|
26
|
+
function ambientHashAliasTargets(checker: ts.TypeChecker, anchor: ts.Node): ReadonlySet<ts.Symbol> {
|
|
27
|
+
const targets = new Set<ts.Symbol>();
|
|
28
|
+
for (const symbol of checker.getSymbolsInScope(anchor, ts.SymbolFlags.Type)) {
|
|
29
|
+
if (symbol.name !== "Hash") continue;
|
|
30
|
+
const target = referenceTargetSymbol(checker.getDeclaredTypeOfSymbol(symbol));
|
|
31
|
+
if (target) targets.add(target);
|
|
32
|
+
}
|
|
33
|
+
return targets;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The declarations *the* `Hash` can come from, resolved from the two anchors the
|
|
37
|
+
// shipped ambient declarations carry one line apart rather than from the file
|
|
38
|
+
// `Hash` happens to be declared in. A path test answers differently for an
|
|
39
|
+
// installed `node_modules/@defold-typescript/types/…` and a workspace-resolved
|
|
40
|
+
// `packages/types/…` copy of the very same declarations, which silently emptied
|
|
41
|
+
// this report for anyone consuming the package through a `paths` mapping or a
|
|
42
|
+
// symlink (bug-121); a symbol identity holds under both.
|
|
43
|
+
//
|
|
44
|
+
// The answer is the *intersection* of the two anchors. The `hash` function is
|
|
45
|
+
// the scope-and-fail-closed anchor: its scope anchor is an ambient file so that
|
|
46
|
+
// no user module's own `hash` participates, and every call signature is walked
|
|
47
|
+
// because a project's global-script `function hash(s: string): string` *merges*
|
|
48
|
+
// into this symbol rather than replacing it — the declarations then span both
|
|
49
|
+
// files and a fully-qualified-name test would reject the real one. The global
|
|
50
|
+
// `Hash` alias is the identity anchor: the function alone is not enough, because
|
|
51
|
+
// a merged project overload contributes its own signatures, and testing what
|
|
52
|
+
// they return by *name* admits any project type called `Hash` and then reports a
|
|
53
|
+
// type argument that was never an address (bug-122). Asking the checker instead
|
|
54
|
+
// of the filesystem is the same discipline as `tableKey` in
|
|
55
|
+
// `url-address-slots.ts`.
|
|
56
|
+
export function canonicalHashSymbols(
|
|
57
|
+
checker: ts.TypeChecker,
|
|
58
|
+
program: ts.Program,
|
|
59
|
+
): ReadonlySet<ts.Symbol> {
|
|
60
|
+
const files = program.getSourceFiles();
|
|
61
|
+
const anchor = files.find((file) => isAmbient(file.fileName)) ?? files[0];
|
|
62
|
+
const canonical = new Set<ts.Symbol>();
|
|
63
|
+
if (!anchor) return canonical;
|
|
64
|
+
|
|
65
|
+
const aliased = ambientHashAliasTargets(checker, anchor);
|
|
66
|
+
|
|
67
|
+
for (const symbol of checker.getSymbolsInScope(anchor, ts.SymbolFlags.Function)) {
|
|
68
|
+
if (symbol.name !== "hash") continue;
|
|
69
|
+
const declaration = symbol.declarations?.[0];
|
|
70
|
+
if (!declaration) continue;
|
|
71
|
+
for (const signature of checker
|
|
72
|
+
.getTypeOfSymbolAtLocation(symbol, declaration)
|
|
73
|
+
.getCallSignatures()) {
|
|
74
|
+
const target = referenceTargetSymbol(signature.getReturnType());
|
|
75
|
+
if (target && aliased.has(target)) canonical.add(target);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return canonical;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The string a `Hash` was hashed from, recovered from its type argument —
|
|
82
|
+
// `undefined` for anything else, including a bare `Hash`, a `Url`, a `string`,
|
|
83
|
+
// and a union. `hash` records the argument in `Hash<S>` (see the `Hash` JSDoc in
|
|
84
|
+
// `@defold-typescript/types`), so `const SPRITE = hash("#sprite")` carries the
|
|
85
|
+
// text on the constant's type and no reassignment tracking is needed.
|
|
86
|
+
//
|
|
87
|
+
// The interface must be *the* `Hash`: a project's own generic type named `Hash`
|
|
88
|
+
// resolves to its own declaration, and reading a type argument off it would
|
|
89
|
+
// report a fragment that was never an address. `canonical` is what settles that.
|
|
90
|
+
function hashedSourceOfType(
|
|
91
|
+
checker: ts.TypeChecker,
|
|
92
|
+
type: ts.Type,
|
|
93
|
+
canonical: ReadonlySet<ts.Symbol>,
|
|
94
|
+
): string | undefined {
|
|
95
|
+
const target = referenceTargetSymbol(type);
|
|
96
|
+
if (!target || !canonical.has(target)) return undefined;
|
|
97
|
+
const [source] = checker.getTypeArguments(type as ts.TypeReference);
|
|
98
|
+
return source?.isStringLiteral() ? source.value : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The address text a slot expression is statically known to carry — a quoted or
|
|
103
|
+
* backtick-quoted literal's own text, or the string a `Hash`-typed value was
|
|
104
|
+
* hashed from — and `undefined` for everything a project only settles at
|
|
105
|
+
* runtime: a substituted template, a bare `Hash`, a `Url`, a `string`.
|
|
106
|
+
*/
|
|
107
|
+
export function staticAddressTextOf(
|
|
108
|
+
checker: ts.TypeChecker,
|
|
109
|
+
node: ts.Expression,
|
|
110
|
+
canonical: ReadonlySet<ts.Symbol>,
|
|
111
|
+
): string | undefined {
|
|
112
|
+
return ts.isStringLiteralLike(node)
|
|
113
|
+
? node.text
|
|
114
|
+
: hashedSourceOfType(checker, checker.getTypeAtLocation(node), canonical);
|
|
115
|
+
}
|
package/src/url-address-slots.ts
CHANGED
|
@@ -17,14 +17,18 @@ import * as ts from "typescript";
|
|
|
17
17
|
// fragment. `addressText` is the statically-known text of the sibling argument
|
|
18
18
|
// the entry names as its address companion — present only for a class that
|
|
19
19
|
// declares one, and only when that argument is a plain string literal.
|
|
20
|
-
// `
|
|
21
|
-
//
|
|
20
|
+
// `nodeParameter` is the alternative companion, carried through as the entry
|
|
21
|
+
// wrote it: it names a gui node rather than an address, so nothing is read out
|
|
22
|
+
// of the argument and its presence alone says the candidates are scoped by the
|
|
23
|
+
// scene owning the script. `resourceExtensions` is the same shape for the file
|
|
24
|
+
// extensions a resource slot accepts: present only when the entry declares them.
|
|
22
25
|
export interface ClassifiedSlot {
|
|
23
26
|
readonly class: UrlParameterClass;
|
|
24
27
|
readonly text: string;
|
|
25
28
|
readonly textStart: number;
|
|
26
29
|
readonly fragmentStart: number;
|
|
27
30
|
readonly addressText?: string;
|
|
31
|
+
readonly nodeParameter?: string;
|
|
28
32
|
readonly resourceExtensions?: readonly string[];
|
|
29
33
|
}
|
|
30
34
|
|
|
@@ -192,6 +196,20 @@ function slotThroughHashedCall(
|
|
|
192
196
|
return ascended?.entry.fqn === "hash" ? undefined : ascended;
|
|
193
197
|
}
|
|
194
198
|
|
|
199
|
+
// The table entry governing the argument slot this expression occupies, but only
|
|
200
|
+
// when that slot names something in the scene graph by address — `undefined`
|
|
201
|
+
// otherwise. Filtering the entry through the same `isAddressClass` the class
|
|
202
|
+
// accessor is judged by is what stops a caller from reading a slot's
|
|
203
|
+
// `socketScope` off an entry whose class was never an address.
|
|
204
|
+
export function addressEntryOfArgument(
|
|
205
|
+
checker: ts.TypeChecker,
|
|
206
|
+
table: UrlParameterTable,
|
|
207
|
+
argument: ts.Expression,
|
|
208
|
+
): UrlParameterEntry | undefined {
|
|
209
|
+
const entry = classifiedEntryOfArgument(checker, table, argument)?.entry;
|
|
210
|
+
return entry !== undefined && isAddressClass(entry.class) ? entry : undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
195
213
|
// How the committed table classifies the argument slot this expression occupies
|
|
196
214
|
// — `"none"` when nothing resolves, which is also the answer for any node that
|
|
197
215
|
// is not a call argument at all.
|
|
@@ -283,6 +301,9 @@ export function resolveClassifiedSlotAtPosition(input: {
|
|
|
283
301
|
textStart,
|
|
284
302
|
fragmentStart: hash === -1 ? -1 : textStart + hash + 1,
|
|
285
303
|
...(addressText === undefined ? {} : { addressText }),
|
|
304
|
+
...(classified.entry.nodeParameter === undefined
|
|
305
|
+
? {}
|
|
306
|
+
: { nodeParameter: classified.entry.nodeParameter }),
|
|
286
307
|
...(classified.entry.resourceExtensions === undefined
|
|
287
308
|
? {}
|
|
288
309
|
: { resourceExtensions: classified.entry.resourceExtensions }),
|
|
@@ -0,0 +1,109 @@
|
|
|
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 judgment is read off the entry the
|
|
5
|
+
// slot lookup already resolved, and `url-cross-world-address.test.ts` binds that
|
|
6
|
+
// reading to `slotAcceptsForeignSocket` over the committed table so the two
|
|
7
|
+
// cannot drift.
|
|
8
|
+
import type { UrlParameterEntry, UrlParameterTable } from "@defold-typescript/types";
|
|
9
|
+
import * as ts from "typescript";
|
|
10
|
+
import { socketOfAddress } from "./scene-naming-context";
|
|
11
|
+
import { canonicalHashSymbols, isAmbient, staticAddressTextOf } from "./url-address-literals";
|
|
12
|
+
import { addressEntryOfArgument } from "./url-address-slots";
|
|
13
|
+
|
|
14
|
+
/** One address naming a world the script writing it cannot reach. */
|
|
15
|
+
export interface CrossWorldAddressFinding {
|
|
16
|
+
readonly fileName: string;
|
|
17
|
+
readonly start: number;
|
|
18
|
+
readonly length: number;
|
|
19
|
+
/** The statically-known address text, as written or as hashed. */
|
|
20
|
+
readonly address: string;
|
|
21
|
+
/** The foreign proxy socket that text names. */
|
|
22
|
+
readonly socket: string;
|
|
23
|
+
readonly message: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Whether this slot refuses a socket-qualified address naming another world.
|
|
28
|
+
*
|
|
29
|
+
* The negation of `slotAcceptsForeignSocket` in `@defold-typescript/types`, and
|
|
30
|
+
* deliberately fails open the same way: a slot carrying no judgment refuses
|
|
31
|
+
* nothing, so nothing is reported on a slot nobody classified.
|
|
32
|
+
*/
|
|
33
|
+
export function slotRejectsForeignSocket(entry: UrlParameterEntry): boolean {
|
|
34
|
+
return entry.socketScope === "same-world";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// How a caller's own world reads in a message. The bootstrap world has no
|
|
38
|
+
// socket, and naming it by its absence would produce `the world ""`.
|
|
39
|
+
function worldPhrase(sockets: readonly (string | undefined)[]): string {
|
|
40
|
+
const named = [...new Set(sockets)].map((socket) =>
|
|
41
|
+
socket === undefined ? "the bootstrap world" : `"${socket}"`,
|
|
42
|
+
);
|
|
43
|
+
return named.join(" and ");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Report every address-slot expression whose statically-known text names a proxy
|
|
48
|
+
* socket the script writing it does not run in, at a slot that resolves through
|
|
49
|
+
* an instance or component handle rather than the message bus.
|
|
50
|
+
*
|
|
51
|
+
* Deliberately not folded into `checkUrlFragmentReachability`: that check
|
|
52
|
+
* suppresses index-wide on an incomplete component universe, while this one's
|
|
53
|
+
* honesty rule is per file — a script whose naming contexts could not be
|
|
54
|
+
* resolved is reported on at all, because unknown is not wrong. Fusing them
|
|
55
|
+
* would put one check's early return in front of the other's findings.
|
|
56
|
+
*
|
|
57
|
+
* `worldsOf` answers with the sockets of every naming context the file's script
|
|
58
|
+
* runs in; an empty answer is the unknown case and yields nothing.
|
|
59
|
+
*/
|
|
60
|
+
export function checkCrossWorldAddresses(input: {
|
|
61
|
+
program: ts.Program;
|
|
62
|
+
table: UrlParameterTable;
|
|
63
|
+
worldsOf: (fileName: string) => readonly (string | undefined)[];
|
|
64
|
+
sourceFiles?: readonly ts.SourceFile[];
|
|
65
|
+
}): readonly CrossWorldAddressFinding[] {
|
|
66
|
+
const { program, table, worldsOf, sourceFiles } = input;
|
|
67
|
+
|
|
68
|
+
const checker = program.getTypeChecker();
|
|
69
|
+
const canonical = canonicalHashSymbols(checker, program);
|
|
70
|
+
const findings: CrossWorldAddressFinding[] = [];
|
|
71
|
+
const targets = sourceFiles ?? program.getSourceFiles();
|
|
72
|
+
|
|
73
|
+
for (const sourceFile of targets) {
|
|
74
|
+
if (isAmbient(sourceFile.fileName)) continue;
|
|
75
|
+
const worlds = worldsOf(sourceFile.fileName);
|
|
76
|
+
if (worlds.length === 0) continue;
|
|
77
|
+
|
|
78
|
+
const visit = (node: ts.Node): void => {
|
|
79
|
+
if (ts.isExpression(node)) {
|
|
80
|
+
const entry = addressEntryOfArgument(checker, table, node);
|
|
81
|
+
if (entry !== undefined && slotRejectsForeignSocket(entry)) {
|
|
82
|
+
const address = staticAddressTextOf(checker, node, canonical);
|
|
83
|
+
const socket = address === undefined ? undefined : socketOfAddress(address);
|
|
84
|
+
if (
|
|
85
|
+
address !== undefined &&
|
|
86
|
+
socket !== undefined &&
|
|
87
|
+
worlds.every((world) => world !== socket)
|
|
88
|
+
) {
|
|
89
|
+
findings.push({
|
|
90
|
+
fileName: sourceFile.fileName,
|
|
91
|
+
start: node.getStart(sourceFile),
|
|
92
|
+
length: node.getWidth(sourceFile),
|
|
93
|
+
address,
|
|
94
|
+
socket,
|
|
95
|
+
message:
|
|
96
|
+
`this address names the world "${socket}", but \`${entry.fqn}\` resolves it in ` +
|
|
97
|
+
`the world the script runs in (${worldPhrase(worlds)}), so it cannot resolve at ` +
|
|
98
|
+
`runtime — only \`msg.post\` and \`msg.url\` cross a collection proxy`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
ts.forEachChild(node, visit);
|
|
104
|
+
};
|
|
105
|
+
ts.forEachChild(sourceFile, visit);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return findings;
|
|
109
|
+
}
|