Files
pikuma_ps1/scripts/passes/atoms_source_map.lua
ed 80a35aa23a WIP: Better step debug on atom components, better db_skip annotation, lots of curation passes on lua.
Still don't have this thing in its final state for  the curse but its close.
2026-07-26 13:55:47 -04:00

536 lines
26 KiB
Lua

--- passes/atoms_source_map.lua — Per-.word source-line map emitter for tape atoms.
---
--- Writer: this pass, given `atom.paths` (the per-atom mutable surface owned by `emission_model`). Readers:
--- `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>`).
---
--- 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** — `<out_root>/<basename>.atoms.sourcemap.txt`. Format-version-tagged for forward-compat.
--- Lives in `<out_root>/` (build/gen). Mirrors the convention used by `annotation.lua`
--- (`<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/`.
--- 2. **gdb-runtime form** — `<ctx.out_root>/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 <path>` loads it with no Python / Tcl / Guile required.
---
--- **Output format** (sourcemap.txt form):
--- ```
--- # FORMAT_VERSION 1
--- # auto-generated by ps1_meta.lua (passes/atoms_source_map.lua) — DO NOT EDIT
--- ATOM <name> "<abs-source-path>" <total_words>
--- WORD 0 LINE 49 TEXT load_half_u(R_T0, R_FaceCursor, 0 * S_(S2)),
--- WORD 1 LINE 49 TEXT load_half_u(R_T0, R_FaceCursor, 0 * S_(S2)),
--- ... (one WORD line per .word emitted by the atom body) ...
--- ENDATOM
--- ATOM <next-name> "<abs-source-path>" <total_words>
--- ...
--- ENDATOM
--- ```
---
--- 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
-- ════════════════════════════════════════════════════════════════════════════
-- 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 elf_dwarf = require("elf_dwarf")
-- ════════════════════════════════════════════════════════════════════════════
-- Constants
-- ════════════════════════════════════════════════════════════════════════════
-- Format version emitted as the first line. Bump + add a migration test if the format changes;
-- the gdb runtime loader rejects mismatches (E2).
local FORMAT_VERSION = 1
-- ════════════════════════════════════════════════════════════════════════════
-- Type declarations
-- ════════════════════════════════════════════════════════════════════════════
--- @class AtomSourceMapCtx
--- @field shared table -- `ctx.shared`
--- @field shared.corpus table -- source-order registry; single writer is build_ctx
--- @field shared.word_counts table
--- @field out_root string -- output root (e.g. "build/gen")
--- @field flags table -- `ctx.flags`; reads `flags.gdb_runtime` + `flags.elf_path`
-- ════════════════════════════════════════════════════════════════════════════
-- Atom-path renderers
-- ════════════════════════════════════════════════════════════════════════════
--- Join word boundaries (from `items`) to per-word call text + source lines (from `word_events`).
--- @param atom table
--- @return table[], integer
local function canonical_word_entries(atom)
local paths = atom.paths or {}
local events = paths.word_events or {}
local word_items = {}
for _, item in ipairs(paths.items or {}) do
if item.kind == "word" then word_items[#word_items + 1] = item end
end
local entries = {}
for index, event in ipairs(events) do
local item = word_items[index] or {}
entries[#entries + 1] = {
pos = event.i or (index - 1),
line = event.call_line or item.line or 0,
text = event.call_text or item.call_text or "",
body_line = event.body_line or item.body_line or item.line or 0,
invocation = (event.outermost_invocation_id
and paths.invocations
and paths.invocations[event.outermost_invocation_id]) or nil,
}
end
return entries, #events
end
--- Render one atom's provenance stanza. Format 1 line shapes:
--- `WORD N CALL <src-path>:<src-line> MACRO <name> "<def-path>:<def-line>" BODY <line>` (component invocation)
--- `WORD N CALL <src-path>:<src-line> RAW` (raw `.word` outside any mac_* component)
--- Component identity comes from the outermost invocation record; the count-table lookup confirms the component was
--- declared in `corpus.word_counts` (populated by word_count_eval + components passes).
--- @param src table
--- @param atom table
--- @param wc table -- identity alias of corpus.word_counts
--- @return string[], integer
local function emit_provenance_stanza(src, atom, wc)
local lines = {}
local rel_path = src.path:gsub("\\\\", "/")
local entries, total = canonical_word_entries(atom)
lines[#lines + 1] = string.format('ATOM %s "%s" 0', atom.raw_name or atom.name, rel_path)
for _, entry in ipairs(entries) do
local inv = entry.invocation
local macro_count = inv 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
lines[1] = lines[1]:gsub(" 0$", " " .. tostring(total))
lines[#lines + 1] = "ENDATOM"
return lines, total
end
--- Render the full provenance file content for one source.
--- @param src table
--- @param wc table
--- @return string
local function render_provenance(src, wc)
local lines = {}
lines[#lines + 1] = "# FORMAT_VERSION 1"
lines[#lines + 1] = "# auto-generated by ps1_meta.lua (passes/atoms_source_map.lua) — DO NOT EDIT"
lines[#lines + 1] = "# Per-.word provenance: maps each emitted .word to its call site (atom body"
lines[#lines + 1] = "# file:line) and, when the word was emitted by a `mac_X(...)` component invocation,"
lines[#lines + 1] = "# the component's definition file:line + the per-word BODY line. Used by"
lines[#lines + 1] = "# dwarf_injection to synthesize DW_TAG_inlined_subroutine instances + per-word"
lines[#lines + 1] = "# line program rows for native source-level step into component bodies."
local function append(atom)
local stanza = emit_provenance_stanza(src, atom, wc)
for _, line in ipairs(stanza) do lines[#lines + 1] = line end
end
for _, atom in ipairs(src.scan.atoms or {}) do
if atom.paths then append(atom) end
end
for _, atom in ipairs(src.scan.raw_atoms or {}) do
if atom.paths then append(atom) end
end
return table.concat(lines, "\n") .. "\n"
end
--- Render one atom's stanza for the sourcemap.txt form (ATOM header line, N WORD lines, ENDATOM marker).
--- Returns (lines, total_words).
--- @param src table
--- @param atom table
--- @param wc table
--- @return string[], integer
local function emit_atom_stanza(src, atom)
local lines = {}
local rel_path = src.path:gsub("\\\\", "/")
local entries, total = canonical_word_entries(atom)
lines[#lines + 1] = string.format('ATOM %s "%s" 0', atom.raw_name or atom.name, rel_path)
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[1] = lines[1]:gsub(" 0$", " " .. tostring(total))
lines[#lines + 1] = "ENDATOM"
return lines, total
end
--- Render the full source map file content for one source (one .atoms.sourcemap.txt per source). Mirrors offsets.lua's
--- `project_atoms` shape: scan.atoms + scan.raw_atoms, no kind filter.
--- @param src table
--- @param wc table
--- @return string
local function render_source_map(src)
local lines = {}
lines[#lines + 1] = "# FORMAT_VERSION " .. FORMAT_VERSION
lines[#lines + 1] = "# auto-generated by ps1_meta.lua (passes/atoms_source_map.lua) — DO NOT EDIT"
local function append(atom)
local stanza = emit_atom_stanza(src, atom)
for _, line in ipairs(stanza) do lines[#lines + 1] = line end
end
for _, atom in ipairs(src.scan.atoms or {}) do
if atom.paths then append(atom) end
end
for _, atom in ipairs(src.scan.raw_atoms or {}) do
if atom.paths then append(atom) end
end
return table.concat(lines, "\n") .. "\n"
end
-- ════════════════════════════════════════════════════════════════════════════
-- gdb-runtime emission (post-link, addresses via nm)
-- ════════════════════════════════════════════════════════════════════════════
--- Escape a string for embedding in a gdb `set $var = "..."` literal.
--- gdb uses C-style escaping; we escape `\` and `"` (newlines were flattened earlier).
--- @param s string
--- @return string
local function gdb_escape(s)
return (s:gsub("\\", "\\\\"):gsub('"', '\\"'))
end
--- Build the list of atoms with addresses + word entries. Shared helper for the gdb-runtime file emission.
--- @param ctx PassCtx
--- @return table[] -- list of {idx, name, src_path, file_base, addr, size_bytes, words, entries}
local function build_atom_table(ctx)
local addrs = elf_dwarf.read_nm(ctx.flags.elf_path)
local corpus = ctx.shared and ctx.shared.corpus
local matched = {}
for _, src in ipairs(corpus.source_order or {}) do
local file_base = src.path:match("([^/\\\\]+)$") or src.path
local function append(atom)
if not atom.paths then return end
local name = atom.raw_name or atom.name
local info = addrs[name]
if not info then return end
local entries, total = canonical_word_entries(atom)
matched[#matched + 1] = {
name = name,
src_path = src.path,
file_base = file_base,
addr = info[1],
size_bytes = info[2],
words = total,
entries = entries,
}
end
for _, atom in ipairs((src.scan or {}).atoms or {}) do append(atom) end
for _, atom in ipairs((src.scan or {}).raw_atoms or {}) do append(atom) end
end
-- Deterministic order: sort by address (matches `nm` output ordering).
table.sort(matched, function(a, b) return a.addr < b.addr end)
for i, a in ipairs(matched) do a.idx = i - 1 end
return matched
end
--- Append the 9 gdb command definitions to `lines`. Pure gdb scripting — addresses come from `nm`, the convenience
--- vars set in `emit_gdb_runtime` provide printf args, and each command is a static sequence of `printf` / `tbreak` /
--- `if ... end` blocks. The Lua pass emits N atoms' worth of lines; runtime iteration is gdb's job.
---
--- Why hardcoded per-atom: gdb's `$` substitution doesn't concat inside var names — `$__atom_name_$__i` in a `while`
--- loop resolves to one literal identifier, not `name_i`. Compile-time emission is the only path.
--- @param lines table -- output line buffer (mutated in place)
--- @param matched table -- list of atom records from `build_atom_table`
local function append_gdb_commands(lines, matched)
-- ── tape_atoms ──
-- Hardcoded one printf per atom. No loop.
lines[#lines + 1] = "define tape_atoms"
for _, a in ipairs(matched) do
-- gdb 12.1 quirk: literals in printf args require an attached target.
-- Use the per-atom convenience vars set above as printf args.
lines[#lines + 1] = string.format(' printf " code_%%-32s @ 0x%%08x %%4d words\\n", $__atom_name_%d, $__atom_addr_%d, $__atom_words_%d',
a.idx, a.idx, a.idx)
end
lines[#lines + 1] = "end"
lines[#lines + 1] = "document tape_atoms"
lines[#lines + 1] = " List every tape atom symbol in the loaded ELF (code_<name>) with .rodata addr + word count."
lines[#lines + 1] = "end"
lines[#lines + 1] = ""
-- ── break_atom (generic) + per-atom break_atom_X ──
lines[#lines + 1] = "define break_atom"
lines[#lines + 1] = ' echo "Usage: break_atom_<exact_name> (pick from the list below)"'
for _, a in ipairs(matched) do
lines[#lines + 1] = string.format(' printf " break_atom_%%-32s\\n", $__atom_name_%d', a.idx)
end
lines[#lines + 1] = "end"
lines[#lines + 1] = "document break_atom"
lines[#lines + 1] = " Generic help: lists the per-atom break_atom_<name> commands."
lines[#lines + 1] = "end"
lines[#lines + 1] = ""
for _, a in ipairs(matched) do
lines[#lines + 1] = string.format("define break_atom_%s", a.name)
lines[#lines + 1] = string.format(" break *$__atom_addr_%d", a.idx)
lines[#lines + 1] = string.format(' printf " Breakpoint set at code_%s (0x%%08x)\\n", $__atom_addr_%d', a.name, a.idx)
lines[#lines + 1] = "end"
lines[#lines + 1] = string.format("document break_atom_%s", a.name)
lines[#lines + 1] = string.format(" Set a breakpoint at code_%s.", a.name)
lines[#lines + 1] = "end"
lines[#lines + 1] = ""
end
-- ── step_atom / next_atom ──
-- Hardcoded one tbreak per atom. No loop.
lines[#lines + 1] = "define step_atom"
for _, a in ipairs(matched) do
lines[#lines + 1] = string.format(" tbreak *$__atom_addr_%d", a.idx)
end
lines[#lines + 1] = " continue"
lines[#lines + 1] = "end"
lines[#lines + 1] = "document step_atom"
lines[#lines + 1] = " Set one-shot BPs at every atom + continue. Stops at the next atom boundary."
lines[#lines + 1] = "end"
lines[#lines + 1] = ""
lines[#lines + 1] = "define next_atom"
lines[#lines + 1] = " step_atom"
lines[#lines + 1] = "end"
lines[#lines + 1] = "document next_atom"
lines[#lines + 1] = " Alias for step_atom."
lines[#lines + 1] = "end"
lines[#lines + 1] = ""
-- ── where_in_atom ──
-- Hardcoded one outer-if per atom; inside, one inner-if per WORD entry.
lines[#lines + 1] = "define where_in_atom"
lines[#lines + 1] = " set $__pc = (unsigned int)$pc"
lines[#lines + 1] = " set $__matched = 0"
for _, a in ipairs(matched) do
-- Precompute end_addr (gdb 12.1's expression evaluator chokes on `addr + words*4`).
lines[#lines + 1] = string.format(" set $__end_%d = $__atom_addr_%d + $__atom_words_%d * 4", a.idx, a.idx, a.idx)
lines[#lines + 1] = string.format(" if $__pc >= $__atom_addr_%d && $__pc < $__end_%d", a.idx, a.idx)
lines[#lines + 1] = string.format(' printf "atom: code_%%s\\n", $__atom_name_%d', a.idx)
lines[#lines + 1] = ' printf "addr: 0x%08x\\n", $__pc'
lines[#lines + 1] = string.format(" set $__word = ($__pc - $__atom_addr_%d) / 4", a.idx)
lines[#lines + 1] = string.format(' printf "word: %%d/%%d\\n", $__word, $__atom_words_%d', a.idx)
-- One inner-if per WORD entry. Each word's line + text hardcoded.
for _, we in ipairs(a.entries) do
lines[#lines + 1] = string.format(" if $__word == %d", we.pos)
-- Escape TEXT for printf format string.
local escaped_text = we.text:gsub("%%", "%%%%"):gsub('"', '\\"')
lines[#lines + 1] = string.format(' printf "source: %%s:%%d %%s\\n", $__atom_file_%d, %d, "%s"', a.idx, we.line, escaped_text)
lines[#lines + 1] = " end"
end
-- Fallback for words beyond the source map (shouldn't happen if nm matches).
local max_word = 0
if #a.entries > 0 then max_word = a.entries[#a.entries].pos end
lines[#lines + 1] = string.format(' if $__word > %d', max_word)
lines[#lines + 1] = ' printf "source: (no source-map entry for word %%d; map may be stale)\\n", $__word'
lines[#lines + 1] = " end"
lines[#lines + 1] = " set $__matched = 1"
lines[#lines + 1] = " end"
end
lines[#lines + 1] = " if !$__matched"
lines[#lines + 1] = ' echo PC is not inside any known atom (in .text or unmapped region).'
lines[#lines + 1] = " end"
lines[#lines + 1] = "end"
lines[#lines + 1] = "document where_in_atom"
lines[#lines + 1] = " Report current atom name, .rodata addr, word offset, and source line."
lines[#lines + 1] = "end"
lines[#lines + 1] = ""
-- ── stepi_inside_atom ──
-- Hardcoded one if-containment-check per atom (no loop).
-- Precompute end_addr in Lua so we don't ask gdb to evaluate `addr + words*4` inside the if condition
-- (gdb 12.1's expression evaluator chokes on the `*` and emits a misleading 'function malloc' error in some gdb builds).
lines[#lines + 1] = "define stepi_inside_atom"
lines[#lines + 1] = " set $__in_atom = 0"
lines[#lines + 1] = " set $__did_step = 0"
lines[#lines + 1] = " set $__pc = (unsigned int)$pc"
for _, a in ipairs(matched) do
-- Precompute end_addr in the convenience var (single expression gdb handles).
lines[#lines + 1] = string.format(" set $__end_%d = $__atom_addr_%d + $__atom_words_%d * 4", a.idx, a.idx, a.idx)
lines[#lines + 1] = string.format(" if $__pc >= $__atom_addr_%d && $__pc < $__end_%d", a.idx, a.idx)
lines[#lines + 1] = " set $__in_atom = 1"
lines[#lines + 1] = " stepi"
lines[#lines + 1] = " set $__did_step = 1"
lines[#lines + 1] = " end"
end
lines[#lines + 1] = " if !$__did_step"
lines[#lines + 1] = ' echo [gdb_tape_atoms] stepi_inside_atom: PC is not inside any atom; refusing to step.'
lines[#lines + 1] = " end"
lines[#lines + 1] = " where_in_atom"
lines[#lines + 1] = "end"
lines[#lines + 1] = "document stepi_inside_atom"
lines[#lines + 1] = " One MIPS-instruction step, then where_in_atom. The step-and-see-source-line workflow."
lines[#lines + 1] = "end"
lines[#lines + 1] = ""
-- ── wave_ctx ──
lines[#lines + 1] = "define wave_ctx"
lines[#lines + 1] = ' printf "$t4 = R_FaceCursor 0x%08x\\n", $t4'
lines[#lines + 1] = ' printf "$t5 = R_VertBase 0x%08x\\n", $t5'
lines[#lines + 1] = ' printf "$t6 = R_OtBase 0x%08x\\n", $t6'
lines[#lines + 1] = ' printf "$t7 = R_PrimCursor 0x%08x\\n", $t7'
lines[#lines + 1] = "end"
lines[#lines + 1] = "document wave_ctx"
lines[#lines + 1] = " Pretty-print the 4 wave-context GPRs ($t4=R_FaceCursor, $t5=R_VertBase, $t6=R_OtBase, $t7=R_PrimCursor). Requires target attached."
lines[#lines + 1] = "end"
end
--- Emit the gdb-runtime file (post-link). Pure gdb scripting — addresses come from `mipsel-none-elf-nm -S`, get embedded
--- in `<ctx.out_root>/gdb_tape_atoms_runtime.gdb`, and load via `set $var = ...` + `define ... end` blocks at gdb
--- source-time.
--- @param ctx PassCtx
local function emit_gdb_runtime(ctx)
if not (ctx.flags and ctx.flags.gdb_runtime) then return end
local elf_path = ctx.flags.elf_path
if not elf_path or elf_path == "" then
io.stderr:write("[atoms_source_map] --gdb-runtime requires --elf <elf>\n")
return
end
if lfs.attributes(elf_path, "mode") ~= "file" then
io.stderr:write(string.format(
"[atoms_source_map] --gdb-runtime: ELF not found at %s\n", elf_path))
return
end
local matched = build_atom_table(ctx)
if #matched == 0 then
io.stderr:write("[atoms_source_map] --gdb-runtime: no atoms matched against nm symbols (stale scan?).\n")
return
end
local lines = {}
lines[#lines + 1] = "# Auto-generated by ps1_meta.lua (passes/atoms_source_map.lua)"
lines[#lines + 1] = "# DO NOT EDIT — re-run ps1_meta.lua --atoms-source-map --gdb-runtime to regenerate"
lines[#lines + 1] = "# Sourced by scripts/gdb/gdb_tape_atoms.gdb (the wrapper)."
lines[#lines + 1] = "# Pure gdb scripting — no Python, no Tcl, no Guile required."
lines[#lines + 1] = "# Commands are FULLY HARDCODED per-atom because gdb doesn't do nested"
lines[#lines + 1] = "# `$` substitution in var names (`$foo_$i` is one literal identifier)."
lines[#lines + 1] = "# Per-atom convenience vars ($__atom_name_<i> etc.) are set so gdb's"
lines[#lines + 1] = "# `printf` has valid expression args (gdb 12.1 quirks: literals in"
lines[#lines + 1] = "# printf args require an attached target; convenience-var args do not)."
lines[#lines + 1] = string.format("# %d atoms from ELF: %s", #matched, elf_path)
lines[#lines + 1] = ""
-- Format version + count + ELF path (the latter is referenced by the load-line).
lines[#lines + 1] = "set $__atom_format_version = " .. FORMAT_VERSION
lines[#lines + 1] = string.format("set $__atom_count = %d", #matched)
lines[#lines + 1] = string.format('set $__elf_path = "%s"', gdb_escape(elf_path))
lines[#lines + 1] = ""
-- Per-atom convenience vars (used as printf args; literals aren't accepted
-- without an attached target on gdb 12.1).
for _, a in ipairs(matched) do
lines[#lines + 1] = string.format('set $__atom_name_%d = "%s"', a.idx, gdb_escape(a.name))
lines[#lines + 1] = string.format("set $__atom_addr_%d = 0x%x", a.idx, a.addr)
lines[#lines + 1] = string.format("set $__atom_words_%d = %d", a.idx, a.words)
lines[#lines + 1] = string.format('set $__atom_file_%d = "%s"', a.idx, gdb_escape(a.file_base))
end
lines[#lines + 1] = ""
-- The 9 commands (each `define ... end` overrides the wrapper's stub).
lines[#lines + 1] = "# ── 9 user commands (overrides wrapper stubs) ──"
append_gdb_commands(lines, matched)
lines[#lines + 1] = ""
-- 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"
duffle.ensure_dir(duffle.dirname(out_path))
duffle.write_file_lf(out_path, table.concat(lines, "\n") .. "\n")
-- io.stderr:write(string.format("[atoms_source_map] wrote %s (%d atoms)\n", out_path, #matched))
end
-- ════════════════════════════════════════════════════════════════════════════
-- M — module exports
-- ════════════════════════════════════════════════════════════════════════════
local M = {}
--- 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
--- @return PassResult
function M.run(ctx)
local outputs = {}
local errors = {}
local warnings = {}
local corpus = ctx.shared and ctx.shared.corpus
if type(corpus) ~= "table" or type(corpus.source_order) ~= "table" then
error("atoms_source_map.run requires ctx.shared.corpus.source_order (canonical corpus).", 0)
end
-- Word counts come from `corpus.word_counts` (populated by word_count_eval + components passes).
local wc = corpus.word_counts or {}
if not next(wc) then
warnings[#warnings + 1] = {
line = 0,
msg = "atoms_source_map: corpus.word_counts is empty; the word-counts + components passes may not have populated it. Check the PASSES dep edges.",
}
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
-- Optionally emit the gdb-runtime form (post-link, one file per build).
if ctx.flags and ctx.flags.gdb_runtime then
emit_gdb_runtime(ctx)
end
return { outputs = outputs, errors = errors, warnings = warnings }
end
return M