@el4cteo/rbx-studio-mcp 0.2.9 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,255 +1,357 @@
1
- --!strict
2
- --[[
3
- Dot-notation instance paths, e.g. "Workspace.Map.Spawn".
4
-
5
- Paths are the address format for every tool, so resolution failures are the
6
- most common error an agent will hit. Each failure says which segment broke
7
- and what does exist at that level, which is usually enough for the model to
8
- fix the path itself instead of falling back to a blind tree dump.
9
- ]]
10
-
11
- local Dispatch = require(script.Parent.Dispatch)
12
-
13
- local Paths = {}
14
-
15
- --[[
16
- Splits a path into segments. A leading "game." is optional and stripped, so
17
- both "game.Workspace.Part" and "Workspace.Part" resolve identically.
18
- ]]
19
- function Paths.split(path: string): { string }
20
- local segments: { string } = {}
21
- for segment in string.gmatch(path, "[^%.]+") do
22
- table.insert(segments, segment)
23
- end
24
- if segments[1] == "game" then
25
- table.remove(segments, 1)
26
- end
27
- return segments
28
- end
29
-
30
- --[[
31
- Splits "Part[3]" into ("Part", 3), or ("Part", nil) when unindexed.
32
-
33
- Sibling names are not unique in Roblox -- a Workspace with 77 parts all named
34
- "Part" is completely ordinary -- so a bare dotted path is ambiguous and
35
- `FindFirstChild` would silently pick the first match. Every path this module
36
- emits therefore carries a 1-based index whenever the name is shared, and
37
- `resolve` honours it.
38
- ]]
39
- local function parseSegment(segment: string): (string, number?)
40
- local name, index = string.match(segment, "^(.*)%[(%d+)%]$")
41
- if name and index then
42
- return name, tonumber(index)
43
- end
44
- return segment, nil
45
- end
46
-
47
- export type NameIndex = { [Instance]: { [string]: { Instance } } }
48
-
49
- --[[
50
- Groups a parent's children by name, memoised across one request. Without the
51
- memo, formatting paths for N same-named siblings is O(N^2); a listing of a
52
- few thousand parts would stall Studio's main thread.
53
- ]]
54
- local function childrenByName(parent: Instance, memo: NameIndex?): { [string]: { Instance } }
55
- local cached = if memo then memo[parent] else nil
56
- if cached then
57
- return cached
58
- end
59
-
60
- local byName: { [string]: { Instance } } = {}
61
- for _, child in parent:GetChildren() do
62
- local list = byName[child.Name]
63
- if not list then
64
- list = {}
65
- byName[child.Name] = list
66
- end
67
- table.insert(list, child)
68
- end
69
-
70
- if memo then
71
- memo[parent] = byName
72
- end
73
- return byName
74
- end
75
-
76
- --[[
77
- 1-based position among identically named siblings, or nil when the name is
78
- already unique and no index is needed.
79
- ]]
80
- local function siblingIndex(instance: Instance, memo: NameIndex?): number?
81
- local parent = instance.Parent
82
- if not parent then
83
- return nil
84
- end
85
-
86
- local list = childrenByName(parent, memo)[instance.Name]
87
- if not list or #list <= 1 then
88
- return nil
89
- end
90
- for index, child in list do
91
- if child == instance then
92
- return index
93
- end
94
- end
95
- return nil
96
- end
97
-
98
- local function childNames(parent: Instance, limit: number): string
99
- local names: { string } = {}
100
- for _, child in parent:GetChildren() do
101
- table.insert(names, child.Name)
102
- if #names >= limit then
103
- table.insert(names, "...")
104
- break
105
- end
106
- end
107
- if #names == 0 then
108
- return "(no children)"
109
- end
110
- return table.concat(names, ", ")
111
- end
112
-
113
- --[[
114
- Resolves a path to an Instance, or raises a NOT_FOUND with the failing
115
- segment and its siblings. `FindFirstChild` is used rather than indexing so a
116
- missing child never throws a bare Luau error.
117
- ]]
118
- function Paths.resolve(path: string): Instance
119
- if typeof(path) ~= "string" or path == "" then
120
- Dispatch.fail(
121
- "BAD_PATH",
122
- "An instance path is required.",
123
- 'Paths are dot-separated from the DataModel root, e.g. "Workspace.Map.Spawn". '
124
- .. "Use `find` or `tree` to discover valid paths."
125
- )
126
- end
127
-
128
- local segments = Paths.split(path)
129
- if #segments == 0 then
130
- return game
131
- end
132
-
133
- local current: Instance = game
134
- for index, segment in segments do
135
- local name, ordinal = parseSegment(segment)
136
- local nextInstance: Instance? = nil
137
-
138
- if ordinal then
139
- -- Explicit disambiguation: take the nth child with this exact name.
140
- local list = childrenByName(current, nil)[name]
141
- nextInstance = if list then list[ordinal] else nil
142
- elseif index == 1 then
143
- -- Services must be fetched by class name, and are not always
144
- -- present as children until first accessed.
145
- local ok, service = pcall(function()
146
- return game:GetService(name :: any)
147
- end)
148
- nextInstance = if ok then service else current:FindFirstChild(name)
149
- else
150
- nextInstance = current:FindFirstChild(name)
151
- end
152
-
153
- if not nextInstance then
154
- local partial = table.concat(segments, ".", 1, index)
155
- -- `fail` never returns; returning its result keeps that visible to
156
- -- the type checker so `current` stays non-optional below.
157
- return Dispatch.fail(
158
- "NOT_FOUND",
159
- string.format('No instance at "%s".', partial),
160
- string.format(
161
- '"%s" has no child named "%s". It does have: %s',
162
- current:GetFullName(),
163
- segment,
164
- childNames(current, 25)
165
- )
166
- )
167
- end
168
- current = nextInstance
169
- end
170
-
171
- return current
172
- end
173
-
174
- --[[
175
- Resolves many paths at once, collecting failures instead of stopping at the
176
- first one. Batch tools report every bad path in a single response so the
177
- agent can fix them all in one retry rather than one call per mistake.
178
- ]]
179
- function Paths.resolveMany(paths: { string }): ({ Instance }, { string })
180
- local resolved: { Instance } = {}
181
- local failures: { string } = {}
182
- for _, path in paths do
183
- local ok, result = pcall(Paths.resolve, path)
184
- if ok then
185
- table.insert(resolved, result :: Instance)
186
- else
187
- local err = result :: any
188
- table.insert(
189
- failures,
190
- string.format("%s: %s", path, if typeof(err) == "table" then err.message else tostring(err))
191
- )
192
- end
193
- end
194
- return resolved, failures
195
- end
196
-
197
- --[[
198
- Formats an instance into a path that resolves back to that exact instance.
199
-
200
- `GetFullName` is deliberately not used: it emits "Workspace.Part" for every
201
- one of 77 parts named "Part", so the paths it produces are not addresses at
202
- all. Here each segment gains a [n] suffix when its name is shared with a
203
- sibling.
204
-
205
- Pass a shared `memo` when formatting many instances in one request.
206
-
207
- The index is positional, so it shifts if same-named siblings are inserted or
208
- removed between calls. Read a fresh path after any structural change.
209
- ]]
210
- function Paths.of(instance: Instance, memo: NameIndex?): string
211
- --[[
212
- Refuses anything that is not an Instance, rather than duck-typing it.
213
-
214
- This walks `.Name` and `.Parent`, which a plain table can also have -- so
215
- handed a table carrying those two fields it used to return a perfectly
216
- well-formed path for an object that exists nowhere in the data model.
217
- That happened: `GeometryService:FragmentAsync` returns `{Index, Instance}`
218
- wrappers, a handler assigned `.Name` and `.Parent` to the wrapper, and
219
- this function reported eight new parts by full path when none had been
220
- created. A fabricated path is worse than an error, because everything
221
- downstream treats it as real.
222
- ]]
223
- if typeof(instance) ~= "Instance" then
224
- error(
225
- string.format(
226
- "Paths.of expects an Instance, got %s. This is a bug in the caller, "
227
- .. "not in the request.",
228
- typeof(instance)
229
- ),
230
- 2
231
- )
232
- end
233
-
234
- if instance == game then
235
- return "game"
236
- end
237
-
238
- local segments: { string } = {}
239
- local current: Instance? = instance
240
-
241
- while current ~= nil and current ~= game do
242
- local node = current :: Instance
243
- local ordinal = siblingIndex(node, memo)
244
- table.insert(
245
- segments,
246
- 1,
247
- if ordinal then string.format("%s[%d]", node.Name, ordinal) else node.Name
248
- )
249
- current = node.Parent
250
- end
251
-
252
- return table.concat(segments, ".")
253
- end
254
-
255
- return Paths
1
+ --!strict
2
+ --[[
3
+ Dot-notation instance paths, e.g. "Workspace.Map.Spawn".
4
+
5
+ Paths are the address format for every tool, so resolution failures are the
6
+ most common error an agent will hit. Each failure says which segment broke
7
+ and what does exist at that level, which is usually enough for the model to
8
+ fix the path itself instead of falling back to a blind tree dump.
9
+ ]]
10
+
11
+ local Dispatch = require(script.Parent.Dispatch)
12
+
13
+ local Paths = {}
14
+
15
+ --[[
16
+ Splits a path into segments. A leading "game." is optional and stripped, so
17
+ both "game.Workspace.Part" and "Workspace.Part" resolve identically.
18
+ ]]
19
+ function Paths.split(path: string): { string }
20
+ local segments: { string } = {}
21
+ for segment in string.gmatch(path, "[^%.]+") do
22
+ table.insert(segments, segment)
23
+ end
24
+ if segments[1] == "game" then
25
+ table.remove(segments, 1)
26
+ end
27
+ return segments
28
+ end
29
+
30
+ --[[
31
+ Splits "Part[3]" into ("Part", 3), or ("Part", nil) when unindexed.
32
+
33
+ Sibling names are not unique in Roblox -- a Workspace with 77 parts all named
34
+ "Part" is completely ordinary -- so a bare dotted path is ambiguous and
35
+ `FindFirstChild` would silently pick the first match. Every path this module
36
+ emits therefore carries a 1-based index whenever the name is shared, and
37
+ `resolve` honours it.
38
+ ]]
39
+ local function parseSegment(segment: string): (string, number?)
40
+ local name, index = string.match(segment, "^(.*)%[(%d+)%]$")
41
+ if name and index then
42
+ return name, tonumber(index)
43
+ end
44
+ return segment, nil
45
+ end
46
+
47
+ export type NameIndex = { [Instance]: { [string]: { Instance } } }
48
+
49
+ --[[
50
+ Groups a parent's children by name, memoised across one request. Without the
51
+ memo, formatting paths for N same-named siblings is O(N^2); a listing of a
52
+ few thousand parts would stall Studio's main thread.
53
+ ]]
54
+ local function childrenByName(parent: Instance, memo: NameIndex?): { [string]: { Instance } }
55
+ local cached = if memo then memo[parent] else nil
56
+ if cached then
57
+ return cached
58
+ end
59
+
60
+ local byName: { [string]: { Instance } } = {}
61
+ for _, child in parent:GetChildren() do
62
+ local list = byName[child.Name]
63
+ if not list then
64
+ list = {}
65
+ byName[child.Name] = list
66
+ end
67
+ table.insert(list, child)
68
+ end
69
+
70
+ if memo then
71
+ memo[parent] = byName
72
+ end
73
+ return byName
74
+ end
75
+
76
+ --[[
77
+ 1-based position among identically named siblings, or nil when the name is
78
+ already unique and no index is needed.
79
+ ]]
80
+ local function siblingIndex(instance: Instance, memo: NameIndex?): number?
81
+ local parent = instance.Parent
82
+ if not parent then
83
+ return nil
84
+ end
85
+
86
+ local list = childrenByName(parent, memo)[instance.Name]
87
+ if not list or #list <= 1 then
88
+ return nil
89
+ end
90
+ for index, child in list do
91
+ if child == instance then
92
+ return index
93
+ end
94
+ end
95
+ return nil
96
+ end
97
+
98
+ --[[
99
+ Names a parent's children for an error message, collapsing repeats.
100
+
101
+ Same-named siblings are ordinary in Roblox, and listing them one by one spent
102
+ the whole budget saying "Part, Part, Part" 25 times -- which tells a reader
103
+ nothing and hides the names that would have helped. Repeats are counted
104
+ instead, and the count is exactly what says an index is needed.
105
+ ]]
106
+ local function childNames(parent: Instance, limit: number): string
107
+ local order: { string } = {}
108
+ local counts: { [string]: number } = {}
109
+ for _, child in parent:GetChildren() do
110
+ if counts[child.Name] == nil then
111
+ counts[child.Name] = 0
112
+ table.insert(order, child.Name)
113
+ end
114
+ counts[child.Name] += 1
115
+ end
116
+
117
+ if #order == 0 then
118
+ return "(no children)"
119
+ end
120
+
121
+ local names: { string } = {}
122
+ for _, name in order do
123
+ if #names >= limit then
124
+ table.insert(names, string.format("... %d more", #order - limit))
125
+ break
126
+ end
127
+ local count = counts[name]
128
+ table.insert(names, if count > 1 then string.format("%s (x%d)", name, count) else name)
129
+ end
130
+ return table.concat(names, ", ")
131
+ end
132
+
133
+ --[[
134
+ Finds one child by a single segment, honouring "Name[2]" and, at the root,
135
+ fetching services by class name -- they are not always present as children
136
+ until first accessed.
137
+
138
+ The second return is a diagnostic for the one miss a caller cannot work out
139
+ from the sibling list: an index past the end of a name that does exist.
140
+ ]]
141
+ local function childBySegment(
142
+ parent: Instance,
143
+ segment: string,
144
+ atRoot: boolean
145
+ ): (Instance?, string?)
146
+ local name, ordinal = parseSegment(segment)
147
+
148
+ if ordinal then
149
+ local list = childrenByName(parent, nil)[name]
150
+ if list == nil then
151
+ return nil, nil
152
+ end
153
+ local child = list[ordinal]
154
+ if child then
155
+ return child, nil
156
+ end
157
+ return nil,
158
+ string.format(
159
+ '"%s" has %d child%s named "%s", so [%d] is past the end. Indexes are '
160
+ .. "1-based and shift when siblings are added or removed -- read a "
161
+ .. "fresh path from `find` or `tree`.",
162
+ parent:GetFullName(),
163
+ #list,
164
+ if #list == 1 then "" else "ren",
165
+ name,
166
+ ordinal
167
+ )
168
+ end
169
+
170
+ if atRoot then
171
+ local ok, service = pcall(function()
172
+ return game:GetService(name :: any)
173
+ end)
174
+ if ok and service then
175
+ return service, nil
176
+ end
177
+ end
178
+ return parent:FindFirstChild(name), nil
179
+ end
180
+
181
+ --[[
182
+ Resolves a path to an Instance, or raises a NOT_FOUND with the failing
183
+ segment and its siblings. `FindFirstChild` is used rather than indexing so a
184
+ missing child never throws a bare Luau error.
185
+
186
+ Instance names may contain dots -- "Dr. Simon" is an ordinary name, and so is
187
+ "v1.2 backup" -- while the separator is a dot too, so such a name arrives
188
+ here already split into pieces. Each step therefore tries consuming one
189
+ segment first and rejoins further segments only if that leads nowhere, which
190
+ means the search has to BACK TRACK: with both "Dr" and "Dr. Who" present,
191
+ "Workspace.Dr. Who.Head" matches "Dr", fails to find " Who" under it, and
192
+ must return to try "Dr. Who". A first pass without that backtracking left the
193
+ tools emitting `Workspace.Dr. Who` as a path and then refusing to read it
194
+ back, which is worse than either behaviour on its own.
195
+
196
+ Shortest-first keeps the old precedence: where a short name resolves the
197
+ whole remaining path, it still wins over a longer join.
198
+ ]]
199
+ function Paths.resolve(path: string): Instance
200
+ if typeof(path) ~= "string" or path == "" then
201
+ Dispatch.fail(
202
+ "BAD_PATH",
203
+ "An instance path is required.",
204
+ 'Paths are dot-separated from the DataModel root, e.g. "Workspace.Map.Spawn". '
205
+ .. "Use `find` or `tree` to discover valid paths."
206
+ )
207
+ end
208
+
209
+ local segments = Paths.split(path)
210
+ if #segments == 0 then
211
+ return game
212
+ end
213
+
214
+ -- The deepest point any branch of the search reached, so a failure still
215
+ -- reports the segment that actually broke rather than the first one tried.
216
+ local deepest = 0
217
+ local deepestParent: Instance = game
218
+ local deepestNote: string? = nil
219
+ -- The segment text the note is about: with a rejoined name it is not
220
+ -- `segments[failedAt]`, and reporting that instead named "Mr" for a path
221
+ -- that asked for "Mr. X[3]".
222
+ local deepestSegment: string? = nil
223
+
224
+ local function walk(current: Instance, index: number): Instance?
225
+ if index > #segments then
226
+ return current
227
+ end
228
+ if index - 1 > deepest then
229
+ deepest = index - 1
230
+ deepestParent = current
231
+ deepestNote = nil
232
+ deepestSegment = nil
233
+ end
234
+
235
+ for last = index, #segments do
236
+ local joined = table.concat(segments, ".", index, last)
237
+ local child, note = childBySegment(current, joined, index == 1)
238
+ if note and index - 1 == deepest then
239
+ deepestNote = note
240
+ deepestSegment = joined
241
+ end
242
+ if child then
243
+ local found = walk(child, last + 1)
244
+ if found then
245
+ return found
246
+ end
247
+ end
248
+ end
249
+ return nil
250
+ end
251
+
252
+ local resolved = walk(game, 1)
253
+ if resolved then
254
+ return resolved
255
+ end
256
+
257
+ local failedAt = deepest + 1
258
+ local reached = if deepest > 0 then table.concat(segments, ".", 1, deepest) .. "." else ""
259
+ local named = if deepestSegment
260
+ then reached .. deepestSegment
261
+ else table.concat(segments, ".", 1, failedAt)
262
+ return Dispatch.fail(
263
+ "NOT_FOUND",
264
+ string.format('No instance at "%s".', named),
265
+ if deepestNote
266
+ then deepestNote
267
+ else string.format(
268
+ '"%s" has no child named "%s". It does have: %s',
269
+ deepestParent:GetFullName(),
270
+ segments[failedAt],
271
+ childNames(deepestParent, 25)
272
+ )
273
+ )
274
+ end
275
+
276
+ --[[
277
+ Resolves many paths at once, collecting failures instead of stopping at the
278
+ first one. Batch tools report every bad path in a single response so the
279
+ agent can fix them all in one retry rather than one call per mistake.
280
+ ]]
281
+ function Paths.resolveMany(paths: { string }): ({ Instance }, { string })
282
+ local resolved: { Instance } = {}
283
+ local failures: { string } = {}
284
+ for _, path in paths do
285
+ local ok, result = pcall(Paths.resolve, path)
286
+ if ok then
287
+ table.insert(resolved, result :: Instance)
288
+ else
289
+ local err = result :: any
290
+ table.insert(
291
+ failures,
292
+ string.format("%s: %s", path, if typeof(err) == "table" then err.message else tostring(err))
293
+ )
294
+ end
295
+ end
296
+ return resolved, failures
297
+ end
298
+
299
+ --[[
300
+ Formats an instance into a path that resolves back to that exact instance.
301
+
302
+ `GetFullName` is deliberately not used: it emits "Workspace.Part" for every
303
+ one of 77 parts named "Part", so the paths it produces are not addresses at
304
+ all. Here each segment gains a [n] suffix when its name is shared with a
305
+ sibling.
306
+
307
+ Pass a shared `memo` when formatting many instances in one request.
308
+
309
+ The index is positional, so it shifts if same-named siblings are inserted or
310
+ removed between calls. Read a fresh path after any structural change.
311
+ ]]
312
+ function Paths.of(instance: Instance, memo: NameIndex?): string
313
+ --[[
314
+ Refuses anything that is not an Instance, rather than duck-typing it.
315
+
316
+ This walks `.Name` and `.Parent`, which a plain table can also have -- so
317
+ handed a table carrying those two fields it used to return a perfectly
318
+ well-formed path for an object that exists nowhere in the data model.
319
+ That happened: `GeometryService:FragmentAsync` returns `{Index, Instance}`
320
+ wrappers, a handler assigned `.Name` and `.Parent` to the wrapper, and
321
+ this function reported eight new parts by full path when none had been
322
+ created. A fabricated path is worse than an error, because everything
323
+ downstream treats it as real.
324
+ ]]
325
+ if typeof(instance) ~= "Instance" then
326
+ error(
327
+ string.format(
328
+ "Paths.of expects an Instance, got %s. This is a bug in the caller, "
329
+ .. "not in the request.",
330
+ typeof(instance)
331
+ ),
332
+ 2
333
+ )
334
+ end
335
+
336
+ if instance == game then
337
+ return "game"
338
+ end
339
+
340
+ local segments: { string } = {}
341
+ local current: Instance? = instance
342
+
343
+ while current ~= nil and current ~= game do
344
+ local node = current :: Instance
345
+ local ordinal = siblingIndex(node, memo)
346
+ table.insert(
347
+ segments,
348
+ 1,
349
+ if ordinal then string.format("%s[%d]", node.Name, ordinal) else node.Name
350
+ )
351
+ current = node.Parent
352
+ end
353
+
354
+ return table.concat(segments, ".")
355
+ end
356
+
357
+ return Paths