@el4cteo/rbx-studio-mcp 0.6.1 → 0.6.7

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.
Files changed (75) hide show
  1. package/README.md +28 -2
  2. package/dist/bridge/console.js +182 -0
  3. package/dist/bridge/console.js.map +1 -1
  4. package/dist/index.js +8 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/cloudassets.js +233 -0
  7. package/dist/lib/cloudassets.js.map +1 -0
  8. package/dist/lib/credentials.js +180 -0
  9. package/dist/lib/credentials.js.map +1 -0
  10. package/dist/lib/livedata.js +325 -0
  11. package/dist/lib/livedata.js.map +1 -0
  12. package/dist/lib/liveluau.js +83 -0
  13. package/dist/lib/liveluau.js.map +1 -0
  14. package/dist/lib/liveops.js +358 -0
  15. package/dist/lib/liveops.js.map +1 -0
  16. package/dist/lib/opencloud.js +235 -0
  17. package/dist/lib/opencloud.js.map +1 -0
  18. package/dist/tools/anim.js +159 -0
  19. package/dist/tools/anim.js.map +1 -0
  20. package/dist/tools/audio.js +96 -0
  21. package/dist/tools/audio.js.map +1 -0
  22. package/dist/tools/character.js +95 -5
  23. package/dist/tools/character.js.map +1 -1
  24. package/dist/tools/data.js +292 -0
  25. package/dist/tools/data.js.map +1 -0
  26. package/dist/tools/device.js +77 -7
  27. package/dist/tools/device.js.map +1 -1
  28. package/dist/tools/discover.js +80 -4
  29. package/dist/tools/discover.js.map +1 -1
  30. package/dist/tools/exec.js +96 -2
  31. package/dist/tools/exec.js.map +1 -1
  32. package/dist/tools/input.js +35 -9
  33. package/dist/tools/input.js.map +1 -1
  34. package/dist/tools/perf.js +74 -7
  35. package/dist/tools/perf.js.map +1 -1
  36. package/dist/tools/scripts.js +162 -6
  37. package/dist/tools/scripts.js.map +1 -1
  38. package/dist/tools/spatial.js +135 -0
  39. package/dist/tools/spatial.js.map +1 -0
  40. package/dist/tools/universe.js +177 -0
  41. package/dist/tools/universe.js.map +1 -0
  42. package/dist/tools/upload.js +294 -0
  43. package/dist/tools/upload.js.map +1 -0
  44. package/dist/tools/world.js +675 -51
  45. package/dist/tools/world.js.map +1 -1
  46. package/package.json +2 -2
  47. package/plugin/src/Commands.luau +31 -7
  48. package/plugin/src/Config.luau +65 -65
  49. package/plugin/src/Console.luau +1909 -1843
  50. package/plugin/src/Emulation.luau +172 -0
  51. package/plugin/src/Phrase.luau +816 -618
  52. package/plugin/src/Png.luau +8 -4
  53. package/plugin/src/Prompt.luau +965 -961
  54. package/plugin/src/Secret.luau +86 -0
  55. package/plugin/src/Serialize.luau +440 -8
  56. package/plugin/src/Undo.luau +94 -6
  57. package/plugin/src/handlers/Anim.luau +897 -0
  58. package/plugin/src/handlers/Assets.luau +286 -2
  59. package/plugin/src/handlers/Audio.luau +411 -0
  60. package/plugin/src/handlers/Capture.luau +155 -20
  61. package/plugin/src/handlers/Character.luau +823 -361
  62. package/plugin/src/handlers/Data.luau +539 -0
  63. package/plugin/src/handlers/Device.luau +394 -139
  64. package/plugin/src/handlers/Discover.luau +685 -363
  65. package/plugin/src/handlers/Geometry.luau +722 -450
  66. package/plugin/src/handlers/Instances.luau +84 -4
  67. package/plugin/src/handlers/Perf.luau +227 -0
  68. package/plugin/src/handlers/Scripts.luau +673 -539
  69. package/plugin/src/handlers/Session.luau +3 -0
  70. package/plugin/src/handlers/Spatial.luau +334 -0
  71. package/plugin/src/handlers/Viewport.luau +268 -0
  72. package/plugin/src/handlers/World.luau +89 -15
  73. package/plugin/src/init.server.luau +9 -1
  74. package/scripts/build-plugin.mjs +20 -0
  75. package/scripts/check-plugin.mjs +171 -124
