@defold-typescript/tstl-plugin 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.
@@ -0,0 +1,55 @@
1
+ import { type NamingContext, type SceneObjectPathIndex } from "@defold-typescript/transpiler";
2
+ import { type SceneIndexCache } from "./scene-index-cache";
3
+ export interface RelativeUniverse {
4
+ /** Every object hosting this file's generated script, sorted by object path. */
5
+ readonly contexts: readonly NamingContext[];
6
+ /** Relative game-object paths, valid from at least one context. */
7
+ readonly paths: ReadonlySet<string>;
8
+ /** Those paths joined to the components their object owns. */
9
+ readonly addresses: ReadonlySet<string>;
10
+ /** Entry name -> the sorted object paths it resolves from. */
11
+ readonly contextsByEntry: ReadonlyMap<string, readonly string[]>;
12
+ }
13
+ /**
14
+ * Every game-object path the project declares and the components each owns —
15
+ * the index a check scopes an absolute `#fragment` address against.
16
+ *
17
+ * Read off the walk `projectContexts` already caches rather than built again:
18
+ * the naming contexts are derived *from* this index, so the reachability path
19
+ * costs no second `.go`/`.collection` parse.
20
+ */
21
+ export declare function projectObjectIndex(cache: SceneIndexCache): SceneObjectPathIndex;
22
+ /**
23
+ * The relative universe the file at `fileName` may write from, keyed per file
24
+ * under the same cache the absolute indexes live in.
25
+ *
26
+ * The file is mapped *forward* to the script resource a scene would name — the
27
+ * same direction `nodeEntries` takes, because an output path cannot say which
28
+ * include base produced it. Both script kinds are resolved and their contexts
29
+ * unioned: a `.ts` is named as a `.script` by a game object and as a
30
+ * `.gui_script` by a `.gui`, and nothing in the file itself says which.
31
+ */
32
+ export declare function relativeUniverseFor(cache: SceneIndexCache, fileName: string): RelativeUniverse;
33
+ /**
34
+ * The worlds the file's script runs in, as `checkCrossWorldAddresses` asks for
35
+ * them: the de-duplicated sockets of every naming context hosting it, with the
36
+ * bootstrap world carried as `undefined`.
37
+ *
38
+ * The same answer `scriptWorldsForBuild` gives the CLI, reached through the
39
+ * chain that resolver was modelled on — so an address the build calls foreign is
40
+ * foreign in the editor too. An empty answer is the honest unknown: a script no
41
+ * scene hosts runs in no world this project can prove, and the check reports
42
+ * nothing for it.
43
+ */
44
+ export declare function worldsFor(cache: SceneIndexCache, fileName: string): readonly (string | undefined)[];
45
+ /**
46
+ * Whether a bare `/path` can resolve from where this file runs. A bare address
47
+ * continues the caller's own world, so one offered to a script running behind a
48
+ * proxy names an object that world cannot reach.
49
+ *
50
+ * A file with no naming context offers them: nothing is known about where it
51
+ * runs, and withholding every bare path would delete the completions the
52
+ * absolute universe has always provided. Socket-qualified keys are never
53
+ * touched — whether a slot accepts a foreign socket is a different question.
54
+ */
55
+ export declare function offersBareWorld(universe: RelativeUniverse): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defold-typescript/tstl-plugin",
3
- "version": "0.29.0",
3
+ "version": "0.31.0",
4
4
  "description": "TypeScript language-service plugin surfacing Defold transpiler diagnostics live in the editor.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -11,32 +11,34 @@
11
11
  "homepage": "https://github.com/defold-typescript/toolchain#readme",
12
12
  "bugs": "https://github.com/defold-typescript/toolchain/issues",
13
13
  "type": "module",
14
- "main": "./dist/index.js",
14
+ "main": "./dist/index.cjs",
15
15
  "types": "./dist/index.d.ts",
