@@ -37,12 +37,23 @@ luau-lsp analyze --sourcemap=sourcemap.json --base-luaurc=.luaurc \
3737```
3838
3939Clean = only the ` [INFO] Loading... ` line. ` LuauSolverV2=false ` is required (repo pins the old
40- solver). Iterate until clean, then run ` npm run lint:luau ` ** once** as the final gate — single-file
40+ solver). ** Never drop ` --defs=globalTypes.d.lua ` or ` --base-luaurc=.luaurc ` ** — without ` --defs `
41+ the analyzer loses the Roblox global declarations and reports * false* ` Unknown global 'tick'/'time' `
42+ (and similar) errors while still resolving ` game ` /` Enum ` , which looks like a real conversion bug but
43+ isn't. If you see unknown-global errors only on deprecated globals, you forgot ` --defs ` ; confirm with
44+ ` npm run lint:luau ` (which always passes the flag) before "fixing" anything. Iterate until clean,
45+ then run ` npm run lint:luau ` ** once** as the final gate — single-file
4146analyze can't see files that depend on * yours* , and tightening a type ripples to subclasses and
4247callers. Triage new downstream errors: pre-existing → leave & flag; your type is genuinely too
4348tight for the real contract → loosen * your* type (often ` T? ` not ` T ` ); a small obvious follow-on
4449→ fix it.
4550
51+ The gate is ** both** ` lint:luau ` (types) ** and** ` lint:selene ` (lints), which exits non-zero.
52+ Dot-syntax conversion routinely trips selene even when analyze is clean: an ` unused_variable: self `
53+ (method body ignores the now-explicit param → rename it ** ` _self ` ** ) or a ` shadowing ` from an Rx escape
54+ ` local X = X :: any ` inside a function (→ cast at the source, ` local X: any = require("X") ` ). See the
55+ "selene" section in ` references/conventions.md ` .
56+
4657## Core patterns
4758
4859** Class with a parent (the common case):**
@@ -81,6 +92,47 @@ function MyClass.GetEnabled(self: MyClass): boolean
8192end
8293```
8394
95+ ### ⚠️ Metamethod classes — NEVER rewrite ` rawget ` /` rawset ` or hoist ` setmetatable `
96+
97+ The patterns above assume the ordinary metatable (` __index = MyClass ` , default ` __newindex ` ).
98+ Some classes define a ** custom ` __index ` and/or ` __newindex ` function** that intercepts field
99+ access — often to expose computed keys (` .Value ` , ` .Changed ` ) and to ** ` error() ` on any unknown
100+ key** . Grep for ` (MyClass :: any).__index = function ` / ` .__newindex = function ` before touching
101+ the constructor or any ` self._field ` access. For these classes, two edits that look like harmless
102+ cleanups are ** runtime-breaking changes** — do not make them:
103+
104+ 1 . ** Do not replace ` rawget(self, "_x") ` with ` self._x ` , or ` rawset(self, "_x", v) ` with
105+ ` self._x = v ` .** The raw calls are load-bearing: they deliberately bypass the custom metamethod.
106+ Routing the access through ` self._x ` fires the metamethod, which may compute a different value or
107+ ` error("Bad index") ` — silently for present fields, fatally for absent ones (e.g. lazy caches
108+ that start unset). Keep every ` rawget ` /` rawset ` exactly as-is; the only allowed change is adding
109+ the receiver cast: ` rawget(self, "_x") ` → ` rawget(self :: any, "_x") ` (and ` :: any ` on the result
110+ if the checker complains about ` any? ` ).
111+
112+ 2 . ** Do not hoist ` setmetatable ` above the field assignments in the constructor.** The strict
113+ pattern ` local self = setmetatable(Base.new() :: any, MyClass) ` followed by ` self._x = ... ` is
114+ correct ** only** when ` __newindex ` is the default (raw write). If the class has a custom
115+ ` __newindex ` that errors on unknown keys, that pattern makes ** every** field assignment throw
116+ ` "Bad index" ` . Build a plain table first, then apply the metatable last — the original order:
117+
118+ ``` lua
119+ function MyClass .new (...): MyClass
120+ -- __newindex errors on unknown keys: assign fields BEFORE the metatable.
121+ local self = {} :: any
122+ self ._serviceBag = assert (serviceBag , " No serviceBag" )
123+ self ._definition = assert (definition , " Bad definition" )
124+ return setmetatable (self , MyClass )
125+ end
126+ ```
127+
128+ (Equivalently, keep ` setmetatable ` first but write each field with ` rawset(self :: any, "_x", v) ` .)
129+ When a subclass wraps a metamethod parent — ` setmetatable(Parent.new(...) :: any, MyClass) ` — the
130+ fields it adds must use ` rawset ` too, since ` MyClass.__newindex ` will reject them.
131+
132+ These classes carry no test coverage during conversion, so the checker won't catch either mistake —
133+ they only surface at runtime. When in doubt, preserve the original access/order verbatim and just
134+ add casts.
135+
84136## The export type rule — always ` typeof(setmetatable(...)) ` , never hand-list methods
85137
86138There is ** one** way to write a class's export type, and it holds for generic, inherited, and
0 commit comments