diff --git a/.gitignore b/.gitignore index 6957337..28d4786 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ toolchain/PSn00bSDK .vscode/settings.json toolchain/lfs toolchain/lpeg + +scratch diff --git a/scripts/gdb/gdb_tape_atoms.gdb b/scripts/gdb/gdb_tape_atoms.gdb index 7c9924a..54f0dbe 100644 --- a/scripts/gdb/gdb_tape_atoms.gdb +++ b/scripts/gdb/gdb_tape_atoms.gdb @@ -3,16 +3,16 @@ # Wrapper for the tape-atom step-debug helpers. # The 9 user commands are defined here as STUBS (degraded-state messages). # The real implementations + the per-atom data tables are emitted by `passes/atoms_source_map.lua` -# (post-link invocation: `ps1_meta.lua --atoms-source-map --gdb-runtime --elf `) into `build/gen/gdb_tape_atoms_runtime.gdb`. +# (post-link invocation: `ps1_meta.lua --atoms-source-map --gdb-runtime --elf `) into `build/gdb_tape_atoms_runtime.gdb`. # Sourcing that file RE-DEFINES the commands with real implementations. # -# If `build/gen/gdb_tape_atoms_runtime.gdb` is missing or stale, the stubs remain (E1: no source map). +# If `build/gdb_tape_atoms_runtime.gdb` is missing or stale, the stubs remain (E1: no source map). # The user just needs to re-run `build_psyq.ps1` to regenerate. -# ── Stub commands (defined here so they're always present, even if the runtime file is missing). The runtime file overrides these if sourced. ── +# ?? Stub commands (defined here so they're always present, even if the runtime file is missing). The runtime file overrides these if sourced. ?? define tape_atoms - echo "[gdb_tape_atoms] STUB: runtime file build/gen/gdb_tape_atoms_runtime.gdb not found." + echo "[gdb_tape_atoms] STUB: runtime file build/gdb_tape_atoms_runtime.gdb not found." echo "[gdb_tape_atoms] STUB: run .\\build_psyq.ps1 to regenerate, then re-source this file." end document tape_atoms @@ -21,35 +21,35 @@ document tape_atoms end define break_atom - echo "[gdb_tape_atoms] STUB: build/gen/gdb_tape_atoms_runtime.gdb not sourced. Run build_psyq.ps1." + echo "[gdb_tape_atoms] STUB: build/gdb_tape_atoms_runtime.gdb not sourced. Run build_psyq.ps1." end document break_atom Set a breakpoint at the start of tape atom . STUB state. end define step_atom - echo "[gdb_tape_atoms] STUB: build/gen/gdb_tape_atoms_runtime.gdb not sourced. Run build_psyq.ps1." + echo "[gdb_tape_atoms] STUB: build/gdb_tape_atoms_runtime.gdb not sourced. Run build_psyq.ps1." end document step_atom Resume execution until the next atom boundary. STUB state. end define next_atom - echo "[gdb_tape_atoms] STUB: build/gen/gdb_tape_atoms_runtime.gdb not sourced. Run build_psyq.ps1." + echo "[gdb_tape_atoms] STUB: build/gdb_tape_atoms_runtime.gdb not sourced. Run build_psyq.ps1." end document next_atom Alias for step_atom. STUB state. end define where_in_atom - echo "[gdb_tape_atoms] STUB: build/gen/gdb_tape_atoms_runtime.gdb not sourced. Run build_psyq.ps1." + echo "[gdb_tape_atoms] STUB: build/gdb_tape_atoms_runtime.gdb not sourced. Run build_psyq.ps1." end document where_in_atom Report current atom name, .rodata addr, word offset, and source line (if known). STUB state. end define stepi_inside_atom - echo "[gdb_tape_atoms] STUB: build/gen/gdb_tape_atoms_runtime.gdb not sourced. Run build_psyq.ps1." + echo "[gdb_tape_atoms] STUB: build/gdb_tape_atoms_runtime.gdb not sourced. Run build_psyq.ps1." end document stepi_inside_atom One MIPS-instruction step, then where_in_atom. STUB state. @@ -89,17 +89,17 @@ document wave_ctx end -# ── Source the runtime file (re-defines commands with real impls + data). ── +# ?? Source the runtime file (re-defines commands with real impls + data). ?? # Try to source from project-root-relative path first (the typical case). # If the user is in a different CWD, the source will fail and stubs remain. -# The runtime file path is computed relative to the ELF's source map convention (build/gen/gdb_tape_atoms_runtime.gdb). +# The runtime file path is computed relative to the ELF's source map convention (build/gdb_tape_atoms_runtime.gdb). echo [gdb_tape_atoms] Wrapper loaded. Sourcing runtime file... # Suppress the "Redefine command" prompts that would otherwise appear when the runtime file overrides the 9 stub commands defined above. -# The runtime's `define` blocks are intended to overwrite — there's no ambiguity to confirm. +# The runtime's `define` blocks are intended to overwrite ? there's no ambiguity to confirm. set confirm off # Source the runtime file (re-defines commands with real impls + data). -source build/gen/gdb_tape_atoms_runtime.gdb +source build/gdb_tape_atoms_runtime.gdb set confirm on echo [gdb_tape_atoms] Runtime sourced successfully (9 commands now have real implementations). diff --git a/scripts/passes/annotation.lua b/scripts/passes/annotation.lua index 70cd109..661855e 100644 --- a/scripts/passes/annotation.lua +++ b/scripts/passes/annotation.lua @@ -7,18 +7,11 @@ --- --- Ownership: the canonical `ctx.shared.corpus` supplies cross-source registries, while each `src.scan` supplies its source's declarations and bodies. --- A context without `ctx.shared.corpus` is rejected with an explicit canonical-corpus message. ---- ---- Writes `/.errors.h` once per module, with `#error` directives for findings that the C compile surfaces. ---- `passes/report.lua` renders annotations.txt from `corpus.sources_by_dir`, re-validating each source through `M.validate()`. ---- ---- **Conventions**: tabs (1/level), EmmyLua annotations, no regex, Lua 5.3 compatible. -- Bootstrap follows the entry scripts; `scripts/duffle_paths.lua` sets package.path and package.cpath. See `ps1_meta.lua` for the rationale. -- `debug.getinfo(1, "S").source` locates this file for standalone and orchestrated runs, then `duffle_paths.lua` returns the loaded `duffle` module. local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./" local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua") -local write_file = duffle.write_file -local ensure_dir = duffle.ensure_dir -- The annotation pass reads the source-derived registries from scan_source: -- * pipe_ctx.register_alias_registry — for atom_dbg_reg_default(R_X, ...) and atom_reg_types(R_X, ...) member-identity checks @@ -605,40 +598,6 @@ local function validate(ctx, src, corpus_pipe_ctx) } end --- ════════════════════════════════════════════════════════════════════════════ --- Per-DIRECTORY (per-module) output: errors.h + annotations.txt --- ════════════════════════════════════════════════════════════════════════════ - ---- Render `.errors.h` with `#error` directives for every error found across all sources in the directory. ---- Empty directories (no errors, no atoms) produce no file. -local function emit_module_errors_h(ctx, dir_basename, atoms_count, errors, sources) - if atoms_count == 0 and #errors == 0 then - return nil - end - local out_path = ctx.out_root .. "/" .. dir_basename .. ".errors.h" - local lines = { - "// Auto-generated by ps1_meta.lua (passes/annotation.lua) — DO NOT EDIT", - string.format("// Module: %s Sources: %d", dir_basename, #sources), - "#pragma once", - "", - } - if #errors == 0 then - lines[#lines + 1] = "// annotation pass OK" - else - for _, e in ipairs(errors) do - local src_tag = "" - if e.source then - local src_name = e.source:match("([^/\\]+)$") or e.source - src_tag = src_name .. ": " - end - lines[#lines + 1] = string.format('#error "%s%s (line %d)"', src_tag, e.msg, e.line) - end - end - ensure_dir(ctx.out_root) - write_file(out_path, table.concat(lines, "\n") .. "\n") - return out_path -end - -- ════════════════════════════════════════════════════════════════════════════ -- M.run — orchestrator entry -- ════════════════════════════════════════════════════════════════════════════ @@ -683,11 +642,6 @@ function M.run(ctx) warnings [#warnings + 1] = { line = w.line, msg = w.msg } end end - - local err_path = emit_module_errors_h(ctx, dir_basename, dir_atoms, dir_errors, dir_sources) - if err_path then - table.insert(outputs, { errors_h = err_path }) - end 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 c332856..2fae395 100644 --- a/scripts/passes/atoms_source_map.lua +++ b/scripts/passes/atoms_source_map.lua @@ -4,20 +4,18 @@ --- `passes/dwarf_injection.lua` (synthesizes DW_TAG_inlined_subroutine + per-word line program rows) and the gdb-runtime --- wrapper at `scripts/gdb/gdb_tape_atoms.gdb` (loads the source map via `source `). --- ---- Inputs from `atom.paths`: the ordered `items` stream, dense `word_events`, `invocations` views. Outputs: one ---- `WORD N LINE L TEXT T` line per emitted `.word`, plus the per-word provenance form that DWARF synthesis consumes. +--- Inputs from `atom.paths`: the ordered `items` stream, dense `word_events`, `invocations` views. Outputs: +--- one `WORD N LINE L TEXT T` line per emitted `.word`, plus the per-word provenance form that DWARF synthesis consumes. --- ---- **Two output forms** (per the workspace's per-emission-form pattern from `guide_metaprogram_ssdl.md`): ---- 1. **Sourcemap.txt form** — `/.atoms.sourcemap.txt`. Format-version-tagged for forward-compat. ---- Lives in `/` (build/gen). Mirrors the convention used by `annotation.lua` ---- (`/.errors.h`) and `static_analysis.lua` (`/.static_analysis.txt`). +--- Two output forms: +--- 1. Markdown form: Handled by `passes/report.lua` (writes `.atoms.md`). +--- The render functions `render_source_map` + `render_provenance` are exported for `report.lua` to call directly. --- Compile artifacts (`*.macs.h`, `*.offsets.h`) stay in `/gen/`. ---- 2. **gdb-runtime form** — `/gdb_tape_atoms_runtime.gdb`. A pure gdb command script — addresses come ---- from `nm`, the 9 user commands are static `define ... end` blocks. Emitted when `ctx.flags.gdb_runtime` is true ---- AND `ctx.flags.elf_path` points to an existing ELF. Useful for `gdb-multiarch --without-python` users ---- (the common case on Windows MinGW builds) — `source ` loads it with no Python / Tcl / Guile required. +--- 2. `gdb_tape_atoms_runtime.gdb`: Post-link opt-in (`ctx.flags.gdb_runtime`), +--- so the gdb wrapper script + the generated runtime script share the same canonical location. +--- Triggered by `--post-link` or `--gdb-runtime`. --- ---- **Output format** (sourcemap.txt form): +--- Output forma (sourcemap.txt form): --- ``` --- # FORMAT_VERSION 1 --- # auto-generated by ps1_meta.lua (passes/atoms_source_map.lua) — DO NOT EDIT @@ -32,8 +30,6 @@ --- ``` --- --- Marker records are zero-width in `atom.paths.items`, so they emit no WORD rows in the dense word view. ---- ---- **Conventions:** tabs (1/level), EmmyLua annotations, no regex, Lua 5.3 compatible. -- ════════════════════════════════════════════════════════════════════════════ -- Module-scope requires + package.path setup @@ -458,7 +454,22 @@ local function emit_gdb_runtime(ctx) -- Confirmation line for the source operator. 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" + local out_path + -- Move out of `/gdb_tape_atoms_runtime.gdb` to `/../gdb_tape_atoms_runtime.gdb` when the conventional `` is `/gen` + -- (any equivalent spelling — relative, absolute backslash, absolute forward-slash, trailing-separator variants). + -- This puts the gdb runtime alongside the ELF at `build/` rather than under the report subdir. + local function ends_with_gen_dir(p) + if type(p) ~= "string" then return false end + return p:match("[/\\]gen[/\\]?$") ~= nil or p == "build/gen" or p == "build\\gen" + end + if ends_with_gen_dir(ctx.out_root) then + -- Strip the trailing `/gen` segment, then write the runtime script under `build/`. + -- e.g. "C:/projects/Pikuma/ps1/build/gen" -> "C:/projects/Pikuma/ps1/build". + local parent = ctx.out_root:gsub("[/\\]gen[/\\]?$", "") + out_path = parent .. "/gdb_tape_atoms_runtime.gdb" + else + out_path = ctx.out_root .. "/gdb_tape_atoms_runtime.gdb" + 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)) @@ -470,10 +481,62 @@ end local M = {} ---- Pass entry. For each source that declares at least one `MipsAtom_(name)` / `MipsCode code_`, emit two files ---- in `/`: `.atoms.sourcemap.txt` (per-word call-site map) and `.atoms.provenance.txt` ---- (per-word definition + body line, resolved via the outermost `mac_X(...)` invocation). When `ctx.flags.gdb_runtime` ---- is true and `ctx.flags.elf_path` exists, also emit the post-link gdb script `/gdb_tape_atoms_runtime.gdb`. +-- Expose the pure render functions so `report.lua` and the focused tests can call them directly without triggering the file-emit path. +M.render_source_map = render_source_map +M.render_provenance = render_provenance + +--- Render ONE atom's sourcemap stanza. +--- @param atom table -- atom record (must have `atom.paths` populated) +--- @return string +function M.render_atom_source_map(atom) + assert(type(atom) == "table", "render_atom_source_map: atom must be a table") + assert(type(atom.paths) == "table", "render_atom_source_map: atom.paths must be a table") + local entries, total = canonical_word_entries(atom) + local lines = {} + lines[#lines + 1] = string.format("ATOM %s %d", (atom.raw_name or atom.name), total) + for _, entry in ipairs(entries) do + lines[#lines + 1] = string.format("WORD %d LINE %d TEXT %s", + entry.pos, entry.line, entry.text) + end + lines[#lines + 1] = "ENDATOM" + return table.concat(lines, "\n") .. "\n" +end + +--- Render ONE atom's provenance stanza — no per-file format header, no enumeration of other atoms. +--- +--- `rel_path` is the source path (forward-slashes) embedded in every `CALL` line. +--- The .md caller (report.lua) is expected to derive this once per `## ` heading and pass it down for each atom in that source. +--- @param atom table -- atom record (must have `atom.paths` populated) +--- @param wc table -- identity alias of `corpus.word_counts` +--- @param rel_path string -- source path (forward-slashes) for `CALL` fields +--- @return string +function M.render_atom_provenance(atom, wc, rel_path) + assert(type(atom) == "table", "render_atom_provenance: atom must be a table") + assert(type(atom.paths) == "table", "render_atom_provenance: atom.paths must be a table") + assert(type(rel_path) == "string", "render_atom_provenance: rel_path must be a string") + local entries, total = canonical_word_entries(atom) + local lines = {} + lines[#lines + 1] = string.format("ATOM %s %d", (atom.raw_name or atom.name), total) + for _, entry in ipairs(entries) do + local inv = entry.invocation + local macro_count = inv and wc and wc["mac_" .. inv.component_name] + if inv and macro_count ~= nil then + lines[#lines + 1] = string.format( + 'WORD %d CALL %s:%d MACRO %s "%s:%d" BODY %d', + entry.pos, rel_path, entry.line, inv.component_name, + inv.def_path or "", inv.def_line or 0, entry.body_line) + else + lines[#lines + 1] = string.format( + "WORD %d CALL %s:%d RAW", entry.pos, rel_path, entry.line) + end + end + return table.concat(lines, "\n") .. "\n" +end + +--- Pass entry. For each source that declares at least one `MipsAtom_(name)` / `MipsCode code_`, +--- emit two files in `/`: `.atoms.sourcemap.txt` (per-word call-site map) and `.atoms.provenance.txt` +--- (per-word definition + body line, resolved via the outermost `mac_X(...)` invocation). +--- When `ctx.flags.gdb_runtime` is true and `ctx.flags.elf_path` exists, also emit the post-link gdb script `/gdb_tape_atoms_runtime.gdb`. --- @param ctx PassCtx --- @return PassResult function M.run(ctx) @@ -495,34 +558,8 @@ function M.run(ctx) } end - -- Always emit the text form (per-source). - for _, src in ipairs(corpus.source_order) do - local has_projection = false - for _, atom in ipairs((src.scan or {}).atoms or {}) do - 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 - if atom.paths then has_projection = true; break end - end - 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) - 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 - end + -- atoms.sourcemap.txt + atoms.provenance.txt content moved to report.lua via `.atoms.md` markdown file. + -- This pass emits only the post-link gdb_runtime artifact (see emit_gdb_runtime below). -- Optionally emit the gdb-runtime form (post-link, one file per build). if ctx.flags and ctx.flags.gdb_runtime then diff --git a/scripts/passes/report.lua b/scripts/passes/report.lua index a62d4b5..004d3a3 100644 --- a/scripts/passes/report.lua +++ b/scripts/passes/report.lua @@ -7,9 +7,6 @@ --- --- 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. -- ════════════════════════════════════════════════════════════════════════════ -- Module-scope requires + package.path setup @@ -18,18 +15,22 @@ -- Resolve `arg[0]` to an absolute-ish script directory so that `require("duffle")` resolves against `scripts/` regardless of CWD. -- Bootstrap: see `ps1_meta.lua` for the rationale. -- Bootstrap: load `scripts/duffle_paths.lua` (sets package.path + package.cpath). --- Uses `debug.getinfo` to find this file's own directory, so it works --- both standalone and when require'd from the orchestrator. +-- Uses `debug.getinfo` to find this file's own directory, so it works both standalone and when require'd from the orchestrator. -- Bootstrap: load `duffle_paths.lua` via `debug.getinfo(1, "S").source` (works both standalone + when require'd). -- 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 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") +-- Load atoms_source_map for the `render_source_map` / `render_provenance` module functions (used by `render_module_atoms_md` to produce `.atoms.md` without re-walking source tokens). +-- The pass itself emits no per-source files anymore; we only consume the two pure renderers here. +-- Defined BEFORE the renderer functions below so their upvalues resolve to this local (not the global `atoms_source_map`, which is nil). +local atoms_source_map = dofile(_bootstrap_dir .. "atoms_source_map.lua") + -- ════════════════════════════════════════════════════════════════════════════ -- Constants -- ════════════════════════════════════════════════════════════════════════════ @@ -148,328 +149,451 @@ local function source_basename(path) return path:match(BASENAME_PATTERN) or path end ---- (internal) Format a single annotation entry as one rendered line. ---- @param a AnnotEntry ---- @param src_name string +-- ════════════════════════════════════════════════════════════════════════════ +-- Markdown renderers (consolidated-report-files refactor, 2026-07-26) +-- ════════════════════════════════════════════════════════════════════════════ + +--- Render the thin project-wide summary (`build/atom_meta_report.summary.md`). +--- @param all_results { module:string, atoms:integer, annots:integer, binds:integer, +--- macros:integer, findings:integer, errors:integer, +--- warnings:integer, info:integer }[] --- @return string -local function format_annot_line(a, src_name) - if a.error then - return string.format(" ✗ line %d %s [ERROR: %s] [%s]", a.line, a.macro or "?", a.error, src_name) - end - local line = string.format(" ● line %d %s [%s]", a.line, a.name, src_name) - if a.binds then line = line .. " binds=" .. a.binds end - if #a.reads > 0 then line = line .. " reads={" .. table.concat(a.reads, ",") .. "}" end - if #a.writes > 0 then line = line .. " writes={" .. table.concat(a.writes, ",") .. "}" end - return line -end - ---- (internal) Tally totals across all results in a module. ---- @param results AnnotationResult[] ---- @return integer, integer, integer, integer, integer, integer -local function tally_module_totals(results) - local total_atoms, total_annots, total_binds, total_macros = 0, 0, 0, 0 - local total_errors, total_warnings = 0, 0 - for _, r in ipairs(results) do - total_atoms = total_atoms + #r.atoms - total_annots = total_annots + #r.annots - total_binds = total_binds + #r.binds - total_macros = total_macros + #r.macros - total_errors = total_errors + #r.errors - total_warnings = total_warnings + #r.warnings - end - return total_atoms, total_annots, total_binds, total_macros, total_errors, total_warnings -end - --- (internal) Section renderer: per-source atom declarations. -local function render_module_atoms_section(add, results) - add(SECTION_HEADER_ATOMS) - for _, r in ipairs(results) do - local src_name = source_basename(r.source) - for _, a in ipairs(r.atoms) do - add(string.format(" MipsAtom_(%s) line %d [%s]", a.name, a.line, src_name)) - end - end - add("") -end - --- (internal) Section renderer: per-source annotation entries. -local function render_module_annots_section(add, results) - add(SECTION_HEADER_ANNOTS) - for _, r in ipairs(results) do - local src_name = source_basename(r.source) - for _, a in ipairs(r.annots) do - add(format_annot_line(a, src_name)) - end - end - add("") -end - --- (internal) Section renderer: per-source Binds_* struct declarations. -local function render_module_binds_section(add, results) - add(SECTION_HEADER_BINDS) - for _, r in ipairs(results) do - local src_name = source_basename(r.source) - for _, b in ipairs(r.binds) do - add(string.format(" %s line %d %d bytes [%s]", b.name, b.line, b.bytes, src_name)) - for _, f in ipairs(b.fields) do - add(string.format(" +%2d: %s", f.offset, f.name)) - end - end - end - add("") -end - --- (internal) Section renderer: per-source macro word-count declarations. -local function render_module_macros_section(add, results) - add(SECTION_HEADER_MACROS) - for _, r in ipairs(results) do - local src_name = source_basename(r.source) - for _, m in ipairs(r.macros) do - add(string.format(" %s line %d words=%d [%s]", m.name, m.line, m.words, src_name)) - end - end - add("") -end - --- (internal) Section renderer: per-source errors (one-line + "(none)" if empty). -local function render_module_errors_section(add, results, total_errors) - add(SECTION_HEADER_ERRORS) - if total_errors == 0 then - add(" (none)") - else - for _, r in ipairs(results) do - local src_name = source_basename(r.source) - for _, e in ipairs(r.errors) do - add(string.format(" ✗ line %d %s [%s]", e.line, e.msg, src_name)) - end - end - end - add("") -end - --- (internal) Section renderer: per-source warnings (one-line + "(none)" if empty). -local function render_module_warnings_section(add, results, total_warnings) - add(SECTION_HEADER_WARNINGS) - if total_warnings == 0 then - add(" (none)") - else - for _, r in ipairs(results) do - local src_name = source_basename(r.source) - for _, w in ipairs(r.warnings) do - add(string.format(" ⚠ line %d %s [%s]", w.line, w.msg, src_name)) - end - end - end - add("") -end - --- ════════════════════════════════════════════════════════════════════════════ --- SECTION_RENDERERS — data-driven section dispatch (the plex pattern) --- ════════════════════════════════════════════════════════════════════════════ --- --- Each entry maps a section to its (header, render_fn). The render_fn signature: --- render_fn(add, results, totals) --- add -- the `add(line)` closure from the surrounding report renderer --- results -- AnnotationResult[] (per-source results) --- totals -- {atoms, annots, binds, macros, errors, warnings} counts --- --- Sections that need to render "(none)" vs iterate use totals.errors / totals.warnings; --- other sections ignore the totals arg. --- Adding a new section = 1 row here + 1 render__section function. -local SECTION_RENDERERS = { - { header = SECTION_HEADER_ATOMS, render = render_module_atoms_section }, - { header = SECTION_HEADER_ANNOTS, render = render_module_annots_section }, - { header = SECTION_HEADER_BINDS, render = render_module_binds_section }, - { header = SECTION_HEADER_MACROS, render = render_module_macros_section }, - { header = SECTION_HEADER_ERRORS, render = function(add, results, totals) return render_module_errors_section(add, results, totals.errors) end }, - { header = SECTION_HEADER_WARNINGS, render = function(add, results, totals) return render_module_warnings_section(add, results, totals.warnings) end }, -} - ---- Render the per-MODULE annotation report (one `.annotations.txt`). ---- @param dir string -- module directory path ---- @param sources SourceFile[] -- sources in this module ---- @param results AnnotationResult[] -- per-source validate() results ---- @return string -- the rendered report text -local function render_module_report(dir, sources, results) - local lines = {} - local function add(s) lines[#lines + 1] = s end - - add(RULE_THICK) - add("ANNOTATION PASS — module " .. source_basename(dir)) - add(RULE_THICK) - add(string.format("Sources: %d", #sources)) - for _, s in ipairs(sources) do add(" " .. s.path) end - add("") - - local total_atoms, total_annots, total_binds, total_macros, total_errors, total_warnings = tally_module_totals(results) - add(string.format("Atoms: %d Annotations: %d Binds structs: %d Macro decls: %d", - total_atoms, total_annots, total_binds, total_macros)) - add("") - - -- Bundle the totals so the section renderers don't need separate parameter lists. - -- Errors/warnings sections need their total count to decide "(none)" vs iterate. - -- Sections without totals (atoms/annots/binds/macros) ignore this arg. - local totals = { - atoms = total_atoms, annots = total_annots, binds = total_binds, - macros = total_macros, errors = total_errors, warnings = total_warnings, +local function render_project_summary(all_results) + local lines = { + "# Project summary", + "> Auto-generated by ps1_meta.lua (passes/report.lua).", + "", + "| module | atoms | annots | binds | macros | findings | errors | warnings | info |", + "|--------|-------|--------|-------|--------|----------|--------|----------|------|", } - - -- THE per-section dispatch. ONE loop over SECTION_RENDERERS. - -- Each renderer writes its header + content via the `add` closure (pre-bound above). - -- Adding a new section = 1 row here + 1 render__section function. - for _, section in ipairs(SECTION_RENDERERS) do - section.render(add, results, totals) + local totals = { atoms = 0, annots = 0, binds = 0, macros = 0, + findings = 0, errors = 0, warnings = 0, info = 0 } + for _, e in ipairs(all_results) do + lines[#lines + 1] = string.format( + "| %s | %d | %d | %d | %d | %d | %d | %d | %d |", + e.module, e.atoms, e.annots, e.binds, e.macros, + e.findings, e.errors, e.warnings, e.info) + totals.atoms = totals.atoms + e.atoms + totals.annots = totals.annots + e.annots + totals.binds = totals.binds + e.binds + totals.macros = totals.macros + e.macros + totals.findings = totals.findings + e.findings + totals.errors = totals.errors + e.errors + totals.warnings = totals.warnings + e.warnings + totals.info = totals.info + e.info end - + lines[#lines + 1] = string.format( + "| **TOTAL** | %d | %d | %d | %d | %d | %d | %d | %d |", + totals.atoms, totals.annots, totals.binds, totals.macros, + totals.findings, totals.errors, totals.warnings, totals.info) return table.concat(lines, "\n") .. "\n" end --- ════════════════════════════════════════════════════════════════════════════ --- Per-project summary --- ════════════════════════════════════════════════════════════════════════════ - ---- Render the per-project summary (`build/gen/annotation_validation.txt`). ---- Aggregates totals across all sources; lists per-source error counts if any source has errors. ---- @param all_results AnnotationResult[] +--- Render the per-module verbose source-map markdown (`build/.atoms.md`). +--- Per-source sub-section, per-atom stanza with sourcemap + provenance rows. +--- Pulls sourcemap + provenance from `atoms_source_map` (no second source walk). +--- @param dir string +--- @param dir_sources SourceFile[] +--- @param wc table --- @return string -local function render_project_report(all_results) - local lines = {} +local function render_module_atoms_md(dir, dir_sources, wc) + local dir_basename = source_basename(dir) + local lines = { + "# " .. dir_basename .. " — atoms (verbose source map)", + "> Per-word call-site + provenance. Auto-generated.", + "", + } + for _, src in ipairs(dir_sources) do + local src_name = source_basename(src.path) + lines[#lines + 1] = "## " .. src_name + lines[#lines + 1] = "" + -- For each atom with a projection, render its sourcemap + provenance. + local atoms_list = {} + for _, atom in ipairs((src.scan or {}).atoms or {}) do + if atom.paths then atoms_list[#atoms_list + 1] = atom end + end + for _, atom in ipairs((src.scan or {}).raw_atoms or {}) do + if atom.paths then atoms_list[#atoms_list + 1] = atom end + end + if #atoms_list == 0 then + lines[#lines + 1] = "_(no atom projections)_" + lines[#lines + 1] = "" + else + -- Per-source forward-slash path (same one `emit_atom_stanza` / `emit_provenance_stanza` would derive; + -- computed once per `## ` heading and reused by each atom's `WORD N CALL ...` field). + local rel_path = src.path:gsub("\\\\", "/") + for _, atom in ipairs(atoms_list) do + lines[#lines + 1] = string.format( + "### atom: %s (line %d, %d words)", + atom.name, atom.line or 0, #(atom.paths.items or {})) + lines[#lines + 1] = "" + lines[#lines + 1] = "**Sourcemap** — per-word call site:" + lines[#lines + 1] = "```" + -- Per-atom invariant: call the per-atom renderers, NOT the per-source ones. + -- The per-source renderers enumerate every atom in `src`; + -- calling them in a per-atom loop would repeat the whole source under every `### atom:` heading. + lines[#lines + 1] = atoms_source_map.render_atom_source_map(atom):gsub("\n+$", "") + lines[#lines + 1] = "```" + lines[#lines + 1] = "" + lines[#lines + 1] = "**Provenance** — per-word definition + body:" + lines[#lines + 1] = "```" + lines[#lines + 1] = atoms_source_map.render_atom_provenance(atom, wc, rel_path):gsub("\n+$", "") + lines[#lines + 1] = "```" + lines[#lines + 1] = "" + end + end + end + return table.concat(lines, "\n") .. "\n" +end + +--- Render the consolidated per-module markdown (`build/.atom_meta_report.md`). +--- Aggregates annotation + static-analysis content across all sources in `dir`. +--- Annotations come from re-running `annotation.validate()` per source (the existing pattern); +--- static-analysis comes from `corpus.static_analysis_results[dir_basename]` (populated by `static_analysis.lua` — no second corpus_pipe_ctx build). +--- @param dir string +--- @param dir_sources SourceFile[] +--- @param annot_results AnnotationResult[] +--- @param sa_results table -- corpus.static_analysis_results[dir_basename] +--- @return string +local function render_module_meta_report(dir, dir_sources, annot_results, sa_results) + local dir_basename = source_basename(dir) + local lines = { + "# " .. dir_basename .. " — atom meta report", + "> Auto-generated by ps1_meta.lua (passes/report.lua). Do not edit.", + "", + } local function add(s) lines[#lines + 1] = s end - local total_atoms, total_annots, total_macros, total_binds = 0, 0, 0, 0 - local total_errors, total_warnings = 0, 0 - for _, r in ipairs(all_results) do - total_atoms = total_atoms + #r.atoms - total_annots = total_annots + #r.annots - total_macros = total_macros + #r.macros - total_binds = total_binds + #r.binds - total_errors = total_errors + #r.errors - total_warnings = total_warnings + #r.warnings + -- Module summary table. + local n_atoms = 0 + local n_annot = 0 + local n_binds = 0 + local n_macros = 0 + local n_bare, n_proc = 0, 0 + for _, r in ipairs(annot_results) do + n_atoms = n_atoms + #r.atoms + n_annot = n_annot + #r.annots + n_binds = n_binds + #r.binds + n_macros = n_macros + #r.macros + end + for _, a in ipairs(sa_results.atoms or {}) do + if a.kind == "comp_bare" then n_bare = n_bare + 1 + elseif a.kind == "comp_proc" then n_proc = n_proc + 1 + end end - add(RULE_THICK) - add("ANNOTATION VALIDATION — project summary") - add(RULE_THICK) - add("") - add(string.format("Atoms: %d", total_atoms)) - add(string.format("Annotations: %d", total_annots)) - add(string.format("Macros: %d", total_macros)) - add(string.format("Binds: %d", total_binds)) - add("") - add(string.format("Errors: %d", total_errors)) - add(string.format("Warnings: %d", total_warnings)) + add("## Module summary"); add("") + add("| metric | value |"); add("|--------|-------|") + add(string.format("| sources | %d |", #dir_sources)) + add(string.format("| atoms | %d (atoms: %d, comp_bare: %d, comp_proc: %d) |", + #(sa_results.atoms or {}), + #(sa_results.atoms or {}) - n_bare - n_proc, n_bare, n_proc)) + add(string.format("| annotations | %d |", n_annot)) + add(string.format("| binds structs | %d |", n_binds)) + add(string.format("| macro decls | %d |", n_macros)) + add(string.format("| findings | %d (errors: %d, warnings: %d, info: %d) |", + #(sa_results.findings or {}), + #(sa_results.errors or {}), + #(sa_results.warnings or {}), + #(sa_results.info or {}))) add("") - if total_errors > 0 then - add("Per-source error counts:") - for _, r in ipairs(all_results) do - if #r.errors > 0 then - local src_name = source_basename(r.source) - add(string.format(" %s : %d error(s)", src_name, #r.errors)) + -- Sources + add("## Sources"); add("") + for _, s in ipairs(dir_sources) do add("- `" .. s.path .. "`") end + add("") + + -- Atoms (annotation) + add("## Atoms"); add("") + add("| kind | name | source | line |"); add("|------|------|--------|------|") + for _, r in ipairs(annot_results) do + local src_name = source_basename(r.source) + for _, a in ipairs(r.atoms) do + add(string.format("| atom | %s | %s | %d |", a.name, src_name, a.line)) + end + end + add("") + + -- Annotations + add("## Annotations"); add("") + if #annot_results == 0 then + add("_(none)_") + else + add("| source | line | name | binds | reads | writes |") + add("|--------|------|------|-------|-------|--------|") + for _, r in ipairs(annot_results) do + local src_name = source_basename(r.source) + for _, a in ipairs(r.annots) do + local binds = a.binds or "—" + local reads = (#a.reads > 0 and table.concat(a.reads, ",")) or "—" + local writes = (#a.writes > 0 and table.concat(a.writes, ",")) or "—" + add(string.format("| %s | %d | %s | %s | %s | %s |", + src_name, a.line, a.name, binds, reads, writes)) + end + end + end + add("") + + -- Binds_* structs + add("## Binds_* structs"); add("") + if #annot_results == 0 then + add("_(none)_") + else + for _, r in ipairs(annot_results) do + local src_name = source_basename(r.source) + for _, b in ipairs(r.binds) do + add(string.format("### %s (%s:%d, %d bytes)", + b.name, src_name, b.line, b.bytes)) + for _, f in ipairs(b.fields) do + add(string.format("- `+%d %s`", f.offset, f.name)) + end + add("") + end + end + end + + -- Macro decls + add("## Macro word-count declarations"); add("") + if #annot_results == 0 then + add("_(none)_") + else + add("| source | line | macro declaration |") + add("|--------|------|-------------------|") + for _, r in ipairs(annot_results) do + local src_name = source_basename(r.source) + for _, m in ipairs(r.macros) do + add(string.format("| %s | %d | %s |", + src_name, m.line, m.name)) + end + end + end + add("") + + -- Findings by atom (static-analysis) + add("## Static analysis — findings by atom"); add("") + local by_atom = {} + for _, f in ipairs(sa_results.findings or {}) do + by_atom[f.atom] = by_atom[f.atom] or {} + by_atom[f.atom][#by_atom[f.atom] + 1] = f + end + if next(by_atom) == nil then + add("_(no findings)_") + else + for _, a in ipairs(sa_results.atoms or {}) do + local fs = by_atom[a.name] + if fs then + add(string.format("### %s", a.name)) + for _, f in ipairs(fs) do + add(string.format("- `[%s] %s`", f.check, f.msg)) + end + add("") + end + end + end + + -- Errors / Warnings / Info + local function add_findings(label, entries) + add(string.format("## %s", label)) + if #entries == 0 then + add("_(none)_") + else + for _, e in ipairs(entries) do + add(string.format("- line %d %s", e.line, e.msg)) end end add("") end + add_findings("Errors", sa_results.errors or {}) + add_findings("Warnings", sa_results.warnings or {}) + add_findings("Info", sa_results.info or {}) + + -- Per-atom cycle counts (path-aware) + add("## Per-atom cycle counts (path-aware, best case, no stalls)"); add("") + add("| atom | source | min | max | branches | paths | notes |") + add("|------|--------|-----|-----|----------|-------|-------|") + local sorted = {} + for _, a in ipairs(sa_results.atoms or {}) do sorted[#sorted + 1] = a end + table.sort(sorted, function(x, y) + return ((x.paths or {}).cycles_max or 0) > ((y.paths or {}).cycles_max or 0) + end) + for _, a in ipairs(sorted) do + local p = a.paths or {} + local src_name = a.source_path and source_basename(a.source_path) or "" + local notes = "" + if p.has_loops then notes = notes .. " [loop!]" end + if p.unknown_macros and #p.unknown_macros > 0 then + notes = notes .. " [unknown: " .. table.concat(p.unknown_macros, ", ") .. "]" + end + add(string.format("| %s | %s | %d | %d | %d | %d | %s |", + a.name, src_name, + p.cycles_min or 0, p.cycles_max or 0, + p.branches or 0, p.paths or 0, notes)) + end + add("") + + -- Per-source scan summary + add("## Per-source scan summary"); add("") + for _, src in ipairs(dir_sources) do + local src_atoms = {} + for _, a in ipairs(sa_results.atoms or {}) do + if a.source_path == src.path then src_atoms[#src_atoms + 1] = a end + end + if #src_atoms > 0 then + local mn, mx = math.huge, -1 + for _, a in ipairs(src_atoms) do + local p = a.paths or {} + if (p.cycles_min or 0) < mn then mn = p.cycles_min or 0 end + if (p.cycles_max or 0) > mx then mx = p.cycles_max or 0 end + end + local path_str + if mx > 0 then + path_str = string.format(" cycles=%d..%d", mn, mx) + else + path_str = string.format(" %d cycles", mn) + end + add(string.format("- `%s` — %d atom%s%s", + src.basename, #src_atoms, + #src_atoms == 1 and "" or "s", path_str)) + end + end + add("") return table.concat(lines, "\n") .. "\n" end -- ════════════════════════════════════════════════════════════════════════════ --- Orchestration helpers +-- REPORT_RENDERERS — data-driven report dispatch (one row per file kind) -- ════════════════════════════════════════════════════════════════════════════ - ---- (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. Eeach 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 module_results = {} - local all_results = {} - for _, src in ipairs(dir_sources) do - 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 - end - return module_results, all_results -end - ---- (internal) Does this module's results contain anything worth emitting? ---- @param module_results AnnotationResult[] ---- @return boolean -local function module_has_content(module_results) - for _, r in ipairs(module_results) do - if #r.atoms > 0 or #r.annots > 0 or #r.binds > 0 - or #r.macros > 0 or #r.errors > 0 or #r.warnings > 0 then - return true - end - end - return false -end - ---- (internal) Log a debug message if `_G[DEBUG_FLAG]` is truthy. ---- @param fmt string -local function debug_log(fmt, ...) - if _G[DEBUG_FLAG] then - io.stderr:write(string.format("[%s] " .. fmt, PASS_NAME, ...)) - end -end +-- `once = true` means render once at the project level (not per-module). +-- `basename(dir_basename)` yields the file's basename for that kind. +-- `gather(ctx, dir, dir_sources [, all_modules])` returns the rendered string. +local REPORT_RENDERERS = { + { + name = "atom_meta_report", + ext = "md", + basename = function(dir_basename) return dir_basename .. ".atom_meta_report" end, + once = false, + gather = function(ctx, dir, dir_sources) + -- Annotations: re-run `annotation.validate()` per source (the existing pattern). + local annot_results = {} + for _, src in ipairs(dir_sources) do + if src.scan then + local r = annotation.validate(ctx, src, nil) + r.source = src.path + annot_results[#annot_results + 1] = r + end + end + -- Static-analysis: read stashed projection (no re-validate). + local dir_basename = dir:match("([^/\\]+)$") or dir + local sa_results = (ctx.shared.corpus.static_analysis_results or {})[dir_basename] or {} + return render_module_meta_report(dir, dir_sources, annot_results, sa_results) + end, + }, + { + name = "atoms", + ext = "md", + basename = function(dir_basename) return dir_basename .. ".atoms" end, + once = false, + gather = function(ctx, dir, dir_sources) + return render_module_atoms_md(dir, dir_sources, + ctx.shared.corpus.word_counts or {}) + end, + }, + { + name = "summary", + ext = "md", + basename = function(_dir_basename) return "atom_meta_report.summary" end, + once = true, + gather = function(_ctx, _dir, _dir_sources, all_modules) + return render_project_summary(all_modules) + end, + }, +} -- ════════════════════════════════════════════════════════════════════════════ --- M — module exports +-- M — public pass surface -- ════════════════════════════════════════════════════════════════════════════ local M = {} ---- Run the report pass. ---- Renders one `.annotations.txt` per source-directory that has content, plus the project-wide `annotation_validation.txt` summary. +--- Run the report pass. Emits 1 `atom_meta_report.summary.md` per build + 2 `atom_meta_report.md` + 2 `atoms.md` files per module (duffle + gte_hello). +--- Reads `corpus.static_analysis_results` (added in Phase 1) to populate per-module findings without re-running validate(). --- @param ctx PassCtx --- @return PassResult function M.run(ctx) - local outputs = {} - local errors = {} - local warnings = {} + local outputs = {} + local corpus = ctx.shared and ctx.shared.corpus + local by_dir = (corpus and corpus.sources_by_dir) or {} - -- 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 {} + -- `out_path_root`: when the conventional `out_root` is `build/gen` (any spelling — relative, absolute, separator variants). + -- Write the md files to `build/` (parent of `gen/`) instead of nested under `gen/`. + -- Mirrors the `gdb_tape_atoms_runtime.gdb` relocation. + local function ends_with_gen(p) + return type(p) == "string" and (p:match("[/\\]gen[/\\]?$") ~= nil + or p == "build/gen" or p == "build\\gen") + end + local out_root_effective = ends_with_gen(ctx.out_root) + and ctx.out_root:gsub("[/\\]gen[/\\]?$", "") + or ctx.out_root - duffle.ensure_dir(ctx.out_root) + duffle.ensure_dir(out_root_effective) + + -- Aggregator for the project-wide `once = true` summary renderer. + local all_modules = {} - local all_results_for_summary = {} 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 #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 + -- Per-renderer dispatch for the per-module renderers (once = false). + for _, renderer in ipairs(REPORT_RENDERERS) do + if not renderer.once then + local body = renderer.gather(ctx, dir, dir_sources) + local out_path = out_root_effective .. "/" .. renderer.basename(dir_basename) .. "." .. renderer.ext + duffle.write_file(out_path, body) + outputs[#outputs + 1] = { kind = renderer.name, path = out_path } end + end - if module_has_content(module_results) then - 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") + -- For the summary, compute per-module totals once (re-validating annotations per source — same pattern as the meta_report renderer). + local annot_results = {} + for _, src in ipairs(dir_sources) do + if src.scan then + local r = annotation.validate(ctx, src, nil) + r.source = src.path + annot_results[#annot_results + 1] = r end end + local n_annot, n_binds, n_macros = 0, 0, 0 + for _, r in ipairs(annot_results) do + n_annot = n_annot + #r.annots + n_binds = n_binds + #r.binds + n_macros = n_macros + #r.macros + end + local sa_results = (corpus.static_analysis_results or {})[dir_basename] or {} + all_modules[#all_modules + 1] = { + module = dir_basename, + atoms = #(sa_results.atoms or {}), + annots = n_annot, + binds = n_binds, + macros = n_macros, + findings = #(sa_results.findings or {}), + errors = #(sa_results.errors or {}), + warnings = #(sa_results.warnings or {}), + info = #(sa_results.info or {}), + } + end + + -- Project-wide renderer (once = true): write the summary file. + for _, renderer in ipairs(REPORT_RENDERERS) do + if renderer.once then + local body = renderer.gather(ctx, nil, nil, all_modules) + local out_path = out_root_effective .. "/" .. renderer.basename("") .. "." .. renderer.ext + duffle.write_file(out_path, body) + outputs[#outputs + 1] = { kind = renderer.name, path = out_path } + end end - 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 } - end - - return { outputs = outputs, errors = errors, warnings = warnings } + return { outputs = outputs, errors = {}, warnings = {} } end return M diff --git a/scripts/passes/static_analysis.lua b/scripts/passes/static_analysis.lua index 556596a..b17ad3f 100644 --- a/scripts/passes/static_analysis.lua +++ b/scripts/passes/static_analysis.lua @@ -44,12 +44,9 @@ --- module = "passes.static_analysis", --- kind = "diagnostic", --- deps = {"word-counts", "components"}, ---- out = { { kind = "report", path_template = "/.static_analysis.txt" } } --- } ---- `kind = "diagnostic"` keeps every finding visible in the report; the orchestrator does not exit non-zero on static-analysis errors. +--- `kind = "diagnostic"` keeps every finding visible in the projection; the orchestrator does not exit non-zero on static-analysis errors. --- Annotation and header-output validation remain build-stopping. ---- ---- **Conventions**: tabs (1/level), EmmyLua annotations, no regex, Lua 5.3 compatible. -- ════════════════════════════════════════════════════════════════════════════ -- Module-scope requires + package.path setup @@ -2179,203 +2176,6 @@ local function validate(ctx, src, corpus_pipe_ctx) } end --- ════════════════════════════════════════════════════════════════════════════ --- Per-directory output: build/gen/.static_analysis.txt --- ════════════════════════════════════════════════════════════════════════════ - ---- Per-directory emit. Aggregates atoms + findings across every source in `dir_sources` ---- and writes a single report to `/.static_analysis.txt`. ---- Called only when at least one atom was found (the caller in M.run handles the skip). ---- ---- `info` is finding-level info only (kind == "info" findings); the scanned/cycles summary rows ---- live in `summaries` and are rendered as trailing summary lines after `Module findings:`. -local function emit_module_static_analysis_txt(ctx, dir, dir_sources, atoms, findings, errors, warnings, info, summaries) - -- 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" - duffle.ensure_dir(ctx.out_root) - - local lines = {} - local function add(s) lines[#lines + 1] = s end - - add("========================================================") - add("STATIC ANALYSIS PASS -- module " .. dir_basename) - add("========================================================") - add(string.format("Sources: %d", #dir_sources)) - for _, s in ipairs(dir_sources) do - add(" " .. s.path) - end - add("") - - -- Tally atoms by kind for the header summary - local n_atoms, n_bare, n_proc = 0, 0, 0 - for _, a in ipairs(atoms) do - n_atoms = n_atoms + 1 - if a.kind == "comp_bare" then n_bare = n_bare + 1 - elseif a.kind == "comp_proc" then n_proc = n_proc + 1 - end - end - local header_atoms = string.format("Atoms: %d", n_atoms) - if n_bare > 0 or n_proc > 0 then - header_atoms = header_atoms .. string.format(" (atoms: %d, comp_bare: %d, comp_proc: %d)", - n_atoms - n_bare - n_proc, n_bare, n_proc) - end - -- Header carries the per-severity counts; info is its own column, not a warning. - -- (`Info: N` is the byte-asserted field that the focused test matches; do not collapse it into Warnings.) - add(string.format("%s Findings: %d Errors: %d Warnings: %d Info: %d", - header_atoms, #findings, #errors, #warnings, #info)) - add("") - - -- Group findings by atom (with source prefix when multi-source module) - local multi_source = #dir_sources > 1 - local by_atom = {} - for _, f in ipairs(findings) do - by_atom[f.atom] = by_atom[f.atom] or {} - by_atom[f.atom][#by_atom[f.atom] + 1] = f - end - - if next(by_atom) == nil then - add(" (no findings -- every atom passed all checks)") - else - add("── Findings by atom ─────────────────────────────────────") - for _, a in ipairs(atoms) do - local fs = by_atom[a.name] - if fs then - local label = a.name - if multi_source and a.source_path then - label = string.format("%s (%s)", a.name, a.source_path:match("([^/\\]+)$") or a.source_path) - end - add(string.format(" %s line %d", label, a.line)) - for _, f in ipairs(fs) do - add(string.format(" [%s] %s", f.check, f.msg)) - end - end - end - end - - add("") - add("── Errors ──────────────────────────────────────────────") - if #errors == 0 then add(" (none)") end - for _, e in ipairs(errors) do - add(string.format(" X line %d %s", e.line, e.msg)) - end - - add("") - add("── Warnings ────────────────────────────────────────────") - if #warnings == 0 then add(" (none)") end - for _, w in ipairs(warnings) do - add(string.format(" ! line %d %s", w.line, w.msg)) - end - - -- Finding-level Info section. - -- Rendered between Warnings and the per-atom cycle table so the next `── ` line after `── Info` is the per-atom cycle counts section; - -- the trailing scan/cycle summary rows (rendered after Module findings) stay outside this section. - add("") - add("── Info ────────────────────────────────────────────────") - if #info == 0 then add(" (none)") end - for _, i_ in ipairs(info) do - add(string.format(" i line %d %s", i_.line, i_.msg)) - end - - -- Per-atom cycle counts (path-aware). For each atom: - -- min = shortest path through the body (earliest exit) - -- max = longest path through the body (full fall-through) - -- br = number of branch instructions - -- paths = number of distinct paths reached - -- Both min and max are best-case (no stalls); BD-slot nops are absorbed into branch costs (MIPS semantics). - add("") - add("── Per-atom cycle counts (path-aware, best case, no stalls) ─") - if #atoms == 0 then - add(" (no atoms)") - else - -- Sort atoms by max cycles descending for quick scanning. - local sorted = {} - for _, a in ipairs(atoms) do sorted[#sorted + 1] = a end - table.sort(sorted, function(x, y) return ((x.paths or {}).cycles_max or 0) > ((y.paths or {}).cycles_max or 0) end) - for _, a in ipairs(sorted) do - local p = a.paths or {} - local br_count = p.branches or 0 - local path_count = p.paths or 0 - local loops_tag = p.has_loops and " [loop!]" or "" - local unknown_tag = "" - if p.unknown_macros and #p.unknown_macros > 0 then - unknown_tag = string.format(" [unknown: %s]", - table.concat(p.unknown_macros, ", ")) - end - local name_label = a.name - if multi_source and a.source_path then - name_label = string.format("%s (%s)", a.name, a.source_path:match("([^/\\]+)$") or a.source_path) - end - if br_count > 0 then - add(string.format(" %-44s min=%4d max=%4d br=%d paths=%d (line %d)%s%s", - name_label, p.cycles_min or 0, p.cycles_max or 0, br_count, path_count, - a.line, loops_tag, unknown_tag)) - else - add(string.format(" %-44s %4d cycles (line %d, no branches)%s%s", - name_label, p.cycles_min or 0, a.line, loops_tag, unknown_tag)) - end - end - end - - add("") - add("── Per-source scan summary ──────────────────────────────") - -- One line per source that contributed atoms. - -- The line includes the source basename + per-source atom count + (if path-aware cycle data is present) the min..max cycle range. - -- Sources with 0 atoms are skipped (they're just header files that declared no MipsAtom_ — they're already listed in the module's "Sources:" section above). - for _, src in ipairs(dir_sources) do - local src_atoms = {} - for _, a in ipairs(atoms) do - if a.source_path == src.path then - src_atoms[#src_atoms + 1] = a - end - end - if #src_atoms == 0 then - goto continue - end - local atom_count = #src_atoms - local mn, mx = math.huge, -1 - for _, a in ipairs(src_atoms) do - local p = a.paths or {} - if (p.cycles_min or 0) < mn then mn = p.cycles_min or 0 end - if (p.cycles_max or 0) > mx then mx = p.cycles_max or 0 end - end - local path_str - if mx > 0 then - path_str = string.format(" cycles=%d..%d", mn, mx) - else - path_str = string.format(" %d cycles", mn) - end - add(string.format(" %-30s %d atom%s%s", - src.basename, atom_count, - atom_count == 1 and "" or "s", - path_str)) - ::continue:: - end - - -- Module-level findings summary (across all sources). - -- Info has its own count; it remains separate from warnings. - local total_errs = #errors - local total_warns = #warnings - local total_infos = #info - add("") - add(string.format("Module findings: %d error(s), %d warning(s), %d info", total_errs, total_warns, total_infos)) - - -- Per-source "scanned:" / "cycles:" summary lines (each line includes the source basename for traceability). - -- These are kept SEPARATE from the finding-level Info section above so the report's Info section is signal-only - -- (true findings), not a mix of findings + rollups. - -- The downstream test (`test_control_transfer_delay_slot.lua`) - -- asserts that the Info section contains NEITHER `scanned:` NOR `cycles:` lines. - if summaries and #summaries > 0 then - add("") - for _, s in ipairs(summaries) do - add(string.format(" %s", s.msg)) - end - end - - duffle.write_file(out_path, table.concat(lines, "\n") .. "\n") - return out_path -end - -- ════════════════════════════════════════════════════════════════════════════ -- M.run — orchestrator entry -- ════════════════════════════════════════════════════════════════════════════ @@ -2431,21 +2231,30 @@ function M.run(ctx) for _, s in ipairs(result.summaries or {}) do dir_summaries[#dir_summaries + 1] = s end end - -- Skip directories with zero atoms. A directory with only headers / no MipsAtom_ is "nothing to report". - if #all_atoms == 0 then - -- Still aggregate errors/warnings/info so orchestrator sees them, but don't write a file. - for _, e in ipairs(dir_errors) do errors [#errors + 1] = e end - for _, w in ipairs(dir_warnings) do warnings[#warnings + 1] = w end - for _, i_ in ipairs(dir_info) do info[#info + 1] = i_ end - else - local out_path = emit_module_static_analysis_txt(ctx, dir, dir_sources, all_atoms, all_findings, dir_errors, dir_warnings, dir_info, dir_summaries) - if out_path then - table.insert(outputs, { static_analysis_txt = out_path }) - end - for _, e in ipairs(dir_errors) do errors [#errors + 1] = e end - for _, w in ipairs(dir_warnings) do warnings[#warnings + 1] = w end - for _, i_ in ipairs(dir_info) do info[#info + 1] = i_ end - end + -- Stash per-module results on the corpus for `report.lua` to consume. + -- Avoids re-running validate() in the report pass + avoids rebuilding corpus_pipe_ctx. + -- Pattern matches `corpus.atoms_by_name` / `corpus.word_counts` / `corpus.components` + -- (one writer: `static_analysis.lua`; one reader: `report.lua`). + -- Module basename = last component of `dir` ("code/duffle" -> "duffle"). + local dir_basename = dir:match("([^/\\]+)$") or dir + corpus.static_analysis_results = corpus.static_analysis_results or {} + corpus.static_analysis_results[dir_basename] = { + atoms = all_atoms, + findings = all_findings, + errors = dir_errors, + warnings = dir_warnings, + info = dir_info, + summaries = dir_summaries, + sources = dir_sources, + } + + -- Aggregate per-dir errors/warnings/info into the orchestrator totals. + -- Hoisted out of any per-dir file-emit so `report.lua` can drop the on-disk file emitter without losing the cross-module rollup. + for _, e in ipairs(dir_errors) do errors [#errors + 1] = e end + for _, w in ipairs(dir_warnings) do warnings[#warnings + 1] = w end + for _, i_ in ipairs(dir_info) do info [#info + 1] = i_ end + -- (No per-dir emit: per-module findings are stashed on `corpus.static_analysis_results` above. + -- `report.lua` reads that projection to render `.atom_meta_report.md` without re-running validate().) end -- Result exposes at least {outputs, errors, warnings, info}. diff --git a/scripts/ps1_meta.lua b/scripts/ps1_meta.lua index e5293cd..2055757 100644 --- a/scripts/ps1_meta.lua +++ b/scripts/ps1_meta.lua @@ -2,17 +2,14 @@ --- --- Dispatches to pass modules under `scripts/passes/`, resolving dependencies topologically (Kahn's algorithm + cycle detection). --- ---- **Architecture**: ---- - **PASSES table** — declarative dep graph (data, not code). ---- - **FLAG_HANDLERS table** — maps CLI flags to handlers. ---- - **parse_args** → **build_ctx** (resolves unity/direct includes or exact sources; no semantic scanning) → **topo_sort** → **dispatch_passes**. +--- Architecture: +--- - PASSES table: Declarative dep graph (data, not code). +--- - FLAG_HANDLERS table: Maps CLI flags to handlers. +--- - parse_args → build_ctx (resolves unity/direct includes or exact sources) → topo_sort → dispatch_passes. --- - The first pass in the dep graph is `scan-source` (see `passes/scan_source.lua`). --- It calls `duffle.scan_source` once per source to produce the fat `SourceScan` payload, which is attached to each `src.scan`. --- Every other pass that reads source structure depends on `scan-source` and consumes `src.scan` as a read-only. --- ---- **Conventions**: tabs (1/level), EmmyLua annotations, no regex, ---- Lua 5.3 compatible. ---- -- ════════════════════════════════════════════════════════════════════════════ -- Module-scope requires + package.path setup -- ════════════════════════════════════════════════════════════════════════════ @@ -160,7 +157,7 @@ local PASSES = { report = { module = "passes.report", kind = "report", - deps = {"annotation", "static-analysis"}, + deps = {"annotation", "static-analysis", "atoms-source-map"}, -- +atoms-source-map (consolidated-report-files refactor, 2026-07-26) groups = { "pre-link" }, }, } @@ -206,7 +203,8 @@ end -- Pass-kind taxonomy: Which kinds stop the build on errors? -- --- Report severity is independent from process exit policy. A "diagnostic" pass still writes every `error`/`warning` finding into its report file, +-- Report severity is independent from process exit policy. +-- A "diagnostic" pass still writes every `error`/`warning` finding into its report file, -- but `report_validation_errors` returns early for non-stopping kinds, so nothing is printed to stderr and the orchestrator does not exit non-zero. -- Adding a new pass kind requires listing it here explicitly; an unknown kind must not silently fall back to "true". local PASS_KIND_STOP_ON_ERROR = {