16
16
  "exports": {
17
17
  ".": {
18
18
  "types": "./dist/index.d.ts",
19
19
  "bun": "./src/index.ts",
20
- "import": "./dist/index.js"
20
+ "import": "./dist/index.js",
21
+ "require": "./dist/index.cjs"
21
22
  }
22
23
  },
23
24
  "files": [
24
25
  "dist",
25
26
  "src",
26
27
  "!src/**/*.test.ts",
28
+ "!src/**/completion-harness.ts",
27
29
  "!src/**/__snapshots__"
28
30
  ],
29
31
  "publishConfig": {
30
32
  "access": "public"
31
33
  },
32
34
  "scripts": {
33
- "build": "rm -rf dist && bun build src/index.ts --target=node --format=esm --packages=external --outdir=dist && tsc -p tsconfig.build.json --emitDeclarationOnly",
35
+ "build": "rm -rf dist && bun build src/index.ts --target=node --format=esm --packages=external --outdir=dist && cp src/require-entry.cjs dist/index.cjs && tsc -p tsconfig.build.json --emitDeclarationOnly",
34
36
  "typecheck": "tsc -p tsconfig.json --noEmit",
35
37
  "test": "bun test"
36
38
  },
37
39
  "dependencies": {
38
- "@defold-typescript/transpiler": "0.29.0",
39
- "@defold-typescript/types": "0.29.0"
40
+ "@defold-typescript/transpiler": "0.31.0",
41
+ "@defold-typescript/types": "0.31.0"
40
42
  },
