TODO: need to review snapshot

This commit is contained in:
ed
2026-07-14 12:16:00 -04:00
parent 2d901003f9
commit 7d5b13aadb
14 changed files with 2461 additions and 124 deletions
+307 -16
View File
@@ -27,6 +27,27 @@ local WAVE_CONTEXT_REGS = duffle.WAVE_CONTEXT_REGS
local function is_wave_context_reg(n) return WAVE_CONTEXT_REGS[n] ~= nil end
-- Phase 5 closed sets. atom_dbg_reg_default and atom_reg_types MUST target
-- one of these registers; atom_view MUST reference a real Binds_* struct.
local SEMANTIC_DEFAULT_REGS = {
["R_TapePtr"] = true, ["R_AtomJmp"] = true,
["R_PrimCursor"] = true, ["R_FaceCursor"] = true,
["R_VertBase"] = true, ["R_OtBase"] = true,
}
local COMPUTE_REG_PREFIX = "R_"
local COMPUTE_REG_RE = -- permits R_T0..R_T3 + caller-trash (not wave-context)
"^R_T[0-3]$"
-- Compute-register types are restricted to byte-width primitives (no struct
-- identity in this phase). Pointer depth is also bounded — typed pointer
-- chains live in the Binds_* struct, not in a per-atom override.
local ALLOWED_COMPUTE_TYPES = {
["U4"] = true, ["S4"] = true,
["U2"] = true, ["S2"] = true,
["U1"] = true, ["S1"] = true,
}
-- ════════════════════════════════════════════════════════════════════════════
-- Type declarations
-- ════════════════════════════════════════════════════════════════════════════
@@ -66,6 +87,16 @@ local function is_wave_context_reg(n) return WAVE_CONTEXT_REGS[n] ~= nil end
--- @field writes string[] -- R_* names (write targets)
--- @field errors string[]|nil -- parse-time errors from scan_source (atom_info body malformed)
--- @class SkipOverMarker -- sub-shape of scan_source.lua's @class SkipOverMarker
--- @field marker_kind string -- exact marker ident (always "atom_dbg_skip_over")
--- @field marker_line integer
--- @field args string|nil -- trimmed text inside the parens (nil when has_parens is false)
--- @field has_parens boolean
--- @field pending boolean -- true while awaiting the following declaration
--- @field superseded_by_marker_line integer|nil -- set on a marker that was bumped out of the pending slot
--- @field target_kind string|nil -- "atom" | "comp_bare" | "comp_proc" | "unrelated" once observed
--- @field declaration_line integer|nil
--- @class Finding
--- @field line integer -- source line (or 0 for pass-level)
--- @field msg string -- finding message
@@ -76,9 +107,14 @@ local function is_wave_context_reg(n) return WAVE_CONTEXT_REGS[n] ~= nil end
--- @field info Finding[]
--- @class PipeCtx
--- @field atom_index table<string, AtomAnnotation> -- name -> AtomAnnotation (only kind=="atom")
--- @field binds_index table<string, BindsStruct> -- name -> BindsStruct
--- @field annot_counts table<string, integer> -- name -> annotation count (for unique_annotation check)
--- @field atom_index table<string, AtomAnnotation> -- name -> AtomAnnotation (only kind=="atom")
--- @field binds_index table<string, BindsStruct> -- name -> BindsStruct
--- @field annot_counts table<string, integer> -- name -> annotation count (for unique_annotation check)
--- @field types table<string, RegTypeDefault> -- from scan_source
--- @field atom_views table<string, AtomViewEntry> -- from scan_source
--- @field seen_defaults table<string, integer> -- duplicate atom_dbg_reg_default detection
--- @field seen_field table<string, integer> -- Binds_* -> count of fields (set/checked by check_binds_no_duplicate_fields)
--- @field _scan SourceScan -- full scan payload (typed-view sub-calls live here)
--- @class AnnotatedResult
--- @field atoms AtomEntry[]
@@ -217,24 +253,244 @@ local function check_macro_word_drift(m, wc, findings)
}
end
---- Check: atom_dbg_reg_default(R_X, <type>) must target a wave-context register
--- and the type name must be a recognized C type (we currently allow the
--- types actually used in the codebase; pointer depth must be 0 or 1).
--- Also detects duplicate defaults for the same register.
--- @param _src SourceFile -- unused (kept for the per_source shape)
--- @param pipe_ctx PipeCtx
--- @param findings Findings
-- Known type names accepted in atom_dbg_reg_default and the Binds_*-via-atom_view
-- resolution path. The Phase 5 list mirrors the canonical duffle math.h
-- typedefs plus the byte-width primitives used as compute-register overrides.
local KNOWN_REG_DEFAULT_TYPES = {
["U4"] = true, ["S4"] = true,
["U2"] = true, ["S2"] = true,
["U1"] = true, ["S1"] = true,
["MipsCode"] = true, ["MipsAtom"] = true,
["V2_S2"] = true, ["V2_S4"] = true,
["V3_S2"] = true, ["V3_S4"] = true,
["V4_S2"] = true, ["V4_S4"] = true,
["M3_S2"] = true,
["void"] = true,
}
local function check_semantic_reg_defaults(_src, pipe_ctx, findings)
-- Detect duplicate defaults using the ordered occurrence list (the
-- out.types hash only retains the last declaration).
local seen_first_line = {}
for _, occ in ipairs(pipe_ctx.type_occurrences or {}) do
if seen_first_line[occ.reg] == nil then
seen_first_line[occ.reg] = occ.source_line
else
findings.errors[#findings.errors + 1] = {
line = occ.source_line,
msg = string.format(
"duplicate atom_dbg_reg_default for %q at line %d (first declared at line %d); one default per register",
occ.reg, occ.source_line, seen_first_line[occ.reg]),
}
end
end
for reg, def in pairs(pipe_ctx.types or {}) do
if not SEMANTIC_DEFAULT_REGS[reg] then
findings.errors[#findings.errors + 1] = {
line = def.source_line,
msg = string.format(
"atom_dbg_reg_default at line %d references unknown register %q; expected one of R_TapePtr, R_AtomJmp, R_PrimCursor, R_FaceCursor, R_VertBase, R_OtBase",
def.source_line, reg),
}
end
if def.pointer_depth == nil or def.pointer_depth < 0 or def.pointer_depth > 1 then
findings.errors[#findings.errors + 1] = {
line = def.source_line,
msg = string.format(
"atom_dbg_reg_default at line %d for %q has unsupported pointer depth %d (expected 0 or 1)",
def.source_line, reg, def.pointer_depth or -1),
}
end
if not def.type_name or not KNOWN_REG_DEFAULT_TYPES[def.type_name] then
findings.errors[#findings.errors + 1] = {
line = def.source_line,
msg = string.format(
"atom_dbg_reg_default at line %d for %q uses unknown type %q (allowed: byte-width primitives, V*_S*, M3_S2, MipsCode, void)",
def.source_line, reg, tostring(def.type_name)),
}
end
end
end
--- Check: atom_reg_types(R_X, <type>) entries must point to a compute register
--- (R_T0..R_T3) with a recognized fundamental type.
--- @param _src SourceFile
--- @param pipe_ctx PipeCtx
--- @param findings Findings
local function check_atom_reg_types(_src, pipe_ctx, findings)
for _, ai in ipairs(pipe_ctx.atom_infos_list or {}) do
if ai.reg_type_overrides then
for reg, ov in pairs(ai.reg_type_overrides) do
if not reg:match(COMPUTE_REG_RE) then
findings.errors[#findings.errors + 1] = {
line = ai.info_line,
msg = string.format(
"atom '%s' has atom_reg_types for %q; compute-register types are restricted to R_T0..R_T3",
ai.atom_name, reg),
}
end
if not ov.type_name or not ALLOWED_COMPUTE_TYPES[ov.type_name] then
findings.errors[#findings.errors + 1] = {
line = ai.info_line,
msg = string.format(
"atom '%s' atom_reg_types for %q uses unknown compute type %q (allowed: U1/U2/U4/S1/S2/S4)",
ai.atom_name, reg, tostring(ov.type_name)),
}
end
end
end
end
end
--- Check: atom_view(Binds_X) entries must reference a real Binds_* struct
--- and that struct must declare at least one field.
--- @param _src SourceFile
--- @param pipe_ctx PipeCtx
--- @param findings Findings
local function check_atom_view_layout(_src, pipe_ctx, findings)
for atom_name, view in pairs(pipe_ctx.atom_views or {}) do
if not view.binds_name then
-- The atom had atom_reg_types but no atom_view; no layout check needed.
else
local bs = pipe_ctx.binds_index[view.binds_name]
if not bs then
findings.errors[#findings.errors + 1] = {
line = view.info_line,
msg = string.format(
"atom '%s' has atom_view(%s) but no Struct_(%s) { ... } declaration was found",
atom_name, view.binds_name, view.binds_name),
}
elseif not bs.fields or #bs.fields == 0 then
findings.errors[#findings.errors + 1] = {
line = bs.line,
msg = string.format(
"atom '%s' has atom_view(%s) but that struct declares zero typed fields",
atom_name, view.binds_name),
}
end
end
end
end
--- Check: Binds_* structs may not have duplicate field names (they would
--- defeat the typed-field name lookup that atom_view exposes in gdb).
--- @param _src SourceFile
--- @param pipe_ctx PipeCtx
--- @param findings Findings
local function check_binds_no_duplicate_fields(_src, pipe_ctx, findings)
for _, bs in ipairs(pipe_ctx.binds_list or {}) do
local seen = {}
for _, f in ipairs(bs.fields or {}) do
seen[f.name] = (seen[f.name] or 0) + 1
end
for name, count in pairs(seen) do
if count > 1 then
findings.errors[#findings.errors + 1] = {
line = bs.line,
msg = string.format(
"%s has duplicate field name %q (count %d); the typed-view contract requires unique field names",
bs.name, name, count),
}
end
end
end
end
-- Check: skip-over markers must satisfy shape + placement constraints.
--- Walks the priority list once; at most one error is appended per marker so that
--- a single source-level defect does not cascade into multiple findings.
--- Priority order (first defect wins):
--- 1. has_parens == false -> requires parentheses: marker()
--- 2. args ~= "" -> takes no arguments
--- 3. superseded_by_marker_line -> duplicate marker (cite superseding line)
--- 4. pending + no target_kind -> dangling (no following declaration)
--- 5. unsupported target_kind -> marker precedes an unrelated declaration
--- Valid markers before whole-atom / bare-component / proc-component declarations
--- emit no error and remain in src.scan.skip_over.atoms / .components for Task 15.
--- @param marker SkipOverMarker
--- @param _pipe_ctx PipeCtx -- unused today; kept for plex-shape consistency with per_annot
--- @param findings Findings
local function check_skip_marker(marker, _pipe_ctx, findings)
local kind = marker.marker_kind
local line = marker.marker_line
if not marker.has_parens then
findings.errors[#findings.errors + 1] = {
line = line,
msg = string.format("%s marker at line %d requires parentheses: marker()", kind, line),
}
return
end
if marker.args ~= nil and marker.args ~= "" then
findings.errors[#findings.errors + 1] = {
line = line,
msg = string.format("%s marker at line %d takes no arguments; found %q", kind, line, marker.args),
}
return
end
if marker.superseded_by_marker_line then
findings.errors[#findings.errors + 1] = {
line = line,
msg = string.format("duplicate %s marker at line %d; superseded by another %s marker at line %d",
kind, line, kind, marker.superseded_by_marker_line),
}
return
end
if marker.pending and not marker.target_kind then
findings.errors[#findings.errors + 1] = {
line = line,
msg = string.format("dangling %s marker at line %d: no following MipsAtom_/MipsAtomComp_/MipsAtomComp_Proc_ declaration",
kind, line),
}
return
end
if marker.target_kind
and marker.target_kind ~= "atom"
and marker.target_kind ~= "comp_bare"
and marker.target_kind ~= "comp_proc" then
findings.errors[#findings.errors + 1] = {
line = line,
msg = string.format("%s marker at line %d must precede MipsAtom_, MipsAtomComp_, or MipsAtomComp_Proc_; found an unrelated declaration",
kind, line),
}
end
end
-- ════════════════════════════════════════════════════════════════════════════
-- CHECK_RULES — data-driven check dispatch (the plex pattern)
-- ════════════════════════════════════════════════════════════════════════════
--
-- Each rule entry picks one of three "shapes" of dispatch:
-- per_annot(annot, pipe_ctx, findings) — runs once per AtomAnnotation
-- post(pipe_ctx, findings) — runs once after all per_annot calls complete (full-corpus aggregation)
-- per_macro(macro, wc, findings) — runs once per TAPE_WORDS / _Pragma macro declaration
-- Each rule entry picks one of four "shapes" of dispatch:
-- per_annot(annot, pipe_ctx, findings) — runs once per AtomAnnotation
-- post(pipe_ctx, findings) — runs once after all per_annot calls complete (full-corpus aggregation)
-- per_macro(macro, wc, findings) — runs once per TAPE_WORDS / _Pragma macro declaration
-- per_skip_marker(marker, pipe_ctx, findings) — runs once per src.scan.skip_over.markers entry (Task 14)
--
-- Adding a new check = 1 row here + 1 function above. The `validate()` dispatch loop never needs editing.
local CHECK_RULES = {
{ name = "atom_decl_exists", per_annot = check_atom_decl_exists },
{ name = "binds_struct_exists", per_annot = check_binds_struct_exists },
{ name = "binds_field_wave_context", per_annot = check_binds_field_wave_context },
{ name = "reads_wave_context", per_annot = check_reads_wave_context },
{ name = "unique_annotation", post = check_unique_annotation },
{ name = "macro_word_drift", per_macro = check_macro_word_drift },
{ name = "atom_decl_exists", per_annot = check_atom_decl_exists },
{ name = "binds_struct_exists", per_annot = check_binds_struct_exists },
{ name = "binds_field_wave_context", per_annot = check_binds_field_wave_context },
{ name = "reads_wave_context", per_annot = check_reads_wave_context },
{ name = "unique_annotation", post = check_unique_annotation },
{ name = "macro_word_drift", per_macro = check_macro_word_drift },
{ name = "skip_marker_validation", per_skip_marker = check_skip_marker },
{ name = "semantic_reg_defaults", per_source = check_semantic_reg_defaults },
{ name = "atom_reg_types", per_source = check_atom_reg_types },
{ name = "atom_view_layout", per_source = check_atom_view_layout },
{ name = "binds_no_duplicate_fields", per_source = check_binds_no_duplicate_fields },
}
-- ════════════════════════════════════════════════════════════════════════════
@@ -276,10 +532,27 @@ local function validate(ctx, src)
-- Build pipe_ctx (Fleury: expose structure). Pre-compute everything the per-check functions need.
-- Single source of truth for atom / binds / annotation-count lookups.
-- Phase 5 typed views: pipe_ctx.types / pipe_ctx.atom_views / pipe_ctx.seen_defaults
-- are projected from the scan payload so per_source check rules can iterate.
local seen_defaults = {}
for reg, _ in pairs(scan.types or {}) do
seen_defaults[reg] = (seen_defaults[reg] or 0) + 1
end
local atom_infos_list = {}
for _, ai in ipairs(scan.atom_infos or {}) do
atom_infos_list[#atom_infos_list + 1] = ai
end
local pipe_ctx = {
atom_index = {},
binds_index = {},
annot_counts = {},
atom_index = {},
binds_index = {},
annot_counts = {},
types = scan.types or {},
type_occurrences = scan.type_occurrences or {},
atom_views = scan.atom_views or {},
seen_defaults = seen_defaults,
atom_infos_list = atom_infos_list,
binds_list = scan.binds or {},
}
for _, a in ipairs(atoms) do pipe_ctx.atom_index [a.name] = a end
for _, b in ipairs(scan.binds) do pipe_ctx.binds_index[b.name] = b end
@@ -319,6 +592,17 @@ local function validate(ctx, src)
if rule.post then rule.post(pipe_ctx, findings) end
end
-- Per-skip-marker rules (Task 14). Each raw marker recorded by scan_source
-- (in scan.skip_over.markers) is validated independently; the check emits at
-- most one error per marker. Valid markers stay attached to scan.skip_over.atoms /
-- .components for Task 15's dwarf_injection.lua consumer.
local skip_markers = scan.skip_over and scan.skip_over.markers or {}
for _, marker in ipairs(skip_markers) do
for _, rule in ipairs(CHECK_RULES) do
if rule.per_skip_marker then rule.per_skip_marker(marker, pipe_ctx, findings) end
end
end
-- Per-macro rules (TAPE_WORDS vs WORD_COUNT drift).
local wc = ctx.shared.word_counts
for _, m in ipairs(scan.macros) do
@@ -327,6 +611,13 @@ local function validate(ctx, src)
end
end
-- Per-source rules (Phase 5 typed views: reg defaults, atom_view layout,
-- compute-register type overrides, Binds_* field uniqueness). Each
-- per_source rule sees the full scan payload via pipe_ctx.
for _, rule in ipairs(CHECK_RULES) do
if rule.per_source then rule.per_source(src, pipe_ctx, findings) end
end
-- Information summary (always emitted).
findings.info[#findings.info + 1] = {
line = 0,
+173 -8
View File
@@ -148,6 +148,153 @@ local function compute_word_entries(atom, src, wc)
return entries, pos
end
-- ════════════════════════════════════════════════════════════════════════════
-- Provenance emission (Phase 3 — debug_ux)
-- ════════════════════════════════════════════════════════════════════════════
-- Component-macro invocation prefix (mirrors components.lua's MAC_PREFIX).
local MAC_PREFIX = "mac_"
local MAC_PREFIX_LEN = 4
--- Strip the `mac_` prefix from a token's leading identifier. Returns nil
--- if the identifier doesn't start with `mac_` (so non-component tokens
--- like `load_half_u`, `nop2`, `gte_cmdw_*` fall through cleanly).
--- @param tok string
--- @return string|nil
local function strip_mac_prefix_from_token(tok)
local leading = duffle.read_ident(tok, 1)
if not leading then return nil end
if leading:sub(1, MAC_PREFIX_LEN) == MAC_PREFIX then
return leading:sub(MAC_PREFIX_LEN + 1)
end
return nil
end
--- Compute per-word provenance entries for an atom. Mirrors `compute_word_entries`
--- but additionally classifies each emitted `.word` as either:
--- - `RAW` — emitted by a direct instruction token (no component provenance)
--- - `MACRO X` — emitted by a `mac_X(...)` component invocation, with the
--- component's definition file:line resolved from `ctx.shared.components`.
---
--- Returns a list of `{pos, line, text, comp_name, comp_line, comp_path}` entries + the
--- total word count. `comp_name` is nil for RAW rows.
--- @param atom table -- one entry of scan.atoms / scan.raw_atoms
--- @param src table -- SourceFile (has .scan with .line_of(), .path)
--- @param wc table -- shared.word_counts
--- @param comp table -- shared.components map: bare_name -> {name=, line=, path=, kind=}
--- @return table[], integer
local function compute_provenance_entries(atom, src, wc, comp)
local entries = {}
local pos = 0
for _, t in ipairs(atom.body_tokens) do
local tok = t.tok
local rel = t.rel
local words
if is_marker_token(tok) then
words = count_marker_rest(tok, wc)
else
words = count_token_words(tok, wc)
end
-- Resolve component provenance for this token (if any).
local comp_name = nil
local comp_line = nil
local comp_path = nil
local comp_kind = nil
local bare = strip_mac_prefix_from_token(tok)
if bare and comp and comp[bare] then
comp_name = bare
comp_line = comp[bare].line
comp_path = comp[bare].path
comp_kind = comp[bare].kind
end
if words > 0 then
local line = src.scan.line_of(atom.body_off + rel)
local text = duffle.trim(tok):gsub("[\t\r\n]+", " ")
for _ = 1, words do
entries[#entries + 1] = {
pos = pos,
line = line,
text = text,
comp_name = comp_name,
comp_line = comp_line,
comp_path = comp_path,
comp_kind = comp_kind,
}
pos = pos + 1
end
end
end
return entries, pos
end
--- Render one atom's provenance stanza. Format:
--- `WORD N CALL <src-path>:<src-line> MACRO <name> "<def-path>:<def-line>"` (for component words)
--- `WORD N CALL <src-path>:<src-line> RAW` (for direct instructions)
--- Returns (lines, total_words).
--- @param src table
--- @param atom table
--- @param wc table
--- @param comp table -- shared.components map
--- @return string[], integer
local function emit_provenance_stanza(src, atom, wc, comp)
local lines = {}
local rel_path = src.path:gsub("\\", "/")
local entries, total = compute_provenance_entries(atom, src, wc, comp)
-- ATOM header line with placeholder total (patched after we know it).
lines[#lines + 1] = string.format('ATOM %s "%s" 0', atom.raw_name or atom.name, rel_path)
for _, pe in ipairs(entries) do
if pe.comp_name then
lines[#lines + 1] = string.format(
'WORD %d CALL %s:%d MACRO %s "%s:%d"',
pe.pos, rel_path, pe.line, pe.comp_name, pe.comp_path, pe.comp_line)
else
lines[#lines + 1] = string.format(
"WORD %d CALL %s:%d RAW",
pe.pos, rel_path, pe.line)
end
end
-- Patch the placeholder total in the ATOM header line.
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 (one `.atoms.provenance.txt` per source).
--- @param src table
--- @param wc table
--- @param comp table -- shared.components map
--- @return string
local function render_provenance(src, wc, comp)
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. Used by dwarf_injection (Phase 3) to synthesize"
lines[#lines + 1] = "# DW_TAG_inlined_subroutine instances for component step-into."
for _, atom in ipairs(src.scan.atoms or {}) do
local stanza = emit_provenance_stanza(src, atom, wc, comp)
for _, line in ipairs(stanza) do
lines[#lines + 1] = line
end
end
for _, atom in ipairs(src.scan.raw_atoms or {}) do
local stanza = emit_provenance_stanza(src, atom, wc, comp)
for _, line in ipairs(stanza) do
lines[#lines + 1] = line
end
end
return table.concat(lines, "\n") .. "\n"
end
--- Render one atom's stanza for the canonical text form
--- (ATOM header line, N WORD lines, ENDATOM marker). Returns (lines, total_words).
--- @param src table
@@ -533,6 +680,9 @@ local M = {}
--- Pass entry: emit one `<out_root>/<basename>.atoms.sourcemap.txt` per source file
--- that contains at least one `MipsAtom_(name)` / `MipsCode code_<name>` declaration.
--- Also emits `<out_root>/<basename>.atoms.provenance.txt` (Phase 3 — debug_ux):
--- per-.word provenance with `mac_X(...)` component resolution back to the
--- component's definition file:line.
--- Optionally also emit `<ctx.out_root>/gdb_tape_atoms_runtime.gdb` when
--- `ctx.flags.gdb_runtime` is true.
--- @param ctx PassCtx
@@ -552,6 +702,12 @@ function M.run(ctx)
}
end
-- Phase 3 (debug_ux): shared.components map is populated by `passes/components.lua`.
-- Used to attribute each emitted `.word` to either a component macro or the
-- enclosing atom body. If absent, all words fall through as RAW (correct
-- behavior — provenance is additive).
local comp = (ctx.shared and ctx.shared.components) or {}
-- Always emit the canonical text form (per-source).
for _, src in ipairs(ctx.sources) do
if src.scan then
@@ -559,18 +715,27 @@ function M.run(ctx)
local n_raw_atoms = src.scan.raw_atoms and #src.scan.raw_atoms or 0
if n_atoms + n_raw_atoms > 0 then
local basename = duffle.basename_no_ext(src.path)
-- Build report, NOT compile artifact: live in <out_root> alongside the other reports
-- (annotation.lua's *.errors.h, static_analysis.lua's *.static_analysis.txt, gdb_tape_atoms_runtime.gdb).
-- The per-source <source_dir>/gen/ is reserved for headers actually #included by C.
local out_path = ctx.out_root .. "/" .. basename .. ".atoms.sourcemap.txt"
local content = render_source_map(src, wc)
-- (1) atoms.sourcemap.txt — per-.word line map (unchanged contract).
local sourcemap_path = ctx.out_root .. "/" .. basename .. ".atoms.sourcemap.txt"
local sourcemap_body = render_source_map(src, wc)
-- (2) atoms.provenance.txt — Phase 3 (debug_ux): per-.word provenance
-- with `mac_X(...)` component resolution back to the component's
-- definition file:line. Consumed by `passes/dwarf_injection.lua`
-- to synthesize `DW_TAG_inlined_subroutine` instances for
-- source-level Step Into on component invocations.
local prov_path = ctx.out_root .. "/" .. basename .. ".atoms.provenance.txt"
local prov_body = render_provenance(src, wc, comp)
if not ctx.dry_run then
duffle.ensure_dir(duffle.dirname(out_path))
duffle.write_file_lf(out_path, content)
duffle.ensure_dir(duffle.dirname(sourcemap_path))
duffle.write_file_lf(sourcemap_path, sourcemap_body)
duffle.write_file_lf(prov_path, prov_body)
end
outputs[#outputs + 1] = { kind = "report", path = out_path }
outputs[#outputs + 1] = { kind = "report", path = sourcemap_path }
outputs[#outputs + 1] = { kind = "report", path = prov_path }
end
end
end
+38
View File
@@ -366,6 +366,7 @@ local function project_components(source, scan)
body_tokens = a.body_tokens,
args = args,
comment = comment,
kind = a.kind, -- "comp_bare" | "comp_proc"; Phase 3 provenance emitter reads this.
}
end
end
@@ -677,6 +678,35 @@ local function update_shared_word_counts(ctx, components, counts)
end
end
--- @class ComponentDef
--- @field name string -- bare name (without ac_/mac_ prefix)
--- @field line integer -- definition source line (line of `MipsAtomComp_(ac_X)` / `MipsAtomComp_Proc_(ac_X, ...)`)
--- @field path string -- absolute source path of the definition
--- @field kind string -- "comp_bare" | "comp_proc"
--- (internal) Extend `ctx.shared.components` with this source's components-by-name
-- map so downstream passes (atoms_source_map, dwarf_injection) can resolve
-- `mac_X(...)` invocations back to their component definition file:line.
--- Phase 3 (debug_ux) provenance emission uses this to attribute each emitted
-- `.word` to either a component macro or the enclosing atom body.
-- @param ctx PassCtx
-- @param src SourceFile
-- @param components Component[]
local function update_shared_components(ctx, src, components)
ctx.shared.components = ctx.shared.components or {}
local rel_path = src.path:gsub("\\", "/")
for _, c in ipairs(components) do
-- Keyed by bare name (e.g. `yield`, `load_tri_indices`). The atoms_source_map
-- pass strips the `mac_` prefix from the call site identifier before lookup.
ctx.shared.components[c.name] = {
name = c.name,
line = c.line,
path = rel_path,
kind = c.kind or "comp_bare",
}
end
end
--- @param ctx PassCtx
--- @return PassResult
function M.run(ctx)
@@ -684,6 +714,11 @@ function M.run(ctx)
local errors = {}
local warnings = {}
-- Initialize shared component map (Phase 3 — debug_ux). The atoms_source_map
-- and dwarf_injection passes consume `ctx.shared.components` to resolve
-- `mac_X(...)` invocations back to the component's definition file:line.
ctx.shared.components = ctx.shared.components or {}
for _, src in ipairs(ctx.sources) do
-- project_components reads from src.scan + does backward lookups on src.text
local components = project_components(src.text, src.scan)
@@ -694,6 +729,9 @@ function M.run(ctx)
if macs_path then
outputs[#outputs + 1] = { macs_h = macs_path }
update_shared_word_counts(ctx, components, counts)
-- Phase 3 (debug_ux): share component definitions with downstream passes.
-- `mac_X(...)` invocations in atom bodies resolve back to (path, line) via this map.
update_shared_components(ctx, src, components)
end
end
end
File diff suppressed because it is too large Load Diff
+306 -18
View File
@@ -6,6 +6,7 @@
--- MipsAtom_ (kind = "atom", with optional atom_info inner)
--- MipsAtomComp_ (kind = "comp_bare")
--- MipsAtomComp_Proc_ (kind = "comp_proc", body inside last {})
--- atom_dbg_skip_over (whole-atom/component debug-step marker; following declaration disambiguates)
--- MipsCode code_<name> (kind = "raw_atom", offsets pass only)
--- typedef Struct_(Binds_X) { fields }
--- #pragma mac_X tape_atom words=N + _Pragma("...")
@@ -35,8 +36,55 @@ local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua")
--- @field binds BindsEntry[] -- typedef Struct_(Binds_X) { fields } (fields pre-parsed)
--- @field atom_infos AtomInfoEntry[] -- MipsAtom_(name) atom_info(...) (sub-calls pre-parsed)
--- @field macros MacroEntry[] -- #pragma mac_X tape_atom words=N + _Pragma("...")
--- @field skip_over SkipOverScan -- atom/component debug-step markers + resolved declaration associations
--- @field types table<string, RegTypeDefault> -- atom_dbg_reg_default(R_X, <type>) declarations
--- @field atom_views table<string, AtomViewEntry> -- MipsAtom_(name) -> {binds_name, reg_type_overrides, info_line}
--- @field line_of fun(pos: integer): integer -- shared LineIndex closure
--- @class SkipOverScan
--- @field atoms table<string, SkipOverAssociation>
--- @field components table<string, SkipOverAssociation>
--- @field markers SkipOverMarker[]
--- @class SkipOverMarker
--- @field marker_kind string -- exact marker ident (always "atom_dbg_skip_over")
--- @field marker_line integer
--- @field marker_pos integer
--- @field after_paren integer
--- @field args string|nil -- trimmed marker args; valid Task 13 markers use ""
--- @field has_parens boolean
--- @field pending boolean
--- @field superseded_by_marker_line integer|nil
--- @field target_name string|nil -- stripped declaration name once observed
--- @field target_raw_name string|nil -- source-written declaration name once observed
--- @field target_kind string|nil -- "atom" | "comp_bare" | "comp_proc" | "unrelated" once observed
--- @field declaration_line integer|nil
--- @field declaration_pos integer|nil
--- @field proc_prelude boolean|nil -- marker has crossed FI_ and awaits MipsAtomComp_Proc_
--- @class RegTypeDefault
--- @field name string -- "R_TapePtr" (the register ident; without the value part)
--- @field type_name string -- "U4" / "V3_S2" / "void" (the pointer/struct base name)
--- @field pointer_depth integer -- 0 for `U4`, 1 for `U4*`, 2 for `U4**`
--- @field source_line integer -- 1-based source line of the declaration
--- @class RegTypeOverride
--- @field reg string -- "R_T0"
--- @field type_name string
--- @field pointer_depth integer
--- @class AtomViewEntry
--- @field atom_name string -- e.g. "red_cube_g4_face"
--- @field binds_name string|nil -- "Binds_CubeTri" if attached
--- @field reg_type_overrides table<string, RegTypeOverride> -- "R_T0" -> override
--- @field info_line integer -- line of the atom_info call
--- @class SkipOverAssociation
--- @field marker_line integer
--- @field declaration_line integer
--- @field kind string
--- @field marker SkipOverMarker
--- @class SourceFile
--- @field path string -- absolute path to the source file
--- @field text string -- the full source text
@@ -101,6 +149,94 @@ local function strip_ac_prefix(raw_name)
return raw_name
end
-- Preserve a source marker until the following declaration parser observes it.
-- Task 13 records evidence only; Task 14 diagnoses non-empty args,
-- duplicates, dangling markers, and unrelated declaration placement.
local function push_skip_over_marker(out, marker)
local markers = out.skip_over.markers
local prior = markers[#markers]
if prior and prior.pending then
prior.pending = false
prior.superseded_by_marker_line = marker.marker_line
end
marker.pending = true
markers[#markers + 1] = marker
end
-- Attach the pending marker to the next declaration. The declaration form
-- disambiguates whole atoms from components; unsupported declarations retain
-- placement evidence for annotation.lua and populate neither lookup table.
local function associate_skip_over_marker(out, target_name, target_raw_name, target_kind, declaration_line, declaration_pos)
local markers = out.skip_over.markers
local marker = markers[#markers]
if not (marker and marker.pending) then return end
marker.pending = false
marker.target_name = target_name
marker.target_raw_name = target_raw_name
marker.target_kind = target_kind
marker.declaration_line = declaration_line
marker.declaration_pos = declaration_pos
if not (marker.has_parens and marker.args == "") then return end
local association = {
marker_line = marker.marker_line,
declaration_line = declaration_line,
kind = target_kind,
marker = marker,
}
if target_kind == "atom" then
out.skip_over.atoms[target_name] = association
elseif target_kind == "comp_bare" or target_kind == "comp_proc" then
out.skip_over.components[target_name] = association
end
end
-- Read a top-level comma-delimited argument list. Mirrors split_top_level_commas
-- in shape; kept inline so scan_source can remain dependency-free.
local function read_top_level_args(text, pos)
local args = {}
pos = duffle.skip_ws_and_cmt(text, pos)
while pos <= #text do
local buf, level = {}, 0
while pos <= #text do
local c = text:sub(pos, pos)
if c == "(" or c == "[" or c == "{" then level = level + 1
elseif c == ")" or c == "]" or c == "}" then
if level == 0 then break end
level = level - 1
elseif c == "," and level == 0 then break end
buf[#buf + 1] = c
pos = pos + 1
end
local arg = duffle.trim(table.concat(buf))
if arg ~= "" then args[#args + 1] = arg end
if pos > #text then break end
if text:sub(pos, pos) == "," then pos = pos + 1; pos = duffle.skip_ws_and_cmt(text, pos) end
if not text:sub(pos, pos) or text:sub(pos, pos) == ")" then break end
end
return args
end
-- Parse a `Type*` chain (zero or more `*` separated by optional whitespace)
-- followed by the type ident. Returns (type_name, pointer_depth) or nil.
local function parse_type_chain(text, pos)
if pos > #text then return nil end
-- Skip leading whitespace before the type ident.
local start = duffle.skip_ws_and_cmt(text, pos)
local ident, after = duffle.read_ident(text, start)
if not ident then return nil end
local depth = 0
local cursor = duffle.skip_ws_and_cmt(text, after)
while cursor <= #text and text:sub(cursor, cursor) == "*" do
depth = depth + 1
cursor = cursor + 1
cursor = duffle.skip_ws_and_cmt(text, cursor)
end
return ident, depth, cursor
end
-- Parse the U4 fields from a Binds_X body. Returns (fields, byte_count).
local function scan_binds_fields(body)
local fields = {}
@@ -146,10 +282,13 @@ local function scan_reg_list(sub_inner)
return regs
end
-- Parse the sub-calls inside `atom_info(atom_bind(...), atom_reads(...), atom_writes(...))`.
-- Returns (binds, reads, writes).
-- Parse the sub-calls inside `atom_info(atom_bind(...), atom_reads(...), atom_writes(...), atom_view(...), atom_reg_types(...))`.
-- Returns (binds, reads, writes, view_binds, reg_overrides).
-- `view_binds` is the Binds_X ident from `atom_view(Binds_X)`.
-- `reg_overrides` is a {reg_name = {type_name, pointer_depth}} table for `atom_reg_types(R_X, <type>)`.
local function scan_atom_info_subcalls(info_inner)
local binds, reads, writes = nil, nil, nil
local view_binds, reg_overrides = nil, nil
local sub_pos = 1
while sub_pos <= #info_inner do
sub_pos = duffle.skip_ws_and_cmt(info_inner, sub_pos)
@@ -179,11 +318,43 @@ local function scan_atom_info_subcalls(info_inner)
else
sub_pos = sub_open + 1
end
elseif sub_ident == "atom_view" then
local sub_open = duffle.skip_ws_and_cmt(info_inner, sub_end)
if info_inner:sub(sub_open, sub_open) == "(" then
local sub_inner, sub_after2 = duffle.read_parens(info_inner, sub_open)
view_binds = duffle.trim(sub_inner)
sub_pos = sub_after2
else
sub_pos = sub_open + 1
end
elseif sub_ident == "atom_reg_types" then
local sub_open = duffle.skip_ws_and_cmt(info_inner, sub_end)
if info_inner:sub(sub_open, sub_open) == "(" then
local sub_inner, sub_after2 = duffle.read_parens(info_inner, sub_open)
local args = read_top_level_args(sub_inner, 1)
if args[1] then
reg_overrides = reg_overrides or {}
local reg_name = duffle.trim(args[1])
local type_name, depth = nil, 0
if args[2] then
local parsed_name, parsed_depth = parse_type_chain(args[2], 1)
if parsed_name then
type_name, depth = parsed_name, parsed_depth
else
type_name, depth = duffle.trim(args[2]), 0
end
end
reg_overrides[reg_name] = { type_name = type_name, pointer_depth = depth }
end
sub_pos = sub_after2
else
sub_pos = sub_open + 1
end
else
sub_pos = sub_end
end
end
return binds, reads, writes
return binds, reads, writes, view_binds, reg_overrides
end
-- Skip C qualifier keywords and return the position past the last one.
@@ -207,7 +378,7 @@ end
-- pos -- position of the construct's leading ident (e.g., `M` of `MipsAtom_`)
-- ident_end -- position past the leading ident (where the `(` should be)
-- line_of -- closure over LineIndex(source) for 1-based line lookups
-- out -- the SourceScan out table (mutated in place: out.atoms / out.raw_atoms / out.binds / out.atom_infos / out.macros)
-- out -- the SourceScan out table (mutated in place: out.atoms / out.raw_atoms / out.binds / out.atom_infos / out.macros / out.skip_over)
-- returns -- new position after the construct
--
-- All parsers read source-as-written via the duffle primitives (skip_ws_and_cmt / read_parens / read_braces / read_balanced).
@@ -216,6 +387,68 @@ end
--
-- Adding a new construct = 1 row in DECL_PARSERS + 1 parser function. The scan_source() loop never needs editing.
--- Parse an empty debug-skip marker and retain its raw placement evidence.
--- @param source string
--- @param pos integer
--- @param ident_end integer
--- @param line_of fun(pos: integer): integer
--- @param out SourceScan
--- @param marker_kind string
--- @return integer
local function parse_skip_over_marker(source, pos, ident_end, line_of, out, marker_kind)
local open_paren = duffle.skip_ws_and_cmt(source, ident_end)
local marker = {
marker_kind = marker_kind,
marker_line = line_of(pos),
marker_pos = pos,
after_paren = ident_end,
args = nil,
has_parens = false,
}
if source:sub(open_paren, open_paren) == "(" then
local inner, after_paren = duffle.read_parens(source, open_paren)
marker.after_paren = after_paren
marker.args = duffle.trim(inner)
marker.has_parens = true
end
push_skip_over_marker(out, marker)
return marker.after_paren
end
local function parse_atom_dbg_skip_over(source, pos, ident_end, line_of, out)
return parse_skip_over_marker(source, pos, ident_end, line_of, out, "atom_dbg_skip_over")
end
-- Parse `atom_dbg_reg_default(R_X, <type>...)`; the second argument may be
-- a `Type` or `Type*`/`Type**` chain. Records in `out.types[R_X]`.
local function parse_atom_dbg_reg_default(source, pos, ident_end, line_of, out)
local open_paren = duffle.skip_ws_and_cmt(source, ident_end)
if source:sub(open_paren, open_paren) ~= "(" then return open_paren + 1 end
local inner, after_paren = duffle.read_parens(source, open_paren)
local args = read_top_level_args(inner, 1)
if #args < 1 then
-- Annotation pass surfaces this; we still consume the marker.
return after_paren
end
local reg_name = duffle.trim(args[1])
local type_part = args[2] or "void"
local type_name, depth = parse_type_chain(type_part, 1)
if not type_name then type_name, depth = duffle.trim(type_part), 0 end
out.types[reg_name] = {
name = reg_name,
type_name = type_name,
pointer_depth = depth,
source_line = line_of(pos),
}
out.type_occurrences = out.type_occurrences or {}
out.type_occurrences[#out.type_occurrences + 1] = {
reg = reg_name,
type_name = type_name,
source_line = line_of(pos),
}
return after_paren
end
--- Parse: `MipsAtom_(<name>) [atom_info(<binds>, <reads>, <writes>)] { <body> }`
--- @param source string
--- @param pos integer
@@ -238,12 +471,27 @@ local function parse_mips_atom(source, pos, ident_end, line_of, out)
local info_open = duffle.skip_ws_and_cmt(source, look_end)
if source:sub(info_open, info_open) == "(" then
local info_inner, info_after = duffle.read_parens(source, info_open)
local ai_binds, ai_reads, ai_writes = scan_atom_info_subcalls(info_inner)
local ai_binds, ai_reads, ai_writes, ai_view, ai_overrides = scan_atom_info_subcalls(info_inner)
out.atom_infos[#out.atom_infos + 1] = {
atom_name = raw_name or "?", binds = ai_binds,
reads = ai_reads or {}, writes = ai_writes or {},
view = ai_view,
reg_type_overrides = ai_overrides,
info_line = line_of(lookahead),
}
if ai_view and raw_name then
out.atom_views[raw_name] = {
atom_name = raw_name,
binds_name = ai_view,
reg_type_overrides = ai_overrides,
info_line = line_of(lookahead),
}
elseif raw_name and ai_overrides then
-- Record per-atom overrides even without atom_view.
out.atom_views[raw_name] = out.atom_views[raw_name]
or { atom_name = raw_name, binds_name = nil, reg_type_overrides = nil, info_line = line_of(lookahead) }
out.atom_views[raw_name].reg_type_overrides = ai_overrides
end
brace_search_pos = info_after
end
end
@@ -253,11 +501,13 @@ local function parse_mips_atom(source, pos, ident_end, line_of, out)
local body, after_brace = duffle.read_braces(source, brace)
if raw_name and raw_name ~= "" then
local declaration_line = line_of(pos)
out.atoms[#out.atoms + 1] = {
line = line_of(pos), name = raw_name, body = body, body_off = brace + 1,
line = declaration_line, name = raw_name, body = body, body_off = brace + 1,
kind = "atom", raw_name = raw_name,
ident_pos = pos, after_paren = after_paren,
}
associate_skip_over_marker(out, raw_name, raw_name, "atom", declaration_line, pos)
end
return after_brace
@@ -282,12 +532,15 @@ local function parse_mips_atom_comp(source, pos, ident_end, line_of, out)
if not brace then return open_paren + 1 end
local body, after_brace = duffle.read_braces(source, brace)
local name = strip_ac_prefix(raw_name)
local declaration_line = line_of(pos)
out.atoms[#out.atoms + 1] = {
line = line_of(pos), name = strip_ac_prefix(raw_name), body = body, body_off = brace + 1,
line = declaration_line, name = name, body = body, body_off = brace + 1,
kind = "comp_bare", raw_name = raw_name,
ident_pos = pos, after_paren = after_paren,
}
associate_skip_over_marker(out, name, raw_name, "comp_bare", declaration_line, pos)
return after_brace
end
@@ -318,14 +571,17 @@ local function parse_mips_atom_comp_proc(source, pos, ident_end, line_of, out)
if close_pos > #inner + 1 then return after_paren end
local raw_name = inner:match("^%s*([%w_]+)") or "?"
local name = strip_ac_prefix(raw_name)
local declaration_line = line_of(pos)
-- Position of body[1] in source = open_paren + 1 (start of inner) + last_brace_pos + 1 (past '{').
local body_off = open_paren + 2 + last_brace_pos
out.atoms[#out.atoms + 1] = {
line = line_of(pos), name = strip_ac_prefix(raw_name), body = body, body_off = body_off,
line = declaration_line, name = name, body = body, body_off = body_off,
kind = "comp_proc", raw_name = raw_name,
ident_pos = pos, after_paren = after_paren,
}
associate_skip_over_marker(out, name, raw_name, "comp_proc", declaration_line, pos)
return after_paren
end
@@ -349,10 +605,12 @@ local function parse_mips_code(source, pos, ident_end, line_of, out)
if not brace_pos then return ident_end end
local body, after_brace = duffle.read_braces(source, brace_pos)
local declaration_line = line_of(pos)
out.raw_atoms[#out.raw_atoms + 1] = {
line = line_of(pos), name = atom_name, body = body, body_off = brace_pos + 1,
line = declaration_line, name = atom_name, body = body, body_off = brace_pos + 1,
kind = "raw_atom", raw_name = atom_name,
}
associate_skip_over_marker(out, atom_name, next_ident, "unrelated", declaration_line, pos)
return after_brace
end
@@ -383,6 +641,7 @@ local function parse_typedef_binds(source, pos, ident_end, line_of, out)
local fields, byte_off = scan_binds_fields(body)
out.binds[#out.binds + 1] = { line = line_of(pos), name = name, fields = fields, bytes = byte_off }
end
associate_skip_over_marker(out, name, name, "unrelated", line_of(pos), pos)
return after_brace
end
@@ -443,13 +702,15 @@ end
-- Adding a new construct = 1 row here + 1 parser function above.
local DECL_PARSERS = {
MipsAtom_ = parse_mips_atom,
MipsAtomComp_ = parse_mips_atom_comp,
MipsAtomComp_Proc_ = parse_mips_atom_comp_proc,
MipsCode = parse_mips_code,
typedef = parse_typedef_binds,
_Pragma = parse_pragma_macro,
pragma = parse_pragma_dummy,
MipsAtom_ = parse_mips_atom,
MipsAtomComp_ = parse_mips_atom_comp,
MipsAtomComp_Proc_ = parse_mips_atom_comp_proc,
atom_dbg_skip_over = parse_atom_dbg_skip_over,
atom_dbg_reg_default = parse_atom_dbg_reg_default,
MipsCode = parse_mips_code,
typedef = parse_typedef_binds,
_Pragma = parse_pragma_macro,
pragma = parse_pragma_dummy,
}
-- ════════════════════════════════════════════════════════════════════════════
@@ -459,7 +720,7 @@ local DECL_PARSERS = {
--- Single-pass source scan. Walks the source ONCE and extracts every construct type the metaprogram passes need.
--- Returns a fat SourceScan table. Each pass filters from this payload instead of re-walking the source.
--- @param source string
--- @return table -- SourceScan { atoms, raw_atoms, binds, atom_infos, macros, line_of }
--- @return table -- SourceScan { atoms, raw_atoms, binds, atom_infos, macros, skip_over, line_of }
local function scan_source(source)
local line_of = duffle.LineIndex(source)
local out = {
@@ -468,6 +729,13 @@ local function scan_source(source)
binds = {},
atom_infos = {},
macros = {},
skip_over = {
atoms = {},
components = {},
markers = {},
},
types = {},
atom_views = {},
line_of = line_of,
}
local pos = 1
@@ -492,10 +760,30 @@ local function scan_source(source)
if parser then
pos = parser(source, pos, ident_end, line_of, out)
else
-- Unrecognized ident — advance past it.
-- A component-procedure declaration has an FI_ signature before
-- MipsAtomComp_Proc_; keep the marker pending across that prelude.
-- Any other identifier begins an unrelated declaration/construct
-- and consumes the marker so it cannot drift to a later atom.
local markers = out.skip_over.markers
local marker = markers[#markers]
if marker and marker.pending then
if ident == "FI_" then
marker.proc_prelude = true
elseif not marker.proc_prelude then
associate_skip_over_marker(out, ident, ident, "unrelated", line_of(pos), pos)
end
end
pos = ident_end
end
else
local markers = out.skip_over.markers
local marker = markers[#markers]
if marker and marker.pending and marker.proc_prelude then
local c = source:sub(pos, pos)
if c == "{" or c == ";" then
associate_skip_over_marker(out, c, c, "unrelated", line_of(pos), pos)
end
end
pos = pos + 1
end
end