Skip to content

[Bug]: Assigning an undeclared field to a container element inside a loop/conditional makes it a required field of the class #86

Description

@AmyJeanes

Area: glua_check (expected to affect the language server too; the repro and diagnostics below are from glua_check)


Summary

Assigning an undeclared field to a value typed as a container's element (table<_, T> or T[]), inside a loop or conditional block, promotes that field to a required member of the class T. Every table literal that constructs a T then fails with Missing required fields in type T: X - even though T never declared X, and it was only attached at runtime in an unrelated loop.

These are warnings, so this fails glua_check --warnings-as-errors with exit 1.

Minimal repro

Repro: bug7-undeclared-field-promoted-required.zip

Standalone - no workspace.library and no GLua annotations. .luarc.json is only {"runtime":{"version":"LuaJIT"}}.

---@class Fires
---@field ID string

---@type table<string, Fires>
local storeF = {}

---@param t Fires
local function makeF(t) return t.ID end

makeF({ ID = "a" })                       -- Missing required fields in type `Fires`: `X`
for _, v in pairs(storeF) do v.X = 5 end  -- attaching undeclared `X` here is what promotes it

glua_check --warnings-as-errors . reports two warnings, both on the constructor at line 28 of repro.lua (Fires never declares X):

warning: Missing required fields in type `Fires`: `X` [missing-fields]
  --> repro.lua:28:7
warning: expected `Fires` but found `{ ID = "a" }`. missing member X, in table [param-type-mismatch]
  --> repro.lua:28:7

Removing the v.X = 5 line clears both. The constructor and the loop are otherwise unrelated - the loop merely stashes a runtime value on each element.

What the trigger needs (controls, each its own class so they don't cross-contaminate)

All four scenarios are in the repro file; only the first fires. Each control drops one ingredient and goes clean:

  • A declared field - for _, v in pairs(store) do v.ID = "z" end (assigning the declared ID) promotes nothing. Only an undeclared field is promoted.
  • A plain param, not a container element - ---@param a T ... a.X = 5 does not promote. The value has to come from indexing a table<_, T> / T[].
  • Top level, not a nested block - the identical store["k"].X = 5 at file scope is clean; wrapping it in if ... then ... end (or a for) is what triggers it.

That last one is the clearest sign it is an implementation artefact: the same container-element assignment promotes the field to required only when it sits inside a block.

Real-world impact

Sonic-Screwdriver keeps its sonic definitions in a table<string, sonicsd_sonic> and, when building the options menu, iterates them to stash a combo-box handle on each:

for _, v in pairs(SonicSD.sonics) do
    v.OptionID = comboBox:AddChoice(v.Name, v.ID)
end

OptionID is not declared on sonicsd_sonic, so on 1.2.0 it was promoted to a required field and every sonic literal reported Missing required fields: OptionID. We worked around it by declaring a sonicsd_sonic_complete : sonicsd_sonic subclass with ---@field OptionID number? and retyping the store to it - fine here because the field genuinely exists at runtime, but the promotion is still wrong: a field attached in a loop should not become mandatory for constructors.

Expected

X (attached only by a runtime assignment) is at most an optional member of Fires, never a required one, so makeF({ ID = "a" }) type-checks - matching the declared-field, param, and top-level forms, which already are clean.

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 all three controls were run before filing. The Fires / X reduction keeps the real Sonic-Screwdriver structure - a table<string, T> store, a loop attaching an undeclared field to each element, and a T literal elsewhere.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions