mirror of
https://github.com/Ed94/pikuma_ps1.git
synced 2026-08-05 15:18:49 +00:00
Compare commits
4
Commits
9ffd6592bc
...
590ff1e2ec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
590ff1e2ec | ||
|
|
653e18ee28 | ||
|
|
ebb876fe89 | ||
|
|
1b40b16c0e |
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user