@chrrxs/robloxstudio-mcp 3.2.1 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1471,6 +1471,7 @@ local ClientBroker = TS.import(script, script.Parent, "ClientBroker")
1471
1471
  local ServerUrlSettings = TS.import(script, script.Parent, "ServerUrlSettings")
1472
1472
  local PluginSession = TS.import(script, script.Parent, "PluginSession")
1473
1473
  local StudioWebSocket = TS.import(script, script.Parent, "StudioWebSocket")
1474
+ local PathGuard = TS.import(script, script.Parent, "PathGuard")
1474
1475
  local assignedRole
1475
1476
  local lastReadyPlaceKey
1476
1477
  local initialRole = PluginSession.getRole()
@@ -1523,12 +1524,17 @@ local routeMap = {
1523
1524
  ["/api/get-memory-breakdown"] = MemoryHandlers.getMemoryBreakdown,
1524
1525
  ["/api/get-scene-analysis"] = SceneAnalysisHandlers.getSceneAnalysis,
1525
1526
  }
1527
+ local pathGuard = PathGuard.createPathGuard(function(path)
1528
+ return Utils.resolveInstancePath(path).ambiguity
1529
+ end)
1526
1530
  local function processRequest(request, context)
1527
1531
  local endpoint = request.endpoint
1528
- local data = request.data or {}
1532
+ local data = (request.data or {})
1529
1533
  local handler = routeMap[endpoint]
1530
1534
  if handler then
1531
- return handler(data, context)
1535
+ return pathGuard.run(endpoint, data, function()
1536
+ return handler(data, context)
1537
+ end)
1532
1538
  else
