@defold-typescript/transpiler 0.30.0 → 0.31.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.
Files changed (37) hide show
  1. package/dist/animation-sources.d.ts +6 -0
  2. package/dist/{sprite-animation-index.d.ts → component-animation-index.d.ts} +4 -4
  3. package/dist/config-key-index.d.ts +1 -0
  4. package/dist/gui-flipbook-index.d.ts +8 -0
  5. package/dist/gui-node-index.d.ts +21 -0
  6. package/dist/index.d.ts +19 -9
  7. package/dist/index.js +1329 -400
  8. package/dist/library-dependencies.d.ts +47 -0
  9. package/dist/scene-address-declaration.d.ts +4 -2
  10. package/dist/scene-collection-roles.d.ts +30 -0
  11. package/dist/scene-component-index.d.ts +20 -0
  12. package/dist/scene-documents.d.ts +3 -0
  13. package/dist/scene-naming-context.d.ts +66 -0
  14. package/dist/scene-object-path-index.d.ts +4 -1
  15. package/dist/url-address-literals.d.ts +10 -0
  16. package/dist/url-address-slots.d.ts +3 -1
  17. package/dist/url-cross-world-address.d.ts +41 -0
  18. package/dist/url-fragment-reachability.d.ts +3 -0
  19. package/package.json +2 -2
  20. package/src/animation-sources.ts +90 -0
  21. package/src/component-animation-index.ts +399 -0
  22. package/src/config-key-index.ts +28 -2
  23. package/src/gui-flipbook-index.ts +91 -0
  24. package/src/gui-node-index.ts +42 -4
  25. package/src/index.ts +52 -10
  26. package/src/library-dependencies.ts +337 -0
  27. package/src/scene-address-declaration.ts +29 -12
  28. package/src/scene-collection-roles.ts +267 -0
  29. package/src/scene-component-index.ts +74 -6
  30. package/src/scene-documents.ts +66 -6
  31. package/src/scene-naming-context.ts +130 -0
  32. package/src/scene-object-path-index.ts +172 -33
  33. package/src/url-address-literals.ts +115 -0
  34. package/src/url-address-slots.ts +23 -2
  35. package/src/url-cross-world-address.ts +109 -0
  36. package/src/url-fragment-reachability.ts +72 -93
  37. 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
- return { paths: new Set(), declaredIn: new Map(), incomplete };
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, string>>();
107
+ const composed = new Map<string, ReadonlyMap<string, PathLeaf>>();
80
108
  const inProgress = new Set<string>();
81
109
 
82
- function pathsOf(displayPath: string): ReadonlyMap<string, string> {
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, 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 !== undefined && id !== "") paths.set(`/${id}`, displayPath);
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, declarer] of pathsOf(key)) {
116
- paths.set(`/${id}${nested}`, declarer);
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 un-instanced ones alone: a
126
- // cycle or a dangling reference reached only from inside an instanced
127
- // collection is still a gap the caller has to hear about.
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
- for (const displayPath of parsed.keys()) {
131
- const own = pathsOf(displayPath);
132
- if (instanced.has(displayPath)) continue;
133
- for (const [path, declarer] of own) {
134
- paths.add(path);
135
- const declarers = declaredIn.get(path);
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(path, [declarer]);
138
- } else if (!declarers.includes(declarer)) {
139
- // Two roots instancing one collection under the same id compose the same
140
- // address from the same leaf, which is one declaring file and not two.
141
- declarers.push(declarer);
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
- return { paths, declaredIn, incomplete };
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
  }
@@ -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
+ }
@@ -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
- // `resourceExtensions` is the same shape for the file extensions a resource slot
21
- // accepts: present only when the entry declares them.
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
+ }