@defold-typescript/transpiler 0.29.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 +21 -8
  7. package/dist/index.js +1317 -289
  8. package/dist/library-dependencies.d.ts +47 -0
  9. package/dist/scene-address-declaration.d.ts +14 -0
  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 +21 -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 +66 -10
  26. package/src/library-dependencies.ts +337 -0
  27. package/src/scene-address-declaration.ts +74 -0
  28. package/src/scene-collection-roles.ts +267 -0
  29. package/src/scene-component-index.ts +74 -6
  30. package/src/scene-documents.ts +186 -0
  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
@@ -0,0 +1,130 @@
1
+ import type { SceneObjectPathIndex } from "./scene-object-path-index";
2
+
3
+ /**
4
+ * One place a script runs, and the addressing frame it runs in. At runtime an
5
+ * instance carries `m_CollectionPathHashState` seeded from its compiled id up
6
+ * to and including the last `/`, and `GetAbsoluteIdentifier` resolves a
7
+ * relative string by continuing that hash — so a relative address written in a
8
+ * script means one thing from this object and nothing anywhere else.
9
+ */
10
+ export interface NamingContext {
11
+ /** The world-qualified composed path of the object hosting the script. */
12
+ readonly object: string;
13
+ /** The proxy socket this object lives behind, or `undefined` in the bootstrap world. */
14
+ readonly socket: string | undefined;
15
+ /**
16
+ * `object` through its last `/`, inclusive — the collection path a relative
17
+ * address continues. World-qualified by construction, so a relative address
18
+ * can never resolve across a world.
19
+ */
20
+ readonly prefix: string;
21
+ }
22
+
23
+ const SCRIPT_SUFFIX = ".script";
24
+ const GUI_SUFFIX = ".gui";
25
+
26
+ /**
27
+ * The proxy socket an address names, or `undefined` when it names none — a
28
+ * bootstrap-world path, a relative path, a bare `#fragment`.
29
+ *
30
+ * Defold's address grammar is `[socket:][path][#fragment]` with both bracketed
31
+ * halves optional, so `mylevel:`, `mylevel:enemy` and `mylevel:#body` are as
32
+ * socket-qualified as `mylevel:/path`. The socket is therefore read from the
33
+ * `:` delimiter rather than from a following `/`: the head is the address up to
34
+ * the first `/` or `#` (the whole address when it carries neither), and the
35
+ * socket is the text before that head's first `:`. A `:` inside a path segment
36
+ * (`/level:1/enemy`) is not a socket, because the `/` closes the head first,
37
+ * and an empty socket (`:path`) is none.
38
+ *
39
+ * Exported because a written address literal and a composed object path are the
40
+ * same shape: the cross-world check reads a socket out of source text with this
41
+ * one rule rather than a second reader that could disagree with the contexts it
42
+ * compares against.
43
+ */
44
+ export function socketOfAddress(address: string): string | undefined {
45
+ const pathStart = address.indexOf("/");
46
+ const fragmentStart = address.indexOf("#");
47
+ const ends = [pathStart, fragmentStart, address.length].filter((end) => end >= 0);
48
+ const headEnd = Math.min(...ends);
49
+ const delimiter = address.indexOf(":");
50
+ return delimiter > 0 && delimiter < headEnd ? address.slice(0, delimiter) : undefined;
51
+ }
52
+
53
+ function contextOf(object: string): NamingContext {
54
+ return {
55
+ object,
56
+ socket: socketOfAddress(object),
57
+ prefix: object.slice(0, object.lastIndexOf("/") + 1),
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Every naming context each script resource runs in, keyed by that resource and
63
+ * sorted by object path.
64
+ *
65
+ * `guiScripts` maps a `.gui` display path to the gui-script resource it names,
66
+ * which the caller builds with `guiScriptResourceOf` — the walk needs no `.gui`
67
+ * documents of its own.
68
+ *
69
+ * A script hosted by several objects has several contexts, and that is not
70
+ * ambiguity: a `.go` instanced in two collections genuinely runs in both, and
71
+ * it is the consumer's provenance that tells an author which context an entry
72
+ * resolves from.
73
+ */
74
+ export function buildScriptNamingContexts(
75
+ index: SceneObjectPathIndex,
76
+ guiScripts: ReadonlyMap<string, string>,
77
+ ): ReadonlyMap<string, readonly NamingContext[]> {
78
+ const contexts = new Map<string, NamingContext[]>();
79
+
80
+ for (const [object, resources] of index.componentResourcesOf) {
81
+ for (const resource of resources.values()) {
82
+ const script = resource.endsWith(SCRIPT_SUFFIX)
83
+ ? resource
84
+ : resource.endsWith(GUI_SUFFIX)
85
+ ? guiScripts.get(resource)
86
+ : undefined;
87
+ if (script === undefined) continue;
88
+ const hosted = contexts.get(script);
89
+ if (hosted === undefined) {
90
+ contexts.set(script, [contextOf(object)]);
91
+ } else if (!hosted.some((context) => context.object === object)) {
92
+ hosted.push(contextOf(object));
93
+ }
94
+ }
95
+ }
96
+
97
+ for (const hosted of contexts.values()) {
98
+ hosted.sort((a, b) => a.object.localeCompare(b.object));
99
+ }
100
+ return contexts;
101
+ }
102
+
103
+ /**
104
+ * The relative forms a script may write from one naming context: every object
105
+ * path under the context's prefix with that prefix stripped, and each of those
106
+ * joined to the components its object owns.
107
+ *
108
+ * An object `componentsOf` withheld still contributes its path — the object is
109
+ * provably there — but no `#component` join, because which components it owns
110
+ * was never read.
111
+ */
112
+ export function relativeAddressesFrom(
113
+ index: SceneObjectPathIndex,
114
+ context: NamingContext,
115
+ ): { readonly paths: ReadonlySet<string>; readonly addresses: ReadonlySet<string> } {
116
+ const paths = new Set<string>();
117
+ const addresses = new Set<string>();
118
+
119
+ for (const key of index.paths) {
120
+ if (!key.startsWith(context.prefix)) continue;
121
+ const relative = key.slice(context.prefix.length);
122
+ if (relative === "") continue;
123
+ paths.add(relative);
124
+ for (const id of index.componentsOf.get(key) ?? []) {
125
+ addresses.add(`${relative}#${id}`);
126
+ }
127
+ }
128
+
129
+ return { paths, addresses };
130
+ }
@@ -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 }),