1533
1539
  return {
1534
1540
  error = `Unknown endpoint: {endpoint}`,
@@ -1975,9 +1981,9 @@ local function computeBridgeStamp()
1975
1981
  for i = 1, #combined do
1976
1982
  h = (h * 33 + (string.byte(combined, i))) % 2147483647
1977
1983
  end
1978
- -- "3.2.1" is replaced with the package version at package time
1984
+ -- "3.3.0" is replaced with the package version at package time
1979
1985
  -- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
1980
- return `{tostring(h)}-3.2.1`
1986
+ return `{tostring(h)}-3.3.0`
1981
1987
  end
1982
1988
  local BRIDGE_STAMP = computeBridgeStamp()
1983
1989
  local function setSource(scriptInst, source)
@@ -4213,15 +4219,20 @@ local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
4213
4219
  -- capability and drives the REAL input pipeline: SendKey feeds
4214
4220
  -- UserInputService.InputBegan/Ended and the control modules (so WASD walks the
4215
4221
  -- character at full WalkSpeed with controls intact, no Humanoid hijack),
4216
- -- SendMouseButton feeds UIS and activates GUI buttons (and hit-tests against
4217
- -- CoreGui), and SendTextInput types into the focused TextBox.
4222
+ -- SendMouseButton feeds UIS and activates GUI buttons, SendMousePosition moves
4223
+ -- the cursor (hover, MouseEnter, InputChanged), SendPointerAction scrolls
4224
+ -- under a position, and SendTextInput types into the focused TextBox. Every method throws when the
4225
+ -- input would hit interactive CoreGui.
4226
+ --
4227
+ -- Verified live on a play client: move and scroll reach game scripts with
4228
+ -- the device simulator stopped. Under touch device emulation the client has
4229
+ -- no mouse: clicks arrive as Touch, scroll still works, but moves never reach
4230
+ -- the game, so we reject move there instead of returning a false success.
4218
4231
  --
4219
- -- Method set on the VirtualInput object (verified live):
4220
- -- SendKey(isDown: boolean, keyCode: Enum.KeyCode)
4221
- -- SendMouseButton(position: Vector2, inputType: Enum.UserInputType, isDown: boolean)
4222
- -- SendTextInput(text: string)
4223
- -- There is NO SendMouseMove / SendMouseWheel / SendKeyEvent — so "move" and
4224
- -- "scroll" mouse actions are not supported.
4232
+ -- SendMouseDelta is deliberately NOT exposed. It only works while the cursor
4233
+ -- is locked (MouseBehavior.LockCenter), and on desktop Studio holds that lock
4234
+ -- by pinning and hiding the user's real OS cursor — an unacceptable takeover
4235
+ -- of the host's input.
4225
4236
  --
4226
4237
  -- Coordinate space: SendMouseButton coordinates are viewport pixels matching
4227
4238
  -- what capture_screenshot returns (window space, origin at the top-left of the
@@ -4254,6 +4265,7 @@ local MOUSE_TYPE_MAP = {
4254
4265
  Right = Enum.UserInputType.MouseButton2,
4255
4266
  Middle = Enum.UserInputType.MouseButton3,
4256
4267
  }
4268
+ local NO_MOUSE_ERROR = "This client has no mouse (touch device emulation is active), so cursor movement never reaches the game. " .. "Stop it with set_device_simulator stopSimulation=true, then retry."
4257
4269
  local function simulateMouseInput(requestData)
4258
4270
  local action = requestData.action
4259
4271
  local x = requestData.x
@@ -4263,6 +4275,7 @@ local function simulateMouseInput(requestData)
4263
4275
  _condition = "Left"
4264
4276
  end
4265
4277
  local button = _condition
4278
+ local wheel = requestData.wheel
4266
4279
  if not (action ~= "" and action) then
4267
4280
  return {
4268
4281
  error = "action is required",
@@ -4273,6 +4286,11 @@ local function simulateMouseInput(requestData)
4273
4286
  error = "x and y are required",
4274
4287
  }
4275
4288
  end
4289
+ if action == "scroll" and (not (type(wheel) == "number") or wheel == 0) then
4290
+ return {
4291
+ error = "wheel must be a non-zero number for scroll",
4292
+ }
4293
+ end
4276
4294
  -- Input is silently dropped by the engine when the window isn't rendering
4277
4295
  -- (e.g. minimized). Surface that instead of returning a false success.
4278
4296
  local notRendering = RenderMonitor.notRenderingReason()
@@ -4281,6 +4299,11 @@ local function simulateMouseInput(requestData)
4281
4299
  error = notRendering,
4282
4300
  }
4283
4301
  end
4302
+ if action == "move" and not UserInputService.MouseEnabled then
4303
+ return {
4304
+ error = NO_MOUSE_ERROR,
4305
+ }
4306
+ end
4284
4307
  local vi = getVI()
4285
4308
  if not vi then
4286
4309
  return {
@@ -4298,18 +4321,29 @@ local function simulateMouseInput(requestData)
4298
4321
  vi:SendMouseButton(pos, inputType, true)
4299
4322
  elseif action == "mouseUp" then
4300
4323
  vi:SendMouseButton(pos, inputType, false)
4324
+ elseif action == "move" then
4325
+ vi:SendMousePosition(pos)
4326
+ elseif action == "scroll" then
4327
+ vi:SendPointerAction(pos, {
4328
+ Wheel = wheel,
4329
+ })
4301
4330
  else
4302
- error(`Unsupported action "{action}". CreateVirtualInput supports click, mouseDown, mouseUp ` .. `(no move/scroll — those methods don't exist on VirtualInput).`)
4331
+ error(`Unsupported action "{action}". Use click, mouseDown, mouseUp, move, or scroll.`)
4303
4332
  end
4304
4333
  end)
4305
4334
  if success then
4306
- return {
4335
+ local result = {
4307
4336
  success = true,
4308
4337
  action = action,
4309
4338
  x = x,
4310
4339
  y = y,
4311
- button = button,
4312
4340
  }
4341
+ if action == "scroll" then
4342
+ result.wheel = wheel
4343
+ elseif action ~= "move" then
4344
+ result.button = button
4345
+ end
4346
+ return result
4313
4347
  end
4314
4348
  return {
4315
4349
  error = `Failed to simulate mouse input: {err}`,
@@ -4500,6 +4534,7 @@ local Selection = game:GetService("Selection")
4500
4534
  local _binding = Utils
4501
4535
  local getInstancePath = _binding.getInstancePath
4502
4536
  local getInstanceByPath = _binding.getInstanceByPath
4537
+ local createPathAmbiguityChecker = _binding.createPathAmbiguityChecker
4503
4538
  local function serializeValue(value)
4504
4539
  local _value = value
4505
4540
  local vType = typeof(_value)
@@ -4601,15 +4636,20 @@ local function getSelection(_requestData)
4601
4636
  message = "No objects selected",
4602
4637
  }
4603
4638
  end
4639
+ local isAmbiguous = createPathAmbiguityChecker()
4604
4640
  -- ▼ ReadonlyArray.map ▼
4605
4641
  local _newValue = table.create(#selection)
4606
4642
  local _callback = function(instance)
4607
- return {
4643
+ local entry = {
4608
4644
  name = instance.Name,
4609
4645
  className = instance.ClassName,
4610
4646
  path = getInstancePath(instance),
4611
4647
  parent = if instance.Parent then getInstancePath(instance.Parent) else nil,
4612
4648
  }
4649
+ if isAmbiguous(instance) then
4650
+ entry.ambiguous = true
4651
+ end
4652
+ return entry
4613
4653
  end
4614
4654
  for _k, _v in selection do
4615
4655
  _newValue[_k] = _callback(_v, _k - 1, selection)
@@ -6748,9 +6788,11 @@ local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
6748
6788
  local Utils = TS.import(script, script.Parent.Parent, "Utils")
6749
6789
  local CooperativeJobRunner = TS.import(script, script.Parent.Parent, "CooperativeJobRunner")
6750
6790
  local ScriptSearch = TS.import(script, script.Parent.Parent, "ScriptSearch")
6791
+ local InstanceSearch = TS.import(script, script.Parent.Parent, "InstanceSearch")
6751
6792
  local _binding = Utils
6752
6793
  local getInstancePath = _binding.getInstancePath
6753
6794
  local getInstanceByPath = _binding.getInstanceByPath
6795
+ local createPathAmbiguityChecker = _binding.createPathAmbiguityChecker
6754
6796
  local readScriptSource = _binding.readScriptSource
6755
6797
  local function getFileTree(requestData)
6756
6798
  local _condition = (requestData.path)
@@ -6876,62 +6918,42 @@ local function getPlaceInfo(_requestData)
6876
6918
  },
6877
6919
  }
6878
6920
  end
6879
- local function searchObjects(requestData)
6880
- local query = requestData.query
6881
- local _condition = (requestData.searchType)
6882
- if _condition == nil then
6883
- _condition = "name"
6884
- end
6885
- local searchType = _condition
6886
- local propertyName = requestData.propertyName
6887
- if not (query ~= "" and query) then
6888
- return {
6889
- error = "Query is required",
6890
- }
6891
- end
6892
- local results = {}
6893
- local function searchRecursive(instance)
6894
- local match = false
6895
- if searchType == "name" then
6896
- local _exp = string.lower(instance.Name)
6897
- local _arg0 = string.lower(query)
6898
- match = (string.find(_exp, _arg0)) ~= nil
6899
- elseif searchType == "class" then
6900
- local _exp = string.lower(instance.ClassName)
6901
- local _arg0 = string.lower(query)
6902
- match = (string.find(_exp, _arg0)) ~= nil
6903
- else
6904
- local _value = searchType == "property" and propertyName
6905
- if _value ~= "" and _value then
6906
- local success, value = pcall(function()
6907
- return tostring(instance[propertyName])
6908
- end)
6909
- if success then
6910
- local _exp = string.lower(value)
6911
- local _arg0 = string.lower(query)
6912
- match = (string.find(_exp, _arg0)) ~= nil
6913
- end
6914
- end
6915
- end
6916
- if match then
6917
- local _arg0 = {
6918
- name = instance.Name,
6919
- className = instance.ClassName,
6920
- path = getInstancePath(instance),
6921
- }
6922
- table.insert(results, _arg0)
6923
- end
6924
- for _, child in instance:GetChildren() do
6925
- searchRecursive(child)
6926
- end
6921
+ local instanceSearch = InstanceSearch.createInstanceSearch({
6922
+ resolveRoot = getInstanceByPath,
6923
+ getChildren = function(instance)
6924
+ return instance:GetChildren()
6925
+ end,
6926
+ getName = function(instance)
6927
+ return instance.Name
6928
+ end,
6929
+ getClassName = function(instance)
6930
+ return instance.ClassName
6931
+ end,
6932
+ isA = function(instance, className)
6933
+ return instance:IsA(className)
6934
+ end,
6935
+ readPropertyText = function(instance, propertyName)
6936
+ local success, value = pcall(function()
6937
+ return tostring(instance[propertyName])
6938
+ end)
6939
+ return if success then value else nil
6940
+ end,
6941
+ getPath = getInstancePath,
6942
+ createAmbiguityChecker = createPathAmbiguityChecker,
6943
+ })
6944
+ local function searchObjects(requestData, execution)
6945
+ -- Keyed per request: concurrent searches are independent reads, so the
6946
+ -- runner is used only for yielding, deadlines and cancellation.
6947
+ local result = CooperativeJobRunner.runExclusive(`instance-search:{execution.requestId}`, execution, function(control)
6948
+ return instanceSearch.search(requestData, control)
6949
+ end)
6950
+ if result.error == "deadline_exceeded" then
6951
+ local _object = table.clone(result)
6952
+ setmetatable(_object, nil)
6953
+ _object.message = "search_objects exceeded its bridge deadline before the scan completed. Narrow it with path or className."
6954
+ return _object
6927
6955
  end
6928
- searchRecursive(game)
6929
- return {
6930
- results = results,
6931
- query = query,
6932
- searchType = searchType,
6933
- count = #results,
6934
- }
6956
+ return result
6935
6957
  end
6936
6958
  local function getInstanceProperties(requestData)
6937
6959
  local instancePath = requestData.instancePath
@@ -7199,9 +7221,10 @@ local function getProjectStructure(requestData)
7199
7221
  error = `Path not found: {startPath}`,
7200
7222
  }
7201
7223
  end
7224
+ local isAmbiguous = createPathAmbiguityChecker()
7202
7225
  local function getStructure(instance, depth)
7203
7226
  if depth > maxDepth then
7204
- return {
7227
+ local truncatedNode = {
7205
7228
  name = instance.Name,
7206
7229
  className = instance.ClassName,
7207
7230
  path = getInstancePath(instance),
@@ -7209,12 +7232,19 @@ local function getProjectStructure(requestData)
7209
7232
  hasMore = true,
7210
7233
  note = "Max depth reached - use this path to explore further",
7211
7234
  }
7235
+ if isAmbiguous(instance) then
7236
+ truncatedNode.ambiguous = true
7237
+ end
7238
+ return truncatedNode
7212
7239
  end
7213
7240
  local node = {
7214
7241
  name = instance.Name,
7215
7242
  className = instance.ClassName,
7216
7243
  path = getInstancePath(instance),
7217
7244
  }
7245
+ if isAmbiguous(instance) then
7246
+ node.ambiguous = true
7247
+ end
7218
7248
  if instance:IsA("LuaSourceContainer") then
7219
7249
  node.hasSource = true
7220
7250
  node.scriptType = instance.ClassName
@@ -7344,6 +7374,9 @@ local scriptSearch = ScriptSearch.createScriptSearch({
7344
7374
  resolveRoot = function(self, path)
7345
7375
  return getInstanceByPath(path)
7346
7376
  end,
7377
+ createAmbiguityChecker = function(self)
7378
+ return createPathAmbiguityChecker()
7379
+ end,
7347
7380
  getChildren = function(self, instance)
7348
7381
  return instance:GetChildren()
7349
7382
  end,
@@ -7651,6 +7684,9 @@ return {
7651
7684
  local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
7652
7685
  local Utils = TS.import(script, script.Parent.Parent, "Utils")
7653
7686
  local Recording = TS.import(script, script.Parent.Parent, "Recording")
7687
+ local _ScriptEdits = TS.import(script, script.Parent.Parent, "ScriptEdits")
7688
+ local planScriptEdits = _ScriptEdits.planScriptEdits
7689
+ local sourceRevision = _ScriptEdits.sourceRevision
7654
7690
  local _binding = Utils
7655
7691
  local getInstancePath = _binding.getInstancePath
7656
7692
  local getInstanceByPath = _binding.getInstanceByPath
@@ -7766,6 +7802,7 @@ local function getScriptSource(requestData)
7766
7802
  numberedSource = numberLines(selectedLines, returnedStartLine),
7767
7803
  sourceLength = #fullSource,
7768
7804
  lineCount = totalLineCount,
7805
+ revision = sourceRevision(fullSource),
7769
7806
  startLine = returnedStartLine,
7770
7807
  endLine = returnedEndLine,
7771
7808
  isPartial = explicitRange,
@@ -7837,14 +7874,74 @@ local function setScriptSource(requestData)
7837
7874
  error = `Failed to set script source: {applyResult.error}`,
7838
7875
  }
7839
7876
  end
7877
+ -- Accepts the batched `edits` array or the single top-level old_string form.
7878
+ local function readScriptEdits(requestData)
7879
+ local rawEdits = requestData.edits
7880
+ if rawEdits == nil then
7881
+ local oldString = requestData.old_string
7882
+ local newString = requestData.new_string
7883
+ local startLine = requestData.startLine
7884
+ if not (type(oldString) == "string") or not (type(newString) == "string") then
7885
+ return "Instance path, old_string, and new_string are required"
7886
+ end
7887
+ if startLine ~= nil and not (type(startLine) == "number") then
7888
+ return "startLine must be a number"
7889
+ end
7890
+ return { {
7891
+ oldString = oldString,
7892
+ newString = newString,
7893
+ startLine = startLine,
7894
+ } }
7895
+ end
7896
+ if not (type(rawEdits) == "table") then
7897
+ return "edits must be an array"
7898
+ end
7899
+ local edits = {}
7900
+ for _, rawEdit in rawEdits do
7901
+ local index = #edits
7902
+ if not (type(rawEdit) == "table") then
7903
+ return `edits[{index}] must be an object`
7904
+ end
7905
+ local fields = rawEdit
7906
+ local oldString = fields.old_string
7907
+ local newString = fields.new_string
7908
+ local startLine = fields.startLine
7909
+ if not (type(oldString) == "string") or not (type(newString) == "string") then
7910
+ return `edits[{index}] requires old_string and new_string`
7911
+ end
7912
+ if startLine ~= nil and not (type(startLine) == "number") then
7913
+ return `edits[{index}].startLine must be a number`
7914
+ end
7915
+ local _arg0 = {
7916
+ oldString = oldString,
7917
+ newString = newString,
7918
+ startLine = startLine,
7919
+ }
7920
+ table.insert(edits, _arg0)
7921
+ end
7922
+ if #edits == 0 then
7923
+ return "edits must contain at least one edit"
7924
+ end
7925
+ return edits
7926
+ end
7840
7927
  local function editScriptLines(requestData)
7841
7928
  local instancePath = requestData.instancePath
7842
- local oldString = requestData.old_string
7843
- local newString = requestData.new_string
7844
- local startLine = requestData.startLine
7845
- if not (instancePath ~= "" and instancePath) or oldString == nil or newString == nil then
7929
+ local expectedRevision = requestData.expectedRevision
7930
+ local dryRun = requestData.dryRun == true
7931
+ if not (instancePath ~= "" and instancePath) then
7932
+ return {
7933
+ error = "Instance path is required",
7934
+ }
7935
+ end
7936
+ local edits = readScriptEdits(requestData)
7937
+ if type(edits) == "string" then
7938
+ return {
7939
+ error = edits,
7940
+ }
7941
+ end
7942
+ if expectedRevision ~= nil and not (type(expectedRevision) == "string") then
7846
7943
  return {
7847
- error = "Instance path, old_string, and new_string are required",
7944
+ error = "expectedRevision must be a string",
7848
7945
  }
7849
7946
  end
7850
7947
  local instance = getInstanceByPath(instancePath)
@@ -7858,76 +7955,63 @@ local function editScriptLines(requestData)
7858
7955
  error = `Instance is not a script-like object: {instance.ClassName}`,
7859
7956
  }
7860
7957
  end
7861
- local recordingId = beginRecording(`Edit script: {instance.Name}`)
7862
- local success, result = pcall(function()
7863
- local source = readScriptSource(instance)
7864
- local searchLen = #oldString
7865
- local matchStart
7866
- if startLine ~= nil then
7867
- if startLine < 1 then
7868
- error(`startLine must be >= 1 (got {startLine})`)
7869
- end
7870
- local lineStartByte = 1
7871
- local currentLine = 1
7872
- while currentLine < startLine do
7873
- local nlPos = string.find(source, "\n", lineStartByte, true)
7874
- if nlPos == nil then
7875
- error(`startLine {startLine} is past end of script ({currentLine} lines)`)
7876
- end
7877
- lineStartByte = nlPos + 1
7878
- currentLine += 1
7879
- end
7880
- local candidate = string.sub(source, lineStartByte, lineStartByte + searchLen - 1)
7881
- if candidate ~= oldString then
7882
- error(`old_string does not match at line {startLine}. Use get_script_source to verify the exact text at that line.`)
7883
- end
7884
- matchStart = lineStartByte
7885
- else
7886
- local count = 0
7887
- local searchPos = 1
7888
- local firstMatch
7889
- while true do
7890
- local foundStart = string.find(source, oldString, searchPos, true)
7891
- if foundStart == nil then
7892
- break
7893
- end
7894
- if firstMatch == nil then
7895
- firstMatch = foundStart
7896
- end
7897
- count += 1
7898
- if count > 1 then
7899
- break
7900
- end
7901
- searchPos = foundStart + searchLen
7902
- end
7903
- if count == 0 then
7904
- error("old_string not found in script. If old_string contains repeated patterns (e.g. closing braces), pass startLine to anchor the edit.")
7905
- end
7906
- if count > 1 then
7907
- error("old_string matches multiple locations. Provide more surrounding context, or pass startLine to anchor the edit to a specific line.")
7908
- end
7909
- matchStart = firstMatch
7910
- end
7911
- -- Byte-slice replacement avoids Lua pattern escaping (safe for multi-byte chars like em dashes).
7912
- local newSource = string.sub(source, 1, matchStart - 1) .. newString .. string.sub(source, matchStart + searchLen)
7913
- local applyResult = applyScriptSource(instance, newSource, source)
7914
- if not applyResult.success then
7915
- error(applyResult.error)
7916
- end
7958
+ local readSuccess, readResult = pcall(function()
7959
+ return readScriptSource(instance)
7960
+ end)
7961
+ if not readSuccess then
7962
+ return {
7963
+ error = `Failed to edit script: {readResult}`,
7964
+ }
7965
+ end
7966
+ local source = readResult
7967
+ local revision = sourceRevision(source)
7968
+ if expectedRevision ~= nil and expectedRevision ~= revision then
7969
+ return {
7970
+ error = "Script changed since expected_revision; read it again with get_script_source and retry.",
7971
+ code = "revision_mismatch",
7972
+ instancePath = instancePath,
7973
+ expectedRevision = expectedRevision,
7974
+ revision = revision,
7975
+ }
7976
+ end
7977
+ local plan = planScriptEdits(source, edits, dryRun)
7978
+ if not plan.ok then
7917
7979
  return {
7980
+ error = `Failed to edit script: {plan.error}`,
7981
+ }
7982
+ end
7983
+ if dryRun then
7984
+ local preview = {
7918
7985
  success = true,
7986
+ dryRun = true,
7919
7987
  instancePath = instancePath,
7920
- method = applyResult.method,
7921
- message = "Script edited successfully",
7988
+ editCount = #edits,
7989
+ revision = revision,
7990
+ resultRevision = sourceRevision(plan.newSource),
7991
+ diff = plan.diff,
7922
7992
  }
7923
- end)
7924
- if success then
7925
- finishRecording(recordingId, true)
7926
- return result
7993
+ if plan.diffTruncated then
7994
+ preview.diffTruncated = true
7995
+ end
7996
+ return preview
7927
7997
  end
7928
- finishRecording(recordingId, false)
7998
+ local recordingId = beginRecording(if #edits > 1 then `Edit script ({#edits} edits): {instance.Name}` else `Edit script: {instance.Name}`)
7999
+ -- One compare-and-set write: a draft typed since the read rejects the whole batch.
8000
+ local applyResult = applyScriptSource(instance, plan.newSource, source)
8001
+ if not applyResult.success then
8002
+ finishRecording(recordingId, false)
8003
+ return {
8004
+ error = `Failed to edit script: {applyResult.error}`,
8005
+ }
8006
+ end
8007
+ finishRecording(recordingId, true)
7929
8008
  return {
7930
- error = `Failed to edit script: {result}`,
8009
+ success = true,
8010
+ instancePath = instancePath,
8011
+ editCount = #edits,
8012
+ revision = sourceRevision(plan.newSource),
8013
+ method = applyResult.method,
8014
+ message = "Script edited successfully",
7931
8015
  }
7932
8016
  end
7933
8017
  local function insertScriptLines(requestData)
@@ -9448,6 +9532,166 @@ return {
9448
9532
  </Properties>
9449
9533
  </Item>
9450
9534
  <Item class="ModuleScript" referent="27">
9535
+ <Properties>
9536
+ <string name="Name">InstanceSearch</string>
9537
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
9538
+ -- Bounded instance search behind search_objects. It walks one subtree in
9539
+ -- GetChildren order, applies an optional IsA filter, and stops after
9540
+ -- maxResults matches. An ambiguous `path` is reported by PathGuard before this
9541
+ -- runs. It is written against an injected corpus so it can run outside Studio.
9542
+ -- Function properties rather than methods, so plain functions can be assigned
9543
+ -- directly under roblox-ts.
9544
+ local SEARCH_TYPES = {
9545
+ ["name"] = true,
9546
+ ["class"] = true,
9547
+ ["property"] = true,
9548
+ }
9549
+ local DEFAULT_MAX_RESULTS = 100
9550
+ local MAX_RESULTS = 10000
9551
+ local function invalid(message)
9552
+ return {
9553
+ error = "invalid_request",
9554
+ message = message,
9555
+ }
9556
+ end
9557
+ local function containsText(haystack, needle)
9558
+ return (string.find(string.lower(haystack), needle, 1, true)) ~= nil
9559
+ end
9560
+ local function createInstanceSearch(corpus)
9561
+ local function search(requestData, control)
9562
+ local rawQuery = requestData.query
9563
+ if rawQuery ~= nil and not (type(rawQuery) == "string") then
9564
+ return invalid("query must be a string")
9565
+ end
9566
+ local _condition = rawQuery
9567
+ if _condition == nil then
9568
+ _condition = ""
9569
+ end
9570
+ local query = _condition
9571
+ local _condition_1 = requestData.searchType
9572
+ if _condition_1 == nil then
9573
+ _condition_1 = "name"
9574
+ end
9575
+ local rawSearchType = _condition_1
9576
+ if not (type(rawSearchType) == "string") or not (SEARCH_TYPES[rawSearchType] ~= nil) then
9577
+ return invalid("searchType must be one of name, class, property")
9578
+ end
9579
+ local searchType = rawSearchType
9580
+ local propertyName = requestData.propertyName
9581
+ if searchType == "property" and (not (type(propertyName) == "string") or propertyName == "") then
9582
+ return invalid("propertyName is required when searchType is property")
9583
+ end
9584
+ local className = requestData.className
9585
+ if className ~= nil and (not (type(className) == "string") or className == "") then
9586
+ return invalid("className must be a non-empty string")
9587
+ end
9588
+ if query == "" and className == nil then
9589
+ return {
9590
+ error = "query is required unless className is set",
9591
+ }
9592
+ end
9593
+ local requestedMaxResults = requestData.maxResults
9594
+ if requestedMaxResults ~= nil and (not (type(requestedMaxResults) == "number") or math.floor(requestedMaxResults) ~= requestedMaxResults or requestedMaxResults < 1 or requestedMaxResults > MAX_RESULTS) then
9595
+ return invalid(`maxResults must be an integer between 1 and {MAX_RESULTS}`)
9596
+ end
9597
+ local _condition_2 = requestedMaxResults
9598
+ if _condition_2 == nil then
9599
+ _condition_2 = DEFAULT_MAX_RESULTS
9600
+ end
9601
+ local maxResults = _condition_2
9602
+ local rawPath = requestData.path
9603
+ if rawPath ~= nil and not (type(rawPath) == "string") then
9604
+ return invalid("path must be a string")
9605
+ end
9606
+ local _condition_3 = rawPath
9607
+ if _condition_3 == nil then
9608
+ _condition_3 = ""
9609
+ end
9610
+ local searchPath = _condition_3
9611
+ local root = corpus.resolveRoot(searchPath)
9612
+ if root == nil then
9613
+ return {
9614
+ error = `Path not found: {searchPath}`,
9615
+ }
9616
+ end
9617
+ local needle = string.lower(query)
9618
+ local isAmbiguous = corpus.createAmbiguityChecker()
9619
+ local function matches(node)
9620
+ if className ~= nil and not corpus.isA(node, className) then
9621
+ return false
9622
+ end
9623
+ if needle == "" then
9624
+ return true
9625
+ end
9626
+ if searchType == "name" then
9627
+ return containsText(corpus.getName(node), needle)
9628
+ end
9629
+ if searchType == "class" then
9630
+ return containsText(corpus.getClassName(node), needle)
9631
+ end
9632
+ local text = corpus.readPropertyText(node, propertyName)
9633
+ return text ~= nil and containsText(text, needle)
9634
+ end
9635
+ local results = {}
9636
+ local stack = { root }
9637
+ local truncated = false
9638
+ while #stack > 0 do
9639
+ control:checkpoint()
9640
+ -- ▼ Array.pop ▼
9641
+ local _length = #stack
9642
+ local _result = stack[_length]
9643
+ stack[_length] = nil
9644
+ -- ▲ Array.pop ▲
9645
+ local node = _result
9646
+ if matches(node) then
9647
+ if #results >= maxResults then
9648
+ truncated = true
9649
+ break
9650
+ end
9651
+ local entry = {
9652
+ name = corpus.getName(node),
9653
+ className = corpus.getClassName(node),
9654
+ path = corpus.getPath(node),
9655
+ }
9656
+ if isAmbiguous(node) then
9657
+ entry.ambiguous = true
9658
+ end
9659
+ table.insert(results, entry)
9660
+ end
9661
+ local children = corpus.getChildren(node)
9662
+ for index = #children - 1, 0, -1 do
9663
+ local _arg0 = children[index + 1]
9664
+ table.insert(stack, _arg0)
9665
+ end
9666
+ end
9667
+ local response = {
9668
+ results = results,
9669
+ query = query,
9670
+ searchType = searchType,
9671
+ count = #results,
9672
+ truncated = truncated,
9673
+ maxResults = maxResults,
9674
+ path = corpus.getPath(root),
9675
+ }
9676
+ if searchType == "property" then
9677
+ response.propertyName = propertyName
9678
+ end
9679
+ if className ~= nil then
9680
+ response.className = className
9681
+ end
9682
+ return response
9683
+ end
9684
+ return {
9685
+ search = search,
9686
+ }
9687
+ end
9688
+ return {
9689
+ createInstanceSearch = createInstanceSearch,
9690
+ }
9691
+ ]]></string>
9692
+ </Properties>
9693
+ </Item>
9694
+ <Item class="ModuleScript" referent="28">
9451
9695
  <Properties>
9452
9696
  <string name="Name">LuauExec</string>
9453
9697
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -9881,7 +10125,331 @@ return {
9881
10125
  ]]></string>
9882
10126
  </Properties>
9883
10127
  </Item>
9884
- <Item class="ModuleScript" referent="28">
10128
+ <Item class="ModuleScript" referent="29">
10129
+ <Properties>
10130
+ <string name="Name">PathAmbiguity</string>
10131
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
10132
+ -- Canonical instance paths address children by name, so siblings that share a
10133
+ -- name produce identical paths. This module resolves paths while reporting the
10134
+ -- first ambiguous segment, and marks emitted paths that cannot identify one
10135
+ -- instance. It is written against an injected tree so it can run outside Studio.
10136
+ local function findNamedChild(tree, parent, name)
10137
+ local first
10138
+ local count = 0
10139
+ for _, child in tree.getChildren(parent) do
10140
+ if tree.getName(child) == name then
10141
+ if first == nil then
10142
+ first = child
10143
+ end
10144
+ count += 1
10145
+ end
10146
+ end
10147
+ return { first, count }
10148
+ end
10149
+ -- Mirrors Utils.getInstanceByPath: the root segment prefers service lookup and
10150
+ -- later segments take the first child with a matching name. The resolved
10151
+ -- instance is returned even when ambiguous, so callers decide whether to refuse.
10152
+ local function resolvePath(tree, parts)
10153
+ if #parts == 0 then
10154
+ return {
10155
+ instance = tree.root,
10156
+ }
10157
+ end
10158
+ local ambiguity
10159
+ local current = tree.getService(parts[1])
10160
+ if current == nil then
10161
+ local _binding = findNamedChild(tree, tree.root, parts[1])
10162
+ local first = _binding[1]
10163
+ local count = _binding[2]
10164
+ current = first
10165
+ if count > 1 then
10166
+ ambiguity = {
10167
+ segment = parts[1],
10168
+ parentPath = tree.getPath(tree.root),
10169
+ matchCount = count,
10170
+ }
10171
+ end
10172
+ end
10173
+ for i = 1, #parts - 1 do
10174
+ if current == nil then
10175
+ break
10176
+ end
10177
+ local _binding = findNamedChild(tree, current, parts[i + 1])
10178
+ local first = _binding[1]
10179
+ local count = _binding[2]
10180
+ if count > 1 and ambiguity == nil then
10181
+ ambiguity = {
10182
+ segment = parts[i + 1],
10183
+ parentPath = tree.getPath(current),
10184
+ matchCount = count,
10185
+ }
10186
+ end
10187
+ current = first
10188
+ end
10189
+ return {
10190
+ instance = current,
10191
+ ambiguity = ambiguity,
10192
+ }
10193
+ end
10194
+ -- Returns a checker for one request. Sibling name counts and verdicts are
10195
+ -- cached per node, so marking many results under one parent stays linear.
10196
+ local function createAmbiguityChecker(tree)
10197
+ local nameCounts = {}
10198
+ local verdicts = {}
10199
+ local function countNamed(parent, name)
10200
+ local _parent = parent
10201
+ local counts = nameCounts[_parent]
10202
+ if counts == nil then
10203
+ counts = {}
10204
+ for _, child in tree.getChildren(parent) do
10205
+ local childName = tree.getName(child)
10206
+ local _counts = counts
10207
+ local _condition = counts[childName]
10208
+ if _condition == nil then
10209
+ _condition = 0
10210
+ end
10211
+ _counts[childName] = _condition + 1
10212
+ end
10213
+ local _parent_1 = parent
10214
+ local _counts = counts
10215
+ nameCounts[_parent_1] = _counts
10216
+ end
10217
+ local _counts = counts
10218
+ local _name = name
10219
+ local _condition = _counts[_name]
10220
+ if _condition == nil then
10221
+ _condition = 0
10222
+ end
10223
+ return _condition
10224
+ end
10225
+ local function segmentIsAmbiguous(node, parent)
10226
+ local name = tree.getName(node)
10227
+ if parent == tree.root then
10228
+ if tree.isRootService(node) then
10229
+ return false
10230
+ end
10231
+ -- A root segment that names a service resolves to that service,
10232
+ -- never to this child.
10233
+ local service = tree.getService(name)
10234
+ if service ~= nil and service ~= node then
10235
+ return true
10236
+ end
10237
+ end
10238
+ return countNamed(parent, name) > 1
10239
+ end
10240
+ return function(node)
10241
+ local visited = {}
10242
+ local result = false
10243
+ local current = node
10244
+ while current ~= tree.root do
10245
+ local _current = current
10246
+ local cached = verdicts[_current]
10247
+ if cached ~= nil then
10248
+ result = cached
10249
+ break
10250
+ end
10251
+ local parent = tree.getParent(current)
10252
+ if parent == nil then
10253
+ break
10254
+ end
10255
+ local _current_1 = current
10256
+ table.insert(visited, _current_1)
10257
+ if segmentIsAmbiguous(current, parent) then
10258
+ result = true
10259
+ break
10260
+ end
10261
+ current = parent
10262
+ end
10263
+ for _, visitedNode in visited do
10264
+ local _result = result
10265
+ verdicts[visitedNode] = _result
10266
+ end
10267
+ return result
10268
+ end
10269
+ end
10270
+ return {
10271
+ resolvePath = resolvePath,
10272
+ createAmbiguityChecker = createAmbiguityChecker,
10273
+ }
10274
+ ]]></string>
10275
+ </Properties>
10276
+ </Item>
10277
+ <Item class="ModuleScript" referent="30">
10278
+ <Properties>
10279
+ <string name="Name">PathGuard</string>
10280
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
10281
+ -- Central handling of request paths that name more than one instance.
10282
+ -- Every endpoint that accepts instance paths declares them here. Before the
10283
+ -- handler runs, each declared path is checked:
10284
+ -- - write: an ambiguous path refuses the request, so no arbitrary duplicate
10285
+ -- is modified;
10286
+ -- - read: the handler runs on the first match, and the response gains
10287
+ -- `ambiguousPaths` and `pathWarning` so the caller knows it may be wrong.
10288
+ -- Written against an injected resolver so it can run outside Studio.
10289
+ local function pathField(field, access)
10290
+ return function(data)
10291
+ local value = data[field]
10292
+ if not (type(value) == "string") or value == "" then
10293
+ return {}
10294
+ end
10295
+ local _object = {
10296
+ field = field,
10297
+ path = value,
10298
+ }
10299
+ local _left = "access"
10300
+ local _access = access
10301
+ _object[_left] = if type(_access) == "string" then access else access(data)
10302
+ return { _object }
10303
+ end
10304
+ end
10305
+ local function pathListField(field, access)
10306
+ return function(data)
10307
+ local value = data[field]
10308
+ local references = {}
10309
+ if not (type(value) == "table") then
10310
+ return references
10311
+ end
10312
+ local index = 0
10313
+ for _, entry in value do
10314
+ if type(entry) == "string" and entry ~= "" then
10315
+ local _arg0 = {
10316
+ field = `{field}[{index}]`,
10317
+ path = entry,
10318
+ access = access,
10319
+ }
10320
+ table.insert(references, _arg0)
10321
+ end
10322
+ index += 1
10323
+ end
10324
+ return references
10325
+ end
10326
+ end
10327
+ -- set_properties takes instance paths as the values of reference properties.
10328
+ local REFERENCE_PROPERTIES = { "Parent", "PrimaryPart" }
10329
+ local function referencePropertyPaths(data)
10330
+ local properties = data.properties
10331
+ local references = {}
10332
+ if not (type(properties) == "table") then
10333
+ return references
10334
+ end
10335
+ for _, propertyName in REFERENCE_PROPERTIES do
10336
+ local value = properties[propertyName]
10337
+ if type(value) == "string" and value ~= "" then
10338
+ local _arg0 = {
10339
+ field = `properties.{propertyName}`,
10340
+ path = value,
10341
+ access = "write",
10342
+ }
10343
+ table.insert(references, _arg0)
10344
+ end
10345
+ end
10346
+ return references
10347
+ end
10348
+ local dryRunIsRead = function(data)
10349
+ return if data.dryRun == true then "read" else "write"
10350
+ end
10351
+ local ENDPOINT_PATHS = {
10352
+ ["/api/file-tree"] = { pathField("path", "read") },
10353
+ ["/api/search-objects"] = { pathField("path", "read") },
10354
+ ["/api/instance-properties"] = { pathField("instancePath", "read") },
10355
+ ["/api/project-structure"] = { pathField("path", "read") },
10356
+ ["/api/grep-scripts"] = { pathField("path", "read") },
10357
+ ["/api/get-script-source"] = { pathField("instancePath", "read") },
10358
+ ["/api/get-attributes"] = { pathField("instancePath", "read") },
10359
+ ["/api/set-selection"] = { pathListField("paths", "read") },
10360
+ ["/api/focus-viewport"] = { pathField("path", "read") },
10361
+ ["/api/breakpoints"] = { pathField("script_path", "read") },
10362
+ ["/api/export-rbxm"] = { pathListField("instance_paths", "read") },
10363
+ ["/api/set-properties"] = { pathField("instancePath", "write"), referencePropertyPaths },
10364
+ ["/api/set-script-source"] = { pathField("instancePath", "write") },
10365
+ ["/api/edit-script-lines"] = { pathField("instancePath", dryRunIsRead) },
10366
+ ["/api/insert-script-lines"] = { pathField("instancePath", "write") },
10367
+ ["/api/delete-script-lines"] = { pathField("instancePath", "write") },
10368
+ ["/api/find-and-replace-in-scripts"] = { pathField("path", dryRunIsRead) },
10369
+ ["/api/insert-asset"] = { pathField("parentPath", "write") },
10370
+ ["/api/import-rbxm"] = { pathField("parent_path", "write") },
10371
+ }
10372
+ local function describe(report)
10373
+ return `{report.path} ({report.field}): {report.matchCount} children of {report.parentPath} are named "{report.segment}"`
10374
+ end
10375
+ local function describeAll(reports)
10376
+ local parts = {}
10377
+ for _, report in reports do
10378
+ local _arg0 = describe(report)
10379
+ table.insert(parts, _arg0)
10380
+ end
10381
+ return table.concat(parts, "; ")
10382
+ end
10383
+ local function createPathGuard(findAmbiguity)
10384
+ local function inspect(endpoint, data)
10385
+ local reports = {}
10386
+ local extractors = ENDPOINT_PATHS[endpoint]
10387
+ if extractors == nil then
10388
+ return reports
10389
+ end
10390
+ for _, extract in extractors do
10391
+ for _1, reference in extract(data) do
10392
+ local ambiguity = findAmbiguity(reference.path)
10393
+ if ambiguity ~= nil then
10394
+ local _arg0 = {
10395
+ field = reference.field,
10396
+ path = reference.path,
10397
+ access = reference.access,
10398
+ segment = ambiguity.segment,
10399
+ parentPath = ambiguity.parentPath,
10400
+ matchCount = ambiguity.matchCount,
10401
+ }
10402
+ table.insert(reports, _arg0)
10403
+ end
10404
+ end
10405
+ end
10406
+ return reports
10407
+ end
10408
+ local function run(endpoint, data, handle)
10409
+ local reports = inspect(endpoint, data)
10410
+ if #reports == 0 then
10411
+ return handle()
10412
+ end
10413
+ -- ▼ ReadonlyArray.filter ▼
10414
+ local _newValue = {}
10415
+ local _callback = function(report)
10416
+ return report.access == "write"
10417
+ end
10418
+ local _length = 0
10419
+ for _k, _v in reports do
10420
+ if _callback(_v, _k - 1, reports) == true then
10421
+ _length += 1
10422
+ _newValue[_length] = _v
10423
+ end
10424
+ end
10425
+ -- ▲ ReadonlyArray.filter ▲
10426
+ local writes = _newValue
10427
+ if #writes > 0 then
10428
+ return {
10429
+ error = `Ambiguous path {describeAll(writes)}. Refusing to modify an arbitrary match; rename the duplicates or target the instance with execute_luau.`,
10430
+ ambiguousPaths = writes,
10431
+ }
10432
+ end
10433
+ local result = handle()
10434
+ if type(result) == "table" then
10435
+ local response = result
10436
+ response.ambiguousPaths = reports
10437
+ response.pathWarning = `Ambiguous path {describeAll(reports)}; the first match was used.`
10438
+ end
10439
+ return result
10440
+ end
10441
+ return {
10442
+ inspect = inspect,
10443
+ run = run,
10444
+ }
10445
+ end
10446
+ return {
10447
+ createPathGuard = createPathGuard,
10448
+ }
10449
+ ]]></string>
10450
+ </Properties>
10451
+ </Item>
10452
+ <Item class="ModuleScript" referent="31">
9885
10453
  <Properties>
