#!/usr/bin/env lua
-- ============================================================================
-- semantic_sweep.lua -- data-flow drift tier (AST-based; option (d))
--
-- Catches the class regex CAN'T reach: aliased-state cascades.
--     local d = state.session.ui.decor
--     ... d and d.selectedItemID        <- dead guard? or legit?
-- A regex sees `d and d.x` and can't know whether `d` is a factory-seeded
-- bucket (guaranteed non-nil -> the guard is dead -> NO-GUARDS violation) or a
-- nil-able slot (guard is correct). The answer lives in the assignment PLUS the
-- state factory shape.
--
-- Two halves, both ground-truth (no modelling, no regex):
--   1. RUN the state factories and walk the result -> the set of guaranteed
--      paths. A seeded bucket (acquisition = {}) enumerates via pairs(); a nil
--      slot (daily = { bestowed = nil }) does not.
--   2. PARSE each source file with luacheck's real Lua AST and walk it with
--      lexical block scoping. For every `X and X.field`, resolve X to its
--      aliased state path (if any) via the scope, then classify:
--         alias path in guaranteed  -> VIOLATION (dead guard, strict-read it)
--         alias path not seeded      -> PASS      (nil-able, guard is correct)
--         X is not a state alias     -> ignore    (global/upvalue/param)
--
-- Tier 2 (theme-color guards, Phase 1): a SECOND provenance class on the same
-- AST walk. A theme color is always factory-built (rgba/hex/withAlpha all set
-- r,g,b,a), so `<color>.a or X` is a dead guard. We mark locals aliased to a
-- `:GetColor(...)` result (+ inline GetColor calls) and flag color-field `or`
-- defaults. The palette is materialized the same ground-truth way as the state
-- factory -- every scheme color leaf must carry r/g/b/a (100% coverage = the
-- invariant holds). The COLOR-alias requirement keeps it off arbitrary
-- `x.a or 1` (e.g. an optional shadowOffset), which is a different provenance.
--
-- This supersedes the earlier line-based POC. The AST removes its blind spots:
-- multi-line cascades (alias and guard on different lines), dotted-prefix
-- aliases (HDG.Store.state.X), exact lexical scope (a `local d` in one
-- function/block can't alias `d` in another), and string/comment safety (the
-- parser never matches text inside a literal). Indexed access
-- (state.account.collections[id]) is correctly NOT treated as the seeded
-- parent -- the key isn't a String literal, so the path doesn't resolve.
--
-- Run from the HousingDecorGuide/ root:   lua scripts/semantic_sweep.lua
-- Exit 0 = clean, 1 = violations found.
-- ============================================================================

-- ---- 0. luacheck AST (real parser; needs a decoder Chars object, not a str) -
local okp, parser  = pcall(require, "luacheck.parser")
local okd, decoder = pcall(require, "luacheck.decoder")
if not (okp and okd) then
    io.stderr:write("semantic_sweep: luacheck not available (luarocks install luacheck). " ..
        "parser=" .. tostring(parser) .. "\n")
    os.exit(3)
end

-- ---- 1. Materialize the guaranteed state shape by RUNNING the factories -----
dofile("tests/wow_mock.lua")
-- wow_mock stubs _G.print to a no-op (so test print() is silent); restore a
-- real one for this tool's report.
print = function(...)
    local n, a = select("#", ...), { ... }
    for i = 1, n do io.write(tostring(a[i])); if i < n then io.write("\t") end end
    io.write("\n")
