unity-open-mcp 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -73
- package/dist/batch-spawn.d.ts +4 -0
- package/dist/batch-spawn.js +73 -18
- package/dist/batch-spawn.js.map +1 -1
- package/dist/capabilities/build-capabilities.d.ts +5 -5
- package/dist/capabilities/build-capabilities.js +2 -2
- package/dist/capabilities/cost-hints.js +20 -0
- package/dist/capabilities/cost-hints.js.map +1 -1
- package/dist/capabilities/lifecycle.js +1 -1
- package/dist/capabilities/lifecycle.js.map +1 -1
- package/dist/capabilities/tool-groups.d.ts +6 -9
- package/dist/capabilities/tool-groups.js +11 -13
- package/dist/capabilities/tool-groups.js.map +1 -1
- package/dist/cli/commands.js +9 -8
- package/dist/cli/commands.js.map +1 -1
- package/dist/compressible-router.js +6 -0
- package/dist/compressible-router.js.map +1 -1
- package/dist/constants.d.ts +89 -0
- package/dist/constants.js +109 -0
- package/dist/constants.js.map +1 -0
- package/dist/dialog-dismiss.d.ts +0 -7
- package/dist/dialog-dismiss.js +17 -19
- package/dist/dialog-dismiss.js.map +1 -1
- package/dist/hub-control.d.ts +6 -2
- package/dist/hub-control.js +8 -8
- package/dist/hub-control.js.map +1 -1
- package/dist/index.js +12 -13
- package/dist/index.js.map +1 -1
- package/dist/instance-discovery.js +2 -1
- package/dist/instance-discovery.js.map +1 -1
- package/dist/live-client.d.ts +49 -0
- package/dist/live-client.js +292 -255
- package/dist/live-client.js.map +1 -1
- package/dist/offline/api.d.ts +171 -0
- package/dist/offline/api.js +855 -0
- package/dist/offline/api.js.map +1 -0
- package/dist/offline/hierarchy.d.ts +24 -0
- package/dist/offline/hierarchy.js +242 -0
- package/dist/offline/hierarchy.js.map +1 -0
- package/dist/offline/index-builders.d.ts +11 -0
- package/dist/offline/index-builders.js +202 -0
- package/dist/offline/index-builders.js.map +1 -0
- package/dist/offline/model.d.ts +8 -0
- package/dist/offline/model.js +188 -0
- package/dist/offline/model.js.map +1 -0
- package/dist/offline/names.d.ts +7 -0
- package/dist/offline/names.js +20 -0
- package/dist/offline/names.js.map +1 -0
- package/dist/offline/overrides.d.ts +17 -0
- package/dist/offline/overrides.js +266 -0
- package/dist/offline/overrides.js.map +1 -0
- package/dist/offline/parse.d.ts +25 -0
- package/dist/offline/parse.js +236 -0
- package/dist/offline/parse.js.map +1 -0
- package/dist/offline/paths.d.ts +4 -0
- package/dist/offline/paths.js +23 -0
- package/dist/offline/paths.js.map +1 -0
- package/dist/offline/primitives.d.ts +17 -0
- package/dist/offline/primitives.js +195 -0
- package/dist/offline/primitives.js.map +1 -0
- package/dist/offline/references.d.ts +9 -0
- package/dist/offline/references.js +55 -0
- package/dist/offline/references.js.map +1 -0
- package/dist/offline/types.d.ts +65 -0
- package/dist/offline/types.js +154 -0
- package/dist/offline/types.js.map +1 -0
- package/dist/offline.d.ts +2 -180
- package/dist/offline.js +27 -2282
- package/dist/offline.js.map +1 -1
- package/dist/project-health.d.ts +1 -1
- package/dist/project-health.js +66 -3
- package/dist/project-health.js.map +1 -1
- package/dist/routers.js +6 -5
- package/dist/routers.js.map +1 -1
- package/dist/tool-router.d.ts +13 -0
- package/dist/tool-router.js +179 -251
- package/dist/tool-router.js.map +1 -1
- package/dist/tool-session-state.js +18 -9
- package/dist/tool-session-state.js.map +1 -1
- package/dist/tools/apply-fix.js +7 -2
- package/dist/tools/apply-fix.js.map +1 -1
- package/dist/tools/batch-execute.js +8 -2
- package/dist/tools/batch-execute.js.map +1 -1
- package/dist/tools/bridge-status.js +1 -2
- package/dist/tools/bridge-status.js.map +1 -1
- package/dist/tools/checkpoint-create.js +1 -0
- package/dist/tools/checkpoint-create.js.map +1 -1
- package/dist/tools/component-get.js +44 -11
- package/dist/tools/component-get.js.map +1 -1
- package/dist/tools/delta.js +1 -0
- package/dist/tools/delta.js.map +1 -1
- package/dist/tools/dependencies.js +4 -1
- package/dist/tools/dependencies.js.map +1 -1
- package/dist/tools/editor-status.js +3 -2
- package/dist/tools/editor-status.js.map +1 -1
- package/dist/tools/execute-csharp.js +3 -2
- package/dist/tools/execute-csharp.js.map +1 -1
- package/dist/tools/find-references.js +8 -1
- package/dist/tools/find-references.js.map +1 -1
- package/dist/tools/index.js +19 -17
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/inputsystem-asset-create.js +4 -4
- package/dist/tools/inputsystem-asset-create.js.map +1 -1
- package/dist/tools/invoke-method.js +2 -1
- package/dist/tools/invoke-method.js.map +1 -1
- package/dist/tools/manage-tools.js +10 -10
- package/dist/tools/manage-tools.js.map +1 -1
- package/dist/tools/navigation-surface-add.js +6 -5
- package/dist/tools/navigation-surface-add.js.map +1 -1
- package/dist/tools/probuilder-create-shape.js +4 -4
- package/dist/tools/probuilder-create-shape.js.map +1 -1
- package/dist/tools/read-compile-errors.js +44 -31
- package/dist/tools/read-compile-errors.js.map +1 -1
- package/dist/tools/scene-get-data.js +10 -2
- package/dist/tools/scene-get-data.js.map +1 -1
- package/dist/tools/scene-save.js +16 -5
- package/dist/tools/scene-save.js.map +1 -1
- package/dist/tools/scene-set-active.js +22 -6
- package/dist/tools/scene-set-active.js.map +1 -1
- package/dist/tools/scene-unload.js +20 -6
- package/dist/tools/scene-unload.js.map +1 -1
- package/dist/tools/search-assets.js +3 -1
- package/dist/tools/search-assets.js.map +1 -1
- 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
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
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
|
-
//
|
|
1117
|
-
//
|
|
1118
|
-
//
|
|
1119
|
-
//
|
|
1120
|
-
//
|
|
1121
|
-
//
|
|
1122
|
-
//
|
|
1123
|
-
//
|
|
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
|