9886
10454
  <string name="Name">PeerRole</string>
9887
10455
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -9902,7 +10470,7 @@ return {
9902
10470
  ]]></string>
9903
10471
  </Properties>
9904
10472
  </Item>
9905
- <Item class="ModuleScript" referent="29">
10473
+ <Item class="ModuleScript" referent="32">
9906
10474
  <Properties>
9907
10475
  <string name="Name">PlaytestTickets</string>
9908
10476
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -10083,7 +10651,7 @@ return {
10083
10651
  ]]></string>
10084
10652
  </Properties>
10085
10653
  </Item>
10086
- <Item class="ModuleScript" referent="30">
10654
+ <Item class="ModuleScript" referent="33">
10087
10655
  <Properties>
10088
10656
  <string name="Name">PluginSession</string>
10089
10657
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -10750,7 +11318,7 @@ return {
10750
11318
  ]]></string>
10751
11319
  </Properties>
10752
11320
  </Item>
10753
- <Item class="ModuleScript" referent="31">
11321
+ <Item class="ModuleScript" referent="34">
10754
11322
  <Properties>
10755
11323
  <string name="Name">Recording</string>
10756
11324
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -10780,7 +11348,7 @@ return {
10780
11348
  ]]></string>
10781
11349
  </Properties>
10782
11350
  </Item>
