From 1b40b16c0e99033e9bd7022e971d70d869157c45 Mon Sep 17 00:00:00 2001 From: Ed_ Date: Sat, 25 Jul 2026 11:20:53 -0400 Subject: [PATCH] Review pass. --- code/duffle/memory.h | 2 +- code/gte_hello/hello_gte.c | 6 - scripts/duffle.lua | 175 +--------------------------- scripts/duffle_paths.lua | 13 ++- scripts/elf_dwarf.lua | 132 +-------------------- scripts/passes/annotation.lua | 23 +--- scripts/passes/atoms_source_map.lua | 31 +---- scripts/passes/components.lua | 14 +-- scripts/passes/dwarf_injection.lua | 59 ++++------ scripts/passes/emission_model.lua | 17 ++- scripts/passes/offsets.lua | 2 +- scripts/passes/report.lua | 46 +++++--- scripts/passes/scan_source.lua | 4 +- scripts/passes/static_analysis.lua | 69 +++++------ scripts/ps1_meta.lua | 64 +--------- 15 files changed, 119 insertions(+), 538 deletions(-) diff --git a/code/duffle/memory.h b/code/duffle/memory.h index a911887..88aefe9 100644 --- a/code/duffle/memory.h +++ b/code/duffle/memory.h @@ -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); diff --git a/code/gte_hello/hello_gte.c b/code/gte_hello/hello_gte.c index b43a64b..643e70c 100644 --- a/code/gte_hello/hello_gte.c +++ b/code/gte_hello/hello_gte.c @@ -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); diff --git a/scripts/duffle.lua b/scripts/duffle.lua index 381c27d..76e75c1 100644 --- a/scripts/duffle.lua +++ b/scripts/duffle.lua @@ -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`). @@ -813,10 +793,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 +807,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 +958,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 +1979,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 +2014,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 +2114,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 @@ -2608,87 +2526,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 diff --git a/scripts/duffle_paths.lua b/scripts/duffle_paths.lua index ab74b94..b0d6d65 100644 --- a/scripts/duffle_paths.lua +++ b/scripts/duffle_paths.lua @@ -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 `@` 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 + -- /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 diff --git a/scripts/elf_dwarf.lua b/scripts/elf_dwarf.lua index cff1d2a..c045424 100644 --- a/scripts/elf_dwarf.lua +++ b/scripts/elf_dwarf.lua @@ -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 @@ -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 `` since the `code_` prefix was removed from the MipsAtom_ macro). + -- Filter: keep all symbol-table symbols (atoms emit their name as the bare `` — 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 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 -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 "" - 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 `*.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 ---- ATOM "" ---- WORD LINE TEXT ---- ... ---- ENDATOM ---- ``` ---- ---- **Conventions:** the in-memory shape uses `{pos, line, text}` ---- (`atoms_source_map.lua:142`); the `.txt` file uses `WORD ` so the parser maps `n` → `pos` field name. ---- @param sm_path Path ---- @param expected_version integer -- expected FORMAT_VERSION line ---- @return 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 `*.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 ---- ATOM "" ---- WORD CALL : RAW ---- WORD CALL : MACRO ":" ---- ... ---- 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 -function M.parse_provenance_file(prov_path, expected_version) - return M.parse_atom_records(prov_path, expected_version, function(line) - -- Two accepted shapes: - -- WORD CALL : RAW - -- WORD CALL : MACRO ":" - 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 diff --git a/scripts/passes/annotation.lua b/scripts/passes/annotation.lua index b407e84..1137710 100644 --- a/scripts/passes/annotation.lua +++ b/scripts/passes/annotation.lua @@ -7,7 +7,7 @@ --- --- Writes: --- - `/.errors.h` — one per module, with `#error` directives on findings (the C compile will surface the error) ---- - The annotations.txt report is rendered by `passes/report.lua` from the per-module results stashed in `ctx.flags._annot_results` +--- - 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,7 +45,6 @@ local ensure_dir = duffle.ensure_dir --- @field project_root string --- @field upstream table --- @field flags table ---- @field flags._annot_results table[] -- stashed by annotation pass; consumed by report.lua --- @field dry_run boolean --- @field verbose boolean @@ -500,9 +499,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. @@ -668,17 +668,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 +702,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 +720,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 } diff --git a/scripts/passes/atoms_source_map.lua b/scripts/passes/atoms_source_map.lua index e9178e1..0e1c1bf 100644 --- a/scripts/passes/atoms_source_map.lua +++ b/scripts/passes/atoms_source_map.lua @@ -63,7 +63,7 @@ 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 +391,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' @@ -533,7 +508,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 diff --git a/scripts/passes/components.lua b/scripts/passes/components.lua index 90eb9d4..6e17842 100644 --- a/scripts/passes/components.lua +++ b/scripts/passes/components.lua @@ -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 @@ -247,7 +237,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 @@ -640,7 +630,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[] diff --git a/scripts/passes/dwarf_injection.lua b/scripts/passes/dwarf_injection.lua index 6b6ae54..651f2ea 100644 --- a/scripts/passes/dwarf_injection.lua +++ b/scripts/passes/dwarf_injection.lua @@ -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_ → 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. @@ -1283,7 +1283,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 +2099,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 +2107,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 +2154,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 +2232,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 +2291,7 @@ function M.run(ctx) -- -- .debug_str now also receives the new RR_ 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) diff --git a/scripts/passes/emission_model.lua b/scripts/passes/emission_model.lua index 10008f2..be26c9e 100644 --- a/scripts/passes/emission_model.lua +++ b/scripts/passes/emission_model.lua @@ -135,14 +135,21 @@ function M.run(ctx) 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). + -- Atom declarations (`kind == "atom"` / `"raw_atom"`) AND component declarations + -- (`comp_bare` / `comp_proc`) each receive the canonical `atom.paths` projection. + -- Components are macros inlined into atom bodies; focused tests and isolated + -- component analyses read them from `atom.paths` on the component record. + -- The per-atom emission projection is produced by `duffle.project_emission` (this pass). + -- Test-only fixtures may consume `atom.paths.word_events` directly from the emission-model pass output. 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 + if atom and atom.body and ( + atom.kind == "atom" or + atom.kind == "raw_atom" or + atom.kind == "comp_bare" or + atom.kind == "comp_proc" + ) 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. diff --git a/scripts/passes/offsets.lua b/scripts/passes/offsets.lua index d1c5600..348d594 100644 --- a/scripts/passes/offsets.lua +++ b/scripts/passes/offsets.lua @@ -48,7 +48,7 @@ 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 diff --git a/scripts/passes/report.lua b/scripts/passes/report.lua index 1d5515d..ecb574a 100644 --- a/scripts/passes/report.lua +++ b/scripts/passes/report.lua @@ -5,8 +5,10 @@ --- - `build/gen/.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 `.annotations.txt` output. +local annotation = dofile(_bootstrap_dir .. "annotation.lua") + -- ════════════════════════════════════════════════════════════════════════════ -- Constants -- ════════════════════════════════════════════════════════════════════════════ @@ -67,7 +76,6 @@ local PASS_NAME = "report" --- @field project_root string -- project root (e.g. "code/") --- @field upstream 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 @@ -377,21 +385,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,29 +444,28 @@ 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 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" + local out_path = ctx.out_root .. "/" .. dir_basename .. ".annotations.txt" if not ctx.dry_run then - duffle.write_file(out_path, render_module_report(entry.dir, dir_sources, module_results)) + duffle.write_file(out_path, render_module_report(dir, dir_sources, module_results)) end outputs[#outputs + 1] = { annotations_txt = out_path } else diff --git a/scripts/passes/scan_source.lua b/scripts/passes/scan_source.lua index a9d7157..3cb398d 100644 --- a/scripts/passes/scan_source.lua +++ b/scripts/passes/scan_source.lua @@ -2029,8 +2029,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 diff --git a/scripts/passes/static_analysis.lua b/scripts/passes/static_analysis.lua index 286fea6..c216dc0 100644 --- a/scripts/passes/static_analysis.lua +++ b/scripts/passes/static_analysis.lua @@ -15,7 +15,7 @@ --- 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 +30,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. @@ -714,10 +710,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 +1027,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_" 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 +1096,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 +1241,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 +1257,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 +1918,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 +1988,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 +2044,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) diff --git a/scripts/ps1_meta.lua b/scripts/ps1_meta.lua index fbe6db5..6a242cd 100644 --- a/scripts/ps1_meta.lua +++ b/scripts/ps1_meta.lua @@ -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. "/gen/.macs.h" --- @class SourceFile --- @field path string -- absolute path to the source file @@ -82,15 +76,10 @@ 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 -- 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 @@ -125,46 +114,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 = "/gen/.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 = "/.errors.h" }, - { kind = "report", path_template = "/.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 = "/gen/.offsets.h" } }, }, ["static-analysis"] = { module = "passes.static_analysis", @@ -173,43 +147,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 = "/.static_analysis.txt" } }, }, ["atoms-source-map"] = { module = "passes.atoms_source_map", kind = "header-output", deps = {"word-counts", "components", "emission-model"}, - desc = "Emit gen/.atoms.sourcemap.txt (per-.word C source line map for gdb debugging) AND gen/.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 = "/.atoms.sourcemap.txt" }, - { kind = "report", path_template = "/.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 = "/.dwarf_line.bin" }, - { kind = "report", path_template = "/.dwarf_aranges.bin" }, - { kind = "report", path_template = "/.dwarf_rnglists.bin" }, - { kind = "report", path_template = "/.dwarf_abbrev.bin" }, - { kind = "report", path_template = "/.dwarf_info.bin" }, - { kind = "report", path_template = "/.dwarf_str.bin" }, - { kind = "report", path_template = "/.dwarf_loc.bin" }, - { kind = "report", path_template = "/.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 = "/annotation_validation.txt" } }, }, } @@ -557,7 +511,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,7 +608,6 @@ 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 {}, @@ -782,16 +735,6 @@ 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 +755,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