mostly comment review (lua metaprogram)

This commit is contained in:
ed
2026-07-11 01:47:38 -04:00
parent a0d22700db
commit 91a91b3495
9 changed files with 386 additions and 557 deletions
+23 -36
View File
@@ -187,7 +187,6 @@ local function split_csv_top(s)
end
--- Split a string into whitespace-separated tokens.
--- Hand-rolled (no regex patterns).
--- @param s string
--- @return string[]
local function split_ws(s)
@@ -212,10 +211,8 @@ 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.
-- 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 },
@@ -451,9 +448,8 @@ local function parse_binds_body(body)
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.
--- 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`
@@ -527,10 +523,9 @@ 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.
--- 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
@@ -541,8 +536,8 @@ local function read_alnum_ident(s, pos)
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`.)
--- 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)
@@ -648,9 +643,8 @@ local function parse_atom_info_call(source, atom_name, after_mipsatom_paren, lin
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).
--- 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)
@@ -861,16 +855,13 @@ 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).
-- 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.
-- 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.
--- 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
@@ -878,7 +869,7 @@ local function emit_module_errors_h(ctx, dir_basename, atoms_count, errors, sour
return nil
end
local out_path = ctx.out_root .. "/" .. dir_basename .. ".errors.h"
local lines = {
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",
@@ -923,10 +914,8 @@ end
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.
-- 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
@@ -936,11 +925,9 @@ function M.run(ctx)
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.
-- 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 {}
+57 -93
View File
@@ -1,8 +1,7 @@
--- passes/components.lua — Component-macro header generator.
---
--- Walks every source for `MipsAtomComp_(ac_X) { body }`
--- (and the function-form `MipsAtomComp_Proc_(ac_X, { body })`) declarations
--- and emits a per-directory `<dir_basename>.macs.h` containing one `#define mac_X(sig) \` macro per component + `WORD_COUNT(mac_X, N)`
--- Walks every source for `MipsAtomComp_(ac_X) { body }` (and the function-form `MipsAtomComp_Proc_(ac_X, { body })`) declarations and
--- emits a per-directory `<dir_basename>.macs.h` containing one `#define mac_X(sig) \` macro per component + `WORD_COUNT(mac_X, N)`
--- entries for downstream offset computation.
---
--- **Conventions**: tabs (1/level), EmmyLua annotations, no regex,
@@ -116,8 +115,7 @@ local function to_absolute_path(path)
local cwd = p:read("*l")
p:close()
if not cwd then return path end
-- Normalize forward slashes to backslashes (Windows convention) on
-- both the cwd AND the relative path tail, so the join is uniform.
-- Normalize forward slashes to backslashes (Windows convention) on both the cwd AND the relative path tail, so the join is uniform.
cwd = cwd:gsub("/", "\\")
local tail = (path:gsub("/", "\\"))
return cwd .. "\\" .. tail
@@ -149,16 +147,13 @@ local function find_last_name_open_paren(source, name, before_pos)
return last_idx
end
--- Find the args of the function declaration that immediately precedes
--- a `MipsAtomComp_Proc_` invocation of the given name. Returns the
--- args string (e.g., `"U4 off, U4 code, U1 r, U1 g, U1 b"`) or nil
--- if no function declaration is found.
--- Find the args of the function declaration that immediately precedes a `MipsAtomComp_Proc_` invocation of the given name.
--- Returns the args string (e.g., `"U4 off, U4 code, U1 r, U1 g, U1 b"`) or nil if no function declaration is found.
---
--- Convention: function form is
--- `FI_ MipsAtom ac_X(args) MipsAtomComp_Proc_(ac_X, { body })`
--- We find the LAST occurrence of `"ac_X("` before `before_pos` and
--- extract the args from inside the parens. We then verify the
--- preceding context ends with `MipsAtom` (the function-decl keyword
--- We find the LAST occurrence of `"ac_X("` before `before_pos` and extract the args from inside the parens.
--- We then verify the preceding context ends with `MipsAtom` (the function-decl keyword
--- with possible qualifiers between).
---
--- @param source string
@@ -169,8 +164,7 @@ local function find_function_args_for(source, name, before_pos)
local last_idx = find_last_name_open_paren(source, name, before_pos)
if not last_idx then return nil end
-- Verify the preceding context ends with "MipsAtom" (with
-- possible qualifiers between).
-- Verify the preceding context ends with "MipsAtom" (with possible qualifiers between).
local before = source:sub(1, last_idx - 1)
local trimmed = duffle.trim(before)
if trimmed:sub(-#MIPS_ATOM) ~= MIPS_ATOM then
@@ -188,8 +182,7 @@ end
-- Preceding-comment-block extraction
-- ════════════════════════════════════════════════════════════════════════════
-- Skip whitespace (space/tab/newline/CR) backward from `pos`, returning
-- the position of the first non-whitespace char.
-- Skip whitespace (space/tab/newline/CR) backward from `pos`, returning the position of the first non-whitespace char.
-- @param source string
-- @param pos integer
-- @return integer
@@ -223,8 +216,7 @@ local function find_block_comment_open(source, close_pos)
return open_at
end
-- Walk back from `open_at` over leading spaces + tabs to include the
-- indentation before the `/*` in the captured comment.
-- Walk back from `open_at` over leading spaces + tabs to include the indentation before the `/*` in the captured comment.
-- @param source string
-- @param open_at integer
-- @return integer
@@ -241,8 +233,7 @@ local function extend_left_over_indent(source, open_at)
return start
end
-- Walk back from `line_end` to the start of the source line (the most
-- recent `\n` or position 1).
-- Walk back from `line_end` to the start of the source line (the most recent `\n` or position 1).
-- @param source string
-- @param line_end integer
-- @return integer
@@ -255,9 +246,8 @@ local function find_line_start(source, line_end)
end
-- (internal) Capture one `/* ... */` block comment whose closing `*/`
-- ends at `close_end_pos`. Returns (block_text, new_scan_pos) where
-- `new_scan_pos` is where to continue scanning for more comments, or
-- nil if no block comment was found.
-- ends at `close_end_pos`. Returns (block_text, new_scan_pos) where `new_scan_pos`
-- is where to continue scanning for more comments, or nil if no block comment was found.
local function capture_block_comment(source, close_end_pos)
local open_at = find_block_comment_open(source, close_end_pos)
if not open_at then return nil end
@@ -276,15 +266,11 @@ local function capture_line_comment(source, line_end_pos)
return nil
end
--- Find the contiguous comment block immediately preceding `pos` in
--- `source`. Returns the comment text (with the `/* */` or `//` markers
--- preserved) or an empty string if no comment is adjacent.
--- Find the contiguous comment block immediately preceding `pos` in `source`.
--- Returns the comment text (with the `/* */` or `//` markers preserved) or an empty string if no comment is adjacent.
---
--- Used to copy signature comments from the source declaration
--- (`MipsAtomComp_` / `MipsAtomComp_Proc_` / function decl) over to the
--- generated `mac_X` macro, so LSP/IntelliSense displays the args doc.
---
--- No regex (per the no_regex constraint).
--- Used to copy signature comments from the source declaration (`MipsAtomComp_` / `MipsAtomComp_Proc_` / function decl)
--- over to the generated `mac_X` macro, so LSP/IntelliSense displays the args doc.
---
--- @param source string
--- @param pos integer
@@ -321,8 +307,7 @@ end
-- Argument-name extraction
-- ════════════════════════════════════════════════════════════════════════════
-- Walk `trimmed` backward from `pos` over trailing whitespace /
-- asterisks / brackets, returning the position of the first
-- Walk `trimmed` backward from `pos` over trailing whitespace / asterisks / brackets, returning the position of the first
-- non-trailer character (i.e. the end of the identifier).
-- @param trimmed string
-- @param pos integer
@@ -358,8 +343,7 @@ local function trim_ident_back(trimmed, pos)
return back
end
--- Extract just the parameter NAMES from a function-args string
--- (stripping type annotations). E.g.,
--- Extract just the parameter NAMES from a function-args string (stripping type annotations). E.g.,
--- `"U4 off, U4 code, U1 r, U1 g, U1 b"` -> `{"off", "code", "r", "g", "b"}`
--- `"U4 *ptr"` -> `{"ptr"}`
--- `""` -> nil
@@ -389,9 +373,8 @@ end
-- Component scanner (bare + function forms)
-- ════════════════════════════════════════════════════════════════════════════
-- Parse the inner content of an `AtomComp_(name, ...)` call. Returns
-- (name, body_or_nil) — `body_or_nil` is non-nil iff this is the
-- function-form `MipsAtomComp_Proc_(name, { body })` invocation.
-- Parse the inner content of an `AtomComp_(name, ...)` call.
-- Returns (name, body_or_nil) — `body_or_nil` is non-nil iff this is the function-form `MipsAtomComp_Proc_(name, { body })` invocation.
-- @param inner string -- the content between ( and ) of the AtomComp_ call
--- @return string|nil, string|nil
local function parse_atomcomp_inner(inner)
@@ -414,8 +397,7 @@ local function parse_atomcomp_inner(inner)
end
-- (internal) Try to extract a bare-form `MipsAtomComp_(ac_X)` declaration.
-- Bare form: `MipsAtomComp_(ac_X) { body }` — body comes from the brace block
-- AFTER the parens.
-- Bare form: `MipsAtomComp_(ac_X) { body }` — body comes from the brace block AFTER the parens.
-- @param source string
-- @param name string -- the `ac_X` ident from the parens
--- @param ident_pos integer -- position of the `MipsAtomComp_` ident start
@@ -507,13 +489,11 @@ end
-- Convert `//` line comments to `/* */` block comments in a token.
--
-- C macros use `\` line-continuations; a `//` comment before `\` would
-- consume the continuation, breaking the macro. We convert `//` to
-- `/* */` so the multi-line macro structure is preserved.
-- C macros use `\` line-continuations; a `//` comment before `\` would consume the continuation,
-- breaking the macro. We convert `//` to `/* */` so the multi-line macro structure is preserved.
--
-- Skips `//` sequences that are inside string or character literals
-- (a rough heuristic — sufficient for component bodies which don't
-- have those constructs).
-- (a rough heuristic — sufficient for component bodies which don't have those constructs).
--
--- @param s string
--- @return string
@@ -551,10 +531,8 @@ end
-- Word-count computation (memoized recursive lookup)
-- ════════════════════════════════════════════════════════════════════════════
-- Strip the `mac_` prefix from a component-call ident so we can look it
-- up against the components-by-name table. Returns the ident unchanged
-- if it doesn't start with the prefix (so a non-component ident like
-- `mask_upper` falls through to the wc-table branch).
-- Strip the `mac_` prefix from a component-call ident so we can look it up against the components-by-name table. Returns the ident unchanged
-- if it doesn't start with the prefix (so a non-component ident like `mask_upper` falls through to the wc-table branch).
-- @param ident string|nil
-- @return string|nil
local function strip_mac_prefix(ident)
@@ -565,10 +543,9 @@ local function strip_mac_prefix(ident)
return ident
end
-- (internal) Recursive word-count lookup. `cache` is the memoization table
-- across all calls to `compute_component_word_count`; the in-progress
-- -1 sentinel detects cycles (A -> B -> A).
-- @param name string -- the component name (without `mac_`)
-- (internal) Recursive word-count lookup. `cache` is the memoization table across all calls to `compute_component_word_count`;
-- the in-progress -1 sentinel detects cycles (A -> B -> A).
-- @param name string -- the component name (without `mac_`)
-- @param comp_by_name table<string, Component>
-- @param wc table<string, integer>
-- @param cache table<string, integer>
@@ -604,17 +581,15 @@ local function word_count_rec(name, comp_by_name, wc, cache)
return n
end
--- Compute the word count of a component body, accounting for macro
--- expansion. Each comma-separated entry in the body is a "slot" that
--- contributes its own word count. For most entries (regular MIPS
--- instructions) the count is 1. For `mac_Y(...)` calls, the count is
--- the word count of `mac_Y` (recursive lookup through `components`).
--- Compute the word count of a component body, accounting for macro expansion.
--- Each comma-separated entry in the body is a "slot" that contributes its own word count.
--- For most entries (regular MIPS instructions) the count is 1.
--- For `mac_Y(...)` calls, the count is the word count of `mac_Y` (recursive lookup through `components`).
--- For encoding macros with a known multi-word count (e.g. `mask_upper` = 2),
--- the count is taken from `word_counts`.
---
--- The lookup is memoized via `word_count_rec` to avoid infinite recursion
--- (e.g. if two components referenced each other). This is the same
--- algorithm as the original `tape_atom_annotation_pass.lua` (commit 7d20a4d).
--- The lookup is memoized via `word_count_rec` to avoid infinite recursion (e.g. if two components referenced each other).
--- This is the same algorithm as the original `tape_atom_annotation_pass.lua` (commit 7d20a4d).
---
--- @param c Component
--- @param components Component[]
@@ -663,8 +638,7 @@ local function tokens_from_body(body)
return out
end
--- Determine the macro signature: function-args list (function form)
--- or variadic-ignored (bare form).
--- Determine the macro signature: function-args list (function form) or variadic-ignored (bare form).
--- @param args_str string|nil
--- @return string
local function signature_from_args(args_str)
@@ -675,8 +649,8 @@ local function signature_from_args(args_str)
return "..."
end
--- Strip the trailing `" \"` (space + backslash) line continuation
--- from the last body line. The last 2 chars are always that pair.
--- Strip the trailing `" \"` (space + backslash) line continuation from the last body line.
--- The last 2 chars are always that pair.
local function strip_trailing_continuation(lines)
local last = lines[#lines]
if last:sub(-2) == " \\" then
@@ -684,9 +658,8 @@ local function strip_trailing_continuation(lines)
end
end
--- Emit the `#define mac_X(sig) \<newline>\t<tok1> \<newline>,\t<tok2> ...`
--- block. Converts `//` line comments to `/* */` block comments in
--- each token so they don't break the C macro `\` line continuations.
--- Emit the `#define mac_X(sig) \<newline>\t<tok1> \<newline>,\t<tok2> ...` block.
--- Converts `//` line comments to `/* */` block comments in each token so they don't break the C macro `\` line continuations.
local function emit_macro_body(lines, c, sig, tokens)
for tok_idx = 1, #tokens do
tokens[tok_idx] = convert_line_comments_to_block(tokens[tok_idx])
@@ -699,9 +672,8 @@ local function emit_macro_body(lines, c, sig, tokens)
strip_trailing_continuation(lines)
end
--- Build the list of lines for one component (signature comment,
--- `#define mac_X(...)` line with backslash-continued tokens, then
--- `WORD_COUNT(mac_X, N)` entry).
--- Build the list of lines for one component
--- (signature comment, `#define mac_X(...)` line with backslash-continued tokens, then `WORD_COUNT(mac_X, N)` entry).
--- @param c Component
--- @param components Component[]
--- @param wc table<string, integer>
@@ -734,17 +706,14 @@ end
-- Per-source emit logic
-- ════════════════════════════════════════════════════════════════════════════
-- Build the boilerplate header lines (the `#ifdef INTELLISENSE_DIRECTIVES`
-- block, the `// Auto-generated` comment, the `// Source:` line, and the
-- self-contained `WORD_COUNT` macro definition).
-- Build the boilerplate header lines (the `#ifdef INTELLISENSE_DIRECTIVES` block,
-- the `// Auto-generated` comment, the `// Source:` line, and the self-contained `WORD_COUNT` macro definition).
-- @param src SourceFile
-- @return string[]
local function header_boilerplate(src)
return {
-- #pragma once wrapped in #ifdef INTELLISENSE_DIRECTIVES, matching
-- the convention in lottes_tape.h. The build does manual unity
-- includes (the user controls include order), so the pragma
-- is only active for IDE/tooling.
-- #pragma once wrapped in #ifdef INTELLISENSE_DIRECTIVES, matching the convention in lottes_tape.h.
-- The build does manual unity includes (the user controls include order), so the pragma is only active for IDE/tooling.
"#ifdef INTELLISENSE_DIRECTIVES",
"#pragma once",
"#endif",
@@ -753,8 +722,7 @@ local function header_boilerplate(src)
"// Component atoms (MipsAtomComp_(ac_*)) -> macro variants (mac_*)",
"",
-- Self-contained: define WORD_COUNT if not already defined.
-- We use the same definition here so the auto-generated
-- entries below expand to compile-time constants whether
-- We use the same definition here so the auto-generated entries below expand to compile-time constants whether
-- the metadata file is included first or not.
"#ifndef WORD_COUNT",
"#define WORD_COUNT(name, count) enum { words_##name = (count) };",
@@ -764,10 +732,9 @@ local function header_boilerplate(src)
end
-- Compute the output path for one source's `.macs.h` file.
-- The pre-rework convention uses the *directory* basename (not the
-- source file basename) — e.g. `code/duffle/lottes_tape.h` produces
-- `code/duffle/gen/duffle.macs.h`. This matches what the C codebase
-- #includes.
-- The pre-rework convention uses the *directory* basename
-- (not the source file basename) — e.g. `code/duffle/lottes_tape.h` produces `code/duffle/gen/duffle.macs.h`.
-- This matches what the C codebase #includes.
-- @param src SourceFile
-- @return string -- the output directory
-- @return string -- the full output path
@@ -777,13 +744,10 @@ local function compute_macs_h_path(src)
return out_dir, out_path
end
--- Emit a per-source `.macs.h` header with the `mac_X` macros +
--- `WORD_COUNT` entries. Writes in BINARY mode so LF line endings are
--- preserved (the git blob is LF; Windows text-mode would emit CRLF and
--- break the byte-identical diff).
--- Emit a per-source `.macs.h` header with the `mac_X` macros + `WORD_COUNT` entries. Writes in BINARY mode so LF line endings are
--- preserved (the git blob is LF; Windows text-mode would emit CRLF and break the byte-identical diff).
---
--- Honors `ctx.dry_run`: prints the intended path but does not write
--- the file.
--- Honors `ctx.dry_run`: prints the intended path but does not write the file.
---
--- @param ctx PassCtx
--- @param src SourceFile
@@ -793,7 +757,7 @@ local function emit_component_macros_h(ctx, src, components)
if #components == 0 then return nil end
local out_dir, out_path = compute_macs_h_path(src)
local lines = header_boilerplate(src)
local lines = header_boilerplate(src)
local wc = ctx.shared.word_counts
for _, c in ipairs(components) do
@@ -819,8 +783,8 @@ end
-- Pass entry
-- ════════════════════════════════════════════════════════════════════════════
-- (internal) Extend `ctx.shared.word_counts` with this source's component
-- macros so offsets sees them without re-reading the file.
-- (internal) Extend `ctx.shared.word_counts` with this source's component macros
-- so offsets sees them without re-reading the file.
-- @param ctx PassCtx
-- @param components Component[]
local function update_shared_word_counts(ctx, components)
+27 -42
View File
@@ -1,26 +1,19 @@
--- passes/offsets.lua — Branch-offset generator.
---
--- Scans every source for `MipsAtom_(name) { ... }` (and the raw
--- `MipsCode code_<name> { ... }` form) declarations, computes the
--- word offset from each `atom_offset(F, T)` marker to its target
--- `atom_label(T)` declaration, and emits `<dir_basename>.offsets.h`
--- with one `#define _atom_offset_F_T = N` per branch.
--- Scans every source for `MipsAtom_(name) { ... }` (and the raw `MipsCode code_<name> { ... }` form) declarations,
--- computes the word offset from each `atom_offset(F, T)` marker to its target `atom_label(T)` declaration,
--- and emits `<dir_basename>.offsets.h` with one `#define _atom_offset_F_T = N` per branch.
---
--- The offset is `target_word - branch_word - 1` (the standard MIPS
--- branch-immediate encoding: branch_offset = relative_pc_in_words - 1).
--- The offset is `target_word - branch_word - 1` (the standard MIPS branch-immediate encoding: branch_offset = relative_pc_in_words - 1).
---
--- **Conventions**: tabs (1/level), EmmyLua annotations, no regex,
--- Lua 5.3 compatible. See
--- `C:\projects\Pikuma\ps1-ai\conductor\code_styleguides\lua.md`.
--- Lua 5.3 compatible.
-- ════════════════════════════════════════════════════════════════════════════
-- Module-scope requires + package.path setup
-- ════════════════════════════════════════════════════════════════════════════
-- Resolve `arg[0]` to an absolute-ish script directory so that
-- `require("duffle")` resolves against `scripts/` regardless of CWD.
-- Note: this boilerplate is duplicated in 6 other entry scripts; a
-- Phase-6 extraction target (`duffle.setup_package_path()`).
-- Resolve `arg[0]` to an absolute-ish script directory so that `require("duffle")` resolves against `scripts/` regardless of CWD.
-- Bootstrap: 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
@@ -145,10 +138,8 @@ end
-- Marker-call helpers
-- ════════════════════════════════════════════════════════════════════════════
-- Extract comma-separated identifier args from a parenthesized group
-- after a function-like macro call. Returns (args, after_paren) where
-- `after_paren` is the position just past the closing `)`, or nil if
-- `token` did not start with `(`.
-- Extract comma-separated identifier args from a parenthesized group after a function-like macro call.
-- Returns (args, after_paren) where `after_paren` is the position just past the closing `)`, or nil if `token` did not start with `(`.
-- @param token string
-- @param after_ident integer
-- @return string[], integer|nil
@@ -157,8 +148,8 @@ local function extract_ident_args(token, after_ident)
if token:sub(arg_start, arg_start) ~= "(" then return {}, nil end
local inner, after_paren = duffle.read_parens(token, arg_start)
local args = {}
local pos = 1
local args = {}
local pos = 1
local inner_len = #inner
while pos <= inner_len do
pos = duffle.skip_ws_and_cmt(inner, pos)
@@ -177,8 +168,7 @@ local function extract_ident_args(token, after_ident)
return args, after_paren
end
-- (internal) Record a `atom_label(name)` marker — `at_pos` is the
-- branch-free word position within the atom body.
-- (internal) Record a `atom_label(name)` marker — `at_pos` is the branch-free word position within the atom body.
-- @param labels table<string, integer>
-- @param args string[]
-- @param at_pos integer
@@ -196,8 +186,7 @@ local function record_offset_marker(branches, args, at_pos)
end
end
--- Scan a single token for atom_label/atom_offset markers, walking through
--- balanced groups transparently (so nested calls are found).
--- Scan a single token for atom_label/atom_offset markers, walking through balanced groups transparently (so nested calls are found).
--- @param token string
--- @param at_pos integer -- the branch-free word position of this token in the body
--- @param labels table<string, integer>
@@ -229,8 +218,7 @@ local function scan_for_atom_markers(token, at_pos, labels, branches)
end
end
--- Find the end position (just past the closing ')') of the first
--- atom_label/atom_offset call in `tok`. Returns 0 if no such call.
--- Find the end position (just past the closing ')') of the first atom_label/atom_offset call in `tok`. Returns 0 if no such call.
--- @param tok string
--- @return integer -- 0 if no marker call found; otherwise end-1 (just past ')')
local function find_marker_call_end(tok)
@@ -266,8 +254,7 @@ end
-- Atom scanner
-- ════════════════════════════════════════════════════════════════════════════
--- Skip C qualifier keywords (`static`, `const`, etc.) and return the
--- position past the last qualifier.
--- Skip C qualifier keywords (`static`, `const`, etc.) and return the position past the last qualifier.
--- @param source string
--- @param pos integer
--- @return integer
@@ -281,8 +268,7 @@ local function skip_qualifiers(source, pos)
end
-- (internal) Try to parse the wrapped atom form: `MipsAtom_(<name>) { ... }`.
-- Returns the parsed Atom (name + body + position past body), or nil if
-- the form didn't match.
-- Returns the parsed Atom (name + body + position past body), or nil if the form didn't match.
-- @param source_text string
-- @param after_pos integer -- position just past `MipsAtom_`
-- @return Atom|nil
@@ -300,9 +286,9 @@ local function try_wrapped_atom(source_text, after_pos)
name_end = name_end + 1
end
local name = inner:sub(name_start, name_end - 1)
if name == "" then return nil end
if name == "" then return nil end
local brace_pos = duffle.scan_to_char(source_text, "{", after_paren)
local brace_pos = duffle.scan_to_char(source_text, "{", after_paren)
if not brace_pos then return nil end
local body, after_brace = duffle.read_braces(source_text, brace_pos)
return { name = name, body = body, after_brace = after_brace }
@@ -325,8 +311,7 @@ local function try_raw_atom(source_text, after_pos)
return { name = atom_name, body = body, after_brace = after_brace }
end
--- Find every `MipsAtom_(name) { ... }` (or raw `MipsCode code_<name> { ... }`)
--- declaration in a source.
--- Find every `MipsAtom_(name) { ... }` (or raw `MipsCode code_<name> { ... }`) declaration in a source.
--- @param source_text string
--- @return Atom[]
local function find_atoms(source_text)
@@ -368,10 +353,10 @@ end
-- Per-atom body scan
-- ════════════════════════════════════════════════════════════════════════════
-- (internal) Count words emitted by the rest of `tok` after a marker call
-- (the marker call itself emits 0 words, but the source pattern may bundle
-- the marker with the next instruction on the same line, separated by no
-- top-level comma). Returns the word count contributed by that rest.
-- (internal) Count words emitted by the rest of `tok` after a marker call
-- (the marker call itself emits 0 words, but the source pattern may bundle the marker with the next instruction on the same line,
-- separated by no top-level comma).
-- Returns the word count contributed by that rest.
-- @param tok string
-- @param word_counts table
-- @return integer
@@ -418,8 +403,8 @@ end
-- Offset computation + header generation
-- ════════════════════════════════════════════════════════════════════════════
-- Compute branch offsets as `target_word - branch_word - 1` (the
-- standard MIPS branch-immediate encoding).
-- Compute branch offsets as `target_word - branch_word - 1`
-- (the standard MIPS branch-immediate encoding).
-- @param labels table<string, integer>
-- @param branches table[]
-- @return BranchOffset[]
@@ -528,9 +513,9 @@ local function process_source(ctx, src)
return out_path
end
--- Run the offsets pass. For each source, emits a per-module
--- `<dir_basename>.offsets.h` containing `#define _atom_offset_F_T = N`
--- constants for every `atom_offset(F, T)` reference in the source's atoms.
--- Run the offsets pass.
--- For each source, emits a per-module `<dir_basename>.offsets.h` containing `#define _atom_offset_F_T = N` constants for every `atom_offset(F, T)` reference
--- in the source's atoms.
--- @param ctx PassCtx
--- @return PassResult
function M.run(ctx)
+17 -27
View File
@@ -20,10 +20,8 @@
-- Module-scope requires + package.path setup
-- ════════════════════════════════════════════════════════════════════════════
-- Resolve `arg[0]` to an absolute-ish script directory so that
-- `require("duffle")` resolves against `scripts/` regardless of CWD.
-- Note: this boilerplate is duplicated in 6 other entry scripts; a
-- Phase-6 extraction target (`duffle.setup_package_path()`).
-- Resolve `arg[0]` to an absolute-ish script directory so that `require("duffle")` resolves against `scripts/` regardless of CWD.
-- Note: this boilerplate is duplicated in 6 other entry scripts; a Phase-6 extraction target (`duffle.setup_package_path()`).
-- Bootstrap: 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
@@ -37,9 +35,8 @@ local duffle = require("duffle")
-- Constants
-- ════════════════════════════════════════════════════════════════════════════
-- Section separators used in the rendered text reports. The thin rules
-- are hand-tuned to align with the per-section content width; do not
-- change without also checking the section renderers below.
-- Section separators used in the rendered text reports.
-- The thin rules are hand-tuned to align with the per-section content width; do not change without also checking the section renderers below.
local RULE_THICK = "========================================================"
local SECTION_HEADER_ATOMS = "── Atoms ────────────────────────────────────────────────"
local SECTION_HEADER_ANNOTS = "── Annotations ──────────────────────────────────────────"
@@ -147,8 +144,7 @@ local PASS_NAME = "report"
-- Per-MODULE annotation report (aggregated across all sources in a dir)
-- ════════════════════════════════════════════════════════════════════════════
-- Extract the basename (last path segment) of a forward- or back-slash
-- separated path. Returns the input unchanged if no separator is found.
-- Extract the basename (last path segment) of a forward- or back-slash separated path. Returns the input unchanged if no separator is found.
-- @param path string
-- @return string
local function source_basename(path)
@@ -306,8 +302,7 @@ end
-- ════════════════════════════════════════════════════════════════════════════
--- Render the per-project summary (`build/gen/annotation_validation.txt`).
--- Aggregates totals across all sources; lists per-source error counts
--- if any source has errors.
--- Aggregates totals across all sources; lists per-source error counts if any source has errors.
--- @param all_results AnnotationResult[]
--- @return string
local function render_project_report(all_results)
@@ -356,8 +351,7 @@ end
-- Orchestration helpers
-- ════════════════════════════════════════════════════════════════════════════
-- Group source files by their `dir` field. Used to mirror the
-- per-DIRECTORY partitioning the annotation pass uses.
-- Group source files by their `dir` field. Used to mirror the per-DIRECTORY partitioning the annotation pass uses.
-- @param sources SourceFile[]
-- @return table<string, SourceFile[]> -- map of dir -> sources in that dir
local function group_sources_by_dir(sources)
@@ -369,19 +363,17 @@ local function group_sources_by_dir(sources)
return by_dir
end
-- (internal) Validate each source in `dir_sources` via the annotation pass,
-- tagging each result with `result.source = src.path` for downstream rendering.
-- Returns the list of module results + the flat list of all results (for the
-- project-wide summary).
-- (internal) Validate each source in `dir_sources` via the annotation pass, tagging each result with `result.source = src.path` for downstream rendering.
-- Returns the list of module results + the flat list of all results (for the project-wide summary).
-- @param ctx PassCtx
-- @param dir_sources SourceFile[]
-- @return AnnotationResult[], AnnotationResult[]
local function validate_module_sources(ctx, dir_sources)
local annotation = require("passes.annotation")
local annotation = require("passes.annotation")
local module_results = {}
local all_results = {}
local all_results = {}
for _, src in ipairs(dir_sources) do
local result = annotation.validate(ctx, src)
local result = annotation.validate(ctx, src)
result.source = src.path
module_results[#module_results + 1] = result
all_results[#all_results + 1] = result
@@ -394,7 +386,7 @@ end
-- @return boolean
local function module_has_content(module_results)
for _, r in ipairs(module_results) do
if #r.atoms > 0 or #r.annots > 0 or #r.binds > 0
if #r.atoms > 0 or #r.annots > 0 or #r.binds > 0
or #r.macros > 0 or #r.errors > 0 or #r.warnings > 0 then
return true
end
@@ -416,9 +408,8 @@ end
local M = {}
--- Run the report pass. Renders one `<dir_basename>.annotations.txt`
--- per source-directory that has content, plus the project-wide
--- `annotation_validation.txt` summary.
--- Run the report pass.
--- Renders one `<dir_basename>.annotations.txt` per source-directory that has content, plus the project-wide `annotation_validation.txt` summary.
--- @param ctx PassCtx
--- @return PassResult
function M.run(ctx)
@@ -433,11 +424,10 @@ function M.run(ctx)
local all_results_for_summary = {}
for _, entry in ipairs(module_entries) do
debug_log("entry: dir=%s basename=%s atoms_count=%d dir_sources=%d\n",
entry.dir, entry.dir_basename, entry.atoms_count, #(by_dir[entry.dir] or {}))
debug_log("entry: dir=%s basename=%s atoms_count=%d dir_sources=%d\n", entry.dir, entry.dir_basename, entry.atoms_count, #(by_dir[entry.dir] or {}))
if entry.atoms_count > 0 or #(by_dir[entry.dir] or {}) > 0 then
local dir_sources = by_dir[entry.dir] or {}
local dir_sources = by_dir[entry.dir] or {}
local module_results, all_results = validate_module_sources(ctx, dir_sources)
for _, r in ipairs(all_results) do
all_results_for_summary[#all_results_for_summary + 1] = r
+41 -75
View File
@@ -1,57 +1,32 @@
--- passes/static_analysis.lua — Per-atom static-analysis checks.
---
--- The 5 checks currently shipped:
--- 1. **GTE pipeline-fill** — every `gte_cmdw_*` invocation must be
--- preceded by the minimum number of `nop` words (per
--- `duffle.duffle.GTE_PIPELINE_LATENCY`) so the COP2 pipeline latency is
--- fully retired before the command issues.
--- 2. **mac_yield uniformity** — every atom body must contain exactly
--- one `mac_yield()` call (control transfer pattern).
--- 3. **ABI handoff** — every `atom_bind(Binds_X)` must reference a
--- `typedef Struct_(Binds_X) { ... }` declaration.
--- 4. **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.
--- 5. **per-atom cycle budget** — sum each atom body's instruction
--- latencies (per `duffle.duffle.INSTRUCTION_LATENCY`); report total.
--- 1. **GTE pipeline-fill** — every `gte_cmdw_*` invocation must be preceded by the minimum number of `nop` words
--- (per `duffle.GTE_PIPELINE_LATENCY`) so the COP2 pipeline latency is fully retired before the command issues.
--- 2. **mac_yield uniformity** — every atom body must contain exactly one `mac_yield()` call (control transfer pattern).
--- 3. **ABI handoff** — every `atom_bind(Binds_X)` must reference a `typedef Struct_(Binds_X) { ... }` declaration.
--- 4. **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.
--- 5. **per-atom cycle budget** — sum each atom body's instruction latencies (per `duffle.INSTRUCTION_LATENCY`); report total.
---
--- The orchestrator (`ps1_meta.lua`) wires this module in via the
--- PASSES table:
--- `["static-analysis"] = { module = "passes.static_analysis",
--- kind = "validation",
--- deps = {"word-counts", "components"},
--- out = { { kind = "report",
--- path_template = "<out_root>/<basename>.static_analysis.txt" } } }`
--- `["static-analysis"] = { module = "passes.static_analysis", kind = "validation", deps = {"word-counts", "components"},
--- out = { { kind = "report", path_template = "<out_root>/<basename>.static_analysis.txt" } } }`
---
--- **Conventions**: tabs (1/level), EmmyLua annotations, no regex,
--- Lua 5.3 compatible. See
--- `C:\projects\Pikuma\ps1-ai\conductor\code_styleguides\lua.md`.
--- **Conventions**: tabs (1/level), EmmyLua annotations, no regex, Lua 5.3 compatible. See `lua.md` in the ps1-ai styleguides.
-- ════════════════════════════════════════════════════════════════════════════
-- Module-scope requires + package.path setup
-- ════════════════════════════════════════════════════════════════════════════
-- `duffle.setup_package_path()` resolves `arg[0]` and prepends `scripts/`
-- (and `scripts/passes/`) to `package.path`, so `require("duffle")`
-- resolves regardless of CWD. See `duffle.lua` for the implementation.
-- Bootstrap: 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.
-- 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")
-- Domain tables (single source of truth in duffle.lua).
-- ════════════════════════════════════════════════════════════════════════════
-- Constants
-- ════════════════════════════════════════════════════════════════════════════
@@ -137,7 +112,7 @@ local OUTPUT_EXTENSION = ".static_analysis.txt"
--- @field atom AtomBody
--- @field tokens Token[] -- the tokens in the atom body, annotated
--- @field findings Finding[] -- findings for this atom
--- @field total_cycles integer -- sum of token cycle costs (Phase 3)
--- @field total_cycles integer -- sum of token cycle costs
-- ════════════════════════════════════════════════════════════════════════════
-- Source walkers
@@ -232,7 +207,7 @@ local function find_atom_bodies(source_text)
elseif c == 91 then
local _, a = duffle.read_brackets(inner, inner_pos); inner_pos = a
elseif c == 34 or c == 39 then
inner_pos = duffle.duffle.skip_str_or_cmt(inner, inner_pos) + 1
inner_pos = duffle.skip_str_or_cmt(inner, inner_pos) + 1
else
inner_pos = inner_pos + 1
end
@@ -381,7 +356,7 @@ local function tokenize_body(body)
elseif c == 91 then -- '['
local _, a = duffle.read_brackets(body, scan); scan = a
elseif c == 34 or c == 39 then -- '"' or '\''
scan = duffle.duffle.skip_str_or_cmt(body, scan) + 1
scan = duffle.skip_str_or_cmt(body, scan) + 1
else
scan = scan + 1
end
@@ -448,9 +423,9 @@ local function check_gte_pipeline_fill(atoms, findings, line_of)
line = line,
check = "gte_pipeline_fill",
kind = "warning",
msg = string.format(
"%s at line %d uses `gte_cmdw_%s` but that macro is not in duffle.duffle.GTE_PIPELINE_LATENCY -- add a min_nops entry",
a.name, line, variant),
msg = string.format(
"%s at line %d uses `gte_cmdw_%s` but that macro is not in duffle.GTE_PIPELINE_LATENCY -- add a min_nops entry",
a.name, line, variant),
}
ti = ti + 1
elseif need > 0 then
@@ -950,7 +925,9 @@ local function check_gpu_portstore_shape(atoms, findings)
findings[#findings + 1] = {
atom = a.name, line = a.line,
check = "gpu_portstore_shape", kind = "warning",
msg = string.format("%s at line %d writes to R_PrimCursor via raw store_word(...) but uses no `mac_format_*_color`; the cmd byte + word count cannot be auto-validated. Consider migrating to `mac_format_X_color` + `mac_gte_store_X_post_*` + `mac_insert_ot_tag_X`.",
msg = string.format("%s at line %d writes to R_PrimCursor via raw store_word(...)"
.. " but uses no `mac_format_*_color`; the cmd byte + word count cannot be auto-validated."
.. " Consider migrating to `mac_format_X_color` + `mac_gte_store_X_post_*` + `mac_insert_ot_tag_X`.",
a.name, a.line),
}
end
@@ -970,7 +947,7 @@ local function check_gpu_portstore_shape(atoms, findings)
end
-- ════════════════════════════════════════════════════════════════════════════
-- Check #5: per-atom cycle budget (Phase 3)
-- Check #5: per-atom cycle budget
-- ════════════════════════════════════════════════════════════════════════════
--- Compute the cycle cost of one token. The token is a string like
@@ -1179,8 +1156,8 @@ local function analyze_atom_paths(atom)
end
if n >= 1 then dfs(1, 0, {}) end
-- cycles_full: sum of every token's cost (the previous model; useful
-- for comparing against the path-aware min/max).
-- cycles_full: sum of every token's cost (the legacy sum-of-all-tokens
-- value; over-counts BD-slot nops relative to the path-aware min/max).
local cycles_full = 0
for tok_idx = 1, n do cycles_full = cycles_full + costs[tok_idx] end
@@ -1210,9 +1187,9 @@ local function analyze_atom_paths(atom)
}
end
--- Backward-compat wrapper: returns total cycle count (the previous
--- "best case" value, which over-counts BD-slot nops) + unknown macro
--- list. New code should call `analyze_atom_paths(atom)` instead.
--- Returns total cycle count (the sum-of-all-tokens value, which over-counts
--- BD-slot nops) + unknown macro list. New code should call
--- `analyze_atom_paths(atom)` instead.
local function count_atom_cycles(atom)
local tokens = tokenize_body(atom.body)
local total = 0
@@ -1242,7 +1219,8 @@ local function check_per_atom_cycle_budget(atoms, findings)
findings[#findings + 1] = {
atom = a.name, line = a.line,
check = "per_atom_cycle_budget", kind = "warning",
msg = string.format("%s at line %d uses macro `%s` which is not in duffle.INSTRUCTION_LATENCY; cycle count will be +%d per call (best-case). Add an entry to duffle.M.duffle.INSTRUCTION_LATENCY.",
msg = string.format("%s at line %d uses macro `%s` which is not in duffle.INSTRUCTION_LATENCY; "
.. "cycle count will be +%d per call (best-case). Add an entry to duffle.INSTRUCTION_LATENCY.",
a.name, a.line, name, duffle.UNKNOWN_INSTRUCTION_CYCLES),
}
end
@@ -1274,7 +1252,7 @@ local function validate(ctx, src)
check_gpu_portstore_shape(atoms, findings)
check_per_atom_cycle_budget(atoms, findings)
-- Phase 3 cycle-budget output: attach per-path cycle data to each
-- Path-aware cycle-budget output: attach per-path cycle data to each
-- atom. Best-case (no-stall) cycle count with BD-slot absorbed; the
-- `cycles_full` field is the legacy sum-of-all-tokens value (kept
-- for backward compat; over-counts BD-slot nops).
@@ -1322,7 +1300,7 @@ local function validate(ctx, src)
}
end
-- Phase 3: cycle-budget summary line. Per-path min/max totals.
-- Path-aware cycle-budget summary line. Per-path min/max totals.
if #atoms > 0 then
local total_min = 0
local total_max = 0
@@ -1432,10 +1410,9 @@ local function emit_static_analysis_txt(ctx, src, result)
return out_path
end
-- (Old per-source emit function above kept for backward compat but no
-- longer called from M.run; replaced by `emit_module_static_analysis_txt`
-- which aggregates by directory. Kept because some test harnesses may
-- still call it directly.)
-- (Old per-source emit function above; kept for backward compat but no
-- longer called from M.run. Replaced by `emit_module_static_analysis_txt`
-- which aggregates by directory.)
--- Per-directory emit. Aggregates atoms + findings across every source
--- in `dir_sources` and writes a single report to
@@ -1518,15 +1495,13 @@ local function emit_module_static_analysis_txt(ctx, dir, dir_sources, atoms, fin
add(string.format(" ! line %d %s", w.line, w.msg))
end
-- Per-atom cycle counts (Phase 3 path-aware). For each atom:
-- Per-atom cycle counts (path-aware). For each atom:
-- min = shortest path through the body (earliest exit)
-- max = longest path through the body (full fall-through)
-- br = number of branch instructions
-- paths = number of distinct paths reached
-- Both min and max are best-case (no stalls); BD-slot nops are
-- absorbed into branch costs (MIPS semantics). The previous "best
-- case" model counted every token separately, which double-counted
-- BD-slot nops; the path-aware model is the MIPS-accurate value.
-- absorbed into branch costs (MIPS semantics).
add("")
add("── Per-atom cycle counts (path-aware, best case, no stalls) ─")
if #atoms == 0 then
@@ -1569,13 +1544,6 @@ local function emit_module_static_analysis_txt(ctx, dir, dir_sources, atoms, fin
-- 0 atoms are skipped (they're just header files that declared
-- no MipsAtom_ — they're already listed in the module's
-- "Sources:" section above).
--
-- TODO: per-source finding attribution. Currently we can't tell
-- which source a given error/warning came from (errors/warnings
-- only carry atom-name + line, not source-path). The per-atom
-- cycle section already shows which atoms are in which source
-- via the `(file_basename)` suffix. Adding source attribution to
-- error/warning would be a future enhancement.
for _, src in ipairs(dir_sources) do
local src_atoms = {}
for _, a in ipairs(atoms) do
@@ -1640,10 +1608,10 @@ function M.run(ctx)
local errors = {}
local warnings = {}
-- Phase 3.7+: aggregate per-DIRECTORY (per-module). One
-- static_analysis.txt per source-directory, emitted only if the
-- directory contains at least one atom. Empty-source directories
-- (e.g. duffle headers with no atoms) produce no report.
-- Aggregate per-DIRECTORY (per-module). One static_analysis.txt per
-- source-directory, emitted only if the directory contains at least
-- one atom. Empty-source directories (e.g. duffle headers with no
-- atoms) produce no report.
--
-- Group sources by `src.dir`. The first component of `dir` is the
-- module name (e.g. "code/duffle" -> "duffle", "code/gte_hello" ->
@@ -1689,10 +1657,8 @@ function M.run(ctx)
end
end
-- Skip directories with zero atoms. The previous behavior emitted
-- a "<no atoms>" report per source; the new behavior emits nothing
-- at all (a directory with only headers / no MipsAtom_ is
-- "nothing to report").
-- 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.
+44 -74
View File
@@ -1,32 +1,25 @@
--- word_count_eval.lua — Word-counting logic for the tape-atom metaprogram
--- pipeline.
--- word_count_eval.lua — Word-counting logic for the tape-atom metaprogram pipeline.
---
--- Three responsibilities:
--- 1. **Public utilities** (used by `passes/components.lua`,
--- `passes/offsets.lua`, `passes/annotation.lua`):
--- 1. **Public utilities** (used by `passes/components.lua`, `passes/offsets.lua`, `passes/annotation.lua`):
--- - `M.count_token_words(token, wc)` — words emitted by one token
--- - `M.scan_dir(dir, suffix)` — glob walk for *.macs.h
--- - `M.count_body_words(body, wc)` — words emitted by an atom body
--- 2. **Pass entry** `M.run(ctx)` — loads metadata.h + *.macs.h into
--- `ctx.shared.word_counts` for downstream passes.
--- 2. **Pass entry** `M.run(ctx)` — loads metadata.h + *.macs.h into `ctx.shared.word_counts` for downstream passes.
--- 3. **Internal helpers** for the body scanner.
---
--- **Conventions**: tabs (1/level), EmmyLua annotations, no regex,
--- Lua 5.3 compatible. See
--- `C:\projects\Pikuma\ps1-ai\conductor\code_styleguides\lua.md`.
--- Lua 5.3 compatible.
-- ════════════════════════════════════════════════════════════════════════════
-- Module-scope requires + package.path setup
-- ════════════════════════════════════════════════════════════════════════════
-- Resolve `arg[0]` to an absolute-ish script directory so that
-- `require("duffle")` resolves against `scripts/` regardless of CWD.
-- Note: this boilerplate is duplicated in 6 other entry scripts; a
-- Phase-6 extraction target (`duffle.setup_package_path()`).
-- Resolve `arg[0]` to an absolute-ish script directory so that `require("duffle")` resolves against `scripts/` regardless of CWD.
-- Note: this boilerplate is duplicated in 6 other entry scripts; a Phase-6 extraction target (`duffle.setup_package_path()`).
-- Bootstrap: 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.
-- 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")
@@ -36,14 +29,12 @@ local duffle = require("duffle")
-- Constants
-- ════════════════════════════════════════════════════════════════════════════
-- Windows separator chars — used to convert `dir /b /s` output (which uses
-- `\`) into POSIX paths (which our scripts expect).
-- Windows separator chars — used to convert `dir /b /s` output (which uses `\`) into POSIX paths (which our scripts expect).
local PATH_SEP_BACKSLASH = "\\"
local PATH_SEP_FORWARD = "/"
-- Glob command for Windows directory walk. `dir /b /s` lists all matching
-- files recursively with bare paths (no headers); `2>nul` discards the
-- "file not found" stderr when nothing matches.
-- Glob command for Windows directory walk. `dir /b /s` lists all matching files recursively with bare paths (no headers);
-- `2>nul` discards the "file not found" stderr when nothing matches.
local DIR_GLOB_CMD = 'dir /b /s "%s\\%s" 2>nul'
-- ════════════════════════════════════════════════════════════════════════════
@@ -88,8 +79,7 @@ local M = {}
--- Count words emitted by a single comma-separated token inside an atom body.
--- For most tokens (regular MIPS instructions) this returns 1.
--- For `mac_X(...)` calls, this returns the resolved word count from `wc`
--- (recursively if needed). For `nop2` etc., returns wc[name].
--- For `mac_X(...)` calls, this returns the resolved word count from `wc` (recursively if needed). For `nop2` etc., returns wc[name].
--- For unknown macros, returns 1 and (optionally) warns.
---
--- @param token string -- a single token from split_top_level_commas
@@ -97,7 +87,7 @@ local M = {}
--- @return integer
function M.count_token_words(token, wc)
local s = duffle.trim(token)
if s == "" then return 0 end
if s == "" then return 0 end
local name, after = duffle.read_ident(s, 1)
if not name then return 1 end
if wc[name] then return wc[name] end
@@ -113,30 +103,25 @@ end
-- └────────────────────────────────────────────────────────────────────┘
--- Recursively scan a directory for files matching a glob suffix.
--- No regex per the no_regex constraint — uses plain byte matching
--- via `dir /b /s` on Windows.
--- No regex per the no_regex constraint — uses plain byte matching via `dir /b /s` on Windows.
---
--- The `.macs.h` files produced by the components pass always live at
--- `<project_root>/<module>/gen/`. We can shortcut the `dir /b /s` walk by
--- listing modules first (one `dir /b /ad`), then walking each `<module>/gen/`
--- (one `dir /b` per module, no recursion). For projects with 2 modules and
--- 0 .macs.h files, this drops the cost from ~52ms (full recursive walk of
--- the entire project tree) to ~5ms.
--- The `.macs.h` files produced by the components pass always live at `<project_root>/<module>/gen/`.
--- We can shortcut the `dir /b /s` walk by listing modules first (one `dir /b /ad`), then walking each `<module>/gen/`
--- (one `dir /b` per module, no recursion).
--- For projects with 2 modules and 0 .macs.h files, this drops the cost from ~52ms
--- (full recursive walk of the entire project tree) to ~5ms.
---
--- @param dir string -- directory to scan (absolute or relative)
--- @param suffix string -- file pattern, e.g. "*.macs.h"
--- @return string[]
-- Cache the scan_dir result per (dir, suffix) in package.loaded. Each
-- `io.popen` call on Windows is ~50-100ms of subprocess overhead, so
-- caching the result saves a fixed cost on every build. The cache
-- persists for the lifetime of the Lua process (cleared when ps1_meta.lua
-- exits). If a build removes/creates .macs.h files mid-process, the
-- caller can invalidate by calling `M._invalidate_scan_cache()`.
-- Cache the scan_dir result per (dir, suffix) in package.loaded.
-- Each `io.popen` call on Windows is ~50-100ms of subprocess overhead, so caching the result saves a fixed cost on every build.
-- The cache persists for the lifetime of the Lua process (cleared when ps1_meta.lua exits).
-- If a build removes/creates .macs.h files mid-process, the caller can invalidate by calling `M._invalidate_scan_cache()`.
local SCAN_CACHE_KEY = "__word_count_eval_scan_cache__"
--- Recursively scan a directory for files matching a glob suffix.
--- No regex per the no_regex constraint — uses plain byte matching
--- via `dir /b /s` on Windows.
--- No regex per the no_regex constraint — uses plain byte matching via `dir /b /s` on Windows.
---
--- @param dir string -- directory to scan (absolute or relative)
--- @param suffix string -- file pattern, e.g. "*.macs.h"
@@ -144,22 +129,17 @@ local SCAN_CACHE_KEY = "__word_count_eval_scan_cache__"
function M.scan_dir(dir, suffix)
local key = dir .. "\0" .. suffix
-- Check the in-process cache first. (Mostly helps when a build
-- triggers multiple `M.run` calls -- e.g. the audit_lua_nesting
-- script's stress tests -- but the cost is ~free either way.)
-- Check the in-process cache first. (Mostly helps when a build triggers multiple `M.run` calls -- e.g.
-- the audit_lua_nesting script's stress tests but the cost is ~free either way.)
local cache = package.loaded[SCAN_CACHE_KEY]
if cache and cache[key] then
return cache[key]
end
if cache and cache[key] then return cache[key] end
local results = {}
local pipe = io.popen(DIR_GLOB_CMD:format(dir, suffix))
if not pipe then
-- Cache the empty result too (avoids re-scan if the dir is
-- genuinely empty -- e.g. a clean build before components
-- has run yet).
cache = cache or {}
cache[key] = results
-- Cache the empty result too (avoids re-scan if the dir is genuinely empty -- e.g. a clean build before components has run yet).
cache = cache or {}
cache[key] = results
package.loaded[SCAN_CACHE_KEY] = cache
return results
end
@@ -170,18 +150,15 @@ function M.scan_dir(dir, suffix)
pipe:close()
-- Cache the result.
cache = cache or {}
cache[key] = results
cache = cache or {}
cache[key] = results
package.loaded[SCAN_CACHE_KEY] = cache
return results
end
--- Invalidate the scan cache (call after creating new .macs.h files
--- in the same Lua process — usually not needed).
function M._invalidate_scan_cache()
package.loaded[SCAN_CACHE_KEY] = nil
end
--- Invalidate the scan cache (call after creating new .macs.h files in the same Lua process — usually not needed).
function M._invalidate_scan_cache() package.loaded[SCAN_CACHE_KEY] = nil end
-- ┌────────────────────────────────────────────────────────────────────┐
-- │ Shared utility: count_body_words │
@@ -189,9 +166,8 @@ end
--- Count words emitted by an entire atom body (a brace-delimited block).
--- Splits by top-level commas; for each token, delegates to count_token_words.
--- Handles `atom_label(name)` / `atom_offset(tag, name)` markers (record at
--- current pos, do NOT advance pos; if the marker call bundles an instruction
--- after it, count that instruction too).
--- Handles `atom_label(name)` / `atom_offset(tag, name)` markers
--- (record at current pos, do NOT advance pos; if the marker call bundles an instruction after it, count that instruction too).
---
--- @param body string -- brace-delimited atom body (without braces)
--- @param wc WordCounts -- the shared word-count table
@@ -208,10 +184,8 @@ function M.count_body_words(body, wc)
local is_marker = leading_ident == "atom_label" or leading_ident == "atom_offset"
if is_marker then
-- Marker call: record at current pos, do NOT advance pos.
-- But the source pattern may bundle the marker with the next
-- instruction on a new line (no top-level comma between them).
-- In that case, the rest of `tok` after the marker call is
-- a real instruction that must still be counted.
-- But the source pattern may bundle the marker with the next instruction on a new line (no top-level comma between them).
-- In that case, the rest of `tok` after the marker call is a real instruction that must still be counted.
local marker_end = M.find_marker_call_end(tok)
if marker_end > 0 and marker_end < #tok then
local rest = duffle.trim(tok:sub(marker_end + 1))
@@ -226,8 +200,7 @@ function M.count_body_words(body, wc)
return total
end
--- Find the end position (just past the closing ')') of the first
--- atom_label/atom_offset call in `tok`. Returns 0 if no such call.
--- Find the end position (just past the closing ')') of the first atom_label/atom_offset call in `tok`. Returns 0 if no such call.
--- Internal helper for count_body_words.
---
--- @param tok string
@@ -244,10 +217,10 @@ function M.find_marker_call_end(tok)
elseif ch == "/" then
-- comment — skip past it (delegated to duffle.skip_str_or_cmt)
local nx = duffle.skip_str_or_cmt(tok, pos)
pos = (nx > pos) and nx or (pos + 1)
pos = (nx > pos) and nx or (pos + 1)
else
local ident, after_ident = duffle.read_ident(tok, pos)
local marker_end = find_marker_end(tok, ident, after_ident)
local marker_end = find_marker_end(tok, ident, after_ident)
if marker_end > 0 then return marker_end end
pos = after_ident or (pos + 1)
end
@@ -255,8 +228,8 @@ function M.find_marker_call_end(tok)
return 0
end
-- (internal) If `ident` is `atom_label`/`atom_offset` followed by `(...)`,
-- return the position just past the closing ')'. Otherwise 0.
-- (internal) If `ident` is `atom_label`/`atom_offset` followed by `(...)`, return the position just past the closing ')'.
-- Otherwise 0.
-- @param tok string
-- @param ident string|nil
-- @param after_ident integer
@@ -273,10 +246,8 @@ end
-- │ Pass entry: M.run(ctx) — "word-counts" pass │
-- └────────────────────────────────────────────────────────────────────┘
--- Load metadata.h + scan for existing *.macs.h files into
--- ctx.shared.word_counts. Loading the .macs.h files is idempotent:
--- entries from later (current-build) .macs.h files override
--- metadata.h entries of the same name.
--- Load metadata.h + scan for existing *.macs.h files into ctx.shared.word_counts.
--- Loading the .macs.h files is idempotent: entries from later (current-build) .macs.h files override metadata.h entries of the same name.
---
--- @param ctx PassCtx
--- @return PassResult
@@ -301,7 +272,6 @@ function M.run(ctx)
end
ctx.shared.word_counts = wc
return { outputs = {}, errors = {}, warnings = {} }
end