roblox-guard 0.4.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Muhammad Andriansyah
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,159 @@
1
+ <div align="center">
2
+
3
+ <img src="assets/logo.svg" alt="" width="88" height="88">
4
+
5
+ # roblox-optimum
6
+
7
+ Professional Roblox and Luau standards for AI agents, and a checker that runs without one.
8
+
9
+ [![CI](https://github.com/andrian-syh/roblox-optimum/actions/workflows/ci.yml/badge.svg)](https://github.com/andrian-syh/roblox-optimum/actions/workflows/ci.yml)
10
+ [![version](https://img.shields.io/github/package-json/v/andrian-syh/roblox-optimum?label=version&color=blue)](CHANGELOG.md)
11
+ [![license](https://img.shields.io/github/license/andrian-syh/roblox-optimum?color=green)](LICENSE)
12
+ [![node](https://img.shields.io/badge/node-%3E%3D18-brightgreen)](package.json)
13
+ [![dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](package.json)
14
+
15
+ </div>
16
+
17
+ An AI agent writing Luau tends to produce code that runs and then leaks, trusts the client, or
18
+ lays out every file differently. This repository fixes that from two directions at once.
19
+
20
+ ## Two parts
21
+
22
+ **The standards** are written for an agent to follow while it works: server-authoritative,
23
+ leak-free, framework-agnostic, and laid out the same way in every file. They install as skills in
24
+ Claude Code, and as a project instruction file everywhere else. [AGENTS.md](AGENTS.md) ships
25
+ ready to copy, along with a matching file for Cursor, Windsurf, Cline, Kiro, Qoder, Copilot,
26
+ Qwen Code, and Antigravity.
27
+
28
+ **The checker** is [`scripts/roblox-guard.mjs`](scripts/roblox-guard.mjs), one Node file with no
29
+ dependencies. It reads Luau and reports the same findings whoever wrote the file, so it works as
30
+ an agent hook, a commit hook, or a CI step. It never asks a model anything, which is why it still
31
+ holds after a long session has been summarized and the rules have fallen out of context.
32
+
33
+ Guidance drifts. A check does not.
34
+
35
+ ## Install
36
+
37
+ Claude Code:
38
+
39
+ ```
40
+ /plugin marketplace add andrian-syh/roblox-optimum
41
+ /plugin install roblox-optimum@andrian-syh
42
+ ```
43
+
44
+ Codex:
45
+
46
+ ```
47
+ codex plugin marketplace add andrian-syh/roblox-optimum
48
+ codex plugin add roblox-optimum@andrian-syh
49
+ ```
50
+
51
+ Qwen Code:
52
+
53
+ ```
54
+ qwen extensions install https://github.com/andrian-syh/roblox-optimum
55
+ ```
56
+
57
+ Cursor, Antigravity, and Kiro install it as a plugin from this repository. For those, plus
58
+ OpenCode, Copilot, or a plain commit hook, see **[INSTALL.md](INSTALL.md)**, which also states
59
+ plainly what each setup can and cannot do.
60
+
61
+ ## Skills
62
+
63
+ | Skill | Use it for |
64
+ |---|---|
65
+ | `best-practices` | Writing, refactoring, or implementing any Luau system |
66
+ | `code-review` | Reviewing a file or diff, auditing a place, scoring architectural health |
67
+ | `studio-ops` | Studio MCP, sync toolchains, and proving a change works in a running session |
68
+
69
+ The split follows what the task is rather than what it is about: one skill produces code, one
70
+ judges it, one drives the tooling around it. A review that turns into a fix hands control back
71
+ to `best-practices`, which owns the layout and comment rules the fix has to land inside.
72
+
73
+ In Claude Code these are invoked as `/roblox-optimum:best-practices` and so on. Elsewhere they are
74
+ read as project instructions.
75
+
76
+ ## Agent
77
+
78
+ `roblox-auditor` audits a project in its own context and returns a scored report rather than the
79
+ files it opened. It is read-only and has no write tools.
80
+
81
+ Use it when an audit would otherwise mean reading a large part of the project into your
82
+ conversation. For one file or one diff, `code-review` is cheaper. Claude Code only, since it
83
+ depends on subagents.
84
+
85
+ ## Supervision
86
+
87
+ How much the agent confirms before acting.
88
+
89
+ | Level | Behaviour |
90
+ |---|---|
91
+ | `ask` | Stops at every decision |
92
+ | `bal` | Asks only when a choice is consequential |
93
+ | `go` | Runs autonomously, reports its assumptions afterwards |
94
+
95
+ Per request, as an argument or inline:
96
+
97
+ ```
98
+ /roblox-optimum:best-practices go
99
+ ```
100
+
101
+ Or once, as a standing preference:
102
+
103
+ ```
104
+ /plugin configure roblox-optimum@andrian-syh
105
+ ```
106
+
107
+ This is the only setting stored. The other facts a session needs, such as which community
108
+ libraries the project uses, whether it is server-authoritative, and whether it is Studio-native
109
+ or synced, belong to a project rather than to a person, and are resolved per session.
110
+
111
+ ## What the checker catches
112
+
113
+ Deprecated APIs and out-of-order section headers. Where the agent can receive them, findings go
114
+ straight back to it and it fixes its own output before you see it.
115
+
116
+ The check is deliberately conservative, because a wrong complaint gets a guard switched off:
117
+
118
+ - Comments and string literals are excluded, so a rule quoted in prose is never reported as a
119
+ use of it.
120
+ - `.lua` is only checked when the file proves it is Roblox.
121
+ - A module with no functions is data or types, and is exempt from the layout.
122
+ - The layout is only judged when the file already uses the section names. A project running its
123
+ own scheme is left alone.
124
+ - A section is only judged on its order. One left out because it would be empty is correct, and
125
+ the standards ban writing the header anyway.
126
+ - Package directories are skipped. Their code belongs to its publisher.
127
+
128
+ Measured on a 387 file project: 0 findings, after the three causes of a wrong complaint found on
129
+ its first real run were fixed in [0.1.4](CHANGELOG.md).
130
+
131
+ Set `ROBLOX_GUARD=off` to silence it without uninstalling.
132
+
133
+ ## What the standards cover
134
+
135
+ - **Layout.** Every script is VARIABLES, FUNCTIONS, INITIALIZATION, in that order, with a
136
+ specified ordering inside each section.
137
+ - **Server authority.** Every remote argument is validated for type, range, ownership, and rate
138
+ before it is trusted.
139
+ - **Cleanup.** Every connection has an owner and a teardown path.
140
+ - **Data safety.** `UpdateAsync` with backoff, save on `PlayerRemoving`, flush on `BindToClose`,
141
+ and a stated failure policy after the last retry.
142
+ - **No deprecated APIs.** `wait`, `spawn`, `delay`, `tick`, lowercase `:connect`, `Body*` movers,
143
+ and the rest.
144
+ - **Engine facts are cited, not remembered.** A newer API is confirmed against the version dump
145
+ or an in-Studio probe before it is relied on.
146
+
147
+ Recommendations only. Nothing is refactored unasked, and your instructions always win.
148
+
149
+ ## Documentation
150
+
151
+ | File | Contents |
152
+ |---|---|
153
+ | [INSTALL.md](INSTALL.md) | Setup for every supported agent, and what each one can do |
154
+ | [AGENTS.md](AGENTS.md) | The standards as one instruction file, ready to copy into a project |
155
+ | [CHANGELOG.md](CHANGELOG.md) | What changed in each version |
156
+
157
+ ## License
158
+
159
+ MIT
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "roblox-guard",
3
+ "version": "0.4.0",
4
+ "description": "Deterministic Roblox and Luau standards checks: deprecated APIs and out-of-order section headers, as an agent hook, a commit hook, or a CI step.",
5
+ "keywords": [
6
+ "roblox",
7
+ "luau",
8
+ "lint",
9
+ "linter",
10
+ "static-analysis",
11
+ "pre-commit",
12
+ "game-development",
13
+ "ai-agent"
14
+ ],
15
+ "type": "module",
16
+ "bin": {
17
+ "roblox-guard": "scripts/roblox-guard.mjs"
18
+ },
19
+ "files": [
20
+ "scripts/roblox-guard.mjs"
21
+ ],
22
+ "scripts": {
23
+ "test": "node scripts/roblox-guard.mjs --selftest"
24
+ },
25
+ "license": "MIT",
26
+ "homepage": "https://github.com/andrian-syh/roblox-optimum#using-the-checks-without-claude-code",
27
+ "bugs": "https://github.com/andrian-syh/roblox-optimum/issues",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/andrian-syh/roblox-optimum.git"
31
+ },
32
+ "engines": {
33
+ "node": ">=18"
34
+ }
35
+ }
@@ -0,0 +1,513 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Deterministic standards checks for Luau files, driven by the plugin's hooks.
4
+ * What the skill can only ask for, this enforces, and it keeps working after the
5
+ * rules have fallen out of the model's context.
6
+ */
7
+
8
+ import { readFileSync, existsSync } from "node:fs";
9
+ import { join, resolve, sep } from "node:path";
10
+ import { pathToFileURL } from "node:url";
11
+
12
+ /** Proof that a source file is Roblox rather than another Lua dialect. */
13
+ const ROBLOX_MARKERS =
14
+ /\bgame:GetService\s*\(|\bscript\.Parent\b|\bworkspace\b|\bInstance\.new\s*\(/;
15
+
16
+ /** Proof that a directory is a Roblox project. */
17
+ const PROJECT_MARKERS = [
18
+ "default.project.json",
19
+ "sourcemap.json",
20
+ ".robloxrc",
21
+ "wally.toml",
22
+ "rokit.toml",
23
+ "aftman.toml",
24
+ ];
25
+
26
+ /**
27
+ * APIs the skill forbids outright, each paired with the replacement to offer. A deprecated
28
+ * name that community libraries also expose is left out, because a hook cannot tell the two
29
+ * apart and a wrong complaint costs more than a missed one. Review still catches those.
30
+ */
31
+ const DEPRECATED = [
32
+ [/(?<![.:\w])wait\s*\(/, "wait()", "task.wait()"],
33
+ [/(?<![.:\w])spawn\s*\(/, "spawn()", "task.spawn()"],
34
+ [/(?<![.:\w])delay\s*\(/, "delay()", "task.delay()"],
35
+ [/(?<![.:\w])tick\s*\(/, "tick()", "os.clock() or os.time()"],
36
+ [/:connect\s*\(/, ":connect()", ":Connect()"],
37
+ [/[Hh]umanoid[\w.]*:LoadAnimation\s*\(/, "Humanoid:LoadAnimation()", "Animator:LoadAnimation()"],
38
+ [/:SetPrimaryPartCFrame\s*\(/, "SetPrimaryPartCFrame()", "Model:PivotTo()"],
39
+ [
40
+ /\bBody(?:Velocity|Position|Gyro|AngularVelocity|Force|Thrust)\b/,
41
+ "Body* mover",
42
+ "a constraint (LinearVelocity, AlignPosition, AlignOrientation, VectorForce)",
43
+ ],
44
+ ];
45
+
46
+ const SECTIONS = ["VARIABLES", "FUNCTIONS", "INITIALIZATION"];
47
+
48
+ /**
49
+ * Directories holding code from elsewhere. A package manager rewrites them, so a finding there
50
+ * names a file the reader is not allowed to edit.
51
+ */
52
+ const VENDOR = ["Packages", "DevPackages", "ServerPackages", "_Index", "node_modules"];
53
+
54
+ const HOME_PAGE = "https://github.com/andrian-syh/roblox-optimum";
55
+
56
+ /** A directory that cannot exist, so the selftest can prove an unopenable path is skipped. */
57
+ const ROOT_ABSENT = "/roblox-guard-no-such-directory";
58
+
59
+ /** Why a path was passed over, so a run that checked nothing can say what it saw. */
60
+ const SKIP_REASON = {
61
+ "not-luau": "not a .lua or .luau file",
62
+ "not-roblox": "a .lua file with no Roblox API in it",
63
+ vendored: "inside a package directory, so its publisher owns it",
64
+ unreadable: "could not be read as a file",
65
+ };
66
+
67
+ const USAGE = `roblox-guard - deterministic Roblox and Luau standards checks
68
+
69
+ Usage:
70
+ roblox-guard --check <file...> Check files. Exit 1 when a file has findings.
71
+ roblox-guard --selftest Run the built-in assertions.
72
+ roblox-guard Read a post-write hook payload on stdin. Exit 2 reports
73
+ findings back to the agent.
74
+ roblox-guard --compact Read a session-start payload on stdin.
75
+ roblox-guard --help Show this text.
76
+
77
+ Exit codes: 0 nothing to report, 1 findings, 2 findings for an agent or a usage error.
78
+ Set ROBLOX_GUARD=off to disable every check without uninstalling.
79
+
80
+ Standards: ${HOME_PAGE}
81
+ `;
82
+
83
+ /**
84
+ * Blanks the prose in a source file so that a rule named in a comment or a string is never
85
+ * mistaken for a use of it. Line and column positions are preserved.
86
+ *
87
+ * @param source string -- Luau source as written to disk.
88
+ * @param keepLineComments boolean -- Leave single-line comments in place, for callers that read them.
89
+ * @return string -- The same source with the selected prose blanked.
90
+ */
91
+ export function stripNonCode(source, keepLineComments = false) {
92
+ const out = source.split("");
93
+ const n = source.length;
94
+ let i = 0;
95
+
96
+ const blank = (from, to) => {
97
+ for (let k = from; k < to && k < n; k++) {
98
+ if (out[k] !== "\n") out[k] = " ";
99
+ }
100
+ };
101
+
102
+ while (i < n) {
103
+ const two = source.slice(i, i + 2);
104
+ const long = /^(--)?\[(=*)\[/.exec(source.slice(i, i + 12));
105
+
106
+ if (long && (two === "--" || source[i] === "[")) {
107
+ const close = "]" + long[2] + "]";
108
+ const end = source.indexOf(close, i + long[0].length);
109
+ const stop = end === -1 ? n : end + close.length;
110
+ blank(i, stop);
111
+ i = stop;
112
+ continue;
113
+ }
114
+
115
+ if (two === "--") {
116
+ const end = source.indexOf("\n", i);
117
+ const stop = end === -1 ? n : end;
118
+ if (!keepLineComments) blank(i, stop);
119
+ i = stop;
120
+ continue;
121
+ }
122
+
123
+ if (source[i] === '"' || source[i] === "'") {
124
+ const quote = source[i];
125
+ let j = i + 1;
126
+ while (j < n && source[j] !== quote && source[j] !== "\n") {
127
+ j += source[j] === "\\" ? 2 : 1;
128
+ }
129
+ blank(i, Math.min(j + 1, n));
130
+ i = Math.min(j + 1, n);
131
+ continue;
132
+ }
133
+
134
+ i++;
135
+ }
136
+
137
+ return out.join("");
138
+ }
139
+
140
+ /**
141
+ * Whether a file opted into this section layout. A project is entitled to its own scheme and
142
+ * one shared word is not consent, so a file is judged only once it uses most of the names.
143
+ *
144
+ * @param names table -- Section names found in the file, in the order they appear.
145
+ * @return boolean
146
+ */
147
+ function usesThisLayout(names) {
148
+ return new Set(names).size >= 2;
149
+ }
150
+
151
+ /**
152
+ * Returns the standards violations found in one Luau source, ready to print. A module that
153
+ * declares no function is treated as data or types and is exempt from the layout.
154
+ *
155
+ * A section with nothing to hold is left out on purpose, because the standards ban an empty
156
+ * header. Only the headers a file carries are judged, and only on their order.
157
+ *
158
+ * @param source string -- Luau source as written to disk.
159
+ * @return table -- One string per violation; empty means the file passes.
160
+ */
161
+ export function inspect(source) {
162
+ const problems = [];
163
+ const code = stripNonCode(source);
164
+
165
+ if (/\bfunction\b/.test(code)) {
166
+ const seen = [];
167
+ stripNonCode(source, true).split("\n").forEach((line, idx) => {
168
+ if (!line.trimStart().startsWith("--")) return;
169
+ for (const name of SECTIONS) {
170
+ if (new RegExp(`\\b${name}\\b`).test(line)) seen.push({ name, line: idx + 1 });
171
+ }
172
+ });
173
+
174
+ const order = seen.map((s) => s.name);
175
+
176
+ if (usesThisLayout(order)) {
177
+ const present = SECTIONS.map((s) => seen.find((x) => x.name === s)).filter(Boolean);
178
+ for (let k = 1; k < present.length; k++) {
179
+ if (present[k].line < present[k - 1].line) {
180
+ problems.push(
181
+ `Section order is wrong: ${present[k].name} (line ${present[k].line}) appears before ` +
182
+ `${present[k - 1].name} (line ${present[k - 1].line}). Required order: ${SECTIONS.join(" > ")}.`,
183
+ );
184
+ break;
185
+ }
186
+ }
187
+ }
188
+ }
189
+
190
+ const lines = code.split("\n");
191
+ for (const [pattern, name, replacement] of DEPRECATED) {
192
+ for (let k = 0; k < lines.length; k++) {
193
+ if (pattern.test(lines[k])) {
194
+ problems.push(`Line ${k + 1}: ${name} is deprecated. Use ${replacement}.`);
195
+ break;
196
+ }
197
+ }
198
+ }
199
+
200
+ return problems;
201
+ }
202
+
203
+ /** Files named by an apply_patch body, which is how Codex reports an edit. */
204
+ const PATCH_TARGET = /^\*\*\* (?:Add|Update|Move to) File:\s*(.+?)\s*$/gm;
205
+
206
+ /**
207
+ * Returns the files a post-write hook payload says were written. Claude Code nests the path,
208
+ * Cursor puts it at the top level, and Codex sends a patch body instead, so all three shapes
209
+ * are read and one hook entry serves any of them.
210
+ *
211
+ * @param payload table -- The parsed hook payload.
212
+ * @return table -- Absolute paths, empty when the payload names no file.
213
+ */
214
+ export function targetsFromPayload(payload) {
215
+ const direct = payload?.tool_input?.file_path ?? payload?.file_path;
216
+ if (typeof direct === "string") return [direct];
217
+
218
+ const command = payload?.tool_input?.command;
219
+ if (typeof command !== "string") return [];
220
+
221
+ const base = typeof payload?.cwd === "string" ? payload.cwd : process.cwd();
222
+ return [...command.matchAll(PATCH_TARGET)].map((m) => resolve(base, m[1]));
223
+ }
224
+
225
+ /**
226
+ * Checks one path and returns its report, or null when the file is out of scope. A path that
227
+ * is unreadable, not Luau, or not provably Roblox is skipped rather than guessed at.
228
+ *
229
+ * @param path string -- Path to a file the agent wrote.
230
+ * @return table -- `{ path, problems }`, or null when nothing should be said.
231
+ */
232
+ export function checkFile(path) {
233
+ if (typeof path !== "string") return { path: String(path), status: "unreadable", problems: [] };
234
+ if (!/\.luau?$/i.test(path)) return { path, status: "not-luau", problems: [] };
235
+
236
+ const parts = path.split(sep).flatMap((p) => p.split("/"));
237
+ if (parts.some((p) => VENDOR.includes(p))) return { path, status: "vendored", problems: [] };
238
+
239
+ let source;
240
+ try {
241
+ source = readFileSync(path, "utf8");
242
+ } catch {
243
+ return { path, status: "unreadable", problems: [] };
244
+ }
245
+
246
+ if (!/\.luau$/i.test(path) && !ROBLOX_MARKERS.test(source)) {
247
+ return { path, status: "not-roblox", problems: [] };
248
+ }
249
+
250
+ const problems = inspect(source);
251
+ return { path, status: problems.length === 0 ? "clean" : "problems", problems };
252
+ }
253
+
254
+ /**
255
+ * Renders reports as the text every entry point shares, so a finding reads the same whether it
256
+ * reached the developer through an agent, a commit hook, or CI. Each finding carries its own
257
+ * replacement, so the text stands alone wherever the checker was installed from.
258
+ *
259
+ * @param reports table -- Reports from checkFile whose status is "problems".
260
+ * @return string
261
+ */
262
+ export function formatReport(reports) {
263
+ const body = reports
264
+ .map((r) => `Roblox standards check failed for ${r.path}:\n` + r.problems.map((p) => ` - ${p}`).join("\n"))
265
+ .join("\n\n");
266
+
267
+ return `${body}\n\nStandards: ${HOME_PAGE}\n`;
268
+ }
269
+
270
+ /**
271
+ * Returns the hook payload the agent sends, or empty when there is none.
272
+ * Synchronous reads are not portable when stdin is a pipe.
273
+ *
274
+ * @return string
275
+ */
276
+ async function readStdin() {
277
+ try {
278
+ const chunks = [];
279
+ for await (const chunk of process.stdin) chunks.push(chunk);
280
+ return Buffer.concat(chunks).toString("utf8");
281
+ } catch {
282
+ return "";
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Checks the file the agent just wrote and prints what it must fix. Anything unreadable,
288
+ * unrecognized, or outside Roblox passes without comment.
289
+ *
290
+ * @return number -- Process exit code; the failing code is what surfaces the report.
291
+ */
292
+ async function runPostToolUse() {
293
+ if (process.env.ROBLOX_GUARD === "off") return 0;
294
+
295
+ let payload;
296
+ try {
297
+ payload = JSON.parse((await readStdin()) || "{}");
298
+ } catch {
299
+ return 0;
300
+ }
301
+
302
+ const reports = targetsFromPayload(payload)
303
+ .map(checkFile)
304
+ .filter((r) => r.status === "problems");
305
+ if (reports.length === 0) return 0;
306
+
307
+ process.stderr.write(formatReport(reports));
308
+ return 2;
309
+ }
310
+
311
+ /**
312
+ * Checks paths given on the command line, for a commit hook, CI, or a hand run. Exit 1 is the
313
+ * convention a build step expects, unlike the exit 2 an agent hook uses to speak to the model.
314
+ *
315
+ * @param paths table -- File paths to check.
316
+ * @return number -- Process exit code.
317
+ */
318
+ function runCheck(paths) {
319
+ if (process.env.ROBLOX_GUARD === "off") return 0;
320
+
321
+ if (paths.length === 0) {
322
+ process.stderr.write(`roblox-guard --check needs at least one file.\n\n${USAGE}`);
323
+ return 2;
324
+ }
325
+
326
+ const results = paths.map(checkFile);
327
+ const reports = results.filter((r) => r.status === "problems");
328
+
329
+ if (reports.length > 0) {
330
+ process.stderr.write(formatReport(reports));
331
+ return 1;
332
+ }
333
+
334
+ // Silence after checking nothing reads as a pass, so name what was skipped and why.
335
+ if (!results.some((r) => r.status === "clean")) {
336
+ const why = results.map((r) => ` - ${r.path}: ${SKIP_REASON[r.status]}`).join("\n");
337
+ process.stderr.write(`roblox-guard checked no files.\n${why}\n`);
338
+ }
339
+
340
+ return 0;
341
+ }
342
+
343
+ /**
344
+ * Tells the agent to re-read the skill once a summary has dropped the rules, and stays
345
+ * silent outside a Roblox project so unrelated sessions are not disturbed.
346
+ *
347
+ * @return number -- Process exit code.
348
+ */
349
+ async function runCompactReminder() {
350
+ let payload;
351
+ try {
352
+ payload = JSON.parse((await readStdin()) || "{}");
353
+ } catch {
354
+ return 0;
355
+ }
356
+
357
+ const cwd = payload?.cwd || process.cwd();
358
+ if (!PROJECT_MARKERS.some((m) => existsSync(join(cwd, m)))) return 0;
359
+
360
+ process.stdout.write(
361
+ JSON.stringify({
362
+ hookSpecificOutput: {
363
+ hookEventName: "SessionStart",
364
+ additionalContext:
365
+ "This session was summarized. If Luau work continues, re-read the " +
366
+ "roblox-optimum best-practices SKILL.md before writing any code - the section " +
367
+ "layout, comment rules, and runtime non-negotiables do not survive a summary, " +
368
+ "and reconstructing them from memory produces confidently wrong files.",
369
+ },
370
+ }),
371
+ );
372
+ return 0;
373
+ }
374
+
375
+ /** Asserts the checker still behaves, so a regression fails here and not in a user's session. */
376
+ function selftest() {
377
+ const ok = (cond, label) => {
378
+ if (!cond) {
379
+ console.error(`FAIL ${label}`);
380
+ process.exitCode = 1;
381
+ }
382
+ };
383
+
384
+ const good = `-- // VARIABLES // --
385
+ local Players = game:GetService("Players")
386
+
387
+ -- // FUNCTIONS // --
388
+ local function greet(player)
389
+ \ttask.wait(1)
390
+ \tprint(player.Name)
391
+ end
392
+
393
+ -- // INITIALIZATION // --
394
+ Players.PlayerAdded:Connect(greet)
395
+ `;
396
+ ok(inspect(good).length === 0, "clean file passes");
397
+
398
+ ok(
399
+ inspect(good.replace("task.wait(1)", "wait(1)")).some((p) => p.includes("wait()")),
400
+ "bare wait() is caught",
401
+ );
402
+ ok(
403
+ !inspect(good).some((p) => p.includes("wait()")),
404
+ "task.wait() is not mistaken for wait()",
405
+ );
406
+ ok(
407
+ !inspect(`-- never call wait() here\nlocal x = "spawn("\nreturn { a = 1 }`).some((p) =>
408
+ p.includes("deprecated"),
409
+ ),
410
+ "API names inside comments and strings are ignored",
411
+ );
412
+ ok(
413
+ inspect(`-- // VARIABLES // --\nlocal ACTIONS = { "show" }\n\n-- // INITIALIZATION // --\nreturn function(registry)\n\tregistry:Register(ACTIONS)\nend`).length === 0,
414
+ "a file whose only function belongs to INITIALIZATION needs no FUNCTIONS header",
415
+ );
416
+ ok(
417
+ inspect(`-- // FUNCTIONS // --\nlocal function f() end\n-- // INITIALIZATION // --\nf()`).length === 0,
418
+ "a file with no top-level state needs no VARIABLES header",
419
+ );
420
+ ok(
421
+ inspect(`local Players = game:GetService("Players")\nlocal function f() end\nf()`).length === 0,
422
+ "a file using no section headers at all is left alone",
423
+ );
424
+ ok(
425
+ inspect(`--== SERVICES ==--\nlocal Players = game:GetService("Players")\n--== VARIABLES ==--\nlocal sessions = {}\n--== MAIN ==--\nlocal function f() end\nf()`).length === 0,
426
+ "another project's scheme sharing one word is not treated as this layout",
427
+ );
428
+ ok(
429
+ inspect(`-- // FUNCTIONS // --\nlocal function f() end\n-- // VARIABLES // --\nlocal a = 1\n-- // INITIALIZATION // --\nf()`).some(
430
+ (p) => p.includes("order is wrong"),
431
+ ),
432
+ "out-of-order sections are caught",
433
+ );
434
+ ok(
435
+ inspect(`local Items = {\n\tSword = { damage = 10 },\n}\nreturn Items`).length === 0,
436
+ "a pure data module is exempt from the layout",
437
+ );
438
+ ok(
439
+ inspect(good.replace("print(player.Name)", "player.Character:SetPrimaryPartCFrame(cf)")).some(
440
+ (p) => p.includes("PivotTo"),
441
+ ),
442
+ "SetPrimaryPartCFrame is caught",
443
+ );
444
+ ok(
445
+ inspect("--[[\n-- // VARIABLES // --\n-- // FUNCTIONS // --\n]]\nlocal function f() end\nf()").length === 0,
446
+ "headers quoted inside a long comment do not opt the file in",
447
+ );
448
+ ok(
449
+ inspect(`${good.split("-- // FUNCTIONS // --")[0]}-- // FUNCTIONS // --\nlocal function f()\n\tPromise.new():wait()\nend\n-- // INITIALIZATION // --\nf()`).length === 0,
450
+ "a library method sharing a deprecated name is not reported",
451
+ );
452
+
453
+ ok(
454
+ targetsFromPayload({ tool_input: { file_path: "/tmp/a.luau" } }).join() === "/tmp/a.luau",
455
+ "a payload naming the file directly is read",
456
+ );
457
+ ok(
458
+ targetsFromPayload({
459
+ cwd: "/proj",
460
+ tool_input: { command: "*** Begin Patch\n*** Update File: src/a.luau\n*** Add File: src/b.luau\n*** End Patch" },
461
+ }).length === 2,
462
+ "both files in an apply_patch body are read",
463
+ );
464
+ ok(
465
+ targetsFromPayload({ tool_input: { command: "ls -la" } }).length === 0,
466
+ "a shell command naming no file yields nothing",
467
+ );
468
+ ok(
469
+ targetsFromPayload({ file_path: "/tmp/a.luau", edits: [] }).join() === "/tmp/a.luau",
470
+ "a payload with the path at the top level is read",
471
+ );
472
+ ok(
473
+ inspect(good.replace("print(player.Name)", "player.Character.Humanoid:LoadAnimation(a)")).some(
474
+ (p) => p.includes("Animator"),
475
+ ),
476
+ "LoadAnimation on a Humanoid is caught",
477
+ );
478
+ ok(
479
+ !inspect(good.replace("print(player.Name)", "local t = animator:LoadAnimation(a)")).some((p) =>
480
+ p.includes("Animator"),
481
+ ),
482
+ "LoadAnimation on an Animator is not reported",
483
+ );
484
+
485
+ ok(checkFile("notes.txt").status === "not-luau", "a non-Luau path is reported as skipped");
486
+ ok(
487
+ checkFile(join("proj", "Packages", "Cmdr.luau")).status === "vendored",
488
+ "a file inside a package tree is skipped",
489
+ );
490
+ ok(
491
+ checkFile(join(ROOT_ABSENT, "missing.luau")).status === "unreadable",
492
+ "a path that cannot be opened is reported as skipped",
493
+ );
494
+ ok(checkFile(undefined).status === "unreadable", "a missing path does not throw");
495
+
496
+ if (!process.exitCode) console.log("roblox-guard selftest: all checks passed");
497
+ }
498
+
499
+ const invokedDirectly =
500
+ process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
501
+
502
+ if (invokedDirectly) {
503
+ const [mode, ...rest] = process.argv.slice(2);
504
+ if (mode === "--selftest") selftest();
505
+ else if (mode === "--compact") process.exit(await runCompactReminder());
506
+ else if (mode === "--check") process.exit(runCheck(rest));
507
+ else if (mode === "--help" || mode === "-h") process.stdout.write(USAGE);
508
+ else if (mode === undefined) process.exit(await runPostToolUse());
509
+ else {
510
+ process.stderr.write(`roblox-guard: unknown option ${mode}\n\n${USAGE}`);
511
+ process.exit(2);
512
+ }
513
+ }