10783
- <Item class="ModuleScript" referent="32">
11351
+ <Item class="ModuleScript" referent="35">
10784
11352
  <Properties>
10785
11353
  <string name="Name">RenderMonitor</string>
10786
11354
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -10848,7 +11416,7 @@ return {
10848
11416
  ]]></string>
10849
11417
  </Properties>
10850
11418
  </Item>
10851
- <Item class="ModuleScript" referent="33">
11419
+ <Item class="ModuleScript" referent="36">
10852
11420
  <Properties>
10853
11421
  <string name="Name">RuntimeLogBuffer</string>
10854
11422
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -11084,7 +11652,357 @@ return {
11084
11652
  ]]></string>
11085
11653
  </Properties>
11086
11654
  </Item>
11087
- <Item class="ModuleScript" referent="34">
11655
+ <Item class="ModuleScript" referent="37">
11656
+ <Properties>
11657
+ <string name="Name">ScriptEdits</string>
11658
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
11659
+ -- Exact-text script edits resolved against one source snapshot, so several
11660
+ -- replacements either all apply or none do. Kept free of Studio services so
11661
+ -- the planning logic can be exercised outside Studio.
11662
+ local FNV_OFFSET_BASIS = 2166136261
11663
+ local UINT32_MODULUS = 4294967296
11664
+ local BYTE_CHUNK = 4096
11665
+ local DIFF_CHAR_BUDGET = 20000
11666
+ --[[
11667
+ *
11668
+ * Content revision of the exact script text: byte length plus FNV-1a 32.
11669
+ * It is an optimistic-concurrency token, not a security hash.
11670
+
11671
+ ]]
11672
+ local function sourceRevision(source)
11673
+ local length = #source
11674
+ local hash = FNV_OFFSET_BASIS
11675
+ do
11676
+ local chunkStart = 1
11677
+ local _shouldIncrement = false
11678
+ while true do
11679
+ if _shouldIncrement then
11680
+ chunkStart += BYTE_CHUNK
11681
+ else
11682
+ _shouldIncrement = true
11683
+ end
11684
+ if not (chunkStart <= length) then
11685
+ break
11686
+ end
11687
+ local _array = {}
11688
+ local _length = #_array
11689
+ local _array_1 = { string.byte(source, chunkStart, math.min(chunkStart + BYTE_CHUNK - 1, length)) }
11690
+ table.move(_array_1, 1, #_array_1, _length + 1, _array)
11691
+ local bytes = _array
11692
+ for _, byte in bytes do
11693
+ hash = bit32.bxor(hash, byte)
11694
+ -- hash * 16777619 mod 2^32. The FNV prime is 2^24 + 403; splitting the
11695
+ -- product keeps every intermediate below 2^53, where doubles stay exact.
11696
+ hash = (bit32.lshift(hash, 24) + hash * 403) % UINT32_MODULUS
11697
+ end
11698
+ end
11699
+ end
11700
+ return `{length}:{hash}`
11701
+ end
11702
+ -- 1-based byte offset at which each line starts. A trailing newline yields a
11703
+ -- final empty line, which is where an anchored insertion at EOF lands.
11704
+ local function lineStarts(source)
11705
+ local starts = { 1 }
11706
+ local searchFrom = 1
11707
+ while true do
11708
+ local newline = string.find(source, "\n", searchFrom, true)
11709
+ if newline == nil then
11710
+ break
11711
+ end
11712
+ local _arg0 = newline + 1
11713
+ table.insert(starts, _arg0)
11714
+ searchFrom = newline + 1
11715
+ end
11716
+ return starts
11717
+ end
11718
+ -- Line containing a byte offset; a newline belongs to the line it ends.
11719
+ local function lineAt(starts, offset)
11720
+ local low = 0
11721
+ local high = #starts - 1
11722
+ while low < high do
11723
+ local middle = math.floor((low + high + 1) / 2)
11724
+ if starts[middle + 1] <= offset then
11725
+ low = middle
11726
+ else
11727
+ high = middle - 1
11728
+ end
11729
+ end
11730
+ return low + 1
11731
+ end
11732
+ -- Byte offset just past the line's terminator, or past the end of the source.
11733
+ local function lineEnd(starts, source, line)
11734
+ return if line < #starts then starts[line + 1] else #source + 1
11735
+ end
11736
+ local function locateEdit(source, starts, edit, label)
11737
+ local searchLength = #edit.oldString
11738
+ if edit.startLine ~= nil then
11739
+ if edit.startLine < 1 or edit.startLine % 1 ~= 0 then
11740
+ return `{label}line must be a positive whole number (got {edit.startLine}).`
11741
+ end
11742
+ if edit.startLine > #starts then
11743
+ return `{label}line {edit.startLine} is past the end of the script.`
11744
+ end
11745
+ local start = starts[edit.startLine]
11746
+ if string.sub(source, start, start + searchLength - 1) ~= edit.oldString then
11747
+ return `{label}old_string does not match at line {edit.startLine}. Use get_script_source to verify the exact text at that line.`
11748
+ end
11749
+ return start
11750
+ end
11751
+ if searchLength == 0 then
11752
+ return `{label}old_string is empty; pass line_range to insert at the start of a line.`
11753
+ end
11754
+ local first = string.find(source, edit.oldString, 1, true)
11755
+ if first == nil then
11756
+ return `{label}old_string not found in script. If old_string contains repeated patterns (e.g. closing braces), pass line_range to anchor the edit.`
11757
+ end
11758
+ local second = string.find(source, edit.oldString, first + searchLength, true)
11759
+ if second ~= nil then
11760
+ return `{label}old_string matches multiple locations (lines {lineAt(starts, first)} and {lineAt(starts, second)}). Provide more surrounding context, or pass line_range to anchor the edit.`
11761
+ end
11762
+ return first
11763
+ end
11764
+ -- Source text in [regionStart, regionEnd) with every edit in resolved[from, to) applied.
11765
+ local function applyWithin(source, resolved, from, to, regionStart, regionEnd)
11766
+ local parts = {}
11767
+ local cursor = regionStart
11768
+ do
11769
+ local index = from
11770
+ local _shouldIncrement = false
11771
+ while true do
11772
+ if _shouldIncrement then
11773
+ index += 1
11774
+ else
11775
+ _shouldIncrement = true
11776
+ end
11777
+ if not (index < to) then
11778
+ break
11779
+ end
11780
+ local edit = resolved[index + 1]
11781
+ local _arg0 = string.sub(source, cursor, edit.start - 1)
11782
+ table.insert(parts, _arg0)
11783
+ local _newString = edit.newString
11784
+ table.insert(parts, _newString)
11785
+ cursor = edit.finish
11786
+ end
11787
+ end
11788
+ local _arg0 = string.sub(source, cursor, regionEnd - 1)
11789
+ table.insert(parts, _arg0)
11790
+ return table.concat(parts, "")
11791
+ end
11792
+ local NO_FINAL_NEWLINE = "\"
11793
+ -- Lines of a diff region. A last line without its newline only occurs at the
11794
+ -- end of the file; it is flagged so that adding or removing that newline still
11795
+ -- differs, as in a unified diff.
11796
+ local function textLines(text)
11797
+ if text == "" then
11798
+ return {
11799
+ lines = {},
11800
+ missingFinalNewline = false,
11801
+ }
11802
+ end
11803
+ local lines = string.split(text, "\n")
11804
+ local missingFinalNewline = string.sub(text, -1) ~= "\n"
11805
+ if not missingFinalNewline then
11806
+ lines[#lines] = nil
11807
+ end
11808
+ return {
11809
+ lines = lines,
11810
+ missingFinalNewline = missingFinalNewline,
11811
+ }
11812
+ end
11813
+ local function sameLine(a, aIndex, b, bIndex)
11814
+ return a.lines[aIndex + 1] == b.lines[bIndex + 1] and (a.missingFinalNewline and aIndex == #a.lines - 1) == (b.missingFinalNewline and bIndex == #b.lines - 1)
11815
+ end
11816
+ local function pushHunkLines(hunk, text, from, count, prefix)
11817
+ do
11818
+ local index = from
11819
+ local _shouldIncrement = false
11820
+ while true do
11821
+ if _shouldIncrement then
11822
+ index += 1
11823
+ else
11824
+ _shouldIncrement = true
11825
+ end
11826
+ if not (index < from + count) then
11827
+ break
11828
+ end
11829
+ local _hunk = hunk
11830
+ local _arg0 = `{prefix}{text.lines[index + 1]}`
11831
+ table.insert(_hunk, _arg0)
11832
+ if text.missingFinalNewline and index == #text.lines - 1 then
11833
+ table.insert(hunk, NO_FINAL_NEWLINE)
11834
+ end
11835
+ end
11836
+ end
11837
+ end
11838
+ local function hunkStart(line, count)
11839
+ return if count == 0 then line - 1 else line
11840
+ end
11841
+ local function editFirstLine(starts, edit)
11842
+ return lineAt(starts, edit.start)
11843
+ end
11844
+ local function editLastLine(starts, edit)
11845
+ return lineAt(starts, if edit.finish > edit.start then edit.finish - 1 else edit.start)
11846
+ end
11847
+ -- Unified diff of whole changed lines, without context lines. Edits that share
11848
+ -- a line become one hunk; lines the edit left unchanged are trimmed away.
11849
+ local function buildDiff(source, starts, resolved)
11850
+ local output = {}
11851
+ local outputSize = 0
11852
+ local truncated = false
11853
+ local lineDelta = 0
11854
+ local groupStart = 0
11855
+ while groupStart < #resolved and not truncated do
11856
+ local firstLine = editFirstLine(starts, resolved[groupStart + 1])
11857
+ local lastLine = editLastLine(starts, resolved[groupStart + 1])
11858
+ local groupEnd = groupStart + 1
11859
+ local oldText
11860
+ local newText
11861
+ while true do
11862
+ while groupEnd < #resolved and editFirstLine(starts, resolved[groupEnd + 1]) <= lastLine do
11863
+ lastLine = math.max(lastLine, editLastLine(starts, resolved[groupEnd + 1]))
11864
+ groupEnd += 1
11865
+ end
11866
+ local regionStart = starts[firstLine]
11867
+ local regionEnd = lineEnd(starts, source, lastLine)
11868
+ oldText = string.sub(source, regionStart, regionEnd - 1)
11869
+ newText = applyWithin(source, resolved, groupStart, groupEnd, regionStart, regionEnd)
11870
+ -- A replacement that drops the region's final newline joins the next
11871
+ -- line onto it, so that line belongs in the hunk too.
11872
+ local nextLineHasText = lastLine < #starts and starts[lastLine + 1] <= #source
11873
+ if nextLineHasText and string.sub(oldText, -1) == "\n" and string.sub(newText, -1) ~= "\n" then
11874
+ lastLine += 1
11875
+ continue
11876
+ end
11877
+ break
11878
+ end
11879
+ local oldRegion = textLines(oldText)
11880
+ local newRegion = textLines(newText)
11881
+ local oldSize = #oldRegion.lines
11882
+ local newSize = #newRegion.lines
11883
+ local prefix = 0
11884
+ while prefix < oldSize and prefix < newSize and sameLine(oldRegion, prefix, newRegion, prefix) do
11885
+ prefix += 1
11886
+ end
11887
+ local suffix = 0
11888
+ while suffix < oldSize - prefix and suffix < newSize - prefix and sameLine(oldRegion, oldSize - 1 - suffix, newRegion, newSize - 1 - suffix) do
11889
+ suffix += 1
11890
+ end
11891
+ local oldCount = oldSize - prefix - suffix
11892
+ local newCount = newSize - prefix - suffix
11893
+ if oldCount > 0 or newCount > 0 then
11894
+ local oldStart = firstLine + prefix
11895
+ local newStart = oldStart + lineDelta
11896
+ local hunk = { `@@ -{hunkStart(oldStart, oldCount)},{oldCount} +{hunkStart(newStart, newCount)},{newCount} @@` }
11897
+ pushHunkLines(hunk, oldRegion, prefix, oldCount, "-")
11898
+ pushHunkLines(hunk, newRegion, prefix, newCount, "+")
11899
+ for _, line in hunk do
11900
+ if outputSize + #line + 1 > DIFF_CHAR_BUDGET then
11901
+ truncated = true
11902
+ break
11903
+ end
11904
+ table.insert(output, line)
11905
+ outputSize += #line + 1
11906
+ end
11907
+ end
11908
+ lineDelta += newSize - oldSize
11909
+ groupStart = groupEnd
11910
+ end
11911
+ return {
11912
+ diff = table.concat(output, "\n"),
11913
+ truncated = truncated,
11914
+ }
11915
+ end
11916
+ --[[
11917
+ *
11918
+ * Resolves every edit against the same source, rejects overlaps, and builds
11919
+ * the edited source in memory. Nothing is written: the caller applies
11920
+ * newSource in one compare-and-set write, so a bad edit changes nothing.
11921
+ * line_range anchors refer to the source as it was before any edit.
11922
+
11923
+ ]]
11924
+ local function planScriptEdits(source, edits, includeDiff)
11925
+ if #edits == 0 then
11926
+ return {
11927
+ ok = false,
11928
+ error = "At least one edit is required.",
11929
+ }
11930
+ end
11931
+ local starts = lineStarts(source)
11932
+ local resolved = {}
11933
+ for index = 0, #edits - 1 do
11934
+ local edit = edits[index + 1]
11935
+ local label = if #edits > 1 then `edits[{index}]: ` else ""
11936
+ local located = locateEdit(source, starts, edit, label)
11937
+ if type(located) == "string" then
11938
+ return {
11939
+ ok = false,
11940
+ error = located,
11941
+ }
11942
+ end
11943
+ local _arg0 = {
11944
+ index = index,
11945
+ start = located,
11946
+ finish = located + #edit.oldString,
11947
+ newString = edit.newString,
11948
+ }
11949
+ table.insert(resolved, _arg0)
11950
+ -- Insertion sort by position; k is small and ties keep request order.
11951
+ do
11952
+ local at = #resolved - 1
11953
+ local _shouldIncrement = false
11954
+ while true do
11955
+ if _shouldIncrement then
11956
+ at -= 1
11957
+ else
11958
+ _shouldIncrement = true
11959
+ end
11960
+ if not (at > 0 and resolved[at].start > resolved[at + 1].start) then
11961
+ break
11962
+ end
11963
+ local swap = resolved[at]
11964
+ resolved[at] = resolved[at + 1]
11965
+ resolved[at + 1] = swap
11966
+ end
11967
+ end
11968
+ end
11969
+ for at = 1, #resolved - 1 do
11970
+ local previous = resolved[at]
11971
+ local current = resolved[at + 1]
11972
+ if current.start < previous.finish or current.start == previous.start then
11973
+ local first = math.min(previous.index, current.index)
11974
+ local second = math.max(previous.index, current.index)
11975
+ return {
11976
+ ok = false,
11977
+ error = `edits[{first}] and edits[{second}] overlap at line {lineAt(starts, current.start)}; combine them into one edit.`,
11978
+ }
11979
+ end
11980
+ end
11981
+ local newSource = applyWithin(source, resolved, 0, #resolved, 1, #source + 1)
11982
+ if not includeDiff then
11983
+ return {
11984
+ ok = true,
11985
+ newSource = newSource,
11986
+ diff = "",
11987
+ diffTruncated = false,
11988
+ }
11989
+ end
11990
+ local built = buildDiff(source, starts, resolved)
11991
+ return {
11992
+ ok = true,
11993
+ newSource = newSource,
11994
+ diff = built.diff,
11995
+ diffTruncated = built.truncated,
11996
+ }
11997
+ end
11998
+ return {
11999
+ sourceRevision = sourceRevision,
12000
+ planScriptEdits = planScriptEdits,
12001
+ }
12002
+ ]]></string>
12003
+ </Properties>
12004
+ </Item>
12005
+ <Item class="ModuleScript" referent="38">
11088
12006
  <Properties>
11089
12007
  <string name="Name">ScriptSearch</string>
11090
12008
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -11273,6 +12191,18 @@ local function createScriptSearch(corpus)
11273
12191
  error = `Path not found: {searchPath}`,
11274
12192
  }
11275
12193
  end
12194
+ local _self = corpus
12195
+ local _result_1 = _self.createAmbiguityChecker
12196
+ if _result_1 ~= nil then
12197
+ _result_1 = _result_1(_self)
12198
+ end
12199
+ local _condition_6 = _result_1
12200
+ if _condition_6 == nil then
12201
+ _condition_6 = (function()
12202
+ return false
12203
+ end)
12204
+ end
12205
+ local isAmbiguous = _condition_6
11276
12206
  local searchPattern = if caseSensitive then pattern else string.lower(pattern)
11277
12207
  local patternAlternatives = if usePattern then splitLuaAlternation(searchPattern) else nil
11278
12208
  local results = {}
@@ -11284,10 +12214,10 @@ local function createScriptSearch(corpus)
11284
12214
  control:checkpoint()
11285
12215
  -- ▼ Array.pop ▼
11286
12216
  local _length = #stack
11287
- local _result_1 = stack[_length]
12217
+ local _result_2 = stack[_length]
11288
12218
  stack[_length] = nil
11289
12219
  -- ▲ Array.pop ▲
11290
- local instance = _result_1
12220
+ local instance = _result_2
11291
12221
  local snapshot = corpus:readScript(instance, classFilter)
11292
12222
  if snapshot ~= nil and (classFilter == nil or snapshot.className == classFilter) then
11293
12223
  scriptsSearched += 1
@@ -11372,6 +12302,9 @@ local function createScriptSearch(corpus)
11372
12302
  if snapshot.enabled ~= nil then
11373
12303
  scriptResult.enabled = snapshot.enabled
11374
12304
  end
12305
+ if isAmbiguous(instance) then
12306
+ scriptResult.ambiguous = true
12307
+ end
11375
12308
  table.insert(results, scriptResult)
11376
12309
  end
11377
12310
  end
@@ -11411,7 +12344,7 @@ return {
11411
12344
  ]]></string>