41
43
  "peerDependencies": {
42
44
  "typescript": ">=5.0.0 <7"
@@ -1,5 +1,9 @@
1
- import { type BuildConfig, DEFAULT_INCLUDE, parseBuildConfig } from "@defold-typescript/transpiler";
2
- import type { SceneReadHost } from "./scene-documents";
1
+ import {
2
+ type BuildConfig,
3
+ DEFAULT_INCLUDE,
4
+ parseBuildConfig,
5
+ type SceneReadHost,
6
+ } from "@defold-typescript/transpiler";
3
7
 
4
8
  // Alongside output is what the build itself does with no `outDir`, so it is also
5
9
  // the honest answer when the config cannot be read: a wrong guess would offer
package/src/index.ts CHANGED
@@ -1,18 +1,24 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { createRequire } from "node:module";
3
3
  import {
4
+ ANIMATION_ASSET_EXTENSIONS,
5
+ buildComponentAnimationIndex,
4
6
  buildConfigKeyIndex,
7
+ buildGuiFlipbookIndex,
5
8
  buildGuiNodeIndex,
6
9
  buildInputActionIndex,
7
- buildSceneComponentIndex,
8
10
  buildSceneObjectPathIndex,
9
- buildSpriteAnimationIndex,
10
11
  type ClassifiedSlot,
11
12
  componentIdOfSameObjectAddress,
12
13
  computeOutputRel,
14
+ displayPathOf,
15
+ GAME_PROJECT_DOCUMENT,
16
+ GUI_EXTENSIONS,
13
17
  getProgramDiagnostics,
18
+ INPUT_BINDING_EXTENSIONS,
14
19
  isAddressClass,
15
20
  isFragmentCaret,
21
+ PROJECT_EXTENSIONS,
16
22
  resolveClassifiedSlotAtPosition,
17
23
  } from "@defold-typescript/transpiler";
18
24
  import type { UrlParameterTable } from "@defold-typescript/types";
@@ -25,20 +31,16 @@ import {
25
31
  CONTRIBUTED_ENTRY_KIND,
26
32
  DEFOLD_COMPLETION_SOURCE,
27
33
  } from "./scene-completions";
28
- import {
29
- ANIMATION_ASSET_EXTENSIONS,
30
- displayPathOf,
31
- GAME_PROJECT_DOCUMENT,
32
- GUI_EXTENSIONS,
33
- INPUT_BINDING_EXTENSIONS,
34
- PROJECT_EXTENSIONS,
35
- } from "./scene-documents";
34
+ import { sceneReachabilityDiagnostics } from "./scene-diagnostics";
36
35
  import {
37
36
  createSceneIndexCache,
38
37
  type SceneIndexCache,
39
38
  type SceneWatchHost,
39
+ sceneCollectionRolesOf,
40
+ sceneComponentIndexOf,
40
41
  } from "./scene-index-cache";
41
- import { resolveEntryProvenance } from "./scene-provenance";
42
+ import { resolveEntryProvenance, resolveRelativeEntryProvenance } from "./scene-provenance";
43
+ import { offersBareWorld, relativeUniverseFor } from "./scene-relative-addresses";
42
44
 
43
45
  const requireFromHere = createRequire(import.meta.url);
44
46
 
@@ -78,35 +80,50 @@ function componentEntries(
78
80
  // suggestion claims nothing about what is absent.
79
81
  return buildSceneCompletionEntries({
80
82
  slot,
81
- ids: cache.derived(
82
- "component-ids",
83
- () => buildSceneComponentIndex(cache.documents().documents).ids,
84
- ),
83
+ ids: sceneComponentIndexOf(cache).ids,
85
84
  baseEntries,
86
85
  });
87
86
  }
88
87
 
89
88
  // The exact complement of `componentEntries`' guard, so precisely one of the two
90
- // universes answers any caret in an address. Project-wide like the component
91
- // universe, and for the same reason: what a path resolves to at runtime depends
92
- // on the collection that was loaded, which the file being edited does not say.
89
+ // universes answers any caret in an address. The absolute half is project-wide
90
+ // like the component universe, and for the same reason: what a path resolves to
91
+ // at runtime depends on the collection that was loaded, which the file being
92
+ // edited does not say. The relative half cannot be — a relative address
93
+ // continues the collection path of the object hosting *this* script — so it is
94
+ // read per file and merged in, and the caller's own world decides whether a bare
95
+ // absolute path can resolve at all.
93
96
  function objectPathEntries(
94
97
  slot: ClassifiedSlot,
95
98
  position: number,
96
99
  cache: SceneIndexCache,
100
+ fileName: string,
97
101
  baseEntries: readonly ts.CompletionEntry[],
98
102
  ): ts.CompletionEntry[] {
99
103
  if (isFragmentCaret(slot, position)) {
100
104
  return [];
101
105
  }
102
- return buildAddressPathCompletionEntries({
103
- slot,
104
- paths: cache.derived(
105
- "object-paths",
106
- () => buildSceneObjectPathIndex(cache.documents().documents).paths,
107
- ),
108
- baseEntries,
109
- });
106
+ const universe = relativeUniverseFor(cache, fileName);
107
+ const bare = offersBareWorld(universe);
108
+ const absolute = cache.derived(
109
+ "object-paths",
110
+ () =>
111
+ buildSceneObjectPathIndex(cache.documents().documents, sceneCollectionRolesOf(cache)).paths,
112
+ );
113
+ const paths = new Set<string>();
114
+ for (const key of absolute) {
115
+ if (bare || !key.startsWith("/")) paths.add(key);
116
+ }
117
+ for (const key of universe.paths) paths.add(key);
118
+ const entries = buildAddressPathCompletionEntries({ slot, paths, baseEntries });
119
+ // A join is the whole literal, so accepting one where the author already typed
120
+ // a `#` would silently rewrite the fragment they are holding.
121
+ if (slot.fragmentStart === -1) {
122
+ entries.push(
123
+ ...buildWholeLiteralCompletionEntries({ slot, ids: universe.addresses, baseEntries }),
124
+ );
125
+ }
126
+ return entries;
110
127
  }
111
128
 
112
129
  // No caret guard: the span is the whole literal, so an entry is well-formed
@@ -133,24 +150,46 @@ function nodeEntries(
133
150
  }
134
151
 
135
152
  // Also no caret guard, and the same forward output-path mapping — but scoped
136
- // one step further than a node id: to the sprite component the slot's *sibling*
137
- // literal addresses on the one game object that owns this script. Every
138
- // unresolved link returns nothing rather than a project-wide guess, because a
139
- // `sprite.play_flipbook` id the addressed atlas does not declare is a runtime
140
- // crash.
153
+ // one step further than a node id, in whichever of the two ways the slot's own
154
+ // entry declares. Every unresolved link returns nothing rather than a
155
+ // project-wide guess, because an animation id the resolved atlas does not
156
+ // declare is a runtime crash.
157
+ //
158
+ // The branch is the table's own discriminator rather than a second arm in the
159
+ // class chain: an `animation` entry names either an address companion or a node
160
+ // companion, and the drift guard is what keeps it to exactly one.
141
161
  function animationEntries(
142
162
  slot: ClassifiedSlot,
143
163
  cache: SceneIndexCache,
144
164
  fileName: string,
145
165
  baseEntries: readonly ts.CompletionEntry[],
146
166
  ): ts.CompletionEntry[] {
167
+ const { projectRoot } = cache;
168
+ // Scoped to the `.gui` scene naming this file's generated gui script, never
169
+ // to the node the call addresses: `gui.set_texture` retargets a node at
170
+ // runtime, so the scene's textures are the honest universe.
171
+ if (slot.nodeParameter !== undefined) {
172
+ const index = cache.derived("gui-flipbook", () =>
173
+ buildGuiFlipbookIndex({
174
+ scenes: cache.documents(GUI_EXTENSIONS).documents,
175
+ assets: cache.documents(ANIMATION_ASSET_EXTENSIONS).documents,
176
+ }),
177
+ );
178
+ const config = readBuildConfigFromHost(cache.host, projectRoot);
179
+ const resource = computeOutputRel(displayPathOf(projectRoot, fileName), config, "gui-script");
180
+ const declared = index.byScriptResource.get(resource);
181
+ return declared === undefined
182
+ ? []
183
+ : buildWholeLiteralCompletionEntries({ slot, ids: new Set(declared.keys()), baseEntries });
184
+ }
185
+ // Scoped to the sprite or model component the slot's *sibling* literal
186
+ // addresses on the one game object that owns this script.
147
187
  const component = componentIdOfSameObjectAddress(slot.addressText ?? "");
148
188
  if (component === undefined) {
149
189
  return [];
150
190
  }
151
- const { projectRoot } = cache;
152
- const index = cache.derived("sprite-animations", () =>
153
- buildSpriteAnimationIndex({
191
+ const index = cache.derived("component-animations", () =>
192
+ buildComponentAnimationIndex({
154
193
  scenes: cache.documents().documents,
155
194
  assets: cache.documents(ANIMATION_ASSET_EXTENSIONS).documents,
156
195
  }),
@@ -212,23 +251,17 @@ function actionIdEntries(
212
251
  }
213
252
 
214
253
  // The panel a claimed request is answered with. `documentation` rather than
215
- // `displayParts` carries the paths because a host renders the former as the
216
- // body of the panel, which is where a list of files reads as one.
217
- function provenancePanel(
218
- entryName: string,
219
- declaredIn: readonly string[],
220
- ): ts.CompletionEntryDetails {
254
+ // `displayParts` carries the sentence because a host renders the former as the
255
+ // body of the panel, which is where a list of files — or of objects — reads as
256
+ // one. The sentence is the caller's, since an absolute entry is answered with
257
+ // the files declaring it and a relative one with the objects it resolves from.
258
+ function provenancePanel(entryName: string, documentation: string): ts.CompletionEntryDetails {
221
259
  return {
222
260
  name: entryName,
223
261
  kind: CONTRIBUTED_ENTRY_KIND,
224
262
  kindModifiers: "",
225
263
  displayParts: [{ kind: "stringLiteral", text: JSON.stringify(entryName) }],
226
- documentation: [
227
- {
228
- kind: "text",
229
- text: `Declared in ${declaredIn.join(", ")}`,
230
- },
231
- ],
264
+ documentation: [{ kind: "text", text: documentation }],
232
265
  source: [{ kind: "text", text: DEFOLD_COMPLETION_SOURCE }],
233
266
  };
234
267
  }
@@ -306,8 +339,23 @@ export default function init(modules: { typescript: typeof import("typescript")
306
339
  if (!slot) {
307
340
  return forward();
308
341
  }
342
+ // Tried first because the two universes are disjoint by construction and
343
+ // only this one knows which object an entry is relative to; a name it does
344
+ // not carry falls straight through to the declaring-file answer.
345
+ const relative = resolveRelativeEntryProvenance({
346
+ slot,
347
+ position,
348
+ cache,
349
+ fileName,
350
+ entryName,
351
+ });
352
+ if (relative !== undefined) {
353
+ return provenancePanel(entryName, relative);
354
+ }
309
355
  const declaredIn = resolveEntryProvenance({ slot, position, cache, fileName, entryName });
310
- return declaredIn.length === 0 ? forward() : provenancePanel(entryName, declaredIn);
356
+ return declaredIn.length === 0
357
+ ? forward()
358
+ : provenancePanel(entryName, `Declared in ${declaredIn.join(", ")}`);
311
359
  };
312
360
 
313
361
  proxy.getSemanticDiagnostics = (fileName: string): ts.Diagnostic[] => {
@@ -321,7 +369,13 @@ export default function init(modules: { typescript: typeof import("typescript")
321
369
  const transpiler = getProgramDiagnostics(program, program.getSourceFile(fileName)).map(
322
370
  (diagnostic) => ({ ...diagnostic, category: ts.DiagnosticCategory.Suggestion }),
323
371
  );
324
- return [...prior, ...transpiler];
372
+ // The same advisory posture, and the same absent-cache early return the
373
+ // completion path takes: a host that cannot enumerate files has no scene
374
+ // universe, and an empty one would report every address as unreachable.
375
+ const table = loadUrlParameterTable();
376
+ const scene =
377
+ cache && table ? sceneReachabilityDiagnostics({ ts, program, table, cache, fileName }) : [];
378
+ return [...prior, ...transpiler, ...scene];
325
379
  };
326
380
 
327
381
  // Strictly additive: every path that cannot produce a suggestion returns the
@@ -356,7 +410,7 @@ export default function init(modules: { typescript: typeof import("typescript")
356
410
  const entries = isAddressClass(slot.class)
357
411
  ? [
358
412
  ...componentEntries(slot, position, cache, baseEntries),
359
- ...objectPathEntries(slot, position, cache, baseEntries),
413
+ ...objectPathEntries(slot, position, cache, fileName, baseEntries),
360
414
  ]
361
415
  : slot.class === "gui-node"
362
416
  ? nodeEntries(slot, cache, fileName, baseEntries)
@@ -0,0 +1,6 @@
1
+ // tsserver resolves a plugin with Node10 resolution — `exports` is not
2
+ // consulted — and then rejects the loaded value unless it is itself a function.
3
+ // `require()` of the ESM entry yields the namespace object instead, so this
4
+ // CommonJS shim is what `main` points at: it unwraps the factory, and the ESM
5
+ // bundle stays the single build of the plugin.
6
+ module.exports = require("./index.js").default;
@@ -0,0 +1,92 @@
1
+ import {
2
+ checkCrossWorldAddresses,
3
+ checkUrlFragmentReachability,
4
+ } from "@defold-typescript/transpiler";
5
+ import type { UrlParameterTable } from "@defold-typescript/types";
6
+ import type * as ts from "typescript";
7
+ import { type SceneIndexCache, sceneComponentIndexOf } from "./scene-index-cache";
8
+ import { projectObjectIndex, worldsFor } from "./scene-relative-addresses";
9
+
10
+ /** The `source` an editor renders beside the message, and `--json` never sees. */
11
+ export const DEFOLD_DIAGNOSTIC_SOURCE = "defold-typescript";
12
+
13
+ // A range neither TypeScript (< 100000) nor tstl occupies, so a host filtering
14
+ // by code can tell the two address findings apart from each other and from
15
+ // everything else the language service reports.
16
+ const UNREACHABLE_FRAGMENT_CODE = 9_000_101;
17
+ const FOREIGN_SOCKET_CODE = 9_000_102;
18
+
19
+ function suggestion(
20
+ typescript: typeof import("typescript"),
21
+ file: ts.SourceFile,
22
+ finding: { readonly start: number; readonly length: number; readonly message: string },
23
+ code: number,
24
+ ): ts.Diagnostic {
25
+ return {
26
+ file,
27
+ start: finding.start,
28
+ length: finding.length,
29
+ messageText: finding.message,
30
+ category: typescript.DiagnosticCategory.Suggestion,
31
+ source: DEFOLD_DIAGNOSTIC_SOURCE,
32
+ code,
33
+ };
34
+ }
35
+
36
+ /**
37
+ * The address findings the editor surfaces on the file being edited, as
38
+ * advisory suggestions.
39
+ *
40
+ * Every path that cannot produce one answers with an empty array. A suppressed
41
+ * reachability report is the important case: there is no file span on which a
42
+ * "the check did not run" notice could honestly sit, and a speculative squiggle
43
+ * is worse than none — the build says so in prose instead, where a whole-project
44
+ * line has somewhere to go.
45
+ *
46
+ * The *whole* hole is one index, composed the way `sceneIndexForBuild` composes
47
+ * it: what the parse could not read plus what the walk could not reach. The
48
+ * completion path deliberately reads a partial universe — a suggestion claims
49
+ * nothing about what is absent — but a finding does, so a component declared
50
+ * only by an unresolved dependency suppresses the check rather than reporting
51
+ * the address as unreachable.
52
+ */
53
+ export function sceneReachabilityDiagnostics(input: {
54
+ ts: typeof import("typescript");
55
+ program: ts.Program;
56
+ table: UrlParameterTable;
57
+ cache: SceneIndexCache;
58
+ fileName: string;
59
+ }): ts.Diagnostic[] {
60
+ const { ts: typescript, program, table, cache, fileName } = input;
61
+ const sourceFile = program.getSourceFile(fileName);
62
+ if (!sourceFile) {
63
+ return [];
64
+ }
65
+ const index = sceneComponentIndexOf(cache);
66
+ const report = checkUrlFragmentReachability({
67
+ program,
68
+ table,
69
+ index: { ids: index.ids, incomplete: [...index.incomplete, ...cache.documents().unreadable] },
70
+ objects: projectObjectIndex(cache),
71
+ sourceFiles: [sourceFile],
72
+ });
73
+ const fragments =
74
+ report.kind === "suppressed"
75
+ ? []
76
+ : report.findings.map((finding) =>
77
+ suggestion(typescript, sourceFile, finding, UNREACHABLE_FRAGMENT_CODE),
78
+ );
79
+
80
+ // Outside the branch above, never inside it: the two honesty rules are
81
+ // independent — one suppresses index-wide on an incomplete component universe,
82
+ // the other withholds per file on an unresolvable world — which is the reason
83
+ // the transpiler kept them as two exports rather than one.
84
+ const crossWorld = checkCrossWorldAddresses({
85
+ program,
86
+ table,
87
+ worldsOf: (name) => worldsFor(cache, name),
88
+ sourceFiles: [sourceFile],
89
+ }).map((finding) => suggestion(typescript, sourceFile, finding, FOREIGN_SOCKET_CODE));
90
+
91
+ return [...fragments, ...crossWorld];
92
+ }
@@ -1,12 +1,20 @@
1
- import type * as ts from "typescript";
2
1
  import {
2
+ buildSceneCollectionRoles,
3
+ buildSceneComponentIndex,
4
+ COLLECTION_REFERENCE_EXTENSIONS,
3
5
  displayPathOf,
6
+ GAME_PROJECT_DOCUMENT,
4
7
  isExcludedProjectPath,
8
+ LIBRARY_DEPENDENCY_ROOT,
5
9
  listProjectResourcePaths,
10
+ PROJECT_EXTENSIONS,
6
11
  readSceneDocuments,
7
12
  SCENE_EXTENSIONS,
13
+ type SceneCollectionRoles,
14
+ type SceneComponentIndex,
8
15
  type SceneReadHost,
9
- } from "./scene-documents";
16
+ } from "@defold-typescript/transpiler";
17
+ import type * as ts from "typescript";
10
18
 
11
19
  // `SceneReadHost` plus the two watch facilities `ts.System` declares as
12
20
  // optional, typed off the editor's own callback types so the real
@@ -81,6 +89,19 @@ export function createSceneIndexCache(host: SceneWatchHost, projectRoot: string)
81
89
 
82
90
  function affectsCache(hostPath: string): boolean {
83
91
  const displayPath = displayPathOf(projectRoot, hostPath);
92
+ // `resolve` rewrites this surface wholesale, and `.defold-types/` is
93
+ // `.defignore`d — so the exclusion test below would throw the event away and
94
+ // leave the editor offering ids from a dependency the author just removed.
95
+ // The manifest counts too: it alone changes when a dependency is dropped.
96
+ // The root itself, not only its descendants: removing the last dependency
97
+ // deletes the whole directory and writes nothing inside it, so that event is
98
+ // the only one a watcher reports.
99
+ if (
100
+ displayPath === LIBRARY_DEPENDENCY_ROOT ||
101
+ displayPath.startsWith(`${LIBRARY_DEPENDENCY_ROOT}/`)
102
+ ) {
103
+ return true;
104
+ }
84
105
  if (isExcludedProjectPath(displayPath)) return false;
85
106
  for (const extension of servedExtensions) {
86
107
  if (displayPath.endsWith(extension)) return true;
@@ -166,3 +187,30 @@ export function createSceneIndexCache(host: SceneWatchHost, projectRoot: string)
166
187
  },
167
188
  };
168
189
  }
190
+
191
+ // The component-id universe, whole — ids *and* the walk's own record of what it
192
+ // could not prove — read through the cache so one walk serves both readers. The
193
+ // two disagree about what to do with the hole rather than about how to build it:
194
+ // a completion suggests from a partial universe, a finding must not, so the
195
+ // index is shared here and each caller applies its own rule.
196
+ export function sceneComponentIndexOf(cache: SceneIndexCache): SceneComponentIndex {
197
+ return cache.derived("component-index", () =>
198
+ buildSceneComponentIndex(cache.documents().documents),
199
+ );
200
+ }
201
+
202
+ // The world each collection is, read through the cache so the completion path
203
+ // and the provenance path share one classification — and so the walk over the
204
+ // proxy/factory documents and `game.project` is invalidated by the same
205
+ // watchers the scene walk is. Both call sites go through here rather than
206
+ // building roles of their own, which is what would let one of them drift back
207
+ // to offering every collection's paths bare.
208
+ export function sceneCollectionRolesOf(cache: SceneIndexCache): SceneCollectionRoles {
209
+ return cache.derived("collection-roles", () =>
210
+ buildSceneCollectionRoles({
211
+ documents: cache.documents().documents,
212
+ references: cache.documents(COLLECTION_REFERENCE_EXTENSIONS).documents,
213
+ gameProject: cache.documents(PROJECT_EXTENSIONS).documents.get(GAME_PROJECT_DOCUMENT),
214
+ }),
215
+ );
216
+ }