--- passes/static_analysis.lua — Per-atom static-analysis checks. --- --- Ownership: `ctx.shared.corpus` is the canonical merged registry; per-source fallback synthesis is rejected. --- `atom.paths` supplies the emitted and analysis projections consumed by this pass. --- --- Per-atom rules: --- 1. transfer_hazards: A single forward walker (`analyze_hardware_relations`) reads `atom.paths.word_events` once per atom. --- For each emitted word event it (a) inspects pending CPU/COP0/COP2/GTE relations against the event as CONSUMER --- (recording a hazard on `atom.paths.hazards` when the producer→consumer gap is below the required retire-slot count), --- (b) applies the event's GPR value effects (`duffle.INSTRUCTION_GPR_EFFECTS`) to `atom.paths.forward_state.gpr_values`, --- applies bounded constant propagation, and stages matching relation rows as PRODUCERS (with `destination_match` filters, e.g. for the IRGB fan-out). --- The `transfer_hazards` CHECK_RULES reader projects `atom.paths.hazards` into per-atom findings. --- The reader does NOT re-walk source; this is the per-check purity contract. --- The walker runs once per atom before the per-atom dispatch; the reader runs inside the same dispatch. --- 2. control_transfer_delay_slot_use: For every emitted branch/jump/call encoder in `duffle.CONTROL_TRANSFER_DELAY_SLOT_POLICIES` --- (the six `branch_*` encoders plus `jump` / `jump_reg` / `jump_link` / `call_reg` / `call_addr`), --- inspect the next emitted event in `atom.paths.word_events`. --- Emit an `info`-severity finding when the successor is `nop` or absent (the next emitted word IS the hardware delay slot). --- `jump_reg(R_AtomJmp)` is suppressed by policy (the fixed `mac_yield()` handshake). --- `nop2` needs no special case: emission-model emits two `nop` events for it, so the first expansion is the hardware delay slot. --- `atom_label` also needs no special case (zero events). --- 3. mac_yield uniformity: Every atom body must contain exactly one `mac_yield()` call (control transfer pattern). --- 4. Binding handoff: Every `atom_bind(Binds_X)` must reference a `typedef Struct_(Binds_X) { ... }` declaration. --- 5. GPU Port-Store Shape: Per-shape (`f3`/`f4`/`g4`/etc.) the sum of `mac_format_X_color` + `mac_gte_store_X_*` + `mac_insert_ot_tag_X` words --- must equal the GP0 cmd's expected packet size. --- 6. Per-Atom Cycle Budget: Sum each atom body's instruction latencies (per `duffle.INSTRUCTION_LATENCY`); report total. --- --- Per-source rules (registry-driven): --- 8. enum_alias_membership: Every `R_X` referenced from `atom_dbg_reg_default`, `atom_reg_types`, `atom_type(...)`, `atom_reads`, or `atom_writes` --- must be in `corpus.register_alias_registry`. --- 9. atom_type_consistency: Every `reg_type_overrides[R_X].type_name` must resolve in `corpus.type_name_registry`. --- 10. binds_no_substruct_deref: Every `load_word(R_A, R_B, O_(Type, Field))` and `store_word(...)` in every atom body must reference a leaf scalar --- (pointer-to-struct counts as leaf; nested struct members fail the leaf test). --- --- --- Findings carry an explicit `kind` ("error" / "warning" / "info"). --- The renderer maintains three independent severity collections; `info` is never folded into warnings. --- Scan/cycle summary rows are kept in a separate `summaries` collection (rendered as trailing summary lines, not findings). --- The report header includes `Info: N` alongside Findings / Errors / Warnings, and a dedicated --- `── Info` section renders finding-level info between `── Warnings` and the per-atom cycle counts. --- --- The orchestrator (`ps1_meta.lua`) wires this module in via the PASSES table: --- `["static-analysis"] = { --- module = "passes.static_analysis", --- kind = "diagnostic", --- deps = {"word-counts", "components"}, --- out = { { kind = "report", path_template = "/.static_analysis.txt" } } --- } --- `kind = "diagnostic"` keeps every finding visible in the report; the orchestrator does not exit non-zero on static-analysis errors. --- Annotation and header-output validation remain build-stopping. --- --- **Conventions**: tabs (1/level), EmmyLua annotations, no regex, Lua 5.3 compatible. -- ════════════════════════════════════════════════════════════════════════════ -- Module-scope requires + package.path setup -- ════════════════════════════════════════════════════════════════════════════ -- Bootstrap: load `scripts/duffle_paths.lua` (sets package.path + package.cpath). -- Uses `debug.getinfo` to find this file's own directory, so it works both standalone and when require'd from the orchestrator. -- Bootstrap: load `duffle_paths.lua` via `debug.getinfo(1, "S").source` (works both standalone + when require'd). -- duffle_paths.lua sets package.path then returns `require("duffle")` at the bottom, so the dofile value IS the duffle module. local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./" local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua") -- ════════════════════════════════════════════════════════════════════════════ -- Constants -- ════════════════════════════════════════════════════════════════════════════ -- Atom declaration + component declaration identifiers. local ATOM_DECL = "MipsAtom_" local ATOM_COMP = "MipsAtomComp_" local ATOM_COMP_PROC = "MipsAtomComp_Proc_" -- Marker-call identifiers inside atom bodies. local ATOM_LABEL = "atom_label" local ATOM_OFFSET = "atom_offset" local ATOM_INFO = "atom_info" local ATOM_BIND = "atom_bind" local ATOM_READS = "atom_reads" local ATOM_WRITES = "atom_writes" local ATOM_YIELD = "mac_yield" local WORD_COUNT_PRAGMA = "WORD_COUNT(" -- ASCII byte values used in tokenization. local BYTE_NEWLINE = 10 local BYTE_HASH = 35 -- '#' local BYTE_OPEN_PAREN = 40 local BYTE_OPEN_BRACE = 123 local BYTE_OPEN_BRACK = 91 local BYTE_SEMI = 59 -- Per-check output paths (relative to ctx.out_root). local OUTPUT_EXTENSION = ".static_analysis.txt" -- ════════════════════════════════════════════════════════════════════════════ -- Type declarations -- ════════════════════════════════════════════════════════════════════════════ --- @class SourceFile --- @field path string -- absolute path to the source file --- @field text string -- the full source text --- @field dir string -- the directory containing the source --- @field basename string -- filename without extension --- @class PassCtx --- @field sources SourceFile[] --- @field metadata_path string --- @field shared table --- @field shared.word_counts table --- @field out_root string --- @field project_root string --- @field upstream table --- @field flags table --- @field verbose boolean --- @class PassResult --- @field outputs table[] --- @field errors table[] --- @field warnings table[] --- @field info table[] -- finding-level info (kind == "info"); distinct from per-source scanned/cycles summary rows --- @alias AtomName string -- lower_snake_case atom nameMacroName string -- lower_snake_case macro identifier --- @alias CheckName string -- "transfer_hazards" | "control_transfer_delay_slot_use" | "mac_yield_uniformity" | "abi_handoff" | "gpu_portstore_shape" | "per_atom_cycle_budget" | "enum_alias_membership" | "atom_type_consistency" | "binds_no_substruct_deref" --- @class AtomBody --- @field line integer -- source line of the atom declaration --- @field name AtomName -- atom name (e.g. "cube_g4_face") --- @field body string -- the brace-delimited body (without the braces) --- @field body_off integer -- char offset of body[1] in source --- @field kind string -- "atom" | "comp_bare" | "comp_proc" --- @class Token --- @field tok string -- the raw token text (trimmed) --- @field line integer -- source line of the token's start --- @field ident string|nil -- the leading ident of the token (if any) --- @field kind string -- "n_words" | "mac_yield" | "gte_cmdw" | "mac_format" | "mac_gte_store" | "mac_insert_ot_tag" | "atom_label" | "atom_offset" | "other" --- @class Finding --- @field line integer -- source line of the finding --- @field atom AtomName -- the atom this finding is for (or "") --- @field check CheckName -- the check identifier --- @field kind string -- "error" | "warning" | "info" --- @field msg string -- the finding message --- @class AtomAnalysis --- @field atom AtomBody --- @field tokens Token[] -- the tokens in the atom body, annotated --- @field findings Finding[] -- findings for this atom --- @field total_cycles integer -- sum of token cycle costs -- ════════════════════════════════════════════════════════════════════════════ -- classify_tokens — per-token classification -- ════════════════════════════════════════════════════════════════════════════ -- ONE forward pass over the token list produces a flat table of per-token classifications. -- Every check + analyze_atom_paths reads from this table instead of re-scanning the token strings. -- -- The classification is stored on `atom.paths.tok_class` as an array indexed by token index (1..#tokens). -- Each entry has: -- ident — the leading identifier (e.g. "load_word", "gte_cmdw_rtpt", "nop", "mac_yield") -- nop_words — 0 / 1 / 2 (for "nop" / "nop2" / anything else) -- nop_prefix — consecutive nop words ending just BEFORE this token (forward-pass pre-compute; -- makes preceding-nop lookup O(N)) -- is_yield — true if this token is `mac_yield` or `mac_yield(...)` -- is_atom_label — true if this token is `atom_label(name)`; label_name has the name -- is_branch — true if this token is `branch_*(...)`; branch_label has the label or false -- is_load_word — true if this token starts with `load_word(` -- is_store_word — true if this token starts with `store_word(` -- -- Checks that need the leading ident use `tok_class.ident` instead of re-matching the token string. -- Checks that need "how many nops before token i" use `tok_class.nop_prefix` instead of walking backwards. --- @class TokClass --- @field ident string -- leading identifier --- @field nop_words integer -- 0/1/2 --- @field nop_prefix integer -- consecutive nop words before this token --- @field is_yield boolean --- @field is_atom_label boolean --- @field label_name string|nil -- for atom_label(name) --- @field is_branch boolean --- @field branch_label string|false|nil -- for branch_*(..., atom_offset(F, label)) --- @field is_load_word boolean --- @field is_store_word boolean --- @field mac_format_shape string|nil -- "f3" / "g4" etc. for mac_format_X_color; nil otherwise --- @field is_gte_store boolean -- ident matches `mac_gte_store_` --- @field is_ot_tag boolean -- ident matches `mac_insert_ot_tag_` --- @field writes_r_prim_cursor boolean -- store_word targeting R_PrimCursor --- @field reads_r_tape_ptr boolean -- any token referencing R_TapePtr --- @field o_arg1 string|nil -- first arg of O_(, ) captures; nil for non-O_ tokens --- @field o_arg2 string|nil -- second arg of O_(, ) captures --- @field s_arg1 string|nil -- arg of S_() captures; nil for non-S_ tokens -- Patterns for O_(, ) and S_() captures. -- UNANCHORED, the substring can appea anywhere in the token (e.g., `load_word(R_T0, R_TapePtr, O_(Binds_X, field))` matches at position ~24). -- The binds_name match is deferred to check_abi_handoff (which compares tc.o_arg1 == atom.info.binds). local O_PATTERN = "O_%(([%w_]+),%s*([%w_]+)%s*%)" local S_PATTERN = "S_%(([%w_]+)%s*%)" local function classify_tokens(tokens) local n = #tokens local tc = {} local nop_run = 0 -- running count of consecutive nop words (forward pass) for tok_idx, t in ipairs(tokens) do local tok = t.tok local ident = tok:match("^([%w_]+)") or "?" local nop_words = 0 if ident == "nop" then nop_words = 1 elseif ident == "nop2" then nop_words = 2 end local is_yield = ident == "mac_yield" local is_atom_label = false local label_name = nil local is_branch = false local branch_label = nil local is_load_word = ident == "load_word" local is_store_word = ident == "store_word" -- Per-check pre-computes (R3 lift). -- Each pre-compute eliminates one per-token regex/string-find call from check_abi_handoff / check_gpu_portstore_shape. local mac_format_shape = nil local is_gte_store = false local is_ot_tag = false local writes_r_prim_cursor = false local reads_r_tape_ptr = false local o_arg1, o_arg2 = nil, nil local s_arg1 = nil if ident == "atom_label" then is_atom_label = true label_name = tok:match("^atom_label%s*%(%s*([%w_]+)%s*%)") elseif tok:match("^branch_[%w_]+%s*%(") then is_branch = true branch_label = tok:match("atom_offset%s*%([^,]+,%s*([%w_]+)%s*%)") or false end -- mac_format_X_color / mac_gte_store_ / mac_insert_ot_tag_ (used by check_gpu_portstore_shape). local shape = ident:match("^mac_format_([%w_]+)_color$") if shape then mac_format_shape = shape end if ident:match("^mac_gte_store_[%w_]+$") then is_gte_store = true end if ident:match("^mac_insert_ot_tag_[%w_]+$") then is_ot_tag = true end -- O_(, ) / S_() captures (used by check_abi_handoff). -- Cheap pattern match — anchored, fails fast on non-matching tokens. o_arg1, o_arg2 = tok:match(O_PATTERN) if not o_arg1 then s_arg1 = tok:match(S_PATTERN) end -- R_TapePtr + R_PrimCursor references (used by check_abi_handoff / check_gpu_portstore_shape). if tok:find("R_TapePtr", 1, true) then reads_r_tape_ptr = true end if is_store_word and tok:find("R_PrimCursor", 1, true) then writes_r_prim_cursor = true end tc[tok_idx] = { ident = ident, nop_words = nop_words, nop_prefix = nop_run, is_yield = is_yield, is_atom_label = is_atom_label, label_name = label_name, is_branch = is_branch, branch_label = branch_label, is_load_word = is_load_word, is_store_word = is_store_word, mac_format_shape = mac_format_shape, is_gte_store = is_gte_store, is_ot_tag = is_ot_tag, writes_r_prim_cursor = writes_r_prim_cursor, reads_r_tape_ptr = reads_r_tape_ptr, o_arg1 = o_arg1, o_arg2 = o_arg2, s_arg1 = s_arg1, } -- Advance the nop run for the NEXT token. if nop_words > 0 then nop_run = nop_run + nop_words else nop_run = 0 end end return tc end -- ════════════════════════════════════════════════════════════════════════════ -- Check #1: transfer-hazard analysis (forward walker + reader). -- -- Single typed CPU/COP0/COP2/GTE relation analysis via the forward walker (`analyze_hardware_relations`). -- Hazards are projected into findings by the `transfer_hazards` CHECK_RULES reader (`check_transfer_hazards`). -- -- The forward walker (`analyze_hardware_relations`) reads `atom.paths.word_events` once per atom. -- For every emitted word event it: -- 1. Inspects current pending relations against the event as CONSUMER. -- A producer's destination is "consumed" by: -- * a `gte_cmdw_*` whose command input set contains the destination (or any fan-out destination of an IRGB write); OR -- * any encoder whose `OPERAND_READ_POSITIONS` includes the GPR destination of an MFC2/CFC2/MFC0 relation. -- The consumer check computes `gap = consumer.word - producer.word - 1` and records a hazard on `atom.paths.hazards` when `gap < required`. -- 2. Applies the event's GPR value effects (`duffle.INSTRUCTION_GPR_EFFECTS`) to `atom.paths.forward_state.gpr_values`. -- Unknown writers invalidate the destination GPR's lattice value to `{kind="unknown"}`. -- The lattice is closed: `{kind="unknown"}` and `{kind="constant", value=}`. -- Bounded constant propagation handles load_upper_i / add_ui / or_i / and_i / xor_i + their self variants) on top of the same forward walker; -- The writer invalidation is the conservative default. -- 3. Stages any relation rows whose `token` matches the event as PRODUCERS. -- The destination operand is `event.args[relation.writes.arg]`. -- Rows with a `destination_match` filter are only staged when the destination operand equals the filter (e.g. C2_IRGB for the IRGB fan-out row). -- The MTC2 ordinary row and the MTC2-IRGB row are both inspected. -- Only the matching row stages (the non-matching row is ignored for that event). -- -- After the walker runs, the `transfer_hazards` CHECK_RULES reader (`check_transfer_hazards`) copies every entry on `atom.paths.hazards` into the per-atom findings list. -- The first `transfer_hazards` reader comment above records the projection contract. -- -- The walker is called once before the CHECK_RULES per-atom dispatch (see `validate()`); -- The reader runs as part of the same CHECK_RULES dispatch so its findings land in `findings` alongside the other checks. -- -- The producer's own emitted word does NOT retire the relation (per the PSX-SPX rule in `docs/psx-spx/docs/cpuspecifications.md:407-419`): -- "Store delays are counted in numbers of clock cycles (not in numbers of opcodes). -- For 3 cycle delay, one must usually insert 3 cached opcodes (or one uncached opcode)." `gap = consumer.word - producer.word - 1` -- therefore counts ONLY words strictly between the producer and the consumer. -- ───────────────────────────────────────────────────────────────────────── -- True iff `consumer_word` falls inside the COP2 command's input set OR inside the producer's `fanout_to` set (for IRGB writes). -- Used by the consumer-match step of the forward walker. local function is_cop2_consumer_of(consumer_event, destination, producer_rel) local consumer_token = consumer_event.encoder or consumer_event.ident -- Direct match: the consumer's argument is the destination. -- (Reserved for future relations where the consumer literally reads the destination register; -- not used by MTC2/CTC2 today because the "consumer" is a GTE command and its reads are not operand positions.) local args = consumer_event.args or {} for _, pos in ipairs(args) do if pos == destination then return true end end -- Match via the command's input set: the consumer encoder resolves to a `gte_cmdw_*` -- short form whose `duffle.GTE_COMMAND_INPUTS` entry includes the destination (or a fan-out target). local aliases = duffle.GTE_COMMAND_ALIASES or {} local canonical = aliases[consumer_token] or consumer_token if canonical:sub(1, 9) == "gte_cmdw_" or aliases[consumer_token] then local inputs = duffle.GTE_COMMAND_INPUTS or {} local cmd_inputs = inputs[canonical] if cmd_inputs then -- Direct hit. for _, in_reg in ipairs(cmd_inputs) do if in_reg == destination then return true end end -- Fan-out hit (IRGB writes fan out to C2_IR1/C2_IR2/C2_IR3). for _, fanout in ipairs(producer_rel.fanout_to or {}) do for _, in_reg in ipairs(cmd_inputs) do if in_reg == fanout then return true end end end end end return false end -- True iff `consumer_event` reads the GPR operand at any position the destination register occupies. -- The read-position lookup consults `duffle.OPERAND_READ_POSITIONS` -- for the consumer's encoder and walks each `args[pos]` to find an operand-equal match. local function is_gpr_consumer_of(consumer_event, destination) local consumer_token = consumer_event.encoder or consumer_event.ident local read_pos = duffle.OPERAND_READ_POSITIONS or {} local positions = read_pos[consumer_token] if not positions then return false end local args = consumer_event.args or {} for _, pos in ipairs(positions) do if args[pos] == destination then return true end end return false end -- Bounded U4 arithmetic for the GPR-value lattice. LuaJIT supplies the `bit` module; -- the arithmetic fallback keeps this pass Lua 5.3-compatible without adding a dependency to the metaprogram. local bit_ok, bit = pcall(require, "bit") if not bit_ok then bit = nil end local U4_MODULUS = 0x100000000 local function wrap_u4(value) if type(value) ~= "number" then return nil end value = value % U4_MODULUS if value < 0 then value = value + U4_MODULUS end return value end local function bit_binary(left, right, operation) left = wrap_u4(left) right = wrap_u4(right) if left == nil or right == nil then return nil end if bit then local value if operation == "or" then value = bit.bor( left, right) elseif operation == "and" then value = bit.band(left, right) else value = bit.bxor(left, right) end return wrap_u4(value) end local result = 0 local place = 1 for _ = 1, 32 do local left_bit = left % 2 local right_bit = right % 2 local take if operation == "or" then take = left_bit == 1 or right_bit == 1 elseif operation == "and" then take = left_bit == 1 and right_bit == 1 else take = left_bit ~= right_bit end if take then result = result + place end left = (left - left_bit) / 2 right = (right - right_bit) / 2 place = place * 2 end return result end local function shift_left_u4(value, amount) value = wrap_u4(value) amount = tonumber(amount) if value == nil or amount == nil then return nil end amount = math.floor(amount) % 32 if bit then return wrap_u4(bit.lshift(value, amount)) end return wrap_u4(value * (2 ^ amount)) end -- Resolve only a standalone integer literal. Compound C expressions remain -- unknown by design; the analyzer must not pretend to be a C evaluator. local function parse_integer_literal(raw) if type(raw) ~= "string" then return nil end raw = duffle.trim(raw) while raw:sub(1, 1) == "(" and raw:sub(-1) == ")" do raw = duffle.trim(raw:sub(2, -2)) end local sign = 1 if raw:sub(1, 1) == "-" then sign = -1; raw = raw:sub(2) elseif raw:sub(1, 1) == "+" then raw = raw:sub(2) end raw = raw:gsub("[uUlL]+$", "") local value if raw:match("^0[xX][%da-fA-F]+$") then value = tonumber(raw:sub(3), 16) elseif raw:match("^%d+$") then value = tonumber(raw, 10) else return nil end if value == nil then return nil end return wrap_u4(sign * value) end local function sign_extend_i16(value) value = value % 0x10000 if value >= 0x8000 then return value - 0x10000 end return value end local function is_gpr_operand(operand) return type(operand) == "string" and operand:sub(1, 2) == "R_" end local function constant_for_operand(gpr_values, operand) if operand == "R_0" then return 0 end local slot = is_gpr_operand(operand) and gpr_values[operand] or nil if slot and slot.kind == "constant" then return wrap_u4(slot.value) end return nil end local function invalidate_gpr(gpr_values, operand) if is_gpr_operand(operand) and operand ~= "R_0" then gpr_values[operand] = { kind = "unknown" } end end local function store_gpr_constant(gpr_values, operand, value) if not is_gpr_operand(operand) or operand == "R_0" then return end if value == nil then gpr_values[operand] = { kind = "unknown" } else gpr_values[operand] = { kind = "constant", value = wrap_u4(value) } end end local function evaluate_gpr_value_rule(rule, ev_args, gpr_values) local operation = rule.op if operation == "load_upper_i" then local immediate = parse_integer_literal(ev_args[rule.immediate]) if immediate == nil then return nil end return shift_left_u4(immediate % 0x10000, 16) end local source = nil if rule.source then source = constant_for_operand(gpr_values, ev_args[rule.source]) if source == nil then return nil end end local immediate = rule.immediate and parse_integer_literal(ev_args[rule.immediate]) or nil if rule.immediate and immediate == nil then return nil end if operation == "add_ui" then return wrap_u4( source + sign_extend_i16(immediate)) elseif operation == "or_i" then return bit_binary( source, immediate % 0x10000, "or") elseif operation == "and_i" then return bit_binary( source, immediate % 0x10000, "and") elseif operation == "xor_i" then return bit_binary( source, immediate % 0x10000, "xor") elseif operation == "shift_lleft" then return shift_left_u4(source, immediate) end if rule.sources then local values = {} for index, position in ipairs(rule.sources) do values[index] = constant_for_operand(gpr_values, ev_args[position]) if values[index] == nil then return nil end end if operation == "add_u" then return wrap_u4(values[1] + values[2]) elseif operation == "or_u" then return bit_binary(values[1], values[2], "or") end end return nil end -- Apply the GPR read/write effects of one emitted event to the forward_state GPR-value lattice. -- Encoders without an explicit effect row conservatively invalidate every R_-prefixed operand. -- Recognized value rules are evaluated before their destination is invalidated. -- A failed/unknown evaluation writes `{kind = "unknown"}` instead. local function apply_gpr_effects(ev_ident, ev_args, forward_state) local gpr_values = forward_state.gpr_values local effects = duffle.INSTRUCTION_GPR_EFFECTS or {} local row = effects[ev_ident] if row == nil then for _, operand in ipairs(ev_args or {}) do invalidate_gpr(gpr_values, operand) end return end local value_rule = (duffle.GPR_VALUE_RULES or {})[ev_ident] local value = value_rule and evaluate_gpr_value_rule(value_rule, ev_args or {}, gpr_values) or nil for _, position in ipairs(row.writes or {}) do local destination = ev_args and ev_args[position] if is_gpr_operand(destination) then if value_rule and position == value_rule.dest and value ~= nil then store_gpr_constant(gpr_values, destination, value) else invalidate_gpr(gpr_values, destination) end end end end -- Look up the alias of a GTE command ident. -- Defaults to the input ident so unknown idents surface rather than silently inheriting a 0-cycle command input set. local function canonical_command(ident) local aliases = duffle.GTE_COMMAND_ALIASES or {} return aliases[ident] or ident end -- True for a COP2/GTE use that can make a pending SR.CU2 transition observable. -- Atom entry intentionally starts at `unobserved`; this helper never creates a finding without a preceding Status write. local function is_cop2_use(ident) local canonical = canonical_command(ident) return canonical:sub(1, 9) == "gte_cmdw_" or ident:sub(1, 4) == "gte_" end local function append_cu2_finding(atom, event, forward, transition, gap, kind, confidence, message) local event_ident = event.encoder or event.ident or "?" local policy = duffle.CU2_TRANSITION_POLICY or {} local evidence = policy.evidence or {} atom.paths.hazards[#atom.paths.hazards + 1] = { check = "transfer_hazards", kind = kind, atom = atom.name, line = event.body_line or event.line or event.def_line or 0, source = event.def_path or event.source or "", relation_id = "mtc0_cu2_visibility", semantic = "MTC0", direction = "gpr_to_cop0_status", producer_destination = "SR.CU2", producer_word = transition.producer_word, producer_line = transition.producer_line, producer_source = transition.producer_source, consumer_word = event.i or event.word or 0, consumer_token = event_ident, gap = gap, required = transition.required, evidence_confidence = confidence, evidence_source = evidence.source or transition.evidence_source or "", target_state = transition.target_state, status_register = transition.status_register, status_value = transition.status_value, msg = message, } end -- Read `sys_mov_to_cop0(source, 12)` before applying any writes from the current event. -- A known source stages a target transition; an unknown source stages an ambiguity that is reported only if a later COP2 use reaches it. local function stage_cu2_transition(ev_ident, ev_args, ev_word, ev_line, ev_source, forward) if ev_ident ~= "sys_mov_to_cop0" then return end local policy = duffle.CU2_TRANSITION_POLICY if not policy then return end local status_register = parse_integer_literal(ev_args[2]) if status_register ~= policy.status_register then return end local status_value = constant_for_operand(forward.gpr_values, ev_args[1]) local target_state = "unknown" local target_enabled = nil if status_value ~= nil then target_enabled = bit_binary(status_value, policy.enable_bit, "and") ~= 0 target_state = target_enabled and "enabled" or "disabled" end forward.cu2_transition = { producer_word = ev_word, producer_line = ev_line, producer_source = ev_source, status_register = status_register, status_value = status_value, target_enabled = target_enabled, target_state = target_state, required = policy.required, evidence_source = policy.evidence and policy.evidence.source or "", } forward.cu2_state = "pending" end -- Consume a pending Status/CU2 transition at the first relevant COP2 event. -- The producer and consumer endpoints are excluded from the strict gap. -- A conservative early transition emits once and then settles to its target; -- Unknown Status emits one info edge and clears. A settled disable emits the exact COP2-unavailable finding required by the contract. local function consume_cu2_transition(atom, event, ev_word, forward) if not is_cop2_use(event.encoder or event.ident or "") then return end local transition = forward.cu2_transition if not transition then return end local gap = ev_word - transition.producer_word - 1 local target = transition.target_state if target == "unknown" then append_cu2_finding(atom, event, forward, transition, gap, "info", "unknown", string.format("%s at line %d uses COP2 after an MTC0 Status write whose CU2 value is unknown (gap=%d, configured boundary=%d)" , atom.name, event.body_line or event.line or event.def_line or 0 , gap, transition.required ) ) forward.cu2_state = "unknown" forward.cu2_transition = nil return end if gap < transition.required then local verb = target == "enabled" and "enable" or "disable" append_cu2_finding(atom, event, forward, transition, gap, "warning", "conservative", string.format("%s at line %d uses COP2 before the SR.CU2 %s transition has settled (gap=%d, required=%d; timing is conservative)" , atom.name, event.body_line or event.line or event.def_line or 0 , verb, gap, transition.required ) ) forward.cu2_state = target forward.cu2_transition = nil return end forward.cu2_transition = nil if target == "enabled" then forward.cu2_state = "enabled" else append_cu2_finding(atom, event, forward, transition, gap, "error", "exact", string.format( "%s at line %d: COP2 unavailable after SR.CU2 was disabled" .. " (gap=%d, required=%d)", atom.name, event.body_line or event.line or event.def_line or 0, gap, transition.required)) forward.cu2_state = "disabled" end end -- The forward walker. Populates `atom.paths.forward_state`, `.relations`, and `.hazards`. -- Called once per atom before the per-atom CHECK_RULES dispatch loop. local function analyze_hardware_relations(atom) local events = atom.paths and atom.paths.word_events or {} local prior_state = atom.paths.forward_state local seed_values = {} if prior_state and not prior_state._analysis_complete then for register, slot in pairs(prior_state.gpr_values or {}) do if type(slot) == "table" and slot.kind == "constant" then seed_values[register] = { kind = "constant", value = wrap_u4(slot.value), } elseif type(slot) == "table" and slot.kind == "unknown" then seed_values[register] = { kind = "unknown" } end end end local forward = { gpr_values = seed_values, pending = {}, cu2_state = "unobserved", cu2_transition = nil, _analysis_complete = false, } -- The architectural zero register is always a known U4 zero and cannot be invalidated by an emitted writer. forward.gpr_values.R_0 = { kind = "constant", value = 0 } atom.paths.forward_state = forward atom.paths.relations = {} atom.paths.hazards = {} local hazards = atom.paths.hazards local relations = atom.paths.relations local pending = forward.pending local relations_table = duffle.HARDWARE_RELATIONS or {} -- Build a token-indexed lookup once per walker pass. local rows_by_token = {} for _, row in ipairs(relations_table) do local token = row.token if token then rows_by_token[token] = rows_by_token[token] or {} rows_by_token[token][#rows_by_token[token] + 1] = row end end for _, ev in ipairs(events) do local ev_ident = ev.encoder or ev.ident or "?" local ev_line = ev.body_line or ev.line or ev.def_line or 0 local ev_source = ev.def_path or ev.source or "" local ev_args = ev.args or {} -- `word_events` use `i` as the 0-based word index across the entire expansion. -- Default to 0 if the field is absent (the producer's own word). local ev_word = ev.i or 0 -- Read a Status source, then apply current-event GPR writes, then consume a pending CU2 transition at the first relevant COP2 use. -- Both operations are part of this one event walk. stage_cu2_transition(ev_ident, ev_args, ev_word, ev_line, ev_source, forward) consume_cu2_transition(atom, ev, ev_word, forward) -- ── 1. Inspect pending relations against the event as CONSUMER. ── -- Walk pending in REVERSE so `table.remove` doesn't shift indexes still to be inspected. for pending_idx = #pending, 1, -1 do local prod = pending[pending_idx] local relation = prod.relation local semantic = relation.semantic local is_match = false if semantic == "MTC2" or semantic == "CTC2" or semantic == "LWC2" then -- Consumer is a GTE command whose input set contains the producer's COP2 destination (or a fan-out target). is_match = is_cop2_consumer_of(ev, prod.destination, relation) elseif semantic == "MFC2" or semantic == "CFC2" or semantic == "MFC0" then -- Consumer is any encoder that reads the producer's GPR destination as an operand. is_match = is_gpr_consumer_of(ev, prod.destination) elseif semantic == "command_latch" then -- Consumer is a subsequent MTC2/CTC2 overwrite of the same C2 destination. -- The semantic is the post-command latch direction (command -> register); -- This is intentionally separate from the preceding MTC2 -> command relation. is_match = (ev_ident == "gte_mv_to_data_r" or ev_ident == "gte_mv_to_ctrl_r") and ev_args[1] ~= nil and ev_args[2] == prod.destination end if is_match then local gap = ev_word - prod.word - 1 local unknown_visibility = relation.visibility and relation.visibility.kind == "unknown_consumer" if unknown_visibility then hazards[#hazards + 1] = { check = "transfer_hazards", kind = "info", atom = atom.name, line = ev_line, source = ev_source, relation_id = relation.id, semantic = relation.semantic, direction = relation.direction, producer_destination = prod.destination, producer_word = prod.word, producer_line = prod.line, producer_source = prod.source_path, consumer_word = ev_word, consumer_token = ev_ident, gap = gap, required = nil, evidence_confidence = relation.evidence and relation.evidence.confidence or "unknown", evidence_source = relation.evidence and relation.evidence.source or "", msg = string.format("%s at line %d: %s relation %s has an unknown memory-side visibility (producer %s at word %d, consumer at word %d, gap=%d) [%s]" , atom.name, ev_line, relation.semantic, relation.id , prod.destination, prod.word, ev_word, gap , (relation.evidence and relation.evidence.confidence or "unknown") ), } elseif prod.required ~= nil and gap < prod.required then local payload = { check = "transfer_hazards", kind = prod.violation_kind or "error", atom = atom.name, line = ev_line, source = ev_source, relation_id = relation.id, semantic = relation.semantic, direction = relation.direction, producer_destination = prod.destination, producer_word = prod.word, producer_line = prod.line, producer_source = prod.source_path, consumer_word = ev_word, consumer_token = ev_ident, gap = gap, required = prod.required, evidence_confidence = relation.evidence and relation.evidence.confidence or "unknown", evidence_source = relation.evidence and relation.evidence.source or "", msg = string.format("%s at line %d: %s relation %s (producer %s at word %d, %s:%d) violated: consumer at word %d (gap=%d, required=%d) [%s]" , atom.name, ev_line, relation.semantic, relation.id , prod.destination, prod.word, prod.source_path, prod.line , ev_word, gap, prod.required , (relation.evidence and relation.evidence.confidence or "unknown") ), } -- Surface producer_command on the payload (post-command latch relations store it on the relation row. -- Copy it to the top-level payload for the renderer). if relation.producer_command then payload.producer_command = relation.producer_command end hazards[#hazards + 1] = payload end local satisfied = nil if not unknown_visibility then satisfied = gap >= prod.required end -- Record the relation touch on `paths.relations` even when the gap is satisfied. -- Unknown relations are informational, not numeric pass/fail measurements. relations[#relations + 1] = { relation_id = relation.id, semantic = relation.semantic, producer_word = prod.word, consumer_word = ev_word, gap = gap, required = prod.required, satisfied = satisfied, } table.remove(pending, pending_idx) end end -- ── 2. Apply GPR value effects. ── apply_gpr_effects(ev_ident, ev_args, forward) -- ── 3. Stage producers created by this event. ── local rows = rows_by_token[ev_ident] if rows then for _, row in ipairs(rows) do -- `stage = false` rows document a direction but do not create a later command-input producer (SWC2 and ordinary MTC0). if row.stage ~= false then local dest_arg = row.writes and row.writes.arg local destination = dest_arg and ev_args[dest_arg] or nil if destination then -- Apply the destination_match filter when present. if row.destination_match and row.destination_match ~= destination then goto continue_stage end -- A later write supersedes an unknown LWC2 edge for the same C2 destination before any command consumes it. for prior_idx = #pending, 1, -1 do local prior = pending[prior_idx] if prior.relation.semantic == "LWC2" and prior.destination == destination then table.remove(pending, prior_idx) end end local required = row.visibility and row.visibility.required if required == nil and not (row.visibility and row.visibility.kind == "unknown_consumer") then required = 1 end pending[#pending + 1] = { relation = row, destination = destination, word = ev_word, required = required, source_path = ev_source, line = ev_line, violation_kind = row.violation_kind or "error", } end end ::continue_stage:: end end -- ── 4. Update semantic role state and stage post-command latch relations. ── -- A GTE command emits outputs with semantic roles (latest_screen_xy, otz, latest_color, etc.) per `duffle.GTE_COMMAND_OUTPUTS`. -- The walker records these on `forward_state.post_command_roles[]` so the `gte_result_position` reader can later detect a reader that picks the wrong register. -- -- The walker also stages POST-COMMAND LATCH relations (kind = "command_latch_input"): a subsequent MTC2/CTC2 overwrite of a latched output before the measured boundary is a hazard. -- The relation kind is intentionally separate from the preceding MTC2 → command relation (`MTC2` / `CTC2` / `LWC2`). -- They describe different directions of the same memory subsystem and would otherwise be conflated. if canonical_command(ev_ident) ~= ev_ident then -- Not a GTE command; skip. else local canonical = canonical_command(ev_ident) if canonical:sub(1, 9) == "gte_cmdw_" then -- Update the post-command role state. local outputs = duffle.GTE_COMMAND_OUTPUTS or {} local cmd_outputs = outputs[canonical] if cmd_outputs then for _, out in ipairs(cmd_outputs) do if out.register then forward.post_command_roles = forward.post_command_roles or {} forward.post_command_roles[out.register] = { role = out.role, command = canonical, command_register = out.register, producer_word = ev_word, producer_line = ev_line, } end end end -- Stage post-command latch relations for every measured output. local latch_table = duffle.GTE_COMMAND_LATCH_WINDOWS or {} local cmd_latches = latch_table[canonical] if cmd_latches then for _, latch in ipairs(cmd_latches) do if latch.register and latch.required then -- A later MTC2/CTC2 overwrite of the same register before the measured boundary is the consumer of this relation. pending[#pending + 1] = { relation = { id = "command_latch_input", semantic = "command_latch", direction = "gte_command_to_cop2_register", token = canonical, evidence = { confidence = "conservative", source = "gtepipelinetimings.md", }, violation_kind = "warning", producer_command = canonical, producer_register = latch.register, }, destination = latch.register, word = ev_word, required = latch.required, source_path = ev_source, line = ev_line, violation_kind = "warning", } end end end end end end forward._analysis_complete = true end -- ───────────────────────────────────────────────────────────────────────── -- Check #1b: transfer_hazards (READER for analyze_hardware_relations output). -- -- The single forward walker `analyze_hardware_relations` (defined above) has already populated `atom.paths.hazards`. -- This check copies every entry on that list into the per-atom `findings` table. -- The first `transfer_hazards` reader comment above records the projection contract. -- -- The walker also populates `atom.paths.relations` (one entry per satisfied-or-violated relation touch) and `atom.paths.forward_state` (the GPR-value lattice). -- Neither of those is rendered as a finding here; bounded-value rules and LWC2 unknown edges share on top of the same forward walker and adds additional readers. -- -- Static-analysis pass kind remains non-stopping (diagnostic): -- The transfer-hazards findings appear in `result.errors` / `result.warnings` (according to the row's `violation_kind`) without changing the build exit status. -- This preserves the documented PASSES["static-analysis"] policy in `ps1_meta.lua`. -- ───────────────────────────────────────────────────────────────────────── local function check_transfer_hazards(atom, _pipe_ctx, findings) local hazards = atom.paths and atom.paths.hazards or {} for _, hazard in ipairs(hazards) do findings[#findings + 1] = hazard end end -- ───────────────────────────────────────────────────────────────────────── -- Check #1d: gte_input_latch (READER for analyze_hardware_relations output). -- -- The forward walker stages post-command latch relations on `atom.paths.hazards` with `relation_id = "command_latch_input"`. -- This reader filters those entries and re-emits them under the `gte_input_latch` check name so the test contract can target them independently of the transfer_hazards check. -- The first `transfer_hazards` reader comment above records the projection contract. -- ───────────────────────────────────────────────────────────────────────── local function check_gte_input_latch(atom, _pipe_ctx, findings) local hazards = atom.paths and atom.paths.hazards or {} for _, hazard in ipairs(hazards) do if hazard.relation_id == "command_latch_input" then local payload = {} for k, v in pairs(hazard) do payload[k] = v end payload.check = "gte_input_latch" -- Surface `producer_command` on the emitted payload: -- The hazard relation record carries it under `relation.producer_command` (since it lives on the relation row); -- copy it to the top-level payload for the renderer and the focused tests. if payload.producer_command == nil and payload.relation and payload.relation.producer_command then payload.producer_command = payload.relation.producer_command end findings[#findings + 1] = payload end end end -- ───────────────────────────────────────────────────────────────────────── -- Check #1e: gte_result_position (READER for forward_state semantic roles). -- -- A GTE command emits outputs with semantic roles (latest_screen_xy, otz, latest_color, etc.) per `duffle.GTE_COMMAND_OUTPUTS`. -- The forward walker records `forward_state.post_command_roles[]` after each command. -- -- A subsequent MFC2 (or any encoder that reads a C2 register) that picks the WRONG register for the active role emits a `result_role_mismatch` warning. -- For example, reading `C2_SXY0` after RTPS is wrong: the `latest_screen_xy` role is `C2_SXY2`. -- -- The first `transfer_hazards` reader comment above records the projection contract. -- ───────────────────────────────────────────────────────────────────────── local function check_gte_result_position(atom, _pipe_ctx, findings) local forward = atom.paths and atom.paths.forward_state if not forward or not forward.post_command_roles then return end local events = atom.paths.word_events or {} -- Build a set of known _post_ component names whose contract rows we have to verify -- (table-gap detection: a missing row key is itself an info finding). -- The names are the BODY-LEVEL component calls that appear in atom body text; -- The walker doesn't expose body tokens to the reader, so we scan the events' root_call_text. local contracts = duffle.GTE_COMPONENT_RESULT_CONTRACTS or {} local component_names_seen = {} for _, ev in ipairs(events) do local root_call = ev.root_call_text or ev.call_text or "" local name = root_call:match("^([%w_]+)") or "" if name:find("_post_") then component_names_seen[name] = true end end for component_name in pairs(component_names_seen) do -- Strip any trailing parenthesized argument list / whitespace. local bare = component_name:match("^([%w_]+)") or component_name if contracts[bare] == nil then findings[#findings + 1] = { check = "gte_result_position", kind = "info", atom = atom.name, line = 0, source = "", relation_id = "table_gap", component_name = bare, msg = string.format("%s: component %q has no GTE_COMPONENT_RESULT_CONTRACTS row (unknown _post_ contract)" , atom.name, bare), } end end -- For each word event whose encoder is `gte_mv_from_data_r`, look up the register being read in `forward_state.post_command_roles`. -- If a role is set, the reader's register must match the role's register (the registered "latest_" target). for _, ev in ipairs(events) do local ev_ident = ev.encoder if ev_ident == "gte_mv_from_data_r" then local args = ev.args or {} local reg = args[2] -- Find any post-command `latest_screen_xy` role entry recorded by a prior command. -- The newest projected screen coordinate is recorded under the command name. -- Reading from C2_SXY0 (the older projection slot) when a `latest_screen_xy` role was set to C2_SXY2 by RTPS / RTPT is a semantic mismatch. local latest_screen_xy_entry = nil for r, e in pairs(forward.post_command_roles or {}) do if e.role == "latest_screen_xy" then latest_screen_xy_entry = e break end end if reg and latest_screen_xy_entry then -- The reader picked C2_SXY0 but the latest_screen_xy role was set to C2_SXY2 by the prior command. -- This is a semantic mismatch. if reg ~= latest_screen_xy_entry.command_register and (reg == "C2_SXY0" or reg == "C2_SXY1") then findings[#findings + 1] = { check = "gte_result_position", kind = "warning", atom = atom.name, line = ev.body_line or ev.line or ev.def_line or 0, source = ev.def_path or ev.source or "", relation_id = "result_role_mismatch", semantic = "result_position", command = latest_screen_xy_entry.command, role = latest_screen_xy_entry.role, actual_register = reg, expected_register = "C2_SXY2", producer_word = latest_screen_xy_entry.producer_word, producer_line = latest_screen_xy_entry.producer_line, msg = string.format("%s at line %d: reading %s after %s but the %s role is C2_SXY2 (not %s)" , atom.name, ev.body_line or ev.line or ev.def_line or 0 , reg, latest_screen_xy_entry.command , latest_screen_xy_entry.role , reg), } end end end end end -- ───────────────────────────────────────────────────────────────────────── -- Check #1f: hazard_nop_use (READER for forward_state NOP classification). -- -- Each emitted `nop` word event is classified by inspecting the forward-state immediately before the word: -- * `modeled-required`: a pending modeled relation exists that the nop retires -- (the nop is needed to retire the relation, even if it can be replaced by independent useful work). -- * `modeled-redundant`: no modeled relation is pending immediately before the nop (the nop is a redundant hazard). -- -- Branch/jump delay-slot NOPs belong to `control_transfer_delay_slot_use`, so this check leaves them unclassified. -- The fixed `mac_yield()` handshake (`jump_reg(R_AtomJmp), nop`) is preserved as suppressed. -- -- The first `transfer_hazards` reader comment above records the projection contract. -- ───────────────────────────────────────────────────────────────────────── local function check_hazard_nop_use(atom, _pipe_ctx, findings) local forward = atom.paths and atom.paths.forward_state local events = atom.paths.word_events or {} if not events or #events == 0 then return end -- The walker does not currently snapshot the pending state per event; we replay the same forward walk cheaply here. -- The replay is observation-only (no staging); the only output is one finding per non-BD-slot nop with its classification. local pending_snapshot = {} local prev_ev = nil for event_idx, ev in ipairs(events) do local ev_ident = ev.encoder or "" local ev_args = ev.args or {} local ev_word = ev.i or 0 -- Classify the nop BEFORE its event is applied to the pending state. if ev_ident == "nop" and prev_ev ~= nil then -- Skip BD-slot nops: they are exclusively owned by control_transfer_delay_slot_use. local prev_ident = prev_ev.encoder or "" local prev_args = prev_ev.args or {} local bd_policies = duffle.CONTROL_TRANSFER_DELAY_SLOT_POLICIES or {} local is_bd_slot = false local policy = bd_policies[prev_ident] if policy then local arg1 = prev_args[1] local suppressed = policy.suppress_arg1 and policy.suppress_arg1[arg1] or nil if not suppressed then is_bd_slot = true end end if not is_bd_slot then -- Find a pending modeled relation that this nop would retire. local retired = nil for _, prod in ipairs(pending_snapshot) do if prod.required and (prod.word + prod.required + 1) > ev_word then retired = prod break end end if retired then -- Look ahead for the would-be consumer (the next emitted command or read after the nop that the relation would retire). -- For an MTC2 -> command relation, the consumer is the next GTE command after the nop. local would_be_consumer = nil for _, future_ev in ipairs(events) do local f_word = future_ev.i or future_ev.word or 0 if f_word > ev_word then local f_ident = future_ev.encoder or future_ev.ident or "" local f_args = future_ev.args or {} local aliases = duffle.GTE_COMMAND_ALIASES or {} local canonical = aliases[f_ident] or f_ident if canonical:sub(1, 9) == "gte_cmdw_" then local inputs = duffle.GTE_COMMAND_INPUTS or {} local cmd_inputs = inputs[canonical] if cmd_inputs then for _, in_reg in ipairs(cmd_inputs) do if in_reg == retired.destination then would_be_consumer = f_ident break end end end elseif f_ident == "gte_mv_to_data_r" or f_ident == "gte_mv_to_ctrl_r" then if f_args[2] == retired.destination then would_be_consumer = f_ident end end if would_be_consumer then break end end end findings[#findings + 1] = { check = "hazard_nop_use", kind = "info", atom = atom.name, line = ev.body_line or ev.line or ev.def_line or 0, source = ev.def_path or ev.source or "", nop_classification = "modeled-required", nop_word_index = ev_word, retired_relation = retired.relation.id, producer_destination = retired.destination, consumer_token = would_be_consumer or "", msg = string.format("%s at line %d: nop at word %d is modeled-required (retires %s for %s)" , atom.name, ev.body_line or ev.line or ev.def_line or 0, ev_word, retired.relation.id, retired.destination ), } else -- Track the slot_kind so the BD-separation case can assert the mac_yield handshake is still suppressed. local slot_kind = "plain" findings[#findings + 1] = { check = "hazard_nop_use", kind = "warning", atom = atom.name, line = ev.body_line or ev.line or ev.def_line or 0, source = ev.def_path or ev.source or "", nop_classification = "modeled-redundant", nop_word_index = ev_word, retired_relation = nil, slot_kind = slot_kind, msg = string.format("%s at line %d: nop at word %d is modeled-redundant (no pending modeled relation)" , atom.name, ev.body_line or ev.line or ev.def_line or 0, ev_word ), } end end end -- Update the pending snapshot for the next iteration. -- The replay is observation-only; we mirror the walker's staging -- behavior for MTC2 / CTC2 / LWC2 / MFC2 / CFC2 / MFC0 / command_latch. local aliases = duffle.GTE_COMMAND_ALIASES or {} local canonical = aliases[ev_ident] or ev_ident if ev_ident == "gte_mv_to_data_r" or ev_ident == "gte_mv_to_ctrl_r" then local relations_table = duffle.HARDWARE_RELATIONS or {} for _, row in ipairs(relations_table) do if row.token == ev_ident and row.stage ~= false then local dest_arg = row.writes and row.writes.arg local destination = dest_arg and ev_args[dest_arg] or nil if destination and (not row.destination_match or row.destination_match == destination) then local required = row.visibility and row.visibility.required if required == nil and not (row.visibility and row.visibility.kind == "unknown_consumer") then required = 1 end pending_snapshot[#pending_snapshot + 1] = { relation = row, destination = destination, word = ev_word, required = required, } end end end elseif canonical:sub(1, 9) == "gte_cmdw_" then -- Command: stage post-command latch relations (same as the walker). local latch_table = duffle.GTE_COMMAND_LATCH_WINDOWS or {} local cmd_latches = latch_table[canonical] if cmd_latches then for _, latch in ipairs(cmd_latches) do if latch.register and latch.required then pending_snapshot[#pending_snapshot + 1] = { relation = { id = "command_latch_input", semantic = "command_latch", }, destination = latch.register, word = ev_word, required = latch.required, } end end end end prev_ev = ev end end -- ───────────────────────────────────────────────────────────────────────── -- Check #1c: control-transfer delay-slot use. -- -- Reads `atom.paths.word_events` (the semantic emitted-word stream from `passes/emission_model.lua`). -- For each event whose `encoder` is in `duffle.CONTROL_TRANSFER_DELAY_SLOT_POLICIES`, inspect the next emitted event in the SAME `events` array. -- The next event is the hardware delay-slot word (the duffle pipeline already absorbs the BD-slot into the branch's cost in `analyze_atom_paths`. -- This check observes, it does not reschedule. -- -- Emit one `info`-severity finding when: -- * the successor event is absent (no following emitted word); `slot_ident` is reported as ``; OR -- * the successor event's `ident == "nop"` (the first emitted word of `nop2` is also `nop`). -- -- Suppress the finding when `policy.suppress_arg1[first_arg]` is non-nil. -- The only current suppression is `jump_reg(R_AtomJmp)`, the fixed `mac_yield()` handshake. -- -- `pipe_ctx` is unused; the uniform `(atom, pipe_ctx, findings)` signature is preserved so the check plugs into -- the existing CHECK_RULES dispatch without modifying the per-atom loop or analyze_atom_paths. -- `passes/emission_model` already normalizes `nop2` to two `nop` events and `atom_label` to zero events, so no special-case branching is needed for either. -- ───────────────────────────────────────────────────────────────────────── local function check_control_transfer_delay_slot_use(atom, pipe_ctx, findings) local events = atom.paths.word_events or {} if not events or #events == 0 then return end local policies = duffle.CONTROL_TRANSFER_DELAY_SLOT_POLICIES or {} for event_idx, event in ipairs(events) do -- Canonical word_events use `encoder` as the leading identifier of the emitting token). -- Focused inputs may supply `ident` when constructing isolated events. local event_ident = event.encoder or event.ident local slot_ident_field = event.encoder and "encoder" or "ident" local policy = policies[event_ident] if policy then local arg1 = event.args and event.args[1] or nil local suppressed = policy.suppress_arg1 and policy.suppress_arg1[arg1] or nil if not suppressed then local slot = events[event_idx + 1] local slot_ident = slot and (slot.encoder or slot.ident) or "" if slot == nil or (slot.encoder or slot.ident) == "nop" then -- Each word event carries `body_line` as the physical source line. -- Use `body_line`, then `def_line`, then 0. local ev_line = event.body_line or event.line or event.def_line or 0 findings[#findings + 1] = { atom = atom.name, line = ev_line, check = "control_transfer_delay_slot_use", kind = "info", msg = string.format("%s at line %d has `%s` whose emitted delay-slot word is `%s`; useful work may replace that no-op if its dependencies are valid on both paths" , atom.name, ev_line, event_ident, slot_ident), } end end end end end -- ════════════════════════════════════════════════════════════════════════════ -- Check #2: mac_yield uniformity -- ════════════════════════════════════════════════════════════════════════════ --- Every atom body must contain exactly one `mac_yield()` call and it must be the LAST top-level token in the body --- (so the tape runtime can pick up cleanly at the next atom's bound registers). --- --- Empty bodies are not currently flagged — runtime infrastructure atoms like --- `MipsAtom_(yield) { mac_yield() }` and `MipsAtom_(tape_exit) { jump_reg(rret_addr), nop }` --- are valid as-is; mac_yield at the end is the contract. --- Uses the standard `(atom, pipe_ctx, findings)` signature; `pipe_ctx` is unused. local function check_mac_yield_uniformity(atom, pipe_ctx, findings) -- Per-kind semantics: -- MipsAtom_ (baked atom): exactly 1 mac_yield at the end of the body. Control transfer is the atom's job. -- MipsAtomComp_ (bare static-array component): ZERO mac_yield. -- The component is invoked from inside an atom body; the parent atom does the yield. -- MipsAtomComp_Proc_ (procedural component): ZERO mac_yield. -- Same reasoning -- it's a function returning a MipsAtom slice, invoked from a parent atom. -- -- The GTE pipeline-fill check applies to all 3 kinds (see check_gte_pipeline_fill). Only the mac_yield rule branches on kind. local tokens = atom.paths.tokens local line_in_body = atom.paths.line_in_body local tc = atom.paths.tok_class local n = #tokens local count = 0 local last_idx = 0 for tok_idx = 1, n do if tc[tok_idx].is_yield then count = count + 1 last_idx = tok_idx end end local function line_for(idx) return atom.line + line_in_body[tokens[idx].rel] end if atom.kind == "atom" then -- Baked atom: exactly 1 yield at the end. if count == 0 then findings[#findings + 1] = { atom = atom.name, line = atom.line, check = "mac_yield_uniformity", kind = "warning", msg = string.format("%s at line %d has no `mac_yield()`; every atom must hand control to the next via mac_yield at end" , atom.name, atom.line), } elseif count > 1 then findings[#findings + 1] = { atom = atom.name, line = line_for(last_idx), check = "mac_yield_uniformity", kind = "warning", msg = string.format("%s at line %d has %d `mac_yield()` calls; exactly 1 is allowed", atom.name, line_for(last_idx), count), } elseif last_idx < n then -- 1 call, but not the last token. We DON'T fail if the post-token is just `nop` or `nop2` or a branch with `, nop` delay slot. -- It's the standard "yield, then BD nop" idiom. local post_non_nop = false for search_idx = last_idx + 1, n do if tc[search_idx].nop_words == 0 and tokens[search_idx].tok ~= "" then post_non_nop = true break end end if post_non_nop then findings[#findings + 1] = { atom = atom.name, line = line_for(last_idx), check = "mac_yield_uniformity", kind = "warning", msg = string.format("%s at line %d has `mac_yield()` at token %d/%d; the yield must be the LAST non-nop token in the body" , atom.name, line_for(last_idx), last_idx, #tokens), } end end else -- Component (comp_bare or comp_proc): ZERO yields. -- The parent atom does the yield. -- A yield inside a component would either be dead code (bare) or prematurely terminate the function (proc). -- Both are bugs. if count > 0 then findings[#findings + 1] = { atom = atom.name, line = line_for(last_idx), check = "mac_yield_uniformity", kind = "warning", msg = string.format("%s at line %d is a %s component but has %d `mac_yield()` call(s); components must not yield (the parent atom does)" , atom.name, line_for(last_idx), atom.kind, count), } end end end -- ════════════════════════════════════════════════════════════════════════════ -- Check #3: Binding handoff discipline -- ════════════════════════════════════════════════════════════════════════════ --- For every atom with `atom_bind(Binds_X)`, verify the atom body reads every field of `Binds_X` from R_TapePtr (in any order) --- and advances R_TapePtr by S_(Binds_X) at the end. Mismatches are errors. --- --- Binds_X is the atom phase's input payload (like a C function's argument struct). --- The body must read each input field and advance the input cursor past the payload. The order of reads doesn't matter. --- Each field is at a different offset in the struct, and the advance at the end is what keeps the tape pointer in sync. --- --- Rules: --- 1. Body MUST contain one `load_word(R_*, R_TapePtr, O_(Binds_X, field))` per field of Binds_X. Missing field = error. --- 2. Body MUST contain an `add_ui_self(R_TapePtr, S_(Binds_X))` (or equivalent advance by the struct's byte count). Missing = error. --- 3. atom_bind(Binds_X) where Binds_X doesn't exist = error. --- Per-atom: Verify the atom body reads every field of its `Binds_X` from R_TapePtr and advances R_TapePtr by S_(Binds_X). --- Takes `(atom, pipe_ctx, findings)`; `pipe_ctx` carries the cross-atom `info_by_atom` + `binds_index` tables --- (built once by validate() before the per-atom loop). --- `validate()` owns per-atom iteration; this function evaluates one atom. local function check_abi_handoff(atom, pipe_ctx, findings) local info = pipe_ctx.info_by_atom[atom.name] if not info or not info.binds then return end local binds_name = info.binds local binds = pipe_ctx.binds_index[binds_name] if not binds then findings[#findings + 1] = { atom = atom.name, line = atom.line, check = "abi_handoff", kind = "error", msg = string.format("%s at line %d has `atom_bind(%s)` but no `typedef Struct_(%s)` declaration found in source" , atom.name, atom.line, binds_name, binds_name), } return end local tokens = atom.paths.tokens local line_in_body = atom.paths.line_in_body local tc = atom.paths.tok_class local found_field_set = {} local found_advance = false -- Reads from tc_entry fields pre-computed by classify_tokens (R3 lift). -- Eliminates 3 per-token string-find/match calls (R_TapePtr + O_(binds_name,...) + bind_re) → 3 O(1) field reads. for tok_idx = 1, #tokens do local tc_entry = tc[tok_idx] -- scan: load_word(R_*, R_TapePtr, O_(, )) if tc_entry.is_load_word and tc_entry.reads_r_tape_ptr and tc_entry.o_arg1 == binds_name then local field = tc_entry.o_arg2 if field then found_field_set[field] = true else local body_line = atom.line + line_in_body[tokens[tok_idx].rel] findings[#findings + 1] = { atom = atom.name, line = body_line, check = "abi_handoff", kind = "error", msg = string.format("%s at line %d has load_word(R_TapePtr, O_(%s, )); expected O_(%s, )", atom.name, body_line, binds_name, binds_name), } end end -- scan: add_ui_self(R_TapePtr, S_()) if tc_entry.reads_r_tape_ptr and tc_entry.s_arg1 == binds_name then found_advance = true end end for _, f in ipairs(binds.fields) do if not found_field_set[f.name] then findings[#findings + 1] = { atom = atom.name, line = atom.line, check = "abi_handoff", kind = "error", msg = string.format("%s at line %d binds %s but never loads field `%s` from R_TapePtr (expected O_(%s, %s))" , atom.name, atom.line, binds_name, f.name, binds_name, f.name), } end end if not found_advance then findings[#findings + 1] = { atom = atom.name, line = atom.line, check = "abi_handoff", kind = "error", msg = string.format("%s at line %d binds %s but never advances R_TapePtr by S_(%s) (= %d bytes / %d words)" , atom.name, atom.line, binds_name, binds_name, binds.bytes, binds.bytes / 0x04), } end end -- ════════════════════════════════════════════════════════════════════════════ -- Check #4: GPU port-store shape -- ════════════════════════════════════════════════════════════════════════════ --- For every baked atom body, detect which GP0 primitive it's emitting --- (first `mac_format__color` call). Sum contributions from `mac_format_X_color` + `mac_gte_store_X_post_*` + `mac_insert_ot_tag_X`. --- Compare to duffle.GP0_CMD_SIZE[cmd_byte]. Mismatch = error. --- --- Soft behavior (warnings): --- - Atoms emitting a primitive via raw `store_word(R_PrimCursor, ...)` (no `mac_format_X_color` call) emit a "manual packet assembly" advisory. --- Cannot auto-validate. --- - Atoms containing a `mac_(...)` call whose name is not in duffle.GP0_MACRO_CONTRIB emit a "new macro; update duffle.GP0_MACRO_CONTRIB" advisory. --- --- Applies only to `kind = "atom"` (baked atoms). Components don't emit full primitives. local function check_gpu_portstore_shape(atom, pipe_ctx, findings) if atom.kind ~= "atom" then return end local tokens = atom.paths.tokens local line_in_body = atom.paths.line_in_body local tc = atom.paths.tok_class local cmd_byte = nil local cmd_line = nil local contrib = 0 local saw_format = false local saw_prim_write = false -- Reads from tc_entry fields pre-computed by classify_tokens (R3 lift). -- Eliminates 4 per-token string matches (mac_format_X_color + mac_gte_store_ + mac_insert_ot_tag_ + R_PrimCursor) for tok_idx = 1, #tokens do local tc_entry = tc[tok_idx] local shape = tc_entry.mac_format_shape if shape and duffle.GP0_CMD_BY_SHAPE[shape] then if not cmd_byte then cmd_byte = duffle.GP0_CMD_BY_SHAPE[shape] cmd_line = atom.line + line_in_body[tokens[tok_idx].rel] end saw_format = true local n = duffle.GP0_MACRO_CONTRIB["mac_format_" .. shape .. "_color"] if n then contrib = contrib + n end end if tc_entry.is_gte_store then local n = duffle.GP0_MACRO_CONTRIB[tc_entry.ident] if n then contrib = contrib + n end end if tc_entry.is_ot_tag then local n = duffle.GP0_MACRO_CONTRIB[tc_entry.ident] if n then contrib = contrib + n end end if tc_entry.writes_r_prim_cursor then saw_prim_write = true end end if not cmd_byte then if saw_prim_write and not saw_format then findings[#findings + 1] = { atom = atom.name, line = atom.line, check = "gpu_portstore_shape", kind = "warning", msg = string.format("%s at line %d writes to R_PrimCursor via raw store_word(...)" .. " but uses no `mac_format_*_color`; the cmd byte + word count cannot be auto-validated." .. " Consider migrating to `mac_format_X_color` + `mac_gte_store_X_post_*` + `mac_insert_ot_tag_X`." , atom.name, atom.line), } end else local expected = duffle.GP0_CMD_SIZE[cmd_byte] if contrib ~= expected then findings[#findings + 1] = { atom = atom.name, line = cmd_line or atom.line, check = "gpu_portstore_shape", kind = "error", msg = string.format("%s at line %d emits GP0 0x%02X with %d prim word(s); expected %d (cmd 0x%02X total = %d)" , atom.name, cmd_line or atom.line, cmd_byte, contrib, expected, cmd_byte, expected), } end end end -- ════════════════════════════════════════════════════════════════════════════ -- Check #5: per-atom cycle budget (uses analyze_atom_paths's unknown_macros) -- ════════════════════════════════════════════════════════════════════════════ --- Walk all paths through an atom body and return per-path cycle sums. --- Builds a tiny CFG: each token has a "next" pointer; branches have two (fall-through + taken). --- The BD-slot nop after a branch is absorbed into the branch's cost (MIPS-accurate: BD slot always runs), --- and is SKIPPED when continuing down the fall-through path (otherwise we'd double-count it). --- --- Returns: --- cycles_min - shortest path through the body (sum of token costs) --- cycles_max - longest path through the body --- branches - number of branches in the body --- paths - number of distinct paths reached (terminated at mac_yield or end-of-body) --- has_loops - true iff a path re-entered a token it had visited (warning; loop bodies aren't supported) --- unknown_macros - list of unique macro names not in duffle.INSTRUCTION_LATENCY local function analyze_atom_paths(atom) local tokens = atom.paths.tokens or duffle.tokenize_body(atom.body) local tc = atom.paths.tok_class or classify_tokens(tokens) local n = #tokens -- Build label + branch maps from the pre-computed classification (no re-scan). local labels = {} local branches = {} for tok_idx = 1, n do local c = tc[tok_idx] if c.is_atom_label and c.label_name then labels[c.label_name] = tok_idx end if c.is_branch then branches[tok_idx] = c.branch_label end end -- Pre-compute per-token cycle costs from the pre-computed ident (no re-match). local costs = {} local unknown_set = {} for tok_idx = 1, n do local c = tc[tok_idx] local cost = duffle.INSTRUCTION_LATENCY[c.ident] if cost == nil then cost = duffle.UNKNOWN_INSTRUCTION_CYCLES unknown_set[c.ident] = true end costs[tok_idx] = cost end -- A token is a terminator if it's `mac_yield`. local function is_terminator(tok_idx) return tc[tok_idx].is_yield end -- A token is a "branch" if the classification says so. local function is_branch(tok_idx) return tc[tok_idx].is_branch end local function successors(tok_idx) local tok = tokens[tok_idx].tok if is_terminator(tok_idx) then return {}, tok_idx -- empty list; term = tok_idx signals "path ends here" end if is_branch(tok_idx) then local label = branches[tok_idx] -- may be false for literal-offset branches local succ = {} -- Fall-through: skip the BD slot (tok_idx+1). Use tok_idx+2. if tok_idx + 2 <= n then succ[#succ + 1] = tok_idx + 2 end -- Taken: only if the branch has a known atom_offset target. if label then local label_pos = labels[label] if label_pos and label_pos + 1 <= n then succ[#succ + 1] = label_pos + 1 end end -- For literal-offset branches (label == false), the taken path would jump to a non-tracked address; conservatively omit. -- Return (succ, nil), the second value is the terminator marker (nil = not a terminator). return succ, nil end -- Normal token: just the next one if tok_idx + 1 <= n then return { tok_idx + 1 }, nil end return {}, nil end -- DFS through all paths. Track the current cycle sum, a visited set scoped to the current path (to detect loops), and a count of paths. -- Cap recursion at MAX_PATHS to prevent runaway exploration on pathological bodies. local MAX_PATHS = 64 local cycles_min = math.huge local cycles_max = -1 local path_count = 0 local has_loops = false local function dfs(tok_idx, acc, visited) if path_count >= MAX_PATHS then return end if _G._DEBUG_DFS then io.stderr:write(string.format("dfs(tok_idx=%d, acc=%d)\n", tok_idx, acc)) end if visited[tok_idx] then has_loops = true if _G._DEBUG_DFS_LOOP then io.stderr:write(string.format(" -> LOOP at tok_idx=%d (tok=%s) acc=%d\n", tok_idx, tokens[tok_idx].tok, acc)) end return end -- Add this token's cost. For a branch, ADD the BD-slot cost too -- (and skip the BD slot in the successor list — already done in `successors` above for fall-through; -- for taken path the BD slot was at tok_idx+1 which is now skipped entirely). local cost = costs[tok_idx] if is_branch(tok_idx) and tok_idx + 1 <= n then cost = cost + costs[tok_idx + 1] end local new_acc = acc + cost local succ, term = successors(tok_idx) if term then -- Terminator: record the path's cycle sum. -- The terminator token stays out of `visited`, so another path reaching the same terminator remains a distinct path. -- Marking it visited would flag those legitimate paths as loops. path_count = path_count + 1 if new_acc < cycles_min then cycles_min = new_acc end if new_acc > cycles_max then cycles_max = new_acc end return end visited[tok_idx] = true for _, next_tok_idx in ipairs(succ) do dfs(next_tok_idx, new_acc, visited) end visited[tok_idx] = nil end if n >= 1 then dfs(1, 0, {}) end -- If no paths were recorded (e.g. atom body is empty), cycles_min/max default to 0 (atom costs nothing). if cycles_min == math.huge then cycles_min = 0 end if cycles_max == -1 then cycles_max = 0 end local unknown_list = {} for macro_name in pairs(unknown_set) do unknown_list[#unknown_list + 1] = macro_name end table.sort(unknown_list) -- branch_count: number of `branch_*(...)` tokens. local branch_count = 0 for _ in pairs(branches) do branch_count = branch_count + 1 end -- Mutate the pre-allocated `atom.paths` slot in place (caller owns the table). -- Mega-struct move: a single source of truth for all per-atom path-analysis data, -- instead of returning a fresh table that would just get copied onto 5 atom fields. local p = atom.paths or {} p.cycles_min = cycles_min p.cycles_max = cycles_max p.branches = branch_count p.paths = path_count p.has_loops = has_loops p.unknown_macros = unknown_list atom.paths = p end --- Per-source check that emits one finding per unknown macro seen --- (deduplicated across atoms so the warning section doesn't get spammed with N copies of "macro X not in duffle.INSTRUCTION_LATENCY"). --- Per-atom: emit one finding per unknown macro seen, deduplicated across atoms --- (so the warning section doesn't get spammed with N copies of "macro X not in duffle.INSTRUCTION_LATENCY"). --- Reuses `analyze_atom_paths`'s per-atom unknown_macros discovery, which walks tokens and computes per-token cycle costs. local function check_per_atom_cycle_budget(atom, pipe_ctx, findings) local p = atom.paths or {} for _, name in ipairs(p.unknown_macros or {}) do if not pipe_ctx.unknown_seen[name] then pipe_ctx.unknown_seen[name] = atom.line findings[#findings + 1] = { atom = atom.name, line = atom.line, check = "per_atom_cycle_budget", kind = "warning", msg = string.format("%s at line %d uses macro `%s` which is not in duffle.INSTRUCTION_LATENCY; " .. "cycle count will be +%d per call (best-case). Add an entry to duffle.INSTRUCTION_LATENCY." , atom.name, atom.line, name, duffle.UNKNOWN_INSTRUCTION_CYCLES), } end end end -- ════════════════════════════════════════════════════════════════════════════ -- Check #6: enum_alias_membership -- ════════════════════════════════════════════════════════════════════════════ -- Every R_X referenced from a debug-visible surface — atom_dbg_reg_default, atom_reg_types, atom_type sub-entries, atom_reads, atom_writes; -- MUST be present in `pipe_ctx.register_alias_registry`. -- The registry is the source-derived answer to "is this R_X a real, opt-in alias?" -- (populated by scan_source's `parse_enum_aliases` from `enum { R_X = N atom_reg }` declarations). -- Per-source rule (called once per source via the CHECK_RULES dispatch). -- Signature matches the per_source shape established by check_semantic_reg_defaults. -- -- Severity: WARNING (build continues). -- The rule is intentionally permissive because the production `code/duffle/` and `code/gte_hello/` -- sources use R_* aliases in atom_reads / atom_writes that may not yet be opted in via the bare `atom_reg` marker. -- R_TapePtr / R_AtomJmp / R_PrimCursor / R_FaceCursor / R_VertBase / R_OtBase ARE opted in. -- Raw C-ABI aliases like R_T0..R_T3 require explicit opt-in; the prototype keeps wave-context registration explicit. -- no auto-include of wave-context; explicit opt-in only). -- Warnings keep the build green and report aliases that need explicit registration. local function check_enum_alias_membership(_src, pipe_ctx, findings) local reg_registry = pipe_ctx.register_alias_registry or {} -- (a) atom_dbg_reg_default(R_X, T) -- pipe_ctx.types. -- source_line is on every entry; emit the diagnostic against the default declaration's own line so the report's -- "Findings by atom" section can attribute the failure to the marker location. for reg, def in pairs(pipe_ctx.types or {}) do if not reg_registry[reg] then findings[#findings + 1] = { atom = "", line = def.source_line or 0, check = "enum_alias_membership", kind = "warning", msg = string.format("atom_dbg_reg_default at line %d references unknown register %q (not in register_alias_registry)" , def.source_line or 0, reg), } end end -- (b) atom_reg_types(R_X, T) + (c) atom_type(R_X, T) sub-entries both populate `ai.reg_type_overrides`. -- (d) atom_reads(R_X) + (e) atom_writes(R_X) populate the reads/writes arrays. -- All four are checked against the same registry; the per-rule dispatch iterates `ai` once and covers all three locations -- so we don't re-walk atom_infos for each sub-check. for _, ai in ipairs(pipe_ctx.atom_infos_list or {}) do local info_line = ai.info_line or 0 local atom_name = ai.atom_name or "" if ai.reg_type_overrides then for reg in pairs(ai.reg_type_overrides) do if not reg_registry[reg] then findings[#findings + 1] = { atom = atom_name, line = info_line, check = "enum_alias_membership", kind = "warning", msg = string.format("atom '%s' at line %d has reg_type_overrides for %q; the alias is not in register_alias_registry" , atom_name, info_line, reg), } end end end for _, reg in ipairs(ai.reads or {}) do if not reg_registry[reg] then findings[#findings + 1] = { atom = atom_name, line = info_line, check = "enum_alias_membership", kind = "warning", msg = string.format("atom '%s' at line %d has atom_reads for %q; the alias is not in register_alias_registry" , atom_name, info_line, reg), } end end for _, reg in ipairs(ai.writes or {}) do if not reg_registry[reg] then findings[#findings + 1] = { atom = atom_name, line = info_line, check = "enum_alias_membership", kind = "warning", msg = string.format("atom '%s' at line %d has atom_writes for %q; the alias is not in register_alias_registry" , atom_name, info_line, reg), } end end end end -- ════════════════════════════════════════════════════════════════════════════ -- Check #7: atom_type_consistency -- ════════════════════════════════════════════════════════════════════════════ -- Every `reg_type_overrides[R_X].type_name` (populated by BOTH `atom_reg_types(R_X, )` -- and `atom_type(R_X, )` sub-entries inside atom_reads/atom_writes) MUST resolve to a `type_name_registry` entry. -- The registry is the source-derived answer to "is this type name declared in this translation unit?" -- (populated by `typedef Struct_(...)`, `typedef Enum_(...)`, `typedef ... TSet_(...)` declarations). -- Missing type names are errors (the build stops) so the user adds the typedef before re-running. -- Per-source rule. local function check_atom_type_consistency(_src, pipe_ctx, findings) local type_registry = pipe_ctx.type_name_registry or {} for _, ai in ipairs(pipe_ctx.atom_infos_list or {}) do local info_line = ai.info_line or 0 local atom_name = ai.atom_name or "" if ai.reg_type_overrides then for reg, ov in pairs(ai.reg_type_overrides) do if not ov.type_name or not type_registry[ov.type_name] then findings[#findings + 1] = { atom = atom_name, line = info_line, check = "atom_type_consistency", kind = "error", msg = string.format("atom '%s' at line %d reg_type_overrides[%q] uses unknown type %q (not in type_name_registry)" , atom_name, info_line, reg, tostring(ov.type_name)), } end end end end end -- ════════════════════════════════════════════════════════════════════════════ -- Check #8: binds_no_substruct_deref -- ════════════════════════════════════════════════════════════════════════════ -- For every `load_word(R_A, R_B, O_(, ))` and matching `store_word(...)` call in every atom body, -- the `` MUST resolve to a leaf scalar of ``. A "leaf scalar" is: -- * a non-struct field with `pointer_depth >= 1` (pointer-to-struct IS a leaf — the field is a pointer; the pointee is unrelated), OR -- * a non-struct field whose type_name resolves to a typedef / enum / builtin in `type_name_registry`. -- A nested struct member (pointer_depth == 0 and type_name resolves to a `kind = "struct"` registry entry) fails the leaf-scalar test. -- The check also flags fields whose Type has no `fields` table (typedefs and enums don't have fields — any Field reference against them is bogus) -- and fields whose name doesn't appear in the resolved Type's fields array. -- -- Walks every atom's pre-computed `paths.tok_class` -- (set by `classify_tokens` once per atom in validate()) and uses the `o_arg1` / `o_arg2` captures instead of re-matching the token string. -- Resolution consults `pipe_ctx.type_name_registry` -- (Binds_* structs are registered there by scan_source's `register_struct_type`, so a unified lookup works for both Binds_* and non-Binds structs). -- -- Severity: warning (build continues) — this catches a category of bugs -- (passing a struct by value through the tape payload) where the symptom is runtime corruption, not a compile error. -- Look up a field by name in a type's `fields` array. Returns the matching field entry, or nil if not found. -- Helper extracted to keep the caller's nesting depth <= 5 (project convention; this is the 5th nesting level: -- function -> for-atom -> for-token -> if-load/store -> if-type-resolves). local function find_field_by_name(type_entry, field_name) for _, f in ipairs(type_entry.fields or {}) do if f.name == field_name then return f end end return nil end -- True iff a (field, type_registry) pair is a leaf scalar (safe to dereference as a tape-payload field). -- Pointer-to-X is always a leaf; non-pointer struct members fail the leaf test. local function is_field_leaf(field, type_registry) if field.pointer_depth and field.pointer_depth > 0 then return true end local ftype_entry = type_registry[field.type_name] if ftype_entry and ftype_entry.kind == "struct" then return false end return true end local function check_binds_no_substruct_deref(_src, pipe_ctx, findings) local type_registry = pipe_ctx.type_name_registry or {} for _, a in ipairs(pipe_ctx.atoms or {}) do local tc = a.paths and a.paths.tok_class or {} local tokens = a.paths and a.paths.tokens or {} local line_in_body = a.paths and a.paths.line_in_body or {} for ti = 1, #tokens do local tc_entry = tc[ti] if (tc_entry.is_load_word or tc_entry.is_store_word) and tc_entry.o_arg1 and tc_entry.o_arg2 then local type_name = tc_entry.o_arg1 local field_name = tc_entry.o_arg2 local body_line = a.line + (line_in_body[tokens[ti].rel] or 0) local type_entry = type_registry[type_name] if not type_entry or not type_entry.fields then findings[#findings + 1] = { atom = a.name, line = body_line, check = "binds_no_substruct_deref", kind = "warning", msg = string.format("atom '%s' at line %d O_(%s, %s) refers to type %q which has no fields table in type_name_registry" , a.name, body_line, type_name, field_name, type_name), } else local field = find_field_by_name(type_entry, field_name) if not field then findings[#findings + 1] = { atom = a.name, line = body_line, check = "binds_no_substruct_deref", kind = "warning", msg = string.format("atom '%s' at line %d O_(%s, %s) does not resolve to a field of %s" , a.name, body_line, type_name, field_name, type_name), } elseif not is_field_leaf(field, type_registry) then findings[#findings + 1] = { atom = a.name, line = body_line, check = "binds_no_substruct_deref", kind = "warning", msg = string.format("atom '%s' at line %d O_(%s, %s) dereferences a non-pointer struct field of type %q; nested struct members are forbidden" , a.name, body_line, type_name, field_name, field.type_name), } end end end end end end -- ════════════════════════════════════════════════════════════════════════════ -- CHECK_RULES — data-driven check dispatch (Muratori: data over control flow) -- ════════════════════════════════════════════════════════════════════════════ -- Each rule is a table entry: { name, }. -- Dispatch shapes: -- per_atom(atom, pipe_ctx, findings) — runs once per atom inside validate()'s single loop -- post(pipe_ctx, findings) — runs once after all per-atom calls complete -- per_macro(macro, wc, findings) — runs once per TAPE_WORDS / _Pragma macro declaration -- per_skip_marker(marker, pipe_ctx, findings) — runs once per src.scan.debug_skip_markers entry -- per_source(src, pipe_ctx, findings) — runs once per source AFTER the per-atom loop completes -- (registry-driven rule; same CHECK_RULES table) -- Each check is one table row and one `check_*` function. -- This is the plex pattern: the iteration is in ONE place (validate), the variation is in DATA (this table). local CHECK_RULES = { { name = "transfer_hazards", per_atom = check_transfer_hazards }, { name = "gte_input_latch", per_atom = check_gte_input_latch }, { name = "gte_result_position", per_atom = check_gte_result_position }, { name = "hazard_nop_use", per_atom = check_hazard_nop_use }, { name = "control_transfer_delay_slot_use",per_atom = check_control_transfer_delay_slot_use}, { name = "mac_yield_uniformity", per_atom = check_mac_yield_uniformity }, { name = "abi_handoff", per_atom = check_abi_handoff }, { name = "gpu_portstore_shape", per_atom = check_gpu_portstore_shape }, { name = "per_atom_cycle_budget", per_atom = check_per_atom_cycle_budget }, { name = "enum_alias_membership", per_source = check_enum_alias_membership }, { name = "atom_type_consistency", per_source = check_atom_type_consistency }, { name = "binds_no_substruct_deref", per_source = check_binds_no_substruct_deref }, } -- ════════════════════════════════════════════════════════════════════════════ -- Per-source validation -- ════════════════════════════════════════════════════════════════════════════ --- Build the corpus-wide pipe_ctx ONCE per pass run. --- Reads the merged `corpus.*` registries and the corpus-wide `atom_infos` list (preserving source order + duplicates). --- The corpus supplies shared registries; `src.scan` and per-source projections retain body and declaration ownership. --- --- A context without `ctx.shared.corpus` is rejected with an explicit corpus message. --- Callers construct the context through `build_ctx`. --- @param ctx PassCtx --- @return PipeCtx local function build_corpus_pipe_ctx(ctx) local corpus = ctx.shared and ctx.shared.corpus if not corpus then error("static_analysis requires ctx.shared.corpus " .. "(the canonical corpus is the source of truth; " .. "no per-source fallback is supported)", 0) end -- The pipe_ctx views REFERENCE the corpus tables directly (no copies). -- Every consumer observes mutations through the corpus tables directly. return { -- Cross-source lookup tables. register_alias_registry = corpus.register_alias_registry or {}, type_name_registry = corpus.type_name_registry or {}, atom_views = corpus.atom_views or {}, atom_ctxs = corpus.atom_ctxs or {}, atom_phases = corpus.atom_phases or {}, binds_by_name = corpus.binds_by_name or {}, atoms_by_name = corpus.atoms_by_name or {}, -- Corpus-wide ordered list of atom_info records (source-order + duplicates). atom_infos_list = corpus.atom_infos or {}, -- Corpus-wide collisions (recorded by scan_source.merge_corpus_registries). collisions = corpus.collisions or {}, } end local function validate(ctx, src, corpus_pipe_ctx) local scan = src.scan -- Read the corpus word_counts for the per-atom pipeline -- (`atom.paths.word_events` is the emitted projection). local corpus = (ctx.shared and ctx.shared.corpus) or {} -- Read atoms + binds + atom_infos from the pre-scanned SourceScan payload. -- The scan was done once upstream by duffle.scan_source(); this pass is pure. local atoms = scan.atoms local atom_infos = scan.atom_infos -- Build per-source Binds_* index. Local to validate() — no cross-source sharing. local binds_index = {} for _, b in ipairs(scan.binds) do binds_index[b.name] = b end -- pipe_ctx: the cross-atom shared state for the per-atom pipeline (Fleury "expose structure"). -- Pre-allocated here, mutated by each per-atom check call below. -- Cross-source lookup tables come from `corpus_pipe_ctx` (built once per pass); -- source-local body / declaration ownership comes from `src.scan`. -- info_by_atom — atom_name -> atom_info (built once; check_abi_handoff reads it) -- binds_index — Binds_X -> binds struct (built once; check_abi_handoff reads it) -- unknown_seen — macro_name -> first atom line (accumulated across atoms; check_per_atom_cycle_budget dedups) -- atoms — full atom list (used by check_binds_no_substruct_deref's per-source body walk) -- types — R_X -> default-type info from atom_dbg_reg_default (check_enum_alias_membership source a) -- atom_infos_list — per-source flat list of atom_info entries (checks #6/#7 iterate it) -- register_alias_registry — R_X -> {name, code, has_atom_reg, source_line} from corpus-wide merge -- type_name_registry — T -> {name, kind, fields, ...} from corpus-wide merge -- All registry fields are READ from the corpus (the dep-closed scan-source merge); this pass never re-parses. local info_by_atom = {} for _, info in ipairs(atom_infos) do info_by_atom[info.atom_name] = info end local pipe_ctx = { info_by_atom = info_by_atom, binds_index = binds_index, unknown_seen = {}, atoms = atoms, types = scan.types or {}, atom_infos_list = atom_infos or {}, register_alias_registry = corpus_pipe_ctx.register_alias_registry, type_name_registry = corpus_pipe_ctx.type_name_registry, } -- Shared cross-source component-body index is owned by the corpus -- (`corpus.component_body_index`, populated by `passes/components.lua`). -- Per-atom checks consume the corpus-owned index directly. pipe_ctx.component_body_index = (corpus and corpus.component_body_index) or {} --- Per-atom pipeline. ONE iteration of atoms; the 5 check_* functions + analyze_atom_paths all run here, sharing a single tokenize_body + build_body_line_index per body. --- Every piece of state derived from an atom body lives on `atom.paths` (per-atom mega-struct); --- readers (analyze_atom_paths, the 5 checks, the renderers) all consume `atom.paths`, not the raw `atoms` list. --- Each `check_*` function accepts one atom and its shared context. --- Per-source rules run once after this loop completes (no parallel dispatch table). --- --- Body, token, and emission projections come from here (`paths.tokens = body_tokens`, `paths.line_in_body = build_body_line_index` `paths.word_events` --- and related fields are owned by `passes/emission_model.lua` pass (per-atom emission projection). --- This pass reads: `paths.tokens`, `paths.line_in_body`, `paths.items`, `paths.word_events` from the emitted projection, --- then computes `paths.tok_class`, `paths.cycles_min/max`, `paths.branches`, `paths.paths`, `paths.has_loops`, `paths.unknown_macros` via `classify_tokens` + `analyze_atom_paths`. --- No re-walk of body text or body_tokens happens here. --- --- Canonical contract: `atom.paths` and `atom.paths.word_events` MUST be populated by `passes/emission_model.run(ctx)` before this pass runs. --- The `atom.paths.word_events` projection is owned by the emission-model pass; static-analysis reads it directly. local findings = {} for _, a in ipairs(atoms) do if a.paths == nil then error("static_analysis: a.paths is nil; emit emission-model first") end if a.paths.word_events == nil then error("static_analysis: a.paths.word_events is nil; emit emission-model first") end -- `paths.tokens` / `paths.line_in_body` / `paths.items` / `paths.word_events` are populated by `passes/emission_model.lua`. -- Supply tokens when no emission projection is present. if a.paths.tokens == nil then a.paths.tokens = a.body_tokens end a.paths.tok_class = classify_tokens(a.paths.tokens) -- analyze_atom_paths fills the *cycles / branches / has_loops / unknown_macros* fields of a.paths. analyze_atom_paths(a) -- Run the single forward walker for transfer-hazard policy. -- Runs once per atom BEFORE the CHECK_RULES per-atom dispatch so the `transfer_hazards` reader (`check_transfer_hazards`) can -- project `atom.paths.hazards` into `findings` without re-walking source. -- The walker owns `atom.paths.{forward_state, relations, hazards}`; readers never re-iterate events or re-classify tokens. analyze_hardware_relations(a) -- Run all per-atom checks on this one atom via the CHECK_RULES data table. -- Adding a new check = 1 row in CHECK_RULES; this loop never needs editing. for _, rule in ipairs(CHECK_RULES) do if rule.per_atom then rule.per_atom(a, pipe_ctx, findings) end end end -- Per-source dispatch. Run once per source AFTER the per-atom loop; -- consults pipe_ctx's cross-atom registries (register_alias_registry, type_name_registry). -- Same CHECK_RULES table; no parallel dispatch table. for _, rule in ipairs(CHECK_RULES) do if rule.per_source then rule.per_source(src, pipe_ctx, findings) end end -- Three-way severity binning: per-finding severity is set by the check via `f.kind`. -- "error" / "warning" / "info" are all distinct; info findings are NEVER folded into warnings. -- Keep error, warning, and info findings in distinct buckets so the control-transfer delay-slot check remains distinct from warnings.) -- The `info` list returned here is finding-level only; scan/cycle summary lines go into `summaries`. -- An invalid/missing kind is a hard error (no silent fallback to info); this prevents typos like -- kind="warn" or omitted kind fields from being misclassified as info in the rendered report. local errors = {} local warnings = {} local info = {} for _, f in ipairs(findings) do -- Preserve the diagnostic context so focused tests + the renderer can route by the originating check or relation id. -- Hazard readers (transfer_hazards) populate `f.check`, `f.relation_id`, `f.semantic`, `f.direction`, `f.producer_destination`, `f.gap`, `f.required`, `f.evidence_confidence`, etc.; -- Copying them through keeps the per-severity bucket schema compatible with the renderer while making the diagnostic payload queryable. local payload = { line = f.line, msg = f.msg, check = f.check, atom = f.atom, source = f.source, relation_id = f.relation_id, semantic = f.semantic, direction = f.direction, producer_destination = f.producer_destination, producer_word = f.producer_word, producer_line = f.producer_line, producer_source = f.producer_source, consumer_word = f.consumer_word, consumer_token = f.consumer_token, gap = f.gap, required = f.required, evidence_confidence = f.evidence_confidence, evidence_source = f.evidence_source, } -- Preserve relation fields such as target_state and status_register, -- status_value, and future policy metadata) without making the binner -- another semantic walker. for key, value in pairs(f) do if payload[key] == nil then payload[key] = value end end if f.kind == "error" then errors [#errors + 1] = payload elseif f.kind == "warning" then warnings[#warnings + 1] = payload elseif f.kind == "info" then info [#info + 1] = payload else error(string.format("invalid finding kind %s for check %q (atom=%s, line=%d); expected one of \"error\", \"warning\", \"info\"" , tostring(f.kind), tostring(f.check), tostring(f.atom), f.line or 0), 0) end end -- Per-source "scanned:" / "cycles:" summary lines. These are SCANNER / BUDGET rollups, not findings; They belong in their own collection so the report can render them -- AS summary rows (after Module findings) rather than mixed into the Info finding section. local summaries = {} -- Per-source "scanned:" summary line. -- Includes the source basename for traceability -- Include the source basename so multi-source module summaries remain identifiable. -- Sources with 0 atoms (pure-header files like dsl.h, mips.h, etc.) are SKIPPED. -- The per-module header already lists them in the "Sources:" section, and emitting a noisy "0 atom bodies" line per header is just clutter. if #atoms > 0 or #findings > 0 then summaries[#summaries + 1] = { line = 0, msg = string.format("scanned: %s: %d atom bodies; %d findings", src.basename, #atoms, #findings), } end -- Path-aware cycle-budget summary line. Per-path min/max totals. if #atoms > 0 then local total_min = 0 local total_max = 0 local max_atom_cyc = 0 local max_atom_name = nil for _, a in ipairs(atoms) do local p = a.paths or {} total_min = total_min + (p.cycles_min or 0) total_max = total_max + (p.cycles_max or 0) if (p.cycles_max or 0) > max_atom_cyc then max_atom_cyc = p.cycles_max max_atom_name = a.name end end summaries[#summaries + 1] = { line = 0, msg = string.format("cycles: path-aware min=%d max=%d across %d atoms; worst atom=%s (%d); best-case, no stalls; BD-slot nops absorbed into branch costs", total_min, total_max, #atoms, max_atom_name or "?", max_atom_cyc), } end return { atoms = atoms, findings = findings, errors = errors, warnings = warnings, info = info, summaries = summaries, } end -- ════════════════════════════════════════════════════════════════════════════ -- Per-directory output: build/gen/.static_analysis.txt -- ════════════════════════════════════════════════════════════════════════════ --- Per-directory emit. Aggregates atoms + findings across every source in `dir_sources` --- and writes a single report to `/.static_analysis.txt`. --- Called only when at least one atom was found (the caller in M.run handles the skip). --- --- `info` is finding-level info only (kind == "info" findings); the scanned/cycles summary rows --- live in `summaries` and are rendered as trailing summary lines after `Module findings:`. local function emit_module_static_analysis_txt(ctx, dir, dir_sources, atoms, findings, errors, warnings, info, summaries) -- Module basename = last component of `dir` ("code/duffle" -> "duffle"). local dir_basename = dir:match("([^/\\]+)$") or dir local out_path = ctx.out_root .. "/" .. dir_basename .. ".static_analysis.txt" duffle.ensure_dir(ctx.out_root) local lines = {} local function add(s) lines[#lines + 1] = s end add("========================================================") add("STATIC ANALYSIS PASS -- module " .. dir_basename) add("========================================================") add(string.format("Sources: %d", #dir_sources)) for _, s in ipairs(dir_sources) do add(" " .. s.path) end add("") -- Tally atoms by kind for the header summary local n_atoms, n_bare, n_proc = 0, 0, 0 for _, a in ipairs(atoms) do n_atoms = n_atoms + 1 if a.kind == "comp_bare" then n_bare = n_bare + 1 elseif a.kind == "comp_proc" then n_proc = n_proc + 1 end end local header_atoms = string.format("Atoms: %d", n_atoms) if n_bare > 0 or n_proc > 0 then header_atoms = header_atoms .. string.format(" (atoms: %d, comp_bare: %d, comp_proc: %d)", n_atoms - n_bare - n_proc, n_bare, n_proc) end -- Header carries the per-severity counts; info is its own column, not a warning. -- (`Info: N` is the byte-asserted field that the focused test matches; do not collapse it into Warnings.) add(string.format("%s Findings: %d Errors: %d Warnings: %d Info: %d", header_atoms, #findings, #errors, #warnings, #info)) add("") -- Group findings by atom (with source prefix when multi-source module) local multi_source = #dir_sources > 1 local by_atom = {} for _, f in ipairs(findings) do by_atom[f.atom] = by_atom[f.atom] or {} by_atom[f.atom][#by_atom[f.atom] + 1] = f end if next(by_atom) == nil then add(" (no findings -- every atom passed all checks)") else add("── Findings by atom ─────────────────────────────────────") for _, a in ipairs(atoms) do local fs = by_atom[a.name] if fs then local label = a.name if multi_source and a.source_path then label = string.format("%s (%s)", a.name, a.source_path:match("([^/\\]+)$") or a.source_path) end add(string.format(" %s line %d", label, a.line)) for _, f in ipairs(fs) do add(string.format(" [%s] %s", f.check, f.msg)) end end end end add("") add("── Errors ──────────────────────────────────────────────") if #errors == 0 then add(" (none)") end for _, e in ipairs(errors) do add(string.format(" X line %d %s", e.line, e.msg)) end add("") add("── Warnings ────────────────────────────────────────────") if #warnings == 0 then add(" (none)") end for _, w in ipairs(warnings) do add(string.format(" ! line %d %s", w.line, w.msg)) end -- Finding-level Info section. -- Rendered between Warnings and the per-atom cycle table so the next `── ` line after `── Info` is the per-atom cycle counts section; -- the trailing scan/cycle summary rows (rendered after Module findings) stay outside this section. add("") add("── Info ────────────────────────────────────────────────") if #info == 0 then add(" (none)") end for _, i_ in ipairs(info) do add(string.format(" i line %d %s", i_.line, i_.msg)) end -- Per-atom cycle counts (path-aware). For each atom: -- min = shortest path through the body (earliest exit) -- max = longest path through the body (full fall-through) -- br = number of branch instructions -- paths = number of distinct paths reached -- Both min and max are best-case (no stalls); BD-slot nops are absorbed into branch costs (MIPS semantics). add("") add("── Per-atom cycle counts (path-aware, best case, no stalls) ─") if #atoms == 0 then add(" (no atoms)") else -- Sort atoms by max cycles descending for quick scanning. local sorted = {} for _, a in ipairs(atoms) do sorted[#sorted + 1] = a end table.sort(sorted, function(x, y) return ((x.paths or {}).cycles_max or 0) > ((y.paths or {}).cycles_max or 0) end) for _, a in ipairs(sorted) do local p = a.paths or {} local br_count = p.branches or 0 local path_count = p.paths or 0 local loops_tag = p.has_loops and " [loop!]" or "" local unknown_tag = "" if p.unknown_macros and #p.unknown_macros > 0 then unknown_tag = string.format(" [unknown: %s]", table.concat(p.unknown_macros, ", ")) end local name_label = a.name if multi_source and a.source_path then name_label = string.format("%s (%s)", a.name, a.source_path:match("([^/\\]+)$") or a.source_path) end if br_count > 0 then add(string.format(" %-44s min=%4d max=%4d br=%d paths=%d (line %d)%s%s", name_label, p.cycles_min or 0, p.cycles_max or 0, br_count, path_count, a.line, loops_tag, unknown_tag)) else add(string.format(" %-44s %4d cycles (line %d, no branches)%s%s", name_label, p.cycles_min or 0, a.line, loops_tag, unknown_tag)) end end end add("") add("── Per-source scan summary ──────────────────────────────") -- One line per source that contributed atoms. -- The line includes the source basename + per-source atom count + (if path-aware cycle data is present) the min..max cycle range. -- Sources with 0 atoms are skipped (they're just header files that declared no MipsAtom_ — they're already listed in the module's "Sources:" section above). for _, src in ipairs(dir_sources) do local src_atoms = {} for _, a in ipairs(atoms) do if a.source_path == src.path then src_atoms[#src_atoms + 1] = a end end if #src_atoms == 0 then goto continue end local atom_count = #src_atoms local mn, mx = math.huge, -1 for _, a in ipairs(src_atoms) do local p = a.paths or {} if (p.cycles_min or 0) < mn then mn = p.cycles_min or 0 end if (p.cycles_max or 0) > mx then mx = p.cycles_max or 0 end end local path_str if mx > 0 then path_str = string.format(" cycles=%d..%d", mn, mx) else path_str = string.format(" %d cycles", mn) end add(string.format(" %-30s %d atom%s%s", src.basename, atom_count, atom_count == 1 and "" or "s", path_str)) ::continue:: end -- Module-level findings summary (across all sources). -- Info has its own count; it remains separate from warnings. local total_errs = #errors local total_warns = #warnings local total_infos = #info add("") add(string.format("Module findings: %d error(s), %d warning(s), %d info", total_errs, total_warns, total_infos)) -- Per-source "scanned:" / "cycles:" summary lines (each line includes the source basename for traceability). -- These are kept SEPARATE from the finding-level Info section above so the report's Info section is signal-only -- (true findings), not a mix of findings + rollups. -- The downstream test (`test_control_transfer_delay_slot.lua`) -- asserts that the Info section contains NEITHER `scanned:` NOR `cycles:` lines. if summaries and #summaries > 0 then add("") for _, s in ipairs(summaries) do add(string.format(" %s", s.msg)) end end duffle.write_file(out_path, table.concat(lines, "\n") .. "\n") return out_path end -- ════════════════════════════════════════════════════════════════════════════ -- M.run — orchestrator entry -- ════════════════════════════════════════════════════════════════════════════ --- @class M local M = {} --- @param ctx PassCtx --- @return PassResult function M.run(ctx) local outputs = {} local errors = {} local warnings = {} -- `info` aggregates finding-level info across every source (the per-source validate() also -- returns a `summaries` collection for scan/cycle rollups; -- those are summary rows and never enter `info`). local info = {} -- Build the corpus-wide pipe_ctx ONCE per pass run. -- The pipe_ctx is shared across every validate() invocation in this M.run so cross-source visibility is constant. local corpus_pipe_ctx = build_corpus_pipe_ctx(ctx) local corpus = ctx.shared.corpus -- Aggregate per-DIRECTORY (per-module). -- One static_analysis.txt per source-directory, emitted only if the directory contains at least one atom. -- Empty-source directories (e.g. duffle headers with no atoms) produce no report. -- Group sources by `src.dir` through the corpus-owned `sources_by_dir`. local by_dir = (corpus and corpus.sources_by_dir) or {} for dir, dir_sources in pairs(by_dir) do -- Run validate() against every source in this directory; accumulate atoms / findings / errors / warnings. -- The validate() function does its own per-source analysis (Binds indexing, atom discovery, all checks) -- and attaches path-aware cycle data to each atom it finds. local all_atoms = {} local all_findings = {} local dir_errors = {} local dir_warnings = {} local dir_info = {} local dir_summaries = {} for _, src in ipairs(dir_sources) do local result = validate(ctx, src, corpus_pipe_ctx) -- Tag each atom with its source so the render step can prefix the atom line with ":" -- when atoms from multiple sources live in the same module (e.g. lottes_tape.h + atom_dsl.h both declaring atoms). for _, a in ipairs(result.atoms) do a.source_path = src.path all_atoms[#all_atoms + 1] = a end for _, f in ipairs(result.findings) do all_findings[#all_findings + 1] = f end for _, e in ipairs(result.errors) do dir_errors [#dir_errors + 1] = e end for _, w in ipairs(result.warnings) do dir_warnings [#dir_warnings + 1] = w end for _, i_ in ipairs(result.info) do dir_info [#dir_info + 1] = i_ end for _, s in ipairs(result.summaries or {}) do dir_summaries[#dir_summaries + 1] = s end end -- Skip directories with zero atoms. A directory with only headers / no MipsAtom_ is "nothing to report". if #all_atoms == 0 then -- Still aggregate errors/warnings/info so orchestrator sees them, but don't write a file. for _, e in ipairs(dir_errors) do errors [#errors + 1] = e end for _, w in ipairs(dir_warnings) do warnings[#warnings + 1] = w end for _, i_ in ipairs(dir_info) do info[#info + 1] = i_ end else local out_path = emit_module_static_analysis_txt(ctx, dir, dir_sources, all_atoms, all_findings, dir_errors, dir_warnings, dir_info, dir_summaries) if out_path then table.insert(outputs, { static_analysis_txt = out_path }) end for _, e in ipairs(dir_errors) do errors [#errors + 1] = e end for _, w in ipairs(dir_warnings) do warnings[#warnings + 1] = w end for _, i_ in ipairs(dir_info) do info[#info + 1] = i_ end end end -- Result exposes at least {outputs, errors, warnings, info}. -- Summaries are internal to the renderer; callers (orchestrator, focused tests) consume the four severity-typed collections. return { outputs = outputs, errors = errors, warnings = warnings, info = info } end return M