4 Commits
16 changed files with 427 additions and 911 deletions
+1 -1
View File
@@ -103,7 +103,7 @@ FI_ void farena_init(FArena_R arena, Slice mem) { assert(arena != nullptr);
arena->used = 0;
}
FI_ FArena farena_make(Slice mem) { FArena a; farena_init(& a, mem); return a; }
I_ Slice farena_push(FArena_R arena, U4 amount, Opt_farena o) {
I_ Slice farena_push(FArena_R arena, U4 amount, Opt_farena o) {
if (amount == 0) { return (Slice){}; }
U4 desired = amount * (o.type_width == 0 ? 1 : o.type_width);
U4 to_commit = align_pow2(desired, o.alignment ? o.alignment : MEM_ALIGNMENT_DEFAULT);
-6
View File
@@ -384,12 +384,6 @@ void update(PrimitiveArena* pa, U4* ordering_buf)
TapeBuilder tb = tb_make_old(& tape_arena); tb_scope(& tb) {
// Skip set_gte_world atom for diagnostics to isolate the triangle loop
for (U4 i = 0; i < Floor_num_faces; i++) {
// =======================================================
// SWAP EMIT TO TEST DIFFERENT PARTS OF THE PIPELINE:
// =======================================================
// 1. code_diag_yield -> Tests Tape Engine jump logic
// 2. code_diag_color -> Tests OT and Prim Arena memory
// 3. code_diag_gte -> Tests Vertex arrays and GTE Math
// tb_emit(& tb, code_diag_yield);
// tb_emit(& tb, code_diag_color);
// tb_emit(& tb, code_diag_gte);
+177 -323
View File
@@ -357,22 +357,6 @@ function M.write_file_lf(path, content)
f:write(content); f:close()
end
--- Return `{path, ...}` for files in `out_root` whose basename matches `pattern` (Lua pattern, NOT regex — `%.` not `\.`).
--- Empty list if `out_root` doesn't exist or matches nothing.
--- @param out_root Path
--- @param pattern string -- Lua pattern matched against basename only
--- @return string[]
function M.list_dir(out_root, pattern)
local files = {}
if lfs.attributes(out_root, "mode") ~= "directory" then return files end
for entry in lfs.dir(out_root) do
if entry:match(pattern) then
files[#files + 1] = out_root .. "\\" .. entry
end
end
return files
end
local _absolute_path_cache = {}
--- Convert a (possibly relative) path to an absolute path, using CWD if needed.
@@ -409,10 +393,6 @@ function M.ensure_dir(path)
if lfs.attributes(path, "mode") ~= "directory" then lfs.mkdir(path) end
end
-- Test helper: clear the cache (used by tests + between process runs).
-- Not normally needed since Lua state is per-process.
function M._reset_ensured_dirs() _ensured_dirs = {} end
--- Group a list of `SourceFile`-shaped records by their `dir` field.
--- Used by the annotation / static-analysis / report passes to partition sources into per-DIRECTORY (per-module) buckets before emitting per-module reports.
--- Insertion order preserved within each bucket (matches source order in `ctx.sources`).
@@ -462,7 +442,7 @@ function M.read_balanced(s, open_char, close_char, pos)
local c = s:byte(pos)
if c == open_byte then
depth = depth + 1
pos = pos + 1
pos = pos + 1
-- scan: <open_char> <inner...> <open_char> (depth=depth)
elseif c == close_char:byte() then
depth = depth - 1
@@ -592,65 +572,87 @@ function M.parse_direct_quoted_includes(source_text)
error("parse_direct_quoted_includes requires source text", 2)
end
-- Each arm's effect on (pos, line_leading) is annotated at the branch site.
-- Arm order: newline / horiz-space / '//' / '/*' / '"' / '\'' / '#' / default.
local logical_text, physical_pos, physical_line = splice_c_lines(source_text)
local includes = {}
local pos = 1
local line_leading = true
while pos <= #logical_text do
local byte = logical_text:byte(pos)
if byte == BYTE_NEWLINE then
if byte == BYTE_NEWLINE then
-- line break; refresh leading-whitespace state for next line.
line_leading = true
pos = pos + 1
elseif is_horizontal_space(byte) then
-- ordinary inter-token whitespace; preserve current leading-ness.
pos = pos + 1
elseif byte == BYTE_SLASH and logical_text:byte(pos + 1) == BYTE_SLASH then
-- '//' line comment: skip_str_or_cmt walks to EOL on its own, so no separate newline scan is needed here.
local after = M.skip_str_or_cmt(logical_text, pos)
-- pos := after when the skipper agrees, else single-byte advance.
pos = (after > pos) and after or (pos + 1)
elseif byte == BYTE_SLASH and logical_text:byte(pos + 1) == BYTE_STAR then
-- '/*' block comment.
local after = M.skip_str_or_cmt(logical_text, pos)
if after == pos then
if after <= pos then
-- skipper refused (unterminated /*). Treat this byte as ordinary content: step one, mark non-leading.
line_leading = false
pos = pos + 1
pos = pos + 1
else
-- jump past the closing '*/'. The span may cross lines, so rescan for embedded '\n' to refresh line_leading.
for scan = pos, after - 1 do
if logical_text:byte(scan) == BYTE_NEWLINE then line_leading = true end
end
pos = after
end
elseif byte == BYTE_DQUOTE or byte == BYTE_SQUOTE then
-- enter+leave the string literal in one skip; literal bodies cannot contain a directive regardless of what they look like.
line_leading = false
local after = M.skip_str_or_cmt(logical_text, pos)
pos = (after > pos) and after or (pos + 1)
elseif byte == 35 and line_leading then -- '#'
local hash_pos = pos
local directive_line = physical_line[hash_pos] or 1
local scan = skip_directive_space(logical_text, pos + 1)
local ident, after_ident
if scan then ident, after_ident = M.read_ident(logical_text, scan) end
if ident == "include" then
scan = skip_directive_space(logical_text, after_ident)
end
if ident == "include" and scan and logical_text:byte(scan) == BYTE_DQUOTE then
local after_quote = M.skip_str_or_cmt(logical_text, scan)
if after_quote > scan and logical_text:byte(after_quote - 1) == BYTE_DQUOTE then
local include_path = logical_text:sub(scan + 1, after_quote - 2)
local physical_first = physical_pos[hash_pos]
local physical_last = physical_pos[after_quote - 1]
includes[#includes + 1] = {
path = include_path,
include_path = include_path,
include_text = M.trim(source_text:sub(physical_first, physical_last)),
line = directive_line,
}
pos = after_quote
else
pos = pos + 1
end
else
pos = pos + 1
local after = M.skip_str_or_cmt(logical_text, pos)
pos = (after > pos) and after or (pos + 1)
elseif byte == 35 and line_leading then -- '#' at line head
-- Sequential pre-checks; any one failing falls through to ::not_include:: (single-byte advance).
-- Full success pushes the record and jumps to ::directive_done:: without ever entering the not-include path.
-- (All locals are pre-declared at the top of this arm because Lua forbids a goto from crossing a local declaration into its scope.)
local hash_pos, directive_line, scan, ident, after_ident, after_quote
local include_path, physical_first, physical_last
hash_pos = pos
directive_line = physical_line[hash_pos] or 1
scan = skip_directive_space(logical_text, pos + 1)
if not scan then goto not_include end
ident, after_ident = M.read_ident(logical_text, scan)
if ident ~= "include" then goto not_include end
scan = skip_directive_space(logical_text, after_ident)
if not scan then goto not_include end
if logical_text:byte(scan) ~= BYTE_DQUOTE then goto not_include end
after_quote = M.skip_str_or_cmt(logical_text, scan)
if not (after_quote > scan and logical_text:byte(after_quote - 1) == BYTE_DQUOTE) then
goto not_include
end
-- success: build the include record; pos jumps past closing '"'.
include_path = logical_text:sub(scan + 1, after_quote - 2)
physical_first = physical_pos[hash_pos]
physical_last = physical_pos[after_quote - 1]
includes[#includes + 1] = {
path = include_path,
include_path = include_path,
include_text = M.trim(source_text:sub(physical_first, physical_last)),
line = directive_line,
}
pos = after_quote
goto directive_done
::not_include::
-- any pre-check failure: '#' is ordinary content; advance one.
pos = pos + 1
::directive_done::
-- '#' at line head clears the leading-whitespace state.
line_leading = false
else
-- ordinary source character; mark non-leading, advance one.
line_leading = false
pos = pos + 1
end
@@ -813,10 +815,8 @@ end
-- Split a brace-body into top-level comma-separated tokens. Honors nested parens/braces/brackets and skips strings/comments.
--
-- FIX (2026-07-09): split at top-level NEWLINES and SEMICOLONS too, AND emit a token break after a top-level comment/string.
-- Previous behavior glued the macro call after a comment into the same token, so `word_count_of_token` only saw the
-- leading ident (often nil after stripping the comment), undercounting the body.
-- Pure-comment / pure-string chunks (which now appear between real statements) are filtered out so they contribute 0 words instead of 1.
-- Splits at top-level NEWLINES and SEMICOLONS too, AND emits a token break after a top-level comment/string.
-- Pure-comment / pure-string chunks contribute 0 words.
function M.split_top_level_commas(body)
local tokens = {}
local pos = 1
@@ -829,7 +829,7 @@ function M.split_top_level_commas(body)
local scan = 1
local len = #chunk
while scan <= len do
if M.is_space(chunk:sub(scan, scan)) then
if M.is_space_byte(chunk:byte(scan)) then
scan = scan + 1
else
local nx = M.skip_str_or_cmt(chunk, scan)
@@ -980,51 +980,6 @@ function M.build_body_line_index(body)
return index
end
--- Find the end of a marker call (`atom_label(...)` or `atom_offset(...)`).
--- Returns the position past the closing `)`, or nil if the token isn't a marker call.
--- @param tok string
--- @return integer|nil
function M.find_marker_call_end(tok)
local ident, after = M.read_ident(tok, 1)
if not ident then return nil end
if ident ~= "atom_label" and ident ~= "atom_offset" then return nil end
local paren_pos = M.skip_ws_and_cmt(tok, after)
if tok:sub(paren_pos, paren_pos) ~= "(" then return nil end
local _, close = M.read_parens(tok, paren_pos)
return close
end
--- True iff `tok` is an atom-label or atom-offset marker call.
--- Sibling helper to M.find_marker_call_end; uses the same string constants.
--- @param tok string
--- @return boolean
function M.is_marker_token(tok)
local leading = M.read_ident(tok, 1)
return leading == "atom_label" or leading == "atom_offset"
end
--- Count words contributed by the non-marker portion of `tok` (after the marker's closing `)`).
--- Returns 0 if `tok` isn't a marker call or has no trailing content.
---
--- `count_token_words_fn` is injected by the caller rather than imported here because the dependency arrow already points the other way:
--- `passes/offsets.lua` and `passes/atoms_source_map.lua` both `require("word_count_eval")` and pass its `count_token_words` as the 3rd argument to this function,
--- while `word_count_eval` itself loads `duffle` via `duffle_paths.lua` (see `passes/word_count_eval.lua` near the top of the file)
--- and calls `duffle.trim` / `duffle.read_ident` / `duffle.skip_ws_and_cmt` from `M.count_token_words`. Importing `word_count_eval`
--- from this module would reverse that direction and form a recursive require cycle.
--- The callback keeps the marker-syntax helpers (`find_marker_call_end`, `is_marker_token`, this function)
--- shared in `duffle` without making the foundational utility depend on a pass module.
--- @param tok string
--- @param word_counts table
--- @param count_token_words_fn fun(tok: string, wc: table): integer
--- @return integer
function M.count_marker_rest(tok, word_counts, count_token_words_fn)
local marker_end = M.find_marker_call_end(tok)
if not marker_end or marker_end >= #tok then return 0 end
local rest = M.trim(tok:sub(marker_end))
if rest == "" then return 0 end
return count_token_words_fn(rest, word_counts)
end
-- ════════════════════════════════════════════════════════════════════════════
-- Section 5: load_word_counts
-- ════════════════════════════════════════════════════════════════════════════
@@ -2046,7 +2001,7 @@ M.GPR_VALUE_RULES = {
--
-- Consumers:
-- * passes/static_analysis.lua::check_control_transfer_delay_slot_use
-- No other pass consumes this table as of 2026-07-23.
M.CONTROL_TRANSFER_DELAY_SLOT_POLICIES = {
branch_equal = { family = "branch" },
branch_ne = { family = "branch" },
@@ -2081,21 +2036,6 @@ M.CONTROL_TRANSFER_DELAY_SLOT_POLICIES = {
--- @field declaration integer -- 1-based line number of the MipsAtomComp_(ac_X) declaration
--- @field kind string -- "comp_bare" | "comp_proc"
--- @class WordEvent
--- @field word integer -- 0-based word index across the entire expansion (root atom + recursed bodies)
--- @field ident string -- leading identifier of the emitting token (for nop2 → "nop")
--- @field args string[] -- top-level comma-split args of the emitting token (trimmed)
--- @field source string -- where the token is defined (component source for recursed; atom source for root)
--- @field line integer -- source line of the token (within `source`)
--- @field call_source string -- always the ROOT atom's source path (preserved across recursion)
--- @field call_line integer -- root atom's call-site line (preserved across recursion)
--- @class WordEventError
--- @field kind string -- "cycle" (currently the only error kind)
--- @field msg string -- deterministic human-readable description
--- @field source string -- path of the source containing the offending token
--- @field line integer -- 1-based line of the offending token within `source`
-- The cross-source component-body index is owned by the canonical corpus
-- (`corpus.component_body_index`, populated by `passes/components.lua`).
-- Consumers (`passes/static_analysis.lua`, `passes/emission_model.lua`) read it directly;
@@ -2196,7 +2136,7 @@ local E_MAC_PREFIX_LEN = 4
-- The items stream is the single ordered source of truth; `word_events` and `markers` are dense views over it (never a separate walk).
--
-- The helper below operates on a body string (not a body_entry) so the canonical pass can call it without depending on the older SourceScan / body_off conventions.
-- component_index argument is accepted for parity with `expand_word_events` and is reserved for recursive component expansion.
-- component_index argument is reserved for recursive component expansion.
-- word_counts table is the canonical authored-metadata + current-component count table.
--- @class EmissionProjection
@@ -2308,31 +2248,36 @@ local function _project_emission_inner(root_body_entry, ctx_table)
local function emit_embedded_markers(tok, tok_line)
local pos = 1
while pos <= #tok do
-- trim leading whitespace and comments before each scan.
pos = M.skip_ws_and_cmt(tok, pos)
if pos > #tok then break end
local ident, after = M.read_ident(tok, pos)
if ident then
if ident == "atom_label" or ident == "atom_offset" then
local open = M.skip_ws_and_cmt(tok, after)
local inner, after_paren = M.read_parens(tok, open)
if inner then
local args = split_top_level_args(inner)
if ident == "atom_label" then
emit_marker("label", args[1] or "", nil, tok_line)
else
emit_marker("offset", args[1] or "", args[2] or "", tok_line)
end
pos = after_paren
else
pos = after
end
else
pos = after
end
else
local ident, after = M.read_ident(tok, pos)
if not ident then
-- not an ident: token is a string or comment; skip or one-step.
local next_pos = M.skip_str_or_cmt(tok, pos)
pos = (next_pos > pos) and next_pos or (pos + 1)
goto continue_loop
end
if ident ~= "atom_label" and ident ~= "atom_offset" then
-- ordinary ident; nothing to emit, step past the ident only.
pos = after
goto continue_loop
end
-- marker ident: parse the (...) arguments.
local open = M.skip_ws_and_cmt(tok, after)
local inner, after_paren = M.read_parens(tok, open)
if not inner then
-- (...) unreadable: fall back to non-marker behavior.
pos = after
goto continue_loop
end
-- commit: label takes 1 arg, offset takes 2.
local args = split_top_level_args(inner)
if ident == "atom_label" then emit_marker("label", args[1] or "", nil, tok_line)
else emit_marker("offset", args[1] or "", args[2] or "", tok_line)
end
pos = after_paren
::continue_loop::
end
end
@@ -2418,108 +2363,100 @@ local function _project_emission_inner(root_body_entry, ctx_table)
local line_of = body_entry.line_of or M.LineIndex("")
local def_source = body_entry.source or ""
local def_line = body_entry.declaration or 0
for _, bt in ipairs(tokens) do
-- Per-token dispatch: each matched branch returns; only the fall-through
-- "opaque word" emit handles direct encoders + mac_X-without-component.
local function process_token(bt)
local tok = M.trim(bt.tok or "")
if tok ~= "" then
local ident = M.read_ident(tok, 1) or "?"
local _, args = token_ident_and_args(tok)
local tok_line = line_of(body_off + bt.rel) or 0
if ident ~= "atom_label" and ident ~= "atom_offset" then emit_embedded_markers(tok, tok_line) end
if ident == "atom_label" then emit_marker("label", args[1] or "", nil, tok_line)
elseif ident == "atom_offset" then emit_marker("offset", args[1] or "", args[2] or "", tok_line)
elseif ident:sub(1, 4) == "mac_" then
local bare = ident:sub(5)
local comp = ctx_table.component_index[bare]
if comp then
local invocation_root_call_text = walk_root_call_text or tok
if ctx_table.visiting[bare] then
-- Cycle: this component is already on the expansion stack.
-- Still allocate an ID, still emit invoke_begin / invoke_end (zero-width), still record the cycle error — but do NOT recurse.
local inv = emit_invoke_begin(comp.kind or "comp_bare", bare, tok, invocation_root_call_text, def_source, tok_line)
inv.parent_id = walk_parent_inv_id
inv.call_text = tok
local err = {
kind = "cycle",
msg = string.format(
"project_emission: component cycle detected: %q", bare),
source = def_source,
line = tok_line,
}
inv.errors[#inv.errors + 1] = err
errors[#errors + 1] = err
emit_invoke_end(inv)
else
ctx_table.visiting[bare] = true
local inv = emit_invoke_begin(comp.kind or "comp_bare", bare, tok, invocation_root_call_text, def_source, tok_line)
inv.parent_id = walk_parent_inv_id
inv.call_text = tok
inv.def_path = comp.source
inv.def_line = comp.declaration
-- Propagate trackers into the component body:
-- * `immediate_call_text` for the recursive walk is THIS call's token text (the immediate outer call for any word emitted inside this body).
-- * `root_call_text` is the OUTERMOST call. Immutable value computed before this invocation was opened.
local inner_immediate_call_text = tok
walk_body_entry({
body_tokens = comp.body_tokens or {},
body_off = comp.body_off or 0,
line_of = comp.line_of,
source = comp.source,
declaration = comp.declaration,
}
, inv.id
, invocation_root_call_text
, inner_immediate_call_text
)
ctx_table.visiting[bare] = nil
emit_invoke_end(inv)
-- Count `word` items inside [start_word, end_word].
local wc_inside = 0
for i = inv.start_word, inv.end_word do
local it = items[i]
if it and it.kind == "word" then
wc_inside = wc_inside + 1
end
end
inv.word_count = wc_inside
-- Declared-vs-measured mismatch is a construction error.
-- `word_counts["mac_X"]` is the declared count populated by the components pass;
-- we compare against the measured word count.
local declared = ctx_table.word_counts["mac_" .. bare]
if declared and wc_inside ~= declared then
local err = {
kind = "count_mismatch",
msg = string.format(
"project_emission: mac_%s declared=%d measured=%d",
bare, declared, wc_inside),
source = def_source,
line = tok_line,
}
inv.errors[#inv.errors + 1] = err
errors [#errors + 1] = err
end
end
else
-- mac_X is NOT a known component. Resolve the count from `word_counts`; if unresolved, emit 1 opaque word + one warning.
local n = resolve_count(ident, tok_line)
local out_ident = (ident == "nop2") and "nop" or ident
for _ = 1, n do
emit_word(out_ident, args, tok_line, tok, def_source, def_line, walk_immediate_call_text, walk_root_call_text)
end
end
else
-- Direct encoder (non-mac_X). Resolve count; emit N words.
local n = resolve_count(ident, tok_line)
local out_ident = (ident == "nop2") and "nop" or ident
for _ = 1, n do
emit_word(out_ident, args, tok_line, tok, def_source, def_line, walk_immediate_call_text, walk_root_call_text)
end
end
if tok == "" then return end
local ident = M.read_ident(tok, 1) or "?"
local _, args = token_ident_and_args(tok)
local tok_line = line_of(body_off + bt.rel) or 0
-- embedded markers live only in non-marker tokens.
if ident ~= "atom_label" and ident ~= "atom_offset" then emit_embedded_markers(tok, tok_line) end
-- atom_label / atom_offset: terminal markers, no further descent.
if ident == "atom_label" then emit_marker("label", args[1] or "", nil, tok_line); return
elseif ident == "atom_offset" then emit_marker("offset", args[1] or "", args[2] or "", tok_line); return
end
if ident:sub(1, 4) == "mac_" then
local bare = ident:sub(5)
local comp = ctx_table.component_index[bare]
if comp then
local invocation_root_call_text = walk_root_call_text or tok
if ctx_table.visiting[bare] then
-- cycle: still allocate inv_id, emit zero-width begin/end, record the cycle error; do NOT recurse.
local inv = emit_invoke_begin(comp.kind or "comp_bare", bare, tok, invocation_root_call_text, def_source, tok_line)
inv.parent_id = walk_parent_inv_id
inv.call_text = tok
local err = {
kind = "cycle",
msg = string.format("project_emission: component cycle detected: %q", bare),
source = def_source,
line = tok_line,
}
inv.errors[#inv.errors + 1] = err
errors [#errors + 1] = err
emit_invoke_end(inv)
return
end
-- first visit: descend + count + count_mismatch-check below.
ctx_table.visiting[bare] = true
local inv = emit_invoke_begin(comp.kind or "comp_bare", bare, tok, invocation_root_call_text, def_source, tok_line)
inv.parent_id = walk_parent_inv_id
inv.call_text = tok
inv.def_path = comp.source
inv.def_line = comp.declaration
-- propagate trackers into the recursive walk:
-- immediate_call_text = this call's tok (the IMMEDIATE outer call for words emitted in this body)
-- root_call_text = the OUTERMOST call (immutable across the recursion)
walk_body_entry({
body_tokens = comp.body_tokens or {},
body_off = comp.body_off or 0,
line_of = comp.line_of,
source = comp.source,
declaration = comp.declaration,
},
inv.id,
invocation_root_call_text,
tok)
ctx_table.visiting[bare] = nil
emit_invoke_end(inv)
-- count `word` items inside [start_word, end_word].
local wc_inside = 0
for i = inv.start_word, inv.end_word do
local it = items[i]
if it and it.kind == "word" then
wc_inside = wc_inside + 1
end
end
inv.word_count = wc_inside
-- count_mismatch is a construction error: word_counts["mac_X"] is the declared count populated by the components pass;
-- we compare against the measured word count.
local declared = ctx_table.word_counts["mac_" .. bare]
if declared and wc_inside ~= declared then
local err = {
kind = "count_mismatch",
msg = string.format("project_emission: mac_%s declared=%d measured=%d", bare, declared, wc_inside),
source = def_source,
line = tok_line,
}
inv.errors[#inv.errors + 1] = err
errors [#errors + 1] = err
end
return
end
-- mac_X NOT in component_index: fall through to opaque emit.
end
-- direct encoder, or mac_X-without-component: resolve count + emit n words.
-- resolve_count may emit a warning if the count is unresolved.
local n = resolve_count(ident, tok_line)
local out_ident = (ident == "nop2") and "nop" or ident
for _ = 1, n do
emit_word(out_ident, args, tok_line, tok, def_source, def_line, walk_immediate_call_text, walk_root_call_text)
end
end
for _, bt in ipairs(tokens) do
process_token(bt)
end
end
@@ -2608,87 +2545,4 @@ function M.project_emission(body_text, component_index, word_counts)
})
end
function M.expand_word_events(body_entry, component_index, word_counts)
local events = {}
local errors = {}
-- `word_idx` is 0-based across the entire expansion (root atom body + every recursed component body).
-- Each emitted machine word consumes one slot.
local word_idx = 0
local root_call_source = body_entry.source
local root_call_line = body_entry.declaration or 0
local function expand(tokens, body_off, line_of, def_source, call_source, call_line, visiting)
for _, bt in ipairs(tokens) do
local tok = M.trim(bt.tok or "")
if tok ~= "" then
local ident, args = token_ident_and_args(tok)
local tok_line = (line_of and line_of(body_off + bt.rel)) or 0
if ident == "atom_label" or ident == "atom_offset" then
-- Marker: zero events.
else
-- Strip the `mac_` prefix to look up the component by its BARE name.
local bare = nil
if ident:sub(1, E_MAC_PREFIX_LEN) == E_MAC_PREFIX then
bare = ident:sub(E_MAC_PREFIX_LEN + 1)
end
if bare and component_index and component_index[bare] then
if visiting[bare] then
-- Cycle: this component is already on the expansion stack.
errors[#errors + 1] = {
kind = "cycle",
msg = string.format("component cycle detected involving %q", bare),
source = def_source,
line = tok_line,
}
else
visiting[bare] = true
local inner = component_index[bare]
-- `call_source` / `call_line` (the ROOT atom site) are PRESERVED — we do NOT update them when recursing.
-- Nested events keep pointing at the original root atom.
expand(inner.body_tokens, inner.body_off, inner.line_of,
inner.source, call_source, call_line, visiting)
visiting[bare] = nil
end
else
-- Direct token (or unknown `mac_X` falling back). Emit `n` events.
local n = 1
if word_counts and word_counts[ident] then n = word_counts[ident] end
local out_ident = (ident == "nop2") and "nop" or ident
for _ = 1, n do
word_idx = word_idx + 1
events[#events + 1] = {
word = word_idx - 1,
ident = out_ident,
args = args,
source = def_source,
line = tok_line,
call_source = call_source,
call_line = call_line,
}
end
end
end
end
end
end
-- Initial call: the root atom body. `def_source` and `call_source` both start at the atom's source;
-- `call_line` starts at the atom's declaration line (every event from the root body inherits this).
expand(
body_entry.body_tokens,
body_entry.body_off or 0,
body_entry.line_of,
body_entry.source,
root_call_source,
root_call_line,
{})
return events, errors
end
return M
+7 -6
View File
@@ -11,11 +11,10 @@
--- ```
---
--- That small bootstrap: (a) locates this helper via `arg[0]` / `debug.getinfo`,
--- (b) loads it (which sets `package.path` + `package.cpath` via cached `git rev-parse`),
--- (b) loads it (which sets `package.path` + `package.cpath`),
--- (c) at the bottom calls `require("duffle")` (now resolvable since `package.path` was just set) and returns the duffle M.
--- Net effect: the caller gets the duffle module in one statement; no separate `dofile(...)` + `require("duffle")` dance.
---
--- Replaces the prior 2-line (entry) or 4-line (pass) pattern that had the call site do its own path resolution + duplicated setup.
local M = {}
@@ -27,9 +26,6 @@ local CACHE_KEY = "__duffle_repo_root__"
--- parent of the directory containing this script. We derive it directly from `debug.getinfo(1, "S").source`
--- (returns `@<path>` for the currently-running chunk).
---
--- Replaces the prior `io.popen("git rev-parse --show-toplevel")` approach, which cost ~100-180ms per
--- LuaJIT process on Windows due to git's CLI startup. The path-derive approach costs <1ms.
---
--- If `debug.getinfo` can't parse this script's path (shouldn't happen — dofile always populates source),
--- return nil and let `M.setup()` fail loud.
--- @return string|nil
@@ -61,7 +57,12 @@ end
function M.setup()
local repo_root = find_repo_root()
if not repo_root then
io.stderr:write("[duffle_paths] git rev-parse failed -- not in a git repo?\n")
-- Unreachable in practice: find_repo_root() derives the repo root from this script's
-- own source path via debug.getinfo(1, "S").source (no subprocess, no git CLI, <1ms).
-- A nil return means the source path did not match the expected
-- <repo>/scripts/duffle_paths.lua layout — a packaging bug, not a "missing git repo"
-- condition. os.exit(2) is retained so a real failure surfaces loud rather than
-- silently producing an unconfigured module table.
os.exit(2)
end
+2 -130
View File
@@ -616,7 +616,7 @@ end
--- - ELF32 symtab entry = 16 bytes (`st_name:4 + st_value:4 + st_size:4 + st_info:1 + st_other:1 + st_shndx:2`); offsets within each entry are zero-based wire offsets.
--- - Direct Lua `string.byte`/`string.sub`/`string.find` boundaries receive `+ 1`.
--- - We filter on STB_GLOBAL (high nibble of st_info = 1) to match `nm`'s default (external symbols only). STB_WEAK excluded.
--- - We strip the `code_` prefix to match the previous `read_nm` output.
--- - The `code_` prefix is stripped (MipsAtom_ macros emit bare atom names, no `code_` prefix).
--- - `st_size > 0` filter excludes undefined/imported symbols.
--- @param elf_path Path
--- @return table<string, {integer, integer}>
@@ -654,7 +654,7 @@ function M.read_nm(elf_path)
-- Extract the name from .strtab (null-terminated C string).
local name_end = strtab:find("\0", st_name_off + 1, true) or (st_name_off + 1)
local name = strtab:sub(st_name_off + 1, name_end - 1)
-- Filter: keep all symbol-table symbols (atoms emit their name as the bare `<name>` since the `code_` prefix was removed from the MipsAtom_ macro).
-- Filter: keep all symbol-table symbols (atoms emit their name as the bare `<name>` — MipsAtom_ macros strip the `code_` prefix).
-- The atoms_source_map pass already filters out non-atom symbols via the source-map.txt cross-ref.
if name and #name > 0 then
local st_value = M.read_u32_le(symtab, entry_off + SYM_ST_VALUE)
@@ -791,132 +791,4 @@ end
-- I/O helpers: atoms source-map + native directory glob
-- ════════════════════════════════════════════════════════════════════════════
--- Parse a FORMAT_VERSION <expected_version> atoms-meta file (sourcemap or provenance).
--- Shared by M.parse_source_map_file + M.parse_provenance_file.
--- The two callers differ only in how they parse WORD lines; that's `extract_word(line)`.
--- Returns the standard `{name -> {total, words}}` shape.
--- Returns `{}` on format-version mismatch (and logs to stderr).
--- @param path string
--- @param expected_version integer
--- @param extract_word fun(line: string): table|nil -- caller-supplied per-line parser
--- @return table<string, table>
function M.parse_atom_records(path, expected_version, extract_word)
local out = {}
local cur_name, cur_words = nil, {}
for raw in io.lines(path) do
local line = raw
if line:match("^#") then
local ver = line:match("^# FORMAT_VERSION%s+(%d+)")
if ver and tonumber(ver) ~= expected_version then
io.stderr:write(string.format(
"[elf_dwarf.parse_atom_records] version mismatch (got %s, expected %d) in %s\n",
ver, expected_version, path))
return {}
end
-- skip other comments
elseif line:sub(1, 4) == "ATOM" then
-- ATOM <name> "<abs-source-path>" <total>
local _, _, name = line:find("ATOM%s+(%S+)%s+\"[^\"]*\"%s+(%d+)")
if name then
cur_name = name
cur_words = {}
out[name] = { total = 0, words = cur_words }
end
elseif line == "ENDATOM" then
-- Update the recorded total from the entries count
-- (matches the `lines[1] = lines[1]:gsub(" 0$", " " .. total)` patch in atoms_source_map.lua:170).
if cur_name and out[cur_name] then
out[cur_name].total = #cur_words
end
cur_name, cur_words = nil, {}
elseif line:sub(1, 4) == "WORD" and cur_name then
local field = extract_word(line)
if field then
cur_words[#cur_words + 1] = field
end
end
end
return out
end
--- Parse a FORMAT_VERSION <expected_version> `*.atoms.sourcemap.txt` file.
--- Returns `{name -> {total = N, words = {{pos, line}, ...}}}`.
--- Returns `{}` on format-version mismatch (and logs to stderr).
---
--- **Wire format** (emitted by `passes/atoms_source_map.lua`):
--- ```
--- # FORMAT_VERSION <n>
--- ATOM <name> "<abs-source-path>" <total>
--- WORD <n> LINE <line> TEXT <text...>
--- ...
--- ENDATOM
--- ```
---
--- **Conventions:** the in-memory shape uses `{pos, line, text}`
--- (`atoms_source_map.lua:142`); the `.txt` file uses `WORD <n>` so the parser maps `n` → `pos` field name.
--- @param sm_path Path
--- @param expected_version integer -- expected FORMAT_VERSION line
--- @return table<string, table>
function M.parse_source_map_file(sm_path, expected_version)
return M.parse_atom_records(sm_path, expected_version, function(line)
local _, n, _, src_line = line:find("WORD%s+(%d+)%s+LINE%s+(%d+)")
if n and src_line then
return { pos = tonumber(n), line = tonumber(src_line) }
end
end)
end
--- Parse a FORMAT_VERSION <expected_version> `*.atoms.provenance.txt` file.
--- Returns `{name -> {total = N, words = {{pos, call_file, call_line, comp_name, comp_file, comp_line}, ...}}}`.
--- Returns `{}` on format-version mismatch (and logs to stderr).
---
--- **Wire format** (emitted by `passes/atoms_source_map.lua`):
--- ```
--- # FORMAT_VERSION <n>
--- ATOM <name> "<abs-source-path>" <total>
--- WORD <n> CALL <src-file>:<src-line> RAW
--- WORD <n> CALL <src-file>:<src-line> MACRO <comp_name> "<comp-file>:<comp-line>"
--- ...
--- ENDATOM
--- ```
---
--- **Used by** `passes/dwarf_injection.lua` to:
--- - group consecutive MACRO rows into component invocations (one `DW_TAG_inlined_subroutine` each)
--- - emit abstract `DW_TAG_subprogram` per unique component name
--- - extend `.debug_line` so stepping into a `mac_X(...)` lands on the component's source line.
--- @param prov_path string -- path to *.atoms.provenance.txt
--- @param expected_version integer -- expected FORMAT_VERSION line
--- @return table<string, table>
function M.parse_provenance_file(prov_path, expected_version)
return M.parse_atom_records(prov_path, expected_version, function(line)
-- Two accepted shapes:
-- WORD <n> CALL <call-file>:<call-line> RAW
-- WORD <n> CALL <call-file>:<call-line> MACRO <comp_name> "<comp-file>:<comp-line>"
local pos, call_file, call_line, comp_name, comp_file, comp_line =
line:match('WORD%s+(%d+)%s+CALL%s+(.-):(%d+)%s+MACRO%s+(%S+)%s+"([^"]*):(%d+)"')
if pos then
return {
pos = tonumber(pos),
call_file = call_file,
call_line = tonumber(call_line),
comp_name = comp_name,
comp_file = comp_file,
comp_line = tonumber(comp_line),
}
end
-- RAW row.
local raw_pos, raw_file, raw_line = line:match('WORD%s+(%d+)%s+CALL%s+(.-):(%d+)%s+RAW')
if raw_pos then
return {
pos = tonumber(raw_pos),
call_file = raw_file,
call_line = tonumber(raw_line),
comp_name = nil,
comp_file = nil,
comp_line = nil,
}
end
end)
end
return M
+3 -22
View File
@@ -7,7 +7,7 @@
---
--- Writes:
--- - `<ctx.out_root>/<dir_basename>.errors.h` — one per module, with `#error` directives on findings (the C compile will surface the error)
--- - The annotations.txt report is rendered by `passes/report.lua` from the per-module results stashed in `ctx.flags._annot_results`
--- - The annotations.txt report is rendered by `passes/report.lua` from the canonical `corpus.sources_by_dir` projection (re-validating each source via `M.validate()`).
---
--- **Conventions**: tabs (1/level), EmmyLua annotations, no regex, Lua 5.3 compatible
@@ -45,8 +45,6 @@ local ensure_dir = duffle.ensure_dir
--- @field project_root string
--- @field upstream table<string, table>
--- @field flags table
--- @field flags._annot_results table[] -- stashed by annotation pass; consumed by report.lua
--- @field dry_run boolean
--- @field verbose boolean
--- @class PassResult
@@ -500,9 +498,10 @@ end
--- Validate one source against its pre-scanned SourceScan payload + the corpus-wide pipe_ctx.
--- @param ctx PassCtx
--- @param src SourceFile
--- @param corpus_pipe_ctx PipeCtx -- built once per pass from corpus registries
--- @param corpus_pipe_ctx PipeCtx|nil -- built once per pass from corpus registries; nil = self-build (canonical projection).
--- @return AnnotatedResult
local function validate(ctx, src, corpus_pipe_ctx)
corpus_pipe_ctx = corpus_pipe_ctx or build_corpus_pipe_ctx(ctx)
local scan = src.scan
-- Project the pre-scanned atoms to the AtomEntry shape this pass needs.
@@ -640,7 +639,6 @@ end
--- 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
return nil
end
@@ -668,17 +666,6 @@ local function emit_module_errors_h(ctx, dir_basename, atoms_count, errors, sour
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,
}
end
-- ════════════════════════════════════════════════════════════════════════════
-- M.run — orchestrator entry
-- ════════════════════════════════════════════════════════════════════════════
@@ -713,13 +700,9 @@ function M.run(ctx)
local dir_atoms = 0
local dir_errors = {}
local dir_warnings = {}
-- Per-source validate() results, cached for the report pass (it reads from this instead of re-validating each source).
ctx.flags = ctx.flags or {}
ctx.flags._annot_source_results = ctx.flags._annot_source_results or {}
for _, src in ipairs(dir_sources) do
local result = validate(ctx, src, corpus_pipe_ctx)
result.source = src.path -- tag for downstream rendering
ctx.flags._annot_source_results[src.path] = result -- stash so report.lua reads from cache instead of re-running validate()
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 }
@@ -735,8 +718,6 @@ function M.run(ctx)
if err_path then
table.insert(outputs, { errors_h = err_path })
end
emit_module_annotations_stub(ctx, dir, dir_basename, dir_atoms)
end
return { outputs = outputs, errors = errors, warnings = warnings }
+9 -41
View File
@@ -63,9 +63,8 @@ local FORMAT_VERSION = 1
--- @class AtomSourceMapCtx
--- @field shared table -- `ctx.shared`
--- @field shared.corpus table -- canonical source-order corpus
--- @field shared.word_counts table -- identity alias of `corpus.word_counts`
--- @field shared.word_counts table
--- @field out_root string -- output root (e.g. "build/gen")
--- @field dry_run boolean -- if true, compute but don't write
--- @field flags table -- `ctx.flags`; reads `flags.gdb_runtime` + `flags.elf_path`
-- ════════════════════════════════════════════════════════════════════════════
@@ -391,31 +390,6 @@ local function append_gdb_commands(lines, matched)
lines[#lines + 1] = "end"
lines[#lines + 1] = ""
-- ── show_c2 ──
-- GTE data regs (COP2). pcsx-redux's gdb stub doesn't expose COP2 (only 72 regs: 32 GPR + COP0 + FPR).
-- curl http://localhost:8080/api/v1/lua/gte
-- We keep the command definition as a stub that points the user at the plugin.
lines[#lines + 1] = "define show_c2"
lines[#lines + 1] = ' echo "[gdb_tape_atoms] show_c2: gdb stub does not expose COP2 in this build."'
lines[#lines + 1] = ' echo "[gdb_tape_atoms] Use scripts/pcsx_debug_helper.zip + curl http://localhost:8080/api/v1/lua/gte"'
lines[#lines + 1] = ' echo "[gdb_tape_atoms] (or pcsx-redux Debug > Registers window for a native view)"'
lines[#lines + 1] = "end"
lines[#lines + 1] = "document show_c2"
lines[#lines + 1] = " Stub. The gdb stub in this pcsx-redux build does not expose COP2 regs."
lines[#lines + 1] = " For GTE data + control state, use the pcsx_debug_helper Lua plugin or the"
lines[#lines + 1] = " pcsx-redux Debug > Registers window."
lines[#lines + 1] = "end"
lines[#lines + 1] = ""
-- ── show_c2ctl ──
lines[#lines + 1] = "define show_c2ctl"
lines[#lines + 1] = ' echo "[gdb_tape_atoms] show_c2ctl: see show_c2 for the same workaround."'
lines[#lines + 1] = "end"
lines[#lines + 1] = "document show_c2ctl"
lines[#lines + 1] = " Stub. Same workaround as show_c2."
lines[#lines + 1] = "end"
lines[#lines + 1] = ""
-- ── wave_ctx ──
lines[#lines + 1] = "define wave_ctx"
lines[#lines + 1] = ' printf "$t4 = R_FaceCursor 0x%08x\\n", $t4'
@@ -489,10 +463,8 @@ local function emit_gdb_runtime(ctx)
lines[#lines + 1] = 'printf "[gdb_tape_atoms] runtime loaded %d atoms from %s\\n", $__atom_count, $__elf_path'
local out_path = ctx.out_root .. "/gdb_tape_atoms_runtime.gdb"
if not ctx.dry_run then
duffle.ensure_dir(duffle.dirname(out_path))
duffle.write_file_lf(out_path, table.concat(lines, "\n") .. "\n")
end
duffle.ensure_dir(duffle.dirname(out_path))
duffle.write_file_lf(out_path, table.concat(lines, "\n") .. "\n")
io.stderr:write(string.format(
"[atoms_source_map] wrote %s (%d atoms)\n", out_path, #matched))
end
@@ -533,7 +505,9 @@ function M.run(ctx)
for _, src in ipairs(corpus.source_order) do
local has_projection = false
for _, atom in ipairs((src.scan or {}).atoms or {}) do
if atom.paths then has_projection = true; break end
if (atom.kind == "atom" or atom.kind == "raw_atom") and atom.paths then
has_projection = true; break
end
end
if not has_projection then
for _, atom in ipairs((src.scan or {}).raw_atoms or {}) do
@@ -542,21 +516,15 @@ function M.run(ctx)
end
if has_projection then
local basename = duffle.basename_no_ext(src.path)
-- (1) atoms.sourcemap.txt — format-1 per-word call-site map.
local sourcemap_path = ctx.out_root .. "/" .. basename .. ".atoms.sourcemap.txt"
local sourcemap_body = render_source_map(src)
-- (2) atoms.provenance.txt — format-1 per-word definition/body map.
local prov_path = ctx.out_root .. "/" .. basename .. ".atoms.provenance.txt"
local prov_body = render_provenance(src, wc)
if not ctx.dry_run then
duffle.ensure_dir(duffle.dirname(sourcemap_path))
duffle.write_file_lf(sourcemap_path, sourcemap_body)
duffle.write_file_lf(prov_path, prov_body)
end
duffle.ensure_dir(duffle.dirname(sourcemap_path))
duffle.write_file_lf(sourcemap_path, sourcemap_body)
duffle.write_file_lf(prov_path, prov_body)
outputs[#outputs + 1] = { kind = "report", path = sourcemap_path }
outputs[#outputs + 1] = { kind = "report", path = prov_path }
end
+17 -44
View File
@@ -11,15 +11,6 @@
--- **Conventions**: tabs (1/level), EmmyLua annotations, no regex,
--- Lua 5.3 compatible.
--- @class Component
--- @field name string
--- @field body string
--- @field args string|nil
--- @field line integer
--- @field comment string|nil
--- @class M
-- ════════════════════════════════════════════════════════════════════════════
-- Module-scope requires + package.path setup
-- ════════════════════════════════════════════════════════════════════════════
@@ -31,7 +22,6 @@
-- duffle_paths.lua sets package.path then returns `require("duffle")` at the bottom, so the dofile value IS the duffle module.
local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./"
local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
local word_count_eval = require("word_count_eval")
-- ════════════════════════════════════════════════════════════════════════════
-- Constants
@@ -74,7 +64,6 @@ local GEN_SUBDIR = "gen"
--- @field project_root string -- project root (e.g. "code/")
--- @field upstream table<string, table> -- per-pass upstream outputs
--- @field flags table -- CLI flags
--- @field dry_run boolean -- if true, compute but don't write
--- @field verbose boolean -- log diagnostic info
--- @class PassResult
@@ -155,8 +144,7 @@ local function preceding_comment_block(source, pos)
local scan_pos = pos
local pieces = {}
while true do
-- Skip whitespace (space/tab/newline/CR) backward from `scan_pos`,
-- returning the position of the first non-whitespace char.
-- skip whitespace backward; land on the next non-ws character.
local non_ws = scan_pos - 1
while non_ws > 0 do
local ch = source:sub(non_ws, non_ws)
@@ -167,13 +155,9 @@ local function preceding_comment_block(source, pos)
end
end
if non_ws == 0 then break end
local is_block_close = non_ws >= 2 and source:sub(non_ws - 1, non_ws) == "*/"
local is_line_end = source:sub(non_ws, non_ws) == "\n" or source:sub(non_ws, non_ws) == "\r"
if is_block_close then
-- Find the opening `/*` for a block comment whose `*/` ends at `non_ws`.
-- Walk back from `non_ws` over `/*` candidates.
if non_ws >= 2 and source:sub(non_ws - 1, non_ws) == "*/" then
-- block comment close: find the opening /* by walking back over /* candidates
-- in source[1..non_ws-1].
local prefix = source:sub(1, non_ws - 1)
local open_at = nil
for scan = #prefix - 1, 1, -1 do
@@ -183,33 +167,28 @@ local function preceding_comment_block(source, pos)
end
end
if not open_at then break end
-- Walk back from `open_at` over leading spaces + tabs to include the indentation before the `/*`.
-- include the indentation before the /* by walking back over leading spaces + tabs.
local block_start = open_at
while block_start > 1 do
local ch = source:sub(block_start - 1, block_start - 1)
if ch == " " or ch == "\t" then
block_start = block_start - 1
else
break
end
if ch ~= " " and ch ~= "\t" then break end
block_start = block_start - 1
end
table.insert(pieces, 1, source:sub(block_start, non_ws))
scan_pos = block_start
elseif is_line_end then
-- Walk back from `non_ws` to the start of the source line (the most recent `\n` or position 1).
else
-- line comment path: must end in newline, must start with //.
local ch = source:sub(non_ws, non_ws)
if ch ~= "\n" and ch ~= "\r" then break end
-- walk back from non_ws to the start of the source line (most recent \n or position 1).
local line_start = non_ws
while line_start > 1 and source:sub(line_start - 1, line_start - 1) ~= "\n" do
line_start = line_start - 1
end
local line = source:sub(line_start, non_ws)
if line:sub(1, 2) == "//" then
table.insert(pieces, 1, line)
scan_pos = line_start - 1
else
break
end
else
break
if line:sub(1, 2) ~= "//" then break end
table.insert(pieces, 1, line)
scan_pos = line_start - 1
end
end
if #pieces == 0 then return "" end
@@ -247,7 +226,7 @@ local function extract_arg_names(args_str)
local ident_start = ident_end
while ident_start > 0 do
local ch = trimmed:sub(ident_start, ident_start)
if duffle.is_alnum(ch) or ch == "_" then
if duffle.is_alnum_byte(string.byte(ch)) or ch == "_" then
ident_start = ident_start - 1
else
break
@@ -545,7 +524,6 @@ 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).
--- Honors `ctx.dry_run`: prints the intended path but does not write the file.
--- @param ctx PassCtx
--- @param src SourceFile
--- @param components Component[]
@@ -563,11 +541,6 @@ local function emit_component_macros_h(ctx, src, components, counts)
end
local content = table.concat(lines, "\n") .. "\n"
if ctx.dry_run then
print(string.format(" -> %s (dry-run)", out_path))
return out_path
end
duffle.ensure_dir(out_dir)
duffle.write_file_lf(out_path, content)
print(string.format(" -> %s", out_path))
@@ -640,7 +613,7 @@ end
--- (internal) Populate the canonical `corpus.component_body_index` projection with this source's body index entries.
--- First declaration wins; later declarations are dropped (no separate collision record: the components collision is already surfaced by `update_canonical_components`).
--- The pass does NOT write to `ctx.shared.component_body_index` (the legacy corpus owns this projection).
--- The pass does NOT write to `ctx.shared.component_body_index` (the corpus owns this projection).
--- @param corpus table -- the canonical corpus
--- @param src SourceFile
--- @param components Component[]
+107 -106
View File
@@ -46,10 +46,8 @@ local lfs = require("lfs")
-- File-scope aliases to elf_dwarf helpers; the canonical implementations live in scripts/elf_dwarf.lua.
-- ELF decoding helpers come from `elf_dwarf.lua`.
local read_uleb128_at = elf_dwarf.read_uleb128_at
local read_sleb128_at = elf_dwarf.read_sleb128_at
local find_abbrev_table_end = elf_dwarf.find_abbrev_table_end
-- Note: uleb128 + sleb128 are encoders; read_uleb128_at + read_sleb128_at are decoders. Different functions.
-- Local DWARF opcode constants + length-prefixed integers (uleb128 + sleb128 encoders are in elf_dwarf.lua).
local uleb128 = elf_dwarf.uleb128
local sleb128 = elf_dwarf.sleb128
@@ -207,14 +205,13 @@ local DW_FORM_udata = 0x0F -- ULEB128 (DW_AT_byte_size for struct_typ
local DW_FORM_implicit_const = 0x21 -- DWARF5 §7.5.6: abbrev declaration carries a SLEB constant (used by the abbrev-table walker)
local DW_FORM_sec_offset = 0x17 -- 4-byte section-relative offset (into .debug_loclists / .debug_rnglists)
local DW_OP_reg0 = 0x50
-- DW_OP_regN = 0x50 + N; the variable's value resides in that register.
local DW_OP_piece = 0x93 -- followed by ULEB128 byte count (per DWARF5 §7.7.5)
-- DW_OP_reg0 + DW_OP_piece are declared canonically above (lines 114-116) alongside the other DWARF5 §7.7.3 loclist opcodes.
local DW_ATE_unsigned = 0x07 -- DWARF5 §7.8.1: DW_ATE_unsigned (used for U4 base type)
local DW_LANG_C99 = 0x0C -- = 12 (C99); use as a safe "C-like" placeholder
-- (DW_LANG_Mips_Assembler = 0x8001 was used in the, but we want this CU to look like a C TU so VSCode's Variables pane treats it as code.)
-- No DW_AT_language attribute is emitted (see build_debug_info_section's abbrev 100).
-- R_<name> → MIPS GPR lookups go through the merged register_alias_registry
-- (collected by collect_per_source_registries from ctx.sources[*].scan.register_alias_registry).
@@ -616,13 +613,14 @@ local function build_atom_sequence(atom)
-- If entry 1 is the first word of a component invocation, emit the component-definition row at the same PC immediately after.
-- The def line is always a statement target unless the inv is explicitly skip-over (atom_dbg_skip_over);
-- the whole-atom skip path takes the early-return at line ~497 above so atom_is_stmt is irrelevant here.
-- For the body row, prefer `body_lines[1]` (the actual source line of the first body word in the macro's expansion)
-- For the body row, prefer `body_lines[1]` (the actual source line of the first body word in the macro's expansion)
-- over `comp_line` (the macro signature line).
-- When `body_lines` is absent (the component was not indexed by the scan, e.g. an external macro),
-- fall back to `comp_line` so the line program remains valid.
-- Canonical contract: the emission-model pass populates `body_lines` for every invocation;
-- absence now is an error (older emitters / external macros are no longer supported).
if inv1 and 1 == inv1.start_pos + 1 then
local comp_file_idx_1 = resolve_provenance_file_index(inv1.comp_file)
local body_line_1 = (inv1.body_lines and inv1.body_lines[1]) or inv1.comp_line
assert(inv1.body_lines, "missing body_lines: emitter did not run emission-model")
local body_line_1 = inv1.body_lines[1]
emit_row(comp_file_idx_1, body_line_1, not inv1.skip_over)
end
@@ -648,18 +646,20 @@ local function build_atom_sequence(atom)
emit_row(call_file_idx, inv.call_line, atom_is_stmt)
-- 2) component body row at this PC.
-- Prefer `body_lines[1]` over `comp_line` so gdb's `step` lands on the macro's actual body line (not the signature line above `{ ... }`).
-- Fallback to `comp_line` when the component isn't indexed.
-- Canonical contract: `body_lines` is always populated by the emission-model pass.
local body_file_idx = resolve_provenance_file_index(inv.comp_file)
local body_line_1 = (inv.body_lines and inv.body_lines[1]) or inv.comp_line
assert(inv.body_lines, "missing body_lines: emitter did not run emission-model")
local body_line_1 = inv.body_lines[1]
emit_row(body_file_idx, body_line_1, not inv.skip_over)
elseif inv then
-- Subsequent word of an invocation: 1-based offset into body_lines:
-- word 1 of the invocation corresponds to body_lines[1], word 2 -> body_lines[2], etc.
-- 1-based offset = idx - inv.start_pos (since idx = inv.start_pos + i for the i-th body word).
-- When `body_lines` is missing (older emitters / external macros), fall back to comp_line.
-- Canonical contract: `body_lines` is always populated by the emission-model pass.
local body_file_idx = resolve_provenance_file_index(inv.comp_file)
local words_into = idx - inv.start_pos -- 1-based word position in invocation
local body_line_i = (inv.body_lines and inv.body_lines[words_into]) or inv.comp_line
assert(inv.body_lines, "missing body_lines: emitter did not run emission-model")
local body_line_i = inv.body_lines[words_into]
emit_row(body_file_idx, body_line_i, not inv.skip_over)
else
-- RAW word: single call-site row. emit_row restores is_stmt after a selected component range before exposing this adjacent row.
@@ -700,85 +700,97 @@ end
--- @param skip_over table -- {atoms = {[symbol] = association}, components = {[file|name] = association}}
--- @return table[] -- list of {name, addr, size_bytes, words, entries, invocations, skip_over?}
local function build_atom_table(corpus, addrs, skip_over)
-- Cross-ref: keep only atoms present in BOTH the nm symbol table AND
-- the canonical corpus projection. Output is sorted by ascending addr.
local atoms_by_name = corpus.atoms_by_name or {}
-- Cross-ref: keep only the atoms that exist in BOTH the nm symbol table AND the canonical corpus projection.
-- Address-ascending sort + lexical Stable tie-breaker: declaration order, then symbol address.
local out = {}
for name, info in pairs(addrs) do
-- Per-atom ingest. Returns nil if the atom is absent from the corpus
-- (caller skips it via the `if atom then ...` guard).
local function ingest_atom(name, info)
local atom_record = atoms_by_name[name]
if atom_record then
local paths = atom_record.paths or {}
local word_events = paths.word_events or {}
local invocations_proj = paths.invocations or {}
if not atom_record then return nil end
-- Build the dense entries list from `word_events`. `word_events[i].i` is the 0-based `.word` position;
-- `call_line` is the root atom's physical source line for that word (stamped by emission_model).
local entries = {}
for idx, ev in ipairs(word_events) do
entries[#entries + 1] = {
pos = ev.i or (idx - 1),
line = ev.call_line or 0,
text = ev.call_text or "",
local paths = atom_record.paths or {}
local word_events = paths.word_events or {}
local invocations_proj = paths.invocations or {}
-- Build the dense entries list from `word_events`.
-- `word_events[i].i` = the 0-based `.word` position
-- `call_line` = the root atom's physical source line for that word
-- (stamped by emission_model)
local entries = {}
for idx, ev in ipairs(word_events) do
entries[#entries + 1] = {
pos = ev.i or (idx - 1),
line = ev.call_line or 0,
text = ev.call_text or "",
}
end
local atom = {
name = name,
addr = info[1],
size_bytes = info[2],
words = #word_events,
entries = entries,
skip_over = skip_over.atoms[name] ~= nil,
}
-- Group consecutive `word_events` rows whose outermost invocation is the SAME
-- format-1 invocation into a single `atom.invocations` entry. Rows sharing the
-- same comp_name / call_file / call_line / comp_file / comp_line are part of
-- the same group. Rows outside any invocation flush cur_inv.
if #invocations_proj > 0 then
local invocations = {}
-- Process one word_event row against the current group state.
-- Returns the (possibly updated) cur_inv.
local function row(ev, cur_inv)
local outer_id = ev.outermost_invocation_id
local outer_inv = outer_id and invocations_proj[outer_id] or nil
if not (outer_inv and outer_inv.component_name) then
-- raw row: flush any pending cur_inv; no new group starts.
if cur_inv then invocations[#invocations + 1] = cur_inv end
return nil
end
local inv_key = outer_inv.component_name
.. "|" .. (outer_inv.call_path or "")
.. "|" .. tostring(outer_inv.call_line or 0)
.. "|" .. (outer_inv.def_path or "")
.. "|" .. tostring(outer_inv.def_line or 0)
local ev_pos = ev.i or 0
if cur_inv and cur_inv.key == inv_key then
-- same group: extend range + append body line.
cur_inv.end_pos = ev_pos
cur_inv.body_lines[#cur_inv.body_lines + 1] = ev.body_line or 0
return cur_inv
end
-- key changed (or no current group): flush + start new.
if cur_inv then invocations[#invocations + 1] = cur_inv end
return {
key = inv_key,
comp_name = outer_inv.component_name,
call_file = outer_inv.call_path or "",
call_line = outer_inv.call_line or 0,
comp_file = outer_inv.def_path or "",
comp_line = outer_inv.def_line or 0,
start_pos = ev_pos,
end_pos = ev_pos,
skip_over = skip_over.components[normalize_debug_path(outer_inv.def_path or ""):lower() .. "\0" .. outer_inv.component_name] ~= nil,
body_lines = { ev.body_line or 0 },
}
end
local atom = {
name = name,
addr = info[1],
size_bytes = info[2],
words = #word_events,
entries = entries,
skip_over = skip_over.atoms[name] ~= nil,
}
-- Group consecutive `word_events` rows whose outermost invocation
-- is the SAME format-1 invocation into a single `atom.invocations`
-- entry. Keep entries grouped by outermost invocation
-- (two consecutive rows with the same comp_name/call_file/call_line/
-- comp_file/comp_line are part of the same invocation).
if #invocations_proj > 0 then
local invocations = {}
local cur_inv = nil
for _, ev in ipairs(word_events) do
local outer_id = ev.outermost_invocation_id
local outer_inv = outer_id and invocations_proj[outer_id] or nil
if outer_inv and outer_inv.component_name then
local inv_key = outer_inv.component_name
.. "|" .. (outer_inv.call_path or "")
.. "|" .. tostring(outer_inv.call_line or 0)
.. "|" .. (outer_inv.def_path or "")
.. "|" .. tostring(outer_inv.def_line or 0)
local ev_pos = ev.i or 0
if cur_inv and cur_inv.key == inv_key then
cur_inv.end_pos = ev_pos
cur_inv.body_lines[#cur_inv.body_lines + 1] = ev.body_line or 0
else
if cur_inv then invocations[#invocations + 1] = cur_inv end
cur_inv = {
key = inv_key,
comp_name = outer_inv.component_name,
call_file = outer_inv.call_path or "",
call_line = outer_inv.call_line or 0,
comp_file = outer_inv.def_path or "",
comp_line = outer_inv.def_line or 0,
start_pos = ev_pos,
end_pos = ev_pos,
skip_over = skip_over.components[normalize_debug_path(outer_inv.def_path or ""):lower()
.. "\0" .. outer_inv.component_name] ~= nil,
body_lines = { ev.body_line or 0 },
}
end
else
-- RAW row: flush the current invocation.
if cur_inv then invocations[#invocations + 1] = cur_inv; cur_inv = nil end
end
end
if cur_inv then invocations[#invocations + 1] = cur_inv end
atom.invocations = invocations
local cur_inv = nil
for _, ev in ipairs(word_events) do
cur_inv = row(ev, cur_inv)
end
out[#out + 1] = atom
if cur_inv then invocations[#invocations + 1] = cur_inv end
atom.invocations = invocations
end
return atom
end
local out = {}
for name, info in pairs(addrs) do
local atom = ingest_atom(name, info)
if atom then out[#out + 1] = atom end
end
table.sort(out, function(a, b) return a.addr < b.addr end)
return out
@@ -1283,7 +1295,7 @@ end
--- Abbrev 100 (DW_TAG_compile_unit, with children):
--- DW_AT_name (DW_FORM_strp) -- CU name
--- DW_AT_comp_dir (DW_FORM_strp) -- compilation dir
--- DW_AT_language (DW_FORM_data1) -- DW_LANG_C99
--- DW_AT_language (DW_FORM_data1) -- one byte language code
--- Abbrev 101 (DW_TAG_subprogram, with children):
--- DW_AT_name (DW_FORM_string) -- atom function name
--- DW_AT_low_pc (DW_FORM_addr) -- atom.addr
@@ -2099,8 +2111,6 @@ end
--- Build the new .debug_info: SPLICE inserted DIEs into the MAIN CU as children.
---
--- This function appended a DETACHED synthetic CU to the end of .debug_info.
--- That put every atom DIE in a separate CU from the one GDB selected for PC lookup, so `RR_PrimCursor` + `bind_args` never appeared in scope.
--- This implementation instead:
--- 1. Builds the inserted-children bytes (base_type, struct_types, subprograms with their RR_* + bind_args children) via build_inserted_children.
--- 2. Patches the main CU's `unit_length` field to account for the inserted bytes.
@@ -2109,7 +2119,7 @@ end
--- 5. Replaces the main CU's final byte (the root children-terminator, 0) with: inserted_children_bytes + a single 0 byte (root terminator preserved).
---
--- The crt CU (everything before main_cu_start) is preserved.
--- **No detached synthetic CU is appended.**
--- @param existing string -- existing .debug_info section bytes
--- @param main_cu_start integer -- 0-based offset of the main CU's unit_length field
--- @param main_cu_end_excl integer -- 0-based offset of the first byte AFTER the main CU
@@ -2156,21 +2166,11 @@ end
--- Atoms don't have stack frames. The .debug_loc section describes per-instruction location adjustments for call-frame-based variables;
--- We use DW_OP_regN which is register-based and doesn't need .debug_loc entries).
--- The section itself must not be empty OR gdb may complain; the DW_LLE_end_of_list marker (per DWARF5 §7.7) is a single byte 0x00.
-- local function build_debug_loc_section() return string.char(0x00) end
--
-- The actual .debug_loc emission is `string.char(DW_LLE_end_of_list)` inlined
-- at the per-section writers below (the dispatch is in M.run, not a table).
local SECTION_BUILDERS = {
debug_line = build_dwarf_line_section,
debug_aranges = build_dwarf_aranges_section,
debug_rnglists = build_dwarf_rnglists_section,
debug_abbrev = build_debug_abbrev_section,
debug_info = build_debug_info_section,
debug_str = build_debug_str_section,
debug_loc = function() return string.char(DW_LLE_end_of_list) end,
-- debug_loclists is fed directly in M.run (it needs the merged registries for the R_TapePtr GPR lookup;
-- the SECTION_BUILDERS table cannot carry that context, so the dispatch is inlined in the per-run writers loop).
}
-- Per-section output path resolver (mirrors SECTION_BUILDERS).
-- Per-section output path resolver.
-- Returns the on-disk path for the section's `.bin` blob.
local SECTION_WRITERS = {
debug_line = function(out_root, basename) return out_root .. "\\" .. basename .. ".dwarf_line.bin" end,
@@ -2244,10 +2244,10 @@ function M.run(ctx)
-- Read the existing DWARF sections directly (no subprocess; lfs + io.open + manual ELF32 section-header walk).
-- We need all 8 sections: .debug_line / .debug_aranges / .debug_rnglists get extended
-- (additional rows appended to the existing unit), and .debug_info / .debug_abbrev / .debug_str / .debug_loc / .debug_loclists
-- (additional rows appended to the existing unit), and .debug_info / .debug_abbrev / .debug_str / .debug_loc / .debug_loclists
-- get spliced (the main CU's unit_length is patched;
-- no new compile unit is appended; debug_loc/debug_loclists may not exist in the source ELF so we add-section them on splice).
-- The dispatch (SECTION_BUILDERS) handles each.
-- The per-section dispatch is inlined in the writers loop below.
local existing_sections = elf_dwarf.read_elf_sections(elf_path, {
".debug_line", ".debug_aranges", ".debug_rnglists",
".debug_info", ".debug_abbrev", ".debug_str",
@@ -2303,6 +2303,7 @@ function M.run(ctx)
--
-- .debug_str now also receives the new RR_<R_Name> entries
-- (the merged registry drives the strings table to keep .debug_str and .debug_info in sync).
-- (the per-section dispatch is inlined in the writers loop below)
local basename = ctx.basename or duffle.basename_no_ext(elf_path) or DEFAULT_BASENAME
if ctx.out_root and ctx.out_root ~= "" then
duffle.ensure_dir(ctx.out_root)
+33 -43
View File
@@ -123,7 +123,7 @@ end
-- Run the emission-model pass.
-- ─────────────────────────────────────────────────────────────────────────
--- @param ctx PassCtx -- { shared = { corpus = ... }, out_root, dry_run, ... }
--- @param ctx PassCtx -- { shared = { corpus = ... }, out_root, ... }
--- @return PassResult
function M.run(ctx)
local outputs = {}
@@ -134,53 +134,43 @@ function M.run(ctx)
if type(corpus) ~= "table" then error("emission_model: ctx.shared.corpus is required (canonical projection)", 0) end
if type(corpus.source_order) ~= "table" then error("emission_model: ctx.shared.corpus.source_order is required", 0) end
-- Walk every source in canonical source order; for each source, iterate atoms.
-- Atom declarations (`kind == "atom"` / `"raw_atom"`) receive the canonical `atom.paths` projection; component declarations
-- (`comp_bare` / `comp_proc`) are recursively expanded by atom projections and do not get an independent projection themselves.
-- Test-only fixtures that need per-component word events may still consume `duffle.expand_word_events`
-- (which remains available; emission-model owns the canonical per-atom projection).
-- Project once, collect errors + warnings for one atom.
-- Kind must be one of: atom | raw_atom | comp_bare | comp_proc.
local function process_atom(atom, src)
if not (atom and atom.body) then return end
local kind = atom.kind
if kind ~= "atom" and kind ~= "raw_atom" and kind ~= "comp_bare" and kind ~= "comp_proc" then
return
end
local proj = project_atom(atom, src, corpus)
for _, e in ipairs(proj.errors) do
-- Preserve `kind` (cycle / count_mismatch / unbalanced) so readers can dispatch on the diagnostic class without re-parsing the message string.
errors[#errors + 1] = {
kind = e.kind,
line = e.line,
msg = e.msg,
source = e.source or src.path,
}
end
for _, w in ipairs(proj.warnings) do
warnings[#warnings + 1] = {
kind = w.kind,
line = w.line,
msg = w.msg,
}
end
end
-- Walk every source in canonical order; for each source, iterate atoms + raw_atoms.
-- Recognized kinds (atom | raw_atom | comp_bare | comp_proc) each receive the atom.paths projection via duffle.project_emission.
-- Components are macros inlined into atom bodies; focused tests and isolated component analyses consume atom.paths directly.
for _, src in ipairs(corpus.source_order) do
local scan = src.scan or {}
for _, atom in ipairs(scan.atoms or {}) do
if atom and atom.body and (atom.kind == "atom" or atom.kind == "raw_atom") then
local proj = project_atom(atom, src, corpus)
for _, e in ipairs(proj.errors) do
-- Preserve `kind` (cycle / count_mismatch / unbalanced) so readers can dispatch on the diagnostic class without re-parsing the message string.
errors[#errors + 1] = {
kind = e.kind,
line = e.line,
msg = e.msg,
source = e.source or src.path,
}
end
for _, w in ipairs(proj.warnings) do
warnings[#warnings + 1] = {
kind = w.kind,
line = w.line,
msg = w.msg,
}
end
end
process_atom(atom, src)
end
for _, atom in ipairs(scan.raw_atoms or {}) do
if atom and atom.body then
local proj = project_atom(atom, src, corpus)
for _, e in ipairs(proj.errors) do
errors[#errors + 1] = {
kind = e.kind,
line = e.line,
msg = e.msg,
source = e.source or src.path,
}
end
for _, w in ipairs(proj.warnings) do
warnings[#warnings + 1] = {
kind = w.kind,
line = w.line,
msg = w.msg,
}
end
end
process_atom(atom, src)
end
end
+3 -6
View File
@@ -48,9 +48,8 @@ local OFFSET_MACRO_COL = 44
--- @class PassCtx
--- @field shared table -- cross-pass shared state
--- @field shared.corpus table -- canonical corpus projection
--- @field shared.word_counts table -- compatibility alias to corpus.word_counts
--- @field shared.word_counts table
--- @field out_root string -- output root (e.g. "build/gen")
--- @field dry_run boolean -- if true, compute but don't write
--- @class PassResult
--- @field outputs table[] -- {kind=, path=} entries describing emit files
@@ -220,10 +219,8 @@ local function process_source(ctx, src)
if #atoms_data == 0 then return nil end
local out_path = src.dir .. "/gen/" .. duffle.basename_no_ext(src.dir) .. ".offsets.h"
if not ctx.dry_run then
duffle.ensure_dir(duffle.dirname(out_path))
duffle.write_file(out_path, generate_header(src.path:gsub("/", "\\"), atoms_data))
end
duffle.ensure_dir(duffle.dirname(out_path))
duffle.write_file(out_path, generate_header(src.path:gsub("/", "\\"), atoms_data))
return out_path
end
+29 -26
View File
@@ -5,8 +5,10 @@
--- - `build/gen/<dir_basename>.annotations.txt` — one per source-directory containing atoms; aggregates across all sources in the directory.
--- - `build/gen/annotation_validation.txt` — the project summary.
---
--- The annotation pass stashes per-MODULE summary entries in `ctx.flags._annot_results` (set by `passes/annotation.lua`).
--- This pass re-validates each source via `annotation.validate()` to get the detailed per-source results needed for the report.
--- The annotation pass emits `errors.h` files per module and the canonical
--- `corpus.sources_by_dir` projection groups sources by directory. This pass
--- iterates the canonical dir projection directly and re-validates each source
--- via `annotation.validate()` to get the detailed per-source results.
---
--- **Conventions**: tabs (1/level), EmmyLua annotations, no regex,
--- Lua 5.3 compatible.
@@ -25,6 +27,13 @@
local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./"
local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
-- Load the annotation pass so we can re-validate each source against the
-- canonical corpus projection. The annotation pass exposes `M.validate`,
-- which returns the per-source AnnotationResult (atoms / annots / macros /
-- binds / errors / warnings) that the report pass renders into the
-- per-module `<dir_basename>.annotations.txt` output.
local annotation = dofile(_bootstrap_dir .. "annotation.lua")
-- ════════════════════════════════════════════════════════════════════════════
-- Constants
-- ════════════════════════════════════════════════════════════════════════════
@@ -67,8 +76,6 @@ local PASS_NAME = "report"
--- @field project_root string -- project root (e.g. "code/")
--- @field upstream table<string, table> -- per-pass upstream outputs
--- @field flags table -- CLI flags + per-pass stash
--- @field flags._annot_results ModuleEntry[] -- stashed by annotation pass
--- @field dry_run boolean -- if true, compute but don't write
--- @field verbose boolean -- if true, log diagnostic info
--- @class PassResult
@@ -315,9 +322,7 @@ local function render_module_report(dir, sources, results)
-- Each renderer writes its header + content via the `add` closure (pre-bound above).
-- Adding a new section = 1 row here + 1 render_<thing>_section function.
for _, section in ipairs(SECTION_RENDERERS) do
add(section.header)
section.render(add, results, totals)
add("")
end
return table.concat(lines, "\n") .. "\n"
@@ -377,21 +382,22 @@ end
-- Orchestration helpers
-- ════════════════════════════════════════════════════════════════════════════
--- (internal) Pull per-source validate() results from the annotation pass's stash.
--- The annotation pass runs first in the dep chain and caches results in `ctx.flags._annot_source_results`;
--- we read from there instead of re-validating each source.
--- (internal) Re-validate every source in a directory against the canonical
--- corpus projection. Calls `annotation.validate()` per source to produce the
--- per-source AnnotationResult (atoms / annots / macros / binds / errors /
--- warnings) that the report renderer consumes. This is the canonical path —
--- no private stash; each report pass run is reproducible from the corpus.
--- 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 lookup_module_results(ctx, dir_sources)
local src_cache = (ctx.flags and ctx.flags._annot_source_results) or {}
local module_results = {}
local all_results = {}
for _, src in ipairs(dir_sources) do
local result = src_cache[src.path]
if result then
result.source = src.path -- defensive (annotation tags it too; this guards against cache misses from earlier iterations)
if src.scan then
local result = annotation.validate(ctx, src, nil)
result.source = src.path -- tag for downstream rendering
module_results[#module_results + 1] = result
all_results[#all_results + 1] = result
end
@@ -435,30 +441,27 @@ function M.run(ctx)
local errors = {}
local warnings = {}
local module_entries = (ctx.flags and ctx.flags._annot_results) or {}
-- Read module grouping from `corpus.sources_by_dir` (the canonical projection).
-- Module grouping comes from `corpus.sources_by_dir`.
-- Module grouping comes from `corpus.sources_by_dir` (the canonical projection).
-- Iterate it directly; no private cache, no per-pass stash.
local corpus = ctx.shared and ctx.shared.corpus
local by_dir = (corpus and corpus.sources_by_dir) or {}
if not ctx.dry_run then duffle.ensure_dir(ctx.out_root) end
duffle.ensure_dir(ctx.out_root)
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 {}))
for dir, dir_sources in pairs(by_dir) do
local dir_basename = dir:match("([^/\\]+)$") or dir
debug_log("dir=%s basename=%s sources=%d\n", dir, dir_basename, #dir_sources)
if entry.atoms_count > 0 or #(by_dir[entry.dir] or {}) > 0 then
local dir_sources = by_dir[entry.dir] or {}
if #dir_sources > 0 then
local module_results, all_results = lookup_module_results(ctx, dir_sources)
for _, r in ipairs(all_results) do
all_results_for_summary[#all_results_for_summary + 1] = r
end
if module_has_content(module_results) then
local out_path = ctx.out_root .. "/" .. entry.dir_basename .. ".annotations.txt"
if not ctx.dry_run then
duffle.write_file(out_path, render_module_report(entry.dir, dir_sources, module_results))
end
local out_path = ctx.out_root .. "/" .. dir_basename .. ".annotations.txt"
duffle.write_file(out_path, render_module_report(dir, dir_sources, module_results))
outputs[#outputs + 1] = { annotations_txt = out_path }
else
debug_log(" -> no content; skipping\n")
@@ -466,7 +469,7 @@ function M.run(ctx)
end
end
if not ctx.dry_run and #all_results_for_summary > 0 then
if #all_results_for_summary > 0 then
local summary_path = ctx.out_root .. "/annotation_validation.txt"
duffle.write_file(summary_path, render_project_report(all_results_for_summary))
outputs[#outputs + 1] = { summary_txt = summary_path }
+2 -3
View File
@@ -117,7 +117,6 @@ local parse_enum_int_literal
--- @field project_root string
--- @field upstream table<string, table>
--- @field flags table
--- @field dry_run boolean
--- @field verbose boolean
--- @class PassResult
@@ -2029,8 +2028,8 @@ function M.run(ctx)
local code_macros = {}
local code_macro_bodies = {}
-- Resolve the canonical source list. The corpus owns the authoritative source_order; no legacy alias is consulted and no per-source fallback synthesis is performed.
-- A context without `ctx.shared.corpus` is rejected with an explicit canonical-corpus message so callers migrate to the canonical context (no production compatibility layer).
-- Resolve the canonical source list. The corpus owns the authoritative source_order.
-- A context without `ctx.shared.corpus` is rejected with an explicit canonical-corpus error.
ctx.shared = ctx.shared or {}
local corpus = ctx.shared.corpus
if not corpus or type(corpus.source_order) ~= "table" then
+35 -54
View File
@@ -1,21 +1,19 @@
--- passes/static_analysis.lua — Per-atom static-analysis checks.
---
--- Per-atom rules:
--- 1. transfer_hazards: A single forward walker (`analyze_hardware_relations`) reads `atom.paths.word_events`
--- once per atom. For each emitted word event it (a) inspects pending CPU/COP0/COP2/GTE relations against
--- the event as CONSUMER (recording a hazard on `atom.paths.hazards` when the producer→consumer gap is below
--- the required retire-slot count), (b) applies the event's GPR value effects (`duffle.INSTRUCTION_GPR_EFFECTS`)
--- to `atom.paths.forward_state.gpr_values`, applies bounded constant propagation, and stages
--- matching relation rows as PRODUCERS (with `destination_match` filters, e.g. for the IRGB fan-out). The
--- `transfer_hazards` CHECK_RULES reader projects `atom.paths.hazards` into per-atom findings without
--- re-walking source. The walker runs once per atom before the per-atom dispatch; the reader runs inside
--- the same dispatch.
--- 2. control_transfer_delay_slot_use: For every emitted branch/jump/call encoder in `duffle.CONTROL_TRANSFER_DELAY_SLOT_POLICIES`
--- 1. transfer_hazards: A single forward walker (`analyze_hardware_relations`) reads `atom.paths.word_events` once per atom.
--- For each emitted word event it (a) inspects pending CPU/COP0/COP2/GTE relations against the event as CONSUMER
--- (recording a hazard on `atom.paths.hazards` when the producer→consumer gap is below the required retire-slot count),
--- (b) applies the event's GPR value effects (`duffle.INSTRUCTION_GPR_EFFECTS`) to `atom.paths.forward_state.gpr_values`,
--- applies bounded constant propagation, and stages matching relation rows as PRODUCERS (with `destination_match` filters, e.g. for the IRGB fan-out).
--- The `transfer_hazards` CHECK_RULES reader projects `atom.paths.hazards` into per-atom findings without re-walking source.
--- The walker runs once per atom before the per-atom dispatch; the reader runs inside the same dispatch.
--- 2. control_transfer_delay_slot_use: For every emitted branch/jump/call encoder in `duffle.CONTROL_TRANSFER_DELAY_SLOT_POLICIES`
--- (the six `branch_*` encoders plus `jump` / `jump_reg` / `jump_link` / `call_reg` / `call_addr`),
--- inspect the next emitted event in `atom.paths.word_events`.
--- Emit an `info`-severity finding when the successor is `nop` or absent (the next emitted word IS the hardware delay slot).
--- `jump_reg(R_AtomJmp)` is suppressed by policy (the fixed `mac_yield()` handshake).
--- `nop2` needs no special case: `expand_word_events` emits two `nop` events for it, so the first expansion is the hardware delay slot.
--- `nop2` needs no special case: emission-model emits two `nop` events for it, so the first expansion is the hardware delay slot.
--- `atom_label` also needs no special case (zero events).
--- 3. mac_yield uniformity: Every atom body must contain exactly one `mac_yield()` call (control transfer pattern).
--- 4. Binding handoff: Every `atom_bind(Binds_X)` must reference a `typedef Struct_(Binds_X) { ... }` declaration.
@@ -30,10 +28,6 @@
--- 10. binds_no_substruct_deref: Every `load_word(R_A, R_B, O_(Type, Field))` and `store_word(...)` in every atom body must reference a leaf scalar
--- (pointer-to-struct counts as leaf; nested struct members do NOT).
---
--- `enum_alias_membership` already iterates `ai.reads` and `ai.writes` against
--- `corpus.register_alias_registry`, so a duplicate precedence-class check
--- only produced duplicate findings. The CHECK_RULES row and the helper
--- function are gone; no public/private surface retains that name.)
---
--- Findings carry an explicit `kind` ("error" / "warning" / "info").
--- The renderer maintains three independent severity collections; `info` is never folded into warnings.
@@ -113,7 +107,6 @@ local OUTPUT_EXTENSION = ".static_analysis.txt"
--- @field project_root string
--- @field upstream table<string, table>
--- @field flags table
--- @field dry_run boolean
--- @field verbose boolean
--- @class PassResult
@@ -714,10 +707,9 @@ local function analyze_hardware_relations(atom)
local ev_line = ev.body_line or ev.line or ev.def_line or 0
local ev_source = ev.def_path or ev.source or ""
local ev_args = ev.args or {}
-- `word_events` use `i` as the 0-based word index across the entire expansion);
-- The legacy `duffle.expand_word_events` walker emits `word`.
-- Either is accepted; unknown defaults to 0 (the producer's own word).
local ev_word = ev.i or ev.word or 0
-- `word_events` use `i` as the 0-based word index across the entire expansion.
-- Default to 0 if the field is absent (the producer's own word).
local ev_word = ev.i or 0
-- Read a Status source, then apply current-event GPR writes, then consume a pending CU2 transition at the first relevant COP2 use.
-- Both operations are part of this one event walk.
@@ -1032,7 +1024,7 @@ local function check_gte_result_position(atom, _pipe_ctx, findings)
-- For each word event whose encoder is `gte_mv_from_data_r`, look up the register being read in `forward_state.post_command_roles`.
-- If a role is set, the reader's register must match the role's register (the registered "latest_<role>" target).
for _, ev in ipairs(events) do
local ev_ident = ev.encoder or ev.ident
local ev_ident = ev.encoder
if ev_ident == "gte_mv_from_data_r" then
local args = ev.args or {}
local reg = args[2]
@@ -1101,14 +1093,14 @@ local function check_hazard_nop_use(atom, _pipe_ctx, findings)
local pending_snapshot = {}
local prev_ev = nil
for event_idx, ev in ipairs(events) do
local ev_ident = ev.encoder or ev.ident or ""
local ev_ident = ev.encoder or ""
local ev_args = ev.args or {}
local ev_word = ev.i or ev.word or 0
local ev_word = ev.i or 0
-- Classify the nop BEFORE its event is applied to the pending state.
if ev_ident == "nop" and prev_ev ~= nil then
-- Skip BD-slot nops: they are exclusively owned by control_transfer_delay_slot_use.
local prev_ident = prev_ev.encoder or prev_ev.ident or ""
local prev_ident = prev_ev.encoder or ""
local prev_args = prev_ev.args or {}
local bd_policies = duffle.CONTROL_TRANSFER_DELAY_SLOT_POLICIES or {}
local is_bd_slot = false
@@ -1246,8 +1238,10 @@ end
-- ─────────────────────────────────────────────────────────────────────────
-- Check #1c: control-transfer delay-slot use.
--
-- Reads `atom.paths.word_events` (the semantic emitted-word stream from `duffle.expand_word_events`).
-- For each event whose `ident` is in `duffle.CONTROL_TRANSFER_DELAY_SLOT_POLICIES`, inspect the next emitted event in the SAME `events` array.
-- Reads `atom.paths.word_events` (the semantic emitted-word stream from
-- `passes/emission_model.lua`). For each event whose `encoder` is in
-- `duffle.CONTROL_TRANSFER_DELAY_SLOT_POLICIES`, inspect the next emitted
-- event in the SAME `events` array.
-- The next event is the hardware delay-slot word (the duffle pipeline already absorbs the BD-slot into the branch's cost in `analyze_atom_paths`.
-- This check observes, it does not reschedule.
--
@@ -1260,7 +1254,7 @@ end
--
-- `pipe_ctx` is unused; the uniform `(atom, pipe_ctx, findings)` signature is preserved so the check plugs into
-- the existing CHECK_RULES dispatch without modifying the per-atom loop or analyze_atom_paths.
-- `expand_word_events` already normalizes `nop2` to two `nop` events and `atom_label` to zero events, so no special-case branching is needed for either.
-- `passes/emission_model` already normalizes `nop2` to two `nop` events and `atom_label` to zero events, so no special-case branching is needed for either.
-- ─────────────────────────────────────────────────────────────────────────
local function check_control_transfer_delay_slot_use(atom, pipe_ctx, findings)
@@ -1921,10 +1915,6 @@ local function check_binds_no_substruct_deref(_src, pipe_ctx, findings)
end
end
-- Because enum_alias_membership (Check #8) already iterates ai.reads / ai.writes against corpus.register_alias_registry.
-- The duplicate row produced redundant findings for the same off-registry register.
-- Neither a check function nor a CHECK_RULES row retains the name.
-- If a future regression reintroduces either, the test_canonical_corpus.lua grep sweep will surface it.
-- ════════════════════════════════════════════════════════════════════════════
-- CHECK_RULES — data-driven check dispatch (Muratori: data over control flow)
@@ -1995,8 +1985,9 @@ end
local function validate(ctx, src, corpus_pipe_ctx)
local scan = src.scan
-- Read the canonical corpus word_counts for the defensive fallback path below
-- (test-only: Focused tests that bypass emission-model and feed static_analysis still need word_events produced by the legacy `duffle.expand_word_events` walker).
-- Read the canonical corpus word_counts for the per-atom pipeline
-- (atom.paths.word_events is the canonical projection).
local corpus = (ctx.shared and ctx.shared.corpus) or {}
-- Read atoms + binds + atom_infos from the pre-scanned SourceScan payload.
@@ -2050,36 +2041,27 @@ local function validate(ctx, src, corpus_pipe_ctx)
---
--- Body, token, and emission projections come from here (`paths.tokens = body_tokens`, `paths.line_in_body = build_body_line_index` `paths.word_events`
--- and related fields are owned by `passes/emission_model.lua` pass (per-atom emission projection).
--- This pass reads: `paths.tokens`, `paths.line_in_body` ` paths.items`, `paths.word_events` from the canonical projection,
--- then computes `paths.tok_class`, `paths.cycles_min/max`, `paths.branches`, `paths.paths`, `paths.has_loops`, `paths.unknown_macros`
--- This pass reads: `paths.tokens`, `paths.line_in_body` ` paths.items`, `paths.word_events` from the canonical projection,
--- then computes `paths.tok_class`, `paths.cycles_min/max`, `paths.branches`, `paths.paths`, `paths.has_loops`, `paths.unknown_macros`
--- via `classify_tokens` + `analyze_atom_paths`.
--- No re-walk of body text or body_tokens happens here.
---
--- Isolated component checks may supply a component body directly.
--- Such inputs may omit an emission projection and require this pass to populate `paths.word_events` through `duffle.expand_word_events`.
--- Normal callers run emission-model first.
--- Canonical contract: `atom.paths` and `atom.paths.word_events` MUST be
--- populated by `passes/emission_model.run(ctx)` before this pass runs.
--- The `atom.paths.word_events` projection is owned by the emission-model pass; static-analysis reads it directly.
local findings = {}
for _, a in ipairs(atoms) do
a.paths = a.paths or {}
if a.paths == nil then
error("static_analysis: a.paths is nil; emit emission-model first")
end
if a.paths.word_events == nil then
error("static_analysis: a.paths.word_events is nil; emit emission-model first")
end
-- `paths.tokens` / `paths.line_in_body` / `paths.items` / `paths.word_events` are populated by `passes/emission_model.lua`.
-- Supply tokens when no emission projection is present.
if a.paths.tokens == nil then a.paths.tokens = a.body_tokens end
a.paths.tok_class = classify_tokens(a.paths.tokens)
-- Supply word events when no emission projection is present.
if a.paths.word_events == nil then
local body_entry = {
body_tokens = a.body_tokens,
body_off = a.body_off,
line_of = src.scan.line_of,
source = src.path,
declaration = a.line,
}
a.paths.word_events = duffle.expand_word_events(body_entry,
pipe_ctx.component_body_index,
corpus.word_counts or {})
end
-- analyze_atom_paths fills the *cycles / branches / has_loops / unknown_macros* fields of a.paths.
analyze_atom_paths(a)
@@ -2212,7 +2194,6 @@ local function emit_module_static_analysis_txt(ctx, dir, dir_sources, atoms, fin
-- Module basename = last component of `dir` ("code/duffle" -> "duffle").
local dir_basename = dir:match("([^/\\]+)$") or dir
local out_path = ctx.out_root .. "/" .. dir_basename .. ".static_analysis.txt"
if ctx.dry_run then return out_path end
duffle.ensure_dir(ctx.out_root)
local lines = {}
-1
View File
@@ -49,7 +49,6 @@ local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
--- @field project_root string -- project root (e.g. "code/")
--- @field upstream table<string, table> -- per-pass upstream outputs
--- @field flags table -- CLI flags
--- @field dry_run boolean -- if true, compute but don't write
--- @field verbose boolean -- if true, log diagnostic info
--- @class PassResult
+2 -99
View File
@@ -20,7 +20,7 @@
-- Bootstrap: load `duffle_paths.lua` via this script's own path.
-- Use `arg[0]` when this file is the entry script (`arg[0]` ends in "ps1_meta.lua");
-- fall back to `debug.getinfo(1, "S").source` when this file is being dofile()'d or require()'d (in which case `arg[0]` is the *caller's* path, not ours).
-- That single statement: (a) sets `package.path` + `package.cpath` (via cached `git rev-parse`), (b) at the bottom returns `require("duffle")`.
-- That single statement: (a) sets `package.path` + `package.cpath`, (b) at the bottom returns `require("duffle")`.
-- So the dofile's return value is the duffle module.
local _is_entry_script = arg and arg[0] and arg[0]:match("ps1_meta%.lua$") ~= nil
local _bootstrap_src
@@ -63,12 +63,6 @@ local PASS_FLAG_DISPATCH_KEY = "__pass__"
--- @field deps string[] -- names of upstream passes
--- @field groups string[]? -- OPTIONAL build-phase groups this pass is a root of
--- -- (e.g. { "pre-link" }, { "post-link" }); absent ⇒ dependency-only
--- @field desc string -- human description (used by --help + ASCII graph)
--- @field out PassOutput[] -- output paths (used by --dry-run + report)
--- @class PassOutput
--- @field kind string -- "header" | "report"
--- @field path_template string -- e.g. "<source_dir>/gen/<basename>.macs.h"
--- @class SourceFile
--- @field path string -- absolute path to the source file
@@ -82,15 +76,9 @@ local PASS_FLAG_DISPATCH_KEY = "__pass__"
--- @field shared.corpus table -- canonical authored-source/project projection
--- @field out_root string -- output root (e.g. "build/gen")
--- @field project_root string -- PS1 repository root
--- @field upstream table<string, table> -- per-pass output accumulator
--- @field flags table -- CLI flags + per-pass stash
--- @field dry_run boolean -- if true, compute but don't write
--- @field verbose boolean -- if true, log diagnostic info
--- @class PassOutputEntry
--- @field [string] string -- dynamic shape; key is the output kind
-- (e.g. "macs_h", "offsets_h", "errors_h", "annotations_txt", "static_analysis_txt", "summary_txt"), value is the path
--- @class Finding
--- @field line integer -- source line (or 0 for pass-level)
--- @field msg string -- finding message
@@ -107,7 +95,6 @@ local PASS_FLAG_DISPATCH_KEY = "__pass__"
--- @field metadata string -- --metadata value
--- @field out_root string -- --out-root value (default "build/gen")
--- @field project_root string -- PS1 repository root (derived from metadata by default)
--- @field dry_run boolean -- if true, compute but don't write
--- @field verbose boolean -- if true, log diagnostic info
-- ════════════════════════════════════════════════════════════════════════════
@@ -125,46 +112,31 @@ local PASSES = {
["scan-source"] = {
module = "passes.scan_source",
kind = "shared", deps = {},
desc = "Walk each source once; produce the fat SourceScan payload for downstream passes",
out = {},
},
["word-counts"] = {
module = "passes.word_count_eval",
kind = "shared", deps = {},
desc = "Build the shared metadata table (metadata.h + .macs.h)",
out = {},
},
components = {
module = "passes.components",
kind = "header-output",
deps = {"scan-source", "word-counts"},
desc = "Emit mac_X macros from MipsAtomComp_ declarations",
out = { { kind = "header", path_template = "<source_dir>/gen/<basename>.macs.h" } },
},
["emission-model"] = {
module = "passes.emission_model",
kind = "validation",
deps = {"components"},
desc = "Build canonical per-atom words, markers, and invocation ancestry",
out = {},
},
annotation = {
module = "passes.annotation",
kind = "validation",
deps = {"scan-source", "word-counts"},
desc = "Validate atom DSL usage; emit errors.h + annotations.txt",
out = {
{ kind = "report", path_template = "<out_root>/<basename>.errors.h" },
{ kind = "report", path_template = "<out_root>/<basename>.annotations.txt" },
},
},
offsets = {
module = "passes.offsets",
kind = "header-output",
deps = {"scan-source", "word-counts", "components", "emission-model"},
groups = { "pre-link" },
desc = "Compute branch offsets for atom_label / atom_offset",
out = { { kind = "header", path_template = "<source_dir>/gen/<basename>.offsets.h" } },
},
["static-analysis"] = {
module = "passes.static_analysis",
@@ -173,43 +145,23 @@ local PASSES = {
-- Report severity is independent from process exit policy.
kind = "diagnostic",
deps = {"scan-source", "word-counts", "components", "emission-model"},
desc = "Static analysis: GTE pipeline-fill, mac_yield uniformity, ABI handoff, GPU port-store shape, per-atom cycle budget, type consistency",
out = { { kind = "report", path_template = "<out_root>/<basename>.static_analysis.txt" } },
},
["atoms-source-map"] = {
module = "passes.atoms_source_map",
kind = "header-output",
deps = {"word-counts", "components", "emission-model"},
desc = "Emit gen/<basename>.atoms.sourcemap.txt (per-.word C source line map for gdb debugging) AND gen/<basename>.atoms.provenance.txt (per-.word provenance; each word tagged with its call-site file:line and, when emitted by a mac_X(...) component invocation, the component's definition file:line). Consumed by passes/dwarf_injection.lua to synthesize DW_TAG_inlined_subroutine instances for source-level Step Into on component invocations.",
out = {
{ kind = "report", path_template = "<out_root>/<basename>.atoms.sourcemap.txt" },
{ kind = "report", path_template = "<out_root>/<basename>.atoms.provenance.txt" },
},
},
["dwarf-injection"] = {
module = "passes.dwarf_injection",
kind = "shared",
deps = {"scan-source", "atoms-source-map"},
groups = { "post-link" },
desc = "Inject per-atom .debug_line + .debug_aranges (F') + per-atom .debug_info subprogram + per-wave-context-reg .debug_info variables (G') into the ELF (post-link; writes 7 section .bin blobs plus one deterministic .gdbinit sidecar). (rbind composite) reads ctx.sources[i].scan to find atom_bind(Binds_X) atoms + their Binds_X struct fields; emits per-Binds_X DW_TAG_structure_type DIEs + per-rbind-atom DW_TAG_variable 'bind_args' DIEs with piece-chain DW_OP_bregN/DW_OP_piece location expressions.",
out = {
{ kind = "report", path_template = "<out_root>/<basename>.dwarf_line.bin" },
{ kind = "report", path_template = "<out_root>/<basename>.dwarf_aranges.bin" },
{ kind = "report", path_template = "<out_root>/<basename>.dwarf_rnglists.bin" },
{ kind = "report", path_template = "<out_root>/<basename>.dwarf_abbrev.bin" },
{ kind = "report", path_template = "<out_root>/<basename>.dwarf_info.bin" },
{ kind = "report", path_template = "<out_root>/<basename>.dwarf_str.bin" },
{ kind = "report", path_template = "<out_root>/<basename>.dwarf_loc.bin" },
{ kind = "report", path_template = "<out_root>/<basename>.gdbinit" },
},
},
report = {
module = "passes.report",
kind = "report",
deps = {"annotation", "static-analysis"},
groups = { "pre-link" },
desc = "Render the per-project summary",
out = { { kind = "report", path_template = "<out_root>/annotation_validation.txt" } },
},
}
@@ -345,7 +297,6 @@ COMMON_FLAGS:
<repo>/code/duffle/word_count.metadata.h)
--gdb-runtime Also emit <out_root>/gdb_tape_atoms_runtime.gdb (post-link, requires --elf)
--elf PATH Path to linked .elf (for --gdb-runtime / --dwarf-injection)
--dry-run Print dep order (alphabetical); exit 0 without running
--verbose Print per-pass debug output
--help Show this help and exit
@@ -391,7 +342,6 @@ FLAG_HANDLERS["--help"] = function(args)
os.exit(0)
end
FLAG_HANDLERS["--dry-run"] = function(args) args.dry_run = true end
FLAG_HANDLERS["--verbose"] = function(args) args.verbose = true end
FLAG_HANDLERS["--source"] = function(args, argv, arg_idx)
local value, value_idx = require_flag_value(argv, arg_idx, "--source")
@@ -476,7 +426,6 @@ local function parse_args(argv)
metadata = nil,
out_root = DEFAULT_OUT_ROOT,
project_root = nil,
dry_run = false,
verbose = false,
}
@@ -557,7 +506,7 @@ local function build_ctx(args)
or normalized_project_root:sub(1, 1) == "/"
if not project_root_is_absolute then
-- canonical_path_key validates ordinary relative paths and rejects
-- drive-relative paths before the legacy display-path helper is used.
-- drive-relative paths before the absolute-path rewrite is performed.
duffle.canonical_path_key(normalized_project_root)
project_root = duffle.normalize_path(duffle.to_absolute_path(normalized_project_root))
else
@@ -654,11 +603,9 @@ local function build_ctx(args)
local ctx = {
metadata_path = args.metadata,
shared = { corpus = corpus },
upstream = {},
out_root = args.out_root,
project_root = corpus.project_root,
flags = args.flags or {},
dry_run = args.dry_run,
verbose = args.verbose,
}
@@ -756,42 +703,10 @@ local function topo_sort(passes, requested_set)
return order
end
-- ════════════════════════════════════════════════════════════════════════════
-- ASCII dep graph renderer (Decision 6 in the spec)
-- ════════════════════════════════════════════════════════════════════════════
-- ════════════════════════════════════════════════════════════════════════════
-- Topological dep-order printer (used by --dry-run).
-- Re-render the PASSES graph manually in `docs/guide_metaprogram_ssdl.md` if you need an updated visual;
-- The canonical ASCII view there is regenerated by hand whenever PASSES rows change.
-- ════════════════════════════════════════════════════════════════════════════
local function render_dep_order(passes, closed)
local lines = {}
lines[#lines + 1] = "[ps1_meta] Resolved dependency order (closed under deps):"
for pass_idx, name in ipairs(closed) do
local p = passes[name]
local deps_str = (#p.deps == 0) and "(no deps)" or
"(deps: " .. table.concat(p.deps, ", ") .. ")"
lines[#lines + 1] = string.format(" %d. %-22s %-45s [%s]",
pass_idx, name, deps_str, p.kind)
end
return table.concat(lines, "\n") .. "\n"
end
-- ════════════════════════════════════════════════════════════════════════════
-- Main Orchestrator
-- ════════════════════════════════════════════════════════════════════════════
--- (internal) Push a pass's outputs + warnings into `ctx.upstream[name]` for downstream passes to consume.
--- @param ctx PassCtx
--- @param pass_name string
--- @param result PassResult
local function accumulate_pass_result(ctx, pass_name, result)
ctx.upstream[pass_name] = ctx.upstream[pass_name] or {}
for _, out in ipairs(result.outputs or {}) do table.insert(ctx.upstream[pass_name], out) end
for _, warn in ipairs(result.warnings or {}) do table.insert(ctx.upstream[pass_name], warn) end
end
--- (internal) If the pass's kind is in PASS_KIND_STOP_ON_ERROR and it reported errors, write each error to stderr.
--- Returns true if any validation errors were reported.
--- @param pass_name string
@@ -812,14 +727,11 @@ end
--- @param order string[]
--- @return boolean -- true if any validation errors were reported
local function dispatch_passes(ctx, order)
ctx.shared = ctx.shared or {}
local had_errors = false
for _, pass_name in ipairs(order) do
local pass = PASSES[pass_name]
-- io.stderr:write(string.format("[ps1_meta] %-22s running\n", pass_name))
local mod = require(pass.module)
local result = mod.run(ctx)
accumulate_pass_result(ctx, pass_name, result)
if report_validation_errors(pass_name, pass, result) then
had_errors = true
end
@@ -837,13 +749,6 @@ local function main(argv)
local requested = args.requested_set
local closed = topo_sort(PASSES, requested)
-- --dry-run: print the closed dep order and exit OK.
-- (The hand-rendered PASSES graph lives in docs/guide_metaprogram_ssdl.md; see Decision 6.)
if args.dry_run then
io.write(render_dep_order(PASSES, closed))
os.exit(EXIT_OK)
end
local had_errors = dispatch_passes(ctx, closed)
if had_errors then os.exit(EXIT_VALIDATION_ERRORS) end
end)
@@ -857,11 +762,9 @@ local function main(argv)
end
-- Module export for in-process consumers (tests that dofile this script).
-- The closed dep-order printer + the `PASSES` table are exposed so a test can observe the resolved dep order for synthetic PASSES tables without spawning a subprocess.
-- The conditional `main(...)` call below only fires when this file is invoked as the entry script (arg[0] ends in "ps1_meta.lua");
-- in dofile() mode (test's arg[0] does not match), main() is skipped and the chunk returns `_M` to the caller.
local _M = {
render_dep_order = render_dep_order,
PASSES = PASSES,
PASS_KIND_STOP_ON_ERROR = PASS_KIND_STOP_ON_ERROR,
parse_args = parse_args,