11412
12345
  </Properties>
11413
12346
  </Item>
11414
- <Item class="ModuleScript" referent="35">
12347
+ <Item class="ModuleScript" referent="39">
11415
12348
  <Properties>
11416
12349
  <string name="Name">ServerUrlSettings</string>
11417
12350
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -11525,11 +12458,11 @@ return {
11525
12458
  ]]></string>
11526
12459
  </Properties>
11527
12460
  </Item>
11528
- <Item class="ModuleScript" referent="36">
12461
+ <Item class="ModuleScript" referent="40">
11529
12462
  <Properties>
11530
12463
  <string name="Name">State</string>
11531
12464
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
11532
- local CURRENT_VERSION = "3.2.1"
12465
+ local CURRENT_VERSION = "3.3.0"
11533
12466
  local PLUGIN_VARIANT = "main"
11534
12467
  local BASE_PORT = 58741
11535
12468
  local SENSITIVE_DIAGNOSTIC_TERMS = { "token", "authorization", "bearer", "password", "cookie", "secret" }
@@ -11667,7 +12600,7 @@ return {
11667
12600
  ]]></string>
11668
12601
  </Properties>
11669
12602
  </Item>
11670
- <Item class="ModuleScript" referent="37">
12603
+ <Item class="ModuleScript" referent="41">
11671
12604
  <Properties>
