Better reports from dsl metaprogram

This commit is contained in:
ed
2026-07-27 10:06:23 -04:00
parent 27a9038e0d
commit 338f1fe46e
7 changed files with 538 additions and 614 deletions
+2
View File
@@ -17,3 +17,5 @@ toolchain/PSn00bSDK
.vscode/settings.json .vscode/settings.json
toolchain/lfs toolchain/lfs
toolchain/lpeg toolchain/lpeg
scratch
+13 -13
View File
@@ -3,16 +3,16 @@
# Wrapper for the tape-atom step-debug helpers. # Wrapper for the tape-atom step-debug helpers.
# The 9 user commands are defined here as STUBS (degraded-state messages). # 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` # 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 <elf>`) into `build/gen/gdb_tape_atoms_runtime.gdb`. # (post-link invocation: `ps1_meta.lua --atoms-source-map --gdb-runtime --elf <elf>`) into `build/gdb_tape_atoms_runtime.gdb`.
# Sourcing that file RE-DEFINES the commands with real implementations. # 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. # 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 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." echo "[gdb_tape_atoms] STUB: run .\\build_psyq.ps1 to regenerate, then re-source this file."
end end
document tape_atoms document tape_atoms
@@ -21,35 +21,35 @@ document tape_atoms
end end
define break_atom 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 end
document break_atom document break_atom
Set a breakpoint at the start of tape atom <name>. STUB state. Set a breakpoint at the start of tape atom <name>. STUB state.
end end
define step_atom 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 end
document step_atom document step_atom
Resume execution until the next atom boundary. STUB state. Resume execution until the next atom boundary. STUB state.
end end
define next_atom 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 end
document next_atom document next_atom
Alias for step_atom. STUB state. Alias for step_atom. STUB state.
end end
define where_in_atom 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 end
document where_in_atom document where_in_atom
Report current atom name, .rodata addr, word offset, and source line (if known). STUB state. Report current atom name, .rodata addr, word offset, and source line (if known). STUB state.
end end
define stepi_inside_atom 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 end
document stepi_inside_atom document stepi_inside_atom
One MIPS-instruction step, then where_in_atom. STUB state. One MIPS-instruction step, then where_in_atom. STUB state.
@@ -89,17 +89,17 @@ document wave_ctx
end 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). # 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. # 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... 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. # 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 set confirm off
# Source the runtime file (re-defines commands with real impls + data). # 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 set confirm on
echo [gdb_tape_atoms] Runtime sourced successfully (9 commands now have real implementations). echo [gdb_tape_atoms] Runtime sourced successfully (9 commands now have real implementations).
-46
View File
@@ -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. --- 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. --- A context without `ctx.shared.corpus` is rejected with an explicit canonical-corpus message.
---
--- Writes `<ctx.out_root>/<dir_basename>.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. -- 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. -- `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 _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")
local write_file = duffle.write_file
local ensure_dir = duffle.ensure_dir
-- The annotation pass reads the source-derived registries from scan_source: -- 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 -- * 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 end
-- ════════════════════════════════════════════════════════════════════════════
-- Per-DIRECTORY (per-module) output: errors.h + annotations.txt
-- ════════════════════════════════════════════════════════════════════════════
--- Render `<dir_basename>.errors.h` with `#error` directives for every error found across all sources in the directory.
--- Empty directories (no errors, no atoms) produce no file.
local function emit_module_errors_h(ctx, dir_basename, atoms_count, errors, sources)
if 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 -- M.run — orchestrator entry
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
@@ -683,11 +642,6 @@ function M.run(ctx)
warnings [#warnings + 1] = { line = w.line, msg = w.msg } warnings [#warnings + 1] = { line = w.line, msg = w.msg }
end end
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 end
return { outputs = outputs, errors = errors, warnings = warnings } return { outputs = outputs, errors = errors, warnings = warnings }
+83 -46
View File
@@ -4,20 +4,18 @@
--- `passes/dwarf_injection.lua` (synthesizes DW_TAG_inlined_subroutine + per-word line program rows) and the gdb-runtime --- `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 <path>`). --- wrapper at `scripts/gdb/gdb_tape_atoms.gdb` (loads the source map via `source <path>`).
--- ---
--- Inputs from `atom.paths`: the ordered `items` stream, dense `word_events`, `invocations` views. Outputs: one --- Inputs from `atom.paths`: the ordered `items` stream, dense `word_events`, `invocations` views. Outputs:
--- `WORD N LINE L TEXT T` line per emitted `.word`, plus the per-word provenance form that DWARF synthesis consumes. --- 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`): --- Two output forms:
--- 1. **Sourcemap.txt form** — `<out_root>/<basename>.atoms.sourcemap.txt`. Format-version-tagged for forward-compat. --- 1. Markdown form: Handled by `passes/report.lua` (writes `<module>.atoms.md`).
--- Lives in `<out_root>/` (build/gen). Mirrors the convention used by `annotation.lua` --- The render functions `render_source_map` + `render_provenance` are exported for `report.lua` to call directly.
--- (`<out_root>/<basename>.errors.h`) and `static_analysis.lua` (`<out_root>/<basename>.static_analysis.txt`).
--- Compile artifacts (`*.macs.h`, `*.offsets.h`) stay in `<source_dir>/gen/`. --- Compile artifacts (`*.macs.h`, `*.offsets.h`) stay in `<source_dir>/gen/`.
--- 2. **gdb-runtime form** — `<ctx.out_root>/gdb_tape_atoms_runtime.gdb`. A pure gdb command script — addresses come --- 2. `gdb_tape_atoms_runtime.gdb`: Post-link opt-in (`ctx.flags.gdb_runtime`),
--- from `nm`, the 9 user commands are static `define ... end` blocks. Emitted when `ctx.flags.gdb_runtime` is true --- so the gdb wrapper script + the generated runtime script share the same canonical location.
--- AND `ctx.flags.elf_path` points to an existing ELF. Useful for `gdb-multiarch --without-python` users --- Triggered by `--post-link` or `--gdb-runtime`.
--- (the common case on Windows MinGW builds) — `source <path>` loads it with no Python / Tcl / Guile required.
--- ---
--- **Output format** (sourcemap.txt form): --- Output forma (sourcemap.txt form):
--- ``` --- ```
--- # FORMAT_VERSION 1 --- # FORMAT_VERSION 1
--- # auto-generated by ps1_meta.lua (passes/atoms_source_map.lua) — DO NOT EDIT --- # 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. --- 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 -- Module-scope requires + package.path setup
@@ -458,7 +454,22 @@ local function emit_gdb_runtime(ctx)
-- Confirmation line for the source operator. -- Confirmation line for the source operator.
lines[#lines + 1] = 'printf "[gdb_tape_atoms] runtime loaded %d atoms from %s\\n", $__atom_count, $__elf_path' 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 `<out_root>/gdb_tape_atoms_runtime.gdb` to `<out_root>/../gdb_tape_atoms_runtime.gdb` when the conventional `<out_root>` is `<build>/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.ensure_dir(duffle.dirname(out_path))
duffle.write_file_lf(out_path, table.concat(lines, "\n") .. "\n") 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)) -- io.stderr:write(string.format("[atoms_source_map] wrote %s (%d atoms)\n", out_path, #matched))
@@ -470,10 +481,62 @@ end
local M = {} local M = {}
--- Pass entry. For each source that declares at least one `MipsAtom_(name)` / `MipsCode code_<name>`, emit two files -- Expose the pure render functions so `report.lua` and the focused tests can call them directly without triggering the file-emit path.
--- in `<out_root>/`: `<basename>.atoms.sourcemap.txt` (per-word call-site map) and `<basename>.atoms.provenance.txt` M.render_source_map = render_source_map
--- (per-word definition + body line, resolved via the outermost `mac_X(...)` invocation). When `ctx.flags.gdb_runtime` M.render_provenance = render_provenance
--- is true and `ctx.flags.elf_path` exists, also emit the post-link gdb script `<ctx.out_root>/gdb_tape_atoms_runtime.gdb`.
--- 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 `## <source>` 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_<name>`,
--- emit two files in `<out_root>/`: `<basename>.atoms.sourcemap.txt` (per-word call-site map) and `<basename>.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 `<ctx.out_root>/gdb_tape_atoms_runtime.gdb`.
--- @param ctx PassCtx --- @param ctx PassCtx
--- @return PassResult --- @return PassResult
function M.run(ctx) function M.run(ctx)
@@ -495,34 +558,8 @@ function M.run(ctx)
} }
end end
-- Always emit the text form (per-source). -- atoms.sourcemap.txt + atoms.provenance.txt content moved to report.lua via `<module>.atoms.md` markdown file.
for _, src in ipairs(corpus.source_order) do -- This pass emits only the post-link gdb_runtime artifact (see emit_gdb_runtime below).
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
-- Optionally emit the gdb-runtime form (post-link, one file per build). -- Optionally emit the gdb-runtime form (post-link, one file per build).
if ctx.flags and ctx.flags.gdb_runtime then if ctx.flags and ctx.flags.gdb_runtime then
+408 -284
View File
@@ -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. --- 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. --- 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 -- 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. -- 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: see `ps1_meta.lua` for the rationale.
-- Bootstrap: load `scripts/duffle_paths.lua` (sets package.path + package.cpath). -- Bootstrap: load `scripts/duffle_paths.lua` (sets package.path + package.cpath).
-- Uses `debug.getinfo` to find this file's own directory, so it works -- Uses `debug.getinfo` to find this file's own directory, so it works both standalone and when require'd from the orchestrator.
-- 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). -- 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. -- 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 _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. -- 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) -- The annotation pass exposes `M.validate`, which returns the per-source AnnotationResult (atoms / annots / macros / binds / errors / warnings)
-- that the report pass renders into the per-module `<dir_basename>.annotations.txt` output. -- that the report pass renders into the per-module `<dir_basename>.annotations.txt` output.
local annotation = dofile(_bootstrap_dir .. "annotation.lua") 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 `<module>.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 -- Constants
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
@@ -148,328 +149,451 @@ local function source_basename(path)
return path:match(BASENAME_PATTERN) or path return path:match(BASENAME_PATTERN) or path
end end
--- (internal) Format a single annotation entry as one rendered line. -- ════════════════════════════════════════════════════════════════════════════
--- @param a AnnotEntry -- Markdown renderers (consolidated-report-files refactor, 2026-07-26)
--- @param src_name string -- ════════════════════════════════════════════════════════════════════════════
--- 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 --- @return string
local function format_annot_line(a, src_name) local function render_project_summary(all_results)
if a.error then local lines = {
return string.format(" ✗ line %d %s [ERROR: %s] [%s]", a.line, a.macro or "?", a.error, src_name) "# Project summary",
end "> Auto-generated by ps1_meta.lua (passes/report.lua).",
local line = string.format(" ● line %d %s [%s]", a.line, a.name, src_name) "",
if a.binds then line = line .. " binds=" .. a.binds end "| module | atoms | annots | binds | macros | findings | errors | warnings | info |",
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_<thing>_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 `<dir_basename>.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 totals = { atoms = 0, annots = 0, binds = 0, macros = 0,
-- THE per-section dispatch. ONE loop over SECTION_RENDERERS. findings = 0, errors = 0, warnings = 0, info = 0 }
-- Each renderer writes its header + content via the `add` closure (pre-bound above). for _, e in ipairs(all_results) do
-- Adding a new section = 1 row here + 1 render_<thing>_section function. lines[#lines + 1] = string.format(
for _, section in ipairs(SECTION_RENDERERS) do "| %s | %d | %d | %d | %d | %d | %d | %d | %d |",
section.render(add, results, totals) 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 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" return table.concat(lines, "\n") .. "\n"
end end
-- ════════════════════════════════════════════════════════════════════════════ --- Render the per-module verbose source-map markdown (`build/<module>.atoms.md`).
-- Per-project summary --- 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
--- Render the per-project summary (`build/gen/annotation_validation.txt`). --- @param dir_sources SourceFile[]
--- Aggregates totals across all sources; lists per-source error counts if any source has errors. --- @param wc table<string, integer>
--- @param all_results AnnotationResult[]
--- @return string --- @return string
local function render_project_report(all_results) local function render_module_atoms_md(dir, dir_sources, wc)
local lines = {} 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 `## <source>` 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/<module>.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 function add(s) lines[#lines + 1] = s end
local total_atoms, total_annots, total_macros, total_binds = 0, 0, 0, 0 -- Module summary table.
local total_errors, total_warnings = 0, 0 local n_atoms = 0
for _, r in ipairs(all_results) do local n_annot = 0
total_atoms = total_atoms + #r.atoms local n_binds = 0
total_annots = total_annots + #r.annots local n_macros = 0
total_macros = total_macros + #r.macros local n_bare, n_proc = 0, 0
total_binds = total_binds + #r.binds for _, r in ipairs(annot_results) do
total_errors = total_errors + #r.errors n_atoms = n_atoms + #r.atoms
total_warnings = total_warnings + #r.warnings 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 end
add(RULE_THICK) add("## Module summary"); add("")
add("ANNOTATION VALIDATION — project summary") add("| metric | value |"); add("|--------|-------|")
add(RULE_THICK) add(string.format("| sources | %d |", #dir_sources))
add("") add(string.format("| atoms | %d (atoms: %d, comp_bare: %d, comp_proc: %d) |",
add(string.format("Atoms: %d", total_atoms)) #(sa_results.atoms or {}),
add(string.format("Annotations: %d", total_annots)) #(sa_results.atoms or {}) - n_bare - n_proc, n_bare, n_proc))
add(string.format("Macros: %d", total_macros)) add(string.format("| annotations | %d |", n_annot))
add(string.format("Binds: %d", total_binds)) add(string.format("| binds structs | %d |", n_binds))
add("") add(string.format("| macro decls | %d |", n_macros))
add(string.format("Errors: %d", total_errors)) add(string.format("| findings | %d (errors: %d, warnings: %d, info: %d) |",
add(string.format("Warnings: %d", total_warnings)) #(sa_results.findings or {}),
#(sa_results.errors or {}),
#(sa_results.warnings or {}),
#(sa_results.info or {})))
add("") add("")
if total_errors > 0 then -- Sources
add("Per-source error counts:") add("## Sources"); add("")
for _, r in ipairs(all_results) do for _, s in ipairs(dir_sources) do add("- `" .. s.path .. "`") end
if #r.errors > 0 then add("")
local src_name = source_basename(r.source)
add(string.format(" %s : %d error(s)", src_name, #r.errors)) -- 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
end end
add("") add("")
end 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" return table.concat(lines, "\n") .. "\n"
end end
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- Orchestration helpers -- REPORT_RENDERERS — data-driven report dispatch (one row per file kind)
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- `once = true` means render once at the project level (not per-module).
--- (internal) Re-validate every source in a directory against the canonical corpus projection. -- `basename(dir_basename)` yields the file's basename for that kind.
--- Calls `annotation.validate()` per source to produce the per-source AnnotationResult (atoms / annots / macros / binds / errors / warnings) -- `gather(ctx, dir, dir_sources [, all_modules])` returns the rendered string.
--- that the report renderer consumes. Eeach report pass run is reproducible from the corpus. local REPORT_RENDERERS = {
--- Returns the list of module results + the flat list of all results (for the project-wide summary). {
--- @param ctx PassCtx name = "atom_meta_report",
--- @param dir_sources SourceFile[] ext = "md",
--- @return AnnotationResult[], AnnotationResult[] basename = function(dir_basename) return dir_basename .. ".atom_meta_report" end,
local function lookup_module_results(ctx, dir_sources) once = false,
local module_results = {} gather = function(ctx, dir, dir_sources)
local all_results = {} -- Annotations: re-run `annotation.validate()` per source (the existing pattern).
for _, src in ipairs(dir_sources) do local annot_results = {}
if src.scan then for _, src in ipairs(dir_sources) do
local result = annotation.validate(ctx, src, nil) if src.scan then
result.source = src.path -- tag for downstream rendering local r = annotation.validate(ctx, src, nil)
module_results[#module_results + 1] = result r.source = src.path
all_results[#all_results + 1] = result annot_results[#annot_results + 1] = r
end end
end end
return module_results, all_results -- Static-analysis: read stashed projection (no re-validate).
end local dir_basename = dir:match("([^/\\]+)$") or dir
local sa_results = (ctx.shared.corpus.static_analysis_results or {})[dir_basename] or {}
--- (internal) Does this module's results contain anything worth emitting? return render_module_meta_report(dir, dir_sources, annot_results, sa_results)
--- @param module_results AnnotationResult[] end,
--- @return boolean },
local function module_has_content(module_results) {
for _, r in ipairs(module_results) do name = "atoms",
if #r.atoms > 0 or #r.annots > 0 or #r.binds > 0 ext = "md",
or #r.macros > 0 or #r.errors > 0 or #r.warnings > 0 then basename = function(dir_basename) return dir_basename .. ".atoms" end,
return true once = false,
end gather = function(ctx, dir, dir_sources)
end return render_module_atoms_md(dir, dir_sources,
return false ctx.shared.corpus.word_counts or {})
end end,
},
--- (internal) Log a debug message if `_G[DEBUG_FLAG]` is truthy. {
--- @param fmt string name = "summary",
local function debug_log(fmt, ...) ext = "md",
if _G[DEBUG_FLAG] then basename = function(_dir_basename) return "atom_meta_report.summary" end,
io.stderr:write(string.format("[%s] " .. fmt, PASS_NAME, ...)) once = true,
end gather = function(_ctx, _dir, _dir_sources, all_modules)
end return render_project_summary(all_modules)
end,
},
}
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
-- M — module exports -- M — public pass surface
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
local M = {} local M = {}
--- Run the report pass. --- 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).
--- Renders one `<dir_basename>.annotations.txt` per source-directory that has content, plus the project-wide `annotation_validation.txt` summary. --- Reads `corpus.static_analysis_results` (added in Phase 1) to populate per-module findings without re-running validate().
--- @param ctx PassCtx --- @param ctx PassCtx
--- @return PassResult --- @return PassResult
function M.run(ctx) function M.run(ctx)
local outputs = {} local outputs = {}
local errors = {} local corpus = ctx.shared and ctx.shared.corpus
local warnings = {} local by_dir = (corpus and corpus.sources_by_dir) or {}
-- Module grouping comes from `corpus.sources_by_dir` (the canonical projection). -- `out_path_root`: when the conventional `out_root` is `build/gen` (any spelling — relative, absolute, separator variants).
-- Iterate it directly; no private cache, no per-pass stash. -- Write the md files to `build/` (parent of `gen/`) instead of nested under `gen/`.
local corpus = ctx.shared and ctx.shared.corpus -- Mirrors the `gdb_tape_atoms_runtime.gdb` relocation.
local by_dir = (corpus and corpus.sources_by_dir) or {} 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 for dir, dir_sources in pairs(by_dir) do
local dir_basename = dir:match("([^/\\]+)$") or dir 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 -- Per-renderer dispatch for the per-module renderers (once = false).
local module_results, all_results = lookup_module_results(ctx, dir_sources) for _, renderer in ipairs(REPORT_RENDERERS) do
for _, r in ipairs(all_results) do if not renderer.once then
all_results_for_summary[#all_results_for_summary + 1] = r 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
end
if module_has_content(module_results) then -- For the summary, compute per-module totals once (re-validating annotations per source — same pattern as the meta_report renderer).
local out_path = ctx.out_root .. "/" .. dir_basename .. ".annotations.txt" local annot_results = {}
duffle.write_file(out_path, render_module_report(dir, dir_sources, module_results)) for _, src in ipairs(dir_sources) do
outputs[#outputs + 1] = { annotations_txt = out_path } if src.scan then
else local r = annotation.validate(ctx, src, nil)
debug_log(" -> no content; skipping\n") r.source = src.path
annot_results[#annot_results + 1] = r
end end
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 end
if #all_results_for_summary > 0 then return { outputs = outputs, errors = {}, warnings = {} }
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 }
end end
return M return M
+25 -216
View File
@@ -44,12 +44,9 @@
--- module = "passes.static_analysis", --- module = "passes.static_analysis",
--- kind = "diagnostic", --- kind = "diagnostic",
--- deps = {"word-counts", "components"}, --- deps = {"word-counts", "components"},
--- out = { { kind = "report", path_template = "<out_root>/<basename>.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. --- 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 -- Module-scope requires + package.path setup
@@ -2179,203 +2176,6 @@ local function validate(ctx, src, corpus_pipe_ctx)
} }
end end
-- ════════════════════════════════════════════════════════════════════════════
-- Per-directory output: build/gen/<dir_basename>.static_analysis.txt
-- ════════════════════════════════════════════════════════════════════════════
--- Per-directory emit. Aggregates atoms + findings across every source in `dir_sources`
--- and writes a single report to `<out_root>/<dir_basename>.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 -- 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 for _, s in ipairs(result.summaries or {}) do dir_summaries[#dir_summaries + 1] = s end
end end
-- Skip directories with zero atoms. A directory with only headers / no MipsAtom_ is "nothing to report". -- Stash per-module results on the corpus for `report.lua` to consume.
if #all_atoms == 0 then -- Avoids re-running validate() in the report pass + avoids rebuilding corpus_pipe_ctx.
-- Still aggregate errors/warnings/info so orchestrator sees them, but don't write a file. -- Pattern matches `corpus.atoms_by_name` / `corpus.word_counts` / `corpus.components`
for _, e in ipairs(dir_errors) do errors [#errors + 1] = e end -- (one writer: `static_analysis.lua`; one reader: `report.lua`).
for _, w in ipairs(dir_warnings) do warnings[#warnings + 1] = w end -- Module basename = last component of `dir` ("code/duffle" -> "duffle").
for _, i_ in ipairs(dir_info) do info[#info + 1] = i_ end local dir_basename = dir:match("([^/\\]+)$") or dir
else corpus.static_analysis_results = corpus.static_analysis_results or {}
local out_path = emit_module_static_analysis_txt(ctx, dir, dir_sources, all_atoms, all_findings, dir_errors, dir_warnings, dir_info, dir_summaries) corpus.static_analysis_results[dir_basename] = {
if out_path then atoms = all_atoms,
table.insert(outputs, { static_analysis_txt = out_path }) findings = all_findings,
end errors = dir_errors,
for _, e in ipairs(dir_errors) do errors [#errors + 1] = e end warnings = dir_warnings,
for _, w in ipairs(dir_warnings) do warnings[#warnings + 1] = w end info = dir_info,
for _, i_ in ipairs(dir_info) do info[#info + 1] = i_ end summaries = dir_summaries,
end 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 `<module>.atom_meta_report.md` without re-running validate().)
end end
-- Result exposes at least {outputs, errors, warnings, info}. -- Result exposes at least {outputs, errors, warnings, info}.
+7 -9
View File
@@ -2,17 +2,14 @@
--- ---
--- Dispatches to pass modules under `scripts/passes/`, resolving dependencies topologically (Kahn's algorithm + cycle detection). --- Dispatches to pass modules under `scripts/passes/`, resolving dependencies topologically (Kahn's algorithm + cycle detection).
--- ---
--- **Architecture**: --- Architecture:
--- - **PASSES table** — declarative dep graph (data, not code). --- - PASSES table: Declarative dep graph (data, not code).
--- - **FLAG_HANDLERS table** — maps CLI flags to handlers. --- - 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**. --- - 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`). --- - 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`. --- 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. --- 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 -- Module-scope requires + package.path setup
-- ════════════════════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════════════════════
@@ -160,7 +157,7 @@ local PASSES = {
report = { report = {
module = "passes.report", module = "passes.report",
kind = "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" }, groups = { "pre-link" },
}, },
} }
@@ -206,7 +203,8 @@ end
-- Pass-kind taxonomy: Which kinds stop the build on errors? -- 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. -- 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". -- 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 = { local PASS_KIND_STOP_ON_ERROR = {