end
-- Full UI bootstrap -- mirrors tests/test_layoutconfig_smoke.lua load order
-- (keep in sync) so the spec tier sees LayoutConfig.widgets + the WidgetTypes
-- registry fully materialized, not just Core/state. Paths are root-relative
-- (this tool runs from HousingDecorGuide/; the test runs from tests/ with `../`).
local BOOT = {
    "Core/HDGR_SchemeConstants", "Core/HDGR_Constants", "Core/HDGR_Paths",
    "Core/HDGR_Log", "Core/HDGR_Locale", "Core/HDGR_TableUtils", "Core/HDGR_Memo",
    "Core/HDGR_BlizzardEvents", "Core/HDGR_Environment", "Core/HDGR_WidgetTypes",
    "Core/HDGR_TooltipEngine", "Core/HDGR_TooltipRecipes", "Core/HDGR_Modules",
    "Core/HDGR_Middleware", "Core/HDGR_StoreFurnishings", "Core/HDGR_Store",
    "UI/HDGR_Theme", "UI/HDGR_LayoutConfig",
    "UI/LayoutConfig/HDGR_LayoutConfig_Decor", "UI/LayoutConfig/HDGR_LayoutConfig_Acquisition",
    "UI/LayoutConfig/HDGR_LayoutConfig_Recipes", "UI/LayoutConfig/HDGR_LayoutConfig_Warehouse",
    "UI/LayoutConfig/HDGR_LayoutConfig_Alts", "UI/LayoutConfig/HDGR_LayoutConfig_Mogul",
    "UI/LayoutConfig/HDGR_LayoutConfig_Styles", "UI/LayoutConfig/HDGR_LayoutConfig_Trainers",
    "UI/LayoutConfig/HDGR_LayoutConfig_HouseTab", "UI/LayoutConfig/HDGR_LayoutConfig_Debug",
    "UI/LayoutConfig/HDGR_LayoutConfig_Config",
    "UI/HDGR_Window", "UI/HDGR_Selectors", "UI/HDGR_Selectors_Lumber",
    "UI/LayoutConfig/HDGR_LayoutConfig_Lumber", "UI/HDGR_Selectors_Shopping",
    "UI/LayoutConfig/HDGR_LayoutConfig_Shopping", "UI/HDGR_Selectors_Zone",
    "UI/LayoutConfig/HDGR_LayoutConfig_Zone", "UI/LayoutConfig/HDGR_LayoutConfig_Nav",
    "UI/HDGR_Components", "UI/HDGR_Layout", "UI/HDGR_Panels",
    "UI/HDGR_AcqMapWidget", "UI/HDGR_RowRegistry",
}
for _, m in ipairs(BOOT) do dofile(m .. ".lua") end
HDG.WidgetTypes:Flush()                 -- deferred-queue registry: flush so specFields are queryable
_G.HDG_DB = _G.HDG_DB or {}
HDG.Store:LoadFromSavedVariables()  -- runs NewDefaultSession + NewAccount + Ensure*

local guaranteed = {}   -- ["session.ui.acquisition"] = true  (every seeded path)
local function walkState(t, prefix)
    for k, v in pairs(t) do
        if type(k) == "string" then
            local path = (prefix == "") and k or (prefix .. "." .. k)
            guaranteed[path] = true
            if type(v) == "table" then walkState(v, path) end
        end
    end
end
walkState(HDG.Store:GetState(), "")
local nPaths = 0
for _ in pairs(guaranteed) do nPaths = nPaths + 1 end

-- Materialize the palette: walk every scheme, confirm each color leaf (numeric
-- r,g,b) also carries a numeric `a`. 100% coverage proves the "GetColor result
-- always has r/g/b/a" invariant the color tier relies on (ground-truth, like
-- the state factory above -- if a future scheme adds an alpha-less color, the
-- coverage drops and we revisit before trusting the tier).
local nColorLeaves, nColorAlpha = 0, 0
local function walkPalette(t, seen)
    if type(t) ~= "table" or seen[t] then return end
    seen[t] = true
    if type(t.r) == "number" and type(t.g) == "number" and type(t.b) == "number" then
        nColorLeaves = nColorLeaves + 1
        if type(t.a) == "number" then nColorAlpha = nColorAlpha + 1 end
        return                                            -- color leaf: don't recurse in
    end
    for _, v in pairs(t) do walkPalette(v, seen) end
end
walkPalette(_G.HDGR_SchemeConstants, {})