@@ -18,15 +18,21 @@
18
18
  local ChangeHistoryService = game:GetService("ChangeHistoryService")
19
19
 
20
20
  --[[
21
- Collision groups are read and written through the Workspace, not PhysicsService.
21
+ Collision groups are read and written through a WorldRoot, not PhysicsService.
22
22
 
23
23
  Roblox moved collision group management onto WorldRoot in September 2026 and
24
24
  deprecated the PhysicsService methods in the same announcement. Both still
25
- work today -- checked against Studio 0.737 -- so this is a migration ahead of
26
- a removal rather than a fix. The move also means a WorldModel gets its own
27
- groups instead of sharing the Workspace's, which is the point of it.
25
+ work today -- checked against Studio 0.738 -- so this is a migration ahead of
26
+ a removal rather than a fix.
27
+
28
+ The move is not only a rename. A `WorldModel` is a WorldRoot too, and it now
29
+ keeps its OWN registry: a group named "Doors" inside a viewport's WorldModel
30
+ is a different group from the Workspace's "Doors", with its own collidability
31
+ matrix. Going through `PhysicsService` -- or hard-coding `workspace` here --
32
+ can only ever reach one of them, which would quietly make this tool lie about
33
+ a place that uses both. So the root is chosen per call; see `root`.
28
34
  ]]
29
- local CollisionGroups = workspace
35
+ local Workspace = game:GetService("Workspace")
30
36
 
31
37
  local Dispatch = require(script.Parent.Parent.Dispatch)
32
38
  local Paths = require(script.Parent.Parent.Paths)
@@ -34,6 +40,33 @@ local Undo = require(script.Parent.Parent.Undo)
34
40
 
35
41
  local World = {}
36
42
 
43
+ --[[
44
+ Which world the call is about.
45
+
46
+ Defaults to the Workspace, which is what nearly every caller means. Naming a
47
+ `worldModel` path targets that model's private registry instead -- the
48
+ viewport-preview case -- and anything that is not a WorldRoot is refused by
49
+ name rather than left to fail later inside `RegisterCollisionGroup` with a
50
+ message about a method that does not exist.
51
+ ]]
52
+ local function root(params: { [string]: any }): Instance
53
+ local path = params.worldModel
54
+ if typeof(path) ~= "string" or path == "" then
55
+ return Workspace
56
+ end
57
+
58
+ local instance = Paths.resolve(path)
59
+ if not instance:IsA("WorldRoot") then
60
+ Dispatch.fail(
61
+ "BAD_PARAMS",
62
+ string.format("%s is a %s, not a WorldModel.", path, instance.ClassName),
63
+ "Collision groups live on a WorldRoot: the Workspace, or a WorldModel "
64
+ .. "inside a ViewportFrame. Omit `worldModel` for the Workspace."
65
+ )
66
+ end
67
+ return instance
68
+ end
69
+
37
70
  --[[
38
71
  Steps the undo stack.
39
72
 
@@ -101,18 +134,59 @@ end
101
134
  ]]
102
135
  function World.collision(params: { [string]: any }): { [string]: any }
103
136
  local action = tostring(params.action or "list")
137
+ local world = root(params) :: any
104
138
 
105
139
  if action == "list" then
106
140
  local groups: { { [string]: any } } = {}
107
141
  local ok, registered = pcall(function()
108
- return CollisionGroups:GetRegisteredCollisionGroups()
142
+ return world:GetRegisteredCollisionGroups()
109
143
  end)
