unity-open-mcp 0.4.1 → 0.6.1

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 (120) hide show
  1. package/README.md +20 -14
  2. package/dist/batch-spawn.d.ts +4 -0
  3. package/dist/batch-spawn.js +55 -11
  4. package/dist/batch-spawn.js.map +1 -1
  5. package/dist/capabilities/build-capabilities.js +6 -0
  6. package/dist/capabilities/build-capabilities.js.map +1 -1
  7. package/dist/capabilities/cost-hints.js +30 -0
  8. package/dist/capabilities/cost-hints.js.map +1 -1
  9. package/dist/capabilities/lifecycle.js +13 -0
  10. package/dist/capabilities/lifecycle.js.map +1 -1
  11. package/dist/capabilities/tool-groups.js +4 -0
  12. package/dist/capabilities/tool-groups.js.map +1 -1
  13. package/dist/cli/commands.js +9 -8
  14. package/dist/cli/commands.js.map +1 -1
  15. package/dist/cli/ping-poller.js +20 -0
  16. package/dist/cli/ping-poller.js.map +1 -1
  17. package/dist/constants.d.ts +89 -0
  18. package/dist/constants.js +109 -0
  19. package/dist/constants.js.map +1 -0
  20. package/dist/dialog-dismiss.d.ts +0 -7
  21. package/dist/dialog-dismiss.js +17 -19
  22. package/dist/dialog-dismiss.js.map +1 -1
  23. package/dist/hub-control.d.ts +6 -2
  24. package/dist/hub-control.js +8 -8
  25. package/dist/hub-control.js.map +1 -1
  26. package/dist/index.js +6 -5
  27. package/dist/index.js.map +1 -1
  28. package/dist/instance-discovery.js +2 -1
  29. package/dist/instance-discovery.js.map +1 -1
  30. package/dist/live-client.d.ts +72 -0
  31. package/dist/live-client.js +387 -255
  32. package/dist/live-client.js.map +1 -1
  33. package/dist/offline/api.d.ts +171 -0
  34. package/dist/offline/api.js +852 -0
  35. package/dist/offline/api.js.map +1 -0
  36. package/dist/offline/hierarchy.d.ts +24 -0
  37. package/dist/offline/hierarchy.js +242 -0
  38. package/dist/offline/hierarchy.js.map +1 -0
  39. package/dist/offline/index-builders.d.ts +11 -0
  40. package/dist/offline/index-builders.js +202 -0
  41. package/dist/offline/index-builders.js.map +1 -0
  42. package/dist/offline/model.d.ts +8 -0
  43. package/dist/offline/model.js +188 -0
  44. package/dist/offline/model.js.map +1 -0
  45. package/dist/offline/names.d.ts +7 -0
  46. package/dist/offline/names.js +20 -0
  47. package/dist/offline/names.js.map +1 -0
  48. package/dist/offline/overrides.d.ts +17 -0
  49. package/dist/offline/overrides.js +266 -0
  50. package/dist/offline/overrides.js.map +1 -0
  51. package/dist/offline/parse.d.ts +25 -0
  52. package/dist/offline/parse.js +236 -0
  53. package/dist/offline/parse.js.map +1 -0
  54. package/dist/offline/paths.d.ts +4 -0
  55. package/dist/offline/paths.js +23 -0
  56. package/dist/offline/paths.js.map +1 -0
  57. package/dist/offline/primitives.d.ts +17 -0
  58. package/dist/offline/primitives.js +195 -0
  59. package/dist/offline/primitives.js.map +1 -0
  60. package/dist/offline/references.d.ts +9 -0
  61. package/dist/offline/references.js +55 -0
  62. package/dist/offline/references.js.map +1 -0
  63. package/dist/offline/types.d.ts +65 -0
  64. package/dist/offline/types.js +154 -0
  65. package/dist/offline/types.js.map +1 -0
  66. package/dist/offline.d.ts +2 -180
  67. package/dist/offline.js +27 -2282
  68. package/dist/offline.js.map +1 -1
  69. package/dist/project-health.d.ts +1 -1
  70. package/dist/project-health.js +66 -3
  71. package/dist/project-health.js.map +1 -1
  72. package/dist/routers.js +6 -5
  73. package/dist/routers.js.map +1 -1
  74. package/dist/running-unity.d.ts +105 -0
  75. package/dist/running-unity.js +387 -0
  76. package/dist/running-unity.js.map +1 -0
  77. package/dist/skill/client-paths.d.ts +22 -0
  78. package/dist/skill/client-paths.js +66 -5
  79. package/dist/skill/client-paths.js.map +1 -1
  80. package/dist/skill/generate-skill.d.ts +43 -4
  81. package/dist/skill/generate-skill.js +132 -34
  82. package/dist/skill/generate-skill.js.map +1 -1
  83. package/dist/tool-router.d.ts +12 -0
  84. package/dist/tool-router.js +112 -100
  85. package/dist/tool-router.js.map +1 -1
  86. package/dist/tools/batch-execute.d.ts +2 -0
  87. package/dist/tools/batch-execute.js +83 -0
  88. package/dist/tools/batch-execute.js.map +1 -0
  89. package/dist/tools/bridge-status.js +8 -1
  90. package/dist/tools/bridge-status.js.map +1 -1
  91. package/dist/tools/component-get.js +44 -11
  92. package/dist/tools/component-get.js.map +1 -1
  93. package/dist/tools/editor-status.js +3 -2
  94. package/dist/tools/editor-status.js.map +1 -1
  95. package/dist/tools/execute-csharp.js +3 -2
  96. package/dist/tools/execute-csharp.js.map +1 -1
  97. package/dist/tools/generate-skill.js +8 -0
  98. package/dist/tools/generate-skill.js.map +1 -1
  99. package/dist/tools/index.d.ts +1 -0
  100. package/dist/tools/index.js +29 -17
  101. package/dist/tools/index.js.map +1 -1
  102. package/dist/tools/inputsystem-asset-create.js +4 -4
  103. package/dist/tools/inputsystem-asset-create.js.map +1 -1
  104. package/dist/tools/invoke-method.js +2 -1
  105. package/dist/tools/invoke-method.js.map +1 -1
  106. package/dist/tools/navigation-surface-add.js +6 -5
  107. package/dist/tools/navigation-surface-add.js.map +1 -1
  108. package/dist/tools/probuilder-create-shape.js +4 -4
  109. package/dist/tools/probuilder-create-shape.js.map +1 -1
  110. package/dist/tools/read-compile-errors.js +44 -31
  111. package/dist/tools/read-compile-errors.js.map +1 -1
  112. package/dist/tools/scene-get-data.js +10 -2
  113. package/dist/tools/scene-get-data.js.map +1 -1
  114. package/dist/tools/scene-save.js +16 -5
  115. package/dist/tools/scene-save.js.map +1 -1
  116. package/dist/tools/scene-set-active.js +22 -6
  117. package/dist/tools/scene-set-active.js.map +1 -1
  118. package/dist/tools/scene-unload.js +20 -6
  119. package/dist/tools/scene-unload.js.map +1 -1
  120. package/package.json +1 -1
package/dist/offline.js CHANGED
@@ -1,2288 +1,33 @@
1
1
  // Offline asset reader — parses text-serialized Unity assets (.prefab/.unity/
2
2
  // .asset) from disk without a running Editor.
3
3
  //
4
- // Single runtime module (so it loads cleanly under node --experimental-strip-
5
- // types in tests: no cross-file runtime imports). Type-only contract lives in
6
- // compression/asset-model.ts (stripped at test time).
4
+ // This file is a thin re-export barrel over the focused modules under
5
+ // `offline/` (M28-refactoring Plan 3, T3.1). The former 2708-LOC monolith was
6
+ // split along its natural seams:
7
+ // - offline/types.ts — shared internal types + lookup tables
8
+ // - offline/primitives.ts — low-level GUID/fileID/scalar extraction
9
+ // - offline/names.ts — field-name display helpers
10
+ // - offline/parse.ts — YAML document + JSON stream parsing
11
+ // - offline/hierarchy.ts — GameObject tree + component/field resolution
12
+ // - offline/overrides.ts — prefab-override parsing + label resolution
13
+ // - offline/references.ts — reference resolution (GUID/fileID → labels)
14
+ // - offline/index-builders.ts — GUID/script index + directory walkers
15
+ // - offline/model.ts — AssetModel builders + integrity checks
16
+ // - offline/api.ts — the public entry points (this barrel's exports)
7
17
  //
