Files
pikuma_ps1/scripts/passes/static_analysis.lua
T
ed d56adab38f branch delay slot better support.
Still reviewing. Need to see if gte is handled properly.
2026-07-23 18:35:02 -04:00

1676 lines
83 KiB
Lua
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
--- passes/static_analysis.lua — Per-atom static-analysis checks.
---
--- Per-atom rules:
--- 1. gte_write_retire: Every `gte_mv_to_data_r` / `gte_mv_to_ctrl_r` (CPU-to-COP2 write) must retire the documented slot count
--- (`duffle.COP2_WRITE_RETIRE_SLOTS`: 2 slots default, 3 slots for writes to `gte_cr_IRGB` / `gte_cr_ORGB`)
--- before any subsequent `gte_cmdw_*` consumes one of the command's input registers (`duffle.GTE_COMMAND_INPUTS`).
--- The check walks `atom.paths.word_events` (the semantic emitted-word stream). Every emitted machine word
--- (including CPU ALU, branch, GTE transfer, `nop`/`nop2` half, and `mac_X(...)`-expanded words) counts as one retired slot.
--- 2. cop2_gpr_load_delay: Every `gte_mv_from_data_r` / `gte_mv_from_ctrl_r` (COP2-to-GPR read)
--- requires 1 retired slot before the destination GPR can be used as an operand of the next instruction.
--- The check walks `atom.paths.word_events` and consults `duffle.OPERAND_READ_POSITIONS` to classify which emitted tokens read each GPR operand position.
--- Branch delay slots are out of scope (separate MIPS control-flow concern; tracked by check #3 below).
--- 3. 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: `expand_word_events` emits two `nop` events for it, so the first expansion is the hardware delay slot.
--- `atom_label` also needs no special case (zero events).
--- 4. mac_yield uniformity: Every atom body must contain exactly one `mac_yield()` call (control transfer pattern).
--- 5. Binding handoff: Every `atom_bind(Binds_X)` must reference a `typedef Struct_(Binds_X) { ... }` declaration.
--- 6. 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.
--- 7. 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 `scan.register_alias_registry`.
--- 9. atom_type_consistency: Every `reg_type_overrides[R_X].type_name` must resolve in `scan.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 do NOT).
--- 11. reads_writes_alias_membership: Distinct check name duplicating #8's reads/writes coverage so the report can attribute failures to a precedence class.
---
--- 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 now 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 = "<out_root>/<basename>.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<string, integer>
--- @field out_root string
--- @field project_root string
--- @field upstream table<string, table>
--- @field flags table
--- @field dry_run boolean
--- @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 -- "gte_write_retire" | "cop2_gpr_load_delay" | "control_transfer_delay_slot_use" | "mac_yield_uniformity" | "abi_handoff" | "gpu_portstore_shape" | "per_atom_cycle_budget"
--- @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;
-- replaces the backward walk in count_preceding_nops — O(N) instead of 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_<shape>`
--- @field is_ot_tag boolean -- ident matches `mac_insert_ot_tag_<shape>`
--- @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_(<a>, <b>) captures; nil for non-O_ tokens
--- @field o_arg2 string|nil -- second arg of O_(<a>, <b>) captures
--- @field s_arg1 string|nil -- arg of S_(<a>) captures; nil for non-S_ tokens
-- Patterns for O_(<arg1>, <arg2>) and S_(<arg>) 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_<shape> / mac_insert_ot_tag_<shape> (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_(<arg1>, <arg2>) / S_(<arg>) 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: GTE CPU→C2 write retirement (producer/consumer scoreboard).
--
-- Reads `atom.paths.word_events` (the semantic emitted-word stream from `duffle.expand_word_events`).
-- Every `gte_mv_to_data_r` / `gte_mv_to_ctrl_r` event stages a write into a pending-writes queue;
-- Every `gte_cmdw_*` event intersects its command input set against the still-pending queue.
-- A write that has not retired its documented slot count (`M.COP2_WRITE_RETIRE_SLOTS.cpu_to_cop2`, override for writes to gte_cr_IRGB / gte_cr_ORGB) is reported as an `error`.
--
-- Convention: every emitted instruction slot — CPU ALU, branch, GTE transfer, GTE command, `nop`, `mask_upper` half, and every word expanded from a `mac_X(...)` component;
-- Counts as one retired slot.
-- This replaces the prior literal-NOP-prefix heuristic; the canonical `nop2` is the conservative fallback for callers that haven't modeled their producer/consumer pairs.
-- ─────────────────────────────────────────────────────────────────────────
-- Return true iff `reg_ident` is an IRGB/ORGB write that requires the 3-cycle fan-out.
local function is_irgb_destination(reg_ident)
return reg_ident == "gte_cr_IRGB" or reg_ident == "gte_cr_ORGB"
end
-- Look up the alias → canonical mapping.
-- Defaults to the input ident so MVMVA variants + unknown idents surface as `command_unknown` warnings rather than being silently treated as 0-cycle.
local function canonical_command(ident)
local aliases = duffle.GTE_COMMAND_ALIASES or {}
return aliases[ident] or ident
end
local function push_write(pending, reg_ident, slots, source_path, line)
pending[#pending + 1] = {
reg = reg_ident,
slots = slots,
source = source_path,
line = line,
}
end
local function retire_writes(pending, max_elapsed)
-- Walk pending in FIFO order; advance their slot counters; remove any whose counter has met or exceeded the required retire window.
-- The walk index advances monotonically; once a pending entry's `elapsed >= required` the slot is freed and the next pending entry can retire on the same cycle.
local out = {}
for i = 1, #pending do
pending[i].elapsed = (pending[i].elapsed or 0) + max_elapsed
if pending[i].elapsed < pending[i].slots then
out[#out + 1] = pending[i]
end
end
return out
end
local function check_gte_write_retire(atom, pipe_ctx, findings)
local events = atom.paths.word_events
if not events or #events == 0 then return end
local pending = {}
local inputs = duffle.GTE_COMMAND_INPUTS or {}
local slots_def = duffle.COP2_WRITE_RETIRE_SLOTS or { cpu_to_cop2 = 2, cpu_to_irgb = 3 }
for _, ev in ipairs(events) do
-- Every non-write slot retires pending writes by 1.
if ev.ident == "gte_mv_to_data_r" or ev.ident == "gte_mv_to_ctrl_r" then
local dest_reg = ev.args and ev.args[2] or nil
if dest_reg then
local required = is_irgb_destination(dest_reg) and slots_def.cpu_to_irgb or slots_def.cpu_to_cop2
push_write(pending, dest_reg, required, ev.source, ev.line)
end
-- Advance pre-existing writes by 1 (this very slot counts).
pending = retire_writes(pending, 1)
elseif ev.ident == "gte_cmdw_rtps" or ev.ident == "gte_cmdw_rtpt" or ev.ident == "gte_cmdw_nclip"
or ev.ident == "gte_cmdw_mvmva" or ev.ident == "gte_cmdw_op"
or ev.ident == "gte_cmdw_avsz3" or ev.ident == "gte_cmdw_avsz4"
or duffle.GTE_COMMAND_ALIASES[ev.ident] ~= nil
then
-- A dependent GTE command stalls until in-flight commands complete (hardware interlock).
-- We still retire all pending writes by 1 for the slot this command occupies, then check the still-pending writes against the command's input set.
-- If the command reads a register whose write is still pending, that is a true hazard.
pending = retire_writes(pending, 1)
local canonical = canonical_command(ev.ident)
local cmd_inputs = inputs[canonical]
if cmd_inputs == nil then
findings[#findings + 1] = {
atom = atom.name,
line = ev.line,
check = "gte_write_retire",
kind = "warning",
msg = string.format("%s at line %d uses `%s` but the canonical command is not in GTE_COMMAND_INPUTS -- add an entry (or confirm the alias)"
, atom.name, ev.line, ev.ident
),
}
else
for _, pw in ipairs(pending) do
-- Check intersection with the command's input set.
for _, in_reg in ipairs(cmd_inputs) do
if pw.reg == in_reg then
findings[#findings + 1] = {
atom = atom.name,
line = ev.line,
check = "gte_write_retire",
kind = "error",
msg = string.format("%s at line %d: recent %s at %s:%d has not retired (need %d slot(s); %s reads %s at slot %d)"
, atom.name, ev.line, pw.reg, pw.source, pw.line, pw.slots, ev.ident, in_reg, pw.slapsed or 0
),
}
break -- one finding per pending write per command
end
end
end
end
-- A command does NOT introduce a new pending CPU→C2 write
-- (the command writes its RESULT registers, which are not subject to the CPU-side retire window).
-- We leave `pending` as-is after the input-set check so the next event sees the post-retire state.
else
-- Plain CPU word / branch / GTE transfer read / nop2 half / etc.
pending = retire_writes(pending, 1)
end
end
-- (Surface expansion-cycle diagnostics as informational findings, not errors.)
local errors = atom.paths.word_event_errors or {}
for _, e in ipairs(errors) do
if e.kind == "cycle" then
findings[#findings + 1] = {
atom = atom.name,
line = e.line,
check = "gte_write_retire",
kind = "info",
msg = string.format("%s at line %d: %s", atom.name, e.line, e.msg),
}
end
end
end
-- ─────────────────────────────────────────────────────────────────────────
-- Check #1b: COP2→GPR load delay (mfc2 / cfc2 → first GPR consumer).
--
-- Reads `atom.paths.word_events`. Every `gte_mv_from_data_r` / `gte_mv_from_ctrl_r` stages a destination GPR + 1 remaining slot.
-- A subsequent event that READS from that GPR (per the OPERAND_READ_POSITIONS table + operand-position rules)
-- within the same instruction slot emits a finding with `kind = "error"`.
--
-- Branch delay slots + BD-slot absorption are out of scope (separate MIPS control-flow concern).
-- A separate check (control_transfer_delay_slot_use, below) covers branch/jump/call delay slots.
-- ─────────────────────────────────────────────────────────────────────────
-- Return the textual ident of the GPR read at `pos` in the macro's argument list, or nil if the operand is not a GPR.
-- Operands that are numeric literals (e.g. `0`, `4`, `0xFFFF`) or type keywords (`U4`, `C2_VZ2`) are not GPR reads.
local function operand_gpr_at(ev, pos)
local op = ev.args and ev.args[pos] or nil
if not op then return nil end
if op:sub(1, 2) == "0x" or op:sub(1, 2) == "0X" then return nil end
if op:match("^%d") then return nil end -- numeric literal
if op == "true" or op == "false" then return nil end
-- Strip any trailing whitespace; the operator scanner already trims, but be defensive against raw args.
return op:match("^%s*(%S+)%s*$")
end
local function check_cop2_gpr_load_delay(atom, pipe_ctx, findings)
local events = atom.paths.word_events
if not events or #events == 0 then return end
local pending = {} -- { gpr = "R_T0", line = N, source = path, slots = 1 }
for _, ev in ipairs(events) do
if ev.ident == "gte_mv_from_data_r" or ev.ident == "gte_mv_from_ctrl_r" then
local dst = ev.args and ev.args[1] or nil
if dst then
pending[#pending + 1] = {
gpr = dst,
slots = 1,
source = ev.source,
line = ev.line,
}
end
-- The transfer itself counts as one slot (the destination GPR is updated after the next instruction), so the existing pending list advances.
for i = 1, #pending do pending[i].slots = pending[i].slots - 1 end
local next_pending = {}
for _, p in ipairs(pending) do
if p.slots > 0 then next_pending[#next_pending + 1] = p end
end
pending = next_pending
else
-- Resolve read positions for this emitting token.
local read_pos = duffle.OPERAND_READ_POSITIONS and duffle.OPERAND_READ_POSITIONS[ev.ident]
if read_pos then
for _, pw in ipairs(pending) do
for _, pos in ipairs(read_pos) do
local op = operand_gpr_at(ev, pos)
if op and op == pw.gpr then
findings[#findings + 1] = {
atom = atom.name,
line = ev.line,
check = "cop2_gpr_load_delay",
kind = "error",
msg = string.format("%s at line %d: %s reads %s but the load from %s at %s:%d has not retired (need 1 slot)"
, atom.name, ev.line, ev.ident, pw.gpr, ev.ident, pw.source, pw.line
),
}
break
end
end
end
end
-- Advance all pending writes by 1 for the slot this instruction occupies.
for i = 1, #pending do pending[i].slots = pending[i].slots - 1 end
local next_pending = {}
for _, p in ipairs(pending) do
if p.slots > 0 then next_pending[#next_pending + 1] = p end
end
pending = next_pending
end
end
end
-- ─────────────────────────────────────────────────────────────────────────
-- Check #1c: control-transfer delay-slot use.
--
-- Reads `atom.paths.word_events` (the semantic emitted-word stream from `duffle.expand_word_events`).
-- For each event whose `ident` 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 `<missing>`; 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.
-- `expand_word_events` 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
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]
if slot == nil or slot.ident == "nop" then
local slot_ident = slot and slot.ident or "<missing>"
findings[#findings + 1] = {
atom = atom.name,
line = event.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, event.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.
--- Stage 2: signature uniformized to `(atom, pipe_ctx, findings)` — pipe_ctx is ignored here.
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).
--- Signature changed in Stage 1B: Takes `(atom, pipe_ctx, findings)` where `pipe_ctx` carries the cross-atom
--- `info_by_atom` + `binds_index` tables (built once by validate() before the per-atom loop).
--- Per-atom iteration now lives in validate(); this is a per-atom predicate.
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_(<Binds_X>, <field>))
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, <non-ident>)); expected O_(%s, <field>)",
atom.name, body_line, binds_name, binds_name),
}
end
end
-- scan: add_ui_self(R_TapePtr, S_(<Binds_X>))
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_<shape>_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_<name>(...)` 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_<shape> + mac_insert_ot_tag_<shape> + 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.
-- We do NOT add the terminator token to `visited` a path ends here, so a different path that
-- ALSO reaches this terminator is a legitimate new path (not a loop).
-- If we marked it visited, subsequent paths that reach the same terminator would be incorrectly flagged 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 (it's the canonical place that 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 are intentionally NOT auto-included (per the prototype principle:
-- no auto-include of wave-context; explicit opt-in only). Warnings keep the build green
-- and surface the migration gap so users see which atoms still need opt-in 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, <type>)`
-- and `atom_type(R_X, <type>)` 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_(<Type>, <Field>))` and matching `store_word(...)` call in every atom body,
-- the `<Field>` MUST resolve to a leaf scalar of `<Type>`. 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) is NOT a leaf scalar and is flagged.
-- 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.
-- Extracted to keep check_binds_no_substruct_deref's nesting depth <= 5 (the project convention; this is the 5th nesting level:
-- function -> for-atom -> for-token -> if-load/store -> if-type-resolves -> [helper]).
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 leaf; non-pointer struct members are NOT leaf.
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 #9: reads_writes_alias_membership
-- ════════════════════════════════════════════════════════════════════════════
-- For every `atom_reads(R_X)` and `atom_writes(R_X)` entry in every `atom_infos` entry, the `R_X` MUST be present in `pipe_ctx.register_alias_registry`.
-- This DUPLICATES `enum_alias_membership`'s coverage of the reads/writes arrays;
-- the distinct check name is intentional so the report can attribute the failure to a precedence-class (warnings vs errors) — the production reads/writes
-- paths are intentionally permissive at the warning level even when the registry-driven check is strict at the error level.
-- Per-source rule. Severity: warning (build continues).
local function check_reads_writes_alias_membership(_src, pipe_ctx, findings)
local reg_registry = pipe_ctx.register_alias_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 ""
for _, reg in ipairs(ai.reads or {}) do
if not reg_registry[reg] then
findings[#findings + 1] = {
atom = atom_name, line = info_line,
check = "reads_writes_alias_membership", kind = "warning",
msg = string.format(
"atom '%s' at line %d 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 = "reads_writes_alias_membership", kind = "warning",
msg = string.format(
"atom '%s' at line %d atom_writes for %q; the alias is not in register_alias_registry",
atom_name, info_line, reg),
}
end
end
end
end
-- ════════════════════════════════════════════════════════════════════════════
-- CHECK_RULES — data-driven check dispatch (Muratori: data over control flow)
-- ════════════════════════════════════════════════════════════════════════════
-- Each rule is a table entry: { name, <dispatch> }.
-- 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.skip_over.markers entry
-- per_source(src, pipe_ctx, findings) — runs once per source AFTER the per-atom loop completes
-- (added for the registry-driven rule set; same CHECK_RULES table — no parallel dispatch)
-- Adding a new check = 1 row here + 1 check_* function. validate() is updated only to invoke the per_source dispatch loop (the per_atom dispatch loop never changes).
-- This is the plex pattern: the iteration is in ONE place (validate), the variation is in DATA (this table).
local CHECK_RULES = {
{ name = "gte_write_retire", per_atom = check_gte_write_retire },
{ name = "cop2_gpr_load_delay", per_atom = check_cop2_gpr_load_delay },
{ 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 },
{ name = "reads_writes_alias_membership", per_source = check_reads_writes_alias_membership },
}
-- ════════════════════════════════════════════════════════════════════════════
-- Per-source validation
-- ════════════════════════════════════════════════════════════════════════════
local function validate(ctx, src)
local scan = src.scan
-- 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.
-- Replaces the per-check local tables that used to live inside each check_* function body.
-- 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 — flat list of atom_info entries (checks #6/#7/#9 iterate it)
-- register_alias_registry — R_X -> {name, code, has_atom_reg, source_line} from parse_enum_aliases
-- type_name_registry — T -> {name, kind, fields, ...} from parse_typedef_binds
-- All registry fields are READ from src.scan (the dep-closed scan-source payload); 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 = scan.register_alias_registry or {},
type_name_registry = scan.type_name_registry or {},
}
-- Shared cross-source component-body index (built once per pass via duffle's memoizing helper).
-- `atom.paths.word_events` + `atom.paths.word_event_errors` are populated below and consumed by
-- the per-atom checks (`check_gte_write_retire`, `check_cop2_gpr_load_delay`).
pipe_ctx.component_body_index = duffle.get_component_body_index(ctx)
-- THE 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` (the per-atom mega-struct);
-- readers (analyze_atom_paths, the 5 checks, the renderers) all consume `atom.paths`, not the raw `atoms` list.
-- Stage 1B: each check_* now takes `(atom, ...)` instead of `(atoms, findings)` — no more single-atom `{a}` shim.
-- Per-source rules run once after this loop completes (no parallel dispatch table).
local findings = {}
for _, a in ipairs(atoms) do
a.paths = a.paths or {}
a.paths.tokens = a.body_tokens
a.paths.line_in_body = duffle.build_body_line_index(a.body)
a.paths.tok_class = classify_tokens(a.paths.tokens)
-- Precompute the semantic emitted-word event stream for this atom. The per-atom checks
-- (`check_gte_write_retire`, `check_cop2_gpr_load_delay`) read these to retire slots on the
-- actual emitted machine words (including `mac_X(...)`-expanded words from nested components).
local body_entry = {
body_tokens = a.body_tokens,
body_off = a.body_off,
line_of = src.scan.line_of,
source = src.path,
declaration = a.line,
}
a.paths.word_events, a.paths.word_event_errors =
duffle.expand_word_events(body_entry, pipe_ctx.component_body_index, ctx.shared.word_counts or {})
-- analyze_atom_paths fills the *cycles / branches / has_loops / unknown_macros* fields of a.paths.
analyze_atom_paths(a)
-- Run all per-atom checks on this one atom via the CHECK_RULES data table (Muratori: data over control flow).
-- 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.
-- (Pre-2026-07-23 the binner treated everything non-error as a warning, which made the
-- control-transfer delay-slot check indistinguishable from real warnings in the report.)
-- 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
if f.kind == "error" then errors [#errors + 1] = { line = f.line, msg = f.msg }
elseif f.kind == "warning" then warnings[#warnings + 1] = { line = f.line, msg = f.msg }
elseif f.kind == "info" then info [#info + 1] = { line = f.line, msg = f.msg }
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
-- (the old format was just "scanned: N atom bodies; M findings" which is unidentifiable when the module has multiple sources).
-- 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/<dir_basename>.static_analysis.txt
-- ════════════════════════════════════════════════════════════════════════════
--- Per-directory emit. Aggregates atoms + findings across every source in `dir_sources`
--- and writes a single report to `<out_root>/<dir_basename>.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"
if ctx.dry_run then return out_path end
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 is its own count; it is NOT lumped into 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 NOT finding-level and never enter `info`).
local info = {}
-- 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`. The first component of `dir` is the module name (e.g. "code/duffle" -> "duffle", "code/gte_hello" -> "gte_hello").
-- Output path is `<out_root>/<module_basename>.static_analysis.txt`.
local by_dir = ctx.by_dir or duffle.group_sources_by_dir(ctx.sources)
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)
-- Tag each atom with its source so the render step can prefix the atom line with "<filename>:"
-- 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