--- 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: --- - `/.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 -- Bootstrap: same as entry scripts. See `ps1_meta.lua` for the rationale. -- 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. local _src = debug.getinfo(1, "S").source:sub(2) local _dir = _src:match("(.*[/\\])") or "./" dofile(_dir .. "../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 --- @field out_root string --- @field project_root string --- @field upstream 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[] --- ════════════════════════════════════════════════════════════════════════════ -- split helpers -- ════════════════════════════════════════════════════════════════════════════ --- 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. --- @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 -- Parse a comma-separated inner content (e.g. inside atom_reads(...)) into a list of trimmed identifiers. 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 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. a reserved token) --- "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_info_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 --- 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) -- scan: _Pragma() 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)) -- scan: #pragma tape_atom words= 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 #). -- (_Pragma is an operator, not a directive — it doesn't start with #.) if source:byte(pos) == 35 then -- '#' pos = duffle.skip_preprocessor_line(source, pos) goto continue end local ident, after_ident = read_ident(source, pos) -- scan: if not ident then pos = pos + 1; goto continue end if ident == PRAGMA_OPERATOR then -- scan: _Pragma(...) 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 -- scan: #pragma tape_atom words= 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 ::continue:: 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) -- scan: typedef 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) -- scan: typedef Struct_() local name = trim(inner) local brace = scan_to_char(source, "{", after_paren) -- scan: typedef Struct_() { if not brace then return nil, open_paren + 1 end local body, after_brace = read_braces(source, brace) -- scan: typedef Struct_() { } 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 = duffle.skip_preprocessor_line(source, pos) goto continue end local ident, after_ident = read_ident(source, pos) -- scan: if not ident then pos = pos + 1; goto continue end if ident ~= "typedef" then pos = after_ident; goto continue end -- scan: typedef Struct_() { } 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 ::continue:: 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) -- scan: if not ident then pos = pos + 1; goto continue end if ident ~= ATOM_DECL then pos = after_ident; goto continue end local open_paren = skip_ws_and_cmt(source, after_ident) if source:byte(open_paren) ~= BYTE_OPEN_PAREN then pos = open_paren + 1; goto continue end local inner, after_paren = read_parens(source, open_paren) -- scan: MipsAtom_() 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) -- scan: MipsAtom_() { if brace then local _, after_brace = read_braces(source, brace) -- scan: MipsAtom_() { } pos = after_brace else pos = open_paren + 1 end ::continue:: end return out end -- ════════════════════════════════════════════════════════════════════════════ -- Find atom annotations (atom_info + atom_bind / atom_reads / atom_writes) -- ════════════════════════════════════════════════════════════════════════════ --- 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 use. Currently ignored. 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) -- scan: MipsAtom_() 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) -- scan: MipsAtom_() atom_info(, , ) local args = parse_atom_info_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 = duffle.skip_preprocessor_line(source, pos) goto continue end local ident, after_ident = read_ident(source, pos) -- scan: if not ident then pos = pos + 1; goto continue end if ident ~= ATOM_DECL then pos = after_ident; goto continue end local open_paren = skip_ws_and_cmt(source, after_ident) if source:byte(open_paren) ~= BYTE_OPEN_PAREN then pos = open_paren + 1; goto continue end local inner, after_paren = read_parens(source, open_paren) -- scan: MipsAtom_() local name, _ = read_alnum_ident(inner, 1) local entry, new_pos = parse_atom_info_call(source, name, after_paren, line_of) -- scan: MipsAtom_() atom_info(, , ) if entry then annots[#annots + 1] = entry end pos = new_pos -- Skip past the body { ... } if present. local brace = scan_to_char(source, "{", pos) -- scan: MipsAtom_() atom_info(...) { if brace then local _, after_brace = read_braces(source, brace) -- scan: MipsAtom_() atom_info(...) { } pos = after_brace end ::continue:: end return annots end -- ════════════════════════════════════════════════════════════════════════════ -- Validation -- ════════════════════════════════════════════════════════════════════════════ 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. 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, 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 .errors.h + .annotations.txt, -- which flooded build/gen/ with one report per header. -- Aggregates per-DIRECTORY (one errors.h + one annotations.txt per module basename). -- Directories with zero atoms/annotations are skipped (no file emitted). --- Render `.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