8
- // Exports three entry points used by the compressible-tool router:
9
- // - readAssetOffline → AssetModel (for read_asset)
10
- // - searchAssetsOffline → SearchModel (for search_assets)
11
- // - listAssetsOffline → listing result (for list_assets)
12
- import { readFile, readdir, stat } from "node:fs/promises";
13
- import { join, basename, extname, relative, sep } from "node:path";
14
- // ===========================================================================
15
- // Native Unity class IDs → names.
16
- // ===========================================================================
17
- const nativeClassNames = {
18
- 1: "GameObject",
19
- 4: "Transform",
20
- 20: "Camera",
21
- 23: "MeshRenderer",
22
- 33: "MeshFilter",
23
- 64: "MeshCollider",
24
- 65: "BoxCollider",
25
- 81: "AudioListener",
26
- 82: "AudioSource",
27
- 95: "Animator",
28
- 108: "Light",
29
- 114: "MonoBehaviour",
30
- 115: "MonoScript",
31
- 120: "LineRenderer",
32
- 137: "SkinnedMeshRenderer",
33
- 156: "Terrain",
34
- 198: "ParticleSystem",
35
- 212: "SpriteRenderer",
36
- 222: "CanvasRenderer",
37
- 223: "Canvas",
38
- 224: "RectTransform",
39
- 225: "CanvasGroup",
40
- 329: "VideoPlayer",
41
- 73398921: "VFXRenderer",
42
- };
43
- function nativeClassName(classID) {
44
- return nativeClassNames[classID] ?? "";
45
- }
46
- // ===========================================================================
47
- // Kind / extension utilities.
48
- // ===========================================================================
49
- const extToKind = {
50
- ".anim": "anim",
51
- ".asmdef": "asmdef",
52
- ".asset": "asset",
53
- ".controller": "controller",
54
- ".cs": "cs",
55
- ".mat": "mat",
56
- ".overrideController": "controller",
57
- ".physicsMaterial2D": "physics2d",
58
- ".physicMaterial": "physics",
59
- ".playable": "playable",
60
- ".prefab": "prefab",
61
- ".preset": "preset",
62
- ".shader": "shader",
63
- ".shadergraph": "shadergraph",
64
- ".shadersubgraph": "shadergraph",
65
- ".spriteatlas": "atlas",
66
- ".terrainlayer": "terrainlayer",
67
- ".unity": "scene",
68
- ".uss": "uss",
69
- ".uxml": "uxml",
70
- ".vfx": "vfx",
71
- ".meta": "meta",
72
- };
73
- // ===========================================================================
74
- // M24 — offline-parseable extension sets.
75
- // YAML_PARSEABLE covers text-serialized Unity YAML; JSON_PARSEABLE covers the
76
- // JSON asset kinds unity-scanner does not handle (our differentiator). Both are
77
- // merged into OFFLINE_PARSEABLE for the read_asset / search / find_references
78
- // gates. LISTABLE stays broader (it never parses, only enumerates files).
79
- // ===========================================================================
80
- const YAML_PARSEABLE_EXTENSIONS = new Set([
81
- ".prefab", ".unity", ".asset", ".mat", ".controller", ".anim", ".playable",
82
- ".preset", ".spriteatlas", ".terrainlayer", ".vfx",
83
- ]);
84
- const JSON_PARSEABLE_EXTENSIONS = new Set([
85
- ".asmdef", ".shadergraph", ".shadersubgraph",
86
- ]);
87
- const OFFLINE_PARSEABLE_EXTENSIONS = new Set([
88
- ...YAML_PARSEABLE_EXTENSIONS,
89
- ...JSON_PARSEABLE_EXTENSIONS,
90
- ]);
91
- function offlineParseKind(assetPath) {
92
- const dot = assetPath.lastIndexOf(".");
93
- if (dot < 0)
94
- return null;
95
- const ext = assetPath.slice(dot).toLowerCase();
96
- if (YAML_PARSEABLE_EXTENSIONS.has(ext))
97
- return "yaml";
98
- if (JSON_PARSEABLE_EXTENSIONS.has(ext))
99
- return "json";
100
- return null;
101
- }
102
- const kindAliases = {
103
- prefabs: "prefab",
104
- scenes: "scene",
105
- unity: "scene",
106
- scripts: "cs",
107
- script: "cs",
108
- material: "mat",
109
- materials: "mat",
110
- };
111
- function kindForPath(path) {
112
- const ext = extname(path);
113
- if (extToKind[ext])
114
- return extToKind[ext];
115
- if (ext === "")
116
- return "none";
117
- return ext.slice(1).toLowerCase();
118
- }
119
- function normalizeKind(kind) {
120
- const trimmed = kind.trim().toLowerCase();
121
- if (kindAliases[trimmed])
122
- return kindAliases[trimmed];
123
- return trimmed;
124
- }
125
- function parseKindSet(raw) {
126
- const out = new Set();
127
- for (const part of raw.split(",")) {
128
- const kind = normalizeKind(part);
129
- if (kind !== "")
130
- out.add(kind);
131
- }
132
- return out;
133
- }
134
- const skipDirs = new Set([
135
- ".git", ".vs", "Library", "Logs", "obj", "Obj",
136
- "Temp", "Build", "Builds", "UserSettings", "node_modules",
137
- ]);
138
- function shouldSkipDir(name) {
139
- return skipDirs.has(name);
140
- }
141
- // ===========================================================================
142
- // YAML parser — split YAML into documents, extract key fields per object.
143
- // ===========================================================================
144
- function parseAsset(data) {
145
- const objects = [];
146
- const byID = new Map();
147
- const lines = data.split("\n");
148
- let current = null;
149
- for (const line of lines) {
150
- const header = parseHeaderLine(line);
151
- if (header) {
152
- if (current)
153
- finishObject(current, objects, byID);
154
- current = {
155
- id: header.id,
156
- classID: header.classID,
157
- type: "",
158
- lines: [],
159
- order: objects.length,
160
- name: "",
161
- componentIDs: [],
162
- gameObjectID: "",
163
- fatherTransformID: "",
164
- sourceObjectID: "",
165
- sourceGUID: "",
166
- scriptGUID: "",
167
- };
168
- continue;
169
- }
170
- if (current === null)
171
- continue;
172
- current.lines.push(line);
173
- scanObjectTypeLine(current, line);
174
- }
175
- if (current)
176
- finishObject(current, objects, byID);
177
- if (objects.length === 0) {
178
- throw new Error("no Unity YAML objects found");
179
- }
180
- applyPrefabNameOverrides(objects);
181
- return { path: "", kind: "", guid: "", objects, byID };
182
- }
183
- function finishObject(o, objects, byID) {
184
- if (o.lines.length > 0) {
185
- o.name = readScalar(o.lines, "m_Name");
186
- switch (o.type) {
187
- case "GameObject":
188
- o.componentIDs = readComponentIDs(o.lines);
189
- break;
190
- case "Transform":
191
- case "RectTransform":
192
- o.gameObjectID = readFileIDField(o.lines, "m_GameObject");
193
- o.fatherTransformID = readFileIDField(o.lines, "m_Father");
194
- break;
195
- default:
196
- o.gameObjectID = readFileIDField(o.lines, "m_GameObject");
197
- break;
198
- }
199
- o.sourceObjectID = readFileIDField(o.lines, "m_CorrespondingSourceObject");
200
- o.sourceGUID = readGUIDField(o.lines, "m_CorrespondingSourceObject");
201
- if (o.type === "MonoBehaviour") {
202
- o.scriptGUID = readGUIDField(o.lines, "m_Script");
203
- }
204
- }
205
- objects.push(o);
206
- byID.set(o.id, o);
207
- }
208
- // ===========================================================================
209
- // Hierarchy — build GameObject tree from Transform parent links.
210
- // ===========================================================================
211
- function buildHierarchy(asset) {
212
- const transformToGO = new Map();
213
- const goToTransform = new Map();
214
- const parentTransform = new Map();
215
- for (const obj of asset.objects) {
216
- if (obj.type !== "Transform" && obj.type !== "RectTransform")
217
- continue;
218
- transformToGO.set(obj.id, obj.gameObjectID);
219
- goToTransform.set(obj.gameObjectID, obj.id);
220
- parentTransform.set(obj.id, obj.fatherTransformID);
221
- }
222
- const gameObjects = asset.objects.filter((o) => o.type === "GameObject");
223
- const nodes = new Map();
224
- for (const go of gameObjects) {
225
- nodes.set(go.id, { gameObject: go, children: [], path: "", depth: 0 });
226
- }
227
- const hasParent = new Set();
228
- for (const [goID, transformID] of goToTransform) {
229
- const node = nodes.get(goID);
230
- if (!node)
231
- continue;
232
- const parentGO = transformToGO.get(parentTransform.get(transformID) ?? "");
233
- const parent = parentGO ? nodes.get(parentGO) : undefined;
234
- if (!parent || parent === node)
235
- continue;
236
- parent.children.push(node);
237
- hasParent.add(goID);
238
- }
239
- const roots = [];
240
- for (const go of gameObjects) {
241
- const node = nodes.get(go.id);
242
- if (!node || hasParent.has(go.id))
243
- continue;
244
- roots.push(node);
245
- }
246
- sortNodes(roots);
247
- for (const root of roots)
248
- assignNodePath(root, "", 0);
249
- return roots;
250
- }
251
- function sortNodes(nodes) {
252
- nodes.sort((a, b) => a.gameObject.order - b.gameObject.order);
253
- for (const node of nodes)
254
- sortNodes(node.children);
255
- }
256
- function assignNodePath(node, parent, depth) {
257
- node.depth = depth;
258
- let name = node.gameObject.name;
259
- if (name === "")
260
- name = `<unnamed:${node.gameObject.id}>`;
261
- node.path = parent === "" ? name : `${parent}/${name}`;
262
- for (const child of node.children) {
263
- assignNodePath(child, node.path, depth + 1);
264
- }
265
- }
266
- // ===========================================================================
267
- // Components — resolve component objects for a GameObject.
268
- // ===========================================================================
269
- function componentsFor(asset, goID, scriptIndex) {
270
- const goObj = asset.byID.get(goID);
271
- if (!goObj)
272
- return [];
273
- const out = [];
274
- const seen = new Set();
275
- for (const compID of goObj.componentIDs) {
276
- const compObj = asset.byID.get(compID);
277
- if (!compObj)
278
- continue;
279
- seen.add(compObj.id);
280
- const { name, scriptPath } = componentName(compObj, scriptIndex);
281
- out.push({ object: compObj, name, scriptPath });
282
- }
283
- for (const compObj of asset.objects) {
284
- if (compObj.gameObjectID !== goID || seen.has(compObj.id) || compObj.type === "GameObject")
285
- continue;
286
- seen.add(compObj.id);
287
- const { name, scriptPath } = componentName(compObj, scriptIndex);
288
- out.push({ object: compObj, name, scriptPath });
289
- }
290
- return out;
291
- }
292
- function componentName(obj, scriptIndex) {
293
- if (obj.type === "MonoBehaviour") {
294
- const path = scriptIndex.get(obj.scriptGUID);
295
- if (path) {
296
- const base = basename(path);
297
- const ext = extname(path);
298
- const name = ext ? base.slice(0, -ext.length) : base;
299
- return { name, scriptPath: path };
300
- }
301
- if (obj.scriptGUID !== "") {
302
- return { name: `MonoBehaviour(${shortGUID(obj.scriptGUID)})`, scriptPath: "" };
303
- }
304
- }
305
- const native = nativeClassName(obj.classID);
306
- if (native)
307
- return { name: native, scriptPath: "" };
308
- if (obj.type !== "")
309
- return { name: obj.type, scriptPath: "" };
310
- return { name: `ClassID:${obj.classID}`, scriptPath: "" };
311
- }
312
- // ===========================================================================
313
- // Fields — extract top-level serialized fields.
314
- // ===========================================================================
315
- const skipFieldNames = new Set([
316
- "m_Name", "m_ObjectHideFlags", "m_CorrespondingSourceObject",
317
- "m_PrefabInstance", "m_PrefabAsset", "serializedVersion",
318
- "m_GameObject", "m_Script", "m_Enabled",
319
- "m_EditorHideFlags", "m_EditorClassIdentifier", "m_Modification",
320
- ]);
321
- function fieldsWithHidden(asset, obj, limit, guidIndex) {
322
- const fields = [];
323
- let hidden = 0;
324
- for (let i = 0; i < obj.lines.length; i++) {
325
- const line = obj.lines[i];
326
- if (!isTopLevelFieldLine(line))
327
- continue;
328
- const trim = line.trim();
329
- const colonIdx = trim.indexOf(":");
330
- if (colonIdx < 0)
331
- continue;
332
- const key = trim.slice(0, colonIdx).trim();
333
- if (skipFieldNames.has(key))
334
- continue;
335
- if (limit > 0 && fields.length >= limit) {
336
- hidden++;
337
- continue;
338
- }
339
- let value = trim.slice(colonIdx + 1).trim();
340
- if (value === "") {
341
- value = summarizeNested(obj.lines, i + 1);
342
- }
343
- value = resolveReferences(value, guidIndex, asset);
344
- fields.push({ name: displayFieldName(key), value });
345
- }
346
- return { fields, hidden };
347
- }
348
- function displayFieldName(name) {
349
- if (name.startsWith("<") && name.endsWith(">k__BackingField")) {
350
- return name.slice(1, -">k__BackingField".length);
351
- }
352
- return name;
353
- }
354
- function isTopLevelFieldLine(line) {
355
- return (line.startsWith(" ") &&
356
- !line.startsWith(" ") &&
357
- !line.startsWith(" -"));
358
- }
359
- function summarizeNested(lines, start) {
360
- const maxParts = 4;
361
- const parts = [];
362
- let parent = "";
363
- let sequenceIndex = 0;
364
- for (let i = start; i < lines.length; i++) {
365
- const line = lines[i];
366
- if (line.startsWith(" ") && !line.startsWith(" ") && !line.startsWith(" -"))
367
- break;
368
- const trim = line.trim();
369
- if (trim === "")
370
- continue;
371
- if (trim.startsWith("- ")) {
372
- let v = trim.slice(2);
373
- if (parent !== "") {
374
- v = `${parent}[${sequenceIndex}]: ${v}`;
375
- sequenceIndex++;
376
- }
377
- parts.push(v);
378
- }
379
- else if (trim.endsWith(":")) {
380
- parent = trim.slice(0, -1);
381
- sequenceIndex = 0;
382
- continue;
383
- }
384
- else {
385
- parts.push(trim.split(/\s+/).join(" "));
386
- }
387
- if (parts.length >= maxParts)
388
- break;
389
- }
390
- if (parts.length === 0)
391
- return "<object>";
392
- return parts.join(" | ");
393
- }
394
- // ===========================================================================
395
- // Reference resolution.
396
- // ===========================================================================
397
- function resolveReferences(value, guidIndex, asset) {
398
- const guids = findGUIDs(value);
399
- if (guids.length > 0) {
400
- const refs = [];
401
- const seen = new Set();
402
- for (const guid of guids) {
403
- const path = guidIndex.get(guid);
404
- if (!path || seen.has(path))
405
- continue;
406
- seen.add(path);
407
- const name = basename(path);
408
- const ext = extname(name);
409
- refs.push(ext ? name.slice(0, -ext.length) : name);
410
- }
411
- if (refs.length > 0)
412
- return `${value} -> ${refs.join(", ")}`;
413
- }
414
- if (guids.length === 0) {
415
- const refs = [];
416
- const seen = new Set();
417
- for (const fileID of findFileIDs(value)) {
418
- const label = localReferenceLabel(asset, fileID);
419
- if (label === "" || seen.has(label))
420
- continue;
421
- seen.add(label);
422
- refs.push(label);
423
- }
424
- if (refs.length > 0)
425
- return `${value} -> ${refs.join(", ")}`;
426
- }
427
- return value;
428
- }
429
- function localReferenceLabel(asset, fileID, scriptIndex = new Map()) {
430
- if (fileID === "" || fileID === "0")
431
- return "";
432
- const obj = asset.byID.get(fileID);
433
- if (!obj)
434
- return "";
435
- if (obj.type === "GameObject")
436
- return objectPath(asset, obj.id);
437
- const { name } = componentName(obj, scriptIndex);
438
- if (obj.gameObjectID !== "") {
439
- const path = objectPath(asset, obj.gameObjectID);
440
- if (path)
441
- return `${name} on ${path}`;
442
- }
443
- if (obj.name !== "")
444
- return `${name} ${obj.name}`;
445
- return `${name} ${obj.id}`;
446
- }
447
- function objectPath(asset, goID) {
448
- for (const node of flattenNodes(asset)) {
449
- if (node.gameObject.id === goID)
450
- return node.path;
451
- }
452
- const obj = asset.byID.get(goID);
453
- return obj ? obj.name : "";
454
- }
455
- function flattenNodes(asset) {
456
- const out = [];
457
- const walk = (nodes) => {
458
- for (const node of nodes) {
459
- out.push(node);
460
- walk(node.children);
461
- }
462
- };
463
- walk(buildHierarchy(asset));
464
- return out;
465
- }
466
- // ===========================================================================
467
- // Prefab override name resolution + override parsing.
468
- // ===========================================================================
469
- function applyPrefabNameOverrides(objects) {
470
- const names = new Map();
471
- for (const obj of objects) {
472
- if (obj.type !== "PrefabInstance")
473
- continue;
474
- for (const override of parsePrefabOverrides(obj.lines)) {
475
- if (override.kind !== "property" || override.propertyPath !== "m_Name" || override.value === "")
476
- continue;
477
- const id = extractFileID(override.target);
478
- const guid = extractGUID(override.target);
479
- if (id === "" || guid === "")
480
- continue;
481
- names.set(`${guid.toLowerCase()}\0${id}`, override.value);
482
- }
483
- }
484
- if (names.size === 0)
485
- return;
486
- for (const obj of objects) {
487
- if (obj.name !== "" || obj.sourceObjectID === "" || obj.sourceGUID === "")
488
- continue;
489
- const name = names.get(`${obj.sourceGUID.toLowerCase()}\0${obj.sourceObjectID}`);
490
- if (name)
491
- obj.name = name;
492
- }
493
- }
494
- function parsePrefabOverrides(lines) {
495
- const out = [];
496
- let section = "";
497
- for (let i = 0; i < lines.length; i++) {
498
- const trim = lines[i].trim();
499
- switch (trim) {
500
- case "m_Modifications:":
501
- section = "modifications";
502
- continue;
503
- case "m_RemovedComponents:":
504
- section = "removed-components";
505
- continue;
506
- case "m_RemovedGameObjects:":
507
- section = "removed-gameobjects";
508
- continue;
509
- case "m_AddedComponents:":
510
- section = "added-components";
511
- continue;
512
- case "m_AddedGameObjects:":
513
- section = "added-gameobjects";
514
- continue;
515
- }
516
- if (!trim.startsWith("- "))
517
- continue;
518
- switch (section) {
519
- case "modifications": {
520
- const { override, next } = parsePrefabPropertyOverride(lines, i);
521
- if (override.target !== "")
522
- out.push(override);
523
- i = next;
524
- break;
525
- }
526
- case "removed-components":
527
- case "removed-gameobjects": {
528
- const target = trim.slice(2).trim();
529
- if (target !== "" && target !== "[]") {
530
- out.push({ kind: section, target, propertyPath: "", value: "", addedObject: "" });
531
- }
532
- break;
533
- }
534
- case "added-components": {
535
- const { override, next } = parsePrefabAddedComponentOverride(lines, i, "added-component");
536
- if (override.target !== "" || override.addedObject !== "")
537
- out.push(override);
538
- i = next;
539
- break;
540
- }
541
- case "added-gameobjects": {
542
- const { override, next } = parsePrefabAddedComponentOverride(lines, i, "added-gameobjects");
543
- if (override.target !== "" || override.addedObject !== "")
544
- out.push(override);
545
- i = next;
546
- break;
547
- }
548
- }
549
- }
550
- return out;
551
- }
552
- function parsePrefabPropertyOverride(lines, start) {
553
- const override = { kind: "property", target: "", propertyPath: "", value: "", addedObject: "" };
554
- for (let i = start; i < lines.length; i++) {
555
- const trim = lines[i].trim();
556
- if (i > start && isPrefabOverrideSection(trim))
557
- return { override, next: i - 1 };
558
- if (i > start && trim.startsWith("- "))
559
- return { override, next: i - 1 };
560
- if (trim.startsWith("- target:")) {
561
- override.target = trim.slice("- target:".length).trim();
562
- }
563
- else if (trim.startsWith("propertyPath:")) {
564
- override.propertyPath = displayFieldName(cleanScalar(trim.slice("propertyPath:".length).trim()));
565
- }
566
- else if (trim.startsWith("value:")) {
567
- override.value = cleanScalar(trim.slice("value:".length).trim());
568
- }
569
- else if (trim.startsWith("objectReference:") && override.value === "") {
570
- override.value = trim.slice("objectReference:".length).trim();
571
- }
572
- }
573
- return { override, next: lines.length - 1 };
574
- }
575
- function parsePrefabAddedComponentOverride(lines, start, kind) {
576
- const override = { kind, target: "", propertyPath: "", value: "", addedObject: "" };
577
- for (let i = start; i < lines.length; i++) {
578
- const trim = lines[i].trim();
579
- if (i > start && isPrefabOverrideSection(trim))
580
- return { override, next: i - 1 };
581
- if (i > start && trim.startsWith("- "))
582
- return { override, next: i - 1 };
583
- if (trim.startsWith("- targetCorrespondingSourceObject:")) {
584
- override.target = trim.slice("- targetCorrespondingSourceObject:".length).trim();
585
- }
586
- else if (trim.startsWith("addedObject:")) {
587
- override.addedObject = trim.slice("addedObject:".length).trim();
588
- }
589
- }
590
- return { override, next: lines.length - 1 };
591
- }
592
- function isPrefabOverrideSection(trim) {
593
- switch (trim) {
594
- case "m_Modifications:":
595
- case "m_RemovedComponents:":
596
- case "m_RemovedGameObjects:":
597
- case "m_AddedComponents:":
598
- case "m_AddedGameObjects:":
599
- return true;
600
- default:
601
- return false;
602
- }
603
- }
604
- // ===========================================================================
605
- // Prefab override collection — resolve raw overrides to readable labels.
606
- // ===========================================================================
607
- function buildSourceMap(parsed) {
608
- const map = new Map();
609
- for (const obj of parsed.objects) {
610
- if (obj.sourceObjectID === "" || obj.sourceGUID === "")
611
- continue;
612
- map.set(`${obj.sourceGUID.toLowerCase()}\0${obj.sourceObjectID}`, obj);
613
- }
614
- return map;
615
- }
616
- function collectOverrides(parsed, scriptIndex, guidIndex) {
617
- const sourceMap = buildSourceMap(parsed);
618
- const entries = [];
619
- for (const obj of parsed.objects) {
620
- if (obj.type !== "PrefabInstance")
621
- continue;
622
- for (const ov of parsePrefabOverrides(obj.lines)) {
623
- entries.push(resolveOverrideEntry(ov, parsed, sourceMap, scriptIndex, guidIndex));
624
- }
625
- }
626
- return entries;
627
- }
628
- function resolveOverrideEntry(ov, parsed, sourceMap, scriptIndex, guidIndex) {
629
- const entry = { kind: ov.kind };
630
- switch (ov.kind) {
631
- case "property":
632
- entry.propertyPath = ov.propertyPath;
633
- entry.value = normalizeOverrideValue(ov.value, guidIndex, parsed);
634
- entry.target = resolveOverrideTarget(ov.target, sourceMap, parsed, scriptIndex);
635
- break;
636
- case "added-component":
637
- case "added-gameobjects":
638
- entry.target = resolveOverrideTarget(ov.target, sourceMap, parsed, scriptIndex);
639
- entry.addedObject = resolveAddedObject(ov.addedObject, parsed, scriptIndex);
640
- break;
641
- default:
642
- entry.target = resolveOverrideTarget(ov.target, sourceMap, parsed, scriptIndex);
643
- break;
644
- }
645
- return entry;
646
- }
647
- function normalizeOverrideValue(value, guidIndex, parsed) {
648
- const trimmed = value.trim();
649
- if (trimmed === "{fileID: 0}" || trimmed === "{}")
650
- return "null";
651
- return resolveReferences(value, guidIndex, parsed);
652
- }
653
- function resolveOverrideTarget(target, sourceMap, parsed, scriptIndex) {
654
- if (target === "")
655
- return undefined;
656
- const fileID = extractFileID(target);
657
- const guid = extractGUID(target);
658
- if (fileID === "" && guid === "")
659
- return undefined;
660
- if (guid !== "" && fileID !== "") {
661
- const localObj = sourceMap.get(`${guid.toLowerCase()}\0${fileID}`);
662
- if (localObj) {
663
- return labelForParsedObject(localObj, parsed, scriptIndex);
664
- }
665
- }
666
- if (fileID !== "") {
667
- const label = localReferenceLabel(parsed, fileID, scriptIndex);
668
- if (label)
669
- return label;
670
- }
671
- return undefined;
672
- }
673
- function resolveAddedObject(addedObject, parsed, scriptIndex) {
674
- if (addedObject === "")
675
- return undefined;
676
- const fileID = extractFileID(addedObject);
677
- if (fileID === "")
678
- return undefined;
679
- const label = localReferenceLabel(parsed, fileID, scriptIndex);
680
- return label || undefined;
681
- }
682
- function labelForParsedObject(obj, parsed, scriptIndex) {
683
- if (obj.type === "GameObject")
684
- return objectPath(parsed, obj.id);
685
- const { name } = componentName(obj, scriptIndex);
686
- if (obj.gameObjectID !== "") {
687
- const path = objectPath(parsed, obj.gameObjectID);
688
- if (path)
689
- return `${name} on ${path}`;
690
- }
691
- if (obj.name !== "")
692
- return `${name} ${obj.name}`;
693
- return `${name} ${obj.id}`;
694
- }
695
- // ===========================================================================
696
- // Field-level GUID scanning.
697
- // ===========================================================================
698
- function fieldGUIDs(obj) {
699
- const guids = new Set();
700
- for (const line of obj.lines) {
701
- if (!line.includes("guid:") || line.includes("m_Script"))
702
- continue;
703
- for (const guid of findGUIDs(line))
704
- guids.add(guid);
705
- }
706
- return guids;
707
- }
708
- function scriptGUIDs(asset) {
709
- const guids = new Set();
710
- for (const obj of asset.objects) {
711
- if (obj.scriptGUID !== "")
712
- guids.add(obj.scriptGUID);
713
- }
714
- return guids;
715
- }
716
- // ===========================================================================
717
- // Line-scanning helpers.
718
- // ===========================================================================
719
- function scanObjectTypeLine(obj, line) {
720
- if (obj.type !== "" || line.length <= 1 || line[0] === " " || !line.endsWith(":")) {
721
- return false;
722
- }
723
- obj.type = line.slice(0, -1).trim();
724
- return true;
725
- }
726
- function parseHeaderLine(line) {
727
- const prefix = "--- !u!";
728
- if (!line.startsWith(prefix))
729
- return null;
730
- const rest = line.slice(prefix.length);
731
- const space = rest.indexOf(" ");
732
- if (space <= 0 || space + 2 > rest.length || rest[space + 1] !== "&")
733
- return null;
734
- const classID = parsePositiveInt(rest.slice(0, space));
735
- if (classID === null)
736
- return null;
737
- let id = rest.slice(space + 2);
738
- const fields = id.split(/\s+/);
739
- if (fields.length > 0)
740
- id = fields[0];
741
- if (id === "")
742
- return null;
743
- return { classID, id };
744
- }
745
- function parsePositiveInt(value) {
746
- if (value.length === 0)
747
- return null;
748
- let out = 0;
749
- for (const ch of value) {
750
- if (ch < "0" || ch > "9")
751
- return null;
752
- out = out * 10 + (ch.charCodeAt(0) - "0".charCodeAt(0));
753
- }
754
- return out;
755
- }
756
- function readScalar(lines, key) {
757
- const prefix = ` ${key}:`;
758
- for (const line of lines) {
759
- if (line.startsWith(prefix))
760
- return cleanScalar(line.slice(prefix.length).trim());
761
- }
762
- return "";
763
- }
764
- function readFileIDField(lines, key) {
765
- const prefix = ` ${key}:`;
766
- for (const line of lines) {
767
- if (line.startsWith(prefix))
768
- return extractFileID(line);
769
- }
770
- return "";
771
- }
772
- function readGUIDField(lines, key) {
773
- const prefix = ` ${key}:`;
774
- for (const line of lines) {
775
- if (line.startsWith(prefix))
776
- return extractGUID(line);
777
- }
778
- return "";
779
- }
780
- function readComponentIDs(lines) {
781
- const ids = [];
782
- for (const line of lines) {
783
- if (line.includes("- component:")) {
784
- const id = extractFileID(line);
785
- if (id !== "" && id !== "0")
786
- ids.push(id);
787
- }
788
- }
789
- return ids;
790
- }
791
- function extractFileID(line) {
792
- const start = line.indexOf("fileID:");
793
- if (start < 0)
794
- return "";
795
- let i = start + "fileID:".length;
796
- while (i < line.length && line[i] === " ")
797
- i++;
798
- const begin = i;
799
- if (i < line.length && line[i] === "-")
800
- i++;
801
- while (i < line.length && line[i] >= "0" && line[i] <= "9")
802
- i++;
803
- if (i === begin || (line[begin] === "-" && i === begin + 1))
804
- return "";
805
- return line.slice(begin, i);
806
- }
807
- function extractGUID(line) {
808
- const start = line.indexOf("guid:");
809
- if (start < 0)
810
- return "";
811
- return scanGUID(line.slice(start + "guid:".length));
812
- }
813
- function findGUIDs(value) {
814
- const out = [];
815
- let offset = 0;
816
- while (offset < value.length) {
817
- const start = value.indexOf("guid:", offset);
818
- if (start < 0)
819
- break;
820
- offset = start + "guid:".length;
821
- const guid = scanGUID(value.slice(offset));
822
- if (guid !== "") {
823
- out.push(guid);
824
- offset += guid.length;
825
- }
826
- }
827
- return out;
828
- }
829
- function findFileIDs(value) {
830
- const out = [];
831
- let offset = 0;
832
- while (offset < value.length) {
833
- const start = value.indexOf("fileID:", offset);
834
- if (start < 0)
835
- break;
836
- offset = start + "fileID:".length;
837
- while (offset < value.length && value[offset] === " ")
838
- offset++;
839
- const begin = offset;
840
- if (offset < value.length && value[offset] === "-")
841
- offset++;
842
- while (offset < value.length && value[offset] >= "0" && value[offset] <= "9")
843
- offset++;
844
- if (offset === begin || (value[begin] === "-" && offset === begin + 1))
845
- continue;
846
- out.push(value.slice(begin, offset));
847
- }
848
- return out;
849
- }
850
- function scanGUID(value) {
851
- let i = 0;
852
- while (i < value.length && value[i] === " ")
853
- i++;
854
- const start = i;
855
- while (i < value.length && isHex(value.charCodeAt(i)))
856
- i++;
857
- if (i === start)
858
- return "";
859
- return value.slice(start, i).toLowerCase();
860
- }
861
- function isHex(code) {
862
- return ((code >= 48 && code <= 57) ||
863
- (code >= 97 && code <= 102) ||
864
- (code >= 65 && code <= 70));
865
- }
866
- function cleanScalar(value) {
867
- value = value.trim();
868
- if (value.length >= 2 && value[0] === '"' && value[value.length - 1] === '"') {
869
- try {
870
- return JSON.parse(value);
871
- }
872
- catch {
873
- return value.slice(1, -1);
874
- }
875
- }
876
- return value;
877
- }
878
- function shortGUID(guid) {
879
- if (guid.length <= 8)
880
- return guid;
881
- return guid.slice(0, 8);
882
- }
883
- // ===========================================================================
884
- // GUID index builder — reads .meta files.
885
- // ===========================================================================
886
- function readMetaGUID(data) {
887
- let start;
888
- if (data.startsWith("guid:")) {
889
- start = "guid:".length;
890
- }
891
- else {
892
- const idx = data.indexOf("\nguid:");
893
- if (idx < 0)
894
- return "";
895
- start = idx + 1 + "guid:".length;
896
- }
897
- const end = data.indexOf("\n", start);
898
- const slice = end < 0 ? data.slice(start) : data.slice(start, end);
899
- return slice.trim();
900
- }
901
- function toAssetPath(projectRoot, absPath) {
902
- let rel = relative(projectRoot, absPath);
903
- if (sep === "\\")
904
- rel = rel.replace(/\\/g, "/");
905
- return rel;
906
- }
907
- async function buildGUIDIndex(projectRoot, wanted) {
908
- const index = new Map();
909
- if (wanted && wanted.size === 0)
910
- return index;
911
- const assetsDir = join(projectRoot, "Assets");
912
- await walkMeta(assetsDir, async (metaPath) => {
913
- try {
914
- const data = await readFile(metaPath, "utf-8");
915
- let guid = readMetaGUID(data);
916
- if (guid === "")
917
- return;
918
- guid = guid.toLowerCase();
919
- if (wanted && !wanted.has(guid))
920
- return;
921
- index.set(guid, toAssetPath(projectRoot, metaPath.slice(0, -5)));
922
- }
923
- catch { /* skip */ }
924
- });
925
- return index;
926
- }
927
- async function buildScriptIndex(projectRoot, wanted) {
928
- const index = new Map();
929
- if (wanted && wanted.size === 0)
930
- return index;
931
- const guidIndex = await buildGUIDIndex(projectRoot, wanted);
932
- for (const [guid, path] of guidIndex) {
933
- if (path.endsWith(".cs"))
934
- index.set(guid, path);
935
- }
936
- return index;
937
- }
938
- async function buildScriptIndexForQuery(projectRoot, query) {
939
- const index = new Map();
940
- const q = query.trim().toLowerCase();
941
- if (q === "")
942
- return index;
943
- const assetsDir = join(projectRoot, "Assets");
944
- await walkMeta(assetsDir, async (metaPath) => {
945
- if (!metaPath.endsWith(".cs.meta"))
946
- return;
947
- const scriptPath = metaPath.slice(0, -5);
948
- const base = basename(scriptPath);
949
- const ext = extname(base);
950
- const name = ext ? base.slice(0, -ext.length) : base;
951
- if (!name.toLowerCase().includes(q))
952
- return;
953
- try {
954
- const data = await readFile(metaPath, "utf-8");
955
- const guid = readMetaGUID(data);
956
- if (guid === "")
957
- return;
958
- index.set(guid.toLowerCase(), toAssetPath(projectRoot, scriptPath));
959
- }
960
- catch { /* skip */ }
961
- });
962
- return index;
963
- }
964
- async function walkMeta(dir, fn) {
965
- let entries;
966
- try {
967
- entries = await readdir(dir);
968
- }
969
- catch {
970
- return;
971
- }
972
- await Promise.all(entries.map(async (name) => {
973
- if (shouldSkipDir(name))
974
- return;
975
- const fullPath = join(dir, name);
976
- try {
977
- const s = await stat(fullPath);
978
- if (s.isDirectory())
979
- await walkMeta(fullPath, fn);
980
- else if (name.endsWith(".meta"))
981
- await fn(fullPath);
982
- }
983
- catch { /* skip */ }
984
- }));
985
- }
986
- // ===========================================================================
987
- // readAssetOffline — parse a text-serialized asset → AssetModel.
988
- // ===========================================================================
989
- export function isOfflineAsset(assetPath) {
990
- return offlineParseKind(assetPath) !== null;
991
- }
992
- /**
993
- * M24 — full-hierarchy parity helper. The offline `objectCount` counts every
994
- * YAML object in the file; the live `read_asset` bridge counts only GameObjects
995
- * + the components attached to them (it walks the loaded Transform tree, not
996
- * the raw YAML). This helper returns the live-comparable counts so an
997
- * acceptance test can verify the offline-reconstructed tree matches a live read
998
- * node-for-node and component-for-component, without depending on the bridge.
999
- *
1000
- * Returns `{ nodes, components }` where `nodes` is the GameObject count and
1001
- * `components` is the sum of components across the tree — the two numbers the
1002
- * live `ReadAssetTool` emits as `objectCount` (nodes+components) and
1003
- * `componentCount`.
1004
- */
1005
- export function countHierarchy(model) {
1006
- let nodes = 0;
1007
- let components = 0;
1008
- const walk = (list) => {
1009
- for (const node of list) {
1010
- nodes++;
1011
- components += node.components.length;
1012
- walk(node.children);
1013
- }
1014
- };
1015
- walk(model.roots);
1016
- return { nodes, components };
1017
- }
1018
- export async function readAssetOffline(assetPath, opts) {
1019
- const absPath = join(opts.projectRoot, assetPath);
1020
- const data = await readFile(absPath, "utf-8");
1021
- const kind = kindForPath(assetPath);
1022
- const guid = await safeReadMetaGUID(absPath + ".meta");
1023
- const parseKind = offlineParseKind(assetPath);
1024
- // M24 — JSON asset kinds (.asmdef/.shadergraph/.shadersubgraph/.vfx) parse
1025
- // through a separate path; unity-scanner handles YAML only, so this is the
1026
- // coverage differentiator. vfx is YAML+binary, but its object headers + GUID
1027
- // refs still parse via the YAML path (binary blobs are skipped).
1028
- if (parseKind === "json") {
1029
- const guidIndex = await buildGUIDIndex(opts.projectRoot, new Set(findGUIDs(data)));
1030
- const model = buildJsonAssetModel(data, assetPath, kind, guid, guidIndex, opts.fieldLimit);
1031
- return { model, source: "offline" };
1032
- }
1033
- const parsed = parseAsset(data);
1034
- parsed.path = assetPath;
1035
- parsed.kind = kind;
1036
- parsed.guid = guid;
1037
- const wantedScripts = scriptGUIDs(parsed);
1038
- const scriptIndex = await buildScriptIndex(opts.projectRoot, wantedScripts);
1039
- const wantedGUIDs = collectFieldGUIDs(parsed);
1040
- const guidIndex = await buildGUIDIndex(opts.projectRoot, wantedGUIDs);
1041
- for (const [g, p] of scriptIndex)
1042
- guidIndex.set(g, p);
1043
- const integrity = runYamlIntegrityChecks(parsed, guidIndex);
1044
- return {
1045
- model: { ...buildAssetModel(parsed, scriptIndex, guidIndex, opts.fieldLimit), ...(integrity ? { integrity } : {}) },
1046
- source: "offline",
1047
- };
1048
- }
1049
- async function safeReadMetaGUID(metaPath) {
1050
- try {
1051
- return readMetaGUID(await readFile(metaPath, "utf-8"));
1052
- }
1053
- catch {
1054
- return "";
1055
- }
1056
- }
1057
- function collectFieldGUIDs(asset) {
1058
- const guids = new Set();
1059
- for (const obj of asset.objects) {
1060
- for (const line of obj.lines) {
1061
- if (!line.includes("guid:") || line.includes("m_Script"))
1062
- continue;
1063
- for (const g of findGUIDs(line))
1064
- guids.add(g);
1065
- }
1066
- }
1067
- return guids;
1068
- }
1069
- function buildAssetModel(parsed, scriptIndex, guidIndex, fieldLimit) {
1070
- const hierarchy = buildHierarchy(parsed);
1071
- const overrides = collectOverrides(parsed, scriptIndex, guidIndex);
1072
- if (hierarchy.length > 0) {
1073
- const roots = hierarchy.map((node) => convertNode(node, parsed, scriptIndex, guidIndex, fieldLimit));
1074
- let componentCount = 0;
1075
- const countComps = (n) => {
1076
- componentCount += n.components.length;
1077
- n.children.forEach(countComps);
1078
- };
1079
- roots.forEach(countComps);
1080
- return {
1081
- kind: parsed.kind,
1082
- path: parsed.path,
1083
- guid: parsed.guid || undefined,
1084
- objectCount: parsed.objects.length,
1085
- componentCount,
1086
- roots,
1087
- ...(overrides.length > 0 ? { overrides } : {}),
1088
- };
1089
- }
1090
- const flatObjects = [];
1091
- for (const obj of parsed.objects) {
1092
- if (obj.type === "PrefabInstance")
1093
- continue;
1094
- const { name } = componentName(obj, scriptIndex);
1095
- const { fields } = fieldsWithHidden(parsed, obj, fieldLimit, guidIndex);
1096
- flatObjects.push({
1097
- name: obj.name || name,
1098
- type: name,
1099
- fields: fields.length > 0 ? fields.map(toComponentField) : undefined,
1100
- });
1101
- }
1102
- return {
1103
- kind: parsed.kind,
1104
- path: parsed.path,
1105
- guid: parsed.guid || undefined,
1106
- objectCount: parsed.objects.length,
1107
- componentCount: 0,
1108
- roots: [],
1109
- flatObjects,
1110
- ...(overrides.length > 0 ? { overrides } : {}),
1111
- };
1112
- }
1113
- // ===========================================================================
1114
- // M24 — JSON asset parser (.asmdef / .shadergraph / .shadersubgraph).
18
+ // Public API surface (unchanged): three entry points the compressible-tool
19
+ // router and tool-router call — readAssetOffline, searchAssetsOffline,
20
+ // listAssetsOffline — plus findReferencesOffline, dependenciesOffline,
21
+ // scanIntegrityOffline, isOfflineAsset, countHierarchy, and their result
22
+ // interfaces.
1115
23
  //
