Area: glua_check (expected to affect the language server too; the repro and diagnostics below are from glua_check)
Summary
An empty {} assigned to a class field is rejected against the field's own declared container type, when a second closure - bound to the same fun(self: T) callback slot - writes self.field[k] = nil to that field. The [k] = nil write makes the element type nilable, and that re-inference reaches back into the first closure and rejects its empty {}. An empty table is a valid table<any, element>, so nothing should be reported.
This looks like the now-fixed #50 - same "empty {} re-inferred from a later write and rejected against its declared type" symptom, and the message reads the same. But #50's own repro cases are clean on 1.2.0, so this is not that. The surviving trigger here is structural: it needs the receiver to be inferred from a fun(self: T) callback slot, and the two writes to live in two separate closures bound to that slot. Collapse either ingredient and it clears. So it is a cross-closure field-type unification through the shared inferred self, not #50's single-scope re-inference.
Minimal repro
Repro: bug1-empty-table-callback-slot.zip
Standalone - no workspace.library and no GLua annotations. .luarc.json is only {"runtime":{"version":"LuaJIT"}}.
---@class element
---@class holder
---@field map table<any, element>
---@param f fun(self: holder)
local function hook(f) end
-- FIRES: two closures through the callback slot.
hook(function(self) self.map = {} end) -- rejected
hook(function(self) self.map["k"] = nil end) -- ...makes the element type nilable
glua_check --warnings-as-errors . on the repro reports exactly one diagnostic (line 28 is the first hook(...) line in repro.lua, below the header comment):
hint: Cannot assign `{ k = nil }` to `table<any,element>`. [assign-type-mismatch]
--> repro.lua:28:21
The {} on the first hook line is a legal empty table<any, element>. The self.map["k"] = nil in the second, unrelated closure is what makes it fire - remove that line and the diagnostic goes away.
Two controls that isolate it
Both are in the repro file, and both are clean - each drops one ingredient:
- Both writes in ONE closure body (
hook(function(self) self.map = {}; self.map["k"] = nil end)) - clean. So it is not the two writes per se; it is the two writes living in separate closures.
- Two functions, but
self declared via ---@param self holder instead of the callback slot - clean. So the callback-slot inference of self is required; a declared receiver is fine.
And, to be explicit about the relationship to #50: #50's own cases A and D are clean on 1.2.0 (I re-ran them). The fix landed; this is an adjacent shape it does not cover.
Real-world impact
Hint-level, so it does not fail a build. But it forced a spurious cast into real code: TARDIS cl_idlesound.lua declares ---@field idlesounds table<any, doors_managed_sound>, seeds it with self.idlesounds = {} in its Initialize and ExteriorChanged hooks, and clears entries with self.idlesounds[k] = nil in its Think hook - every hook an ENT:AddHook(name, id, function(self) ... end), i.e. the callback slot. On 1.2.0 both = {} seeds are rejected, and we carry a --[[@as table<any, doors_managed_sound>]] on each purely to silence it.
Expected
The empty {} is accepted as a valid table<any, element> and nothing is reported - matching the single-body and ---@param-declared forms, which already are.
Environment
glua_check 1.2.0
- annotations
gluals-annotations-prerelease @ aae7341 (not needed for the repro)
- Windows 11
Disclosure: this report was researched and written by Claude Code working in my repositories. Every claim in it was reproduced against a real workspace on 1.2.0 rather than asserted, and the two controls and the #50 re-check were run before filing. The ---@class element / ---@class holder names are placeholders standing in for a real content class and its owning entity; the reduction keeps the exact structure of the real site.
Area:
glua_check(expected to affect the language server too; the repro and diagnostics below are fromglua_check)Summary
An empty
{}assigned to a class field is rejected against the field's own declared container type, when a second closure - bound to the samefun(self: T)callback slot - writesself.field[k] = nilto that field. The[k] = nilwrite makes the element type nilable, and that re-inference reaches back into the first closure and rejects its empty{}. An empty table is a validtable<any, element>, so nothing should be reported.This looks like the now-fixed #50 - same "empty
{}re-inferred from a later write and rejected against its declared type" symptom, and the message reads the same. But #50's own repro cases are clean on 1.2.0, so this is not that. The surviving trigger here is structural: it needs the receiver to be inferred from afun(self: T)callback slot, and the two writes to live in two separate closures bound to that slot. Collapse either ingredient and it clears. So it is a cross-closure field-type unification through the shared inferredself, not #50's single-scope re-inference.Minimal repro
Repro: bug1-empty-table-callback-slot.zip
Standalone - no
workspace.libraryand no GLua annotations..luarc.jsonis only{"runtime":{"version":"LuaJIT"}}.glua_check --warnings-as-errors .on the repro reports exactly one diagnostic (line 28 is the firsthook(...)line inrepro.lua, below the header comment):The
{}on the firsthookline is a legal emptytable<any, element>. Theself.map["k"] = nilin the second, unrelated closure is what makes it fire - remove that line and the diagnostic goes away.Two controls that isolate it
Both are in the repro file, and both are clean - each drops one ingredient:
hook(function(self) self.map = {}; self.map["k"] = nil end)) - clean. So it is not the two writes per se; it is the two writes living in separate closures.selfdeclared via---@param self holderinstead of the callback slot - clean. So the callback-slot inference ofselfis required; a declared receiver is fine.And, to be explicit about the relationship to #50: #50's own cases A and D are clean on 1.2.0 (I re-ran them). The fix landed; this is an adjacent shape it does not cover.
Real-world impact
Hint-level, so it does not fail a build. But it forced a spurious cast into real code: TARDIS
cl_idlesound.luadeclares---@field idlesounds table<any, doors_managed_sound>, seeds it withself.idlesounds = {}in itsInitializeandExteriorChangedhooks, and clears entries withself.idlesounds[k] = nilin itsThinkhook - every hook anENT:AddHook(name, id, function(self) ... end), i.e. the callback slot. On 1.2.0 both= {}seeds are rejected, and we carry a--[[@as table<any, doors_managed_sound>]]on each purely to silence it.Expected
The empty
{}is accepted as a validtable<any, element>and nothing is reported - matching the single-body and---@param-declared forms, which already are.Environment
glua_check1.2.0gluals-annotations-prerelease@aae7341(not needed for the repro)Disclosure: this report was researched and written by Claude Code working in my repositories. Every claim in it was reproduced against a real workspace on 1.2.0 rather than asserted, and the two controls and the #50 re-check were run before filing. The
---@class element/---@class holdernames are placeholders standing in for a real content class and its owning entity; the reduction keeps the exact structure of the real site.