110
144
  if ok and typeof(registered) == "table" then
145
+ --[[
146
+ Named pairs, not the raw mask.
147
+
148
+ `mask` is the engine's bitfield, and it is not readable: a group
149
+ that passes through only itself reports -3, which says nothing to
150
+ anyone without a calculator and the group ordering to hand. The
151
+ whole reason a collision group exists is "these two do not
152
+ collide", so that is what is listed -- by name, both ways round,
153
+ which is the answer the caller came for.
154
+
155
+ The mask is kept beside it, because a caller comparing against
156
+ something they stored earlier still needs the number.
157
+ ]]
111
158
  for _, group in registered :: { any } do
112
- table.insert(groups, { name = group.name, mask = group.mask })
159
+ local passesThrough: { string } = {}
160
+ for _, other in registered :: { any } do
161
+ local okPair, collides = pcall(function()
162
+ return world:CollisionGroupsAreCollidable(group.name, other.name)
163
+ end)
164
+ if okPair and collides == false then
165
+ table.insert(passesThrough, other.name)
166
+ end
167
+ end
168
+ table.sort(passesThrough)
169
+ table.insert(groups, {
170
+ name = group.name,
171
+ mask = group.mask,
172
+ passesThrough = if #passesThrough > 0 then table.concat(passesThrough, ", ") else "nothing",
173
+ })
113
174
  end
114
175
  end
115
- return { groups = groups }
176
+ --[[
177
+ The ceiling is reported alongside the groups because it is low --
178
+ 32 in practice -- and a caller building groups programmatically has
179
+ no other way to learn it before the registration that fails.
180
+ ]]
181
+ local limit: number? = nil
182
+ local okLimit, maximum = pcall(function()
183
+ return world:GetMaxCollisionGroups()
184
+ end)
185
+ if okLimit then
186
+ limit = tonumber(maximum)
187
+ end
188
+
189
+ return { groups = groups, world = Paths.of(world), count = #groups, max = limit }
116
190
  end
117
191
 
118
192
  local name = params.group
@@ -122,7 +196,7 @@ function World.collision(params: { [string]: any }): { [string]: any }
122
196
 
123
197
  if action == "create" then
124
198
  local ok, err = pcall(function()
125
- CollisionGroups:RegisterCollisionGroup(name)
199
+ world:RegisterCollisionGroup(name)
126
200
  end)
127
201
  if not ok then
128
202
  -- Already existing is the common case and is not a failure worth
@@ -131,7 +205,7 @@ function World.collision(params: { [string]: any }): { [string]: any }
131
205
  Dispatch.fail("COLLISION_FAILED", string.format("Could not create %q: %s", name, tostring(err)))
132
206
  end
133
207
  end
134
- return { group = name, created = ok, existed = not ok }
208
+ return { group = name, created = ok, existed = not ok, world = Paths.of(world) }
135
209
  end
136
210
 
137
211
  if action == "assign" then
@@ -154,7 +228,7 @@ function World.collision(params: { [string]: any }): { [string]: any }
154
228
  end
155
229
  end
156
230
  end)
157
- return { group = name, assigned = #assigned, parts = assigned, undoable = undoable }
231
+ return { group = name, assigned = #assigned, parts = assigned, undoable = undoable, world = Paths.of(world) }
158
232
  end
159
233
 
160
234
  if action == "remove" then
@@ -168,12 +242,12 @@ function World.collision(params: { [string]: any }): { [string]: any }
168
242
  has existed the whole time; it was just never wired up.
169
243
  ]]
170
244
  local ok, err = pcall(function()
171
- CollisionGroups:UnregisterCollisionGroup(name)
245
+ world:UnregisterCollisionGroup(name)
172
246
  end)
173
247
  if not ok then
174
248
  Dispatch.fail("COLLISION_FAILED", string.format("Could not remove %q: %s", name, tostring(err)))