1116
- // unity-scanner handles YAML only; this path is the coverage differentiator.
1117
- // Each kind has a distinct on-disk shape:
1118
- // - .asmdef → a single JSON object (assembly-definition manifest).
1119
- // - .shadergraph / .shadersubgraph → a JSON-document stream: multiple pretty-
1120
- // printed JSON objects concatenated on blank-line boundaries, each carrying
1121
- // an "m_Type" discriminator. The first is always the graph root.
1122
- // GUID references inside the JSON are resolved against the meta index the same
1123
- // way YAML field refs are.
1124
- // ===========================================================================
1125
- function buildJsonAssetModel(data, assetPath, kind, guid, guidIndex, fieldLimit) {
1126
- const issues = [];
1127
- const objects = parseJsonAsset(data, issues);
1128
- const flatObjects = [];
1129
- for (const obj of objects) {
1130
- const fields = jsonFields(obj.value, fieldLimit, guidIndex);
1131
- flatObjects.push({
1132
- name: obj.name,
1133
- type: obj.type,
1134
- fields: fields.length > 0 ? fields : undefined,
1135
- });
1136
- }
1137
- const integrity = runJsonIntegrityChecks(kind, objects, issues, assetPath);
1138
- return {
1139
- kind,
1140
- path: assetPath,
1141
- guid: guid || undefined,
1142
- objectCount: objects.length,
1143
- componentCount: 0,
1144
- roots: [],
1145
- flatObjects,
1146
- ...(integrity.length > 0 ? { integrity } : {}),
1147
- };
1148
- }
1149
- /**
1150
- * Parse a JSON asset into one or more JsonParsedObjects. `.asmdef` is a single
1151
- * object; `.shadergraph` is a stream of objects. Malformed JSON records an
1152
- * integrity issue instead of throwing — a partial read is more useful to an
1153
- * agent than a hard failure when the bridge is down.
1154
- */
1155
- function parseJsonAsset(data, issues) {
1156
- const ext = "json";
1157
- const chunks = splitJsonStream(data);
1158
- if (chunks.length === 0) {
1159
- issues.push({ code: "malformed_json", detail: "no JSON objects found", severity: "error" });
1160
- return [];
1161
- }
1162
- const out = [];
1163
- for (let i = 0; i < chunks.length; i++) {
1164
- let value;
1165
- try {
1166
- value = JSON.parse(chunks[i]);
1167
- }
1168
- catch (e) {
1169
- issues.push({
1170
- code: "malformed_json",
1171
- detail: `object ${i + 1}: ${e.message}`,
1172
- severity: "error",
1173
- });
1174
- continue;
1175
- }
1176
- out.push(jsonParsedObject(value, i));
1177
- }
1178
- return out;
1179
- function jsonParsedObject(value, index) {
1180
- if (value !== null && typeof value === "object" && !Array.isArray(value)) {
1181
- const obj = value;
1182
- const type = typeof obj.m_Type === "string" && obj.m_Type !== ""
1183
- ? jsonShortType(obj.m_Type)
1184
- : "json";
1185
- const name = pickString(obj.m_Name) ??
1186
- pickString(obj.m_ObjectId) ??
1187
- pickString(obj.name) ??
1188
- (type !== "json" ? `${type}#${index}` : `object#${index}`);
1189
- return { name, type, value };
1190
- }
1191
- return { name: `value#${index}`, type: ext, value };
1192
- }
1193
- }
1194
- function pickString(v) {
1195
- return typeof v === "string" && v !== "" ? v : undefined;
1196
- }
1197
- /** Shorten a ShaderGraph m_Type like "UnityEditor.ShaderGraph.MultiplyNode" → "MultiplyNode". */
1198
- function jsonShortType(full) {
1199
- const dot = full.lastIndexOf(".");
1200
- return dot >= 0 ? full.slice(dot + 1) : full;
1201
- }
1202
- /**
1203
- * A `.shadergraph` is a stream of pretty-printed JSON objects. They are
1204
- * separated by blank lines, but a single object's pretty-printed body also
1205
- * contains newlines — so the split must track brace depth, not line gaps.
1206
- * `.asmdef` is a single object; this still returns a one-element array.
1207
- */
1208
- function splitJsonStream(data) {
1209
- const trimmed = data.trim();
1210
- if (trimmed === "")
1211
- return [];
1212
- const chunks = [];
1213
- let depth = 0;
1214
- let inString = false;
1215
- let escape = false;
1216
- let start = -1;
1217
- for (let i = 0; i < trimmed.length; i++) {
1218
- const ch = trimmed[i];
1219
- if (inString) {
1220
- if (escape)
1221
- escape = false;
1222
- else if (ch === "\\")
1223
- escape = true;
1224
- else if (ch === '"')
1225
- inString = false;
1226
- continue;
1227
- }
1228
- if (ch === '"') {
1229
- inString = true;
1230
- continue;
1231
- }
1232
- if (ch === "{") {
1233
- if (depth === 0)
1234
- start = i;
1235
- depth++;
1236
- }
1237
- else if (ch === "}") {
1238
- depth--;
1239
- if (depth === 0 && start >= 0) {
1240
- chunks.push(trimmed.slice(start, i + 1));
1241
- start = -1;
1242
- }
1243
- }
1244
- }
1245
- // Fallback: if brace tracking found nothing (e.g. a top-level array or a
1246
- // malformed file), try a single JSON.parse on the whole content so the
1247
- // malformed_json issue path can report it.
1248
- if (chunks.length === 0)
1249
- chunks.push(trimmed);
1250
- return chunks;
1251
- }
1252
- /**
1253
- * Flatten a parsed JSON value into display fields. Scalars, arrays, and nested
1254
- * objects are summarized into short `name: value` rows, mirroring the YAML
1255
- * field renderer. GUID refs are resolved against the meta index.
1256
- */
1257
- function jsonFields(value, limit, guidIndex) {
1258
- if (value === null || typeof value !== "object" || Array.isArray(value))
1259
- return [];
1260
- const obj = value;
1261
- const out = [];
1262
- for (const [key, raw] of Object.entries(obj)) {
1263
- if (out.length >= limit && limit > 0)
1264
- break;
1265
- out.push({ name: key, value: jsonScalar(raw, guidIndex) });
1266
- }
1267
- return out;
1268
- }
1269
- function jsonScalar(value, guidIndex) {
1270
- if (value === null)
1271
- return "null";
1272
- switch (typeof value) {
1273
- case "string":
1274
- return resolveReferences(value, guidIndex, EMPTY_PARSED);
1275
- case "number":
1276
- case "boolean":
1277
- return String(value);
1278
- default: {
1279
- if (Array.isArray(value))
1280
- return `[${value.length}]`;
1281
- const obj = value;
1282
- // Inline {fileID, guid, type} PPtr → resolve to asset name.
1283
- if (typeof obj.guid === "string" && obj.guid.length === 32) {
1284
- return resolveReferences(`{fileID: ${obj.fileID ?? 0}, guid: ${obj.guid}, type: ${obj.type ?? 0}}`, guidIndex, EMPTY_PARSED);
1285
- }
1286
- const keys = Object.keys(obj);
1287
- if (keys.length === 0)
1288
- return "{}";
1289
- return `{${keys.slice(0, 4).join(", ")}${keys.length > 4 ? ", …" : ""}}`;
1290
- }
1291
- }
1292
- }
1293
- // A ParsedAsset stand-in used only by resolveReferences' local-reference path.
1294
- // JSON assets have no in-file fileIDs, so local ref resolution is a no-op.
1295
- const EMPTY_PARSED = { path: "", kind: "", guid: "", objects: [], byID: new Map() };
1296
- // ===========================================================================
1297
- // M24 — offline integrity checks. The verify rule suite (M25) consumes these
1298
- // signals; here we only emit asset-local findings derivable from raw bytes.
1299
- // ===========================================================================
1300
- function runYamlIntegrityChecks(parsed, guidIndex) {
1301
- const issues = [];
1302
- // Orphaned PrefabInstance: a PrefabInstance whose m_SourcePrefab GUID is
1303
- // missing from the project (the base prefab was deleted or never imported).
1304
- for (const obj of parsed.objects) {
1305
- if (obj.type !== "PrefabInstance")
1306
- continue;
1307
- const sourceGuid = readGUIDField(obj.lines, "m_SourcePrefab");
1308
- if (sourceGuid !== "" && !guidIndex.has(sourceGuid)) {
1309
- issues.push({
1310
- code: "orphaned_prefab_instance",
1311
- detail: `m_SourcePrefab guid ${sourceGuid} not found in project`,
1312
- severity: "warning",
1313
- });
1314
- }
1315
- }
1316
- // Unresolved field GUIDs (missing references) — every guid: in a field that
1317
- // the meta index cannot resolve. Component m_Script guids get their own code
1318
- // so a missing-script is distinguishable from a missing asset ref.
1319
- const missingScripts = new Set();
1320
- const missingRefs = new Set();
1321
- for (const obj of parsed.objects) {
1322
- for (const line of obj.lines) {
1323
- if (!line.includes("guid:"))
1324
- continue;
1325
- const isScript = line.includes("m_Script");
1326
- for (const g of findGUIDs(line)) {
1327
- if (guidIndex.has(g))
1328
- continue;
1329
- if (isScript)
1330
- missingScripts.add(g);
1331
- else
1332
- missingRefs.add(g);
1333
- }
1334
- }
1335
- }
1336
- for (const g of missingScripts) {
1337
- issues.push({ code: "missing_script_reference", detail: `script guid ${g} unresolved`, severity: "error" });
1338
- }
1339
- for (const g of missingRefs) {
1340
- issues.push({ code: "missing_reference", detail: `asset guid ${g} unresolved`, severity: "warning" });
1341
- }
1342
- return issues;
1343
- }
1344
- function runJsonIntegrityChecks(kind, objects, parseIssues, _assetPath) {
1345
- const issues = [...parseIssues];
1346
- if (kind === "asmdef") {
1347
- const first = objects[0]?.value;
1348
- if (first && typeof first === "object" && !Array.isArray(first)) {
1349
- const obj = first;
1350
- if (pickString(obj.name) === undefined) {
1351
- issues.push({ code: "asmdef_missing_name", detail: "asmdef has no 'name' field", severity: "error" });
1352
- }
1353
- }
1354
- }
1355
- if (kind === "shadergraph") {
1356
- // A valid graph stream must start with the GraphData root.
1357
- const rootType = objects[0]?.type;
1358
- if (rootType !== undefined && !rootType.includes("GraphData")) {
1359
- issues.push({
1360
- code: "shadergraph_root_missing",
1361
- detail: `first object is ${rootType}, expected a GraphData root`,
1362
- severity: "warning",
1363
- });
1364
- }
1365
- }
1366
- return issues;
1367
- }
1368
- function convertNode(node, parsed, scriptIndex, guidIndex, fieldLimit) {
1369
- const comps = componentsFor(parsed, node.gameObject.id, scriptIndex);
1370
- const components = comps.map((c) => {
1371
- const comp = { name: c.name };
1372
- if (c.scriptPath)
1373
- comp.scriptPath = c.scriptPath;
1374
- if (fieldLimit > 0) {
1375
- const { fields } = fieldsWithHidden(parsed, c.object, fieldLimit, guidIndex);
1376
- if (fields.length > 0)
1377
- comp.fields = fields.map(toComponentField);
1378
- }
1379
- return comp;
1380
- });
1381
- return {
1382
- name: node.gameObject.name,
1383
- path: node.path,
1384
- depth: node.depth,
1385
- fileID: node.gameObject.id,
1386
- components,
1387
- children: node.children.map((child) => convertNode(child, parsed, scriptIndex, guidIndex, fieldLimit)),
1388
- };
1389
- }
1390
- function toComponentField(f) {
1391
- return { name: f.name, value: f.value };
1392
- }
1393
- // ===========================================================================
1394
- // searchAssetsOffline — scan Assets/ → SearchModel.
1395
- // ===========================================================================
1396
- const SEARCHABLE_EXTENSIONS = OFFLINE_PARSEABLE_EXTENSIONS;
1397
- export async function searchAssetsOffline(opts) {
1398
- const nameQuery = (opts.name ?? "").trim().toLowerCase();
1399
- const componentQuery = (opts.component ?? "").trim().toLowerCase();
1400
- const guidQuery = (opts.guid ?? "").trim().toLowerCase();
1401
- const typeFilter = opts.type ? parseKindSet(opts.type) : null;
1402
- const folder = opts.folder ?? "Assets";
1403
- const maxResults = opts.maxResults ?? 50;
1404
- const searchDir = join(opts.projectRoot, folder);
1405
- const scriptIndex = componentQuery
1406
- ? await buildScriptIndexForQuery(opts.projectRoot, componentQuery)
1407
- : new Map();
1408
- const guidIndex = guidQuery
1409
- ? await resolveGUIDPaths(opts.projectRoot, guidQuery)
1410
- : new Map();
1411
- const matches = [];
1412
- const files = await collectFiles(searchDir);
1413
- for (const filePath of files) {
1414
- if (matches.length >= maxResults)
1415
- break;
1416
- const assetPath = toAssetPath(opts.projectRoot, filePath);
1417
- const ext = extname(filePath).toLowerCase();
1418
- if (!SEARCHABLE_EXTENSIONS.has(ext))
1419
- continue;
1420
- const kind = kindForPath(filePath);
1421
- if (typeFilter && typeFilter.size > 0 && !typeFilter.has(kind))
1422
- continue;
1423
- const guid = await safeReadMetaGUID(filePath + ".meta");
1424
- // M24 — JSON asset kinds route through a content-based match path (they
1425
- // have no GameObject/component tree for the YAML matcher to walk).
1426
- if (offlineParseKind(filePath) === "json") {
1427
- let content;
1428
- try {
1429
- content = await readFile(filePath, "utf-8");
1430
- }
1431
- catch {
1432
- continue;
1433
- }
1434
- const match = checkJsonMatch(content, guidIndex, assetPath, kind, guid, {
1435
- nameQuery, componentQuery, guidQuery,
1436
- });
1437
- if (match)
1438
- matches.push(match);
1439
- continue;
1440
- }
1441
- let parsed;
1442
- try {
1443
- parsed = parseAsset(await readFile(filePath, "utf-8"));
1444
- }
1445
- catch {
1446
- continue;
1447
- }
1448
- parsed.path = assetPath;
1449
- parsed.kind = kind;
1450
- const match = checkMatch(parsed, scriptIndex, guidIndex, assetPath, kind, guid, { nameQuery, componentQuery, guidQuery });
1451
- if (match)
1452
- matches.push(match);
1453
- }
1454
- return {
1455
- query: { name: opts.name, component: opts.component, guid: opts.guid, type: opts.type },
1456
- matchCount: matches.length,
1457
- matches,
1458
- truncated: 0,
1459
- };
1460
- }
1461
- function checkMatch(parsed, scriptIndex, guidIndex, assetPath, kind, guid, criteria) {
1462
- const reasons = [];
1463
- const objects = [];
1464
- const { nameQuery, componentQuery, guidQuery } = criteria;
1465
- const fileName = basename(assetPath);
1466
- if (nameQuery && fileName.toLowerCase().includes(nameQuery))
1467
- reasons.push("file-name");
1468
- if (guidQuery) {
1469
- if (guid.toLowerCase() === guidQuery)
1470
- reasons.push("guid");
1471
- let hasFieldGUID = false;
1472
- for (const obj of parsed.objects) {
1473
- if (fieldGUIDs(obj).has(guidQuery)) {
1474
- hasFieldGUID = true;
1475
- break;
1476
- }
1477
- }
1478
- if (hasFieldGUID)
1479
- reasons.push("guid");
1480
- }
1481
- const hierarchy = buildHierarchy(parsed);
1482
- if (nameQuery) {
1483
- for (const node of flattenHierarchy(hierarchy)) {
1484
- if (node.gameObject.name.toLowerCase().includes(nameQuery)) {
1485
- const comps = componentsFor(parsed, node.gameObject.id, scriptIndex);
1486
- objects.push({ path: node.path, components: comps.map((c) => c.name) });
1487
- }
1488
- }
1489
- if (objects.length > 0 && !reasons.includes("file-name"))
1490
- reasons.push("gameobject");
1491
- }
1492
- if (componentQuery) {
1493
- let componentMatch = false;
1494
- for (const node of flattenHierarchy(hierarchy)) {
1495
- const comps = componentsFor(parsed, node.gameObject.id, scriptIndex);
1496
- const matching = comps.filter((c) => c.name.toLowerCase().includes(componentQuery) ||
1497
- (c.scriptPath && c.scriptPath.toLowerCase().includes(componentQuery)));
1498
- if (matching.length > 0) {
1499
- componentMatch = true;
1500
- if (!objects.some((o) => o.path === node.path)) {
1501
- objects.push({ path: node.path, components: comps.map((c) => c.name) });
1502
- }
1503
- }
1504
- }
1505
- if (componentMatch && !reasons.includes("component"))
1506
- reasons.push("component");
1507
- }
1508
- if (!componentQuery && !nameQuery && !guidQuery)
1509
- reasons.push("file-name");
1510
- if (reasons.length === 0)
1511
- return null;
1512
- return { path: assetPath, guid: guid || undefined, kind, reasons, objects: objects.length > 0 ? objects : undefined };
1513
- }
1514
- /**
1515
- * M24 — content-based matcher for JSON asset kinds (.asmdef/.shadergraph).
1516
- * They have no GameObject tree, so matching is by file name, raw-text GUID
1517
- * presence, and content substring (name/component queries search the raw text
1518
- * since JSON is human-readable). This keeps `search_assets` offline-capable for
1519
- * the expanded type set.
1520
- */
1521
- function checkJsonMatch(content, guidIndex, assetPath, kind, guid, criteria) {
1522
- const reasons = [];
1523
- const { nameQuery, componentQuery, guidQuery } = criteria;
1524
- const fileName = basename(assetPath);
1525
- const lower = content.toLowerCase();
1526
- if (nameQuery && fileName.toLowerCase().includes(nameQuery))
1527
- reasons.push("file-name");
1528
- if (guidQuery) {
1529
- if (guid.toLowerCase() === guidQuery)
1530
- reasons.push("guid");
1531
- else if (lower.includes(guidQuery))
1532
- reasons.push("guid");
1533
- }
1534
- if (nameQuery && !reasons.includes("file-name") && lower.includes(nameQuery)) {
1535
- reasons.push("gameobject");
1536
- }
1537
- if (componentQuery && lower.includes(componentQuery)) {
1538
- reasons.push("component");
1539
- }
1540
- if (!componentQuery && !nameQuery && !guidQuery)
1541
- reasons.push("file-name");
1542
- if (reasons.length === 0)
1543
- return null;
1544
- void guidIndex;
1545
- return { path: assetPath, guid: guid || undefined, kind, reasons };
1546
- }
1547
- function flattenHierarchy(nodes) {
1548
- const out = [];
1549
- const walk = (ns) => {
1550
- for (const n of ns) {
1551
- out.push(n);
1552
- walk(n.children);
1553
- }
1554
- };
1555
- walk(nodes);
1556
- return out;
1557
- }
1558
- async function collectFiles(dir) {
1559
- const results = [];
1560
- let entries;
1561
- try {
1562
- entries = await readdir(dir);
1563
- }
1564
- catch {
1565
- return results;
1566
- }
1567
- for (const name of entries) {
1568
- if (shouldSkipDir(name))
1569
- continue;
1570
- const fullPath = join(dir, name);
1571
- try {
1572
- const s = await stat(fullPath);
1573
- if (s.isDirectory())
1574
- results.push(...await collectFiles(fullPath));
1575
- else if (!name.endsWith(".meta"))
1576
- results.push(fullPath);
1577
- }
1578
- catch { /* skip */ }
1579
- }
1580
- return results;
1581
- }
1582
- async function resolveGUIDPaths(projectRoot, guidQuery) {
1583
- const result = new Map();
1584
- const assetsDir = join(projectRoot, "Assets");
1585
- await walkMetaForGUIDs(assetsDir, projectRoot, guidQuery, result);
1586
- return result;
1587
- }
1588
- async function walkMetaForGUIDs(dir, projectRoot, guidQuery, result) {
1589
- let entries;
1590
- try {
1591
- entries = await readdir(dir);
1592
- }
1593
- catch {
1594
- return;
1595
- }
1596
- for (const name of entries) {
1597
- if (shouldSkipDir(name))
1598
- continue;
1599
- const fullPath = join(dir, name);
1600
- try {
1601
- const s = await stat(fullPath);
1602
- if (s.isDirectory())
1603
- await walkMetaForGUIDs(fullPath, projectRoot, guidQuery, result);
1604
- else if (name.endsWith(".meta")) {
1605
- const guid = readMetaGUID(await readFile(fullPath, "utf-8")).toLowerCase();
1606
- if (guid === guidQuery) {
1607
- result.set(guid, toAssetPath(projectRoot, fullPath.slice(0, -5)));
1608
- }
1609
- }
1610
- }
1611
- catch { /* skip */ }
1612
- if (result.size > 0)
1613
- return;
1614
- }
1615
- }
1616
- const LISTABLE_EXTENSIONS = new Set([
1617
- ".prefab", ".unity", ".asset", ".mat", ".controller", ".anim", ".playable",
1618
- ".cs", ".shader", ".spriteatlas", ".physicMaterial", ".physicsMaterial2D",
1619
- ".overrideController", ".uxml", ".uss",
1620
- // M24 — expanded offline-parseable kinds (also listable so they show up in
1621
- // directory listings; listing never parses, so they cost nothing to include).
1622
- ".asmdef", ".shadergraph", ".shadersubgraph", ".vfx", ".preset", ".terrainlayer",
1623
- ]);
1624
- export async function listAssetsOffline(opts) {
1625
- const folder = opts.folder ?? "Assets";
1626
- const typeFilter = opts.type
1627
- ? new Set(opts.type.split(",").map((t) => normalizeKind(t)).filter((t) => t !== ""))
1628
- : null;
1629
- const searchDir = join(opts.projectRoot, folder);
1630
- const folders = [];
1631
- const kindSummary = {};
1632
- let totalFiles = 0;
1633
- let truncated = 0;
1634
- const folderMap = new Map();
1635
- await walkFiles(searchDir, opts.projectRoot, async (absPath) => {
1636
- const ext = extname(absPath).toLowerCase();
1637
- if (!LISTABLE_EXTENSIONS.has(ext))
1638
- return;
1639
- const kind = kindForPath(absPath);
1640
- if (typeFilter && typeFilter.size > 0 && !typeFilter.has(kind))
1641
- return;
1642
- const relDir = relativeDir(opts.projectRoot, absPath);
1643
- const fileName = basename(absPath);
1644
- let listing = folderMap.get(relDir);
1645
- if (!listing) {
1646
- listing = { folder: relDir, kinds: {}, fileCount: 0 };
1647
- folderMap.set(relDir, listing);
1648
- folders.push(listing);
1649
- }
1650
- listing.fileCount++;
1651
- totalFiles++;
1652
- if (!listing.kinds[kind])
1653
- listing.kinds[kind] = { count: 0, sample: [] };
1654
- listing.kinds[kind].count++;
1655
- kindSummary[kind] = (kindSummary[kind] ?? 0) + 1;
1656
- const samples = listing.kinds[kind].sample;
1657
- if (samples.length < 5) {
1658
- const nameWithoutExt = ext ? fileName.slice(0, -(ext.length)) : fileName;
1659
- if (!samples.includes(nameWithoutExt))
1660
- samples.push(nameWithoutExt);
1661
- }
1662
- else {
1663
- truncated++;
1664
- }
1665
- });
1666
- folders.sort((a, b) => a.folder.localeCompare(b.folder));
1667
- return {
1668
- root: folder, typeFilter: opts.type, folders,
1669
- totalFiles, totalFolders: folders.length, kindSummary, truncated,
1670
- };
1671
- }
1672
- function relativeDir(projectRoot, absPath) {
1673
- let rel = absPath.slice(projectRoot.length).replace(/\\/g, "/");
1674
- if (rel.startsWith("/"))
1675
- rel = rel.slice(1);
1676
- const lastSlash = rel.lastIndexOf("/");
1677
- return lastSlash >= 0 ? rel.slice(0, lastSlash) : rel;
1678
- }
1679
- async function walkFiles(dir, projectRoot, fn) {
1680
- let entries;
1681
- try {
1682
- entries = await readdir(dir);
1683
- }
1684
- catch {
1685
- return;
1686
- }
1687
- for (const name of entries) {
1688
- if (shouldSkipDir(name))
1689
- continue;
1690
- if (name.endsWith(".meta"))
1691
- continue;
1692
- const fullPath = join(dir, name);
1693
- try {
1694
- const s = await stat(fullPath);
1695
- if (s.isDirectory())
1696
- await walkFiles(fullPath, projectRoot, fn);
1697
- else
1698
- await fn(fullPath);
1699
- }
1700
- catch { /* skip */ }
1701
- }
1702
- }
1703
- // ===========================================================================
1704
- // findReferencesOffline — offline reverse reference lookup (T1.4).
1705
- //
1706
- // Scans .meta + YAML on disk to find all assets that reference a given target
1707
- // (by GUID or asset path). No running Editor required. Output is grouped by
1708
- // asset kind and folder, with detail levels and caps to protect token budget.
1709
- //
1710
- // Algorithm: resolve target GUID → scan all text-serialized files → raw-text
1711
- // fast-filter (content.includes(guid)) → optional YAML parse for verbose
1712
- // locations → group + cap + collapse.
1713
- // ===========================================================================
1714
- const REFERENCEABLE_EXTENSIONS = OFFLINE_PARSEABLE_EXTENSIONS;
1715
- export async function findReferencesOffline(opts) {
1716
- const detail = (opts.detail ?? "normal");
1717
- const maxResults = opts.maxResults ?? 100;
1718
- const maxPerFile = opts.maxPerFile ?? 5;
1719
- const patternThreshold = opts.patternThreshold ?? 0;
1720
- // Resolve target GUID + path.
1721
- let targetGuid = "";
1722
- let targetPath = "";
1723
- if (opts.guid) {
1724
- targetGuid = opts.guid.toLowerCase();
1725
- }
1726
- else if (opts.assetPath) {
1727
- targetPath = opts.assetPath;
1728
- targetGuid = (await safeReadMetaGUID(join(opts.projectRoot, opts.assetPath + ".meta"))).toLowerCase();
1729
- }
1730
- if (targetGuid === "") {
1731
- return {
1732
- queriedAssetPath: targetPath,
1733
- queriedAssetGuid: "",
1734
- referencedBy: [],
1735
- totalCount: 0,
1736
- byKind: {},
1737
- byFolder: {},
1738
- detail,
1739
- truncated: 0,
1740
- };
1741
- }
1742
- // If only GUID was given, resolve target path from .meta index.
1743
- if (targetPath === "") {
1744
- const guidIndex = await buildGUIDIndex(opts.projectRoot, new Set([targetGuid]));
1745
- targetPath = guidIndex.get(targetGuid) ?? "";
1746
- }
1747
- // Scan all candidate files in Assets/.
1748
- const candidates = await collectFiles(join(opts.projectRoot, "Assets"));
1749
- const hits = [];
1750
- for (const absPath of candidates) {
1751
- const ext = extname(absPath).toLowerCase();
1752
- if (!REFERENCEABLE_EXTENSIONS.has(ext))
1753
- continue;
1754
- let content;
1755
- try {
1756
- content = await readFile(absPath, "utf-8");
1757
- }
1758
- catch {
1759
- continue;
1760
- }
1761
- // Fast filter: skip files that don't mention the GUID at all.
1762
- if (!content.includes(targetGuid))
1763
- continue;
1764
- const assetPath = toAssetPath(opts.projectRoot, absPath);
1765
- // Skip self-reference.
1766
- if (assetPath === targetPath)
1767
- continue;
1768
- const kind = kindForPath(absPath);
1769
- const folder = relativeDir(opts.projectRoot, absPath);
1770
- const guid = await safeReadMetaGUID(absPath + ".meta");
1771
- const entry = {
1772
- assetPath,
1773
- guid: guid || undefined,
1774
- kind,
1775
- folder,
1776
- };
1777
- if (detail === "verbose") {
1778
- entry.locations = extractReferenceLocations(content, targetGuid, maxPerFile);
1779
- }
1780
- hits.push(entry);
1781
- }
1782
- // Group by kind and folder.
1783
- const byKind = {};
1784
- const byFolder = {};
1785
- for (const hit of hits) {
1786
- byKind[hit.kind] = (byKind[hit.kind] ?? 0) + 1;
1787
- byFolder[hit.folder] = (byFolder[hit.folder] ?? 0) + 1;
1788
- }
1789
- // Pattern collapsing: folders with >= threshold files get collapsed.
1790
- let collapsedGroups;
1791
- let displayHits = hits;
1792
- if (patternThreshold > 0) {
1793
- collapsedGroups = [];
1794
- const collapsedFolders = new Set();
1795
- for (const [folder, count] of Object.entries(byFolder)) {
1796
- if (count < patternThreshold)
1797
- continue;
1798
- const kinds = {};
1799
- for (const hit of hits) {
1800
- if (hit.folder !== folder)
1801
- continue;
1802
- kinds[hit.kind] = (kinds[hit.kind] ?? 0) + 1;
1803
- }
1804
- collapsedGroups.push({ folder, count, kinds });
1805
- collapsedFolders.add(folder);
1806
- }
1807
- if (collapsedFolders.size > 0) {
1808
- displayHits = hits.filter((h) => !collapsedFolders.has(h.folder));
1809
- }
1810
- }
1811
- // Apply max_results cap and detail filtering.
1812
- const totalCount = hits.length;
1813
- let referencedBy;
1814
- let truncated = 0;
1815
- if (detail === "summary") {
1816
- referencedBy = [];
1817
- }
1818
- else {
1819
- referencedBy = displayHits.slice(0, maxResults);
1820
- truncated = Math.max(0, displayHits.length - maxResults);
1821
- }
1822
- return {
1823
- queriedAssetPath: targetPath,
1824
- queriedAssetGuid: targetGuid,
1825
- referencedBy,
1826
- totalCount,
1827
- byKind,
1828
- byFolder,
1829
- collapsedGroups: collapsedGroups && collapsedGroups.length > 0
1830
- ? collapsedGroups
1831
- : undefined,
1832
- detail,
1833
- truncated,
1834
- };
1835
- }
1836
- function extractReferenceLocations(content, targetGuid, maxPerFile) {
1837
- const locations = [];
1838
- const lines = content.split("\n");
1839
- for (let i = 0; i < lines.length; i++) {
1840
- if (!lines[i].includes(targetGuid))
1841
- continue;
1842
- if (locations.length >= maxPerFile)
1843
- break;
1844
- const trim = lines[i].trim();
1845
- const cleaned = trim.startsWith("- ") ? trim.slice(2) : trim;
1846
- const colonIdx = cleaned.indexOf(":");
1847
- const field = colonIdx > 0 ? cleaned.slice(0, colonIdx).trim() : cleaned;
1848
- if (field === "target") {
1849
- let label = "prefab modification";
1850
- for (let j = i + 1; j < Math.min(i + 5, lines.length); j++) {
1851
- const pp = lines[j].trim();
1852
- if (pp.startsWith("propertyPath:")) {
1853
- label = `prefab → ${displayFieldName(cleanScalar(pp.slice("propertyPath:".length).trim()))}`;
1854
- break;
1855
- }
1856
- if (pp.startsWith("- target:") || pp.startsWith("- ") || pp === "")
1857
- continue;
1858
- break;
1859
- }
1860
- locations.push(label);
1861
- }
1862
- else {
1863
- locations.push(displayFieldName(field));
1864
- }
1865
- }
1866
- return locations;
1867
- }
1868
- /** Collect the forward edges declared by a single asset: every external GUID
1869
- * reference (field PPtrs + m_Script + PrefabInstance m_SourcePrefab). This is
1870
- * the offline forward-edge primitive — unity-scanner has no full forward-edge
1871
- * builder, so this is authored net-new on the existing GUID-extraction
1872
- * primitives (findGUIDs / readGUIDField / parseAsset). */
1873
- function collectForwardEdges(parsed) {
1874
- const edges = [];
1875
- const seen = new Set();
1876
- // 1. PrefabInstance.m_SourcePrefab — the base-prefab edge of a variant.
1877
- for (const obj of parsed.objects) {
1878
- if (obj.type !== "PrefabInstance")
1879
- continue;
1880
- const sourceGuid = readGUIDField(obj.lines, "m_SourcePrefab");
1881
- if (sourceGuid !== "" && seen.add(`prefab:${sourceGuid}`)) {
1882
- edges.push({ guid: sourceGuid, assetPath: "", kind: "prefab_source", resolved: false });
1883
- }
1884
- }
1885
- // 2. m_Script GUIDs on MonoBehaviours — the script-class edge.
1886
- for (const obj of parsed.objects) {
1887
- if (obj.type !== "MonoBehaviour")
1888
- continue;
1889
- if (obj.scriptGUID !== "" && seen.add(`script:${obj.scriptGUID}`)) {
1890
- edges.push({ guid: obj.scriptGUID, assetPath: "", kind: "script", resolved: false });
1891
- }
1892
- }
1893
- // 3. Every other guid: field — material refs, asset refs, animation clips,
1894
- // etc. m_Script is handled above; m_SourcePrefab target: GUIDs inside
1895
- // PrefabInstance.m_Modifications are included here so variant overrides
1896
- // count as forward edges too.
1897
- for (const obj of parsed.objects) {
1898
- for (const line of obj.lines) {
1899
- if (!line.includes("guid:"))
1900
- continue;
1901
- if (line.includes("m_Script:"))
1902
- continue; // handled as a script edge above
1903
- for (const g of findGUIDs(line)) {
1904
- if (!seen.add(`pptr:${g}`))
1905
- continue;
1906
- edges.push({ guid: g, assetPath: "", kind: "pptr", resolved: false });
1907
- }
1908
- }
1909
- }
1910
- return edges;
1911
- }
1912
- /** Resolve forward edges against the GUID→path index. Mutates each edge in
1913
- * place: sets assetPath + resolved. Returns the broken (unresolved) GUIDs. */
1914
- function resolveForwardEdges(edges, guidIndex) {
1915
- const broken = [];
1916
- const brokenSeen = new Set();
1917
- for (const edge of edges) {
1918
- const path = guidIndex.get(edge.guid);
1919
- if (path !== undefined) {
1920
- edge.assetPath = path;
1921
- edge.resolved = true;
1922
- }
1923
- else if (brokenSeen.add(edge.guid)) {
1924
- broken.push(edge.guid);
1925
- }
1926
- }
1927
- return broken;
1928
- }
1929
- export async function dependenciesOffline(opts) {
1930
- const detail = (opts.detail ?? "normal");
1931
- const maxResults = opts.maxResults ?? 100;
1932
- const includeImpact = opts.includeImpact ?? false;
1933
- const maxImpactDepth = opts.maxImpactDepth ?? 5;
1934
- const maxCycleDepth = opts.maxCycleDepth ?? 8;
1935
- // Resolve target GUID + path (mirrors findReferencesOffline's resolution).
1936
- let targetGuid = "";
1937
- let targetPath = "";
1938
- if (opts.guid) {
1939
- targetGuid = opts.guid.toLowerCase();
1940
- }
1941
- else if (opts.assetPath) {
1942
- targetPath = opts.assetPath;
1943
- targetGuid = (await safeReadMetaGUID(join(opts.projectRoot, opts.assetPath + ".meta"))).toLowerCase();
1944
- }
1945
- if (targetGuid === "") {
1946
- return emptyDependencies(targetPath, "", detail);
1947
- }
1948
- if (targetPath === "") {
1949
- const idx = await buildGUIDIndex(opts.projectRoot, new Set([targetGuid]));
1950
- targetPath = idx.get(targetGuid) ?? "";
1951
- }
1952
- // ---- Forward edges ----
1953
- // Parse the queried asset + collect its declared edges, then resolve each
1954
- // against a GUID→path index scoped to exactly the GUIDs this asset names.
1955
- let forwardEdges = [];
1956
- let brokenGuids = [];
1957
- let cycles = [];
1958
- let forwardSkipped;
1959
- const absPath = join(opts.projectRoot, targetPath);
1960
- let data;
1961
- try {
1962
- data = await readFile(absPath, "utf-8");
1963
- }
1964
- catch {
1965
- // The queried asset isn't on disk (GUID resolve but path is stale). No
1966
- // forward edges can be extracted; reverse edges still computed below.
1967
- forwardSkipped = "queried asset not readable on disk";
1968
- }
1969
- if (data !== undefined) {
1970
- try {
1971
- const parsed = parseAsset(data);
1972
- parsed.path = targetPath;
1973
- forwardEdges = collectForwardEdges(parsed);
1974
- const wanted = new Set(forwardEdges.map((e) => e.guid));
1975
- const guidIndex = await buildGUIDIndex(opts.projectRoot, wanted);
1976
- brokenGuids = resolveForwardEdges(forwardEdges, guidIndex);
1977
- if (forwardEdges.length > 0) {
1978
- cycles = await detectCyclesOffline(targetPath, forwardEdges, opts.projectRoot, maxCycleDepth);
1979
- }
1980
- }
1981
- catch (err) {
1982
- // Not parseable as Unity YAML (JSON-only kind, binary, malformed). The
1983
- // forward arrays stay empty; reverse edges are unaffected. This is the
1984
- // honest signal — the offline forward parser covers text-serialized YAML
1985
- // only, matching the documented offline coverage.
1986
- forwardSkipped = err instanceof Error ? err.message : String(err);
1987
- }
1988
- }
1989
- // ---- Reverse edges ---- (reuse the findReferencesOffline machinery).
1990
- const refResult = await findReferencesOffline({
1991
- assetPath: targetPath || undefined,
1992
- guid: targetGuid,
1993
- detail: "normal",
1994
- projectRoot: opts.projectRoot,
1995
- });
1996
- const reverseEdges = refResult.referencedBy.map((e) => ({
1997
- assetPath: e.assetPath,
1998
- guid: e.guid ?? "",
1999
- kind: e.kind,
2000
- }));
2001
- // ---- Transitive impact (optional) ----
2002
- let impact;
2003
- if (includeImpact) {
2004
- impact = await computeTransitiveImpact(targetGuid, targetPath, opts.projectRoot, maxImpactDepth);
2005
- }
2006
- // ---- Apply detail + caps ----
2007
- const displayForward = detail === "summary" ? [] : forwardEdges;
2008
- const totalCount = reverseEdges.length;
2009
- let displayReverse;
2010
- let truncated = 0;
2011
- if (detail === "summary") {
2012
- displayReverse = [];
2013
- }
2014
- else if (maxResults > 0 && totalCount > maxResults) {
2015
- displayReverse = reverseEdges.slice(0, maxResults);
2016
- truncated = totalCount - maxResults;
2017
- }
2018
- else {
2019
- displayReverse = reverseEdges;
2020
- }
2021
- return {
2022
- queriedAssetPath: targetPath,
2023
- queriedAssetGuid: targetGuid,
2024
- forwardDependencies: displayForward,
2025
- forwardCount: forwardEdges.length,
2026
- brokenForwardGuids: brokenGuids,
2027
- cycles,
2028
- reverseDependencies: displayReverse,
2029
- reverseCount: totalCount,
2030
- impact,
2031
- detail,
2032
- truncated,
2033
- ...(forwardSkipped ? { forwardSkipped } : {}),
2034
- _source: "offline",
2035
- };
2036
- }
2037
- function emptyDependencies(path, guid, detail) {
2038
- return {
2039
- queriedAssetPath: path,
2040
- queriedAssetGuid: guid,
2041
- forwardDependencies: [],
2042
- forwardCount: 0,
2043
- brokenForwardGuids: [],
2044
- cycles: [],
2045
- reverseDependencies: [],
2046
- reverseCount: 0,
2047
- detail,
2048
- truncated: 0,
2049
- _source: "offline",
2050
- };
2051
- }
2052
- /** Forward-cycle detection via DFS over the transitive forward closure of the
2053
- * queried asset. Bounded by maxDepth to stay cheap on large graphs; a cycle
2054
- * is reported when the DFS revisits the queried asset's path. This mirrors
2055
- * the C# Dependencies.Scanner.DetectCycles shape (each cycle is a path list).
2056
- *
2057
- * unity-scanner has no cycle detector — this is authored net-new. */
2058
- async function detectCyclesOffline(startPath, startEdges, projectRoot, maxDepth) {
2059
- const cycles = [];
2060
- // Build a small forward-edge cache so repeated visits of the same asset
2061
- // parse once. Keyed by asset path.
2062
- const edgeCache = new Map();
2063
- edgeCache.set(startPath, startEdges);
2064
- async function edgesOf(path) {
2065
- const cached = edgeCache.get(path);
2066
- if (cached)
2067
- return cached;
2068
- try {
2069
- const data = await readFile(join(projectRoot, path), "utf-8");
2070
- const parsed = parseAsset(data);
2071
- const edges = collectForwardEdges(parsed);
2072
- // Best-effort resolve so the DFS walks resolved paths only.
2073
- const idx = await buildGUIDIndex(projectRoot, new Set(edges.map((e) => e.guid)));
2074
- resolveForwardEdges(edges, idx);
2075
- edgeCache.set(path, edges);
2076
- return edges;
2077
- }
2078
- catch {
2079
- edgeCache.set(path, []);
2080
- return [];
2081
- }
2082
- }
2083
- // DFS from each resolved forward neighbor of the start asset, looking for a
2084
- // path back to startPath. visited guards against re-descending the same node
2085
- // within one root-neighbor walk (the cycle itself is the back-edge to start).
2086
- const visiting = new Set();
2087
- async function dfs(current, trail) {
2088
- if (trail.length > maxDepth)
2089
- return;
2090
- const edges = await edgesOf(current);
2091
- for (const edge of edges) {
2092
- if (!edge.resolved || edge.assetPath === "")
2093
- continue;
2094
- if (edge.assetPath === startPath) {
2095
- cycles.push([...trail, startPath]);
2096
- continue;
2097
- }
2098
- if (visiting.has(edge.assetPath))
2099
- continue;
2100
- visiting.add(edge.assetPath);
2101
- await dfs(edge.assetPath, [...trail, edge.assetPath]);
2102
- visiting.delete(edge.assetPath);
2103
- }
2104
- }
2105
- for (const edge of startEdges) {
2106
- if (!edge.resolved || edge.assetPath === "" || edge.assetPath === startPath)
2107
- continue;
2108
- visiting.clear();
2109
- visiting.add(edge.assetPath);
2110
- await dfs(edge.assetPath, [startPath, edge.assetPath]);
2111
- }
2112
- return cycles;
2113
- }
2114
- /** Transitive impact closure via BFS over reverse edges. Starting from the
2115
- * queried asset's direct referencers, walk one hop out at a time, accumulating
2116
- * every asset that (transitively) depends on the queried asset. Bounded by
2117
- * maxDepth. This is the "what breaks if I delete/move this?" answer — neither
2118
- * the offline nor the live path did multi-hop reverse before T24.2. */
2119
- async function computeTransitiveImpact(startGuid, startPath, projectRoot, maxDepth) {
2120
- const affected = [];
2121
- const seen = new Set([startPath]);
2122
- let frontier = [];
2123
- let truncated = false;
2124
- // Seed: direct reverse edges of the queried asset.
2125
- const seed = await findReferencesOffline({
2126
- assetPath: startPath || undefined,
2127
- guid: startGuid,
2128
- detail: "normal",
2129
- projectRoot,
2130
- });
2131
- for (const e of seed.referencedBy) {
2132
- if (seen.has(e.assetPath))
2133
- continue;
2134
- seen.add(e.assetPath);
2135
- frontier.push(e.assetPath);
2136
- affected.push({ assetPath: e.assetPath, depth: 1 });
2137
- }
2138
- // BFS: for each level, resolve the reverse edges of every frontier asset.
2139
- for (let depth = 2; depth <= maxDepth; depth++) {
2140
- if (frontier.length === 0)
2141
- break;
2142
- const next = [];
2143
- for (const nodePath of frontier) {
2144
- const nodeGuid = await safeReadMetaGUID(join(projectRoot, nodePath + ".meta"));
2145
- if (nodeGuid === "")
2146
- continue;
2147
- const res = await findReferencesOffline({
2148
- guid: nodeGuid,
2149
- detail: "normal",
2150
- projectRoot,
2151
- });
2152
- for (const e of res.referencedBy) {
2153
- if (seen.has(e.assetPath))
2154
- continue;
2155
- seen.add(e.assetPath);
2156
- next.push(e.assetPath);
2157
- affected.push({ assetPath: e.assetPath, depth });
2158
- }
2159
- }
2160
- frontier = next;
2161
- if (depth === maxDepth && frontier.length > 0) {
2162
- // The next level exists but we stop here — the closure is a prefix.
2163
- truncated = true;
2164
- }
2165
- }
2166
- return {
2167
- affected,
2168
- affectedCount: affected.length,
2169
- maxDepth,
2170
- truncated,
2171
- };
2172
- }
2173
- /** Scan the whole Assets/ tree for project-wide integrity issues that the
2174
- * per-read checks cannot surface (orphaned .meta, duplicate GUIDs) plus the
2175
- * aggregated missing-reference set. Offline counterpart of a full verify
2176
- * scan_paths run for these three rule families. */
2177
- export async function scanIntegrityOffline(opts) {
2178
- const issues = [];
2179
- const assetsDir = join(opts.projectRoot, "Assets");
2180
- // ---- Walk every .meta + every asset file in one pass ----
2181
- // We collect: guidByPath (path -> guid), pathByGuid (guid -> paths[]), and
2182
- // the set of asset files (companions for the orphan-meta check). Skipping a
2183
- // dir uses the same skipDirs policy as every other offline walk.
2184
- const guidByPath = new Map();
2185
- const allAssetPaths = new Set();
2186
- const assetFiles = await collectFiles(assetsDir);
2187
- for (const absPath of assetFiles) {
2188
- const assetPath = toAssetPath(opts.projectRoot, absPath);
2189
- allAssetPaths.add(assetPath);
2190
- const metaGuid = await safeReadMetaGUID(absPath + ".meta");
2191
- if (metaGuid !== "") {
2192
- guidByPath.set(assetPath, metaGuid);
2193
- }
2194
- }
2195
- // Also index .meta-only paths (files whose companion was deleted show up
2196
- // here but not in allAssetPaths — those are the orphans). walkMeta gives us
2197
- // every .meta under Assets/.
2198
- const metaOnlyPaths = new Set();
2199
- await walkMeta(assetsDir, async (metaPath) => {
2200
- const assetPath = toAssetPath(opts.projectRoot, metaPath.slice(0, -5));
2201
- if (!allAssetPaths.has(assetPath)) {
2202
- metaOnlyPaths.add(assetPath);
2203
- }
2204
- });
2205
- // ---- orphan_meta: .meta whose companion asset is gone ----
2206
- for (const path of metaOnlyPaths) {
2207
- issues.push({
2208
- path: path + ".meta",
2209
- code: "orphan_meta",
2210
- severity: "warning",
2211
- detail: `meta file has no companion asset at ${path}`,
2212
- });
2213
- }
2214
- // ---- duplicate_guid: GUID shared by 2+ assets ----
2215
- const pathsByGuid = new Map();
2216
- for (const [assetPath, guid] of guidByPath) {
2217
- const list = pathsByGuid.get(guid);
2218
- if (list)
2219
- list.push(assetPath);
2220
- else
2221
- pathsByGuid.set(guid, [assetPath]);
2222
- }
2223
- for (const [guid, paths] of pathsByGuid) {
2224
- if (paths.length < 2)
2225
- continue;
2226
- for (const p of paths) {
2227
- issues.push({
2228
- path: p,
2229
- code: "duplicate_guid",
2230
- severity: "error",
2231
- detail: `guid ${guid} shared by ${paths.length} assets`,
2232
- relatedPaths: paths.filter((other) => other !== p),
2233
- });
2234
- }
2235
- }
2236
- // ---- aggregated missing references (the per-read check, project-wide) ----
2237
- // Re-uses the full GUID index so unresolved field GUIDs are detected once
2238
- // per asset across the whole tree. This is the offline seed for the
2239
- // missing_references verify rule (M25 consumes these primitives).
2240
- const fullGuidIndex = await buildGUIDIndex(opts.projectRoot);
2241
- for (const absPath of assetFiles) {
2242
- const ext = extname(absPath).toLowerCase();
2243
- if (!OFFLINE_PARSEABLE_EXTENSIONS.has(ext))
2244
- continue;
2245
- const assetPath = toAssetPath(opts.projectRoot, absPath);
2246
- let data;
2247
- try {
2248
- data = await readFile(absPath, "utf-8");
2249
- }
2250
- catch {
2251
- continue;
2252
- }
2253
- if (!data.includes("guid:"))
2254
- continue; // fast filter: no refs at all
2255
- try {
2256
- const parsed = parseAsset(data);
2257
- parsed.path = assetPath;
2258
- // Run the per-asset YAML integrity check against the FULL index (not a
2259
- // scoped one) so a ref resolves even if its target lives far away.
2260
- const perAsset = runYamlIntegrityChecks(parsed, fullGuidIndex);
2261
- for (const issue of perAsset) {
2262
- issues.push({
2263
- path: assetPath,
2264
- code: issue.code,
2265
- severity: issue.severity,
2266
- detail: issue.detail,
2267
- });
2268
- }
2269
- }
2270
- catch {
2271
- // Not parseable as YAML — skip. JSON integrity is per-read only; a
2272
- // project-wide JSON scan is out of scope for the verify-engine seed.
2273
- }
2274
- }
2275
- // ---- group by code ----
2276
- const byCode = {};
2277
- for (const issue of issues) {
2278
- byCode[issue.code] = (byCode[issue.code] ?? 0) + 1;
2279
- }
2280
- return {
2281
- issues,
2282
- byCode,
2283
- totalIssues: issues.length,
2284
- assetsScanned: allAssetPaths.size,
2285
- _source: "offline",
2286
- };
2287
- }
24
+ // The original header noted the file was monolithic "so it loads cleanly under
25
+ // node --experimental-strip-types in tests: no cross-file runtime imports".
26
+ // Decomposition is now safe: tests compile to dist-test/ via tsc (the
27
+ // strip-types path is not the test execution path), and the only strip-types
28
+ // entry — scripts/generate-token-estimates.mjs — imports tools/index.ts, which
29
+ // does not transitively reach this module. All submodules use static
30
+ // relative imports with `.js` specifiers (Node16 module resolution), so the
31
+ // import graph stays statically resolvable.
32
+ export { readAssetOffline, searchAssetsOffline, listAssetsOffline, findReferencesOffline, dependenciesOffline, scanIntegrityOffline, isOfflineAsset, countHierarchy, } from "./offline/api.js";
2288
33
  //# sourceMappingURL=offline.js.map