azul-sync 1.3.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/.gitattributes +1 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +31 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
- package/README.md +142 -0
- package/dist/build.d.ts +19 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +92 -0
- package/dist/build.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +397 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +26 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +105 -0
- package/dist/config.js.map +1 -0
- package/dist/fs/fileWriter.d.ts +100 -0
- package/dist/fs/fileWriter.d.ts.map +1 -0
- package/dist/fs/fileWriter.js +342 -0
- package/dist/fs/fileWriter.js.map +1 -0
- package/dist/fs/treeManager.d.ts +84 -0
- package/dist/fs/treeManager.d.ts.map +1 -0
- package/dist/fs/treeManager.js +365 -0
- package/dist/fs/treeManager.js.map +1 -0
- package/dist/fs/watcher.d.ts +39 -0
- package/dist/fs/watcher.d.ts.map +1 -0
- package/dist/fs/watcher.js +120 -0
- package/dist/fs/watcher.js.map +1 -0
- package/dist/index.d.ts +61 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +349 -0
- package/dist/index.js.map +1 -0
- package/dist/ipc/httpPolling.d.ts +56 -0
- package/dist/ipc/httpPolling.d.ts.map +1 -0
- package/dist/ipc/httpPolling.js +171 -0
- package/dist/ipc/httpPolling.js.map +1 -0
- package/dist/ipc/messages.d.ts +112 -0
- package/dist/ipc/messages.d.ts.map +1 -0
- package/dist/ipc/messages.js +5 -0
- package/dist/ipc/messages.js.map +1 -0
- package/dist/ipc/server.d.ts +50 -0
- package/dist/ipc/server.d.ts.map +1 -0
- package/dist/ipc/server.js +168 -0
- package/dist/ipc/server.js.map +1 -0
- package/dist/pack.d.ts +19 -0
- package/dist/pack.d.ts.map +1 -0
- package/dist/pack.js +225 -0
- package/dist/pack.js.map +1 -0
- package/dist/push.d.ts +43 -0
- package/dist/push.d.ts.map +1 -0
- package/dist/push.js +532 -0
- package/dist/push.js.map +1 -0
- package/dist/rojo.d.ts +9 -0
- package/dist/rojo.d.ts.map +1 -0
- package/dist/rojo.js +114 -0
- package/dist/rojo.js.map +1 -0
- package/dist/snapshot/rojo.d.ts +39 -0
- package/dist/snapshot/rojo.d.ts.map +1 -0
- package/dist/snapshot/rojo.js +364 -0
- package/dist/snapshot/rojo.js.map +1 -0
- package/dist/snapshot.d.ts +23 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +132 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/sourcemap/generator.d.ts +78 -0
- package/dist/sourcemap/generator.d.ts.map +1 -0
- package/dist/sourcemap/generator.js +351 -0
- package/dist/sourcemap/generator.js.map +1 -0
- package/dist/sourcemap/propertyLoader.d.ts +19 -0
- package/dist/sourcemap/propertyLoader.d.ts.map +1 -0
- package/dist/sourcemap/propertyLoader.js +131 -0
- package/dist/sourcemap/propertyLoader.js.map +1 -0
- package/dist/util/id.d.ts +9 -0
- package/dist/util/id.d.ts.map +1 -0
- package/dist/util/id.js +14 -0
- package/dist/util/id.js.map +1 -0
- package/dist/util/log.d.ts +13 -0
- package/dist/util/log.d.ts.map +1 -0
- package/dist/util/log.js +51 -0
- package/dist/util/log.js.map +1 -0
- package/docs/assets/azul-logo.pdn +0 -0
- package/docs/assets/logo-200px.png +0 -0
- package/docs/assets/logo.png +0 -0
- package/docs/assets/plugin/toolbox.png +0 -0
- package/docs/assets/synced.png +0 -0
- package/package.json +41 -0
- package/plugin/README.md +54 -0
- package/plugin/sourcemap.json +264 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/Actor/AzulSync.server.luau +905 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/AzulService.luau +1010 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/Config.luau +29 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/Enums.luau +11 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/CollapsibleTitledSection.luau +214 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/ColorPicker.luau +360 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/CustomTextButton.luau +170 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/DropdownMenu.luau +363 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/HorizontalLine.luau +43 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/ImageButtonWithText.luau +181 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledCheckbox.luau +295 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledColorInputPicker.luau +294 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledMultiChoice.luau +163 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledNumberInput.luau +312 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledRadioButton.luau +55 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledSlider.luau +151 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledTextInput.luau +222 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledToggleButton.luau +73 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/StatefulImageButton.luau +125 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/VerticalScrollingFrame.luau +100 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/VerticalSpacer.luau +35 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/VerticallyScalingListFrame.luau +107 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/GuiUtilities.luau +429 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/RbxGui.luau +4363 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/UI.luau +425 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/WebSocketClient.luau +161 -0
- package/src/build.ts +120 -0
- package/src/cli.ts +496 -0
- package/src/config.ts +170 -0
- package/src/fs/fileWriter.ts +414 -0
- package/src/fs/treeManager.ts +458 -0
- package/src/fs/watcher.ts +142 -0
- package/src/index.ts +450 -0
- package/src/ipc/httpPolling.ts +214 -0
- package/src/ipc/messages.ts +159 -0
- package/src/ipc/server.ts +196 -0
- package/src/pack.ts +309 -0
- package/src/push.ts +726 -0
- package/src/snapshot/rojo.ts +467 -0
- package/src/snapshot.ts +161 -0
- package/src/sourcemap/generator.ts +504 -0
- package/src/sourcemap/propertyLoader.ts +195 -0
- package/src/util/id.ts +15 -0
- package/src/util/log.ts +94 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { TreeNode } from "../fs/treeManager.js";
|
|
4
|
+
import { FileMapping } from "../fs/fileWriter.js";
|
|
5
|
+
import { log } from "../util/log.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Rojo-compatible sourcemap tree structure
|
|
9
|
+
*/
|
|
10
|
+
interface SourcemapNode {
|
|
11
|
+
name: string;
|
|
12
|
+
className: string;
|
|
13
|
+
guid?: string;
|
|
14
|
+
filePaths?: string[];
|
|
15
|
+
children?: SourcemapNode[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface SourcemapRoot {
|
|
19
|
+
name: string;
|
|
20
|
+
className: string;
|
|
21
|
+
children: SourcemapNode[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Generates Rojo-compatible sourcemap.json for luau-lsp
|
|
26
|
+
*/
|
|
27
|
+
export class SourcemapGenerator {
|
|
28
|
+
constructor() {}
|
|
29
|
+
|
|
30
|
+
private sortTreeNodes(nodes: Iterable<TreeNode>): TreeNode[] {
|
|
31
|
+
return Array.from(nodes).sort((a, b) => {
|
|
32
|
+
const nameCompare = a.name.localeCompare(b.name);
|
|
33
|
+
if (nameCompare !== 0) return nameCompare;
|
|
34
|
+
|
|
35
|
+
const classCompare = a.className.localeCompare(b.className);
|
|
36
|
+
if (classCompare !== 0) return classCompare;
|
|
37
|
+
|
|
38
|
+
return a.guid.localeCompare(b.guid);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private getDuplicatePaths(
|
|
43
|
+
nodes: Map<string, TreeNode>,
|
|
44
|
+
): { path: string[]; nodes: TreeNode[] }[] {
|
|
45
|
+
const buckets = new Map<string, TreeNode[]>();
|
|
46
|
+
|
|
47
|
+
for (const node of nodes.values()) {
|
|
48
|
+
const key = node.path.join("\u0001");
|
|
49
|
+
const bucket = buckets.get(key) ?? [];
|
|
50
|
+
bucket.push(node);
|
|
51
|
+
buckets.set(key, bucket);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const duplicates: { path: string[]; nodes: TreeNode[] }[] = [];
|
|
55
|
+
for (const [key, bucket] of buckets.entries()) {
|
|
56
|
+
if (bucket.length > 1) {
|
|
57
|
+
duplicates.push({ path: key.split("\u0001"), nodes: bucket });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return duplicates;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private findRootNode(nodes: Map<string, TreeNode>): TreeNode | null {
|
|
65
|
+
const root = nodes.get("root");
|
|
66
|
+
if (root) {
|
|
67
|
+
return root;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const node of nodes.values()) {
|
|
71
|
+
if (node.path.length === 0 && node.className === "DataModel") {
|
|
72
|
+
return node;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Incrementally upsert a subtree into the sourcemap, optionally removing the old path first.
|
|
81
|
+
* Falls back to full regeneration if anything goes wrong.
|
|
82
|
+
*/
|
|
83
|
+
public upsertSubtree(
|
|
84
|
+
node: TreeNode,
|
|
85
|
+
allNodes: Map<string, TreeNode>,
|
|
86
|
+
fileMappings: Map<string, FileMapping>,
|
|
87
|
+
outputPath: string,
|
|
88
|
+
oldPath?: string[],
|
|
89
|
+
isNew?: boolean,
|
|
90
|
+
): void {
|
|
91
|
+
const duplicates = this.getDuplicatePaths(allNodes);
|
|
92
|
+
if (duplicates.length > 0) {
|
|
93
|
+
log.debug(
|
|
94
|
+
`Duplicate instance paths detected (${duplicates.length}); proceeding with GUID-based incremental update`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const sourcemap = this.readOrCreateRoot(outputPath);
|
|
100
|
+
|
|
101
|
+
// If the node moved/renamed, prune the previous location
|
|
102
|
+
if (oldPath && !this.pathsMatch(oldPath, node.path)) {
|
|
103
|
+
this.removePath(sourcemap, oldPath, node.className, node.guid);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const newSubtree = this.buildNodeFromTree(node, fileMappings);
|
|
107
|
+
if (newSubtree) {
|
|
108
|
+
this.insertNodeAtPath(
|
|
109
|
+
sourcemap,
|
|
110
|
+
newSubtree,
|
|
111
|
+
node.path,
|
|
112
|
+
allNodes,
|
|
113
|
+
Boolean(isNew),
|
|
114
|
+
);
|
|
115
|
+
this.write(sourcemap, outputPath);
|
|
116
|
+
}
|
|
117
|
+
} catch (error) {
|
|
118
|
+
log.warn("Incremental sourcemap update failed, regenerating:", error);
|
|
119
|
+
this.generateAndWrite(allNodes, fileMappings, outputPath);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Generate complete sourcemap from tree and file mappings
|
|
125
|
+
*/
|
|
126
|
+
public generate(
|
|
127
|
+
nodes: Map<string, TreeNode>,
|
|
128
|
+
fileMappings: Map<string, FileMapping>,
|
|
129
|
+
): SourcemapRoot {
|
|
130
|
+
log.info("Generating sourcemap...");
|
|
131
|
+
log.debug(
|
|
132
|
+
`Total nodes: ${nodes.size}, File mappings: ${fileMappings.size}`,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const rootNode = this.findRootNode(nodes);
|
|
136
|
+
const serviceNodes = rootNode
|
|
137
|
+
? this.sortTreeNodes(rootNode.children.values())
|
|
138
|
+
: this.sortTreeNodes(
|
|
139
|
+
Array.from(nodes.values()).filter((node) => node.path.length === 1),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const visited = new Set<string>();
|
|
143
|
+
const children: SourcemapNode[] = [];
|
|
144
|
+
|
|
145
|
+
for (const serviceNode of serviceNodes) {
|
|
146
|
+
const built = this.buildNodeFromTree(
|
|
147
|
+
serviceNode,
|
|
148
|
+
fileMappings,
|
|
149
|
+
visited,
|
|
150
|
+
process.cwd(),
|
|
151
|
+
);
|
|
152
|
+
if (built) {
|
|
153
|
+
children.push(built);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const sourcemap: SourcemapRoot = {
|
|
158
|
+
name: "Game",
|
|
159
|
+
className: "DataModel",
|
|
160
|
+
children,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
log.success(`Sourcemap generated with ${children.length} root services`);
|
|
164
|
+
return sourcemap;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Write sourcemap to file
|
|
169
|
+
*/
|
|
170
|
+
public write(
|
|
171
|
+
sourcemap: SourcemapRoot,
|
|
172
|
+
outputPath: string = "sourcemap.json",
|
|
173
|
+
): void {
|
|
174
|
+
try {
|
|
175
|
+
// Ensure destination directory exists
|
|
176
|
+
const dir = path.dirname(outputPath);
|
|
177
|
+
if (dir && dir !== "." && !fs.existsSync(dir)) {
|
|
178
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const json = JSON.stringify(sourcemap, null, 2);
|
|
182
|
+
fs.writeFileSync(outputPath, json, "utf-8");
|
|
183
|
+
log.debug(`Sourcemap written to: ${outputPath}`);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
log.error("Failed to write sourcemap:", error);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Check if two paths match
|
|
191
|
+
*/
|
|
192
|
+
private pathsMatch(path1: string[], path2: string[]): boolean {
|
|
193
|
+
if (path1.length !== path2.length) return false;
|
|
194
|
+
return path1.every((segment, i) => segment === path2[i]);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Build a SourcemapNode from a TreeNode, recursively including children.
|
|
199
|
+
*/
|
|
200
|
+
private buildNodeFromTree(
|
|
201
|
+
node: TreeNode,
|
|
202
|
+
fileMappings: Map<string, FileMapping>,
|
|
203
|
+
visited: Set<string> = new Set(),
|
|
204
|
+
cwd = process.cwd(),
|
|
205
|
+
): SourcemapNode | null {
|
|
206
|
+
if (visited.has(node.guid)) {
|
|
207
|
+
log.debug(
|
|
208
|
+
`Detected cyclic path in sourcemap generation: ${node.path.join("/")}`,
|
|
209
|
+
);
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
visited.add(node.guid);
|
|
213
|
+
|
|
214
|
+
const result: SourcemapNode = {
|
|
215
|
+
name: node.name,
|
|
216
|
+
className: node.className,
|
|
217
|
+
guid: node.guid,
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const mapping = fileMappings.get(node.guid);
|
|
221
|
+
if (mapping) {
|
|
222
|
+
const relativePath = path.relative(cwd, mapping.filePath);
|
|
223
|
+
result.filePaths = [relativePath.replace(/\\/g, "/")];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const sortedChildren = this.sortTreeNodes(node.children.values());
|
|
227
|
+
const children: SourcemapNode[] = [];
|
|
228
|
+
for (const child of sortedChildren) {
|
|
229
|
+
const built = this.buildNodeFromTree(child, fileMappings, visited, cwd);
|
|
230
|
+
if (built) {
|
|
231
|
+
children.push(built);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (children.length > 0) {
|
|
236
|
+
result.children = children;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return result;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Read an existing sourcemap or create a new root.
|
|
244
|
+
*/
|
|
245
|
+
private readOrCreateRoot(outputPath: string): SourcemapRoot {
|
|
246
|
+
if (fs.existsSync(outputPath)) {
|
|
247
|
+
try {
|
|
248
|
+
const raw = fs.readFileSync(outputPath, "utf-8");
|
|
249
|
+
return JSON.parse(raw) as SourcemapRoot;
|
|
250
|
+
} catch (error) {
|
|
251
|
+
log.warn("Failed to read existing sourcemap, recreating:", error);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
name: "Game",
|
|
257
|
+
className: "DataModel",
|
|
258
|
+
children: [],
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Insert or replace a subtree at the given path, creating intermediate parents as needed.
|
|
264
|
+
*/
|
|
265
|
+
private insertNodeAtPath(
|
|
266
|
+
root: SourcemapRoot,
|
|
267
|
+
newNode: SourcemapNode,
|
|
268
|
+
pathSegments: string[],
|
|
269
|
+
allNodes: Map<string, TreeNode>,
|
|
270
|
+
isNewEntry: boolean,
|
|
271
|
+
): void {
|
|
272
|
+
if (pathSegments.length === 0) return;
|
|
273
|
+
|
|
274
|
+
let currentChildren = root.children;
|
|
275
|
+
|
|
276
|
+
for (let i = 0; i < pathSegments.length; i++) {
|
|
277
|
+
const segment = pathSegments[i];
|
|
278
|
+
const ancestorNode = this.findNodeByPath(
|
|
279
|
+
allNodes,
|
|
280
|
+
pathSegments.slice(0, i + 1),
|
|
281
|
+
);
|
|
282
|
+
const ancestorGuid = ancestorNode?.guid;
|
|
283
|
+
|
|
284
|
+
let existingIndex = ancestorGuid
|
|
285
|
+
? currentChildren.findIndex((n) => (n as any).guid === ancestorGuid)
|
|
286
|
+
: currentChildren.findIndex((n) => n.name === segment);
|
|
287
|
+
|
|
288
|
+
if (i === pathSegments.length - 1) {
|
|
289
|
+
const guidIndex = (newNode as any).guid
|
|
290
|
+
? currentChildren.findIndex(
|
|
291
|
+
(n) => (n as any).guid === (newNode as any).guid,
|
|
292
|
+
)
|
|
293
|
+
: -1;
|
|
294
|
+
|
|
295
|
+
if (guidIndex !== -1) {
|
|
296
|
+
currentChildren.splice(guidIndex, 1, newNode);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (isNewEntry) {
|
|
301
|
+
// Appending preserves siblings with identical names/classes from being merged
|
|
302
|
+
currentChildren.push(newNode);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
existingIndex = currentChildren.findIndex(
|
|
307
|
+
(n) => n.name === segment && n.className === newNode.className,
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
if (existingIndex !== -1) {
|
|
311
|
+
currentChildren.splice(existingIndex, 1, newNode);
|
|
312
|
+
} else {
|
|
313
|
+
currentChildren.push(newNode);
|
|
314
|
+
}
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (existingIndex === -1) {
|
|
319
|
+
const className = ancestorNode?.className ?? "Folder";
|
|
320
|
+
const placeholder: SourcemapNode = {
|
|
321
|
+
name: segment,
|
|
322
|
+
className,
|
|
323
|
+
guid: ancestorGuid,
|
|
324
|
+
children: [],
|
|
325
|
+
};
|
|
326
|
+
currentChildren.push(placeholder);
|
|
327
|
+
existingIndex = currentChildren.length - 1;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const holder = currentChildren[existingIndex];
|
|
331
|
+
if (!holder.children) {
|
|
332
|
+
holder.children = [];
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
currentChildren = holder.children;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private findNodeByPath(
|
|
340
|
+
nodes: Map<string, TreeNode>,
|
|
341
|
+
pathSegments: string[],
|
|
342
|
+
): TreeNode | undefined {
|
|
343
|
+
for (const node of nodes.values()) {
|
|
344
|
+
if (this.pathsMatch(node.path, pathSegments)) {
|
|
345
|
+
return node;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return undefined;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Generate and write sourcemap in one call
|
|
353
|
+
*/
|
|
354
|
+
public generateAndWrite(
|
|
355
|
+
nodes: Map<string, TreeNode>,
|
|
356
|
+
fileMappings: Map<string, FileMapping>,
|
|
357
|
+
outputPath: string = "sourcemap.json",
|
|
358
|
+
): void {
|
|
359
|
+
const sourcemap = this.generate(nodes, fileMappings);
|
|
360
|
+
this.write(sourcemap, outputPath);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Remove a node (and now-empty ancestors) from an existing sourcemap file by path.
|
|
365
|
+
* Falls back to full regeneration if the file is missing or malformed.
|
|
366
|
+
*/
|
|
367
|
+
public prunePath(
|
|
368
|
+
pathSegments: string[],
|
|
369
|
+
outputPath: string,
|
|
370
|
+
nodes: Map<string, TreeNode>,
|
|
371
|
+
fileMappings: Map<string, FileMapping>,
|
|
372
|
+
targetClassName?: string,
|
|
373
|
+
targetGuid?: string,
|
|
374
|
+
): boolean {
|
|
375
|
+
try {
|
|
376
|
+
if (!fs.existsSync(outputPath)) {
|
|
377
|
+
this.generateAndWrite(nodes, fileMappings, outputPath);
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const raw = fs.readFileSync(outputPath, "utf-8");
|
|
382
|
+
const json = JSON.parse(raw) as SourcemapRoot;
|
|
383
|
+
|
|
384
|
+
const removed = this.removePath(
|
|
385
|
+
json,
|
|
386
|
+
pathSegments,
|
|
387
|
+
targetClassName,
|
|
388
|
+
targetGuid,
|
|
389
|
+
);
|
|
390
|
+
if (removed) {
|
|
391
|
+
this.write(json, outputPath);
|
|
392
|
+
}
|
|
393
|
+
return removed;
|
|
394
|
+
} catch (error) {
|
|
395
|
+
log.warn("Prune failed, regenerating sourcemap:", error);
|
|
396
|
+
this.generateAndWrite(nodes, fileMappings, outputPath);
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Remove node matching path; prune empty parents.
|
|
403
|
+
*/
|
|
404
|
+
private removePath(
|
|
405
|
+
root: SourcemapRoot,
|
|
406
|
+
pathSegments: string[],
|
|
407
|
+
targetClassName?: string,
|
|
408
|
+
targetGuid?: string,
|
|
409
|
+
): boolean {
|
|
410
|
+
if (pathSegments.length === 0) return false;
|
|
411
|
+
|
|
412
|
+
const pruneRecursive = (
|
|
413
|
+
nodes: SourcemapNode[] | undefined,
|
|
414
|
+
idx: number,
|
|
415
|
+
): boolean => {
|
|
416
|
+
if (!nodes) return false;
|
|
417
|
+
const name = pathSegments[idx];
|
|
418
|
+
let nodeIndex = nodes.findIndex((n) => {
|
|
419
|
+
if (n.name !== name) return false;
|
|
420
|
+
if (idx === pathSegments.length - 1) {
|
|
421
|
+
if (targetGuid && (n as any).guid) {
|
|
422
|
+
return (n as any).guid === targetGuid;
|
|
423
|
+
}
|
|
424
|
+
if (targetClassName) {
|
|
425
|
+
return n.className === targetClassName;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return true;
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// Fallback to name-only match so we still prune even if class drifted
|
|
432
|
+
if (nodeIndex === -1 && idx === pathSegments.length - 1) {
|
|
433
|
+
if (targetGuid) {
|
|
434
|
+
nodeIndex = nodes.findIndex((n) => (n as any).guid === targetGuid);
|
|
435
|
+
}
|
|
436
|
+
if (nodeIndex === -1) {
|
|
437
|
+
nodeIndex = nodes.findIndex((n) => n.name === name);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (nodeIndex === -1) return false;
|
|
442
|
+
|
|
443
|
+
const node = nodes[nodeIndex];
|
|
444
|
+
|
|
445
|
+
if (idx === pathSegments.length - 1) {
|
|
446
|
+
// Remove the entire subtree
|
|
447
|
+
nodes.splice(nodeIndex, 1);
|
|
448
|
+
return true;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const removed = pruneRecursive(node.children, idx + 1);
|
|
452
|
+
|
|
453
|
+
// Clean up empty child containers
|
|
454
|
+
if (
|
|
455
|
+
removed &&
|
|
456
|
+
node.children &&
|
|
457
|
+
node.children.length === 0 &&
|
|
458
|
+
!node.filePaths
|
|
459
|
+
) {
|
|
460
|
+
nodes.splice(nodeIndex, 1);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return removed;
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
return pruneRecursive(root.children, 0);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Validate that all paths in sourcemap point to existing files
|
|
471
|
+
*/
|
|
472
|
+
public validate(sourcemap: SourcemapRoot): {
|
|
473
|
+
valid: boolean;
|
|
474
|
+
errors: string[];
|
|
475
|
+
} {
|
|
476
|
+
const errors: string[] = [];
|
|
477
|
+
|
|
478
|
+
const checkNode = (node: SourcemapNode) => {
|
|
479
|
+
if (node.filePaths) {
|
|
480
|
+
for (const filePath of node.filePaths) {
|
|
481
|
+
const fullPath = path.resolve(process.cwd(), filePath);
|
|
482
|
+
if (!fs.existsSync(fullPath)) {
|
|
483
|
+
errors.push(`Missing file: ${filePath}`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (node.children) {
|
|
489
|
+
for (const child of node.children) {
|
|
490
|
+
checkNode(child);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
for (const child of sourcemap.children) {
|
|
496
|
+
checkNode(child);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
valid: errors.length === 0,
|
|
501
|
+
errors,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { log } from "../util/log.js";
|
|
5
|
+
import type { InstanceData } from "../ipc/messages.js";
|
|
6
|
+
|
|
7
|
+
interface SourcemapNode {
|
|
8
|
+
name: string;
|
|
9
|
+
className: string;
|
|
10
|
+
guid?: string;
|
|
11
|
+
properties?: Record<string, unknown>;
|
|
12
|
+
attributes?: Record<string, unknown>;
|
|
13
|
+
children?: SourcemapNode[];
|
|
14
|
+
filePaths?: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface SourcemapRoot {
|
|
18
|
+
name: string;
|
|
19
|
+
className: string;
|
|
20
|
+
children: SourcemapNode[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SourcemapPropertyIndex {
|
|
24
|
+
byGuid: Map<string, SourcemapNode>;
|
|
25
|
+
byPathClass: Map<string, SourcemapNode[]>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const pathClassKey = (segments: string[], className: string): string =>
|
|
29
|
+
`${segments.join("\u0001")}::${className}`;
|
|
30
|
+
|
|
31
|
+
export function loadSourcemapPropertyIndex(
|
|
32
|
+
sourcemapPath: string,
|
|
33
|
+
): SourcemapPropertyIndex | null {
|
|
34
|
+
const resolved = path.resolve(sourcemapPath);
|
|
35
|
+
if (!fs.existsSync(resolved)) {
|
|
36
|
+
log.debug(`No sourcemap found at ${resolved}; skipping property merge.`);
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let root: SourcemapRoot;
|
|
41
|
+
try {
|
|
42
|
+
const raw = fs.readFileSync(resolved, "utf8");
|
|
43
|
+
root = JSON.parse(raw) as SourcemapRoot;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
log.warn(`Failed to read sourcemap at ${resolved}: ${error}`);
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const byGuid = new Map<string, SourcemapNode>();
|
|
50
|
+
const byPathClass = new Map<string, SourcemapNode[]>();
|
|
51
|
+
|
|
52
|
+
const visit = (node: SourcemapNode, currentPath: string[]) => {
|
|
53
|
+
const nodePath = [...currentPath, node.name];
|
|
54
|
+
|
|
55
|
+
if (node.guid) {
|
|
56
|
+
byGuid.set(node.guid, node);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const key = pathClassKey(nodePath, node.className);
|
|
60
|
+
const bucket = byPathClass.get(key) ?? [];
|
|
61
|
+
bucket.push(node);
|
|
62
|
+
byPathClass.set(key, bucket);
|
|
63
|
+
|
|
64
|
+
for (const child of node.children ?? []) {
|
|
65
|
+
visit(child, nodePath);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
for (const child of root.children ?? []) {
|
|
70
|
+
visit(child, []);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { byGuid, byPathClass };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function applySourcemapProperties(
|
|
77
|
+
instances: InstanceData[],
|
|
78
|
+
index: SourcemapPropertyIndex | null,
|
|
79
|
+
): number {
|
|
80
|
+
if (!index) return 0;
|
|
81
|
+
|
|
82
|
+
let applied = 0;
|
|
83
|
+
for (const instance of instances) {
|
|
84
|
+
const match = findNodeForInstance(instance, index);
|
|
85
|
+
if (!match) continue;
|
|
86
|
+
|
|
87
|
+
const hasProps =
|
|
88
|
+
match.properties && Object.keys(match.properties).length > 0;
|
|
89
|
+
const hasAttrs =
|
|
90
|
+
match.attributes && Object.keys(match.attributes).length > 0;
|
|
91
|
+
|
|
92
|
+
if (!hasProps && !hasAttrs) continue;
|
|
93
|
+
|
|
94
|
+
if (hasProps) {
|
|
95
|
+
instance.properties = match.properties;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (hasAttrs) {
|
|
99
|
+
instance.attributes = match.attributes;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
applied += 1;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return applied;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function buildInstancesFromSourcemap(
|
|
109
|
+
sourcemapPath: string,
|
|
110
|
+
): InstanceData[] | null {
|
|
111
|
+
const resolved = path.resolve(sourcemapPath);
|
|
112
|
+
if (!fs.existsSync(resolved)) {
|
|
113
|
+
log.warn(`Sourcemap not found at ${resolved}`);
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let root: SourcemapRoot;
|
|
118
|
+
try {
|
|
119
|
+
const raw = fs.readFileSync(resolved, "utf8");
|
|
120
|
+
root = JSON.parse(raw) as SourcemapRoot;
|
|
121
|
+
} catch (error) {
|
|
122
|
+
log.error(`Failed to parse sourcemap at ${resolved}: ${error}`);
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const results: InstanceData[] = [];
|
|
127
|
+
|
|
128
|
+
const visit = (
|
|
129
|
+
node: SourcemapNode,
|
|
130
|
+
currentPath: string[],
|
|
131
|
+
parentGuid?: string,
|
|
132
|
+
) => {
|
|
133
|
+
const nodePath = [...currentPath, node.name];
|
|
134
|
+
const guid = node.guid ?? randomUUID().replace(/-/g, "");
|
|
135
|
+
|
|
136
|
+
const instance: InstanceData = {
|
|
137
|
+
guid,
|
|
138
|
+
className: node.className,
|
|
139
|
+
name: node.name,
|
|
140
|
+
path: nodePath,
|
|
141
|
+
parentGuid,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
if (node.properties) instance.properties = node.properties;
|
|
145
|
+
if (node.attributes) instance.attributes = node.attributes;
|
|
146
|
+
|
|
147
|
+
const isScript =
|
|
148
|
+
node.className === "Script" ||
|
|
149
|
+
node.className === "LocalScript" ||
|
|
150
|
+
node.className === "ModuleScript";
|
|
151
|
+
|
|
152
|
+
if (isScript && node.filePaths && node.filePaths.length > 0) {
|
|
153
|
+
const scriptPath = path.resolve(process.cwd(), node.filePaths[0]);
|
|
154
|
+
try {
|
|
155
|
+
instance.source = fs.readFileSync(scriptPath, "utf8");
|
|
156
|
+
} catch (error) {
|
|
157
|
+
log.warn(
|
|
158
|
+
`Failed to read script file for ${nodePath.join("/")}: ${error}`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
results.push(instance);
|
|
164
|
+
|
|
165
|
+
for (const child of node.children ?? []) {
|
|
166
|
+
visit(child, nodePath, guid);
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
for (const child of root.children ?? []) {
|
|
171
|
+
visit(child, []);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
results.sort((a, b) => a.path.length - b.path.length);
|
|
175
|
+
return results;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function findNodeForInstance(
|
|
179
|
+
instance: InstanceData,
|
|
180
|
+
index: SourcemapPropertyIndex,
|
|
181
|
+
): SourcemapNode | null {
|
|
182
|
+
if (instance.guid) {
|
|
183
|
+
const byGuid = index.byGuid.get(instance.guid);
|
|
184
|
+
if (byGuid) return byGuid;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const key = pathClassKey(instance.path, instance.className);
|
|
188
|
+
const bucket = index.byPathClass.get(key);
|
|
189
|
+
if (!bucket || bucket.length === 0) return null;
|
|
190
|
+
|
|
191
|
+
if (bucket.length === 1) return bucket[0];
|
|
192
|
+
|
|
193
|
+
// Prefer a node that also carries a guid to reduce ambiguity
|
|
194
|
+
return bucket.find((node) => Boolean(node.guid)) ?? bucket[0];
|
|
195
|
+
}
|
package/src/util/id.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { randomBytes } from "crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate a unique GUID for tracking instances
|
|
5
|
+
*/
|
|
6
|
+
export function generateGUID(): string {
|
|
7
|
+
return randomBytes(16).toString("hex");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Validate GUID format
|
|
12
|
+
*/
|
|
13
|
+
export function isValidGUID(guid: string): boolean {
|
|
14
|
+
return /^[a-f0-9]{32}$/.test(guid);
|
|
15
|
+
}
|