dominus-cli 0.6.0 → 2.1.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.
@@ -1,725 +1,859 @@
1
- --[[
2
- Properties module: read/write instance properties
3
- Uses ReflectionService to determine expected types for smart coercion.
4
- ]]
5
-
6
- local Explorer = require(script.Parent.Explorer)
7
- local ReflectionService = game:GetService("ReflectionService")
8
-
9
- local Properties = {}
10
-
11
- local IGNORED_PROPERTIES = {
12
- Parent = true,
13
- ClassName = true,
14
- Archivable = true,
15
- }
16
-
17
- -- Properties that are read-only / computed / redundant — strip in compact mode
18
- local COMPACT_SKIP = {
19
- -- Read-only computed
20
- AbsolutePosition = true,
21
- AbsoluteSize = true,
22
- AbsoluteRotation = true,
23
- AbsoluteCanvasSize = true,
24
- TextBounds = true,
25
- TextFits = true,
26
- IsLoaded = true,
27
- ContentText = true,
28
- LocalizedText = true,
29
- GuiState = true,
30
- -- Deprecated / redundant (BrickColor duplicates Color3, Font duplicates FontFace)
31
- BackgroundColor = true,
32
- BorderColor = true,
33
- TextColor = true,
34
- Font = true,
35
- FontSize = true,
36
- -- Internal / security
37
- Capabilities = true,
38
- Sandboxed = true,
39
- archivable = true,
40
- className = true,
41
- Localize = true,
42
- -- Selection navigation (almost always nil/default)
43
- NextSelectionDown = true,
44
- NextSelectionLeft = true,
45
- NextSelectionRight = true,
46
- NextSelectionUp = true,
47
- SelectionBehaviorDown = true,
48
- SelectionBehaviorLeft = true,
49
- SelectionBehaviorRight = true,
50
- SelectionBehaviorUp = true,
51
- SelectionGroup = true,
52
- SelectionOrder = true,
53
- SelectionImageObject = true,
54
- -- Rarely useful defaults
55
- Draggable = true,
56
- InputSink = true,
57
- RootLocalizationTable = true,
58
- AutoLocalize = true,
59
- OpenTypeFeatures = true,
60
- OpenTypeFeaturesError = true,
61
- Interactable = true,
62
- Style = true,
63
- BorderMode = true,
64
- SizeConstraint = true,
65
- }
66
-
67
- -- Default values that are not worth reporting in compact mode
68
- local COMPACT_DEFAULT_VALUES = {
69
- ["0"] = true,
70
- ["1"] = true,
71
- ["false"] = true,
72
- ["nil"] = true,
73
- [""] = true,
74
- ["Enum.AutomaticSize.None"] = true,
75
- ["Enum.TextTruncate.None"] = true,
76
- ["Enum.TextDirection.Auto"] = true,
77
- ["Enum.ResamplerMode.Default"] = true,
78
- ["Enum.ScaleType.Stretch"] = true,
79
- }
80
-
81
- -- Cache property type maps per class to avoid repeated reflection calls
82
- local typeCache = {}
83
-
84
- local function getPropertyTypeMap(className)
85
- if typeCache[className] then
86
- return typeCache[className]
87
- end
88
-
89
- local typeMap = {}
90
- local ok, props = pcall(function()
91
- return ReflectionService:GetPropertiesOfClass(className)
92
- end)
93
- if ok and props then
94
- for _, prop in props do
95
- if prop.ValueType then
96
- typeMap[prop.Name] = tostring(prop.ValueType)
97
- end
98
- end
99
- end
100
- typeCache[className] = typeMap
101
- return typeMap
102
- end
103
-
104
- function Properties.get(path, compact)
105
- local instance = Explorer.resolveInstance(path)
106
- if not instance then
107
- return { success = false, error = "Instance not found: " .. path }
108
- end
109
-
110
- local properties = {}
111
-
112
- local ok, apiData = pcall(function()
113
- local info = {}
114
- local propData = ReflectionService:GetPropertiesOfClass(instance.ClassName)
115
- for _, prop in propData do
116
- if not IGNORED_PROPERTIES[prop.Name] then
117
- if compact and COMPACT_SKIP[prop.Name] then
118
- continue
119
- end
120
- local valueOk, value = pcall(function()
121
- return instance[prop.Name]
122
- end)
123
- if valueOk then
124
- local valueStr = tostring(value)
125
- if compact and valueStr == "nil" then
126
- continue
127
- end
128
- table.insert(info, {
129
- name = prop.Name,
130
- value = valueStr,
131
- type = prop.ValueType and tostring(prop.ValueType) or typeof(value),
132
- category = prop.Category or "Other",
133
- })
134
- end
135
- end
136
- end
137
- return info
138
- end)
139
-
140
- if ok then
141
- properties = apiData
142
- else
143
- local commonProps =
144
- { "Name", "Position", "Size", "Color", "Material", "Transparency", "Anchored", "CanCollide" }
145
- for _, propName in commonProps do
146
- local valOk, val = pcall(function()
147
- return instance[propName]
148
- end)
149
- if valOk and val ~= nil then
150
- table.insert(properties, {
151
- name = propName,
152
- value = tostring(val),
153
- type = typeof(val),
154
- })
155
- end
156
- end
157
- end
158
-
159
- return { properties = properties }
160
- end
161
-
162
- function Properties.getDescendants(path, compact)
163
- local rootInstance = Explorer.resolveInstance(path)
164
- if not rootInstance then
165
- return { success = false, error = "Instance not found: " .. path }
166
- end
167
-
168
- -- In compact mode, skip noisy/default properties to reduce payload size
169
- local shouldSkip = compact
170
- and function(propName, valueStr)
171
- if COMPACT_SKIP[propName] then
172
- return true
173
- end
174
- -- Skip nil-valued properties
175
- if valueStr == "nil" then
176
- return true
177
- end
178
- return false
179
- end
180
- or function()
181
- return false
182
- end
183
-
184
- -- Extra compact filter: skip properties that are clearly defaults and non-informative
185
- local isBoringDefault = compact
186
- and function(propName, valueStr)
187
- -- These specific properties at default values are noise
188
- local boringAtDefault = {
189
- Active = "false",
190
- Selectable = "false",
191
- ClipsDescendants = "false",
192
- TextWrap = "false",
193
- TextWrapped = "false",
194
- RichText = "false",
195
- TextScaled = "false",
196
- Rotation = "0",
197
- LayoutOrder = "0",
198
- MaxVisibleGraphemes = "-1",
199
- SliceScale = "1",
200
- LineHeight = "1",
201
- BorderSizePixel = "1",
202
- Visible = "true",
203
- }
204
- return boringAtDefault[propName] == valueStr
205
- end
206
- or function()
207
- return false
208
- end
209
-
210
- local function getProps(instance)
211
- local properties = {}
212
-
213
- local ok, apiData = pcall(function()
214
- local info = {}
215
- local propData = ReflectionService:GetPropertiesOfClass(instance.ClassName)
216
- for _, prop in propData do
217
- if not IGNORED_PROPERTIES[prop.Name] and not shouldSkip(prop.Name, "") then
218
- local valueOk, value = pcall(function()
219
- return instance[prop.Name]
220
- end)
221
- if valueOk then
222
- local valueStr = tostring(value)
223
- if not shouldSkip(prop.Name, valueStr) and not isBoringDefault(prop.Name, valueStr) then
224
- table.insert(info, {
225
- name = prop.Name,
226
- value = valueStr,
227
- type = prop.ValueType and tostring(prop.ValueType) or typeof(value),
228
- category = prop.Category or "Other",
229
- })
230
- end
231
- end
232
- end
233
- end
234
- return info
235
- end)
236
-
237
- if ok then
238
- properties = apiData
239
- else
240
- local commonProps =
241
- { "Name", "Position", "Size", "Color", "Material", "Transparency", "Anchored", "CanCollide" }
242
- for _, propName in commonProps do
243
- local valOk, val = pcall(function()
244
- return instance[propName]
245
- end)
246
- if valOk and val ~= nil then
247
- table.insert(properties, {
248
- name = propName,
249
- value = tostring(val),
250
- type = typeof(val),
251
- })
252
- end
253
- end
254
- end
255
-
256
- return properties
257
- end
258
-
259
- local results = {}
260
-
261
- -- Include the root instance itself
262
- table.insert(results, {
263
- name = rootInstance.Name,
264
- className = rootInstance.ClassName,
265
- path = Explorer.getPath(rootInstance),
266
- properties = getProps(rootInstance),
267
- })
268
-
269
- for _, descendant in rootInstance:GetDescendants() do
270
- table.insert(results, {
271
- name = descendant.Name,
272
- className = descendant.ClassName,
273
- path = Explorer.getPath(descendant),
274
- properties = getProps(descendant),
275
- })
276
- end
277
-
278
- return { success = true, descendants = results }
279
- end
280
-
281
- --[[
282
- Smart type coercion: converts JSON-friendly values into Roblox types.
283
- Uses the expected property type from ReflectionService when available.
284
- ]]
285
- local function coerceValue(instance, key, value)
286
- local expectedType = nil
287
- local typeMap = getPropertyTypeMap(instance.ClassName)
288
- expectedType = typeMap[key]
289
-
290
- -- If we don't know the expected type, try reading the current value
291
- if not expectedType then
292
- local curOk, curVal = pcall(function()
293
- return instance[key]
294
- end)
295
- if curOk and curVal ~= nil then
296
- expectedType = typeof(curVal)
297
- end
298
- end
299
-
300
- -- ═══════════════════════════════════════════
301
- -- UDim2 (GUI Size, Position, etc.)
302
- -- ═══════════════════════════════════════════
303
- if expectedType == "UDim2" then
304
- if type(value) == "table" then
305
- -- {xScale, xOffset, yScale, yOffset} or named keys
306
- if value.XScale or value.xScale or value.X then
307
- return UDim2.new(
308
- value.XScale or value.xScale or value.X and value.X.Scale or 0,
309
- value.XOffset or value.xOffset or value.X and value.X.Offset or 0,
310
- value.YScale or value.yScale or value.Y and value.Y.Scale or 0,
311
- value.YOffset or value.yOffset or value.Y and value.Y.Offset or 0
312
- )
313
- end
314
- -- {X = {Scale, Offset}, Y = {Scale, Offset}}
315
- if value.X and type(value.X) == "table" then
316
- return UDim2.new(
317
- value.X.Scale or value.X[1] or 0,
318
- value.X.Offset or value.X[2] or 0,
319
- value.Y and (value.Y.Scale or value.Y[1]) or 0,
320
- value.Y and (value.Y.Offset or value.Y[2]) or 0
321
- )
322
- end
323
- -- Array: {xScale, xOffset, yScale, yOffset}
324
- if value[1] ~= nil then
325
- return UDim2.new(value[1] or 0, value[2] or 0, value[3] or 0, value[4] or 0)
326
- end
327
- -- fromScale shorthand: {scale_x, scale_y} when only 2 elements
328
- return UDim2.new(0, 0, 0, 0)
329
- end
330
- if type(value) == "string" then
331
- -- "0.5, 0, 0.5, 0" format
332
- local a, b, c, d = value:match("([%d%.%-]+)%s*,%s*([%d%.%-]+)%s*,%s*([%d%.%-]+)%s*,%s*([%d%.%-]+)")
333
- if a then
334
- return UDim2.new(tonumber(a), tonumber(b), tonumber(c), tonumber(d))
335
- end
336
- end
337
- end
338
-
339
- -- ═══════════════════════════════════════════
340
- -- UDim
341
- -- ═══════════════════════════════════════════
342
- if expectedType == "UDim" then
343
- if type(value) == "table" then
344
- return UDim.new(value.Scale or value[1] or 0, value.Offset or value[2] or 0)
345
- end
346
- if type(value) == "number" then
347
- return UDim.new(value, 0)
348
- end
349
- end
350
-
351
- -- ═══════════════════════════════════════════
352
- -- Vector3
353
- -- ═══════════════════════════════════════════
354
- if expectedType == "Vector3" then
355
- if type(value) == "table" then
356
- return Vector3.new(
357
- value.X or value.x or value[1] or 0,
358
- value.Y or value.y or value[2] or 0,
359
- value.Z or value.z or value[3] or 0
360
- )
361
- end
362
- end
363
-
364
- -- ═══════════════════════════════════════════
365
- -- Vector2
366
- -- ═══════════════════════════════════════════
367
- if expectedType == "Vector2" then
368
- if type(value) == "table" then
369
- return Vector2.new(value.X or value.x or value[1] or 0, value.Y or value.y or value[2] or 0)
370
- end
371
- end
372
-
373
- -- ═══════════════════════════════════════════
374
- -- Color3
375
- -- ═══════════════════════════════════════════
376
- if expectedType == "Color3" then
377
- if type(value) == "string" then
378
- if value:sub(1, 1) == "#" then
379
- return Color3.fromHex(value)
380
- end
381
- if value:match("^rgb") then
382
- local r, g, b = value:match("(%d+)%D+(%d+)%D+(%d+)")
383
- if r then
384
- return Color3.fromRGB(tonumber(r), tonumber(g), tonumber(b))
385
- end
386
- end
387
- local ok, bc = pcall(BrickColor.new, value)
388
- if ok then
389
- return bc.Color
390
- end
391
- end
392
- if type(value) == "table" then
393
- -- {R, G, B} 0-1 or {r, g, b}
394
- if value.R or value.r then
395
- return Color3.new(value.R or value.r or 0, value.G or value.g or 0, value.B or value.b or 0)
396
- end
397
- -- {red, green, blue} 0-255
398
- if value.red or value.Red then
399
- return Color3.fromRGB(
400
- value.red or value.Red or 0,
401
- value.green or value.Green or 0,
402
- value.blue or value.Blue or 0
403
- )
404
- end
405
- -- Array [r, g, b]
406
- if value[1] then
407
- if value[1] > 1 or value[2] > 1 or value[3] > 1 then
408
- return Color3.fromRGB(value[1], value[2], value[3])
409
- end
410
- return Color3.new(value[1], value[2], value[3])
411
- end
412
- end
413
- end
414
-
415
- -- ═══════════════════════════════════════════
416
- -- BrickColor
417
- -- ═══════════════════════════════════════════
418
- if expectedType == "BrickColor" then
419
- if type(value) == "string" then
420
- return BrickColor.new(value)
421
- end
422
- if type(value) == "number" then
423
- return BrickColor.new(value)
424
- end
425
- end
426
-
427
- -- ═══════════════════════════════════════════
428
- -- CFrame
429
- -- ═══════════════════════════════════════════
430
- if expectedType == "CFrame" then
431
- if type(value) == "table" then
432
- if value.Position or value.position then
433
- local pos = value.Position or value.position
434
- local cf = CFrame.new(
435
- pos.x or pos.X or pos[1] or 0,
436
- pos.y or pos.Y or pos[2] or 0,
437
- pos.z or pos.Z or pos[3] or 0
438
- )
439
- if value.Rotation or value.rotation then
440
- local rot = value.Rotation or value.rotation
441
- cf = cf
442
- * CFrame.Angles(
443
- math.rad(rot.x or rot.X or rot[1] or 0),
444
- math.rad(rot.y or rot.Y or rot[2] or 0),
445
- math.rad(rot.z or rot.Z or rot[3] or 0)
446
- )
447
- end
448
- return cf
449
- end
450
- -- Simple {x, y, z}
451
- if value[1] then
452
- return CFrame.new(value[1], value[2] or 0, value[3] or 0)
453
- end
454
- end
455
- end
456
-
457
- -- ═══════════════════════════════════════════
458
- -- Rect (for GUI ImageRectOffset, etc.)
459
- -- ═══════════════════════════════════════════
460
- if expectedType == "Rect" then
461
- if type(value) == "table" then
462
- return Rect.new(
463
- value[1] or value.Min and value.Min[1] or 0,
464
- value[2] or value.Min and value.Min[2] or 0,
465
- value[3] or value.Max and value.Max[1] or 0,
466
- value[4] or value.Max and value.Max[2] or 0
467
- )
468
- end
469
- end
470
-
471
- -- ═══════════════════════════════════════════
472
- -- NumberRange
473
- -- ═══════════════════════════════════════════
474
- if expectedType == "NumberRange" then
475
- if type(value) == "table" then
476
- return NumberRange.new(value.Min or value[1] or 0, value.Max or value[2] or 1)
477
- end
478
- if type(value) == "number" then
479
- return NumberRange.new(value)
480
- end
481
- end
482
-
483
- -- ═══════════════════════════════════════════
484
- -- NumberSequence (for transparency gradients, etc.)
485
- -- ═══════════════════════════════════════════
486
- if expectedType == "NumberSequence" then
487
- if type(value) == "table" then
488
- if value[1] and type(value[1]) == "table" then
489
- local keypoints = {}
490
- for _, kp in value do
491
- table.insert(
492
- keypoints,
493
- NumberSequenceKeypoint.new(
494
- kp.Time or kp[1] or 0,
495
- kp.Value or kp[2] or 0,
496
- kp.Envelope or kp[3] or 0
497
- )
498
- )
499
- end
500
- return NumberSequence.new(keypoints)
501
- end
502
- if #value == 2 and type(value[1]) == "number" then
503
- return NumberSequence.new(value[1], value[2])
504
- end
505
- end
506
- if type(value) == "number" then
507
- return NumberSequence.new(value)
508
- end
509
- end
510
-
511
- -- ═══════════════════════════════════════════
512
- -- ColorSequence
513
- -- ═══════════════════════════════════════════
514
- if expectedType == "ColorSequence" then
515
- if type(value) == "table" then
516
- if value[1] and type(value[1]) == "table" then
517
- local keypoints = {}
518
- for _, kp in value do
519
- local color = Color3.new(1, 1, 1)
520
- if kp.Color then
521
- if type(kp.Color) == "string" and kp.Color:sub(1, 1) == "#" then
522
- color = Color3.fromHex(kp.Color)
523
- elseif type(kp.Color) == "table" then
524
- color = Color3.new(kp.Color[1] or 0, kp.Color[2] or 0, kp.Color[3] or 0)
525
- end
526
- end
527
- table.insert(keypoints, ColorSequenceKeypoint.new(kp.Time or kp[1] or 0, color))
528
- end
529
- return ColorSequence.new(keypoints)
530
- end
531
- end
532
- end
533
-
534
- -- ═══════════════════════════════════════════
535
- -- Font
536
- -- ═══════════════════════════════════════════
537
- if expectedType == "Font" then
538
- if type(value) == "table" then
539
- local family = value.Family or value.family or "rbxasset://fonts/families/SourceSansPro.json"
540
- local weight = Enum.FontWeight.Regular
541
- local style = Enum.FontStyle.Normal
542
- if value.Weight or value.weight then
543
- pcall(function()
544
- weight = Enum.FontWeight[value.Weight or value.weight]
545
- end)
546
- end
547
- if value.Style or value.style then
548
- pcall(function()
549
- style = Enum.FontStyle[value.Style or value.style]
550
- end)
551
- end
552
- return Font.new(family, weight, style)
553
- end
554
- if type(value) == "string" then
555
- return Font.new(value)
556
- end
557
- end
558
-
559
- -- ═══════════════════════════════════════════
560
- -- EnumItem (any enum property)
561
- -- ═══════════════════════════════════════════
562
- if type(value) == "string" then
563
- local currentVal = nil
564
- pcall(function()
565
- currentVal = instance[key]
566
- end)
567
- if typeof(currentVal) == "EnumItem" then
568
- local enumType = tostring(currentVal.EnumType)
569
- local enumOk, enumVal = pcall(function()
570
- return Enum[enumType][value]
571
- end)
572
- if enumOk then
573
- return enumVal
574
- end
575
- end
576
- end
577
-
578
- -- ═══════════════════════════════════════════
579
- -- Fallback: type-agnostic guesses (no expectedType known)
580
- -- ═══════════════════════════════════════════
581
- if type(value) == "table" and not expectedType then
582
- -- UDim2 guess: has XScale/YScale keys
583
- if value.XScale or value.xScale then
584
- return UDim2.new(
585
- value.XScale or value.xScale or 0,
586
- value.XOffset or value.xOffset or 0,
587
- value.YScale or value.yScale or 0,
588
- value.YOffset or value.yOffset or 0
589
- )
590
- end
591
- -- Vector3 guess: 3 number elements or X/Y/Z keys
592
- if (value.X or value.x) and (value.Y or value.y) and (value.Z or value.z) then
593
- return Vector3.new(value.X or value.x, value.Y or value.y, value.Z or value.z)
594
- end
595
- if value[1] and value[2] and value[3] and not value[4] then
596
- return Vector3.new(value[1], value[2], value[3])
597
- end
598
- -- Color3 guess: R/G/B keys
599
- if value.R or value.r then
600
- return Color3.new(value.R or value.r or 0, value.G or value.g or 0, value.B or value.b or 0)
601
- end
602
- -- Vector2 guess: 2 number elements
603
- if value[1] and value[2] and not value[3] then
604
- return Vector2.new(value[1], value[2])
605
- end
606
- end
607
-
608
- -- String-based color on color properties (fallback)
609
- if type(value) == "string" and (key == "Color" or key == "Color3" or key:match("Color$")) then
610
- if value:sub(1, 1) == "#" then
611
- return Color3.fromHex(value)
612
- end
613
- local ok, bc = pcall(BrickColor.new, value)
614
- if ok then
615
- return bc.Color
616
- end
617
- end
618
-
619
- if type(value) == "string" and key == "BrickColor" then
620
- return BrickColor.new(value)
621
- end
622
-
623
- return value
624
- end
625
-
626
- function Properties.set(path, propertiesToSet)
627
- local instance = Explorer.resolveInstance(path)
628
- if not instance then
629
- return { success = false, error = "Instance not found: " .. path }
630
- end
631
-
632
- local errors = {}
633
- local set = {}
634
-
635
- for key, value in propertiesToSet do
636
- local coerced = coerceValue(instance, key, value)
637
- local ok, err = pcall(function()
638
- instance[key] = coerced
639
- end)
640
- if ok then
641
- table.insert(set, key)
642
- else
643
- table.insert(errors, key .. ": " .. tostring(err))
644
- end
645
- end
646
-
647
- if #errors > 0 then
648
- return { success = false, error = table.concat(errors, "; "), set = set }
649
- end
650
-
651
- return { success = true, set = set }
652
- end
653
-
654
- function Properties.bulkSet(spec)
655
- local ChangeHistoryService = game:GetService("ChangeHistoryService")
656
- local recording = ChangeHistoryService:TryBeginRecording("Dominus: Bulk set properties")
657
-
658
- local modified = 0
659
- local errors = {}
660
-
661
- if spec.targets and type(spec.targets) == "table" then
662
- -- Explicit mode: array of {path, properties}
663
- for _, target in ipairs(spec.targets) do
664
- local instance = Explorer.resolveInstance(target.path)
665
- if not instance then
666
- table.insert(errors, "Not found: " .. tostring(target.path))
667
- else
668
- for key, value in target.properties do
669
- local coerced = coerceValue(instance, key, value)
670
- local ok, err = pcall(function()
671
- instance[key] = coerced
672
- end)
673
- if not ok then
674
- table.insert(errors, target.path .. "." .. key .. ": " .. tostring(err))
675
- end
676
- end
677
- modified = modified + 1
678
- end
679
- end
680
- elseif spec.root and spec.properties then
681
- -- Filter mode: apply to all matching descendants
682
- local root = Explorer.resolveInstance(spec.root)
683
- if not root then
684
- if recording then
685
- ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Cancel)
686
- end
687
- return { success = false, error = "Root not found: " .. tostring(spec.root) }
688
- end
689
-
690
- local function processInstance(inst)
691
- if spec.className and inst.ClassName ~= spec.className then
692
- return
693
- end
694
- for key, value in spec.properties do
695
- local coerced = coerceValue(inst, key, value)
696
- local ok, err = pcall(function()
697
- inst[key] = coerced
698
- end)
699
- if not ok then
700
- table.insert(errors, Explorer.getPath(inst) .. "." .. key .. ": " .. tostring(err))
701
- end
702
- end
703
- modified = modified + 1
704
- end
705
-
706
- -- Include root itself if it matches
707
- processInstance(root)
708
- for _, desc in root:GetDescendants() do
709
- processInstance(desc)
710
- end
711
- else
712
- if recording then
713
- ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Cancel)
714
- end
715
- return { success = false, error = "Provide either 'targets' array or 'root' + 'properties'" }
716
- end
717
-
718
- if recording then
719
- ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Commit)
720
- end
721
-
722
- return { success = true, modified = modified, errors = errors }
723
- end
724
-
725
- return Properties
1
+ --[[
2
+ Properties module: read/write instance properties
3
+ Uses ReflectionService to determine expected types for smart coercion.
4
+ ]]
5
+
6
+ local Explorer = require(script.Parent.Explorer)
7
+ local InstanceRegistry = require(script.Parent.InstanceRegistry)
8
+ local Reflection = require(script.Parent.Reflection)
9
+ local ValueCodec = require(script.Parent.ValueCodec)
10
+ local ReflectionService = game:GetService("ReflectionService")
11
+
12
+ local Properties = {}
13
+
14
+ local IGNORED_PROPERTIES = {
15
+ Parent = true,
16
+ ClassName = true,
17
+ Archivable = true,
18
+ }
19
+
20
+ -- Properties that are read-only / computed / redundant — strip in compact mode
21
+ local COMPACT_SKIP = {
22
+ -- Read-only computed
23
+ AbsolutePosition = true,
24
+ AbsoluteSize = true,
25
+ AbsoluteRotation = true,
26
+ AbsoluteCanvasSize = true,
27
+ TextBounds = true,
28
+ TextFits = true,
29
+ IsLoaded = true,
30
+ ContentText = true,
31
+ LocalizedText = true,
32
+ GuiState = true,
33
+ -- Deprecated / redundant (BrickColor duplicates Color3, Font duplicates FontFace)
34
+ BackgroundColor = true,
35
+ BorderColor = true,
36
+ TextColor = true,
37
+ Font = true,
38
+ FontSize = true,
39
+ -- Internal / security
40
+ Capabilities = true,
41
+ Sandboxed = true,
42
+ archivable = true,
43
+ className = true,
44
+ Localize = true,
45
+ -- Selection navigation (almost always nil/default)
46
+ NextSelectionDown = true,
47
+ NextSelectionLeft = true,
48
+ NextSelectionRight = true,
49
+ NextSelectionUp = true,
50
+ SelectionBehaviorDown = true,
51
+ SelectionBehaviorLeft = true,
52
+ SelectionBehaviorRight = true,
53
+ SelectionBehaviorUp = true,
54
+ SelectionGroup = true,
55
+ SelectionOrder = true,
56
+ SelectionImageObject = true,
57
+ -- Rarely useful defaults
58
+ Draggable = true,
59
+ InputSink = true,
60
+ RootLocalizationTable = true,
61
+ AutoLocalize = true,
62
+ OpenTypeFeatures = true,
63
+ OpenTypeFeaturesError = true,
64
+ Interactable = true,
65
+ Style = true,
66
+ BorderMode = true,
67
+ SizeConstraint = true,
68
+ }
69
+
70
+ -- Default values that are not worth reporting in compact mode
71
+ local COMPACT_DEFAULT_VALUES = {
72
+ ["0"] = true,
73
+ ["1"] = true,
74
+ ["false"] = true,
75
+ ["nil"] = true,
76
+ [""] = true,
77
+ ["Enum.AutomaticSize.None"] = true,
78
+ ["Enum.TextTruncate.None"] = true,
79
+ ["Enum.TextDirection.Auto"] = true,
80
+ ["Enum.ResamplerMode.Default"] = true,
81
+ ["Enum.ScaleType.Stretch"] = true,
82
+ }
83
+
84
+ -- Cache property type maps per class to avoid repeated reflection calls
85
+ local typeCache = {}
86
+
87
+ local function getPropertyTypeMap(className)
88
+ if typeCache[className] then
89
+ return typeCache[className]
90
+ end
91
+
92
+ local typeMap = {}
93
+ local ok, props = pcall(function()
94
+ return ReflectionService:GetPropertiesOfClass(className)
95
+ end)
96
+ if ok and props then
97
+ for _, prop in props do
98
+ if prop.Type then
99
+ typeMap[prop.Name] = tostring(prop.Type)
100
+ end
101
+ end
102
+ end
103
+ typeCache[className] = typeMap
104
+ return typeMap
105
+ end
106
+
107
+ function Properties.get(path, compact)
108
+ local instance = Explorer.resolveInstance(path)
109
+ if not instance then
110
+ return { success = false, error = "Instance not found: " .. path }
111
+ end
112
+
113
+ local properties = {}
114
+
115
+ local ok, apiData = pcall(function()
116
+ local info = {}
117
+ local propData = ReflectionService:GetPropertiesOfClass(instance.ClassName)
118
+ for _, prop in propData do
119
+ if not IGNORED_PROPERTIES[prop.Name] then
120
+ if compact and COMPACT_SKIP[prop.Name] then
121
+ continue
122
+ end
123
+ local valueOk, value = pcall(function()
124
+ return instance[prop.Name]
125
+ end)
126
+ if valueOk then
127
+ local valueStr = tostring(value)
128
+ if compact and valueStr == "nil" then
129
+ continue
130
+ end
131
+ table.insert(info, {
132
+ name = prop.Name,
133
+ value = valueStr,
134
+ type = prop.Type and tostring(prop.Type) or typeof(value),
135
+ category = prop.Display and prop.Display.Category or "Other",
136
+ })
137
+ end
138
+ end
139
+ end
140
+ return info
141
+ end)
142
+
143
+ if ok then
144
+ properties = apiData
145
+ else
146
+ local commonProps =
147
+ { "Name", "Position", "Size", "Color", "Material", "Transparency", "Anchored", "CanCollide" }
148
+ for _, propName in commonProps do
149
+ local valOk, val = pcall(function()
150
+ return instance[propName]
151
+ end)
152
+ if valOk and val ~= nil then
153
+ table.insert(properties, {
154
+ name = propName,
155
+ value = tostring(val),
156
+ type = typeof(val),
157
+ })
158
+ end
159
+ end
160
+ end
161
+
162
+ return { properties = properties }
163
+ end
164
+
165
+ function Properties.getDescendants(path, compact)
166
+ local rootInstance = Explorer.resolveInstance(path)
167
+ if not rootInstance then
168
+ return { success = false, error = "Instance not found: " .. path }
169
+ end
170
+
171
+ -- In compact mode, skip noisy/default properties to reduce payload size
172
+ local shouldSkip = compact
173
+ and function(propName, valueStr)
174
+ if COMPACT_SKIP[propName] then
175
+ return true
176
+ end
177
+ -- Skip nil-valued properties
178
+ if valueStr == "nil" then
179
+ return true
180
+ end
181
+ return false
182
+ end
183
+ or function()
184
+ return false
185
+ end
186
+
187
+ -- Extra compact filter: skip properties that are clearly defaults and non-informative
188
+ local isBoringDefault = compact
189
+ and function(propName, valueStr)
190
+ -- These specific properties at default values are noise
191
+ local boringAtDefault = {
192
+ Active = "false",
193
+ Selectable = "false",
194
+ ClipsDescendants = "false",
195
+ TextWrap = "false",
196
+ TextWrapped = "false",
197
+ RichText = "false",
198
+ TextScaled = "false",
199
+ Rotation = "0",
200
+ LayoutOrder = "0",
201
+ MaxVisibleGraphemes = "-1",
202
+ SliceScale = "1",
203
+ LineHeight = "1",
204
+ BorderSizePixel = "1",
205
+ Visible = "true",
206
+ }
207
+ return boringAtDefault[propName] == valueStr
208
+ end
209
+ or function()
210
+ return false
211
+ end
212
+
213
+ local function getProps(instance)
214
+ local properties = {}
215
+
216
+ local ok, apiData = pcall(function()
217
+ local info = {}
218
+ local propData = ReflectionService:GetPropertiesOfClass(instance.ClassName)
219
+ for _, prop in propData do
220
+ if not IGNORED_PROPERTIES[prop.Name] and not shouldSkip(prop.Name, "") then
221
+ local valueOk, value = pcall(function()
222
+ return instance[prop.Name]
223
+ end)
224
+ if valueOk then
225
+ local valueStr = tostring(value)
226
+ if not shouldSkip(prop.Name, valueStr) and not isBoringDefault(prop.Name, valueStr) then
227
+ table.insert(info, {
228
+ name = prop.Name,
229
+ value = valueStr,
230
+ type = prop.Type and tostring(prop.Type) or typeof(value),
231
+ category = prop.Display and prop.Display.Category or "Other",
232
+ })
233
+ end
234
+ end
235
+ end
236
+ end
237
+ return info
238
+ end)
239
+
240
+ if ok then
241
+ properties = apiData
242
+ else
243
+ local commonProps =
244
+ { "Name", "Position", "Size", "Color", "Material", "Transparency", "Anchored", "CanCollide" }
245
+ for _, propName in commonProps do
246
+ local valOk, val = pcall(function()
247
+ return instance[propName]
248
+ end)
249
+ if valOk and val ~= nil then
250
+ table.insert(properties, {
251
+ name = propName,
252
+ value = tostring(val),
253
+ type = typeof(val),
254
+ })
255
+ end
256
+ end
257
+ end
258
+
259
+ return properties
260
+ end
261
+
262
+ local results = {}
263
+
264
+ -- Include the root instance itself
265
+ table.insert(results, {
266
+ name = rootInstance.Name,
267
+ className = rootInstance.ClassName,
268
+ path = Explorer.getPath(rootInstance),
269
+ properties = getProps(rootInstance),
270
+ })
271
+
272
+ for _, descendant in rootInstance:GetDescendants() do
273
+ table.insert(results, {
274
+ name = descendant.Name,
275
+ className = descendant.ClassName,
276
+ path = Explorer.getPath(descendant),
277
+ properties = getProps(descendant),
278
+ })
279
+ end
280
+
281
+ return { success = true, descendants = results }
282
+ end
283
+
284
+ --[[
285
+ Smart type coercion: converts JSON-friendly values into Roblox types.
286
+ Uses the expected property type from ReflectionService when available.
287
+ ]]
288
+ local function coerceValue(instance, key, value)
289
+ local expectedType = nil
290
+ local typeMap = getPropertyTypeMap(instance.ClassName)
291
+ expectedType = typeMap[key]
292
+
293
+ -- If we don't know the expected type, try reading the current value
294
+ if not expectedType then
295
+ local curOk, curVal = pcall(function()
296
+ return instance[key]
297
+ end)
298
+ if curOk and curVal ~= nil then
299
+ expectedType = typeof(curVal)
300
+ end
301
+ end
302
+
303
+ -- ═══════════════════════════════════════════
304
+ -- UDim2 (GUI Size, Position, etc.)
305
+ -- ═══════════════════════════════════════════
306
+ if expectedType == "UDim2" then
307
+ if type(value) == "table" then
308
+ -- {xScale, xOffset, yScale, yOffset} or named keys
309
+ if value.XScale or value.xScale or value.X then
310
+ return UDim2.new(
311
+ value.XScale or value.xScale or value.X and value.X.Scale or 0,
312
+ value.XOffset or value.xOffset or value.X and value.X.Offset or 0,
313
+ value.YScale or value.yScale or value.Y and value.Y.Scale or 0,
314
+ value.YOffset or value.yOffset or value.Y and value.Y.Offset or 0
315
+ )
316
+ end
317
+ -- {X = {Scale, Offset}, Y = {Scale, Offset}}
318
+ if value.X and type(value.X) == "table" then
319
+ return UDim2.new(
320
+ value.X.Scale or value.X[1] or 0,
321
+ value.X.Offset or value.X[2] or 0,
322
+ value.Y and (value.Y.Scale or value.Y[1]) or 0,
323
+ value.Y and (value.Y.Offset or value.Y[2]) or 0
324
+ )
325
+ end
326
+ -- Array: {xScale, xOffset, yScale, yOffset}
327
+ if value[1] ~= nil then
328
+ return UDim2.new(value[1] or 0, value[2] or 0, value[3] or 0, value[4] or 0)
329
+ end
330
+ -- fromScale shorthand: {scale_x, scale_y} when only 2 elements
331
+ return UDim2.new(0, 0, 0, 0)
332
+ end
333
+ if type(value) == "string" then
334
+ -- "0.5, 0, 0.5, 0" format
335
+ local a, b, c, d = value:match("([%d%.%-]+)%s*,%s*([%d%.%-]+)%s*,%s*([%d%.%-]+)%s*,%s*([%d%.%-]+)")
336
+ if a then
337
+ return UDim2.new(tonumber(a), tonumber(b), tonumber(c), tonumber(d))
338
+ end
339
+ end
340
+ end
341
+
342
+ -- ═══════════════════════════════════════════
343
+ -- UDim
344
+ -- ═══════════════════════════════════════════
345
+ if expectedType == "UDim" then
346
+ if type(value) == "table" then
347
+ return UDim.new(value.Scale or value[1] or 0, value.Offset or value[2] or 0)
348
+ end
349
+ if type(value) == "number" then
350
+ return UDim.new(value, 0)
351
+ end
352
+ end
353
+
354
+ -- ═══════════════════════════════════════════
355
+ -- Vector3
356
+ -- ═══════════════════════════════════════════
357
+ if expectedType == "Vector3" then
358
+ if type(value) == "table" then
359
+ return Vector3.new(
360
+ value.X or value.x or value[1] or 0,
361
+ value.Y or value.y or value[2] or 0,
362
+ value.Z or value.z or value[3] or 0
363
+ )
364
+ end
365
+ end
366
+
367
+ -- ═══════════════════════════════════════════
368
+ -- Vector2
369
+ -- ═══════════════════════════════════════════
370
+ if expectedType == "Vector2" then
371
+ if type(value) == "table" then
372
+ return Vector2.new(value.X or value.x or value[1] or 0, value.Y or value.y or value[2] or 0)
373
+ end
374
+ end
375
+
376
+ -- ═══════════════════════════════════════════
377
+ -- Color3
378
+ -- ═══════════════════════════════════════════
379
+ if expectedType == "Color3" then
380
+ if type(value) == "string" then
381
+ if value:sub(1, 1) == "#" then
382
+ return Color3.fromHex(value)
383
+ end
384
+ if value:match("^rgb") then
385
+ local r, g, b = value:match("(%d+)%D+(%d+)%D+(%d+)")
386
+ if r then
387
+ return Color3.fromRGB(tonumber(r), tonumber(g), tonumber(b))
388
+ end
389
+ end
390
+ local ok, bc = pcall(BrickColor.new, value)
391
+ if ok then
392
+ return bc.Color
393
+ end
394
+ end
395
+ if type(value) == "table" then
396
+ -- {R, G, B} 0-1 or {r, g, b}
397
+ if value.R or value.r then
398
+ return Color3.new(value.R or value.r or 0, value.G or value.g or 0, value.B or value.b or 0)
399
+ end
400
+ -- {red, green, blue} 0-255
401
+ if value.red or value.Red then
402
+ return Color3.fromRGB(
403
+ value.red or value.Red or 0,
404
+ value.green or value.Green or 0,
405
+ value.blue or value.Blue or 0
406
+ )
407
+ end
408
+ -- Array [r, g, b]
409
+ if value[1] then
410
+ if value[1] > 1 or value[2] > 1 or value[3] > 1 then
411
+ return Color3.fromRGB(value[1], value[2], value[3])
412
+ end
413
+ return Color3.new(value[1], value[2], value[3])
414
+ end
415
+ end
416
+ end
417
+
418
+ -- ═══════════════════════════════════════════
419
+ -- BrickColor
420
+ -- ═══════════════════════════════════════════
421
+ if expectedType == "BrickColor" then
422
+ if type(value) == "string" then
423
+ return BrickColor.new(value)
424
+ end
425
+ if type(value) == "number" then
426
+ return BrickColor.new(value)
427
+ end
428
+ end
429
+
430
+ -- ═══════════════════════════════════════════
431
+ -- CFrame
432
+ -- ═══════════════════════════════════════════
433
+ if expectedType == "CFrame" then
434
+ if type(value) == "table" then
435
+ if value.Position or value.position then
436
+ local pos = value.Position or value.position
437
+ local cf = CFrame.new(
438
+ pos.x or pos.X or pos[1] or 0,
439
+ pos.y or pos.Y or pos[2] or 0,
440
+ pos.z or pos.Z or pos[3] or 0
441
+ )
442
+ if value.Rotation or value.rotation then
443
+ local rot = value.Rotation or value.rotation
444
+ cf = cf
445
+ * CFrame.Angles(
446
+ math.rad(rot.x or rot.X or rot[1] or 0),
447
+ math.rad(rot.y or rot.Y or rot[2] or 0),
448
+ math.rad(rot.z or rot.Z or rot[3] or 0)
449
+ )
450
+ end
451
+ return cf
452
+ end
453
+ -- Simple {x, y, z}
454
+ if value[1] then
455
+ return CFrame.new(value[1], value[2] or 0, value[3] or 0)
456
+ end
457
+ end
458
+ end
459
+
460
+ -- ═══════════════════════════════════════════
461
+ -- Rect (for GUI ImageRectOffset, etc.)
462
+ -- ═══════════════════════════════════════════
463
+ if expectedType == "Rect" then
464
+ if type(value) == "table" then
465
+ return Rect.new(
466
+ value[1] or value.Min and value.Min[1] or 0,
467
+ value[2] or value.Min and value.Min[2] or 0,
468
+ value[3] or value.Max and value.Max[1] or 0,
469
+ value[4] or value.Max and value.Max[2] or 0
470
+ )
471
+ end
472
+ end
473
+
474
+ -- ═══════════════════════════════════════════
475
+ -- NumberRange
476
+ -- ═══════════════════════════════════════════
477
+ if expectedType == "NumberRange" then
478
+ if type(value) == "table" then
479
+ return NumberRange.new(value.Min or value[1] or 0, value.Max or value[2] or 1)
480
+ end
481
+ if type(value) == "number" then
482
+ return NumberRange.new(value)
483
+ end
484
+ end
485
+
486
+ -- ═══════════════════════════════════════════
487
+ -- NumberSequence (for transparency gradients, etc.)
488
+ -- ═══════════════════════════════════════════
489
+ if expectedType == "NumberSequence" then
490
+ if type(value) == "table" then
491
+ if value[1] and type(value[1]) == "table" then
492
+ local keypoints = {}
493
+ for _, kp in value do
494
+ table.insert(
495
+ keypoints,
496
+ NumberSequenceKeypoint.new(
497
+ kp.Time or kp[1] or 0,
498
+ kp.Value or kp[2] or 0,
499
+ kp.Envelope or kp[3] or 0
500
+ )
501
+ )
502
+ end
503
+ return NumberSequence.new(keypoints)
504
+ end
505
+ if #value == 2 and type(value[1]) == "number" then
506
+ return NumberSequence.new(value[1], value[2])
507
+ end
508
+ end
509
+ if type(value) == "number" then
510
+ return NumberSequence.new(value)
511
+ end
512
+ end
513
+
514
+ -- ═══════════════════════════════════════════
515
+ -- ColorSequence
516
+ -- ═══════════════════════════════════════════
517
+ if expectedType == "ColorSequence" then
518
+ if type(value) == "table" then
519
+ if value[1] and type(value[1]) == "table" then
520
+ local keypoints = {}
521
+ for _, kp in value do
522
+ local color = Color3.new(1, 1, 1)
523
+ if kp.Color then
524
+ if type(kp.Color) == "string" and kp.Color:sub(1, 1) == "#" then
525
+ color = Color3.fromHex(kp.Color)
526
+ elseif type(kp.Color) == "table" then
527
+ color = Color3.new(kp.Color[1] or 0, kp.Color[2] or 0, kp.Color[3] or 0)
528
+ end
529
+ end
530
+ table.insert(keypoints, ColorSequenceKeypoint.new(kp.Time or kp[1] or 0, color))
531
+ end
532
+ return ColorSequence.new(keypoints)
533
+ end
534
+ end
535
+ end
536
+
537
+ -- ═══════════════════════════════════════════
538
+ -- Font
539
+ -- ═══════════════════════════════════════════
540
+ if expectedType == "Font" then
541
+ if type(value) == "table" then
542
+ local family = value.Family or value.family or "rbxasset://fonts/families/SourceSansPro.json"
543
+ local weight = Enum.FontWeight.Regular
544
+ local style = Enum.FontStyle.Normal
545
+ if value.Weight or value.weight then
546
+ pcall(function()
547
+ weight = Enum.FontWeight[value.Weight or value.weight]
548
+ end)
549
+ end
550
+ if value.Style or value.style then
551
+ pcall(function()
552
+ style = Enum.FontStyle[value.Style or value.style]
553
+ end)
554
+ end
555
+ return Font.new(family, weight, style)
556
+ end
557
+ if type(value) == "string" then
558
+ return Font.new(value)
559
+ end
560
+ end
561
+
562
+ -- ═══════════════════════════════════════════
563
+ -- EnumItem (any enum property)
564
+ -- ═══════════════════════════════════════════
565
+ if type(value) == "string" then
566
+ local currentVal = nil
567
+ pcall(function()
568
+ currentVal = instance[key]
569
+ end)
570
+ if typeof(currentVal) == "EnumItem" then
571
+ local enumType = tostring(currentVal.EnumType)
572
+ local enumOk, enumVal = pcall(function()
573
+ return Enum[enumType][value]
574
+ end)
575
+ if enumOk then
576
+ return enumVal
577
+ end
578
+ end
579
+ end
580
+
581
+ -- ═══════════════════════════════════════════
582
+ -- Fallback: type-agnostic guesses (no expectedType known)
583
+ -- ═══════════════════════════════════════════
584
+ if type(value) == "table" and not expectedType then
585
+ -- UDim2 guess: has XScale/YScale keys
586
+ if value.XScale or value.xScale then
587
+ return UDim2.new(
588
+ value.XScale or value.xScale or 0,
589
+ value.XOffset or value.xOffset or 0,
590
+ value.YScale or value.yScale or 0,
591
+ value.YOffset or value.yOffset or 0
592
+ )
593
+ end
594
+ -- Vector3 guess: 3 number elements or X/Y/Z keys
595
+ if (value.X or value.x) and (value.Y or value.y) and (value.Z or value.z) then
596
+ return Vector3.new(value.X or value.x, value.Y or value.y, value.Z or value.z)
597
+ end
598
+ if value[1] and value[2] and value[3] and not value[4] then
599
+ return Vector3.new(value[1], value[2], value[3])
600
+ end
601
+ -- Color3 guess: R/G/B keys
602
+ if value.R or value.r then
603
+ return Color3.new(value.R or value.r or 0, value.G or value.g or 0, value.B or value.b or 0)
604
+ end
605
+ -- Vector2 guess: 2 number elements
606
+ if value[1] and value[2] and not value[3] then
607
+ return Vector2.new(value[1], value[2])
608
+ end
609
+ end
610
+
611
+ -- String-based color on color properties (fallback)
612
+ if type(value) == "string" and (key == "Color" or key == "Color3" or key:match("Color$")) then
613
+ if value:sub(1, 1) == "#" then
614
+ return Color3.fromHex(value)
615
+ end
616
+ local ok, bc = pcall(BrickColor.new, value)
617
+ if ok then
618
+ return bc.Color
619
+ end
620
+ end
621
+
622
+ if type(value) == "string" and key == "BrickColor" then
623
+ return BrickColor.new(value)
624
+ end
625
+
626
+ return value
627
+ end
628
+
629
+ function Properties.set(path, propertiesToSet)
630
+ local instance = Explorer.resolveInstance(path)
631
+ if not instance then
632
+ return { success = false, error = "Instance not found: " .. path }
633
+ end
634
+
635
+ local errors = {}
636
+ local set = {}
637
+
638
+ for key, value in propertiesToSet do
639
+ local coerced = coerceValue(instance, key, value)
640
+ local ok, err = pcall(function()
641
+ instance[key] = coerced
642
+ end)
643
+ if ok then
644
+ table.insert(set, key)
645
+ else
646
+ table.insert(errors, key .. ": " .. tostring(err))
647
+ end
648
+ end
649
+
650
+ if #errors > 0 then
651
+ return { success = false, error = table.concat(errors, "; "), set = set }
652
+ end
653
+
654
+ return { success = true, set = set }
655
+ end
656
+
657
+ function Properties.bulkSet(spec)
658
+ local ChangeHistoryService = game:GetService("ChangeHistoryService")
659
+ local recording = ChangeHistoryService:TryBeginRecording("Dominus: Bulk set properties")
660
+
661
+ local modified = 0
662
+ local errors = {}
663
+
664
+ if spec.targets and type(spec.targets) == "table" then
665
+ -- Explicit mode: array of {path, properties}
666
+ for _, target in ipairs(spec.targets) do
667
+ local instance = Explorer.resolveInstance(target.path)
668
+ if not instance then
669
+ table.insert(errors, "Not found: " .. tostring(target.path))
670
+ else
671
+ for key, value in target.properties do
672
+ local coerced = coerceValue(instance, key, value)
673
+ local ok, err = pcall(function()
674
+ instance[key] = coerced
675
+ end)
676
+ if not ok then
677
+ table.insert(errors, target.path .. "." .. key .. ": " .. tostring(err))
678
+ end
679
+ end
680
+ modified = modified + 1
681
+ end
682
+ end
683
+ elseif spec.root and (spec.properties or spec.addChildren) then
684
+ -- Filter mode: apply to all matching descendants
685
+ local root = Explorer.resolveInstance(spec.root)
686
+ if not root then
687
+ if recording then
688
+ ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Cancel)
689
+ end
690
+ return { success = false, error = "Root not found: " .. tostring(spec.root) }
691
+ end
692
+
693
+ local function processInstance(inst)
694
+ if spec.className and inst.ClassName ~= spec.className then
695
+ return
696
+ end
697
+ if spec.properties then
698
+ for key, value in spec.properties do
699
+ local coerced = coerceValue(inst, key, value)
700
+ local ok, err = pcall(function()
701
+ inst[key] = coerced
702
+ end)
703
+ if not ok then
704
+ table.insert(errors, Explorer.getPath(inst) .. "." .. key .. ": " .. tostring(err))
705
+ end
706
+ end
707
+ end
708
+ -- addChildren: insert new child instances (e.g. UIStroke, UICorner)
709
+ if spec.addChildren and type(spec.addChildren) == "table" then
710
+ for _, childSpec in ipairs(spec.addChildren) do
711
+ local childClass = childSpec.ClassName or childSpec.className
712
+ if childClass then
713
+ -- Skip if child of this class already exists (idempotent)
714
+ local skipIfExists = childSpec.skipIfExists ~= false
715
+ if skipIfExists then
716
+ local existing = inst:FindFirstChildOfClass(childClass)
717
+ if existing then
718
+ continue
719
+ end
720
+ end
721
+ local ok2, child = pcall(Instance.new, childClass)
722
+ if ok2 and child then
723
+ if childSpec.Name or childSpec.name then
724
+ child.Name = childSpec.Name or childSpec.name
725
+ end
726
+ -- Set props on the new child
727
+ local props = childSpec.properties
728
+ or childSpec.Properties
729
+ or childSpec.props
730
+ or childSpec.Props
731
+ or {}
732
+ -- Also check flattened keys
733
+ for k, v in childSpec do
734
+ if
735
+ k ~= "ClassName"
736
+ and k ~= "className"
737
+ and k ~= "Name"
738
+ and k ~= "name"
739
+ and k ~= "properties"
740
+ and k ~= "Properties"
741
+ and k ~= "props"
742
+ and k ~= "Props"
743
+ and k ~= "skipIfExists"
744
+ and k ~= "Children"
745
+ and k ~= "children"
746
+ then
747
+ props[k] = v
748
+ end
749
+ end
750
+ for pk, pv in props do
751
+ local coerced = coerceValue(child, pk, pv)
752
+ pcall(function()
753
+ child[pk] = coerced
754
+ end)
755
+ end
756
+ child.Parent = inst
757
+ else
758
+ table.insert(
759
+ errors,
760
+ Explorer.getPath(inst) .. ": failed to create " .. tostring(childClass)
761
+ )
762
+ end
763
+ end
764
+ end
765
+ end
766
+ modified = modified + 1
767
+ end
768
+
769
+ -- Include root itself if it matches
770
+ processInstance(root)
771
+ for _, desc in root:GetDescendants() do
772
+ processInstance(desc)
773
+ end
774
+ else
775
+ if recording then
776
+ ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Cancel)
777
+ end
778
+ return { success = false, error = "Provide either 'targets' array or 'root' + 'properties'/'addChildren'" }
779
+ end
780
+
781
+ if recording then
782
+ ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Commit)
783
+ end
784
+
785
+ return { success = true, modified = modified, errors = errors }
786
+ end
787
+
788
+ function Properties.inspectV2(spec)
789
+ if type(spec.targets) ~= "table" or #spec.targets == 0 then
790
+ return { success = false, error = "targets must contain at least one instance reference" }
791
+ end
792
+ if #spec.targets > 20 then
793
+ return { success = false, error = "At most 20 instances can be inspected at once" }
794
+ end
795
+ local compact = spec.compact ~= false
796
+ local results = {}
797
+
798
+ for index, targetRef in spec.targets do
799
+ local instance, err = InstanceRegistry.resolve(targetRef)
800
+ if not instance then
801
+ table.insert(results, { success = false, index = index, error = err })
802
+ continue
803
+ end
804
+
805
+ local metadata = Reflection.getPropertyMetadata(instance.ClassName)
806
+ local propertyNames = {}
807
+ for propertyName in metadata do
808
+ if not IGNORED_PROPERTIES[propertyName] and (not compact or not COMPACT_SKIP[propertyName]) then
809
+ table.insert(propertyNames, propertyName)
810
+ end
811
+ end
812
+ table.sort(propertyNames)
813
+
814
+ local properties = {}
815
+ for _, propertyName in propertyNames do
816
+ local readOk, value = pcall(function()
817
+ return instance[propertyName]
818
+ end)
819
+ if readOk and value ~= nil then
820
+ table.insert(properties, {
821
+ name = propertyName,
822
+ type = metadata[propertyName].type,
823
+ category = metadata[propertyName].category,
824
+ writable = metadata[propertyName].writable,
825
+ value = ValueCodec.encode(value),
826
+ })
827
+ end
828
+ end
829
+
830
+ local children = instance:GetChildren()
831
+ table.sort(children, function(a, b)
832
+ if a.Name == b.Name then
833
+ return a.ClassName < b.ClassName
834
+ end
835
+ return a.Name < b.Name
836
+ end)
837
+ local childRefs = {}
838
+ for _, child in children do
839
+ table.insert(childRefs, {
840
+ name = child.Name,
841
+ className = child.ClassName,
842
+ ref = InstanceRegistry.toRef(child),
843
+ })
844
+ end
845
+
846
+ table.insert(results, {
847
+ success = true,
848
+ name = instance.Name,
849
+ className = instance.ClassName,
850
+ ref = InstanceRegistry.toRef(instance),
851
+ properties = properties,
852
+ children = childRefs,
853
+ })
854
+ end
855
+
856
+ return { success = true, results = results }
857
+ end
858
+
859
+ return Properties