11672
12605
  <string name="Name">StopPlayMonitor</string>
11673
12606
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -11953,7 +12886,7 @@ return {
11953
12886
  ]]></string>
11954
12887
  </Properties>
11955
12888
  </Item>
11956
- <Item class="ModuleScript" referent="38">
12889
+ <Item class="ModuleScript" referent="42">
11957
12890
  <Properties>
11958
12891
  <string name="Name">StudioWebSocket</string>
11959
12892
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -13111,7 +14044,7 @@ return {
13111
14044
  ]]></string>
13112
14045
  </Properties>
13113
14046
  </Item>
13114
- <Item class="ModuleScript" referent="39">
14047
+ <Item class="ModuleScript" referent="43">
13115
14048
  <Properties>
13116
14049
  <string name="Name">TopologyId</string>
13117
14050
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -13156,7 +14089,7 @@ return {
13156
14089
  ]]></string>
13157
14090
  </Properties>
13158
14091
  </Item>
13159
- <Item class="ModuleScript" referent="40">
14092
+ <Item class="ModuleScript" referent="44">
13160
14093
  <Properties>
13161
14094
  <string name="Name">UI</string>
13162
14095
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -13681,10 +14614,12 @@ return {
13681
14614
  ]]></string>
13682
14615
  </Properties>
13683
14616
  </Item>
13684
- <Item class="ModuleScript" referent="41">
14617
+ <Item class="ModuleScript" referent="45">
13685
14618
  <Properties>
13686
14619
  <string name="Name">Utils</string>
13687
14620
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
14621
+ local TS = require(script.Parent.Parent.include.RuntimeLib)
14622
+ local PathAmbiguity = TS.import(script, script.Parent, "PathAmbiguity")
13688
14623
  local ScriptEditorService = game:GetService("ScriptEditorService")
13689
14624
  local LUAU_KEYWORDS = {
13690
14625
  ["and"] = true,
@@ -13899,16 +14834,17 @@ local function parseInstancePath(path)
13899
14834
  end
13900
14835
  return parts
13901
14836
  end
14837
+ local function getService(segment)
14838
+ local ok, service = pcall(function()
14839
+ return game:GetService(segment)
14840
+ end)
14841
+ return if ok and service then service else nil
14842
+ end
14843
+ local function isRootService(instance)
14844
+ return instance.Parent == game and getService(instance.ClassName) == instance
14845
+ end
13902
14846
  local function getRootSegment(instance)
13903
- if instance.Parent == game then
13904
- local ok, service = pcall(function()
13905
- return game:GetService(instance.ClassName)
13906
- end)
13907
- if ok and service == instance then
13908
- return instance.ClassName
13909
- end
13910
- end
13911
- return instance.Name
14847
+ return if isRootService(instance) then instance.ClassName else instance.Name
13912
14848
  end
13913
14849
  local function getInstancePath(instance)
13914
14850
  if not instance or instance == game then
@@ -13932,13 +14868,7 @@ local function getInstancePath(instance)
13932
14868
  return path
13933
14869
  end
13934
14870
  local function getRootInstance(segment)
13935
- local ok, service = pcall(function()
13936
- return game:GetService(segment)
13937
- end)
13938
- if ok and service then
13939
- return service
13940
- end
13941
- return game:FindFirstChild(segment)
14871
+ return getService(segment) or game:FindFirstChild(segment)
13942
14872
  end
13943
14873
  local function getInstanceByPath(path)
13944
14874
  local parts = parseInstancePath(path)
@@ -13958,6 +14888,36 @@ local function getInstanceByPath(path)
13958
14888
  end
13959
14889
  return current
13960
14890
  end
14891
+ local instancePathTree = {
14892
+ root = game,
14893
+ getParent = function(instance)
14894
+ return instance.Parent
14895
+ end,
14896
+ getChildren = function(instance)
14897
+ return instance:GetChildren()
14898
+ end,
14899
+ getName = function(instance)
14900
+ return instance.Name
14901
+ end,
14902
+ getService = getService,
14903
+ isRootService = isRootService,
14904
+ getPath = getInstancePath,
14905
+ }
14906
+ -- Like getInstanceByPath, but also reports the first segment that names more
14907
+ -- than one sibling. Costs one child scan per segment. PathGuard applies it to
14908
+ -- every declared request path before the handler runs.
14909
+ local function resolveInstancePath(path)
14910
+ local parts = parseInstancePath(path)
14911
+ if parts == nil then
14912
+ return {}
14913
+ end
14914
+ return PathAmbiguity.resolvePath(instancePathTree, parts)
14915
+ end
14916
+ -- Returns a per-request predicate that is true when getInstancePath(instance)
14917
+ -- does not identify exactly that instance.
14918
+ local function createPathAmbiguityChecker()
14919
+ return PathAmbiguity.createAmbiguityChecker(instancePathTree)
14920
+ end
13961
14921
  local function splitLines(source)
13962
14922
  local _condition = source
13963
14923
  if _condition == nil then
@@ -14512,6 +15472,8 @@ return {
14512
15472
  safeCall = safeCall,
14513
15473
  getInstancePath = getInstancePath,
14514
15474
  getInstanceByPath = getInstanceByPath,
15475
+ resolveInstancePath = resolveInstancePath,
15476
+ createPathAmbiguityChecker = createPathAmbiguityChecker,
14515
15477
  splitLines = splitLines,
14516
15478
  joinLines = joinLines,
14517
15479
  readScriptSource = readScriptSource,
@@ -14524,11 +15486,11 @@ return {
14524
15486
  </Properties>
14525
15487
  </Item>
14526
15488
  </Item>
14527
- <Item class="Folder" referent="46">
15489
+ <Item class="Folder" referent="50">
14528
15490
  <Properties>
14529
15491
  <string name="Name">include</string>
14530
15492
  </Properties>
14531
- <Item class="ModuleScript" referent="42">
15493
+ <Item class="ModuleScript" referent="46">
14532
15494
  <Properties>
14533
15495
  <string name="Name">LibMP</string>
14534
15496
  <string name="Source"><![CDATA[-- =============================================================================
@@ -170912,7 +171874,7 @@ return LibMP
170912
171874
  ]]></string>
170913
171875
  </Properties>
170914
171876
  </Item>
170915
- <Item class="ModuleScript" referent="43">
171877
+ <Item class="ModuleScript" referent="47">
170916
171878
  <Properties>
170917
171879
  <string name="Name">Promise</string>
170918
171880
  <string name="Source"><![CDATA[--[[
@@ -172986,7 +173948,7 @@ return Promise
172986
173948
  ]]></string>
172987
173949
  </Properties>
172988
173950
  </Item>
172989
- <Item class="ModuleScript" referent="44">
173951
+ <Item class="ModuleScript" referent="48">
172990
173952
  <Properties>
172991
173953
  <string name="Name">RuntimeLib</string>
172992
173954
  <string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
@@ -173253,15 +174215,15 @@ return TS
173253
174215
  </Properties>
173254
174216
  </Item>
173255
174217
  </Item>
173256
- <Item class="Folder" referent="47">
174218
+ <Item class="Folder" referent="51">
173257
174219
  <Properties>
173258
174220
  <string name="Name">node_modules</string>
173259
174221
  </Properties>
173260
- <Item class="Folder" referent="48">
174222
+ <Item class="Folder" referent="52">
173261
174223
  <Properties>
173262
174224
  <string name="Name">@rbxts</string>
173263
174225
  </Properties>
173264
- <Item class="ModuleScript" referent="45">
174226
+ <Item class="ModuleScript" referent="49">
173265
174227
  <Properties>
173266
174228
  <string name="Name">services</string>
173267
174229
  <string name="Source"><![CDATA[return setmetatable({}, {