@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.
- package/README.md +28 -2
- package/dist/bridge/console.js +182 -0
- package/dist/bridge/console.js.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/cloudassets.js +233 -0
- package/dist/lib/cloudassets.js.map +1 -0
- package/dist/lib/credentials.js +180 -0
- package/dist/lib/credentials.js.map +1 -0
- package/dist/lib/livedata.js +325 -0
- package/dist/lib/livedata.js.map +1 -0
- package/dist/lib/liveluau.js +83 -0
- package/dist/lib/liveluau.js.map +1 -0
- package/dist/lib/liveops.js +358 -0
- package/dist/lib/liveops.js.map +1 -0
- package/dist/lib/opencloud.js +235 -0
- package/dist/lib/opencloud.js.map +1 -0
- package/dist/tools/anim.js +159 -0
- package/dist/tools/anim.js.map +1 -0
- package/dist/tools/audio.js +96 -0
- package/dist/tools/audio.js.map +1 -0
- package/dist/tools/character.js +95 -5
- package/dist/tools/character.js.map +1 -1
- package/dist/tools/data.js +292 -0
- package/dist/tools/data.js.map +1 -0
- package/dist/tools/device.js +77 -7
- package/dist/tools/device.js.map +1 -1
- package/dist/tools/discover.js +80 -4
- package/dist/tools/discover.js.map +1 -1
- package/dist/tools/exec.js +96 -2
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/input.js +35 -9
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/perf.js +74 -7
- package/dist/tools/perf.js.map +1 -1
- package/dist/tools/scripts.js +162 -6
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/spatial.js +135 -0
- package/dist/tools/spatial.js.map +1 -0
- package/dist/tools/universe.js +177 -0
- package/dist/tools/universe.js.map +1 -0
- package/dist/tools/upload.js +294 -0
- package/dist/tools/upload.js.map +1 -0
- package/dist/tools/world.js +675 -51
- package/dist/tools/world.js.map +1 -1
- package/package.json +2 -2
- package/plugin/src/Commands.luau +31 -7
- package/plugin/src/Config.luau +65 -65
- package/plugin/src/Console.luau +1909 -1843
- package/plugin/src/Emulation.luau +172 -0
- package/plugin/src/Phrase.luau +816 -618
- package/plugin/src/Png.luau +8 -4
- package/plugin/src/Prompt.luau +965 -961
- package/plugin/src/Secret.luau +86 -0
- package/plugin/src/Serialize.luau +440 -8
- package/plugin/src/Undo.luau +94 -6
- package/plugin/src/handlers/Anim.luau +897 -0
- package/plugin/src/handlers/Assets.luau +286 -2
- package/plugin/src/handlers/Audio.luau +411 -0
- package/plugin/src/handlers/Capture.luau +155 -20
- package/plugin/src/handlers/Character.luau +823 -361
- package/plugin/src/handlers/Data.luau +539 -0
- package/plugin/src/handlers/Device.luau +394 -139
- package/plugin/src/handlers/Discover.luau +685 -363
- package/plugin/src/handlers/Geometry.luau +722 -450
- package/plugin/src/handlers/Instances.luau +84 -4
- package/plugin/src/handlers/Perf.luau +227 -0
- package/plugin/src/handlers/Scripts.luau +673 -539
- package/plugin/src/handlers/Session.luau +3 -0
- package/plugin/src/handlers/Spatial.luau +334 -0
- package/plugin/src/handlers/Viewport.luau +268 -0
- package/plugin/src/handlers/World.luau +89 -15
- package/plugin/src/init.server.luau +9 -1
- package/scripts/build-plugin.mjs +20 -0
- 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
|
|
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.
|
|
26
|
-
a removal rather than a fix.
|
|
27
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
709
|
+
local elapsed = Console.durationText(milliseconds)
|
|
702
710
|
Console.recordCall(result.ok, milliseconds)
|
|
703
711
|
|
|
704
712
|
if result.ok then
|
package/scripts/build-plugin.mjs
CHANGED
|
@@ -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") {
|
package/scripts/check-plugin.mjs
CHANGED
|
@@ -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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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);
|