-- Materialize the spec contract from DECLARATIONS (Phase 1, declaration-driven):
-- group every declared widget by .kind; a field is "guaranteed present" for a
-- kind iff EVERY widget of that kind declares it. So `spec.<field> or default`
-- in that kind's build fn is a dead guard. No hand-coded shapes -- this reads
-- HDG.LayoutConfig.widgets directly, so it tracks the declarations as they
-- change (add a widget that omits a field and the field stops being guaranteed).
local specGuaranteed = {}   -- [kind] = { field = true } present on every widget of that kind
local nWidgets, nKinds = 0, 0
do
    local byKind = {}
    for _, w in pairs(HDG.LayoutConfig.widgets) do
        if type(w) == "table" and type(w.kind) == "string" then
            nWidgets = nWidgets + 1
            local fields = {}
            for k, v in pairs(w) do
                if type(k) == "string" and v ~= nil then fields[k] = true end
            end
            byKind[w.kind] = byKind[w.kind] or {}
            byKind[w.kind][#byKind[w.kind] + 1] = fields
        end
    end
    for kind, sets in pairs(byKind) do
        nKinds = nKinds + 1
        local inter = {}
        for k in pairs(sets[1]) do inter[k] = true end           -- seed with first widget's keys
        for i = 2, #sets do                                      -- keep only keys in EVERY widget
            for k in pairs(inter) do if not sets[i][k] then inter[k] = nil end end
        end
        specGuaranteed[kind] = inter
    end
end

-- ---- 2. AST helpers ---------------------------------------------------------
-- Flatten a pure dotted Index/Id chain to its parts, or nil if any link is
-- non-pure (indexed `[expr]`, a call, etc.). `state.account.collections[id]`
-- -> nil (the `[id]` key is an Id node, not a String) so it never resolves to
-- the seeded parent -- exactly the anchored behaviour we want.
local function flatten(node)
    if type(node) ~= "table" then return nil end
    if node.tag == "Id" then return { node[1] } end
    if node.tag == "Index" then
        local base = flatten(node[1])
        if not base then return nil end
        local key = node[2]
        if type(key) ~= "table" or key.tag ~= "String" then return nil end
        base[#base + 1] = key[1]
        return base
    end
    return nil
end

-- An assignment RHS -> the seeded state path it aliases, or nil. Accepts both
-- `state.<path>` and `HDG.Store.state.<path>`.
local function aliasPathFromExpr(node)
    local parts = flatten(node)
    if not parts then return nil end
    if parts[1] == "state" and #parts >= 2 then
        return table.concat(parts, ".", 2)
    end
    if #parts >= 4 and parts[1] == "HDG" and parts[2] == "Store" and parts[3] == "state" then
        return table.concat(parts, ".", 4)
    end
    return nil
end

-- ---- 2b. Theme-color provenance (Phase 1) ----------------------------------
-- A theme color is ALWAYS factory-built (rgba/hex/withAlpha all set r,g,b,a),
-- so `<color>.a or X` (or .r/.g/.b) is a dead guard -- strict-read it. We mark
-- any local aliased to a `:GetColor(...)` result, and also catch the inline
-- `HDG.Theme:GetColor("x").a or 1`. The palette materialization (below) proves
-- the r/g/b/a invariant the same way the state factory proves seeded paths.
local COLOR = {}   -- sentinel: a scope var aliased to a GetColor() result
local function isGetColorCall(node)
    if type(node) ~= "table" then return false end
    if node.tag == "Invoke" then                         -- obj:GetColor(...)
        local m = node[2]
        return type(m) == "table" and m.tag == "String" and m[1] == "GetColor"
    end
    if node.tag == "Call" then                           -- obj.GetColor(...) (rare)
        local fn = node[1]
        if type(fn) == "table" and fn.tag == "Index" then
            local k = fn[2]
            return type(k) == "table" and k.tag == "String" and k[1] == "GetColor"
        end
    end
    return false
end
-- Assignment RHS -> alias marker: a seeded state path (string), COLOR, or nil.
local function aliasOf(node)
    local p = aliasPathFromExpr(node)
    if p then return p end
    if isGetColorCall(node) then return COLOR end
    return nil
end

-- ---- 2c. Spec-param recognizer (Phase 1, structural) -----------------------
-- A widget kind's `build` is the 2nd arg of `WidgetTypes:Register("kind", { build
-- = function(parent, spec) ... end })`. We find those build Function nodes and
-- map each to its kind (a String literal in the AST -- no runtime needed). The
-- generic walk then binds that function's 2nd param as a spec alias for the kind,
-- so `spec.<field> or default` resolves against specGuaranteed[kind].
local buildFnKind = {}   -- [FunctionNode] = kind string (inline build OR delegate helper)
local function tableField(tbl, key)               -- value of `key = ...` in a Table node, or nil
    for _, e in ipairs(tbl) do
        if type(e) == "table" and e.tag == "Pair" then
            local k = e[1]
            if type(k) == "table" and k.tag == "String" and k[1] == key then return e[2] end
        end
    end
    return nil
end
-- A trivial wrapper `function(parent, spec) return helper(parent, spec) end` ->
-- the helper's name, so the helper's spec reads are attributed to the kind too
-- (HDG delegates many builds to a named local fn). Requires the wrapper's 2nd
-- param to be passed as the call's 2nd arg (the parent,spec convention).
local function delegateName(wrapperFn)
    local params = wrapperFn[1]
    local specName = type(params) == "table" and type(params[2]) == "table"
        and params[2].tag == "Id" and params[2][1]
    if not specName then return nil end
    local body = wrapperFn[2]
    if type(body) ~= "table" or #body ~= 1 then return nil end       -- only the trivial wrapper
    local ret = body[1]
    if type(ret) ~= "table" or ret.tag ~= "Return" then return nil end
    local call = ret[1]
    if type(call) ~= "table" or call.tag ~= "Call" then return nil end  -- Call to a local fn (not a method)
    local fnId, a2 = call[1], call[3]
    if type(fnId) == "table" and fnId.tag == "Id"
       and type(a2) == "table" and a2.tag == "Id" and a2[1] == specName then
        return fnId[1]
    end
    return nil
end
-- Per file: collect `local function NAME` defs + Register builds, then resolve
-- wrapper delegations to the helper defined in the same file.
local function findBuildFns(ast)
    local localFns, pending = {}, {}
    local function walk(node)
        if type(node) ~= "table" then return end
        if node.tag == "Localrec" then                              -- local function NAME(...)
            local names, fns = node[1], node[2]
            if type(names) == "table" and type(names[1]) == "table" and names[1].tag == "Id"
               and type(fns) == "table" and type(fns[1]) == "table" and fns[1].tag == "Function" then
                localFns[names[1][1]] = fns[1]
            end
        end
        if node.tag == "Invoke" and type(node[2]) == "table" and node[2].tag == "String"
           and node[2][1] == "Register" then
            local kindNode, tbl = node[3], node[4]
            if type(kindNode) == "table" and kindNode.tag == "String"
               and type(tbl) == "table" and tbl.tag == "Table" then
                local fn = tableField(tbl, "build")
                if type(fn) == "table" and fn.tag == "Function" then
                    buildFnKind[fn] = kindNode[1]                   -- inline build reads spec directly
                    local d = delegateName(fn)
                    if d then pending[#pending + 1] = { kind = kindNode[1], name = d } end
                end
            end
        end
        for _, v in ipairs(node) do walk(v) end
    end
    walk(ast)
    for _, p in ipairs(pending) do
        if localFns[p.name] then buildFnKind[localFns[p.name]] = p.kind end
    end
end

-- ---- 3. Lexical scope -------------------------------------------------------
local function newScope(parent) return { vars = {}, parent = parent, inPaint = parent and parent.inPaint } end
-- pathOrNil: a state-path string => tracked alias; nil => bound-but-not-alias
-- (stored as `false`, so a shadowing `local d = foo()` masks an outer alias).
local function define(scope, name, pathOrNil) scope.vars[name] = pathOrNil or false end
local function lookup(scope, name)
    while scope do
        local v = scope.vars[name]
        if v ~= nil then return v end
        scope = scope.parent
    end
    return nil
end

-- ---- 4. Collect + classify cascades ----------------------------------------
local viol, pass, colorViol, specViol = {}, {}, {}, {}
local curFile, curLines

local function checkCascade(op, scope)
    local lhs, rhs = op[2], op[3]
    if type(lhs) ~= "table" or lhs.tag ~= "Id" then return end
    local name = lhs[1]
    local fieldParts = flatten(rhs)                       -- rhs must be X.field[.field...]
    if not (fieldParts and fieldParts[1] == name and #fieldParts > 1) then return end
    local aliased = lookup(scope, name)
    if type(aliased) ~= "string" then return end          -- not a tracked state alias
    local line = op.line or 0
    local rec = {
        file  = curFile, line = line, var = name, path = aliased,
        field = table.concat(fieldParts, ".", 2),
        text  = (curLines[line] or ""):gsub("^%s+", ""),
    }
    if guaranteed[aliased] then viol[#viol + 1] = rec else pass[#pass + 1] = rec end
end

-- `<base>.<r|g|b|a> or <default>` where <base> is a factory-built color (a
-- GetColor alias, or an inline GetColor call) -> dead guard. The COLOR-alias
-- requirement is what keeps this off arbitrary `x.a or 1` (e.g. an optional
-- shadowOffset) -- only known color provenance is flagged.
local COLORFIELDS = { r = true, g = true, b = true, a = true }
local function checkColorGuard(op, scope)
    local left = op[2]                                    -- LHS of `... or default`
    if type(left) ~= "table" or left.tag ~= "Index" then return end
    local key = left[2]
    if type(key) ~= "table" or key.tag ~= "String" or not COLORFIELDS[key[1]] then return end
    local base, isColor = left[1], false
    if type(base) == "table" then
        if base.tag == "Id" then isColor = (lookup(scope, base[1]) == COLOR)
        else isColor = isGetColorCall(base) end           -- inline GetColor(...).a or X
    end
    if not isColor then return end
    local line = op.line or 0
    colorViol[#colorViol + 1] = {
        file = curFile, line = line, field = key[1],
        text = (curLines[line] or ""):gsub("^%s+", ""),
    }
end

-- `spec.<field> or default` inside a kind's build fn where <field> is declared
-- on EVERY widget of that kind -> dead guard (the LayoutConfig already declares
-- it). `spec` is the build fn's 2nd param, marked a spec alias by the walk.
local function checkSpecGuard(op, scope)
    local left = op[2]
    if type(left) ~= "table" or left.tag ~= "Index" then return end
    local key = left[2]
    if type(key) ~= "table" or key.tag ~= "String" then return end
    local base = left[1]
    if type(base) ~= "table" or base.tag ~= "Id" then return end
    local marker = lookup(scope, base[1])
    if type(marker) ~= "table" or not marker.spec then return end
    local g = specGuaranteed[marker.kind]
    if not (g and g[key[1]]) then return end                  -- field not universally declared
    local line = op.line or 0
    specViol[#specViol + 1] = {
        file = curFile, line = line, kind = marker.kind, field = key[1],
        text = (curLines[line] or ""):gsub("^%s+", ""),
    }
end

-- ---- 4b. Row/cell inline-paint (Strategy A enforcement) --------------------
-- Row factories + cell initFuncs paint POOLED frames imperatively -- in _layout*/
-- _paint*/_render* helpers + inline Configure/initFunc/binder closures -- so the
-- widget system's STRUCTURAL Theme discipline (dispatch.push + Theme:Register)
-- holds only by CONVENTION there. A direct :SetTextColor / :SetVertexColor /
-- :SetFont on the pooled frame bakes a color/font that does NOT re-paint on a
-- scheme switch ("rows don't inherit the widget rails" smell). We mark those
-- Function nodes; their scope gets inPaint=true. The SHARED paths are method calls
-- on UI/Theme objects (HDG.UI.RowText, _TintTexture, HDG.Theme:*, applyFontRole),
-- never the frame, so they don't match -- and `function HDG.UI:PaintCraftStar`
-- (an Index-LHS method def) isn't a `local function _paint*`, so the shared paint
-- helper is correctly excluded.
local paintCtxFn = {}   -- [FunctionNode] = label
local PAINT_METHODS = { SetTextColor = true, SetBackdropColor = true,
    SetBackdropBorderColor = true, SetVertexColor = true, SetColorTexture = true, SetFont = true }
local PAINT_FIELDS  = { Configure = true, initFunc = true, binder = true, paint = true }
local paintViol = {}

local function isPaintHelperName(name)
    if type(name) ~= "string" then return false end
    local n = name:lower()
    return (n:find("layout") or n:find("paint") or n:find("configure") or n:find("render")) ~= nil
end

local function findPaintHelpers(ast)
    local function mark(fn, label) if type(fn) == "table" and fn.tag == "Function" then paintCtxFn[fn] = label end end
    local function walk(node)
        if type(node) ~= "table" then return end
        if node.tag == "Localrec" then                                  -- local function _paintXxx(...)
            local names, fns = node[1], node[2]
            if type(names) == "table" and type(names[1]) == "table" and names[1].tag == "Id"
               and isPaintHelperName(names[1][1]) then mark(fns and fns[1], names[1][1]) end
        elseif node.tag == "Local" or node.tag == "Set" then            -- local _paintXxx = function ...
            for i, var in ipairs(node[1] or {}) do
                local nm = type(var) == "table" and var.tag == "Id" and var[1]
                if nm and isPaintHelperName(nm) then mark(node[2] and node[2][i], nm) end
            end
        elseif node.tag == "Invoke" and type(node[2]) == "table" and node[2].tag == "String"
               and (node[2][1] == "Register" or node[2][1] == "RegisterCellKind") then
            local tbl = node[4]                                          -- inline Configure/initFunc/binder closures
            if type(tbl) == "table" and tbl.tag == "Table" then
                for fld in pairs(PAINT_FIELDS) do mark(tableField(tbl, fld), fld) end
            end
        end
        for _, v in ipairs(node) do walk(v) end
    end
    walk(ast)
end

-- ONLY two colors legitimately don't resolve from a token: (1) runtime per-item game
-- DATA (a dye's actual swatch color -- thousands of them) applied via the themed
-- _TintTexture rail, marked `-- data`; (2) the palette INSPECTOR, whose job is to
-- preview arbitrary tokens, marked `-- inspector` / `-- dev`. Everything else MUST be a
-- token (Palette brand: faction/expansion/source, or Theme semantic). "bespoke" /
-- "scheme-invariant" / "physical" are NOT escapes -- a meaningful color is a token.
local function hasPaintEscape(text)
    return text:find("%-%- *data%f[%W]") or text:find("%-%- *inspector") or text:find("%-%- *dev%f[%W]")
end

local function checkPaint(node, scope)
    if not (scope and scope.inPaint) then return end
    -- Skip the shared PAINT layer: Theme skinners + the widget factory + Palette
    -- legitimately paint frames inline -- that IS the canonical path, not row drift.
    if curFile:find("HDGR_Theme") or curFile:find("HDGR_Palette") or curFile:find("HDGR_Components") then return end
    local meth = node[2]
    if type(meth) ~= "table" or meth.tag ~= "String" or not PAINT_METHODS[meth[1]] then return end
    local line = node.line or meth.line or 0
    local text = curLines[line] or ""
    if hasPaintEscape(text) then return end
    paintViol[#paintViol + 1] = { file = curFile, line = line, method = meth[1], text = text:gsub("^%s+", "") }
end

-- ---- 4c. Observer-tick coverage (silent staleness; catalog-backed selectors) -
-- A selector that DIRECTLY reads HousingCatalogObserver data -- the counts /
-- ownership built at sweep and mutated in place by PatchCounts -- must declare
-- session.catalog.sweepGeneration in its `reads`, or it silently goes stale when
-- the catalog changes (learn / remove / place). test_selector_reads CANNOT catch
-- this: it instruments reads of `state.*`, and the observer index is NOT state.
-- The 2026-06-06 "learned item stays Not Collected" bug had the tick declared
-- correctly (the failure was a render-ordering race) -- but the next one could
-- just as easily be a missing tick, and nothing else would see it. Scoped to the
-- catalog observer; its sibling observers (Achievement/Rep/Quest) carry their own
-- ticks. Fully-qualified access only (`HDG.HousingCatalogObserver:...`); a
-- file-level `local O = HDG.HousingCatalogObserver` alias would slip through --
-- keep the access qualified inside selectors (the codebase convention).
local CATALOG_OBSERVER = "HousingCatalogObserver"
local CATALOG_TICK     = "session.catalog.sweepGeneration"
local tickViol = {}
local selectorDefs = {}   -- [name] = { readsSet, wildcard, calls, observerHit, file }
                          -- tick-coverage resolved TRANSITIVELY once all files load

-- An Invoke/Index node that directly reads the catalog observer -> a label for
-- the message (`:Method` or `.byField`), or nil.
local function catalogObserverRead(node)
    if type(node) ~= "table" then return nil end
    if node.tag == "Invoke" then                          -- HDG.HousingCatalogObserver:Method(...)
        local recv = flatten(node[1])
        if recv and recv[1] == "HDG" and recv[#recv] == CATALOG_OBSERVER then
            local m = node[2]
            return ":" .. ((type(m) == "table" and m.tag == "String" and m[1]) or "?")
        end
    elseif node.tag == "Index" then                       -- HDG.HousingCatalogObserver.by<X>
        local parts = flatten(node)
        if parts and parts[1] == "HDG" and parts[2] == CATALOG_OBSERVER
           and type(parts[3]) == "string" and parts[3]:sub(1, 2) == "by" then
            return "." .. parts[3]
        end
    end
    return nil
end

-- A `reads` node -> (set of declared path strings, wildcard bool for "*").
local function extractReads(readsNode)
    local set, wildcard = {}, false
    if type(readsNode) == "table" then
        if readsNode.tag == "String" then wildcard = (readsNode[1] == "*")
        elseif readsNode.tag == "Table" then
            for _, e in ipairs(readsNode) do
                if type(e) == "table" and e.tag == "String" then set[e[1]] = true end
            end
        end
    end
    return set, wildcard
end

-- A `calls = {...}` node -> list of called selector NAMES (String elements).
local function extractCalls(callsNode)
    local out = {}
    if type(callsNode) == "table" and callsNode.tag == "Table" then
        for _, e in ipairs(callsNode) do
            if type(e) == "table" and e.tag == "String" then out[#out + 1] = e[1] end
        end
    end
    return out
end

-- First UNESCAPED catalog-observer read inside a fn subtree -> {line, kind, text},
-- or nil. An `-- exception(...)` / `-- static` on the read line opts out (a
-- genuinely static catalog field that never changes post-load); the walk keeps
-- looking past an escaped line for another, unescaped read.
local function firstCatalogReadIn(fn)
    local found
    local function walk(node)
        if found or type(node) ~= "table" then return end
        local kind = catalogObserverRead(node)
        if kind then
            local line = node.line or 0
            local text = (curLines[line] or ""):gsub("^%s+", "")
            if not (text:find("%-%- *exception%(") or text:find("%-%- *static")) then
                found = { line = line, kind = kind, text = text }
                return
            end
        end
        for _, v in ipairs(node) do walk(v) end
    end
    walk(fn)
    return found
end

-- Collect every `Selectors:Register("name", { reads, calls, fn })` table-form def
-- with its declared reads/calls + whether its fn directly reads the catalog
-- observer. Tick-coverage is resolved TRANSITIVELY after all files load -- a
-- selector legitimately inherits the tick through `calls` (e.g. acq.items reads
-- the observer but pulls sweepGeneration via acq.allItems). Function-form Register
-- (2nd arg a Function, reads defaults to "*") has no `fn` field, so it isn't
-- matched; DefineEnum/DefinePath build names by concatenation (not a String node)
-- and read state, never the observer.
local function collectSelectorDefs(ast)
    local function walk(node)
        if type(node) ~= "table" then return end
        if node.tag == "Invoke" and type(node[2]) == "table" and node[2].tag == "String"
           and node[2][1] == "Register" and type(node[3]) == "table" and node[3].tag == "String"
           and type(node[4]) == "table" and node[4].tag == "Table" then
            local tbl = node[4]
            local fn  = tableField(tbl, "fn")
            if type(fn) == "table" and fn.tag == "Function" then
                local set, wildcard = extractReads(tableField(tbl, "reads"))
                selectorDefs[node[3][1]] = {
                    readsSet = set, wildcard = wildcard,
                    calls = extractCalls(tableField(tbl, "calls")),
                    observerHit = firstCatalogReadIn(fn),
                    file = curFile,
                }
            end
        end
        for _, v in ipairs(node) do walk(v) end
    end
    walk(ast)
end

-- ---- 5. Mutually-recursive AST walk (scope-aware) --------------------------
local walkExpr, walkBlock, processStmt
local EMPTY = {}   -- shared nil-guard for absent child lists (`local x`, `do end`)

function walkExpr(node, scope)
    if type(node) ~= "table" then return end
    if node.tag == "Function" then                        -- function literal -> child scope
        local child = newScope(scope)
        local kind = buildFnKind[node]                    -- a widget build fn? param[2] is `spec`
        if paintCtxFn[node] then child.inPaint = true end -- row/cell paint helper -> flag inline paint inside
        for i, p in ipairs(node[1] or EMPTY) do
            if type(p) == "table" and p.tag == "Id" then
                if kind and i == 2 then define(child, p[1], { spec = true, kind = kind })
                else define(child, p[1], false) end
            end
        end
        walkBlock(node[2], child)
        return
    end
    if node.tag == "Op" and node[1] == "and" then checkCascade(node, scope) end
    if node.tag == "Op" and node[1] == "or"  then checkColorGuard(node, scope); checkSpecGuard(node, scope) end
    if node.tag == "Invoke" then checkPaint(node, scope) end
    for _, v in ipairs(node) do
        if type(v) == "table" then walkExpr(v, scope) end
    end
end

local function bind(lhsList, rhsList, scope)
    rhsList = rhsList or EMPTY
    for i, var in ipairs(lhsList or EMPTY) do
        if type(var) == "table" and var.tag == "Id" then
            define(scope, var[1], aliasOf(rhsList[i]))
        end
    end
end

function processStmt(stmt, scope)
    if type(stmt) ~= "table" then return end
    local t = stmt.tag
    if t == "Local" or t == "Set" then
        for _, e in ipairs(stmt[2] or EMPTY) do walkExpr(e, scope) end  -- RHS cascades first
        bind(stmt[1], stmt[2], scope)
    elseif t == "Localrec" then                                 -- local function f
        bind(stmt[1], {}, scope)                                -- name -> not-alias
        for _, e in ipairs(stmt[2] or EMPTY) do walkExpr(e, scope) end
    elseif t == "Do" then
        walkBlock(stmt[1], newScope(scope))
    elseif t == "While" then
        walkExpr(stmt[1], scope)                                -- cond (outer scope)
        walkBlock(stmt[2], newScope(scope))
    elseif t == "Repeat" then
        local child = newScope(scope)                           -- until-cond sees body locals
        walkBlock(stmt[1], child)
        walkExpr(stmt[2], child)
    elseif t == "Fornum" then
        local child = newScope(scope)
        if type(stmt[1]) == "table" and stmt[1].tag == "Id" then define(child, stmt[1][1], false) end
        for i = 2, #stmt - 1 do walkExpr(stmt[i], scope) end    -- range exprs (outer)
        walkBlock(stmt[#stmt], child)
    elseif t == "Forin" then
        local child = newScope(scope)
        for _, id in ipairs(stmt[1] or EMPTY) do
            if type(id) == "table" and id.tag == "Id" then define(child, id[1], false) end
        end
        for _, e in ipairs(stmt[2] or EMPTY) do walkExpr(e, scope) end  -- iterator exprs (outer)
        walkBlock(stmt[3], child)
    elseif t == "If" then
        -- {cond, block, [cond, block, ...], [elseblock]}. A branch block is a
        -- tag=nil stmt-list (own scope); a condition is a tagged expr (outer).
        for _, child in ipairs(stmt) do
            if type(child) == "table" then
                if child.tag == nil then walkBlock(child, newScope(scope))
                else walkExpr(child, scope) end
            end
        end
    else
        -- Return / Call / Invoke / Label / Goto / Break / ...: walk expr children
        if stmt.tag == "Invoke" then checkPaint(stmt, scope) end   -- paint as a statement
        for _, v in ipairs(stmt) do
            if type(v) == "table" then walkExpr(v, scope) end
        end
    end
end

function walkBlock(block, scope)
    for _, stmt in ipairs(block or EMPTY) do processStmt(stmt, scope) end
end

-- ---- 6. Parse + walk every source file -------------------------------------
local function sourceFiles()
    local out, p = {}, io.popen("find UI Modules Core -name '*.lua' 2>/dev/null | sort")
    for line in p:lines() do out[#out + 1] = line end
    p:close()
    return out
end

local nFiles, nErr = 0, 0
for _, f in ipairs(sourceFiles()) do
    local fh = io.open(f, "r")
    local src = fh:read("*a"); fh:close()
    local ok, ast = pcall(function() return parser.parse(decoder.decode(src)) end)
    if not ok then
        nErr = nErr + 1
        print(("  (parse error in %s -- skipped: %s)"):format(f, tostring(ast):sub(1, 80)))
    else
        nFiles = nFiles + 1
        curFile, curLines = f, {}
        for line in (src .. "\n"):gmatch("(.-)\n") do curLines[#curLines + 1] = line end
        findBuildFns(ast)                                 -- map build Function nodes -> kind first
        findPaintHelpers(ast)                             -- map row/cell paint-context Function nodes
        walkBlock(ast, newScope(nil))
        collectSelectorDefs(ast)                          -- gather selector reads/calls + observer reads (resolved below)
    end
end

-- Resolve catalog-observer tick coverage transitively: a selector that reads the
-- observer is covered iff sweepGeneration is in its OWN reads (or "*"), or in the
-- transitive reads-closure of any selector it `calls` (mirrors Selectors:GetReads).
-- Unknown callees (not collected -- e.g. DefineEnum-generated) contribute nothing.
local function coversTick(name, seen)
    local d = selectorDefs[name]
    if not d then return false end
    if d.wildcard or d.readsSet[CATALOG_TICK] then return true end
    seen = seen or {}
    if seen[name] then return false end
    seen[name] = true
    for _, c in ipairs(d.calls) do
        if coversTick(c, seen) then return true end
    end
    return false
end
for name, d in pairs(selectorDefs) do
    if d.observerHit and not coversTick(name) then
        tickViol[#tickViol + 1] = { file = d.file, line = d.observerHit.line,
            name = name, kind = d.observerHit.kind, text = d.observerHit.text }
    end
end

-- ---- 7. Report --------------------------------------------------------------
print(("=== semantic_sweep (AST): aliased-state cascades (%d files, %d guaranteed paths) ==="):format(nFiles, nPaths))
print(("\nCRITICAL -- dead guard on a factory-seeded path (%d) -- strict-read these:"):format(#viol))
for _, r in ipairs(viol) do
    print(("  %s:%d  [%s = state.%s]  %s"):format(r.file, r.line, r.var, r.path, r.text:sub(1, 70)))
end
print(("\nPASS -- legit guard, source path is nil-able / not seeded (%d):"):format(#pass))
for _, r in ipairs(pass) do
    print(("  %s:%d  [%s = state.%s]  %s"):format(r.file, r.line, r.var, r.path, r.text:sub(1, 70)))
end

print(("\n=== semantic_sweep (AST): theme-color guards (%d/%d palette colors carry r/g/b/a) ==="):format(nColorAlpha, nColorLeaves))
print(("\nCRITICAL -- dead guard on a factory-built color (%d) -- strict-read these:"):format(#colorViol))
for _, r in ipairs(colorViol) do
    print(("  %s:%d  [color.%s or ...]  %s"):format(r.file, r.line, r.field, r.text:sub(1, 70)))
end

print(("\n=== semantic_sweep (AST): LayoutConfig spec guards (%d widgets / %d kinds declared) ==="):format(nWidgets, nKinds))
print(("\nCRITICAL -- dead guard on a universally-declared spec field (%d) -- strict-read these:"):format(#specViol))
for _, r in ipairs(specViol) do
    print(("  %s:%d  [%s spec.%s or ...]  %s"):format(r.file, r.line, r.kind, r.field, r.text:sub(1, 60)))
end

print(("\n=== semantic_sweep (AST): row/cell inline-paint (theme-bypass; NON-GATING -- %d hits) ==="):format(#paintViol))
print("IMPORTANT -- paint via HDG.UI.RowText / applyFontRole / Theme:GetColor, OR annotate the line `-- bespoke` / `-- scheme-invariant`:")
for _, r in ipairs(paintViol) do
    print(("  %s:%d  [:%s]  %s"):format(r.file, r.line, r.method, r.text:sub(1, 70)))
end

print(("\n=== semantic_sweep (AST): catalog-observer tick coverage ==="))
print(("\nCRITICAL -- selector reads HousingCatalogObserver but omits %s from `reads` -> silent staleness (%d):"):format(CATALOG_TICK, #tickViol))
for _, r in ipairs(tickViol) do
    print(("  %s:%d  [%s -> Observer%s, no sweepGeneration in reads]  %s"):format(r.file, r.line, r.name, r.kind, r.text:sub(1, 60)))
end

if nErr > 0 then print(("\n(%d file(s) failed to parse and were skipped)"):format(nErr)) end
local totalViol = #viol + #colorViol + #specViol + #tickViol   -- paintViol is NON-GATING until driven to 0 + promoted
print(("\nsummary: %d state + %d color + %d spec + %d tick CRITICAL violations, %d legitimate; %d row-paint (non-gating)"):format(
    #viol, #colorViol, #specViol, #tickViol, #pass, #paintViol))
os.exit(totalViol > 0 and 1 or 0)