175
249
  end
176
- return { group = name, removed = true }
250
+ return { group = name, removed = true, world = Paths.of(world) }
177
251
  end
178
252
 
179
253
  if action == "collidable" then
@@ -183,12 +257,12 @@ function World.collision(params: { [string]: any }): { [string]: any }
183
257
  end
184
258
  local collidable = params.collidable ~= false
185
259
  local ok, err = pcall(function()
186
- CollisionGroups:CollisionGroupSetCollidable(name, other, collidable)
260
+ world:CollisionGroupSetCollidable(name, other, collidable)
187
261
  end)
188
262
  if not ok then
189
263
  Dispatch.fail("COLLISION_FAILED", string.format("Could not set collidability: %s", tostring(err)))
190
264
  end
191
- return { group = name, with = other, collidable = collidable }
265
+ return { group = name, with = other, collidable = collidable, world = Paths.of(world) }
192
266
  end
193
267
 
194
268
  Dispatch.fail("BAD_PARAMS", string.format("unknown collision action %q", action))
@@ -30,6 +30,8 @@ local Geometry = require(script.handlers.Geometry)
30
30
  local Generate = require(script.handlers.Generate)
31
31
  local Terrain = require(script.handlers.Terrain)
32
32
  local World = require(script.handlers.World)
33
+ local Spatial = require(script.handlers.Spatial)
34
+ local Audio = require(script.handlers.Audio)
33
35
  local Discover = require(script.handlers.Discover)
34
36
  local Exec = require(script.handlers.Exec)
35
37
  local Instances = require(script.handlers.Instances)
@@ -38,6 +40,8 @@ local Playtest = require(script.handlers.Playtest)
38
40
  local Viewport = require(script.handlers.Viewport)
39
41
  local Input = require(script.handlers.Input)
40
42
  local Device = require(script.handlers.Device)
43
+ local Data = require(script.handlers.Data)
44
+ local Anim = require(script.handlers.Anim)
41
45
  local Api = require(script.handlers.Api)
42
46
  local Scripts = require(script.handlers.Scripts)
43
47
  local Session = require(script.handlers.Session)
@@ -176,11 +180,15 @@ Geometry.register()
176
180
  Generate.register()
177
181
  Terrain.register()
178
182
  World.register()
183
+ Spatial.register()
184
+ Audio.register()
179
185
  Exec.register()
180
186
  Viewport.register()
181
187
  Scripts.register()
182
188
  Input.register()
183
189
  Device.register()
190
+ Data.register()
191
+ Anim.register()
184
192
  Api.register()
185
193
 
186
194
  --[[
@@ -698,7 +706,7 @@ local function onCommand(id: string, op: string, params: { [string]: any }?)
698
706
  ]]
699
707
  Transport.sendResult(result)
700
708
 
701
- local elapsed = string.format("%.0fms", milliseconds)
709
+ local elapsed = Console.durationText(milliseconds)
702
710
  Console.recordCall(result.ok, milliseconds)
703
711
 
704
712
  if result.ok then
@@ -17,6 +17,19 @@ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
17
17
  const sourceDir = join(root, "plugin", "src");
18
18
  const outputPath = resolve(process.argv[2] ?? join(root, "build", "StudioMCP.rbxmx"));
19
19
 
20
+ /**
21
+ * The package's version, stamped into the plugin the same way the build id is.
22
+ *
23
+ * `Config.PLUGIN_VERSION` used to be a literal that someone had to remember to
24
+ * edit alongside package.json, and nothing checked. A release that bumped the
25
+ * package and not the literal shipped a plugin reporting the previous version
26
+ * in its own `version` and `status` output, in `list_studios`, and in the
27
+ * mismatch warnings the server prints when a plugin looks out of date -- which
28
+ * is the one place a wrong version number does real damage, because it is read
29
+ * as evidence about which build is running.
30
+ */
31
+ const packageVersion = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version;
32
+
20
33
  const NEWLINE = "\n";
