Files
pikuma_ps1/scripts/passes/static_analysis.lua
T
ed 748b58c5c5 Codebase overhaul. Metaprogram proofread (part 2). Starting to get serious.
Need to rewrite the ps1 lua metaprogram sometime soonish. Getting too bloated... need to consolidate code paths.

In this push codebase structure is starting to get a bit more realized. Decided todo now to match Pikuma's linking module files vods beginning to reorganize its codebase as well.
Atoms & atom components are not in their on *.atom.c files. (Not calling it tape.c as I don't really bake tapes like that outside of the unity c file so far...)

The lua metaprogram has had additional features added to it yet again to avoid hardcoding module handling and supporting multiple atom files per-module.
Either after the camera or cd-rom section I'll be most likely pausing to fully refactor the metaprogram. Possibly as a full re-write to get the loc minimal.
2026-08-04 23:34:00 -04:00

2686 lines
138 KiB
Lua

--- passes/static_analysis.lua — Per-atom static-analysis checks.
--- Ownership: `ctx.shared.corpus` 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. 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 — non-`mac_*` tokens look up `duffle.INSTRUCTION_LATENCY[ident]`;
--- `mac_*` tokens look up `pipe_ctx.components_by_name[bare_name].cycle_cost` (auto-derived from the original `MipsAtomComp_` body by `passes/components.lua::compute_components_metadata`).
--- 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 structural handshake checks (`mac_yield_uniformity`, `hazard_nop_use`, `control_transfer_delay_slot_use`) skip atoms/components with `debug_skip == true`.
--- The `atom_dbg_skip` marker designates runtime-helper declarations whose structure is fixed by the tape runtime (e.g. `tape_exit`, `ac_yield`).
--- Flagging them as "missing mac_yield" or "BD slot is redundant" is signal noise, not a logic failure.
--- Other checks (transfer_hazards, gpu_portstore_shape, abi_handoff, enum_alias_membership, …) still apply to debug_skip declarations because real hazards / typos can still surface in them.
---
--- 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"},
--- }
--- `kind = "diagnostic"` keeps every finding visible in the projection; the orchestrator does not exit non-zero on static-analysis errors.
--- Annotation and header-output validation remain build-stopping.
-- ════════════════════════════════════════════════════════════════════════════
-- 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 -- Full source text
--- @field dir string -- 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 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" | "yield_load_tail_pairing" | "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 -- 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 -- Raw token text (trimmed)
--- @field line integer -- Source line of the token's start
--- @field ident string|nil -- 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 -- Atom this finding is for (or "")
--- @field check CheckName -- Check identifier
--- @field kind string -- "error" | "warning" | "info"
--- @field msg string -- Finding message
--- @class AtomAnalysis
--- @field atom AtomBody
--- @field tokens Token[] -- Tokens in the atom body, annotated
--- @field findings Finding[] -- Findings for this atom
--- @field total_cycles integer -- Sum of token cycle costs
-- ════════════════════════════════════════════════════════════════════════════
-- Per-word-event helpers
-- ════════════════════════════════════════════════════════════════════════════
-- Pick the source-line field that best represents "where in the user's source file is this word?".
-- `word_events` (populated by `passes/emission_model.lua::stamp_root_provenance`) carry four line fields:
-- * `call_line` — physical line in the ROOT atom's source (the line of the `mac_X(...)` call site that triggered this emission, or `body_line` for direct words in the atom body)
-- * `body_line` — physical line in the body containing the emitted word (the atom body for direct words; the component body for words expanded inside `mac_X(...)`)
-- * `def_line` — line of the COMPONENT's declaration in its source file (only meaningful for words emitted inside a component expansion)
-- * `line` — body-relative line in the source text (not a physical source line; rarely useful in rendered findings)
--
-- For component-expanded words (e.g. the BD-slot nop of `jump_reg(R_AtomJmp)` inside `mac_yield()`),
-- `body_line` points into the COMPONENT's source file (e.g. `lottes_tape.h:110` for `ac_yield`'s body).
-- The user editing their atom body expects the line to point at THEIR source — i.e. the line where `mac_yield()`
-- was called (e.g. `hello_gte_tape.c:35`). That line is `call_line`.
--
-- For direct words in the atom body (no invocation wrapping them), `call_line == body_line` already, so `call_line` works for both cases.
local function line_for_word_event(ev)
if ev == nil then return 0 end
return ev.call_line or ev.body_line or ev.line or ev.def_line or 0
end
-- True iff the given atom/component declaration has the bare `atom_dbg_skip` marker.
-- Used by the structural handshake checks (`mac_yield_uniformity`, `hazard_nop_use`,
-- `control_transfer_delay_slot_use`) to exempt runtime-helper declarations (`tape_exit`, `ac_yield`,
-- and the `ac_*` macro components) from findings whose contract they intentionally don't satisfy.
local function is_runtime_helper(atom)
return atom and atom.debug_skip == true
end
-- ════════════════════════════════════════════════════════════════════════════
-- 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_*(...)` OR an unconditional-jump-with-offset (`jump(off)` / `call_addr(off)`); branch_label has the target label or false
-- is_unconditional_jump — true if this token is `jump` or `call_addr` (BD slot + single successor — taken only; no fall-through).
-- Mutually exclusive with the conditional-branch semantics; combined with `is_branch` above.
-- is_terminal_jump — true if this token is `jump_reg` / `call_reg` / `jump_link` (transfers control OUT of the current atom; the `mac_yield()` handshake ends in `jump_reg(R_AtomJmp), nop`).
-- No offset field — `atom_offset` is invalid here. Terminates the current path in the CFG.
-- is_load — true if this token starts with any of: load_word, load_half, load_half_u, load_byte,
-- load_byte_u, gte_lw, gte_lwc2. These all have MIPS load-delay semantics (the destination register is volatile for 1 word after the load).
-- 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 -- lLading 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 -- Conditional branch OR unconditional-jump-with-offset
--- @field is_unconditional_jump boolean -- `jump` / `call_addr` only
--- @field is_terminal_jump boolean -- `jump_reg` / `call_reg` / `jump_link` only
--- @field branch_label string|false|nil -- For branch_*(..., atom_offset(F, label)) OR jump/call_addr
--- @field is_load boolean -- load_word | load_half | load_half_u | load_byte | load_byte_u | gte_lw | gte_lwc2
--- @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
-- The set of MIPS instruction idents that have a load-delay slot.
-- Per MIPS I R3000A: `lw`, `lh`, `lhu`, `lb`, `lbu`, `lwc2` (gte_lw).
-- Note: `lui` (load_upper_i) does NOT have a load delay on MIPS I — it's an ALU op, not a load.
-- The `load_imm_*` macros are lui + ori sequences with no per-component load delay either.
local LOAD_INSTRUCTION_IDENTS = {
load_word = true,
load_half = true,
load_half_u = true,
load_byte = true,
load_byte_u = true,
gte_lw = true,
gte_lwc2 = true,
}
-- 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*%)"
-- Ident patterns for control-transfer instruction kinds:
-- * `branch_*` (conditional): `branch_equal`, `branch_ne`, `branch_lt_zero`, `branch_ge_zero`, `branch_le_zero`, `branch_gt_zero`.
-- * `jump` / `call_addr` (unconditional absolute): one immediate offset field; can carry `atom_offset(F, T)`.
-- * `jump_reg` / `call_reg` / `jump_link` (register-form): no offset field; `atom_offset` is invalid; transfers OUT of the current atom.
local BRANCH_PATTERN = "^branch_[%w_]+%s*%("
-- `jump_rel(off)` is an ergonomic alias for `branch_equal(R_0, R_0, off)` (the within-atom-safe unconditional jump — see `code/duffle/mips.h`).
-- The C preprocessor expands it BEFORE the metaprogram sees the source, but for source-level metadata consistency we still match it here and classify it as a branch_equal.
-- This keeps `consuming_encoder` canonical for any downstream tooling that consults the metadata field.
local JUMP_REL_PATTERN = "^jump_rel%s*%("
local UNCOND_JUMP_PATTERN = "^%f[%w](jump|call_addr)%f[%W]"
local TERMINAL_JUMP_PATTERN = "^%f[%w](jump_reg|call_reg|jump_link)%f[%W]"
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
-- `mac_yield_tail` is the canonical end-of-atom terminator when paired with `mac_yield_load`
-- in a preceding branch's BD-slot. It runs `addiu_self R_TapePtr; jr R_AtomJmp; nop` — the
-- "tail" half of the lego split. Treat it as a yield for the `mac_yield_uniformity` check.
local is_yield = ident == "mac_yield" or ident == "mac_yield_tail"
local is_atom_label = false
local label_name = nil
local is_branch = false
local is_unconditional_jump = false
local is_terminal_jump = false
local branch_label = nil
local is_load = LOAD_INSTRUCTION_IDENTS[ident] == true
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_PATTERN) or tok:match(JUMP_REL_PATTERN) then
-- Conditional branch OR `jump_rel` (the within-atom-safe unconditional jump alias).
-- Both encode a 16-bit signed relative word offset.
is_branch = true
branch_label = tok:match("atom_offset%s*%([^,]+,%s*([%w_]+)%s*%)") or false
elseif tok:match(UNCOND_JUMP_PATTERN) then
-- Unconditional absolute jump / call: `jump(off)` / `call_addr(off)`.
-- One immediate offset field; can carry an `atom_offset(F, T)` marker (the offsets pass dispatches on `consuming_encoder` — see `passes/offsets.lua::compute_offsets`).
is_branch = true
is_unconditional_jump = true
branch_label = tok:match("atom_offset%s*%([^,]+,%s*([%w_]+)%s*%)") or false
elseif tok:match(TERMINAL_JUMP_PATTERN) then
-- Register-form jump / call: no offset field; `atom_offset` is invalid here (the offsets pass will error if one is supplied).
-- Transfers control OUT of the current atom — the CFG treats this as a path terminator.
is_terminal_jump = true
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,
is_unconditional_jump = is_unconditional_jump,
is_terminal_jump = is_terminal_jump,
branch_label = branch_label,
is_load = is_load,
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=<U4>}`.
-- 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_event` is a GTE command (gte_cmdw_* or one of the human-readable aliases mapped in `duffle.GTE_COMMAND_ALIASES`).
-- Used by the LWC2 retirement-regime dispatch in the forward walker: a GTE-command consumer can read the LWC2 result in the very next slot
-- (the GTE pipeline latches the LWC2 data); any other consumer must observe the standard MIPS load delay (gap >= 1).
local function is_gte_command(consumer_event)
local tok = consumer_event.encoder or consumer_event.ident or ""
if tok:sub(1, 9) == "gte_cmdw_" then return true end
local aliases = duffle.GTE_COMMAND_ALIASES or {}
return aliases[tok] ~= nil
end
-- 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 {}
local event_line = line_for_word_event(event)
atom.paths.hazards[#atom.paths.hazards + 1] = {
check = "transfer_hazards",
kind = kind,
atom = atom.name,
line = event_line,
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
local event_line = line_for_word_event(event)
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_line
, 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_line
, 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_line,
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 = line_for_word_event(ev)
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_to_GTE" or semantic == "LWC2_to_other" then
-- Consumer is a GTE command whose input set contains the producer's COP2 destination (or a fan-out target).
-- LWC2_to_GTE — GTE-command consumer: gap = 0 OK (the pipeline latches the LWC2 result).
-- LWC2_to_other — non-GTE consumer: standard load delay applies.
if relation.id == "lwc2_to_gte_command" then
is_match = is_gte_command(ev) and is_cop2_consumer_of(ev, prod.destination, relation)
elseif relation.id == "lwc2_to_other_consumer" then
is_match = (not is_gte_command(ev)) and is_cop2_consumer_of(ev, prod.destination, relation)
else
is_match = is_cop2_consumer_of(ev, prod.destination, relation)
end
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[<register>]` so the `gte_role_mismatch` 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_role_mismatch (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[<register>]` 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`.
--
-- Note: the OLD `gte_result_position` check also emitted table-gap info findings for `_post_<cmd>` components missing a row in `duffle.GTE_COMPONENT_RESULT_CONTRACTS`.
-- That table-gap check was based on the `_post_<cmd>` NAMING convention rather than hardware truth, and was removed
-- (the user did not want naming to encode ordering semantics; A proper `atom_info` directive for ordering semantics is a future TODO).
--
-- The first `transfer_hazards` reader comment above records the projection contract.
-- ─────────────────────────────────────────────────────────────────────────
local function check_gte_role_mismatch(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 {}
-- 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_<role>" 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
local ev_line = line_for_word_event(ev)
findings[#findings + 1] = {
check = "gte_role_mismatch",
kind = "warning",
atom = atom.name,
line = ev_line,
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_line
, 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.
--
-- Both classifications emit at `info` severity: `modeled-required` documents the model boundary and `modeled-redundant`
-- is a soft observation ("you have a redundant nop; consider replacing it").
-- Neither is a logic failure, so neither rises to `warning`.
--
-- `atom_dbg_skip` runtime helpers (`tape_exit`, `ac_yield`, the `ac_*` macro components) are exempt:
-- their structural nops are part of the fixed handshake and not author choices.
--
-- 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 {}
-- GPR effects table used to resolve load destinations when classifying load-delay-slot nops.
local gpr_effects = duffle.INSTRUCTION_GPR_EFFECTS or {}
if not events or #events == 0 then return end
-- Runtime-helper atoms / components (e.g. tape_exit, ac_yield) carry `debug_skip = true` from the bare
-- `atom_dbg_skip` marker; their structural nops are part of the fixed handshake and not author choices.
if is_runtime_helper(atom) 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
local ev_line = line_for_word_event(ev)
-- 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 that are exclusively owned by control_transfer_delay_slot_use
-- (the nop after a branch/jump — covered by that check separately).
-- Every BD-slot nop is structural; this check never reports on it.
-- (The earlier `if not suppressed then is_bd_slot = true end` form inverted the suppression - the `mac_yield()` handshake's `jump_reg(R_AtomJmp)` was incorrectly flagged.)
local prev_ident = prev_ev.encoder or ""
local bd_policies = duffle.CONTROL_TRANSFER_DELAY_SLOT_POLICIES or {}
local is_bd_slot = bd_policies[prev_ident] ~= nil
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_line,
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 "<would-be-consumer>",
msg = string.format("%s at line %d: nop at word %d is modeled-required (retires %s for %s)"
, atom.name, ev_line, 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"
-- MIPS load-delay slot: a `load_*` wrote a register in the previous slot, and the result is unavailable for 1 cycle.
-- This `nop` is structurally required; classifying it as `modeled-required` is the correct signal
-- (removing it would make the following instruction read the OLD value of the loaded register, a load-use hazard).
-- The `load_delay_violations` check (Concern 3) catches the actual read-side error; here we suppress the `modeled-redundant` misclassification.
-- The set of load instructions mirrors the LOAD_INSTRUCTION_IDENTS in `check_load_delay_slots`.
local load_idents = {
load_word = true,
load_half = true,
load_half_u = true,
load_byte = true,
load_byte_u = true,
gte_lw = true,
gte_lwc2 = true
}
local is_load_delay = load_idents[prev_ident] == true
if is_load_delay then
-- Determine the destination register from the load's `writes` field.
local prev_writes = gpr_effects[prev_ident] and gpr_effects[prev_ident].writes or {}
local prev_args = prev_ev.args or {}
local load_dest = prev_writes[1] and prev_args[prev_writes[1]] or "<load-destination>"
findings[#findings + 1] = {
check = "hazard_nop_use",
kind = "info",
atom = atom.name,
line = ev_line,
source = ev.def_path or ev.source or "",
nop_classification = "modeled-required",
nop_word_index = ev_word,
retired_relation = "load_delay_slot",
producer_destination = load_dest,
consumer_token = "<would-be-consumer>",
msg = string.format("%s at line %d: nop at word %d is modeled-required (load-delay slot for %s)"
, atom.name, ev_line, ev_word, load_dest
),
}
else
findings[#findings + 1] = {
check = "hazard_nop_use",
kind = "info",
atom = atom.name,
line = ev_line,
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_line, ev_word
),
}
end
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 `<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.
--
-- `atom_dbg_skip` runtime helpers (`tape_exit`, `ac_yield`, the `ac_*` macro components) are exempt:
-- their BD slots are part of the fixed handshake (`jump_reg(rret_addr), nop` for tape_exit, `jump_reg(R_AtomJmp), nop` for ac_yield).
--
-- `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
-- Runtime-helper atoms / components (e.g. tape_exit, ac_yield) carry `debug_skip = true` from the bare
-- `atom_dbg_skip` marker; their structural BD slots are part of the fixed handshake.
if is_runtime_helper(atom) 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 "<missing>"
if slot == nil or (slot.encoder or slot.ident) == "nop" then
-- Prefer `call_line` (the line of the `mac_X(...)` call site in the atom body) so the rendered
-- finding points at the user's source, not at the vendored component body.
local ev_line = line_for_word_event(event)
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 #1d: load-delay slot violations (per-atom)
-- ═══════════════════════════════════════════════════════════════════════════
--- Walk every emitted word event of one atom. For each `is_load` event (lw / lh / lhu / lb / lbu / lwc2),
--- mark the destination register as "volatile through" the NEXT emitted slot — MIPS I R3000A load-delay
--- semantics. If any subsequent event in that 1-slot window reads the volatile register, emit a `load_delay_violation`
--- finding (severity: error — the load result is unavailable in the delay slot).
---
--- The register becomes non-volatile again at word N+2 (the load has retired), OR sooner if a non-load instruction overwrites the register
--- (the overwriter's write is the fresh producer; the load's value is shadowed and never observed by any reader).
---
--- Runtime-helper atoms / components (`debug_skip == true`) are exempt: their internal load-then-use sequences
--- are part of the fixed handshake (e.g. `ac_load_tri_indices` loads into R_T0..R_T2, but those are caller-supplied).
---
--- The walker reads `duffle.OPERAND_READ_POSITIONS[event.encoder]` to determine which args are read-source
--- (the destination of a load is in `writes`, not `reads` — see `duffle.INSTRUCTION_GPR_EFFECTS`).
--- The check is purely structural; it does not consult the GPR-value lattice (no constant propagation needed for load-delay detection — the volatility window is unconditional).
local function check_load_delay_slots(atom, pipe_ctx, findings)
if atom.kind ~= "atom" then return end
local events = atom.paths.word_events or {}
if #events == 0 then return end
if is_runtime_helper(atom) then return end
local gpr_effects = duffle.INSTRUCTION_GPR_EFFECTS or {}
local read_positions = duffle.OPERAND_READ_POSITIONS or {}
-- volatile_until[reg] = 1-based word_events index; the slot AFTER which the register is safe.
-- `nil` means "not currently volatile".
local volatile_until = {}
-- Compute the "net reads" of an event: read-positions MINUS write-positions.
-- A position that is BOTH read and written (e.g. `add_ui rt, rs, imm` where the duffle table lists position 1 as both.
-- See `duffle.OPERAND_READ_POSITIONS["add_ui"] = {1, 2}` and `INSTRUCTION_GPR_EFFECTS["add_ui"].writes = {1}` —
-- and for genuine RMW ops like `add rt, rs, rt` where position 1 IS both read+written) is not a "read" for load-delay purposes:
-- The write shadows whatever value the register previously held. Only positions that are reads WITHOUT a co-occurring write to the same register count as net reads.
local function net_reads(event_ident, args)
local effect = gpr_effects[event_ident]
local positions = read_positions[event_ident]
if not positions then return {} end
local writes_set = {}
if effect and effect.writes then
for _, pos in ipairs(effect.writes) do writes_set[pos] = true end
end
local net = {}
for _, pos in ipairs(positions) do
if not writes_set[pos] then net[#net + 1] = pos end
end
return net
end
for event_idx, event in ipairs(events) do
local event_ident = event.encoder or event.ident
local args = event.args or {}
local is_load = LOAD_INSTRUCTION_IDENTS[event_ident] == true
-- (1) Is this event reading a register that's still volatile from a previous load?
-- Skip the load instruction itself (the load's own argument list may "read" its destination via `OPERAND_READ_POSITIONS`:
-- e.g. `addiu rt, rs, imm` lists position 1 (rt) as a "read", but rt is the destination; the within-load argument list is not a separate consumer).
-- Use `net_reads` to ignore RMW positions (write shadows read within the same instruction).
if not is_load then
for _, pos in ipairs(net_reads(event_ident, args)) do
local reg = args[pos]
if type(reg) == "string" and reg:sub(1, 2) == "R_" then
local until_idx = volatile_until[reg]
if until_idx and event_idx <= until_idx then
local ev_line = line_for_word_event(event)
findings[#findings + 1] = {
atom = atom.name,
line = ev_line,
check = "load_delay_violation",
kind = "error",
msg = string.format("%s at line %d reads %s at word %d, but a prior load's "
.. "delay slot is not over until word %d; insert a `nop` between the "
.. "load and this instruction.",
atom.name, ev_line, reg, event_idx, until_idx),
}
end
end
end
end
-- (2) Update the volatile set based on what this event writes.
local effect = gpr_effects[event_ident]
if effect and effect.writes then
for _, pos in ipairs(effect.writes) do
local reg = args[pos]
if type(reg) == "string" and reg:sub(1, 2) == "R_" then
if is_load then
-- Load: destination volatile for exactly 1 slot (the delay slot).
volatile_until[reg] = event_idx + 1
else
-- Non-load write to this register: overwrites shadow the load; the volatile state ends.
-- If another reader comes later, it sees the overwriter's value (or unknown), not the stale load value.
volatile_until[reg] = nil
end
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.
---
--- Runtime helpers carrying the bare `atom_dbg_skip` marker (`tape_exit`, `ac_yield`, the `ac_*` macro components) are exempt:
--- they intentionally do not follow the standard "1 yield at the end" contract. `tape_exit` performs its own `jump_reg(rret_addr),
--- nop` to return from the tape runner; `ac_yield` IS the `mac_yield()` implementation.
--- Flagging them as "missing mac_yield" is signal noise, not a logic failure.
---
--- Uses the standard `(atom, pipe_ctx, findings)` signature; `pipe_ctx` is unused.
local function check_mac_yield_uniformity(atom, pipe_ctx, findings)
-- Runtime-helper atoms / components (e.g. tape_exit, ac_yield) carry `debug_skip = true` from the bare
-- `atom_dbg_skip` marker; they intentionally break the "1 yield at the end" contract.
if is_runtime_helper(atom) then return end
-- 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: mac_yield_load / mac_yield_tail pairing (the lego split)
-- ════════════════════════════════════════════════════════════════════════════
--- The lego split — `mac_yield_load()` in a branch BD-slot + `mac_yield_tail()` at the branch's target
--- label — must be used as a pair. Otherwise `R_AtomJmp` is not loaded for the tail's `jr R_AtomJmp`,
--- and the tape runtime would jump to garbage.
---
--- Rules:
--- 1. Every `mac_yield_load()` must be in a branch BD-slot (the immediately preceding token must be a branch).
--- 2. Every `mac_yield_tail()` must be the first instruction after an `atom_label()`, AND
--- at least one branch targeting that label must have `mac_yield_load()` in its BD-slot.
--- 3. `mac_yield_tail()` as the atom-end terminator (last token) is a WARNING, not an error
--- (the safe default for atom-endings is `mac_yield()` which re-loads `R_AtomJmp`).
---
--- Per-atom. Runtime-helper atoms (`debug_skip`) are exempt.
--- Takes `(atom, pipe_ctx, findings)`; `pipe_ctx` is unused.
local function check_yield_load_tail_pairing(atom, _pipe_ctx, findings)
if atom.kind ~= "atom" then return end
if is_runtime_helper(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 n = #tokens
local function line_for(idx)
return atom.line + line_in_body[tokens[idx].rel]
end
-- ── Rule 1: every `mac_yield_load()` must be in a branch BD-slot.
for tok_idx = 1, n do
local c = tc[tok_idx]
if c.ident == "mac_yield_load" then
if tok_idx < 2 or not tc[tok_idx - 1].is_branch then
local prev_ident = (tok_idx >= 2) and (tc[tok_idx - 1].ident or "?") or "<none>"
findings[#findings + 1] = {
atom = atom.name,
line = tok_idx >= 2 and line_for(tok_idx) or atom.line,
check = "yield_load_tail_pairing",
kind = "error",
msg = string.format(
"%s at line %d has `mac_yield_load()` at word %d but the previous token is `%s`, not a branch — `mac_yield_load()` must fill a branch BD-slot."
, atom.name, tok_idx >= 2 and line_for(tok_idx) or atom.line, tok_idx, prev_ident),
}
end
end
end
-- ── Rule 2: every `mac_yield_tail()` must be at a labeled target whose branch BD-slot is `mac_yield_load()`.
for tok_idx = 1, n do
local c = tc[tok_idx]
if c.ident ~= "mac_yield_tail" then goto continue end
-- The immediately preceding token must be an `atom_label()` (no instructions between them).
local prev_idx = tok_idx - 1
if prev_idx < 1 or not tc[prev_idx].is_atom_label then
if tok_idx == n then
-- Atom-ending case: last token is `mac_yield_tail()` without a preceding label. WARNING.
findings[#findings + 1] = {
atom = atom.name,
line = line_for(tok_idx),
check = "yield_load_tail_pairing",
kind = "warning",
msg = string.format(
"%s at line %d has `mac_yield_tail()` as the atom-end terminator. The safe default for atom-endings is `mac_yield()` (which re-loads R_AtomJmp). The split is for BD-slot fill, not atom-endings."
, atom.name, line_for(tok_idx)),
}
else
findings[#findings + 1] = {
atom = atom.name,
line = line_for(tok_idx),
check = "yield_load_tail_pairing",
kind = "error",
msg = string.format(
"%s at line %d has `mac_yield_tail()` at word %d but it's not the first instruction after an `atom_label()` — `mac_yield_tail()` must be the first token of its target label's body."
, atom.name, line_for(tok_idx), tok_idx),
}
end
goto continue
end
local label_name = tc[prev_idx].label_name
-- Find at least one branch targeting `label_name` whose BD-slot is `mac_yield_load()`.
local found_pairing = false
for branch_idx = 1, n do
local bt = tc[branch_idx]
if bt.is_branch and bt.branch_label == label_name then
local bd_idx = branch_idx + 1
local bd_tc = bd_idx <= n and tc[bd_idx] or nil
if bd_tc and bd_tc.ident == "mac_yield_load" then
found_pairing = true
else
findings[#findings + 1] = {
atom = atom.name,
line = line_for(branch_idx),
check = "yield_load_tail_pairing",
kind = "error",
msg = string.format(
"%s at line %d has `mac_yield_tail()` at label `%s` (word %d) but the branch targeting it (at word %d) has BD-slot `%s` instead of `mac_yield_load()`."
, atom.name, line_for(branch_idx), label_name, tok_idx, branch_idx, bd_tc and bd_tc.ident or "?"),
}
end
end
end
if not found_pairing then
findings[#findings + 1] = {
atom = atom.name,
line = line_for(tok_idx),
check = "yield_load_tail_pairing",
kind = "error",
msg = string.format(
"%s at line %d has `mac_yield_tail()` at label `%s` but no branch in the body targets this label with `mac_yield_load()` in its BD-slot — R_AtomJmp would not be loaded."
, atom.name, line_for(tok_idx), label_name),
}
end
::continue::
end
end
-- ════════════════════════════════════════════════════════════════════════════
-- Check #4: 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_(<Binds_X>, <field>))
if tc_entry.is_load 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 registered in `pipe_ctx.components_by_name` emit a "new macro;
--- Not in corpus.components" advisory — the auto-derivation returned nil for that name.
---
--- 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
-- gp0_contrib is auto-derived from the original `MipsAtomComp_(ac_format_<shape>_color) { body }` body
-- in `passes/components.lua::compute_components_metadata` and stored on `corpus.components`.
local comp = pipe_ctx.components_by_name["format_" .. shape .. "_color"]
local n = comp and comp.gp0_contrib
if n then contrib = contrib + n end
end
if tc_entry.is_gte_store then
-- `tc_entry.ident` is the macro-variant form (`mac_gte_store_f3`); strip the `mac_` prefix for the bare-name corpus lookup.
local bare = tc_entry.ident:sub(#"mac_" + 1)
local comp = pipe_ctx.components_by_name[bare]
local n = comp and comp.gp0_contrib
if n then contrib = contrib + n end
end
if tc_entry.is_ot_tag then
local bare = tc_entry.ident:sub(#"mac_" + 1)
local comp = pipe_ctx.components_by_name[bare]
local n = comp and comp.gp0_contrib
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. Three control-transfer kinds are recognized (set by `classify_tokens`):
--- * `branch_*` (conditional): 2 successors — fall-through (BD slot absorbed) + taken (if `atom_offset` target known).
--- * `jump` / `call_addr` (unconditional absolute): 1 successor — taken only (BD slot absorbed into the cost).
--- * `jump_reg` / `call_reg` / `jump_link` (register-form): terminator — transfers control OUT of the current atom (e.g. `mac_yield()` ends in `jump_reg(R_AtomJmp), nop`).
---
--- The BD-slot nop after ANY of these (conditional branch, unconditional jump, terminal jump) is absorbed into the control-transfer's cost
--- (MIPS-accurate: the BD slot always runs) and is SKIPPED in the successor list (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 (conditional + unconditional-with-offset)
--- paths - number of distinct paths reached (terminated at mac_yield / terminal_jump / 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 ident names with no cost lookup: non-`mac_*` idents not in `duffle.INSTRUCTION_LATENCY`,
--- plus `mac_*` idents whose bare name is missing from `pipe_ctx.components_by_name` (i.e. no `MipsAtomComp_` for it).
local function analyze_atom_paths(atom, pipe_ctx)
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).
-- `branches` keys both `branch_*` (conditional) and `jump`/`call_addr` (unconditional absolute);
-- The latter resolve via `tc[tok_idx].branch_label` the same way (the offsets pass produces a valid relative offset for both).
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).
-- For non-`mac_*` tokens: lookup `duffle.INSTRUCTION_LATENCY[c.ident]` directly.
-- For `mac_*` tokens: lookup `pipe_ctx.components_by_name[bare_name].cycle_cost`, which `passes/components.lua::compute_components_metadata` derived from the originals
-- `MipsAtomComp_(ac_X) { body }` definition (sum of `INSTRUCTION_LATENCY` per emitted instruction,
-- recursing through nested `mac_*` calls). `mac_yield` is special-cased to 0 by `compute_components_metadata` (the runtime cost lands in the next atom's prologue).
local costs = {}
local unknown_set = {}
for tok_idx = 1, n do
local c = tc[tok_idx]
local ident = c.ident
local cost
if ident:sub(1, #"mac_") == "mac_" then
-- `mac_*` token: lookup corpus.components[bare_name].cycle_cost.
local bare = ident:sub(#"mac_" + 1)
local comp = pipe_ctx.components_by_name and pipe_ctx.components_by_name[bare]
if comp and comp.cycle_cost ~= nil then
cost = comp.cycle_cost
else
cost = duffle.UNKNOWN_INSTRUCTION_CYCLES
unknown_set[ident] = true
end
else
cost = duffle.INSTRUCTION_LATENCY[ident]
if cost == nil then
cost = duffle.UNKNOWN_INSTRUCTION_CYCLES
unknown_set[ident] = true
end
end
costs[tok_idx] = cost
end
-- Three control-transfer predicates (set by `classify_tokens`):
-- is_terminator — path ends here (`mac_yield` or register-form jump); empty successors.
-- is_branch — has an immediate offset (`branch_*`, `jump`, `call_addr`); 1-2 successors depending on unconditional_jump.
-- is_unconditional_jump — when is_branch is also true: skip fall-through (target only).
local function is_terminator(tok_idx)
local c = tc[tok_idx]
return c.is_yield or c.is_terminal_jump
end
local function is_branch(tok_idx) return tc[tok_idx].is_branch end
local function is_unconditional_jump(tok_idx) return tc[tok_idx].is_unconditional_jump 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 = {}
if is_unconditional_jump(tok_idx) then
-- Unconditional absolute jump / call: BD slot absorbed; single successor — the taken path.
-- The instruction word after the BD slot is unreachable in this atom's execution.
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 jumps (label == false), the target is a non-tracked address; conservatively omit.
return succ, nil
end
-- Conditional branch: BD slot absorbed; two successors — fall-through (tok_idx+2) + taken (if known).
if tok_idx + 2 <= n then
succ[#succ + 1] = tok_idx + 2
end
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 ANY control-transfer (conditional branch, unconditional jump, terminal jump),
-- ADD the BD-slot cost too — MIPS-accurate: the BD slot always runs. Skip the BD slot in the successor list (already done in `successors` above;
-- for the 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) or is_terminator(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 control-transfer tokens with an immediate offset (`branch_*` + `jump` + `call_addr`).
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 the same diagnostic).
--- Per-atom: emit one finding per unknown macro seen, deduplicated across atoms
--- (so the warning section doesn't get spammed with N copies of the same diagnostic).
--- 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` with no cycle_cost lookup; "
.. "cycle count will be +%d per call (best-case). For `mac_*` idents, ensure the "
.. "corresponding `MipsAtomComp_(ac_X)` is in scope of the build so "
.. "`passes/components.lua::compute_components_metadata` can derive its cost; "
.. "for non-`mac_*` idents, 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, <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) 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 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> }.
-- 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_role_mismatch", per_atom = check_gte_role_mismatch },
{ 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 = "load_delay_violation", per_atom = check_load_delay_slots },
{ name = "mac_yield_uniformity", per_atom = check_mac_yield_uniformity },
{ name = "yield_load_tail_pairing", per_atom = check_yield_load_tail_pairing },
{ 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 {},
-- Per-component metadata (cycle_cost + gp0_contrib) auto-derived from the original
-- `MipsAtomComp_` body by `passes/components.lua::compute_components_metadata`.
-- Keyed by bare name (e.g. `format_f3_color`, `gte_store_f3`); the `mac_` prefix at call sites is stripped before lookup.
components_by_name = corpus.components 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,
-- Per-component metadata (cycle_cost + gp0_contrib) auto-derived from the original `MipsAtomComp_` body by `passes/components.lua::compute_components_metadata`.
components_by_name = corpus_pipe_ctx.components_by_name,
}
-- 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`.
--- 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, pipe_ctx)
-- 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
-- ════════════════════════════════════════════════════════════════════════════
-- 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 "<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
-- Stash per-module results on the corpus for `report.lua` to consume.
-- Avoids re-running validate() in the report pass + avoids rebuilding corpus_pipe_ctx.
-- Pattern matches `corpus.atoms_by_name` / `corpus.word_counts` / `corpus.components`
-- (one writer: `static_analysis.lua`; one reader: `report.lua`).
-- Module basename = last component of `dir` ("code/duffle" -> "duffle").
local dir_basename = dir:match("([^/\\]+)$") or dir
corpus.static_analysis_results = corpus.static_analysis_results or {}
corpus.static_analysis_results[dir_basename] = {
atoms = all_atoms,
findings = all_findings,
errors = dir_errors,
warnings = dir_warnings,
info = dir_info,
summaries = dir_summaries,
sources = dir_sources,
}
-- Aggregate per-dir errors/warnings/info into the orchestrator totals.
-- Hoisted out of any per-dir file-emit so `report.lua` can drop the on-disk file emitter without losing the cross-module rollup.
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
-- (No per-dir emit: per-module findings are stashed on `corpus.static_analysis_results` above.
-- `report.lua` reads that projection to render `<module>.atom_meta_report.md` without re-running validate().)
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