@el4cteo/rbx-studio-mcp 0.6.8 → 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.
@@ -1,685 +1,690 @@
1
- --!strict
2
- --[[
3
- Hierarchy exploration: tree, inspect and find.
4
-
5
- These three replace roughly a dozen tools in competing servers
6
- (get_file_tree, get_project_structure, get_instance_children, search_objects,
7
- search_by_property, get_attributes, get_tags, get_tagged, get_class_info...).
8
- Fewer, wider tools mean less schema in the agent's context and fewer chances
9
- to pick the wrong one.
10
-
11
- Traversal is bounded everywhere. A production place can hold hundreds of
12
- thousands of instances, and an unbounded walk would either time out or return
13
- something no context window can hold.
14
- ]]
15
-
16
- local CollectionService = game:GetService("CollectionService")
17
-
18
- local Dispatch = require(script.Parent.Parent.Dispatch)
19
- local Paths = require(script.Parent.Parent.Paths)
20
- local Scope = require(script.Parent.Parent.Scope)
21
- local Serialize = require(script.Parent.Parent.Serialize)
22
-
23
- -- Ceiling on nodes visited in one call, independent of how many are returned.
24
- -- Protects against a `find` over a whole place blocking Studio's main thread.
25
- local MAX_VISITS = 200_000
26
-
27
- local Discover = {}
28
-
29
- -- Root-level noise filtering lives in Scope, which the script tools share.
30
- local isNoisy = Scope.isNoisy
31
-
32
- -- One memo per request keeps path formatting linear when many siblings share a
33
- -- name; see Paths.of.
34
- local function summarise(instance: Instance, memo: Paths.NameIndex): { [string]: any }
35
- return {
36
- path = Paths.of(instance, memo),
37
- className = instance.ClassName,
38
- childCount = #instance:GetChildren(),
39
- }
40
- end
41
-
42
- --[[
43
- Puts a result list into an order that does not change between calls.
44
-
45
- `GetChildren` and `GetDescendants` do NOT return a stable order. Measured on
46
- twelve parts named P01..P12 with nothing mutating between the two calls, the
47
- engine returned them in ascending order once and as P08, P06, P04, P03, P11,
48
- P02, P01, P12, P10, P07, P05, P09 the next time.
49
-
50
- Unsorted, that made paging incoherent rather than merely untidy: `offset` is
51
- a position in this list, so page two came from a different ordering than page
52
- one, which silently skips some instances and repeats others. Nothing about
53
- the response would have said so -- the counts stay right and every row is a
54
- real instance.
55
-
56
- Sorted by name, with the engine's per-instance debug id breaking ties so
57
- same-named siblings hold a fixed order too. Name first because it is also
58
- the order a reader expects: P01 before P02.
59
- ]]
60
- local function stableOrder(list: { Instance })
61
- local ids: { [Instance]: string } = {}
62
- for _, instance in list do
63
- -- Cached: `GetDebugId` is a call per comparison otherwise, and a sort
64
- -- makes O(n log n) of them.
65
- local ok, id = pcall(function()
66
- return instance:GetDebugId()
67
- end)
68
- ids[instance] = if ok then id else tostring(instance)
69
- end
70
- table.sort(list, function(a: Instance, b: Instance): boolean
71
- if a.Name ~= b.Name then
72
- return a.Name < b.Name
73
- end
74
- return (ids[a] or "") < (ids[b] or "")
75
- end)
76
- end
77
-
78
- --[[
79
- Compares a property against the text a caller asked for.
80
-
81
- This was an exact `tostring` match, which made `find` disagree with `modify`
82
- about its own notation: `modify` documents an enum as either "Neon" or
83
- "Enum.Material.Neon", but only the qualified form matched here, and "False"
84
- matched nothing at all. Both came back as a confident zero rather than an
85
- error, which is the hardest kind of wrong answer to notice.
86
-
87
- The bare-name shortcut is allowed only for enums, so a part named
88
- "Map.Backup" is not matched by a search for "Backup".
89
- ]]
90
- local function valueMatches(actual: any, wanted: string): boolean
91
- local text = tostring(actual)
92
- if text == wanted then
93
- return true
94
- end
95
-
96
- local lowered = string.lower(text)
97
- local target = string.lower(wanted)
98
- if lowered == target then
99
- return true
100
- end
101
-
102
- if string.sub(lowered, 1, 5) == "enum." then
103
- return string.match(lowered, "([^%.]+)$") == target
104
- end
105
- return false
106
- end
107
-
108
- --[[
109
- Breadth-first walk to `depth`, newest level last, so a truncated result is
110
- still a coherent picture of the top of the tree rather than one deep spur.
111
- ]]
112
- function Discover.tree(params: { [string]: any }): { [string]: any }
113
- local root = if params.path then Paths.resolve(params.path) else game
114
- local depth = tonumber(params.depth) or 2
115
- local limit = tonumber(params.limit) or 100
116
- local offset = tonumber(params.offset) or 0
117
- local classFilter = params.className
118
- local nameFilter = if params.nameContains then string.lower(params.nameContains) else nil
119
-
120
- local matched: { Instance } = {}
121
- local hidden = 0
122
- local visits = 0
123
- local frontier: { Instance } = { root }
124
-
125
- for level = 1, depth do
126
- local nextFrontier: { Instance } = {}
127
- for _, parent in frontier do
128
- for _, child in parent:GetChildren() do
129
- visits += 1
130
- if visits > MAX_VISITS then
131
- break
132
- end
133
- if isNoisy(child) then
134
- if parent == game then
135
- hidden += 1
136
- end
137
- continue
138
- end
139
-
140
- local keep = true
141
- if classFilter and not child:IsA(classFilter) then
142
- keep = false
143
- end
144
- if keep and nameFilter and not string.find(string.lower(child.Name), nameFilter, 1, true) then
145
- keep = false
146
- end
147
- if keep then
148
- table.insert(matched, child)
149
- end
150
- if level < depth then
151
- table.insert(nextFrontier, child)
152
- end
153
- end
154
- end
155
- -- Sorted per level rather than at the end, so the breadth-first shape is
156
- -- kept -- shallow instances still come before deep ones -- while the
157
- -- siblings inside each level stop reshuffling between calls.
158
- stableOrder(nextFrontier)
159
- frontier = nextFrontier
160
- if #frontier == 0 or visits > MAX_VISITS then
161
- break
162
- end
163
- end
164
-
165
- stableOrder(matched)
166
-
167
- local items: { { [string]: any } } = {}
168
- local memo: Paths.NameIndex = {}
169
- for index = offset + 1, math.min(offset + limit, #matched) do
170
- table.insert(items, summarise(matched[index] :: Instance, memo))
171
- end
172
-
173
- return {
174
- root = Paths.of(root, memo),
175
- items = items,
176
- total = #matched,
177
- offset = offset,
178
- hiddenServices = hidden,
179
- }
180
- end
181
-
182
- --[[
183
- Detailed read of specific instances. `properties` is chosen server-side from
184
- the live API dump, so this handler never needs its own class table.
185
- ]]
186
- --[[
187
- The physical facts about a part, which no property panel shows.
188
-
189
- "Why does this fall over", "why does it sink", "why does the door fly off
190
- when you touch it" are all mass questions, and mass is not a property you can
191
- read in Studio -- it is computed from volume and material and appears
192
- nowhere. A 4-stud cube of Metal weighs nine times the same cube of Neon, and
193
- nothing on screen distinguishes them.
194
-
195
- `AssemblyRootPart` is the other half. Parts welded together move as one body
196
- with one combined mass, and which part leads that body decides how the whole
197
- thing behaves. Two parts that look joined but report different assembly roots
198
- are not joined at all, which is the usual answer to "why did half of it stay
199
- behind".
200
- ]]
201
- local function physicsOf(target: Instance): { [string]: any }?
202
- if not target:IsA("BasePart") then
203
- return nil
204
- end
205
- local part = target :: BasePart
206
-
207
- local facts: { [string]: any } = {
208
- anchored = part.Anchored,
209
- canCollide = part.CanCollide,
210
- massless = part.Massless,
211
- material = tostring(part.Material):gsub("Enum%.Material%.", ""),
212
- }
213
-
214
- -- Every read is guarded separately: an anchored part has no assembly, and a
215
- -- part mid-destruction can refuse any of these without the others failing.
216
- pcall(function()
217
- facts.mass = math.round(part:GetMass() * 1000) / 1000
218
- end)
219
- --[[
220
- Infinity is a real answer here, and JSON cannot carry it.
221
-
222
- An anchored part has infinite assembly mass -- that is the engine saying
223
- "nothing will ever move this", which is exactly the fact someone asking
224
- about mass wants. Encoded raw it came back as
225
- `{"m":null,"t":"numeric","v":"inf"}`, which is the serialiser's honest
226
- attempt at a number JSON has no room for, and is unreadable to everybody.
227
- Said in words instead.
228
- ]]
229
- pcall(function()
230
- local assembly = part.AssemblyMass
231
- if assembly == math.huge then
232
- facts.assemblyMass = "infinite -- anchored, so nothing moves it"
233
- else
234
- facts.assemblyMass = math.round(assembly * 1000) / 1000
235
- end
236
- end)
237
- pcall(function()
238
- local root = part.AssemblyRootPart
239
- if root == nil then
240
- --[[
241
- No assembly at all, which is not the same as being welded to
242
- something.
243
-
244
- Only parts inside Workspace are simulated; one sitting in
245
- StarterPack, ReplicatedStorage or ServerStorage has no assembly
246
- and `AssemblyRootPart` is nil. The first version read that nil as
247
- "the root is not me" and reported `movesAlone = false`, which says
248
- this part is attached to another -- about a part that is not in
249
- the physics world at all. A pistol in StarterPack read as welded
250
- to something.
251
- ]]
252
- facts.simulated = false
253
- facts.note = "Not in Workspace, so it has no physics assembly. Mass and "
254
- .. "density are still real; anything about how it moves is not."
255
- return
256
- end
257
- facts.simulated = true
258
- facts.assemblyRoot = Paths.of(root)
259
- --[[
260
- Whether this part leads its own body. A part that is its own assembly
261
- root is moving alone; one whose root is elsewhere is welded to
262
- something, and the root names what.
263
- ]]
264
- facts.movesAlone = root == part
265
- end)
266
- pcall(function()
267
- --[[
268
- Where the WHOLE body balances, in world coordinates.
269
-
270
- `CenterOfMass` is the part's own, in its own space, and for any
271
- ordinary part it is (0, 0, 0) -- it was reported for every part in
272
- the place and said nothing about any of them. The figure that
273
- answers a question is the assembly's: a plank at x = 0 on a fulcrum
274
- at x = 0, with a weight bolted near one end, balances at x = 7, and
275
- that number is the entire explanation of why it tips. Measured on
276
- exactly that see-saw.
277
-
278
- The local one is kept only when it is not the origin, which means
279
- somebody set it deliberately and it is worth seeing.
280
- ]]
281
- facts.centerOfMass = Serialize.value(part.AssemblyCenterOfMass)
282
- local own = part.CenterOfMass
283
- if own.Magnitude > 0.001 then
284
- facts.centerOfMassOffset = Serialize.value(own)
285
- end
286
- end)
287
- pcall(function()
288
- local velocity = part.AssemblyLinearVelocity.Magnitude
289
- if velocity > 0.01 then
290
- facts.speed = math.round(velocity * 100) / 100
291
- end
292
- end)
293
- --[[
294
- Density from whichever source is actually in force.
295
-
296
- An earlier version read `PhysicalProperties.new(part.Material)` whenever
297
- custom properties existed, which reported the MATERIAL's density while the
298
- part was using the override -- the one number in this block that would
299
- have been confidently wrong. Measured: a Metal part reads 7.85 by
300
- material and weighs 75.36, and the same part with a custom density of 0.3
301
- weighs 2.88. Reporting 7.85 for the second one explains nothing about a
302
- part that floats.
303
-
304
- `CustomPhysicalProperties` is nil when unset, so its presence is the test
305
- for which source applies.
306
- ]]
307
- pcall(function()
308
- local custom = part.CustomPhysicalProperties
309
- if custom ~= nil then
310
- facts.density = math.round(custom.Density * 1000) / 1000
311
- facts.densityFrom = "CustomPhysicalProperties"
312
- else
313
- facts.density = math.round(PhysicalProperties.new(part.Material).Density * 1000) / 1000
314
- facts.densityFrom = "material"
315
- end
316
- end)
317
-
318
- return facts
319
- end
320
-
321
- function Discover.inspect(params: { [string]: any }): { [string]: any }
322
- local paths = params.paths
323
- if typeof(paths) ~= "table" or #paths == 0 then
324
- Dispatch.fail(
325
- "BAD_PARAMS",
326
- "inspect requires a non-empty `paths` array.",
327
- 'Pass paths like ["Workspace.Model.Part"]. Use `find` or `tree` to discover them.'
328
- )
329
- end
330
-
331
- local requested: { string }? = params.properties
332
- local includeChildren = params.includeChildren ~= false
333
- local childLimit = tonumber(params.childLimit) or 25
334
-
335
- local results: { { [string]: any } } = {}
336
- local failures: { string } = {}
337
- local memo: Paths.NameIndex = {}
338
-
339
- for _, path in paths do
340
- local ok, instance = pcall(Paths.resolve, path)
341
- if not ok then
342
- local err = instance :: any
343
- -- The hint carries the sibling listing ("It does have: ..."), which is
344
- -- what lets the agent correct the path without another round trip.
345
- local reason = if typeof(err) == "table"
346
- then (if err.hint then err.message .. " " .. err.hint else err.message)
347
- else tostring(err)
348
- table.insert(failures, string.format("%s: %s", path, reason))
349
- continue
350
- end
351
-
352
- local target = instance :: Instance
353
- local properties: { [string]: any } = {}
354
- if requested then
355
- for _, name in requested do
356
- local readOk, value = Serialize.readProperty(target, name)
357
- if readOk then
358
- properties[name] = value
359
- end
360
- end
361
- end
362
-
363
- local entry: { [string]: any } = {
364
- path = Paths.of(target, memo),
365
- -- Echoed back so a caller can correlate the answer with what it asked
366
- -- for. `path` is the canonical form and often differs: ask about
367
- -- "Workspace.Wall" and the answer comes back as "Workspace.Wall[1]".
368
- requested = path,
369
- className = target.ClassName,
370
- properties = properties,
371
- childCount = #target:GetChildren(),
372
- }
373
-
374
- -- Empty attribute and tag sets are omitted rather than sent as empty
375
- -- containers: most instances have neither, and Luau encodes an empty
376
- -- table as [] which reads as a list and confuses the shape.
377
- local attributes: { [string]: any } = {}
378
- local hasAttributes = false
379
- for name, value in target:GetAttributes() do
380
- attributes[name] = Serialize.value(value)
381
- hasAttributes = true
382
- end
383
- if hasAttributes then
384
- entry.attributes = attributes
385
- end
386
-
387
- local tags = CollectionService:GetTags(target)
388
- if #tags > 0 then
389
- entry.tags = tags
390
- end
391
-
392
- if params.physics == true then
393
- entry.physics = physicsOf(target)
394
- end
395
-
396
- if includeChildren then
397
- local children: { { [string]: any } } = {}
398
- for index, child in target:GetChildren() do
399
- if index > childLimit then
400
- break
401
- end
402
- table.insert(children, { name = child.Name, className = child.ClassName })
403
- end
404
- entry.children = children
405
- end
406
-
407
- table.insert(results, entry)
408
- end
409
-
410
- return { items = results, failures = failures }
411
- end
412
-
413
- --[[
414
- One search over name, class, property value and tag.
415
-
416
- Every filter supplied must match (AND), which is what lets a single tool
417
- answer "every anchored Part under Workspace whose name contains 'door'"
418
- without the agent chaining three calls and intersecting the results itself.
419
- ]]
420
- --[[
421
- Runs a selector through the engine's own matcher.
422
-
423
- `QueryDescendants` is a CSS-like query built into Instance, and it is doing
424
- the same job the loop below does -- except in C++, over the whole subtree, in
425
- one call. Measured on a 3700-instance place: "Part" answered 189 matches
426
- without a single Luau iteration.
427
-
428
- What it understands, confirmed against a live session rather than guessed:
429
-
430
- Part class name, superclasses included
431
- #Baseplate exact name
432
- [Anchored=true] property equality, on its own or after a class
433
- Part, Model either
434
- Model > Part direct children of a Model
435
- Model >> Part descendants of a Model
436
-
437
- What it does not: substring names, and comparisons like `>` or `<` (it
438
- answers "'=' expected after property name"). That is why this is one
439
- candidate source among three rather than a replacement for the filters --
440
- `nameContains` still needs the loop, and composes with a selector.
441
-
442
- A malformed selector raises from inside the engine with a readable reason, so
443
- it is caught and re-raised as BAD_PARAMS with the reason kept: "Pseudo-class
444
- 'first' filter is not supported" tells the caller exactly what to drop.
445
- ]]
446
- local function query(root: Instance, selector: string): { Instance }
447
- local ok, result = pcall(function()
448
- return root:QueryDescendants(selector)
449
- end)
450
- if not ok then
451
- Dispatch.fail(
452
- "BAD_PARAMS",
453
- string.format("selector %q was rejected: %s", selector, tostring(result)),
454
- "Selectors support a class name, #Name, [Property=Value], `A, B`, "
455
- .. "`A > B` for direct children and `A >> B` for descendants. They do "
456
- .. "not support substring names or < > comparisons -- use "
457
- .. "`nameContains` and `propertyValue` for those."
458
- )
459
- end
460
-
461
- --[[
462
- Deduplicated, because a union can match the same instance twice.
463
-
464
- `Script, LocalScript` looks like two disjoint sets and is not: LocalScript
465
- inherits Script, so every LocalScript satisfies both halves and the engine
466
- returns it once per half. Measured -- a place with 7 scripts answered 14
467
- matches, every one of them listed twice, and the count was as wrong as the
468
- listing.
469
-
470
- The engine is not doing anything unreasonable here; a union is a union.
471
- But "how many are there" is the question a caller is usually asking, and
472
- an answer that doubles depending on how the class hierarchy happens to be
473
- shaped is not an answer.
474
- ]]
475
- local matches = result :: { Instance }
476
- local seen: { [Instance]: boolean } = {}
477
- local unique: { Instance } = {}
478
- for _, instance in matches do
479
- if not seen[instance] then
480
- seen[instance] = true
481
- table.insert(unique, instance)
482
- end
483
- end
484
- return unique
485
- end
486
-
487
- --[[
488
- Which tags this place actually uses.
489
-
490
- `find` can filter by tag and could never tell you which tags exist, so using
491
- it meant guessing a name: a search for "Doors" that returns nothing is
492
- indistinguishable from a place whose doors are tagged "Door". On a place
493
- nobody has seen before that is the difference between reading how the game is
494
- organised in one call and not being able to ask the question at all -- tags
495
- like Enemy, Checkpoint or Interactable name a game's systems better than its
496
- folder layout does.
497
-
498
- Counted per tag, because an empty tag and a busy one mean different things: a
499
- tag with nothing on it is usually left over from something deleted, and worth
500
- saying so rather than listing beside the real ones.
501
- ]]
502
-
503
- --[[
504
- Studio's own tags, which are in every place and belong to none of them.
505
-
506
- The tag registry is shared with Studio's interface, so an unfiltered listing
507
- is mostly `data-testid=--studio-foundation--stylesheet-wrapper` and its
508
- relatives -- measured, 7 tags in a place that uses 5. They are recognisable
509
- by prefix rather than by a list, since the set changes with Studio's own UI
510
- and hardcoding today's names would silently rot.
511
- ]]
512
- local function studioOwned(tag: string): boolean
513
- return string.sub(tag, 1, 12) == "data-testid="
514
- end
515
-
516
- function Discover.tags(params: { [string]: any }): { [string]: any }
517
- local root = if typeof(params.path) == "string" and params.path ~= ""
518
- then Paths.resolve(params.path)
519
- else nil
520
-
521
- local rows: { { [string]: any } } = {}
522
- local hidden = 0
523
-
524
- for _, tag in CollectionService:GetAllTags() do
525
- if studioOwned(tag) then
526
- hidden += 1
527
- continue
528
- end
529
-
530
- local tagged = CollectionService:GetTagged(tag)
531
- local count = 0
532
- local sample: { string } = {}
533
-
534
- for _, instance in tagged do
535
- --[[
536
- Scoped the same way `find` scopes, when a path is given. A tag is
537
- global, so "which tags are used in Workspace.Map" is a different
538
- question from "which tags exist", and both get asked.
539
- ]]
540
- if root ~= nil and not instance:IsDescendantOf(root) then
541
- continue
542
- end
543
- if Scope.isNoisy(instance) then
544
- continue
545
- end
546
- count += 1
547
- if #sample < 3 then
548
- table.insert(sample, Paths.of(instance))
549
- end
550
- end
551
-
552
- if root ~= nil and count == 0 then
553
- continue
554
- end
555
-
556
- table.insert(rows, {
557
- tag = tag,
558
- count = count,
559
- -- A few paths, not all of them: the point is to recognise what the tag
560
- -- is for, and three examples do that where two hundred would not.
561
- sample = sample,
562
- })
563
- end
564
-
565
- table.sort(rows, function(a, b)
566
- if a.count == b.count then
567
- return tostring(a.tag) < tostring(b.tag)
568
- end
569
- return (a.count :: number) > (b.count :: number)
570
- end)
571
-
572
- return {
573
- tags = rows,
574
- count = #rows,
575
- scope = if root ~= nil then Paths.of(root) else nil,
576
- studioTagsHidden = hidden,
577
- }
578
- end
579
-
580
- function Discover.find(params: { [string]: any }): { [string]: any }
581
- local root = if params.path then Paths.resolve(params.path) else game
582
- local limit = tonumber(params.limit) or 100
583
- local offset = tonumber(params.offset) or 0
584
-
585
- local nameFilter = if params.nameContains then string.lower(params.nameContains) else nil
586
- local classFilter = params.className
587
- local propertyName = params.propertyName
588
- local propertyValue = params.propertyValue
589
- local tagFilter = params.tag
590
- local selector = if typeof(params.selector) == "string" and params.selector ~= ""
591
- then params.selector
592
- else nil
593
-
594
- --[[
595
- Three ways to produce candidates, cheapest first.
596
-
597
- The filters below are the same in every case -- this only chooses where
598
- the list to filter comes from, which is the part that decides whether a
599
- search on a large place is fast or times out.
600
-
601
- * A tag query reads CollectionService's index instead of walking the
602
- tree, which is orders of magnitude cheaper.
603
- * A selector hands the whole match to `QueryDescendants`, so the walk
604
- happens in C++ and only survivors cross into Luau.
605
- * Otherwise the tree is walked here.
606
- ]]
607
- local candidates: { Instance }
608
- if tagFilter then
609
- candidates = CollectionService:GetTagged(tagFilter)
610
- elseif selector then
611
- candidates = query(root, selector :: string)
612
- else
613
- candidates = root:GetDescendants()
614
- end
615
-
616
- if #candidates > MAX_VISITS then
617
- Dispatch.fail(
618
- "TOO_BROAD",
619
- string.format(
620
- "That search would visit %d instances, over the %d limit.",
621
- #candidates,
622
- MAX_VISITS
623
- ),
624
- "Narrow it with `path` to search one service or model instead of the whole "
625
- .. "place, or pass a `selector` -- that filters inside the engine, so the "
626
- .. "count here is what survived rather than what was visited."
627
- )
628
- end
629
-
630
- local matched: { Instance } = {}
631
- for _, instance in candidates do
632
- if tagFilter and root ~= game and not instance:IsDescendantOf(root) then
633
- continue
634
- end
635
- if isNoisy(instance) then
636
- continue
637
- end
638
- if classFilter and not instance:IsA(classFilter) then
639
- continue
640
- end
641
- if nameFilter and not string.find(string.lower(instance.Name), nameFilter, 1, true) then
642
- continue
643
- end
644
- if propertyName then
645
- local readOk, value = Serialize.readProperty(instance, propertyName)
646
- if not readOk then
647
- continue
648
- end
649
- if propertyValue ~= nil and not valueMatches(value, tostring(propertyValue)) then
650
- continue
651
- end
652
- end
653
- table.insert(matched, instance)
654
- end
655
-
656
- stableOrder(matched)
657
-
658
- local items: { { [string]: any } } = {}
659
- local memo: Paths.NameIndex = {}
660
- for index = offset + 1, math.min(offset + limit, #matched) do
661
- table.insert(items, summarise(matched[index] :: Instance, memo))
662
- end
663
-
664
- return {
665
- items = items,
666
- total = #matched,
667
- offset = offset,
668
- searched = #candidates,
669
- -- Echoed so a caller can see the selector was understood. A typo in a
670
- -- selector is not an error -- it matches nothing -- and "0 results" reads
671
- -- the same either way without this.
672
- selector = selector,
673
- }
674
- end
675
-
676
- function Discover.register()
677
- Dispatch.registerAll("discover", {
678
- tags = Discover.tags,
679
- tree = Discover.tree,
680
- inspect = Discover.inspect,
681
- find = Discover.find,
682
- })
683
- end
684
-
685
- return Discover
1
+ --!strict
2
+ --[[
3
+ Hierarchy exploration: tree, inspect and find.
4
+
5
+ These three replace roughly a dozen tools in competing servers
6
+ (get_file_tree, get_project_structure, get_instance_children, search_objects,
7
+ search_by_property, get_attributes, get_tags, get_tagged, get_class_info...).
8
+ Fewer, wider tools mean less schema in the agent's context and fewer chances
9
+ to pick the wrong one.
10
+
11
+ Traversal is bounded everywhere. A production place can hold hundreds of
12
+ thousands of instances, and an unbounded walk would either time out or return
13
+ something no context window can hold.
14
+ ]]
15
+
16
+ local CollectionService = game:GetService("CollectionService")
17
+
18
+ local Dispatch = require(script.Parent.Parent.Dispatch)
19
+ local Paths = require(script.Parent.Parent.Paths)
20
+ local Scope = require(script.Parent.Parent.Scope)
21
+ local Serialize = require(script.Parent.Parent.Serialize)
22
+
23
+ -- Ceiling on nodes visited in one call, independent of how many are returned.
24
+ -- Protects against a `find` over a whole place blocking Studio's main thread.
25
+ local MAX_VISITS = 200_000
26
+
27
+ local Discover = {}
28
+
29
+ -- Root-level noise filtering lives in Scope, which the script tools share.
30
+ local isNoisy = Scope.isNoisy
31
+
32
+ -- One memo per request keeps path formatting linear when many siblings share a
33
+ -- name; see Paths.of.
34
+ local function summarise(instance: Instance, memo: Paths.NameIndex): { [string]: any }
35
+ return {
36
+ path = Paths.of(instance, memo),
37
+ className = instance.ClassName,
38
+ childCount = #instance:GetChildren(),
39
+ }
40
+ end
41
+
42
+ --[[
43
+ Puts a result list into an order that does not change between calls.
44
+
45
+ `GetChildren` and `GetDescendants` do NOT return a stable order. Measured on
46
+ twelve parts named P01..P12 with nothing mutating between the two calls, the
47
+ engine returned them in ascending order once and as P08, P06, P04, P03, P11,
48
+ P02, P01, P12, P10, P07, P05, P09 the next time.
49
+
50
+ Unsorted, that made paging incoherent rather than merely untidy: `offset` is
51
+ a position in this list, so page two came from a different ordering than page
52
+ one, which silently skips some instances and repeats others. Nothing about
53
+ the response would have said so -- the counts stay right and every row is a
54
+ real instance.
55
+
56
+ Sorted by name, with the engine's per-instance debug id breaking ties so
57
+ same-named siblings hold a fixed order too. Name first because it is also
58
+ the order a reader expects: P01 before P02.
59
+ ]]
60
+ local function stableOrder(list: { Instance })
61
+ local ids: { [Instance]: string } = {}
62
+ for _, instance in list do
63
+ -- Cached: `GetDebugId` is a call per comparison otherwise, and a sort
64
+ -- makes O(n log n) of them.
65
+ local ok, id = pcall(function()
66
+ return instance:GetDebugId()
67
+ end)
68
+ ids[instance] = if ok then id else tostring(instance)
69
+ end
70
+ table.sort(list, function(a: Instance, b: Instance): boolean
71
+ if a.Name ~= b.Name then
72
+ return a.Name < b.Name
73
+ end
74
+ return (ids[a] or "") < (ids[b] or "")
75
+ end)
76
+ end
77
+
78
+ --[[
79
+ Compares a property against the text a caller asked for.
80
+
81
+ This was an exact `tostring` match, which made `find` disagree with `modify`
82
+ about its own notation: `modify` documents an enum as either "Neon" or
83
+ "Enum.Material.Neon", but only the qualified form matched here, and "False"
84
+ matched nothing at all. Both came back as a confident zero rather than an
85
+ error, which is the hardest kind of wrong answer to notice.
86
+
87
+ The bare-name shortcut is allowed only for enums, so a part named
88
+ "Map.Backup" is not matched by a search for "Backup".
89
+ ]]
90
+ local function valueMatches(actual: any, wanted: string): boolean
91
+ local text = tostring(actual)
92
+ if text == wanted then
93
+ return true
94
+ end
95
+
96
+ local lowered = string.lower(text)
97
+ local target = string.lower(wanted)
98
+ if lowered == target then
99
+ return true
100
+ end
101
+
102
+ if string.sub(lowered, 1, 5) == "enum." then
103
+ return string.match(lowered, "([^%.]+)$") == target
104
+ end
105
+ return false
106
+ end
107
+
108
+ --[[
109
+ Breadth-first walk to `depth`, newest level last, so a truncated result is
110
+ still a coherent picture of the top of the tree rather than one deep spur.
111
+ ]]
112
+ function Discover.tree(params: { [string]: any }): { [string]: any }
113
+ local root = if params.path then Paths.resolve(params.path) else game
114
+ local depth = tonumber(params.depth) or 2
115
+ local limit = tonumber(params.limit) or 100
116
+ local offset = tonumber(params.offset) or 0
117
+ local classFilter = params.className
118
+ local nameFilter = if params.nameContains then string.lower(params.nameContains) else nil
119
+
120
+ local matched: { Instance } = {}
121
+ local hidden = 0
122
+ local visits = 0
123
+ local frontier: { Instance } = { root }
124
+
125
+ for level = 1, depth do
126
+ local nextFrontier: { Instance } = {}
127
+ -- This level's matches, ordered on their own and appended after the
128
+ -- shallower ones. Sorting the whole list at the end instead ordered it by
129
+ -- name alone, so a truncated page could hold "Lighting.Sky" and drop
130
+ -- "Workspace" -- the opposite of what a breadth-first walk is for.
131
+ local levelMatches: { Instance } = {}
132
+ for _, parent in frontier do
133
+ for _, child in parent:GetChildren() do
134
+ visits += 1
135
+ if visits > MAX_VISITS then
136
+ break
137
+ end
138
+ if isNoisy(child) then
139
+ if parent == game then
140
+ hidden += 1
141
+ end
142
+ continue
143
+ end
144
+
145
+ local keep = true
146
+ if classFilter and not child:IsA(classFilter) then
147
+ keep = false
148
+ end
149
+ if keep and nameFilter and not string.find(string.lower(child.Name), nameFilter, 1, true) then
150
+ keep = false
151
+ end
152
+ if keep then
153
+ table.insert(levelMatches, child)
154
+ end
155
+ if level < depth then
156
+ table.insert(nextFrontier, child)
157
+ end
158
+ end
159
+ end
160
+ -- Sorted per level rather than at the end, so the breadth-first shape is
161
+ -- kept -- shallow instances still come before deep ones -- while the
162
+ -- siblings inside each level stop reshuffling between calls.
163
+ stableOrder(levelMatches)
164
+ table.move(levelMatches, 1, #levelMatches, #matched + 1, matched)
165
+ stableOrder(nextFrontier)
166
+ frontier = nextFrontier
167
+ if #frontier == 0 or visits > MAX_VISITS then
168
+ break
169
+ end
170
+ end
171
+
172
+ local items: { { [string]: any } } = {}
173
+ local memo: Paths.NameIndex = {}
174
+ for index = offset + 1, math.min(offset + limit, #matched) do
175
+ table.insert(items, summarise(matched[index] :: Instance, memo))
176
+ end
177
+
178
+ return {
179
+ root = Paths.of(root, memo),
180
+ items = items,
181
+ total = #matched,
182
+ offset = offset,
183
+ hiddenServices = hidden,
184
+ }
185
+ end
186
+
187
+ --[[
188
+ Detailed read of specific instances. `properties` is chosen server-side from
189
+ the live API dump, so this handler never needs its own class table.
190
+ ]]
191
+ --[[
192
+ The physical facts about a part, which no property panel shows.
193
+
194
+ "Why does this fall over", "why does it sink", "why does the door fly off
195
+ when you touch it" are all mass questions, and mass is not a property you can
196
+ read in Studio -- it is computed from volume and material and appears
197
+ nowhere. A 4-stud cube of Metal weighs nine times the same cube of Neon, and
198
+ nothing on screen distinguishes them.
199
+
200
+ `AssemblyRootPart` is the other half. Parts welded together move as one body
201
+ with one combined mass, and which part leads that body decides how the whole
202
+ thing behaves. Two parts that look joined but report different assembly roots
203
+ are not joined at all, which is the usual answer to "why did half of it stay
204
+ behind".
205
+ ]]
206
+ local function physicsOf(target: Instance): { [string]: any }?
207
+ if not target:IsA("BasePart") then
208
+ return nil
209
+ end
210
+ local part = target :: BasePart
211
+
212
+ local facts: { [string]: any } = {
213
+ anchored = part.Anchored,
214
+ canCollide = part.CanCollide,
215
+ massless = part.Massless,
216
+ material = tostring(part.Material):gsub("Enum%.Material%.", ""),
217
+ }
218
+
219
+ -- Every read is guarded separately: an anchored part has no assembly, and a
220
+ -- part mid-destruction can refuse any of these without the others failing.
221
+ pcall(function()
222
+ facts.mass = math.round(part:GetMass() * 1000) / 1000
223
+ end)
224
+ --[[
225
+ Infinity is a real answer here, and JSON cannot carry it.
226
+
227
+ An anchored part has infinite assembly mass -- that is the engine saying
228
+ "nothing will ever move this", which is exactly the fact someone asking
229
+ about mass wants. Encoded raw it came back as
230
+ `{"m":null,"t":"numeric","v":"inf"}`, which is the serialiser's honest
231
+ attempt at a number JSON has no room for, and is unreadable to everybody.
232
+ Said in words instead.
233
+ ]]
234
+ pcall(function()
235
+ local assembly = part.AssemblyMass
236
+ if assembly == math.huge then
237
+ facts.assemblyMass = "infinite -- anchored, so nothing moves it"
238
+ else
239
+ facts.assemblyMass = math.round(assembly * 1000) / 1000
240
+ end
241
+ end)
242
+ pcall(function()
243
+ local root = part.AssemblyRootPart
244
+ if root == nil then
245
+ --[[
246
+ No assembly at all, which is not the same as being welded to
247
+ something.
248
+
249
+ Only parts inside Workspace are simulated; one sitting in
250
+ StarterPack, ReplicatedStorage or ServerStorage has no assembly
251
+ and `AssemblyRootPart` is nil. The first version read that nil as
252
+ "the root is not me" and reported `movesAlone = false`, which says
253
+ this part is attached to another -- about a part that is not in
254
+ the physics world at all. A pistol in StarterPack read as welded
255
+ to something.
256
+ ]]
257
+ facts.simulated = false
258
+ facts.note = "Not in Workspace, so it has no physics assembly. Mass and "
259
+ .. "density are still real; anything about how it moves is not."
260
+ return
261
+ end
262
+ facts.simulated = true
263
+ facts.assemblyRoot = Paths.of(root)
264
+ --[[
265
+ Whether this part leads its own body. A part that is its own assembly
266
+ root is moving alone; one whose root is elsewhere is welded to
267
+ something, and the root names what.
268
+ ]]
269
+ facts.movesAlone = root == part
270
+ end)
271
+ pcall(function()
272
+ --[[
273
+ Where the WHOLE body balances, in world coordinates.
274
+
275
+ `CenterOfMass` is the part's own, in its own space, and for any
276
+ ordinary part it is (0, 0, 0) -- it was reported for every part in
277
+ the place and said nothing about any of them. The figure that
278
+ answers a question is the assembly's: a plank at x = 0 on a fulcrum
279
+ at x = 0, with a weight bolted near one end, balances at x = 7, and
280
+ that number is the entire explanation of why it tips. Measured on
281
+ exactly that see-saw.
282
+
283
+ The local one is kept only when it is not the origin, which means
284
+ somebody set it deliberately and it is worth seeing.
285
+ ]]
286
+ facts.centerOfMass = Serialize.value(part.AssemblyCenterOfMass)
287
+ local own = part.CenterOfMass
288
+ if own.Magnitude > 0.001 then
289
+ facts.centerOfMassOffset = Serialize.value(own)
290
+ end
291
+ end)
292
+ pcall(function()
293
+ local velocity = part.AssemblyLinearVelocity.Magnitude
294
+ if velocity > 0.01 then
295
+ facts.speed = math.round(velocity * 100) / 100
296
+ end
297
+ end)
298
+ --[[
299
+ Density from whichever source is actually in force.
300
+
301
+ An earlier version read `PhysicalProperties.new(part.Material)` whenever
302
+ custom properties existed, which reported the MATERIAL's density while the
303
+ part was using the override -- the one number in this block that would
304
+ have been confidently wrong. Measured: a Metal part reads 7.85 by
305
+ material and weighs 75.36, and the same part with a custom density of 0.3
306
+ weighs 2.88. Reporting 7.85 for the second one explains nothing about a
307
+ part that floats.
308
+
309
+ `CustomPhysicalProperties` is nil when unset, so its presence is the test
310
+ for which source applies.
311
+ ]]
312
+ pcall(function()
313
+ local custom = part.CustomPhysicalProperties
314
+ if custom ~= nil then
315
+ facts.density = math.round(custom.Density * 1000) / 1000
316
+ facts.densityFrom = "CustomPhysicalProperties"
317
+ else
318
+ facts.density = math.round(PhysicalProperties.new(part.Material).Density * 1000) / 1000
319
+ facts.densityFrom = "material"
320
+ end
321
+ end)
322
+
323
+ return facts
324
+ end
325
+
326
+ function Discover.inspect(params: { [string]: any }): { [string]: any }
327
+ local paths = params.paths
328
+ if typeof(paths) ~= "table" or #paths == 0 then
329
+ Dispatch.fail(
330
+ "BAD_PARAMS",
331
+ "inspect requires a non-empty `paths` array.",
332
+ 'Pass paths like ["Workspace.Model.Part"]. Use `find` or `tree` to discover them.'
333
+ )
334
+ end
335
+
336
+ local requested: { string }? = params.properties
337
+ local includeChildren = params.includeChildren ~= false
338
+ local childLimit = tonumber(params.childLimit) or 25
339
+
340
+ local results: { { [string]: any } } = {}
341
+ local failures: { string } = {}
342
+ local memo: Paths.NameIndex = {}
343
+
344
+ for _, path in paths do
345
+ local ok, instance = pcall(Paths.resolve, path)
346
+ if not ok then
347
+ local err = instance :: any
348
+ -- The hint carries the sibling listing ("It does have: ..."), which is
349
+ -- what lets the agent correct the path without another round trip.
350
+ local reason = if typeof(err) == "table"
351
+ then (if err.hint then err.message .. " " .. err.hint else err.message)
352
+ else tostring(err)
353
+ table.insert(failures, string.format("%s: %s", path, reason))
354
+ continue
355
+ end
356
+
357
+ local target = instance :: Instance
358
+ local properties: { [string]: any } = {}
359
+ if requested then
360
+ for _, name in requested do
361
+ local readOk, value = Serialize.readProperty(target, name)
362
+ if readOk then
363
+ properties[name] = value
364
+ end
365
+ end
366
+ end
367
+
368
+ local entry: { [string]: any } = {
369
+ path = Paths.of(target, memo),
370
+ -- Echoed back so a caller can correlate the answer with what it asked
371
+ -- for. `path` is the canonical form and often differs: ask about
372
+ -- "Workspace.Wall" and the answer comes back as "Workspace.Wall[1]".
373
+ requested = path,
374
+ className = target.ClassName,
375
+ properties = properties,
376
+ childCount = #target:GetChildren(),
377
+ }
378
+
379
+ -- Empty attribute and tag sets are omitted rather than sent as empty
380
+ -- containers: most instances have neither, and Luau encodes an empty
381
+ -- table as [] which reads as a list and confuses the shape.
382
+ local attributes: { [string]: any } = {}
383
+ local hasAttributes = false
384
+ for name, value in target:GetAttributes() do
385
+ attributes[name] = Serialize.value(value)
386
+ hasAttributes = true
387
+ end
388
+ if hasAttributes then
389
+ entry.attributes = attributes
390
+ end
391
+
392
+ local tags = CollectionService:GetTags(target)
393
+ if #tags > 0 then
394
+ entry.tags = tags
395
+ end
396
+
397
+ if params.physics == true then
398
+ entry.physics = physicsOf(target)
399
+ end
400
+
401
+ if includeChildren then
402
+ local children: { { [string]: any } } = {}
403
+ for index, child in target:GetChildren() do
404
+ if index > childLimit then
405
+ break
406
+ end
407
+ table.insert(children, { name = child.Name, className = child.ClassName })
408
+ end
409
+ entry.children = children
410
+ end
411
+
412
+ table.insert(results, entry)
413
+ end
414
+
415
+ return { items = results, failures = failures }
416
+ end
417
+
418
+ --[[
419
+ One search over name, class, property value and tag.
420
+
421
+ Every filter supplied must match (AND), which is what lets a single tool
422
+ answer "every anchored Part under Workspace whose name contains 'door'"
423
+ without the agent chaining three calls and intersecting the results itself.
424
+ ]]
425
+ --[[
426
+ Runs a selector through the engine's own matcher.
427
+
428
+ `QueryDescendants` is a CSS-like query built into Instance, and it is doing
429
+ the same job the loop below does -- except in C++, over the whole subtree, in
430
+ one call. Measured on a 3700-instance place: "Part" answered 189 matches
431
+ without a single Luau iteration.
432
+
433
+ What it understands, confirmed against a live session rather than guessed:
434
+
435
+ Part class name, superclasses included
436
+ #Baseplate exact name
437
+ [Anchored=true] property equality, on its own or after a class
438
+ Part, Model either
439
+ Model > Part direct children of a Model
440
+ Model >> Part descendants of a Model
441
+
442
+ What it does not: substring names, and comparisons like `>` or `<` (it
443
+ answers "'=' expected after property name"). That is why this is one
444
+ candidate source among three rather than a replacement for the filters --
445
+ `nameContains` still needs the loop, and composes with a selector.
446
+
447
+ A malformed selector raises from inside the engine with a readable reason, so
448
+ it is caught and re-raised as BAD_PARAMS with the reason kept: "Pseudo-class
449
+ 'first' filter is not supported" tells the caller exactly what to drop.
450
+ ]]
451
+ local function query(root: Instance, selector: string): { Instance }
452
+ local ok, result = pcall(function()
453
+ return root:QueryDescendants(selector)
454
+ end)
455
+ if not ok then
456
+ Dispatch.fail(
457
+ "BAD_PARAMS",
458
+ string.format("selector %q was rejected: %s", selector, tostring(result)),
459
+ "Selectors support a class name, #Name, [Property=Value], `A, B`, "
460
+ .. "`A > B` for direct children and `A >> B` for descendants. They do "
461
+ .. "not support substring names or < > comparisons -- use "
462
+ .. "`nameContains` and `propertyValue` for those."
463
+ )
464
+ end
465
+
466
+ --[[
467
+ Deduplicated, because a union can match the same instance twice.
468
+
469
+ `Script, LocalScript` looks like two disjoint sets and is not: LocalScript
470
+ inherits Script, so every LocalScript satisfies both halves and the engine
471
+ returns it once per half. Measured -- a place with 7 scripts answered 14
472
+ matches, every one of them listed twice, and the count was as wrong as the
473
+ listing.
474
+
475
+ The engine is not doing anything unreasonable here; a union is a union.
476
+ But "how many are there" is the question a caller is usually asking, and
477
+ an answer that doubles depending on how the class hierarchy happens to be
478
+ shaped is not an answer.
479
+ ]]
480
+ local matches = result :: { Instance }
481
+ local seen: { [Instance]: boolean } = {}
482
+ local unique: { Instance } = {}
483
+ for _, instance in matches do
484
+ if not seen[instance] then
485
+ seen[instance] = true
486
+ table.insert(unique, instance)
487
+ end
488
+ end
489
+ return unique
490
+ end
491
+
492
+ --[[
493
+ Which tags this place actually uses.
494
+
495
+ `find` can filter by tag and could never tell you which tags exist, so using
496
+ it meant guessing a name: a search for "Doors" that returns nothing is
497
+ indistinguishable from a place whose doors are tagged "Door". On a place
498
+ nobody has seen before that is the difference between reading how the game is
499
+ organised in one call and not being able to ask the question at all -- tags
500
+ like Enemy, Checkpoint or Interactable name a game's systems better than its
501
+ folder layout does.
502
+
503
+ Counted per tag, because an empty tag and a busy one mean different things: a
504
+ tag with nothing on it is usually left over from something deleted, and worth
505
+ saying so rather than listing beside the real ones.
506
+ ]]
507
+
508
+ --[[
509
+ Studio's own tags, which are in every place and belong to none of them.
510
+
511
+ The tag registry is shared with Studio's interface, so an unfiltered listing
512
+ is mostly `data-testid=--studio-foundation--stylesheet-wrapper` and its
513
+ relatives -- measured, 7 tags in a place that uses 5. They are recognisable
514
+ by prefix rather than by a list, since the set changes with Studio's own UI
515
+ and hardcoding today's names would silently rot.
516
+ ]]
517
+ local function studioOwned(tag: string): boolean
518
+ return string.sub(tag, 1, 12) == "data-testid="
519
+ end
520
+
521
+ function Discover.tags(params: { [string]: any }): { [string]: any }
522
+ local root = if typeof(params.path) == "string" and params.path ~= ""
523
+ then Paths.resolve(params.path)
524
+ else nil
525
+
526
+ local rows: { { [string]: any } } = {}
527
+ local hidden = 0
528
+
529
+ for _, tag in CollectionService:GetAllTags() do
530
+ if studioOwned(tag) then
531
+ hidden += 1
532
+ continue
533
+ end
534
+
535
+ local tagged = CollectionService:GetTagged(tag)
536
+ local count = 0
537
+ local sample: { string } = {}
538
+
539
+ for _, instance in tagged do
540
+ --[[
541
+ Scoped the same way `find` scopes, when a path is given. A tag is
542
+ global, so "which tags are used in Workspace.Map" is a different
543
+ question from "which tags exist", and both get asked.
544
+ ]]
545
+ if root ~= nil and not instance:IsDescendantOf(root) then
546
+ continue
547
+ end
548
+ if Scope.isNoisy(instance) then
549
+ continue
550
+ end
551
+ count += 1
552
+ if #sample < 3 then
553
+ table.insert(sample, Paths.of(instance))
554
+ end
555
+ end
556
+
557
+ if root ~= nil and count == 0 then
558
+ continue
559
+ end
560
+
561
+ table.insert(rows, {
562
+ tag = tag,
563
+ count = count,
564
+ -- A few paths, not all of them: the point is to recognise what the tag
565
+ -- is for, and three examples do that where two hundred would not.
566
+ sample = sample,
567
+ })
568
+ end
569
+
570
+ table.sort(rows, function(a, b)
571
+ if a.count == b.count then
572
+ return tostring(a.tag) < tostring(b.tag)
573
+ end
574
+ return (a.count :: number) > (b.count :: number)
575
+ end)
576
+
577
+ return {
578
+ tags = rows,
579
+ count = #rows,
580
+ scope = if root ~= nil then Paths.of(root) else nil,
581
+ studioTagsHidden = hidden,
582
+ }
583
+ end
584
+
585
+ function Discover.find(params: { [string]: any }): { [string]: any }
586
+ local root = if params.path then Paths.resolve(params.path) else game
587
+ local limit = tonumber(params.limit) or 100
588
+ local offset = tonumber(params.offset) or 0
589
+
590
+ local nameFilter = if params.nameContains then string.lower(params.nameContains) else nil
591
+ local classFilter = params.className
592
+ local propertyName = params.propertyName
593
+ local propertyValue = params.propertyValue
594
+ local tagFilter = params.tag
595
+ local selector = if typeof(params.selector) == "string" and params.selector ~= ""
596
+ then params.selector
597
+ else nil
598
+
599
+ --[[
600
+ Three ways to produce candidates, cheapest first.
601
+
602
+ The filters below are the same in every case -- this only chooses where
603
+ the list to filter comes from, which is the part that decides whether a
604
+ search on a large place is fast or times out.
605
+
606
+ * A tag query reads CollectionService's index instead of walking the
607
+ tree, which is orders of magnitude cheaper.
608
+ * A selector hands the whole match to `QueryDescendants`, so the walk
609
+ happens in C++ and only survivors cross into Luau.
610
+ * Otherwise the tree is walked here.
611
+ ]]
612
+ local candidates: { Instance }
613
+ if tagFilter then
614
+ candidates = CollectionService:GetTagged(tagFilter)
615
+ elseif selector then
616
+ candidates = query(root, selector :: string)
617
+ else
618
+ candidates = root:GetDescendants()
619
+ end
620
+
621
+ if #candidates > MAX_VISITS then
622
+ Dispatch.fail(
623
+ "TOO_BROAD",
624
+ string.format(
625
+ "That search would visit %d instances, over the %d limit.",
626
+ #candidates,
627
+ MAX_VISITS
628
+ ),
629
+ "Narrow it with `path` to search one service or model instead of the whole "
630
+ .. "place, or pass a `selector` -- that filters inside the engine, so the "
631
+ .. "count here is what survived rather than what was visited."
632
+ )
633
+ end
634
+
635
+ local matched: { Instance } = {}
636
+ for _, instance in candidates do
637
+ if tagFilter and root ~= game and not instance:IsDescendantOf(root) then
638
+ continue
639
+ end
640
+ if isNoisy(instance) then
641
+ continue
642
+ end
643
+ if classFilter and not instance:IsA(classFilter) then
644
+ continue
645
+ end
646
+ if nameFilter and not string.find(string.lower(instance.Name), nameFilter, 1, true) then
647
+ continue
648
+ end
649
+ if propertyName then
650
+ local readOk, value = Serialize.readProperty(instance, propertyName)
651
+ if not readOk then
652
+ continue
653
+ end
654
+ if propertyValue ~= nil and not valueMatches(value, tostring(propertyValue)) then
655
+ continue
656
+ end
657
+ end
658
+ table.insert(matched, instance)
659
+ end
660
+
661
+ stableOrder(matched)
662
+
663
+ local items: { { [string]: any } } = {}
664
+ local memo: Paths.NameIndex = {}
665
+ for index = offset + 1, math.min(offset + limit, #matched) do
666
+ table.insert(items, summarise(matched[index] :: Instance, memo))
667
+ end
668
+
669
+ return {
670
+ items = items,
671
+ total = #matched,
672
+ offset = offset,
673
+ searched = #candidates,
674
+ -- Echoed so a caller can see the selector was understood. A typo in a
675
+ -- selector is not an error -- it matches nothing -- and "0 results" reads
676
+ -- the same either way without this.
677
+ selector = selector,
678
+ }
679
+ end
680
+
681
+ function Discover.register()
682
+ Dispatch.registerAll("discover", {
683
+ tags = Discover.tags,
684
+ tree = Discover.tree,
685
+ inspect = Discover.inspect,
686
+ find = Discover.find,
687
+ })
688
+ end
689
+
690
+ return Discover