21
34
 
22
35
  /**
@@ -121,6 +134,13 @@ function buildTree(dir, name) {
121
134
  // Stamped with the fingerprint of the pre-injection sources, which is
122
135
  // exactly what the server recomputes at runtime.
123
136
  source = source.replace('Config.BUILD_ID = "dev"', `Config.BUILD_ID = "${stamp}"`);
137
+ // The literal in the source is the fallback for anyone loading
138
+ // plugin/src directly through Rojo; a built plugin always carries the
139
+ // package's own version.
140
+ source = source.replace(
141
+ /Config\.PLUGIN_VERSION = "[^"]*"/,
142
+ `Config.PLUGIN_VERSION = "${packageVersion}"`,
143
+ );
124
144
  }
125
145
 
126
146
  if (entry.name === "init.server.luau") {
@@ -1,125 +1,172 @@
1
- /**
2
- * Compiles every plugin source file, so a broken one is caught here rather than
3
- * in Studio.
4
- *
5
- * This exists because the feedback loop without it is terrible. Nothing in the
6
- * Node build reads the Luau at all -- `build:plugin` packs the files into an
7
- * .rbxmx as text -- so a syntax error ships, installs, and is only discovered
8
- * when the user focuses Studio and the plugin fails to load. It cost two
9
- * sessions in one afternoon: a literal newline written into a string where an
10
- * escape was meant, and a closure that captured a nil global because it sat
11
- * above the forward declaration of the local it meant to call. The first is a
12
- * compile error and would have been caught instantly by this. The second is not,
13
- * which is why `--!strict` analysis runs too when the analyser is available:
14
- * an unknown global is exactly what it flags.
15
- *
16
- * Needs `luau-compile` (and ideally `luau-analyze`) on PATH, in ./tools, or
17
- * named by the LUAU_COMPILE / LUAU_ANALYZE environment variables. Get them from
18
- * https://github.com/luau-lang/luau/releases.
19
- *
20
- * Silent and exit 0 when everything compiles; prints what failed otherwise.
21
- *
22
- * Usage: node scripts/check-plugin.mjs
23
- */
24
- import { spawnSync } from "node:child_process";
25
- import { readdirSync, statSync } from "node:fs";
26
- import { dirname, join, resolve } from "node:path";
27
- import { fileURLToPath } from "node:url";
28
- import { locateLuau, missingLuau } from "./locate-luau.mjs";
29
-
30
- const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
31
-
32
- function luauFiles(directory) {
33
- const found = [];
34
- for (const entry of readdirSync(directory)) {
35
- const path = join(directory, entry);
36
- if (statSync(path).isDirectory()) found.push(...luauFiles(path));
37
- else if (entry.endsWith(".luau")) found.push(path);
38
- }
39
- return found;
40
- }
41
-
42
- const compiler = locateLuau("LUAU_COMPILE", ["luau-compile.exe", "luau-compile"]);
43
- if (compiler === null) {
44
- process.stderr.write(
45
- "No luau-compile found. Put it on PATH or in ./tools, or set LUAU_COMPILE.\n" +
46
- "Download: https://github.com/luau-lang/luau/releases\n",
47
- );
48
- process.exit(1);
49
- }
50
-
51
- const files = luauFiles(join(root, "plugin", "src"));
52
- const failures = [];
53
-
54
- for (const file of files) {
55
- // --binary throws the bytecode away; only the exit status and diagnostics
56
- // matter, and writing it anywhere would just be litter to clean up.
57
- const result = spawnSync(compiler, ["--binary", file], { encoding: "utf8" });
58
- const diagnostics = `${result.stderr ?? ""}${result.status === 0 ? "" : (result.stdout ?? "")}`.trim();
59
- if (result.status !== 0 || diagnostics.length > 0) {
60
- failures.push(`${file.slice(root.length + 1)}\n${diagnostics}`);
61
- }
62
- }
63
-
64
- /*
65
- * One diagnostic from the analyser, deliberately.
66
- *
67
- * `LocalShadow` is reported when a name is used as a global and a local of that
68
- * same name is declared later in the file -- which is the shape of the bug this
69
- * check was written for, and is unambiguous. Running the analyser without
70
- * Roblox's type definitions also reports every engine global as unknown, so
71
- * `script`, `task`, `Color3` and friends produce hundreds of lines of noise;
72
- * filtering to this one diagnostic gets the signal without needing a
73
- * definitions file that would then have to be kept current with the engine.
74
- */
75
- /*
76
- * Diagnostics about a table this file declares its own type for.
77
- *
78
- * The LocalShadow filter was the only thing let through, and that let a whole
79
- * class of error ship: a field read or written on a `--!strict` table type that
80
- * does not declare it. The plugin failed to load on the first line it logged --
81
- * "attempt to perform arithmetic on nil" -- because a batch of edits added five
82
- * uses of `state.entries` and the edit declaring it never landed. The analyser
83
- * had said so, four times, and this script threw it away.
84
- *
85
- * These two patterns are safe to surface where the rest is not. Without Roblox
86
- * type definitions the analyser cannot know what `Instance` or `Color3` are, so
87
- * it reports engine globals in their hundreds -- but those come out as unknown
88
- * *globals* and unknown *types*. A key missing from a named table type can only
89
- * be a table this file declared itself.
90
- */
91
- const TYPE_PATTERNS = [/Key '[^']+' not found in table/, /Cannot add property '[^']+' to table/];
92
-
93
- const analyser = locateLuau("LUAU_ANALYZE", ["luau-analyze.exe", "luau-analyze"]);
94
- const shadowed = [];
95
- const mistyped = [];
96
- if (analyser !== null) {
97
- for (const file of files) {
98
- const result = spawnSync(analyser, [file], { encoding: "utf8" });
99
- const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
100
- for (const line of output.split("\n")) {
101
- if (line.includes("LocalShadow:")) shadowed.push(line.trim());
102
- else if (TYPE_PATTERNS.some((pattern) => pattern.test(line))) mistyped.push(line.trim());
103
- }
104
- }
105
- }
106
-
107
- if (failures.length > 0) {
108
- process.stderr.write(`${failures.join("\n\n")}\n`);
109
- }
110
- if (shadowed.length > 0) {
111
- process.stderr.write(
112
- "\nA local is used before it is declared, so the call reaches a nil global " +
113
- "instead:\n" +
114
- `${shadowed.join("\n")}\n`,
115
- );
116
- }
117
- if (mistyped.length > 0) {
118
- process.stderr.write(
119
- "\nA field is used on a table whose type does not declare it. It is nil at " +
120
- "runtime, and the plugin fails the first time that line runs:\n" +
1
+ /**
2
+ * Compiles every plugin source file, so a broken one is caught here rather than
3
+ * in Studio.
4
+ *
5
+ * This exists because the feedback loop without it is terrible. Nothing in the
6
+ * Node build reads the Luau at all -- `build:plugin` packs the files into an
7
+ * .rbxmx as text -- so a syntax error ships, installs, and is only discovered
8
+ * when the user focuses Studio and the plugin fails to load. It cost two
9
+ * sessions in one afternoon: a literal newline written into a string where an
10
+ * escape was meant, and a closure that captured a nil global because it sat
11
+ * above the forward declaration of the local it meant to call. The first is a
12
+ * compile error and would have been caught instantly by this. The second is not,
13
+ * which is why `--!strict` analysis runs too when the analyser is available:
14
+ * an unknown global is exactly what it flags.
15
+ *
16
+ * Needs `luau-compile` (and ideally `luau-analyze`) on PATH, in ./tools, or
17
+ * named by the LUAU_COMPILE / LUAU_ANALYZE environment variables. Get them from
18
+ * https://github.com/luau-lang/luau/releases.
19
+ *
20
+ * Silent and exit 0 when everything compiles; prints what failed otherwise.
21
+ *
22
+ * Usage: node scripts/check-plugin.mjs
23
+ */
24
+ import { spawnSync } from "node:child_process";
25
+ import { readdirSync, readFileSync, statSync } from "node:fs";
26
+ import { dirname, join, resolve } from "node:path";
27
+ import { fileURLToPath } from "node:url";
28
+ import { locateLuau, missingLuau } from "./locate-luau.mjs";
29
+
30
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
31
+
32
+ function luauFiles(directory) {
33
+ const found = [];
34
+ for (const entry of readdirSync(directory)) {
35
+ const path = join(directory, entry);
36
+ if (statSync(path).isDirectory()) found.push(...luauFiles(path));
37
+ else if (entry.endsWith(".luau")) found.push(path);
38
+ }
39
+ return found;
40
+ }
41
+
42
+ const compiler = locateLuau("LUAU_COMPILE", ["luau-compile.exe", "luau-compile"]);
43
+ if (compiler === null) {
44
+ process.stderr.write(
45
+ "No luau-compile found. Put it on PATH or in ./tools, or set LUAU_COMPILE.\n" +
46
+ "Download: https://github.com/luau-lang/luau/releases\n",
47
+ );
48
+ process.exit(1);
49
+ }
50
+
51
+ const files = luauFiles(join(root, "plugin", "src"));
52
+ const failures = [];
53
+
54
+ for (const file of files) {
55
+ // --binary throws the bytecode away; only the exit status and diagnostics
56
+ // matter, and writing it anywhere would just be litter to clean up.
57
+ const result = spawnSync(compiler, ["--binary", file], { encoding: "utf8" });
58
+ const diagnostics = `${result.stderr ?? ""}${result.status === 0 ? "" : (result.stdout ?? "")}`.trim();
59
+ if (result.status !== 0 || diagnostics.length > 0) {
60
+ failures.push(`${file.slice(root.length + 1)}\n${diagnostics}`);
61
+ }
62
+ }
63
+
64
+ /*
65
+ * One diagnostic from the analyser, deliberately.
66
+ *
67
+ * `LocalShadow` is reported when a name is used as a global and a local of that
68
+ * same name is declared later in the file -- which is the shape of the bug this
69
+ * check was written for, and is unambiguous. Running the analyser without
70
+ * Roblox's type definitions also reports every engine global as unknown, so
71
+ * `script`, `task`, `Color3` and friends produce hundreds of lines of noise;
72
+ * filtering to this one diagnostic gets the signal without needing a
73
+ * definitions file that would then have to be kept current with the engine.
74
+ */
75
+ /*
76
+ * Diagnostics about a table this file declares its own type for.
77
+ *
78
+ * The LocalShadow filter was the only thing let through, and that let a whole
79
+ * class of error ship: a field read or written on a `--!strict` table type that
80
+ * does not declare it. The plugin failed to load on the first line it logged --
81
+ * "attempt to perform arithmetic on nil" -- because a batch of edits added five
82
+ * uses of `state.entries` and the edit declaring it never landed. The analyser
83
+ * had said so, four times, and this script threw it away.
84
+ *
85
+ * These two patterns are safe to surface where the rest is not. Without Roblox
86
+ * type definitions the analyser cannot know what `Instance` or `Color3` are, so
87
+ * it reports engine globals in their hundreds -- but those come out as unknown
88
+ * *globals* and unknown *types*. A key missing from a named table type can only
89
+ * be a table this file declared itself.
90
+ */
91
+ const TYPE_PATTERNS = [/Key '[^']+' not found in table/, /Cannot add property '[^']+' to table/];
92
+
93
+ const analyser = locateLuau("LUAU_ANALYZE", ["luau-analyze.exe", "luau-analyze"]);
94
+ const shadowed = [];
95
+ const mistyped = [];
96
+ if (analyser !== null) {
97
+ for (const file of files) {
98
+ const result = spawnSync(analyser, [file], { encoding: "utf8" });
99
+ const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
100
+ for (const line of output.split("\n")) {
101
+ if (line.includes("LocalShadow:")) shadowed.push(line.trim());
102
+ else if (TYPE_PATTERNS.some((pattern) => pattern.test(line))) mistyped.push(line.trim());
103
+ }
104
+ }
105
+ }
106
+
107
+ /*
108
+ * Every operation the plugin answers has to have a readable name in the panel.
109
+ *
110
+ * `Phrase` falls back to tidying the wire name, so a missing entry is invisible
111
+ * in testing and only shows up as "Data set" where "SAVE over 4212 in PlayerData"
112
+ * belonged. Left unchecked it rots by default: 26 of 72 operations had drifted
113
+ * out of the table, which is every tool added after the table was written. A
114
+ * missing KIND is worse than cosmetic -- the fallback is "read", so an
115
+ * unregistered terrain wipe was announced with the weight of an inspect.
116
+ */
117
+ const unnamed = [];
118
+ const kindless = new Set();
119
+ {
120
+ const phrase = readFileSync(join(root, "plugin", "src", "Phrase.luau"), "utf8");
121
+ const described = new Set([...phrase.matchAll(/\["([^"]+)"\]\s*=\s*function/g)].map((m) => m[1]));
122
+ const kinds = new Set([...phrase.matchAll(/^\t([a-z]+) = "/gm)].map((m) => m[1]));
123
+ // `script` is split by action inside Phrase.kindOf rather than by a table row.
124
+ kinds.add("script");
125
+
126
+ for (const file of files) {
127
+ const source = readFileSync(file, "utf8");
128
+ for (const block of source.matchAll(/Dispatch\.registerAll\("([^"]+)",\s*\{([\s\S]*?)\n\t\}\)/g)) {
129
+ const group = block[1];
130
+ if (!kinds.has(group)) kindless.add(group);
131
+ for (const entry of block[2].matchAll(/^\s*([A-Za-z0-9_]+)\s*=/gm)) {
132
+ const op = `${group}.${entry[1]}`;
133
+ if (!described.has(op)) unnamed.push(op);
134
+ }
135
+ }
136
+ }
137
+ }
138
+
139
+ if (unnamed.length > 0) {
140
+ failures.push(
141
+ "These operations have no entry in Phrase.luau, so the Studio panel shows " +
142
+ "the wire name instead of saying what they touch:\n " +
143
+ unnamed.sort().join("\n "),
144
+ );
145
+ }
146
+ if (kindless.size > 0) {
147
+ failures.push(
148
+ "These operation groups have no entry in Phrase KINDS, so they are announced " +
149
+ 'as "read" whatever they do:\n ' +
150
+ [...kindless].sort().join("\n "),
151
+ );
152
+ }
153
+
154
+ if (failures.length > 0) {
155
+ process.stderr.write(`${failures.join("\n\n")}\n`);
156
+ }
157
+ if (shadowed.length > 0) {
158
+ process.stderr.write(
159
+ "\nA local is used before it is declared, so the call reaches a nil global " +
160
+ "instead:\n" +
161
+ `${shadowed.join("\n")}\n`,
162
+ );
163
+ }
164
+ if (mistyped.length > 0) {
165
+ process.stderr.write(
166
+ "\nA field is used on a table whose type does not declare it. It is nil at " +
167
+ "runtime, and the plugin fails the first time that line runs:\n" +
121
168
  `${mistyped.join("\n")}
122
- `,
123
- );
124
- }
125
- process.exit(failures.length > 0 || shadowed.length > 0 || mistyped.length > 0 ? 1 : 0);
169
+ `,
170
+ );
171
+ }
172
+ process.exit(failures.length > 0 || shadowed.length > 0 || mistyped.length > 0 ? 1 : 0);