Files
pikuma_ps1/scripts/passes/annotation.lua
T
2026-07-10 23:36:44 -04:00

1003 lines
39 KiB
Lua

--- passes/annotation.lua — Atom-annotation DSL validator.
---
--- Validates `MipsAtom_(name) atom_info(atom_bind(Binds_X), atom_reads(...), atom_writes(...)) { ... }` declarations in source files.
--- Also reads: `Binds_*` struct declarations (`typedef Struct_(Binds_X) { ... };`)
---
--- Writes:
--- - `<ctx.out_root>/<dir_basename>.errors.h` — one per module, with `#error` directives on findings (the C compile will surface the error)
--- - The annotations.txt report is rendered by `passes/report.lua` from the per-module results stashed in `ctx.flags._annot_results`
---
--- **Conventions**: tabs (1/level), EmmyLua annotations, no regex,
--- Lua 5.3 compatible
-- ════════════════════════════════════════════════════════════════════════════
-- Module-scope requires + package.path setup
-- ════════════════════════════════════════════════════════════════════════════
-- Bootstrap: same as entry scripts. See `ps1_meta.lua` for the rationale.
dofile((arg[0]:match("(.*[/\\])") or "./") .. "duffle_paths.lua")
local duffle = require("duffle")
local is_space = duffle.is_space
local is_alpha = duffle.is_alpha
local is_alnum = duffle.is_alnum
local trim = duffle.trim
local find_byte = duffle.find_byte
local read_file = duffle.read_file
local write_file = duffle.write_file
local ensure_dir = duffle.ensure_dir
local dirname = duffle.dirname
local basename_no_ext = duffle.basename_no_ext
local skip_str_or_cmt = duffle.skip_str_or_cmt
local skip_ws_and_cmt = duffle.skip_ws_and_cmt
local read_ident = duffle.read_ident
local read_parens = duffle.read_parens
local read_braces = duffle.read_braces
local scan_to_char = duffle.scan_to_char
local split_top_level_commas = duffle.split_top_level_commas
-- Domain tables (single source of truth in duffle.lua).
local WAVE_CONTEXT_REGS = duffle.WAVE_CONTEXT_REGS
local TAPE_ATOM_MACROS = duffle.TAPE_ATOM_MACROS
local function is_wave_context_reg(n) return WAVE_CONTEXT_REGS[n] ~= nil end
-- ════════════════════════════════════════════════════════════════════════════
-- Constants
-- ════════════════════════════════════════════════════════════════════════════
-- Atom declaration + annotation identifiers.
local ATOM_DECL = "MipsAtom_"
local ATOM_INFO = "atom_info"
local STRUCT_TYPE = "Struct_"
local PRAGMA_IDENT = "pragma"
local PRAGMA_OPERATOR = "_Pragma"
-- Struct-name prefix + byte size of U4 fields.
local BINDS_PREFIX = "Binds_"
local BINDS_PREFIX_LEN = 6 -- = #BINDS_PREFIX
local U4_TYPE = "U4"
local U4_BYTES = 4 -- sizeof(U4)
local BINDS_FIELD_PREFIX = "R_" -- wave-context register name prefix
-- TAPE_WORDS pragma keys (the third token after #pragma).
local WORDS_KEY = "words"
local WORDS_KEY_PREFIX = "words=" -- the per-macro `words=N` form
local WORDS_KEY_PREFIX_LEN = 6 -- = #WORDS_KEY_PREFIX
local TAPE_ATOM_WORDS_KEY = "tape_atom words" -- the _Pragma form
-- ASCII byte values used in tokenization.
local BYTE_NEWLINE = 10
local BYTE_SPACE = 32
local BYTE_DQUOTE = 34
local BYTE_EQUALS = 61
local BYTE_OPEN_PAREN = 40
local BYTE_OPEN_BRACE = 123
local BYTE_OPEN_BRACK = 91
local BYTE_CLOSE_PAREN = 41
local BYTE_CLOSE_BRACE = 125
local BYTE_CLOSE_BRACK = 93
local BYTE_COMMA = 44
-- ════════════════════════════════════════════════════════════════════════════
-- Type declarations
-- ════════════════════════════════════════════════════════════════════════════
--- @class SourceFile
--- @field path string -- absolute path to the source file
--- @field text string -- the full source text
--- @field dir string -- the directory containing the source
--- @field basename string -- filename without extension
--- @class PassCtx
--- @field sources SourceFile[]
--- @field metadata_path string
--- @field shared table
--- @field shared.word_counts table<string, integer>
--- @field out_root string
--- @field project_root string
--- @field upstream table<string, table>
--- @field flags table
--- @field flags._annot_results table[] -- stashed by annotation pass; consumed by report.lua
--- @field dry_run boolean
--- @field verbose boolean
--- @class PassResult
--- @field outputs table[]
--- @field errors table[]
--- @field warnings table[]
--- @class Atom
--- @field line integer -- source line of the MipsAtom_ declaration
--- @field name string -- atom name (e.g. "cube_g4_face")
--- @class AtomAnnotation
--- @field line integer -- source line of the atom_info call
--- @field macro string -- the macro name (always "atom_info" in the new shape)
--- @field name string -- the atom name
--- @field kind string -- always "info"
--- @field binds string|nil -- Binds_X name if any
--- @field reads string[] -- R_* names (read targets)
--- @field writes string[] -- R_* names (write targets)
--- @field error string|nil -- error message if annotation was malformed
--- @field errors string[] -- nested errors from per-arg validation
--- @class BindsField
--- @field name string -- field name
--- @field offset integer -- byte offset within the Binds_X struct
--- @class BindsStruct
--- @field name string -- struct name (e.g. "Binds_Floor")
--- @field line integer -- source line of the typedef
--- @field bytes integer -- total byte size
--- @field fields BindsField[] -- the field list
--- @class MacroEntry
--- @field name string -- macro name (e.g. "mac_format_f3_color")
--- @field line integer -- source line of the TAPE_WORDS pragma
--- @field words integer -- declared word count
--- @class Finding
--- @field line integer -- source line (or 0 for pass-level)
--- @field msg string -- finding message
--- @class AnnotatedResult
--- @field atoms Atom[]
--- @field annots AtomAnnotation[]
--- @field macros MacroEntry[]
--- @field binds BindsStruct[]
--- @field errors Finding[]
--- @field warnings Finding[]
--- @field info Finding[]
--- @field pragmas table -- reserved (currently always nil; legacy compat)
--- ════════════════════════════════════════════════════════════════════════════
-- Hand-rolled split helpers (no regex patterns used)
-- ════════════════════════════════════════════════════════════════════════════
--- Split a string at top-level commas. Used inside TAPE_ATOM_* macro
--- bodies where nested parens/braces/brackets are possible.
--- @param s string
--- @return string[]
local function split_csv_top(s)
local tokens = {}
local pos = 1
local chunk_a = 1
local depth = 0
local str_len = #s
while pos <= str_len do
local ch = s:byte(pos)
if ch == BYTE_OPEN_PAREN or ch == BYTE_OPEN_BRACE or ch == BYTE_OPEN_BRACK then
depth = depth + 1
pos = pos + 1
elseif ch == BYTE_CLOSE_PAREN or ch == BYTE_CLOSE_BRACE or ch == BYTE_CLOSE_BRACK then
depth = depth - 1
pos = pos + 1
elseif ch == BYTE_COMMA and depth == 0 then
tokens[#tokens + 1] = s:sub(chunk_a, pos - 1)
pos = pos + 1
chunk_a = pos
else
pos = pos + 1
end
end
local last = s:sub(chunk_a)
if trim(last) ~= "" then tokens[#tokens + 1] = last end
return tokens
end
--- Split a string into whitespace-separated tokens.
--- Hand-rolled (no regex patterns).
--- @param s string
--- @return string[]
local function split_ws(s)
local tokens = {}
local pos = 1
local n = 1
local len = #s
while pos <= len do
-- Skip whitespace.
while pos <= len and is_space(s:sub(pos, pos)) do pos = pos + 1 end
if pos > len then break end
local chunk_a = pos
-- Take non-whitespace run.
while pos <= len and not is_space(s:sub(pos, pos)) do pos = pos + 1 end
tokens[n] = s:sub(chunk_a, pos - 1)
n = n + 1
end
return tokens
end
-- ════════════════════════════════════════════════════════════════════════════
-- Parse TAPE_ATOM_ANNOT(...) calls
-- ════════════════════════════════════════════════════════════════════════════
-- Recognize a `atom_bind(...)`, `atom_reads(...)`, or `atom_writes(...)`
-- sub-call embedded inside an atom_info arg list. Returns the kind
-- ("atom_bind" / "atom_reads" / "atom_writes") and the inner content,
-- or nil if the token isn't a recognized sub-call form. Flattened via
-- a prefix lookup instead of a nested if/elseif chain.
local REGS_CALL_PREFIX = {
["atom_writes("] = { kind = "atom_writes", inner_offset = 13 },
["atom_reads("] = { kind = "atom_reads", inner_offset = 12 },
["atom_bind("] = { kind = "atom_bind", inner_offset = 11, single_ident = true },
}
local function parse_regs_call(s)
if s:sub(-1) ~= ")" then return nil end
-- Try longest prefix first so "atom_writes(" wins over "atom_reads("
-- when both 12-char prefixes would otherwise match. Lengths:
-- atom_writes( = 12 chars, offset 13
-- atom_reads( = 11 chars, offset 12
-- atom_bind( = 10 chars, offset 11
local spec = REGS_CALL_PREFIX[s:sub(1, 12)]
if not spec then
spec = REGS_CALL_PREFIX[s:sub(1, 11)]
end
if not spec then
spec = REGS_CALL_PREFIX[s:sub(1, 10)]
end
if not spec then return nil end
local inner = s:sub(spec.inner_offset, -2)
if spec.single_ident then
-- atom_bind takes a single Binds_* type ident. Trim and pass through.
return spec.kind, trim(inner)
end
return spec.kind, inner
end
-- Resolve any phase_* / R_* alias macros in a register list.
-- (Phase / region / cadence aliases have been dropped. Kept as an
-- identity function so callers can stay uniform.)
local function resolve_reg_aliases(regs) return regs end
-- Parse a comma-separated inner content (e.g. inside atom_reads(...))
-- into a list of trimmed identifiers with aliases resolved.
local function parse_regs_list(inner)
local out = {}
for _, r in ipairs(split_csv_top(inner)) do
local trimmed = trim(r)
if trimmed ~= "" then out[#out + 1] = trimmed end
end
return resolve_reg_aliases(out)
end
-- Parse a single token (from split_csv_top) into an arg entry.
-- Three forms: register-list call, bare identifier,
-- "other" (preserved as text).
local function parse_arg_token(s)
local kind, inner = parse_regs_call(s)
if kind then
if kind == "atom_bind" then
return { kind = kind, value = inner } -- single ident, not a list
end
return { kind = kind, value = parse_regs_list(inner) }
end
local id = read_ident(s, 1)
if id and trim(s) == id then
return { kind = "ident", value = id }
end
return { kind = "other", value = s }
end
--- Extract identifier args from a parenthesized group. Returns a list
--- of {kind, value} pairs where kind is one of:
--- "ident" -- a bare identifier (e.g. phase_work)
--- "atom_reads" -- an atom_reads(...) call: value is the register list
--- "atom_writes" -- an atom_writes(...) call: value is the register list
--- "other" -- something we can't classify (preserved as text)
local function parse_atom_annot_args(inner)
local args = {}
for _, tok in ipairs(split_csv_top(inner)) do
local s = trim(tok)
if s ~= "" then
args[#args + 1] = parse_arg_token(s)
end
end
return args
end
-- ════════════════════════════════════════════════════════════════════════════
-- Parse TAPE_WORDS(mac_X, N) pragma directives
-- ════════════════════════════════════════════════════════════════════════════
--- Skip preprocessor directives (lines starting with `#`).
--- Returns the position past the newline at the end of the line.
--- @param source string
--- @param pos integer
--- @return integer
local function skip_preprocessor_line(source, pos)
local str_len = #source
local scan = pos
while scan <= str_len and source:byte(scan) ~= BYTE_NEWLINE do
scan = scan + 1
end
return scan + 1
end
--- Parse `_Pragma("mac_X tape_atom words=N")` (operator form).
--- @param source string
--- @param ident_pos integer -- position of the `_Pragma` ident
--- @param after_ident integer -- position just past the ident
--- @return MacroEntry|nil, integer -- (entry or nil, new source position)
local function parse_pragma_operator(source, ident_pos, after_ident)
local open_paren = skip_ws_and_cmt(source, after_ident)
if source:byte(open_paren) ~= BYTE_OPEN_PAREN then
return nil, open_paren + 1
end
local str, str_end = read_parens(source, open_paren)
str = trim(str)
if str:sub(1, 1) ~= '"' or str:sub(-1) ~= '"' then
return nil, str_end
end
local inner = str:sub(2, -2)
local space = find_byte(inner, BYTE_SPACE, 1)
if not space then return nil, str_end end
local name = inner:sub(1, space - 1)
local rest = inner:sub(space + 1)
local eq = find_byte(rest, BYTE_EQUALS, 1)
if not eq then return nil, str_end end
local key = trim(rest:sub(1, eq - 1))
local val = trim(rest:sub(eq + 1))
if key ~= TAPE_ATOM_WORDS_KEY and key ~= WORDS_KEY then return nil, str_end end
return {
line = source:sub(1, ident_pos) and 0 or 0, -- see line_of below
name = name,
words = tonumber(val) or 0,
}, str_end
end
--- Parse `#pragma mac_X tape_atom words=N` (directive form).
--- @param source string
--- @param ident_pos integer -- position of the `pragma` ident
--- @param after_ident integer -- position just past the ident
--- @return MacroEntry|nil, integer -- (entry or nil, new source position)
local function parse_pragma_directive(source, ident_pos, after_ident)
local str_len = #source
local rest_start = skip_ws_and_cmt(source, after_ident)
local eol = rest_start
while eol <= str_len and source:byte(eol) ~= BYTE_NEWLINE do
eol = eol + 1
end
local line_text = trim(source:sub(rest_start, eol - 1))
local tokens = split_ws(line_text)
local entry
if #tokens >= 3 and tokens[2] == "tape_atom" and tokens[3]:sub(1, WORDS_KEY_PREFIX_LEN) == WORDS_KEY_PREFIX then
entry = {
name = tokens[1],
words = tonumber(tokens[3]:sub(WORDS_KEY_PREFIX_LEN + 1)) or 0,
}
elseif #tokens >= 2 and tokens[2]:sub(1, WORDS_KEY_PREFIX_LEN) == WORDS_KEY_PREFIX then
entry = {
name = tokens[1],
words = tonumber(tokens[2]:sub(WORDS_KEY_PREFIX_LEN + 1)) or 0,
}
end
if entry then
local line_of = duffle.LineIndex(source)
entry.line = line_of(ident_pos)
end
return entry, eol
end
--- Find every `TAPE_WORDS(mac_X, N)` pragma in source.
--- Accepts both forms:
--- `_Pragma("mac_X tape_atom words=N")` (operator form)
--- `#pragma mac_X tape_atom words=N` (directive form)
--- @param source string
--- @return MacroEntry[]
local function find_macro_word_annotations(source)
local out = {}
local pos = 1
local str_len = #source
while pos <= str_len do
pos = skip_ws_and_cmt(source, pos)
if pos > str_len then break end
-- Skip preprocessor directives (lines starting with #).
if source:byte(pos) == 35 then -- '#'
pos = skip_preprocessor_line(source, pos)
else
local ident, after_ident = read_ident(source, pos)
if not ident then
pos = pos + 1
elseif ident == PRAGMA_OPERATOR then
local entry, new_pos = parse_pragma_operator(source, pos, after_ident)
if entry then
local line_of = duffle.LineIndex(source)
entry.line = line_of(pos)
out[#out + 1] = entry
end
pos = new_pos
elseif ident == PRAGMA_IDENT then
local entry, new_pos = parse_pragma_directive(source, pos, after_ident)
if entry then out[#out + 1] = entry end
pos = new_pos
else
pos = after_ident
end
end
end
return out
end
-- ════════════════════════════════════════════════════════════════════════════
-- Parse `typedef Struct_(Binds_X) { ... };` declarations
-- ════════════════════════════════════════════════════════════════════════════
--- Walk a `Binds_X` body string and extract U4 fields (name + byte offset).
--- Only U4 fields are tracked (Binds_* are always word arrays in this
--- codebase -- pointers stored as U4, indices as U4, etc.).
--- @param body string -- the brace-delimited body (without the braces)
--- @return BindsField[] -- field list
--- @return integer -- total byte size
local function parse_binds_body(body)
local fields = {}
local byte_off = 0
local pos = 1
local body_len = #body
while pos <= body_len do
pos = skip_ws_and_cmt(body, pos)
if pos > body_len then break end
local type_ident, type_after = read_ident(body, pos)
if not type_ident then
pos = pos + 1
elseif type_ident == U4_TYPE then
local field_after = skip_ws_and_cmt(body, type_after)
local fid, fafter = read_ident(body, field_after)
if fid then
fields[#fields + 1] = { name = fid, offset = byte_off }
byte_off = byte_off + U4_BYTES
end
pos = fafter or (type_after + 1)
else
pos = type_after + 1
end
end
return fields, byte_off
end
--- Try to parse a `typedef Struct_(Binds_X) { ... };` declaration.
--- Returns the parsed BindsStruct (if the form matched) and the new
--- source position. If the form didn't match, returns nil + a position
--- to continue scanning from.
--- @param source string
--- @param ident_pos integer -- position of the `typedef` ident start
--- @param after_typedef integer -- position just past `typedef`
--- @param line_of fun(pos: integer): integer
--- @return BindsStruct|nil, integer
local function parse_typedef_binds(source, ident_pos, after_typedef, line_of)
local after_type = skip_ws_and_cmt(source, after_typedef)
local type_ident, after_type_ident = read_ident(source, after_type)
if type_ident ~= STRUCT_TYPE then
return nil, after_type_ident or (after_type + 1)
end
local open_paren = skip_ws_and_cmt(source, after_type_ident)
if source:byte(open_paren) ~= BYTE_OPEN_PAREN then
return nil, open_paren + 1
end
local inner, after_paren = read_parens(source, open_paren)
local name = trim(inner)
local brace = scan_to_char(source, "{", after_paren)
if not brace then return nil, open_paren + 1 end
local body, after_brace = read_braces(source, brace)
local fields, bytes = parse_binds_body(body)
-- Only emit Binds_* structs (other Struct_ typedefs are ignored).
if name:sub(1, BINDS_PREFIX_LEN) ~= BINDS_PREFIX then
return nil, after_brace
end
return {
line = line_of(ident_pos),
name = name,
fields = fields,
bytes = bytes,
}, after_brace
end
--- Find every `Binds_*` struct declaration.
--- @param source string
--- @return BindsStruct[]
local function find_binds_structs(source)
local line_of = duffle.LineIndex(source)
local out = {}
local pos = 1
local str_len = #source
while pos <= str_len do
pos = skip_ws_and_cmt(source, pos)
if pos > str_len then break end
if source:byte(pos) == 35 then -- '#'
pos = skip_preprocessor_line(source, pos)
else
local ident, after_ident = read_ident(source, pos)
if not ident then
pos = pos + 1
elseif ident == "typedef" then
local binds_struct, new_pos = parse_typedef_binds(source, pos, after_ident, line_of)
if binds_struct then out[#out + 1] = binds_struct end
pos = new_pos
else
pos = after_ident
end
end
end
return out
end
-- ════════════════════════════════════════════════════════════════════════════
-- Find every MipsAtom_(name) { ... } declaration in source
-- ════════════════════════════════════════════════════════════════════════════
--- Read the next identifier token from `s` starting at `pos`, where the
--- identifier is a contiguous run of `[a-zA-Z0-9_]` characters (no
--- underscore-starting alpha-only constraint). Returns the ident + the
--- position just past it, or nil + pos if no identifier starts there.
--- @param s string
--- @param pos integer
--- @return string|nil, integer
local function read_alnum_ident(s, pos)
local str_len = #s; while pos <= str_len and is_space(s:sub(pos, pos)) do pos = pos + 1 end
local start = pos; while pos <= str_len and is_alnum(s:sub(pos, pos)) do pos = pos + 1 end
if pos == start then return nil, pos end
return s:sub(start, pos - 1), pos
end
--- Find every `MipsAtom_(name)` declaration in source. (Just the name +
--- source line; the body is parsed separately by `parse_mips_atom`.)
--- @param source string
--- @return Atom[]
local function find_atom_names(source)
local line_of = duffle.LineIndex(source)
local out = {}
local pos = 1
local str_len = #source
while pos <= str_len do
pos = skip_ws_and_cmt(source, pos)
if pos > str_len then break end
local ident, after_ident = read_ident(source, pos)
if not ident then
pos = pos + 1
elseif ident ~= ATOM_DECL then
pos = after_ident
else
local open_paren = skip_ws_and_cmt(source, after_ident)
if source:byte(open_paren) ~= BYTE_OPEN_PAREN then
pos = open_paren + 1
else
local inner, after_paren = read_parens(source, open_paren)
local name, _ = read_alnum_ident(inner, 1)
if name and name ~= "" then
out[#out + 1] = { line = line_of(pos), name = name }
end
local brace = scan_to_char(source, "{", after_paren)
if brace then
local _, after_brace = read_braces(source, brace)
pos = after_brace
else
pos = open_paren + 1
end
end
end
end
return out
end
-- ════════════════════════════════════════════════════════════════════════════
-- Find atom annotations (atom_annot / atom_init / atom_setup / atom_commit
-- / atom_bind / atom_terminate)
-- ════════════════════════════════════════════════════════════════════════════
--- True iff the parsed arg is a register-list call (any recognized form).
local function is_regs_arg(a) return a and (a.kind == "atom_reads" or a.kind == "atom_writes" or a.kind == "regs") end
--- Per-macro arg-shape handlers. Each takes (entry, args) and mutates Per-atom_info sub-call dispatch.
--- Each takes (entry, args) and mutates entry.{reads, writes, binds, errors}.
--- All sub-calls are order-independent; each is dispatched on its `kind` (atom_bind / atom_reads / atom_writes) when parsed.
local ANNOT_ARG_HANDLERS = {}
-- atom_info(atom_bind(Binds_X), atom_reads(...), atom_writes(...))
function ANNOT_ARG_HANDLERS.info(entry, args)
for _, arg in ipairs(args) do
if arg.kind == "atom_bind" then entry.binds = arg.value
elseif arg.kind == "atom_reads" then entry.reads = arg.value
elseif arg.kind == "atom_writes" then entry.writes = arg.value
elseif arg.kind == "ident" then
-- Reserved for future phase tokens. Currently ignored.
-- (Could be reintroduced as `phase_*` sub-calls of atom_info.)
else
entry.errors[#entry.errors + 1] = string.format("unexpected atom_info arg kind=%s value=%s", arg.kind, tostring(arg.value))
end
end
end
-- Build a new annotation entry with the standard shape.
local function new_annot_entry(line, ident, name, kind)
return {
line = line,
macro = ident,
name = name,
kind = kind,
binds = nil,
reads = {},
writes = {},
errors = {},
}
end
--- Try to parse an `atom_info(...)` call right after the `MipsAtom_(name)` parens.
--- Returns the annotation entry (if present) and the new source position past the atom_info call.
--- Returns nil if no atom_info follows.
--- @param source string
--- @param atom_name string
--- @param after_mipsatom_paren integer -- position past the MipsAtom_(...) close paren
--- @param line_of fun(pos: integer): integer
--- @return AtomAnnotation|nil, integer -- (entry or nil, new source position)
local function parse_atom_info_call(source, atom_name, after_mipsatom_paren, line_of)
local lookahead = skip_ws_and_cmt(source, after_mipsatom_paren)
local look_ident, look_after = read_ident(source, lookahead)
if look_ident ~= ATOM_INFO then return nil, after_mipsatom_paren end
local info_open = skip_ws_and_cmt(source, look_after)
if source:byte(info_open) ~= BYTE_OPEN_PAREN then return nil, info_open + 1 end
local info_inner, info_after = read_parens(source, info_open)
local args = parse_atom_annot_args(info_inner)
local entry = new_annot_entry(line_of(lookahead), ATOM_INFO, atom_name, "info")
ANNOT_ARG_HANDLERS.info(entry, args)
return entry, info_after
end
--- Find every `MipsAtom_(name) atom_info(...) { ... };` annotation in source.
--- Returns a list of annotation entries. Atoms without a following
--- `atom_info(...)` call produce NO entry (atoms without annotations are
--- valid in the new minimal shape).
--- @param source string
--- @return AtomAnnotation[]
local function find_atom_annotations(source)
local line_of = duffle.LineIndex(source)
local annots = {}
local pos = 1
local str_len = #source
while pos <= str_len do
pos = skip_ws_and_cmt(source, pos)
if pos > str_len then break end
-- Skip preprocessor directives (lines starting with #).
if source:byte(pos) == 35 then -- '#'
pos = skip_preprocessor_line(source, pos)
else
local ident, after_ident = read_ident(source, pos)
if not ident then
pos = pos + 1
elseif ident == ATOM_DECL then
local open_paren = skip_ws_and_cmt(source, after_ident)
if source:byte(open_paren) ~= BYTE_OPEN_PAREN then
pos = open_paren + 1
else
local inner, after_paren = read_parens(source, open_paren)
local name, _ = read_alnum_ident(inner, 1)
local entry, new_pos = parse_atom_info_call(source, name, after_paren, line_of)
if entry then annots[#annots + 1] = entry end
pos = new_pos
-- Skip past the body { ... } if present.
local brace = scan_to_char(source, "{", pos)
if brace then
local _, after_brace = read_braces(source, brace)
pos = after_brace
end
end
else
pos = after_ident
end
end
end
return annots
end
-- ════════════════════════════════════════════════════════════════════════════
-- Validation (ported from tape_atom_annotation_pass.lua:1193-1405)
-- ════════════════════════════════════════════════════════════════════════════
local function validate(ctx, src)
local source = src.text
local annots = find_atom_annotations(source)
local macros = find_macro_word_annotations(source)
local binds = find_binds_structs(source)
local atoms = find_atom_names(source)
local atom_index = {}
for _, a in ipairs(atoms) do atom_index[a.name] = a end
local binds_index = {}
for _, b in ipairs(binds) do binds_index[b.name] = b end
local errors = {}
local warnings = {}
local info = {}
-- 1. Every annotated atom must exist as a real MipsAtom_ declaration.
for _, a in ipairs(annots) do
if a.error then
errors[#errors + 1] = {line = a.line, msg = a.error}
elseif not atom_index[a.name] then
errors[#errors + 1] = {
line = a.line,
msg = string.format("annotation for '%s' has no matching MipsAtom_(%s) { ... }", a.name, a.name),
}
end
if a.errors then
for _, msg in ipairs(a.errors) do
errors[#errors + 1] = {line = a.line, msg = string.format("'%s': %s", a.name, msg)}
end
end
end
-- 2. Every atom may have AT MOST ONE annotation (no duplicates).
-- (Atoms with ZERO annotations are valid in the new minimal shape.)
local count_per_atom = {}
for _, a in ipairs(annots) do
if a.name and not a.error then
count_per_atom[a.name] = (count_per_atom[a.name] or 0) + 1
end
end
for name, n in pairs(count_per_atom) do
if n > 1 then
errors[#errors + 1] = {
line = atom_index[name] and atom_index[name].line or 0,
msg = string.format("MipsAtom_(%s) has %d annotations (expected at most 1)", name, n),
}
end
end
-- 3. (Phase validity check DROPPED. Phases were removed from the
-- annotation DSL. They may be reintroduced later as sub-calls of
-- atom_info, at which point ordering checks will go here.)
-- 4. BIND atoms must reference a real Binds_* struct.
for _, a in ipairs(annots) do
if a.binds then
if not binds_index[a.binds] then
-- Demoted from error to warning (2026-07-10): the same
-- condition is now caught by passes/static_analysis.lua's
-- check_abi_handoff() as an error. Emitting a warning
-- here keeps the annotation pass from being stop-on-error
-- for the common test-fixture case, while still surfacing
-- the issue in the report. The static-analysis report
-- remains the source of truth for build-stopping errors.
warnings[#warnings + 1] = {
line = a.line,
msg = string.format("'%s' binds '%s' but no Struct_(%s) { ... } declaration found (also flagged as an error by check_abi_handoff in the static-analysis pass)", a.name, a.binds, a.binds),
}
end
end
end
-- 5. BIND writes must be wave-context registers that match Binds_ fields.
for _, a in ipairs(annots) do
if a.binds and binds_index[a.binds] then
local bs = binds_index[a.binds]
local field_names = {}
for _, f in ipairs(bs.fields) do field_names[f.name] = true end
for _, f in ipairs(bs.fields) do
local candidate = "R_" .. f.name
if not is_wave_context_reg(candidate) then
warnings[#warnings + 1] = {
line = bs.line,
msg = string.format("%s field '%s' doesn't match a known wave-context register (candidate '%s')", a.binds, f.name, candidate),
}
end
end
for _, w in ipairs(a.writes) do
if not is_wave_context_reg(w) then
warnings[#warnings + 1] = {
line = a.line,
msg = string.format("%s writes '%s' which is not a known wave-context register", a.name, w),
}
end
end
end
end
-- 6. INFO reads should be wave-context registers (or R_TapePtr for rbind).
for _, a in ipairs(annots) do
for _, r in ipairs(a.reads) do
if not is_wave_context_reg(r) and r ~= "R_TapePtr" then
warnings[#warnings + 1] = {
line = a.line,
msg = string.format("atom '%s' reads '%s' which is not a known wave-context register", a.name, r),
}
end
end
end
-- 7. TAPE_WORDS(mac_X, N) ↔ WORD_COUNT(mac_X, N) drift.
-- Three outcomes: missing (error), mismatch (error), match (info).
-- Flattened via early-return-style helper instead of 3-way elseif.
local function check_macro_drift(m, declared)
if not declared then
errors[#errors + 1] = {
line = m.line,
msg = string.format("TAPE_WORDS(%s, %d) but '%s' is not in metadata.h", m.name, m.words, m.name),
}
return
end
if declared ~= m.words then
errors[#errors + 1] = {
line = m.line,
msg = string.format("DRIFT: TAPE_WORDS(%s, %d) but metadata.h declares WORD_COUNT(%s, %d)", m.name, m.words, m.name, declared),
}
return
end
info[#info + 1] = {
line = m.line,
msg = string.format("OK: %s = %d words", m.name, m.words),
}
end
for _, m in ipairs(macros) do
check_macro_drift(m, ctx.shared.word_counts[m.name])
end
-- 8. (atom_<...> _Pragma validation DROPPED. The pragma macros
-- atom_resource / atom_region / atom_group / atom_cadence /
-- atom_async were removed from atom_dsl.h. They may be
-- reintroduced later as sub-calls of atom_info.)
-- 9. (CADENCE_ONDEMAND requires async check DROPPED. Same reason
-- as #8.)
-- 10. Information summary.
info[#info + 1] = {
line = 0,
msg = string.format("scanned: %d atom(s), %d annotation(s), %d macro-word-decl(s), %d binds struct(s)",
#atoms, #annots, #macros, #binds),
}
return {
atoms = atoms,
annots = annots,
macros = macros,
pragmas = pragmas,
binds = binds,
errors = errors,
warnings = warnings,
info = info,
}
end
-- ════════════════════════════════════════════════════════════════════════════
-- Per-DIRECTORY (per-module) output: errors.h + annotations.txt
-- ════════════════════════════════════════════════════════════════════════════
--
-- Per-source reports were the old behavior; each source in the same
-- directory produced its own <basename>.errors.h + <basename>.annotations.txt,
-- which flooded build/gen/ with one report per header. The new behavior
-- aggregates per-DIRECTORY (one errors.h + one annotations.txt per module
-- basename). Directories with zero atoms/annotations are skipped (no
-- file emitted).
--- Render `<dir_basename>.errors.h` with `#error` directives for every
--- error found across all sources in the directory. Empty directories
--- (no errors, no atoms) produce no file.
local function emit_module_errors_h(ctx, dir_basename, atoms_count, errors, sources)
if ctx.dry_run then return nil end
if atoms_count == 0 and #errors == 0 then
-- Skip dirs with nothing to report
return nil
end
local out_path = ctx.out_root .. "/" .. dir_basename .. ".errors.h"
local lines = {
"// Auto-generated by ps1_meta.lua (passes/annotation.lua) — DO NOT EDIT",
string.format("// Module: %s Sources: %d", dir_basename, #sources),
"#pragma once",
"",
}
if #errors == 0 then
lines[#lines + 1] = "// annotation pass OK"
else
-- Prefix each error with the source basename for traceability
-- in the C compile log.
for _, e in ipairs(errors) do
local src_tag = ""
if e.source then
local src_name = e.source:match("([^/\\]+)$") or e.source
src_tag = src_name .. ": "
end
lines[#lines + 1] = string.format('#error "%s%s (line %d)"', src_tag, e.msg, e.line)
end
end
ensure_dir(ctx.out_root)
write_file(out_path, table.concat(lines, "\n") .. "\n")
return out_path
end
--- Stash aggregated per-module results for the report pass to consume.
local function emit_module_annotations_stub(ctx, dir, dir_basename, atoms_count)
ctx.flags = ctx.flags or {}
ctx.flags._annot_results = ctx.flags._annot_results or {}
ctx.flags._annot_results[#ctx.flags._annot_results + 1] = {
dir = dir,
dir_basename = dir_basename,
atoms_count = atoms_count,
}
-- annotations.txt is written by report.lua
end
-- ════════════════════════════════════════════════════════════════════════════
-- M.run — orchestrator entry
-- ════════════════════════════════════════════════════════════════════════════
--- @class M
local M = {}
-- Expose `validate` for downstream passes (e.g. report.lua) that need
-- to re-render the per-source results into a per-MODULE report. Keeping
-- it as a single shared function avoids the duplication that an
-- earlier version of report.lua had.
M.validate = validate
--- @param ctx PassCtx
--- @return PassResult
function M.run(ctx)
local outputs = {}
local errors = {}
local warnings = {}
-- Per-DIRECTORY (per-module) aggregation. Group sources by `src.dir`,
-- validate every source in the dir, then emit ONE errors.h per dir
-- (skipping dirs with no atoms AND no errors). The actual
-- annotations.txt is rendered by passes/report.lua from the stashed
-- per-module results below.
local by_dir = {}
for _, src in ipairs(ctx.sources) do
by_dir[src.dir] = by_dir[src.dir] or {}
table.insert(by_dir[src.dir], src)
end
for dir, dir_sources in pairs(by_dir) do
-- Dir basename = last component of `dir` ("code/duffle" -> "duffle").
local dir_basename = dir:match("([^/\\]+)$") or dir
-- Aggregate validate() results across the directory.
local dir_atoms = 0
local dir_errors = {}
local dir_warnings = {}
for _, src in ipairs(dir_sources) do
local result = validate(ctx, src)
dir_atoms = dir_atoms + #result.atoms
for _, e in ipairs(result.errors) do
dir_errors[#dir_errors + 1] = { line = e.line, msg = e.msg, source = src.path }
errors[#errors + 1] = { line = e.line, msg = e.msg }
end
for _, w in ipairs(result.warnings) do
dir_warnings[#dir_warnings + 1] = { line = w.line, msg = w.msg }
warnings[#warnings + 1] = { line = w.line, msg = w.msg }
end
end
-- Emit one errors.h per dir.
local err_path = emit_module_errors_h(ctx, dir_basename, dir_atoms, dir_errors, dir_sources)
if err_path then
table.insert(outputs, { errors_h = err_path })
end
-- Stash for report pass.
emit_module_annotations_stub(ctx, dir, dir_basename, dir_atoms)
end
return { outputs = outputs, errors = errors, warnings = warnings }
end
return M