From d56adab38fabec5a89e24aed443aa9bd46a73816 Mon Sep 17 00:00:00 2001 From: Ed_ Date: Thu, 23 Jul 2026 18:35:02 -0400 Subject: [PATCH] branch delay slot better support. Still reviewing. Need to see if gte is handled properly. --- code/gte_hello/hello_gte.c | 2 +- code/gte_hello/hello_gte_tape.c | 8 +- scripts/duffle.lua | 35 ++++- scripts/elf_dwarf.lua | 9 +- scripts/passes/static_analysis.lua | 242 +++++++++++++++++++++-------- 5 files changed, 218 insertions(+), 78 deletions(-) diff --git a/code/gte_hello/hello_gte.c b/code/gte_hello/hello_gte.c index 9302e9e..4a2e1b1 100644 --- a/code/gte_hello/hello_gte.c +++ b/code/gte_hello/hello_gte.c @@ -378,7 +378,7 @@ void update(PrimitiveArena* pa, U4* ordering_buf) smem.floor.rot.y += 5; } // --- TAPE DIAGNOSTICS --- - if (1) + if (0) { LP_ U4 mem_temp_tape[512]; FArena tape_arena; farena_init(& tape_arena, slice_ut_arr(mem_temp_tape)); TapeBuilder tb = tb_make_old(& tape_arena); tb_scope(& tb) { diff --git a/code/gte_hello/hello_gte_tape.c b/code/gte_hello/hello_gte_tape.c index 5d6fbbb..319e40e 100644 --- a/code/gte_hello/hello_gte_tape.c +++ b/code/gte_hello/hello_gte_tape.c @@ -112,18 +112,18 @@ MipsAtom_(floor_f3_face) atom_info(atom_phase(floor_f3) mac_load_tri_indices( R_T0, R_T1, R_T2), mac_gte_load_tri_verts(R_T0, R_T1, R_T2), nop2, gte_cmdw_rotate_translate_perspective_triple, // 2 nops retire the final cpu -> gte writes before RTPT - /*nop2,*/ gte_cmdw_nclip, + gte_cmdw_nclip, /* Culling (Branch forward if Backface) */ - /*nop2,*/ gte_mv_from_data_r(R_T0, C2_MAC0), + gte_mv_from_data_r(R_T0, C2_MAC0), nop, branch_le_zero(R_T0, atom_offset(culling, floor_f3_face_exit)), nop, // required gte -> cpu load-delay slot. /* Format Primitive */ mac_format_f3_color(0xFF, 0xFF, 0xFF), // RGB-form (R=FF, G=FF, B=FF = white) mac_gte_store_f3_post_rtpt(), /* Calculate Depth */ - /*nop2,*/ gte_avg_sort_z3, - /*nop2,*/ gte_mv_from_data_r(R_T1, C2_OTZ), + gte_avg_sort_z3, + gte_mv_from_data_r(R_T1, C2_OTZ), /* Bounds Check OTZ < 2048 (Branch forward to skip insertion) */ add_ui( R_AT, R_0, OrderingTbl_Len), set_lt_u( R_AT, R_T1, R_AT), diff --git a/scripts/duffle.lua b/scripts/duffle.lua index 41dab09..e0489aa 100644 --- a/scripts/duffle.lua +++ b/scripts/duffle.lua @@ -1136,6 +1136,37 @@ M.INSTRUCTION_LATENCY = { -- advisory so the cycle budget stays accurate as the codebase grows. M.UNKNOWN_INSTRUCTION_CYCLES = 1 +-- Control-transfer (branch/jump/call) delay-slot policy table. +-- +-- Used by the emitted-word delay-slot check to identify which emitted machine-word idents +-- are control transfers whose next emitted word is the hardware delay slot. +-- One table row per emitted encoder; the `family` field is informational (informational only; +-- the check matches by `event.ident` against the row keys). +-- `suppress_arg1` (when present) lists first-arg values that should NOT emit a finding even when the next emitted word is +-- `nop` or absent — e.g. the fixed `mac_yield()` handshake uses `jump_reg(R_AtomJmp), nop` and is intentionally suppressed. +-- +-- Consumers: +-- * passes/static_analysis.lua::check_control_transfer_delay_slot_use +-- No other pass consumes this table as of 2026-07-23. +M.CONTROL_TRANSFER_DELAY_SLOT_POLICIES = { + branch_equal = { family = "branch" }, + branch_ne = { family = "branch" }, + branch_lt_zero = { family = "branch" }, + branch_ge_zero = { family = "branch" }, + branch_le_zero = { family = "branch" }, + branch_gt_zero = { family = "branch" }, + jump = { family = "jump" }, + jump_reg = { + family = "jump", + -- The fixed `mac_yield()` 4-word handshake (defined in `lottes_tape.h`) ends in `jump_reg(R_AtomJmp), nop`. + -- The `nop` is structural, not an optimization opportunity — suppress it so the check stays signal-only. + suppress_arg1 = { R_AtomJmp = "fixed mac_yield handshake" }, + }, + jump_link = { family = "call" }, + call_reg = { family = "call" }, + call_addr = { family = "call" }, +} + -- ════════════════════════════════════════════════════════════════════════════ -- Section 8: Cross-source component-body index + word-event expansion -- ════════════════════════════════════════════════════════════════════════════ @@ -1333,8 +1364,8 @@ function M.expand_word_events(body_entry, component_index, word_counts) else visiting[bare] = true local inner = component_index[bare] - -- `call_source` / `call_line` (the ROOT atom site) are PRESERVED — we do NOT - -- update them when recursing. Nested events keep pointing at the original root atom. + -- `call_source` / `call_line` (the ROOT atom site) are PRESERVED — we do NOT update them when recursing. + -- Nested events keep pointing at the original root atom. expand(inner.body_tokens, inner.body_off, inner.line_of, inner.source, call_source, call_line, visiting) visiting[bare] = nil diff --git a/scripts/elf_dwarf.lua b/scripts/elf_dwarf.lua index 4c7ebde..cff1d2a 100644 --- a/scripts/elf_dwarf.lua +++ b/scripts/elf_dwarf.lua @@ -404,10 +404,8 @@ local function read_form_value(buf, str_buf, pos, form) end --- Read a `DW_FORM_ref_sig8` value at 0-based offset `pos` from `buf`. ---- Returns the low 4 bytes (LE) as `low`, the high 4 bytes (LE) as `high`, and ---- the cursor position after the 8-byte value as `next_pos`. ---- Callers that need the full type-unit + type-offset pair ---- (e.g. to resolve a type identifier embedded as a signature) +--- Returns the low 4 bytes (LE) as `low`, the high 4 bytes (LE) as `high`, and the cursor position after the 8-byte value as `next_pos`. +--- Callers that need the full type-unit + type-offset pair (e.g. to resolve a type identifier embedded as a signature) --- should use this directly rather than going through `read_form_value`, --- which only exposes the low 4 bytes to preserve its existing (value, next_pos) return shape. --- @param buf string @@ -427,8 +425,7 @@ end -- -- Unit header layout (from pos 0): -- unit_length(4) + version(2) + unit_type(1) + address_size(1) + debug_abbrev_offset(4) --- -- followed by type_unit_specific fields: --- type_signature(8) + type_offset(4) +-- followed by type_unit_specific fields: type_signature(8) + type_offset(4) -- The type_signature is at byte offset 8 of the body (right after debug_abbrev_offset). -- @param info string -- the .debug_info section bytes -- @param target_sig_lo integer -- low 4 bytes (LE) of the desired signature diff --git a/scripts/passes/static_analysis.lua b/scripts/passes/static_analysis.lua index 532d6ad..3ef3b66 100644 --- a/scripts/passes/static_analysis.lua +++ b/scripts/passes/static_analysis.lua @@ -6,23 +6,36 @@ --- before any subsequent `gte_cmdw_*` consumes one of the command's input registers (`duffle.GTE_COMMAND_INPUTS`). --- The check walks `atom.paths.word_events` (the semantic emitted-word stream). Every emitted machine word --- (including CPU ALU, branch, GTE transfer, `nop`/`nop2` half, and `mac_X(...)`-expanded words) counts as one retired slot. ---- 2. cop2_gpr_load_delay: Every `gte_mv_from_data_r` / `gte_mv_from_ctrl_r` (COP2-to-GPR read) requires 1 retired slot ---- before the destination GPR can be used as an operand of the next instruction. +--- 2. cop2_gpr_load_delay: Every `gte_mv_from_data_r` / `gte_mv_from_ctrl_r` (COP2-to-GPR read) +--- requires 1 retired slot before the destination GPR can be used as an operand of the next instruction. --- The check walks `atom.paths.word_events` and consults `duffle.OPERAND_READ_POSITIONS` to classify which emitted tokens read each GPR operand position. ---- Branch delay slots are out of scope (separate MIPS control-flow concern). ---- 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 +--- Branch delay slots are out of scope (separate MIPS control-flow concern; tracked by check #3 below). +--- 3. control_transfer_delay_slot_use: For every emitted branch/jump/call encoder in `duffle.CONTROL_TRANSFER_DELAY_SLOT_POLICIES` +--- (the six `branch_*` encoders plus `jump` / `jump_reg` / `jump_link` / `call_reg` / `call_addr`), +--- inspect the next emitted event in `atom.paths.word_events`. +--- Emit an `info`-severity finding when the successor is `nop` or absent (the next emitted word IS the hardware delay slot). +--- `jump_reg(R_AtomJmp)` is suppressed by policy (the fixed `mac_yield()` handshake). +--- `nop2` needs no special case: `expand_word_events` emits two `nop` events for it, so the first expansion is the hardware delay slot. +--- `atom_label` also needs no special case (zero events). +--- 4. mac_yield uniformity: Every atom body must contain exactly one `mac_yield()` call (control transfer pattern). +--- 5. Binding handoff: Every `atom_bind(Binds_X)` must reference a `typedef Struct_(Binds_X) { ... }` declaration. +--- 6. GPU Port-Store Shape: Per-shape (`f3`/`f4`/`g4`/etc.) the sum of `mac_format_X_color` + `mac_gte_store_X_*` + `mac_insert_ot_tag_X` words --- must equal the GP0 cmd's expected packet size. ---- 6. Per-Atom Cycle Budget: Sum each atom body's instruction latencies (per `duffle.INSTRUCTION_LATENCY`); report total. +--- 7. Per-Atom Cycle Budget: Sum each atom body's instruction latencies (per `duffle.INSTRUCTION_LATENCY`); report total. --- --- Per-source rules (registry-driven): ---- 7. enum_alias_membership: Every `R_X` referenced from `atom_dbg_reg_default`, `atom_reg_types`, `atom_type(...)`, `atom_reads`, or `atom_writes` +--- 8. enum_alias_membership: Every `R_X` referenced from `atom_dbg_reg_default`, `atom_reg_types`, `atom_type(...)`, `atom_reads`, or `atom_writes` --- must be in `scan.register_alias_registry`. ---- 8. atom_type_consistency: Every `reg_type_overrides[R_X].type_name` must resolve in `scan.type_name_registry`. ---- 9. binds_no_substruct_deref: Every `load_word(R_A, R_B, O_(Type, Field))` and `store_word(...)` in every atom body must reference a leaf scalar (pointer-to-struct counts as leaf; ---- nested struct members do NOT). ---- 10. reads_writes_alias_membership: Distinct check name duplicating #7's reads/writes coverage so the report can attribute failures to a precedence class. +--- 9. atom_type_consistency: Every `reg_type_overrides[R_X].type_name` must resolve in `scan.type_name_registry`. +--- 10. binds_no_substruct_deref: Every `load_word(R_A, R_B, O_(Type, Field))` and `store_word(...)` in every atom body must reference a leaf scalar +--- (pointer-to-struct counts as leaf; nested struct members do NOT). +--- 11. reads_writes_alias_membership: Distinct check name duplicating #8's reads/writes coverage so the report can attribute failures to a precedence class. +--- +--- Findings carry an explicit `kind` ("error" / "warning" / "info"). +--- The renderer maintains three independent severity collections; `info` is never folded into warnings. +--- Scan/cycle summary rows are kept in a separate `summaries` collection (rendered as trailing summary lines, not findings). +--- The report header now includes `Info: N` alongside Findings / Errors / Warnings, and a dedicated +--- `── Info` section renders finding-level info between `── Warnings` and the per-atom cycle counts. --- --- The orchestrator (`ps1_meta.lua`) wires this module in via the PASSES table: --- `["static-analysis"] = { @@ -31,8 +44,8 @@ --- deps = {"word-counts", "components"}, --- out = { { kind = "report", path_template = "/.static_analysis.txt" } } --- } ---- `kind = "diagnostic"` keeps every finding visible in the report; the orchestrator does not exit non-zero ---- on static-analysis errors. Annotation and header-output validation remain build-stopping. +--- `kind = "diagnostic"` keeps every finding visible in the report; the orchestrator does not exit non-zero on static-analysis errors. +--- Annotation and header-output validation remain build-stopping. --- --- **Conventions**: tabs (1/level), EmmyLua annotations, no regex, Lua 5.3 compatible. @@ -103,9 +116,10 @@ local OUTPUT_EXTENSION = ".static_analysis.txt" --- @field outputs table[] --- @field errors table[] --- @field warnings table[] +--- @field info table[] -- finding-level info (kind == "info"); distinct from per-source scanned/cycles summary rows --- @alias AtomName string -- lower_snake_case atom nameMacroName string -- lower_snake_case macro identifier ---- @alias CheckName string -- "gte_pipeline_fill" | "mac_yield_uniformity" | "abi_handoff" | "gpu_port_store_shape" | "per_atom_cycle_budget" +--- @alias CheckName string -- "gte_write_retire" | "cop2_gpr_load_delay" | "control_transfer_delay_slot_use" | "mac_yield_uniformity" | "abi_handoff" | "gpu_portstore_shape" | "per_atom_cycle_budget" --- @class AtomBody --- @field line integer -- source line of the atom declaration @@ -397,6 +411,7 @@ end -- within the same instruction slot emits a finding with `kind = "error"`. -- -- Branch delay slots + BD-slot absorption are out of scope (separate MIPS control-flow concern). +-- A separate check (control_transfer_delay_slot_use, below) covers branch/jump/call delay slots. -- ───────────────────────────────────────────────────────────────────────── -- Return the textual ident of the GPR read at `pos` in the macro's argument list, or nil if the operand is not a GPR. @@ -467,6 +482,56 @@ local function check_cop2_gpr_load_delay(atom, pipe_ctx, findings) end end +-- ───────────────────────────────────────────────────────────────────────── +-- Check #1c: control-transfer delay-slot use. +-- +-- Reads `atom.paths.word_events` (the semantic emitted-word stream from `duffle.expand_word_events`). +-- For each event whose `ident` is in `duffle.CONTROL_TRANSFER_DELAY_SLOT_POLICIES`, inspect the next +-- emitted event in the SAME `events` array. The next event is the hardware delay-slot word +-- (the duffle pipeline already absorbs the BD-slot into the branch's cost in `analyze_atom_paths`; +-- this check observes, it does not reschedule). +-- +-- Emit one `info`-severity finding when: +-- * the successor event is absent (no following emitted word); `slot_ident` is reported as ``; OR +-- * the successor event's `ident == "nop"` (the first emitted word of `nop2` is also `nop`). +-- +-- Suppress the finding when `policy.suppress_arg1[first_arg]` is non-nil — the only current +-- suppression is `jump_reg(R_AtomJmp)`, the fixed `mac_yield()` handshake. +-- +-- `pipe_ctx` is unused; the uniform `(atom, pipe_ctx, findings)` signature is preserved so the check +-- plugs into the existing CHECK_RULES dispatch without modifying the per-atom loop or analyze_atom_paths. +-- `expand_word_events` already normalizes `nop2` to two `nop` events and `atom_label` to zero events, +-- so no special-case branching is needed for either. +-- ───────────────────────────────────────────────────────────────────────── + +local function check_control_transfer_delay_slot_use(atom, pipe_ctx, findings) + local events = atom.paths.word_events or {} + if not events or #events == 0 then return end + local policies = duffle.CONTROL_TRANSFER_DELAY_SLOT_POLICIES or {} + for event_idx, event in ipairs(events) do + local policy = policies[event.ident] + if policy then + local arg1 = event.args and event.args[1] or nil + local suppressed = policy.suppress_arg1 and policy.suppress_arg1[arg1] or nil + if not suppressed then + local slot = events[event_idx + 1] + if slot == nil or slot.ident == "nop" then + local slot_ident = slot and slot.ident or "" + findings[#findings + 1] = { + atom = atom.name, + line = event.line, + check = "control_transfer_delay_slot_use", + kind = "info", + msg = string.format( + "%s at line %d has `%s` whose emitted delay-slot word is `%s`; useful work may replace that no-op if its dependencies are valid on both paths", + atom.name, event.line, event.ident, slot_ident), + } + end + end + end + end +end + -- ════════════════════════════════════════════════════════════════════════════ -- Check #2: mac_yield uniformity -- ════════════════════════════════════════════════════════════════════════════ @@ -917,8 +982,8 @@ end -- -- 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. +-- sources use R_* aliases in atom_reads / atom_writes that may not yet be opted in via the bare `atom_reg` marker. +-- R_TapePtr / R_AtomJmp / R_PrimCursor / R_FaceCursor / R_VertBase / R_OtBase ARE opted in. -- Raw C-ABI aliases like R_T0..R_T3 are intentionally NOT auto-included (per the prototype principle: -- no auto-include of wave-context; explicit opt-in only). Warnings keep the build green -- and surface the migration gap so users see which atoms still need opt-in registration. @@ -1161,16 +1226,17 @@ end -- This is the plex pattern: the iteration is in ONE place (validate), the variation is in DATA (this table). local CHECK_RULES = { - { name = "gte_write_retire", per_atom = check_gte_write_retire }, - { name = "cop2_gpr_load_delay", per_atom = check_cop2_gpr_load_delay }, - { name = "mac_yield_uniformity", per_atom = check_mac_yield_uniformity }, - { name = "abi_handoff", per_atom = check_abi_handoff }, - { name = "gpu_portstore_shape", per_atom = check_gpu_portstore_shape }, - { name = "per_atom_cycle_budget", per_atom = check_per_atom_cycle_budget }, - { name = "enum_alias_membership", per_source = check_enum_alias_membership }, - { name = "atom_type_consistency", per_source = check_atom_type_consistency }, - { name = "binds_no_substruct_deref", per_source = check_binds_no_substruct_deref }, - { name = "reads_writes_alias_membership",per_source = check_reads_writes_alias_membership}, + { name = "gte_write_retire", per_atom = check_gte_write_retire }, + { name = "cop2_gpr_load_delay", per_atom = check_cop2_gpr_load_delay }, + { name = "control_transfer_delay_slot_use",per_atom = check_control_transfer_delay_slot_use}, + { name = "mac_yield_uniformity", per_atom = check_mac_yield_uniformity }, + { name = "abi_handoff", per_atom = check_abi_handoff }, + { name = "gpu_portstore_shape", per_atom = check_gpu_portstore_shape }, + { name = "per_atom_cycle_budget", per_atom = check_per_atom_cycle_budget }, + { name = "enum_alias_membership", per_source = check_enum_alias_membership }, + { name = "atom_type_consistency", per_source = check_atom_type_consistency }, + { name = "binds_no_substruct_deref", per_source = check_binds_no_substruct_deref }, + { name = "reads_writes_alias_membership", per_source = check_reads_writes_alias_membership }, } -- ════════════════════════════════════════════════════════════════════════════ @@ -1265,24 +1331,38 @@ register_alias_registry = scan.register_alias_registry or {}, if rule.per_source then rule.per_source(src, pipe_ctx, findings) end end + -- Three-way severity binning: per-finding severity is set by the check via `f.kind`. + -- "error" / "warning" / "info" are all distinct; info findings are NEVER folded into warnings. + -- (Pre-2026-07-23 the binner treated everything non-error as a warning, which made the + -- control-transfer delay-slot check indistinguishable from real warnings in the report.) + -- The `info` list returned here is finding-level only; scan/cycle summary lines go into `summaries`. + -- An invalid/missing kind is a hard error (no silent fallback to info); this prevents typos like + -- kind="warn" or omitted kind fields from being misclassified as info in the rendered report. local errors = {} local warnings = {} local info = {} for _, f in ipairs(findings) do - -- Per-finding severity is set by the check via `f.kind` ("error" or "warning"). - -- A `gte_pipeline_fill` finding can be either severity (errors for missing nops; warnings for unknown cmdw macros not in the latency table). - -- Bin by `kind`, not by check name. - if f.kind == "error" then errors [#errors + 1] = { line = f.line, msg = f.msg } - else warnings[#warnings + 1] = { line = f.line, msg = f.msg } + if f.kind == "error" then errors [#errors + 1] = { line = f.line, msg = f.msg } + elseif f.kind == "warning" then warnings[#warnings + 1] = { line = f.line, msg = f.msg } + elseif f.kind == "info" then info [#info + 1] = { line = f.line, msg = f.msg } + else + error(string.format( + "invalid finding kind %s for check %q (atom=%s, line=%d); expected one of \"error\", \"warning\", \"info\"", + tostring(f.kind), tostring(f.check), tostring(f.atom), f.line or 0), 0) end end - -- Per-source "scanned:" summary line. - -- Includes the source basename for traceability + + -- Per-source "scanned:" / "cycles:" summary lines. These are SCANNER / BUDGET rollups, + -- not findings — they belong in their own collection so the report can render them + -- AS summary rows (after Module findings) rather than mixed into the Info finding section. + local summaries = {} + -- Per-source "scanned:" summary line. + -- Includes the source basename for traceability -- (the old format was just "scanned: N atom bodies; M findings" which is unidentifiable when the module has multiple sources). - -- Sources with 0 atoms (pure-header files like dsl.h, mips.h, etc.) are SKIPPED. + -- 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 - info[#info + 1] = { + summaries[#summaries + 1] = { line = 0, msg = string.format("scanned: %s: %d atom bodies; %d findings", src.basename, #atoms, #findings), @@ -1304,7 +1384,7 @@ register_alias_registry = scan.register_alias_registry or {}, max_atom_name = a.name end end - info[#info + 1] = { + 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), @@ -1312,11 +1392,12 @@ register_alias_registry = scan.register_alias_registry or {}, end return { - atoms = atoms, - findings = findings, - errors = errors, - warnings = warnings, - info = info, + atoms = atoms, + findings = findings, + errors = errors, + warnings = warnings, + info = info, + summaries = summaries, } end @@ -1324,10 +1405,13 @@ end -- Per-directory output: build/gen/.static_analysis.txt -- ════════════════════════════════════════════════════════════════════════════ ---- Per-directory emit. Aggregates atoms + findings across every source in `dir_sources` ---- and writes a single report to `/.static_analysis.txt`. +--- Per-directory emit. Aggregates atoms + findings across every source in `dir_sources` +--- and writes a single report to `/.static_analysis.txt`. --- Called only when at least one atom was found (the caller in M.run handles the skip). -local function emit_module_static_analysis_txt(ctx, dir, dir_sources, atoms, findings, errors, warnings, info) +--- +--- `info` is finding-level info only (kind == "info" findings); the scanned/cycles summary rows +--- live in `summaries` and are rendered as trailing summary lines after `Module findings:`. +local function emit_module_static_analysis_txt(ctx, dir, dir_sources, atoms, findings, errors, warnings, info, summaries) -- Module basename = last component of `dir` ("code/duffle" -> "duffle"). local dir_basename = dir:match("([^/\\]+)$") or dir local out_path = ctx.out_root .. "/" .. dir_basename .. ".static_analysis.txt" @@ -1359,8 +1443,10 @@ local function emit_module_static_analysis_txt(ctx, dir, dir_sources, atoms, fin header_atoms = header_atoms .. string.format(" (atoms: %d, comp_bare: %d, comp_proc: %d)", n_atoms - n_bare - n_proc, n_bare, n_proc) end - add(string.format("%s Findings: %d Errors: %d Warnings: %d", - header_atoms, #findings, #errors, #warnings)) + -- Header carries the per-severity counts; info is its own column, not a warning. + -- (`Info: N` is the byte-asserted field that the focused test matches; do not collapse it into Warnings.) + add(string.format("%s Findings: %d Errors: %d Warnings: %d Info: %d", + header_atoms, #findings, #errors, #warnings, #info)) add("") -- Group findings by atom (with source prefix when multi-source module) @@ -1404,6 +1490,16 @@ local function emit_module_static_analysis_txt(ctx, dir, dir_sources, atoms, fin add(string.format(" ! line %d %s", w.line, w.msg)) end + -- Finding-level Info section. + -- Rendered between Warnings and the per-atom cycle table so the next `── ` line after `── Info` is the per-atom cycle counts section; + -- the trailing scan/cycle summary rows (rendered after Module findings) stay outside this section. + add("") + add("── Info ────────────────────────────────────────────────") + if #info == 0 then add(" (none)") end + for _, i_ in ipairs(info) do + add(string.format(" i line %d %s", i_.line, i_.msg)) + end + -- Per-atom cycle counts (path-aware). For each atom: -- min = shortest path through the body (earliest exit) -- max = longest path through the body (full fall-through) @@ -1480,16 +1576,22 @@ local function emit_module_static_analysis_txt(ctx, dir, dir_sources, atoms, fin end -- Module-level findings summary (across all sources). + -- Info is its own count; it is NOT lumped into warnings. local total_errs = #errors local total_warns = #warnings + local total_infos = #info add("") - add(string.format("Module findings: %d error(s), %d warning(s)", total_errs, total_warns)) + add(string.format("Module findings: %d error(s), %d warning(s), %d info", total_errs, total_warns, total_infos)) - -- Per-source "scanned:" info lines (each line includes the source basename for traceability). - if #info > 0 then + -- Per-source "scanned:" / "cycles:" summary lines (each line includes the source basename for traceability). + -- These are kept SEPARATE from the finding-level Info section above so the report's Info section is signal-only + -- (true findings), not a mix of findings + rollups. + -- The downstream test (`test_control_transfer_delay_slot.lua`) + -- asserts that the Info section contains NEITHER `scanned:` NOR `cycles:` lines. + if summaries and #summaries > 0 then add("") - for _, i_ in ipairs(info) do - add(string.format(" %s", i_.msg)) + for _, s in ipairs(summaries) do + add(string.format(" %s", s.msg)) end end @@ -1511,53 +1613,63 @@ function M.run(ctx) local outputs = {} local errors = {} local warnings = {} + -- `info` aggregates finding-level info across every source (the per-source validate() also + -- returns a `summaries` collection for scan/cycle rollups; + -- those are NOT finding-level and never enter `info`). + local info = {} -- Aggregate per-DIRECTORY (per-module). - -- One static_analysis.txt per source-directory, emitted only if the directory contains at least one atom. + -- One static_analysis.txt per source-directory, emitted only if the directory contains at least one atom. -- Empty-source directories (e.g. duffle headers with no atoms) produce no report. -- Group sources by `src.dir`. The first component of `dir` is the module name (e.g. "code/duffle" -> "duffle", "code/gte_hello" -> "gte_hello"). -- Output path is `/.static_analysis.txt`. local by_dir = ctx.by_dir or duffle.group_sources_by_dir(ctx.sources) for dir, dir_sources in pairs(by_dir) do - -- Run validate() against every source in this directory; accumulate atoms / findings / errors / warnings. - -- The validate() function does its own per-source analysis (Binds indexing, atom discovery, all 5 checks) + -- 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 all_info = {} + local dir_info = {} + local dir_summaries = {} for _, src in ipairs(dir_sources) do local result = validate(ctx, src) - -- Tag each atom with its source so the render step can prefix the atom line with ":" + -- 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 all_info [#all_info + 1] = i_ end + for _, e in ipairs(result.errors) do dir_errors [#dir_errors + 1] = e end + for _, w in ipairs(result.warnings) do dir_warnings [#dir_warnings + 1] = w end + for _, i_ in ipairs(result.info) do dir_info [#dir_info + 1] = i_ end + for _, s in ipairs(result.summaries or {}) do dir_summaries[#dir_summaries + 1] = s end end -- Skip directories with zero atoms. A directory with only headers / no MipsAtom_ is "nothing to report". if #all_atoms == 0 then - -- Still aggregate errors/warnings so orchestrator sees them, but don't write a file. - for _, e in ipairs(dir_errors) do errors [#errors + 1] = e end + -- Still aggregate errors/warnings/info so orchestrator sees them, but don't write a file. + for _, e in ipairs(dir_errors) do errors [#errors + 1] = e end for _, w in ipairs(dir_warnings) do warnings[#warnings + 1] = w end + for _, i_ in ipairs(dir_info) do info[#info + 1] = i_ end else - local out_path = emit_module_static_analysis_txt(ctx, dir, dir_sources, all_atoms, all_findings, dir_errors, dir_warnings, all_info) + local out_path = emit_module_static_analysis_txt(ctx, dir, dir_sources, all_atoms, all_findings, dir_errors, dir_warnings, dir_info, dir_summaries) if out_path then table.insert(outputs, { static_analysis_txt = out_path }) end - for _, e in ipairs(dir_errors) do errors [#errors + 1] = e end + for _, e in ipairs(dir_errors) do errors [#errors + 1] = e end for _, w in ipairs(dir_warnings) do warnings[#warnings + 1] = w end + for _, i_ in ipairs(dir_info) do info[#info + 1] = i_ end end end - return { outputs = outputs, errors = errors, warnings = warnings } + -- 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