diff --git a/scripts/duffle.lua b/scripts/duffle.lua index 583f2a3..07f7530 100644 --- a/scripts/duffle.lua +++ b/scripts/duffle.lua @@ -31,9 +31,8 @@ local M = {} ---@type DuffleExport --- @field binds_by_name table --- @field atoms_by_name table --- @field atom_infos AtomInfoEntry[] ---- @field components table +--- @field components table --- @field component_atom_infos AtomInfoEntry[]|nil ---- @field component_body_index table --- @field tape_chains table|nil --- @field source_order SourceFile[] --- @field collisions CorpusCollision[] @@ -70,7 +69,6 @@ function M.corpus_view(ctx) atom_infos = corpus.atom_infos or {}, components = corpus.components or {}, component_atom_infos = corpus.component_atom_infos or {}, - component_body_index = corpus.component_body_index or {}, tape_chains = corpus.tape_chains or {}, source_order = corpus.source_order or {}, collisions = corpus.collisions or {}, @@ -80,12 +78,12 @@ end --- @param rules CheckRule[] --- @param phase string --- @param item AtomEntry|SourceFile ---- @param pipe_ctx PipeCtx ---- @param findings CheckFinding[] +--- @param pipe_ctx PassScratch +--- @param findings Finding[] --- @return nil function M.run_check_rules(rules, phase, item, pipe_ctx, findings) for _, rule in ipairs(rules) do ---@type integer, CheckRule - local fn = rule[phase] ---@type (fun(item: AtomEntry|SourceFile, pipe_ctx: PipeCtx, findings: CheckFinding[]): nil)|nil + local fn = rule[phase] ---@type (fun(item: AtomEntry|SourceFile, pipe_ctx: PassScratch, findings: Finding[]): nil)|nil if fn then fn(item, pipe_ctx, findings) end end end diff --git a/scripts/duffle_emit.lua b/scripts/duffle_emit.lua index 0292b36..7b9e34c 100644 --- a/scripts/duffle_emit.lua +++ b/scripts/duffle_emit.lua @@ -11,20 +11,9 @@ for k, v in pairs(isa) do M[k] = v end ---@type string, any -- Shared, memoized helpers: a single emitted-word event stream that every downstream pass reads from, -- built once from the pre-tokenized bodies. ---- @class ComponentBodyEntry ---- @field body_tokens BodyToken[] ---- @field body_off integer -- byte offset of body[1] in `source` ---- @field line_of fun(pos:integer):integer -- byte-offset → 1-based line number in `source` ---- @field source string -- absolute path of the source containing the declaration ---- @field declaration integer -- 1-based line number of the MipsAtomComp_(ac_X) declaration ---- @field kind string -- "comp_bare" | "comp_proc" ---- @field arg_names string[]|nil ---- @field sub_map table|nil -- bag: formal -> substituted operand - --- @class EmissionWalkCtx ---- @field component_index table +--- @field components table --- @field word_counts WordCounts ---- @field components table --- @field reg_use_schema RegUseSchema|nil --- @field reg_use_param string|nil --- @field atom_name AtomName|nil @@ -39,10 +28,12 @@ for k, v in pairs(isa) do M[k] = v end ---@type string, any --- @field atom_name AtomName|nil --- @field schema_name string|nil +-- Finding: see ps1_meta.lua + --- @class DuffleEmit --- The cross-source component-body index is owned by the corpus (`corpus.component_body_index`, populated by `passes/components.lua`). +-- The cross-source component row is owned by the corpus (`corpus.components`, populated by `passes/components.lua`). -- Consumers (`passes/static_analysis.lua`, `passes/emission_model.lua`) read it directly; per-pass memoization helpers stay out of scope. -- ASCII byte constants used by split_call_args (kept local to keep Section 8 self-contained). @@ -119,18 +110,18 @@ local E_MAC_PREFIX_LEN = 4 ---@type integer --- * Known `mac_X(...)` calls: Recursively expand the indexed component body, including nested components. Every event from the expansion carries: --- - `source` / `line` = the COMPONENT'S source path + the line of the token within the component body (i.e. "definition site"). --- - `call_source` / `call_line` = the ROOT atom's source path + call-site line, PRESERVED across recursion so nested events still point at the original root. ---- * Unknown `mac_X` (not in `component_index`): fall back to `word_counts[ident]` if present; otherwise emit one opaque event so the cycle budget accounts for the word. +--- * Unknown `mac_X` (not in `components`): fall back to `word_counts[ident]` if present; otherwise emit one opaque event so the cycle budget accounts for the word. --- * Marker Tokens (`atom_label(...)` / `atom_offset(...)`): Zero events (they are pure metaprogram hints). --- --- Cycle protection: a per-expansion `visiting` set tracks components currently on the expansion stack; ---- a re-entry produces a deterministic `{kind = "cycle", ...}` error and aborts that branch (does NOT hang, does NOT recurse). +--- a re-entry produces a deterministic `{check = "cycle", ...}` error and aborts that branch (does NOT hang, does NOT recurse). --- ---- Pure: reads `body_entry` / `component_index` / `word_counts`. Memoization is the caller's responsibility. +--- Pure: reads `body_entry` / `components` / `word_counts`. Memoization is the caller's responsibility. --- Callers wanting `word_events` / `word_event_errors` precomputed for many atoms should memoize them per atom. ---- @param body_entry ComponentBodyEntry ---- @param component_index table ---- @param word_counts WordCounts ---- @return WordEvent[], EmitError[] +--- @param body_entry Component +--- @param components table +--- @param word_counts WordCounts +--- @return WordEvent[], Finding[] -- ════════════════════════════════════════════════════════════════════════════ -- Section 11: project_emission (per-atom emission projection) @@ -141,7 +132,7 @@ local E_MAC_PREFIX_LEN = 4 ---@type integer -- The items stream is the single ordered source of truth; `word_events` and `markers` are dense views over it. -- -- The helper below operates on a body string (not a body_entry) so the pass can call it without depending on the older SourceScan / body_off conventions. --- component_index argument is reserved for recursive component expansion. +-- `components` is the recursive expansion map (`corpus.components`). -- word_counts table is authored-metadata + current-component count table. --- @class EmissionProjection @@ -149,8 +140,8 @@ local E_MAC_PREFIX_LEN = 4 ---@type integer --- @field word_events WordEvent[] --- @field markers EmissionMarker[] --- @field invocations InvocationRecord[] -- dense view of items where kind == "invoke_begin"|"invoke_end" ---- @field errors EmitError[] ---- @field warnings EmitWarning[] +--- @field errors Finding[] +--- @field warnings Finding[] --- @class InvocationRecord --- Lives at `atom.paths.invocations[*]`. Constructed once at the single invocation-construction site @@ -171,7 +162,7 @@ local E_MAC_PREFIX_LEN = 4 ---@type integer --- @field end_word integer -- 1-based items index of the `invoke_end` item (set by `emit_invoke_end`) --- @field word_count integer -- Number of `word` items emitted between `start_word` and `end_word` (inclusive) --- @field debug_skip boolean -- `debug_skip` stamp; true iff `corpus.components[name].debug_skip` is true at construction. Always boolean (never `nil`). ---- @field errors EmitError[] +--- @field errors Finding[] -- Internal recursive walker. The items stream holds every emitted event in order; `word_events`, `markers`, -- `invocations`, `errors`, `warnings` are dense views / side outputs appended alongside. @@ -187,9 +178,9 @@ local E_MAC_PREFIX_LEN = 4 ---@type integer -- * Unknown uncounted macros emit one opaque word + one warning. Unknown metadata-backed macros (entry in `word_counts`) emit the declared word count, no warning. -- * Cycle detection uses an active DFS stack (`visiting`); a cycle appends a construction error to BOTH the projection errors and the cycle invocation's own errors, -- then breaks out without recursing (the cycle entry still receives an invocation ID + paired `invoke_begin` / `invoke_end` items, so the boundary invariant is preserved). --- * Component declared-count mismatch (declared vs. measured) is a construction error (kind = "count_mismatch"); recorded on the invocation record and pass-level errors list. +-- * Component declared-count mismatch (declared vs. measured) is a construction error (check = "count_mismatch"); recorded on the invocation record and pass-level errors list. -- * Final boundary check: if any invocation is still open at end of walk, surface a "unbalanced" construction error. ---- @param root_body_entry ComponentBodyEntry +--- @param root_body_entry Component --- @param ctx_table EmissionWalkCtx --- @return EmissionProjection local function _project_emission_inner(root_body_entry, ctx_table) @@ -197,8 +188,8 @@ local function _project_emission_inner(root_body_entry, ctx_table) local word_events = {} ---@type WordEvent[] local markers = {} ---@type EmissionMarker[] local invocations = {} ---@type InvocationRecord[] - local errors = {} ---@type EmitError[] - local warnings = {} ---@type EmitWarning[] + local errors = {} ---@type Finding[] + local warnings = {} ---@type Finding[] local word_idx = 0 ---@type integer local invocation_stack = {} ---@type InvocationRecord[] -- stack of currently-open invocation records @@ -282,9 +273,10 @@ local function _project_emission_inner(root_body_entry, ctx_table) gpr_keys[pos] = key if unresolved then errors[#errors + 1] = { - kind = "reguse_unresolved", - line = line, - msg = string.format("RegUse operand %q does not resolve in schema %q", + kind = "error", + check = "reguse_unresolved", + line = line, + msg = string.format("RegUse operand %q does not resolve in schema %q", effective, (reg_use_schema and reg_use_schema.name) or "?"), } end @@ -294,9 +286,10 @@ local function _project_emission_inner(root_body_entry, ctx_table) for _, wpos in ipairs(row.writes) do ---@type integer, integer if wpos == pos then errors[#errors + 1] = { - kind = "reguse_const_write", - line = line, - msg = string.format("RegUse slot %q is Reg const; %s writes it", + kind = "error", + check = "reguse_const_write", + line = line, + msg = string.format("RegUse slot %q is Reg const; %s writes it", slot, encoder), } end @@ -541,15 +534,14 @@ local function _project_emission_inner(root_body_entry, ctx_table) -- The stamp is resolved from the `corpus.components[name]` registry (passed in via `ctx_table.components` by `emission_model.run`), -- Unmarked components stamp `false` (not `nil`) so consumers can dispatch on the boolean without nil checks. -- - -- The walker has already found the component body in `ctx_table.component_index[component_name]`, so the matching entry MUST exist in `ctx_table.components[component_name]` - -- (both registries are populated from the same source by the components pass). + -- The walker has already found the component body in `ctx_table.components[component_name]`. -- A missing entry is a corpus-plumbing bug; we fail loudly here rather than silently stamp `false` and mask the regression. - local components = ctx_table.components ---@type table|nil - local component_def = components and components[component_name] or nil ---@type ComponentDef|nil + local components = ctx_table.components ---@type table|nil + local component_def = components and components[component_name] or nil ---@type Component|nil if not component_def then error("duffle.emit_invoke_begin: component " .. string.format("%q", component_name) - .. " is present in `component_index` (the walker matched a `mac_" .. component_name .. "()` call) but absent from `components` (the canonical corpus.components registry). " - .. "This is a corpus-plumbing bug — the components pass must populate corpus.components[name] for every component it puts in corpus.component_body_index[name]. " + .. " is present in the walk (the walker matched a `mac_" .. component_name .. "()` call) but absent from `components` (the canonical corpus.components registry). " + .. "This is a corpus-plumbing bug — the components pass must populate corpus.components[name] for every expanded component. " .. "The emission pass refuses to silently stamp `debug_skip = false` for a missing registry entry." , 0 ) @@ -621,9 +613,10 @@ local function _project_emission_inner(root_body_entry, ctx_table) local canon = M.gte_canon(ident) ---@type string if canon ~= ident and wc and wc[canon] then return wc[canon] end warnings[#warnings + 1] = { - kind = "uncounted", - line = tok_line, - msg = string.format("project_emission: opaque word emitted for %q (no entry in word_counts or component_index)", + kind = "warning", + check = "uncounted", + line = tok_line, + msg = string.format("project_emission: opaque word emitted for %q (no entry in word_counts or components)", ident), } return 1 @@ -634,19 +627,19 @@ local function _project_emission_inner(root_body_entry, ctx_table) -- walk_root_call_text: Outermost `mac_X(...)` token text (preserved across recursion). -- walk_immediate_call_text: IMMEDIATE outer `mac_X(...)` token text for words emitted in this body — nil for the root atom body. -- Two trackers are propagated as separate parameters so words deep inside nested expansions correctly identify both their immediate call site and the outermost call site. ---- @param body_entry ComponentBodyEntry +--- @param body_entry Component --- @param walk_parent_inv_id integer --- @param walk_root_call_text string|nil --- @param walk_immediate_call_text string|nil +--- @param sub_map table|nil --- @return nil local function walk_body_entry(body_entry, walk_parent_inv_id, - walk_root_call_text, walk_immediate_call_text) + walk_root_call_text, walk_immediate_call_text, sub_map) local tokens = body_entry.body_tokens or {} ---@type BodyToken[] local body_off = body_entry.body_off or 0 ---@type integer local line_of = body_entry.line_of or M.LineIndex("") ---@type LineIndexFn local def_source = body_entry.source or "" ---@type string - local def_line = body_entry.declaration or 0 ---@type integer - local sub_map = body_entry.sub_map ---@type table|nil + local def_line = body_entry.line or 0 ---@type integer -- Per-token dispatch: each matched branch returns; only the fall-through -- "opaque word" emit handles direct encoders + mac_X-without-component. --- @param bt BodyToken @@ -715,8 +708,8 @@ local function _project_emission_inner(root_body_entry, ctx_table) end end if ident:sub(1, 4) == "mac_" then - local bare = ident:sub(5) ---@type string - local comp = ctx_table.component_index[bare] ---@type ComponentBodyEntry|nil + local bare = ident:sub(5) ---@type string + local comp = ctx_table.components[bare] ---@type Component|nil if comp then local invocation_root_call_text = walk_root_call_text or tok ---@type string if ctx_table.visiting[bare] then @@ -724,8 +717,9 @@ local function _project_emission_inner(root_body_entry, ctx_table) local inv = emit_invoke_begin(comp.kind or "comp_bare", bare, tok, invocation_root_call_text, def_source, tok_line) ---@type InvocationRecord inv.parent_id = walk_parent_inv_id inv.call_text = tok - local err = { ---@type EmitError - kind = "cycle", + local err = { ---@type Finding + kind = "error", + check = "cycle", msg = string.format("project_emission: component cycle detected: %q", bare), source = def_source, line = tok_line, @@ -741,30 +735,24 @@ local function _project_emission_inner(root_body_entry, ctx_table) inv.parent_id = walk_parent_inv_id inv.call_text = tok inv.def_path = comp.source - inv.def_line = comp.declaration + inv.def_line = comp.line -- Propagate trackers into the recursive walk: -- immediate_call_text = this call's tok (the IMMEDIATE outer call for words emitted in this body) -- root_call_text = the OUTERMOST call (immutable across the recursion) - local formal_names = ctx_table.component_index[bare] ---@type string[]|nil - and ctx_table.component_index[bare].arg_names - local child_map = nil ---@type table|nil + -- child_map = per-invocation formal substitution; stays on the walk stack + local formal_names = comp.arg_names ---@type string[]|nil + local child_map = nil ---@type table|nil if formal_names then child_map = {} for i, fname in ipairs(formal_names) do ---@type integer, string child_map[fname] = apply_sub(sub_map, args[i]) end end - walk_body_entry({ - body_tokens = comp.body_tokens or {}, - body_off = comp.body_off or 0, - line_of = comp.line_of, - source = comp.source, - declaration = comp.declaration, - sub_map = child_map, - }, + walk_body_entry(comp, inv.id, invocation_root_call_text, - tok) + tok, + child_map) ctx_table.visiting[bare] = nil emit_invoke_end(inv) -- Count `word` items inside [start_word, end_word]. @@ -780,8 +768,9 @@ local function _project_emission_inner(root_body_entry, ctx_table) -- We compare against the measured word count. local declared = ctx_table.word_counts["mac_" .. bare] ---@type integer|nil if declared and wc_inside ~= declared then - local err = { ---@type EmitError - kind = "count_mismatch", + local err = { ---@type Finding + kind = "error", + check = "count_mismatch", msg = string.format("project_emission: mac_%s declared=%d measured=%d", bare, declared, wc_inside), source = def_source, line = tok_line, @@ -791,7 +780,7 @@ local function _project_emission_inner(root_body_entry, ctx_table) end return end - -- mac_X NOT in component_index: fall through to opaque emit. + -- mac_X NOT in components: fall through to opaque emit. end -- Direct encoder, or mac_X-without-component: resolve count + emit n words. -- Resolve_count may emit a warning if the count is unresolved. @@ -816,14 +805,15 @@ local function _project_emission_inner(root_body_entry, ctx_table) -- Walk first; the pass caller stamps the root call site for direct words after the projection returns. -- For nested words the def_path / def_line already point at the component source and MUST be preserved (the stamping helper checks for that). - walk_body_entry(root_body_entry, 0, nil, nil) + walk_body_entry(root_body_entry, 0, nil, nil, nil) -- Boundary check: every invoke_begin must have a matching invoke_end. -- If anything is still open, surface a hard error. if #invocation_stack > 0 then errors[#errors + 1] = { - kind = "unbalanced", - msg = string.format("project_emission: invocation boundaries not balanced (%d unclosed invocation(s) at end of walk)", #invocation_stack), + kind = "error", + check = "unbalanced", + msg = string.format("project_emission: invocation boundaries not balanced (%d unclosed invocation(s) at end of walk)", #invocation_stack), } end @@ -850,7 +840,7 @@ end --- * `mac_X(...)` calls: emit `invoke_begin` (zero-width), recurse into the component body, emit `invoke_end` (zero-width). --- The component body's words land between the begin/end pair; one invocation record is allocated per call (monotonic ID per atom). --- * Unknown uncounted macros emit 1 opaque word + one warning per occurrence. ---- * Tokens whose count cannot be resolved (e.g. `mac_unknown` not in word_counts and not in component_index) surface one +--- * Tokens whose count cannot be resolved (e.g. `mac_unknown` not in word_counts and not in components) surface one --- warning; cycle + count-mismatch + boundary violations are construction errors on `pass.errors`. --- --- Every emitted `word` carries: `i` (0-based word index), `encoder`, `args` (top-level args), `def_path`, `def_line`, @@ -860,15 +850,15 @@ end --- for the open invocation stack at that word. --- --- @param body_text string -- the raw atom body string ---- @param component_index table +--- @param component_index table --- @param word_counts WordCounts ---- @param components table +--- @param components table --- `invocation.debug_skip`. A missing or non-table `components` raises a fail-loud error rather than silently falling back. --- @return EmissionProjection --- @param reg_use_ctx RegUseCtx|nil function M.project_emission(body_text, component_index, word_counts, components, reg_use_ctx) - -- The recursive walk delegates to `_project_emission_inner` so component bodies (which arrive as - -- `{body_tokens, body_off, line_of, source, declaration}` records from `corpus.component_body_index`) + -- The recursive walk delegates to `_project_emission_inner` so component bodies + -- (the `corpus.components` row: body_tokens / body_off / line_of / source / line) -- re-enter the same walker with the same shared output state. -- -- The walker is body-relative: it builds `line_of` from `body_text` and stamps body-relative line numbers (1..N) @@ -898,17 +888,17 @@ function M.project_emission(body_text, component_index, word_counts, components, end local tokens = M.tokenize_body(body_text) ---@type BodyToken[] + local comps = components or component_index or {} ---@type table return _project_emission_inner({ body_tokens = tokens, body_off = 0, line_of = M.LineIndex(body_text), source = "", - declaration = 0, + line = 0, }, { - component_index = component_index or {}, + components = comps, word_counts = word_counts or {}, - components = components, reg_use_schema = reg_use_ctx and reg_use_ctx.reg_use_schema, reg_use_param = reg_use_ctx and reg_use_ctx.reg_use_param, atom_name = reg_use_ctx and reg_use_ctx.atom_name, diff --git a/scripts/duffle_isa.lua b/scripts/duffle_isa.lua index 9fc1105..78cdd4c 100644 --- a/scripts/duffle_isa.lua +++ b/scripts/duffle_isa.lua @@ -88,7 +88,14 @@ --- @field visibility_kind string --- @field evidence HardwareRelationEvidence +--- @class GprRole +--- @field name string +--- @field pool boolean +--- @field optional boolean +--- @field carrier boolean + --- @class DuffleIsa +--- @field GPR_ROLE table --- @field TAPE_ATOM_MACROS table --- @field DELAY_MARKERS table --- @field INSTRUCTION table @@ -111,6 +118,43 @@ local M = {} ---@type DuffleIsa -- Section 7: domain tables -- ════════════════════════════════════════════════════════════════════════════ +-- One GprRole row per name. Construction order is the auto_reg pool order, +-- then R_AT, then the three carriers. Index by name into M.GPR_ROLE. +--- @type table +M.GPR_ROLE = { + { name = "R_V0", pool = true, optional = true, carrier = false }, + { name = "R_V1", pool = true, optional = true, carrier = false }, + { name = "R_T0", pool = true, optional = true, carrier = false }, + { name = "R_T1", pool = true, optional = true, carrier = false }, + { name = "R_T2", pool = true, optional = true, carrier = false }, + { name = "R_T3", pool = true, optional = true, carrier = false }, + { name = "R_T4", pool = true, optional = true, carrier = false }, + { name = "R_T5", pool = true, optional = true, carrier = false }, + { name = "R_T6", pool = true, optional = true, carrier = false }, + { name = "R_T7", pool = true, optional = true, carrier = false }, + { name = "R_A0", pool = true, optional = true, carrier = false }, + { name = "R_A1", pool = true, optional = true, carrier = false }, + { name = "R_A2", pool = true, optional = true, carrier = false }, + { name = "R_A3", pool = true, optional = true, carrier = false }, + { name = "R_S0", pool = true, optional = true, carrier = false }, + { name = "R_S1", pool = true, optional = true, carrier = false }, + { name = "R_S2", pool = true, optional = true, carrier = false }, + { name = "R_S3", pool = true, optional = true, carrier = false }, + { name = "R_S4", pool = true, optional = true, carrier = false }, + { name = "R_S5", pool = true, optional = true, carrier = false }, + { name = "R_S6", pool = true, optional = true, carrier = false }, + { name = "R_S7", pool = true, optional = true, carrier = false }, + { name = "R_T8", pool = true, optional = true, carrier = false }, + { name = "R_T9", pool = true, optional = true, carrier = false }, + { name = "R_AT", pool = false, optional = true, carrier = false }, + { name = "R_TapePtr", pool = false, optional = true, carrier = true }, + { name = "R_AtomJmp", pool = false, optional = true, carrier = true }, + { name = "R_ScratchBase", pool = false, optional = true, carrier = true }, +} +for _, row in ipairs(M.GPR_ROLE) do ---@type integer, GprRole + M.GPR_ROLE[row.name] = row +end + -- atom_info sub-calls: atom_bind, atom_reads, atom_writes, atom_view, atom_reg_types, atom_ctx, atom_phase. --- @type table M.TAPE_ATOM_MACROS = { diff --git a/scripts/duffle_scan.lua b/scripts/duffle_scan.lua index 218f5aa..56afa23 100644 --- a/scripts/duffle_scan.lua +++ b/scripts/duffle_scan.lua @@ -59,6 +59,10 @@ --- @field unity_root Path --- @field project_root Path +--- @class ExactResolveOptions +--- @field sources string[] +--- @field project_root Path + --- @alias LineIndexFn fun(query_pos: integer): integer --- @class DuffleScan @@ -914,6 +918,61 @@ function M.resolve_source_corpus(options) } end +--- Exact `--source` corpus. No include expansion. Paths stay as given (normalized). +--- Same return shape as resolve_source_corpus. +--- @param options ExactResolveOptions +--- @return Corpus +function M.resolve_exact_sources(options) + if type(options) ~= "table" then error("resolve_exact_sources requires options", 2) end + if type(options.project_root) ~= "string" or options.project_root == "" then + error("resolve_exact_sources requires options.project_root", 2) + end + local sources = options.sources ---@type string[]|nil + if type(sources) ~= "table" then error("resolve_exact_sources requires options.sources", 2) end + + local project_root = options.project_root ---@type Path + local code_root = M.normalize_path(project_root .. "/code") ---@type Path + local source_order = {} ---@type SourceFile[] + local sources_by_path = {} ---@type table + local resolver = { ---@type SourceResolver + resolved = {}, + skipped = {}, + shadowed = {}, + } + for _, input_path in ipairs(sources) do ---@type integer, string + local path = M.normalize_path(input_path) ---@type string + M.canonical_path_key(path) + local source = { ---@type SourceFile + path = path, + text = M.read_file(path), + dir = M.dirname(path), + basename = M.basename_no_ext(path), + } + source_order[#source_order + 1] = source + local key = M.canonical_path_key(path) ---@type string + if not sources_by_path[key] then sources_by_path[key] = source end + resolver.resolved[#resolver.resolved + 1] = { + include_path = path, + include_text = nil, + root_source = path, + root_line = 1, + candidate_a = path, + candidate_b = nil, + selected_path = path, + disposition = "exact", + } + end + return { + unity_root = nil, + project_root = project_root, + code_root = code_root, + source_order = source_order, + sources_by_path = sources_by_path, + sources_by_dir = M.group_sources_by_dir(source_order), + resolver = resolver, + } +end + -- Split a brace-body into top-level comma-separated tokens. Honors nested parens/braces/brackets and skips strings/comments. -- Splits at top-level NEWLINES and SEMICOLONS too, AND emits a token break after a top-level comment/string. -- Pure-comment / pure-string chunks contribute 0 words. diff --git a/scripts/passes/annotation.lua b/scripts/passes/annotation.lua index a6fbf9a..6d335c0 100644 --- a/scripts/passes/annotation.lua +++ b/scripts/passes/annotation.lua @@ -21,16 +21,8 @@ local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua") -- Type declarations -- ════════════════════════════════════════════════════════════════════════════ --- SourceFile, PassCtx, PassResult, PassShared, Corpus: see ps1_meta.lua --- SourceScan, AtomEntry, BindsEntry, RegTypeDefault, AtomViewEntry: see scan_source.lua - ---- @class AtomAnnotation ---- @field atom_name string -- Atom name (scan.atom_infos row) ---- @field info_line integer -- Source line of the atom_info call ---- @field binds string|nil -- Binds_X name if any ---- @field reads string[] -- R_* names (read targets) ---- @field writes string[] -- R_* names (write targets) ---- @field errors string[]|nil -- Parse-time errors from scan_source (atom_info body malformed) +-- SourceFile, PassCtx, PassResult, PassShared, Corpus, Finding: see ps1_meta.lua +-- SourceScan, AtomEntry, AtomInfoEntry, BindsEntry, RegTypeDefault, AtomViewEntry: see scan_source.lua --- @class RegTypeOccurrence --- @field reg string @@ -38,44 +30,30 @@ local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua") --- @field source_line integer --- @class Findings ---- @field errors PassFinding[] ---- @field warnings PassFinding[] ---- @field info PassFinding[] +--- @field errors Finding[] +--- @field warnings Finding[] +--- @field info Finding[] ---- @class PipeCtx ---- @field atom_index table -- raw_name or name -> scan.atoms row (kind atom/atom_proc) ---- @field binds_index table ---- @field annot_counts table -- bag: atom name -> annotation count ---- @field types table ---- @field atom_views table ---- @field seen_defaults table -- bag: register ident -> occurrence count ---- @field seen_field table -- bag: leftover field-count slot ---- @field _scan SourceScan ---- @field word_counts WordCounts|nil ---- @field register_alias_registry table|nil ---- @field type_name_registry table|nil ---- @field type_occurrences RegTypeOccurrence[]|nil ---- @field atom_infos_list AtomInfoEntry[]|nil ---- @field binds_list BindsEntry[]|nil +-- PassScratch: see ps1_meta.lua --- @class AnnotatedResult --- @field atoms AtomEntry[] ---- @field annots AtomAnnotation[] +--- @field annots AtomInfoEntry[] --- @field macros MacroEntry[] --- @field binds BindsEntry[] ---- @field errors PassFinding[] ---- @field warnings PassFinding[] ---- @field info PassFinding[] +--- @field errors Finding[] +--- @field warnings Finding[] +--- @field info Finding[] --- @field source string|nil --- @class CheckRule ---- @field per_annot (fun(item: AtomAnnotation, pipe_ctx: PipeCtx, findings: Findings): nil)|nil +--- @field per_annot (fun(item: AtomInfoEntry, pipe_ctx: PassScratch, findings: Findings): nil)|nil --- @class SourceScan --- @field type_occurrences RegTypeOccurrence[]|nil --- @class AnnotationPass ---- @field validate fun(ctx: PassCtx, src: SourceFile, corpus_pipe_ctx: PipeCtx|nil): AnnotatedResult +--- @field validate fun(ctx: PassCtx, src: SourceFile, corpus_pipe_ctx: PassScratch|nil): AnnotatedResult --- @field run fun(ctx: PassCtx): PassResult -- ════════════════════════════════════════════════════════════════════════════ @@ -85,8 +63,8 @@ local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua") --- `macro_word_drift` writes errors[] for missing or mismatched metadata and info[] for a match. --- Check: Every annotated atom must have a matching MipsAtom_(name) declaration. ---- @param info AtomAnnotation ---- @param pipe_ctx PipeCtx +--- @param info AtomInfoEntry +--- @param pipe_ctx PassScratch --- @param findings Findings --- @return nil local function check_atom_decl_exists(info, pipe_ctx, findings) @@ -100,8 +78,8 @@ end --- Check: Every atom may have AT MOST ONE annotation. --- Post-loop: Needs full-corpus `annot_counts` from pipe_ctx. ---- @param _item AtomAnnotation|nil ---- @param pipe_ctx PipeCtx +--- @param _item AtomInfoEntry|nil +--- @param pipe_ctx PassScratch --- @param findings Findings --- @return nil local function check_unique_annotation(_item, pipe_ctx, findings) @@ -117,8 +95,8 @@ end --- Check: BIND atoms must reference a real Binds_* struct. --- I keep this as a warning so the annotation pass can report the common test-fixture case; `check_abi_handoff` in static analysis supplies the build-stopping error. ---- @param info AtomAnnotation ---- @param pipe_ctx PipeCtx +--- @param info AtomInfoEntry +--- @param pipe_ctx PassScratch --- @param findings Findings --- @return nil local function check_binds_struct_exists(info, pipe_ctx, findings) @@ -135,7 +113,7 @@ end --- Check: TAPE_WORDS(mac_X, N) ↔ WORD_COUNT(mac_X, N) drift. --- Three outcomes: missing (error), mismatch (error), match (info). --- @param m MacroEntry ---- @param pipe_ctx PipeCtx +--- @param pipe_ctx PassScratch --- @param findings Findings --- @return nil local function check_macro_word_drift(m, pipe_ctx, findings) @@ -164,7 +142,7 @@ end --- Check: atom_dbg_reg_default(R_X, ) targets an alias in `pipe_ctx.register_alias_registry` and a type in `pipe_ctx.type_name_registry`. --- Pointer depth remains bounded to 0 or 1, and duplicate defaults remain errors. --- @param _src SourceFile -- unused (kept for the per_source shape) ---- @param pipe_ctx PipeCtx +--- @param pipe_ctx PassScratch --- @param findings Findings --- @return nil local function check_semantic_reg_defaults(_src, pipe_ctx, findings) @@ -215,7 +193,7 @@ end --- Check: atom_reg_types(R_X, ) entries target an alias in `pipe_ctx.register_alias_registry` and a type in `pipe_ctx.type_name_registry`. --- A bare `atom_reg` marker opts the `R_` alias into GPR identity; references to R_T0..R_T3 require the same explicit marker. --- @param _src SourceFile ---- @param pipe_ctx PipeCtx +--- @param pipe_ctx PassScratch --- @param findings Findings --- @return nil local function check_atom_reg_types(_src, pipe_ctx, findings) @@ -247,7 +225,7 @@ end --- Check: atom_view(Binds_X) entries reference a Binds_* struct with at least one field. --- @param _src SourceFile ---- @param pipe_ctx PipeCtx +--- @param pipe_ctx PassScratch --- @param findings Findings --- @return nil local function check_atom_view_layout(_src, pipe_ctx, findings) @@ -277,7 +255,7 @@ end --- Check: Binds_* structs require unique field names because atom_view uses those names for typed-field lookup in gdb. --- @param _src SourceFile ---- @param pipe_ctx PipeCtx +--- @param pipe_ctx PassScratch --- @param findings Findings --- @return nil local function check_binds_no_duplicate_fields(_src, pipe_ctx, findings) @@ -310,7 +288,7 @@ end --- 6. unsupported target_kind -> marker precedes an unrelated declaration --- Valid markers stamp `debug_skip` on whole-atom, bare-component, and proc-component declaration records in scan_source.lua. --- @param marker DebugSkipMarker ---- @param _pipe_ctx PipeCtx -- Unused; kept for consistency with per_annot +--- @param _pipe_ctx PassScratch -- Unused; kept for consistency with per_annot --- @param findings Findings --- @return nil local function check_skip_marker(marker, _pipe_ctx, findings) @@ -368,7 +346,7 @@ end --- Warn when a source references an unregistered alias. --- When a source uses an unregistered R_X, this check emits one pass-level info entry for that source and directs C-ABI register names to explicit alias registration. --- @param _src SourceFile ---- @param pipe_ctx PipeCtx +--- @param pipe_ctx PassScratch --- @param findings Findings --- @return nil local function check_wave_context_migration(_src, pipe_ctx, findings) @@ -425,9 +403,9 @@ local CHECK_RULES = { ---@type CheckRule[] --- Builds one pass-wide pipe_ctx from the merged `corpus.*` registries and source-ordered `corpus.atom_infos`; per-source declarations and bodies remain in `src.scan`. --- The module ownership contract above requires callers to construct `ctx.shared.corpus` through `build_ctx`; the error message below enforces that gate. --- @param ctx PassCtx ---- @return PipeCtx +--- @return PassScratch local function build_corpus_pipe_ctx(ctx) - local view = duffle.corpus_view(ctx) ---@type PipeCtx + local view = duffle.corpus_view(ctx) ---@type PassScratch local annot_counts = {} ---@type table -- bag: atom name -> annotation count for _, info in ipairs(view.atom_infos) do ---@type integer, AtomInfoEntry if info and info.atom_name then @@ -443,7 +421,7 @@ end --- Validate one source against its pre-scanned SourceScan payload + the corpus-wide pipe_ctx. --- @param ctx PassCtx --- @param src SourceFile ---- @param corpus_pipe_ctx PipeCtx|nil -- Built once per pass from corpus registries; nil builds the same projection here. +--- @param corpus_pipe_ctx PassScratch|nil -- Built once per pass from corpus registries; nil builds the same projection here. --- @return AnnotatedResult local function validate(ctx, src, corpus_pipe_ctx) corpus_pipe_ctx = corpus_pipe_ctx or build_corpus_pipe_ctx(ctx) @@ -453,7 +431,7 @@ local function validate(ctx, src, corpus_pipe_ctx) local seen_defaults = {}; for reg, _ in pairs (scan.types or {}) do seen_defaults[reg] = (seen_defaults[reg] or 0) + 1 end ---@type table -- bag: register ident -> occurrence count local atom_infos_list = {}; for _, ai in ipairs(scan.atom_infos or {}) do atom_infos_list[#atom_infos_list + 1] = ai end ---@type AtomInfoEntry[] - local pipe_ctx = { ---@type PipeCtx + local pipe_ctx = { ---@type PassScratch atom_index = {}, binds_index = {}, annot_counts = corpus_pipe_ctx.annot_counts, @@ -548,12 +526,12 @@ M.validate = validate --- @return PassResult function M.run(ctx) local outputs = {} ---@type PassOutputEntry[] - local errors = {} ---@type PassFinding[] - local warnings = {} ---@type PassFinding[] + local errors = {} ---@type Finding[] + local warnings = {} ---@type Finding[] -- Build the shared pipe_ctx once for this run; every validate() call sees the same cross-source registries. -- The corpus owns the canonical cross-source registries; per-source scans retain body / declaration ownership. - local corpus_pipe_ctx = build_corpus_pipe_ctx(ctx) ---@type PipeCtx + local corpus_pipe_ctx = build_corpus_pipe_ctx(ctx) ---@type PassScratch local corpus = ctx.shared.corpus ---@type Corpus -- Group `corpus.sources_by_dir` by module, validate every source in each bucket, and emit one errors.h per directory. @@ -562,17 +540,17 @@ function M.run(ctx) for dir, dir_sources in pairs(by_dir) do ---@type string, SourceFile[] local dir_basename = dir:match("([^/\\]+)$") or dir ---@type string local dir_atoms = 0 ---@type integer - local dir_errors = {} ---@type PassFinding[] - local dir_warnings = {} ---@type PassFinding[] + local dir_errors = {} ---@type Finding[] + local dir_warnings = {} ---@type Finding[] for _, src in ipairs(dir_sources) do ---@type integer, SourceFile local result = validate(ctx, src, corpus_pipe_ctx) ---@type AnnotatedResult result.source = src.path -- tag for downstream rendering dir_atoms = dir_atoms + #result.atoms - for _, e in ipairs(result.errors) do ---@type integer, PassFinding + for _, e in ipairs(result.errors) do ---@type integer, Finding dir_errors[#dir_errors + 1] = { line = e.line, msg = e.msg, source = src.path } errors [#errors + 1] = { line = e.line, msg = e.msg } end - for _, w in ipairs(result.warnings) do ---@type integer, PassFinding + for _, w in ipairs(result.warnings) do ---@type integer, Finding dir_warnings[#dir_warnings + 1] = { line = w.line, msg = w.msg } warnings [#warnings + 1] = { line = w.line, msg = w.msg } end diff --git a/scripts/passes/atoms_source_map.lua b/scripts/passes/atoms_source_map.lua index 553dd63..b1864ac 100644 --- a/scripts/passes/atoms_source_map.lua +++ b/scripts/passes/atoms_source_map.lua @@ -595,8 +595,8 @@ end --- @return PassResult function M.run(ctx) local outputs = {} ---@type PassOutputEntry[] - local errors = {} ---@type PassFinding[] - local warnings = {} ---@type PassFinding[] + local errors = {} ---@type Finding[] + local warnings = {} ---@type Finding[] local corpus = ctx.shared and ctx.shared.corpus ---@type Corpus|nil if type(corpus) ~= "table" or type(corpus.source_order) ~= "table" then diff --git a/scripts/passes/auto_reg.lua b/scripts/passes/auto_reg.lua index 36e7863..5cf7379 100644 --- a/scripts/passes/auto_reg.lua +++ b/scripts/passes/auto_reg.lua @@ -28,8 +28,8 @@ --- @class AutoRegResult --- @field outputs AutoRegOutput[] ---- @field errors PassFinding[] ---- @field warnings PassFinding[] +--- @field errors Finding[] +--- @field warnings Finding[] --- @class AutoRegPass --- @field run fun(ctx: PassCtx): AutoRegResult @@ -37,6 +37,7 @@ local _bootstrap_dir = debug.getinfo(1, "S").source:match("^@?(.*[/\\])") or "./" ---@type string local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua") ---@type DuffleExport +local isa = require("duffle_isa") ---@type DuffleIsa --- ════════════════════════════════════════════════════════════════════════════ --- THE GPR ALLOCATION POOL — what's allocatable, and (more importantly) WHY @@ -54,15 +55,12 @@ local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua") --- R_K0/K1 (codes 26-27) — Kernel / interrupt handler reserves. Never touched by user code. --- R_GP/SP/FP/RA (codes 28-31) — R_SP/R_FP/R_RA are tape-runtime carriers between tape_enter and tape_exit; R_GP stays the host global pointer. --- -local POOL = { ---@type GprIdent[] - "R_V0", "R_V1", - "R_T0", "R_T1", "R_T2", "R_T3", - "R_T4", "R_T5", "R_T6", "R_T7", - "R_A0", "R_A1", "R_A2", "R_A3", - "R_S0", "R_S1", "R_S2", "R_S3", - "R_S4", "R_S5", "R_S6", "R_S7", - "R_T8", "R_T9", -} +local POOL = {} ---@type GprIdent[] +for _, row in ipairs(isa.GPR_ROLE) do ---@type integer, GprRole + if row.pool then + POOL[#POOL + 1] = row.name + end +end -- Map from integer MIPS GPR code (the `code` field on AliasEntry) to the physical GPR ident in POOL. -- The standard MIPS O32 ABI register numbering matches mips.h's R_*_Code #defines (mips.h). @@ -94,7 +92,7 @@ end --- @param phase_label string --- @param decls table -- bag: auto-reg symbol -> decl payload --- @return GprAllocMap ---- @return PassFinding[] +--- @return Finding[] local function allocate_phase(phase_label, decls) -- Deep-copy POOL into a fresh sequence table. The original `table.unpack and table.unpack(POOL) or { unpack(POOL) }` -- idiom wraps the unpacked values in a single inner table under LuaJIT 5.1 (`table.unpack` is nil; the `or` returns one value), @@ -102,7 +100,7 @@ local function allocate_phase(phase_label, decls) local pool = {} ---@type GprIdent[] for i = 1, #POOL do pool[i] = POOL[i] end ---@type integer local result = {} ---@type GprAllocMap - local errors = {} ---@type PassFinding[] + local errors = {} ---@type Finding[] for _, sym in ipairs(stable_sort_keys(decls)) do ---@type integer, string local next_gpr = table.remove(pool, 1) ---@type GprIdent|nil if not next_gpr then @@ -226,8 +224,8 @@ local M = {} ---@type AutoRegPass --- @return AutoRegResult function M.run(ctx) local outputs = {} ---@type AutoRegOutput[] - local errors = {} ---@type PassFinding[] - local warnings = {} ---@type PassFinding[] + local errors = {} ---@type Finding[] + local warnings = {} ---@type Finding[] local corpus = ctx.shared and ctx.shared.corpus ---@type Corpus|nil if type(corpus) ~= "table" then @@ -244,12 +242,12 @@ function M.run(ctx) -- 1. Allocate phase pools first (phase declarations take precedence over per-atom declarations). local phase_allocations = {} ---@type table -- bag: phase_label -> alloc map for phase_label, decls in pairs(corpus.phase_auto_regs or {}) do ---@type string, table - local mapping, errs = allocate_phase(phase_label, decls) ---@type GprAllocMap, PassFinding[] + local mapping, errs = allocate_phase(phase_label, decls) ---@type GprAllocMap, Finding[] for sym, gpr in pairs(mapping) do ---@type string, GprIdent phase_allocations[phase_label] = phase_allocations[phase_label] or {} phase_allocations[phase_label][sym] = gpr end - for _, e in ipairs(errs) do ---@type integer, PassFinding + for _, e in ipairs(errs) do ---@type integer, Finding errors[#errors + 1] = e end end diff --git a/scripts/passes/components.lua b/scripts/passes/components.lua index 1c1fdd8..c042ca0 100644 --- a/scripts/passes/components.lua +++ b/scripts/passes/components.lua @@ -1,6 +1,6 @@ --- passes/components.lua — Component-macro header generator. --- ---- Ownership: `corpus.word_counts`, `corpus.components`, and `corpus.component_body_index`. +--- Ownership: `corpus.word_counts` and `corpus.components`. --- Scanner owns `declaration_comment` and `debug_skip` on each declaration record; this pass projects both forward. --- --- Reads the pre-scanned SourceScan payload from `duffle.scan_source` for `MipsAtomComp_(ac_X)` and `MipsAtomComp_Proc_(ac_X, { body })` declarations (kind="comp_bare" / "comp_proc"), @@ -56,20 +56,24 @@ local MACS_FILENAME = "macs.h" ---@type string -- SourceScan, AtomEntry, CorpusCollision, CollisionSite: see scan_source.lua -- BodyToken: see emission_model.lua -- WordCounts: see word_count_eval.lua --- ComponentBodyEntry: see duffle_emit.lua -- InstructionRow, GteCommandRow: see duffle_isa.lua --- @class Component ---- @field name string -- Atom name (without `ac_` prefix) ---- @field body string -- Brace-delimited body (without the braces) ---- @field body_off integer|nil -- Byte offset of body[1] in source ---- @field body_tokens BodyToken[]|nil ---- @field args string|nil -- Function-args string (function form only) ---- @field arg_names string[]|nil -- Formal names with leading `ab` dropped ---- @field line integer -- Source line of the declaration ---- @field comment string|nil -- Scanner-owned `declaration_comment`; the components pass reads it from the scanner record ---- @field kind string -- "comp_bare" | "comp_proc" (atom_proc is NOT a component — see `project_components`) ---- @field debug_skip boolean -- Mirror of `a.debug_skip` (scanner-owned); true iff a bare `atom_dbg_skip` marker immediately preceded the declaration +--- @field name string -- Atom name (without `ac_` prefix) +--- @field body string -- Brace-delimited body (without the braces) +--- @field body_off integer|nil -- Byte offset of body[1] in source +--- @field body_tokens BodyToken[]|nil +--- @field args string|nil -- Function-args string (function form only) +--- @field arg_names string[]|nil -- Formal names with leading `ab` dropped +--- @field line integer -- Source line of the declaration +--- @field comment string|nil -- Scanner-owned `declaration_comment`; the components pass reads it from the scanner record +--- @field kind string -- "comp_bare" | "comp_proc" (atom_proc is NOT a component — see `project_components`) +--- @field debug_skip boolean -- Mirror of `a.debug_skip` (scanner-owned); true iff a bare `atom_dbg_skip` marker immediately preceded the declaration +--- @field path string|nil -- Slash-normalized source path (collision sites) +--- @field source string|nil -- Absolute source path (emit) +--- @field line_of (fun(pos: integer): integer)|nil +--- @field cycle_cost integer|nil -- From metadata[c.name]; nil when the body was not costed +--- @field gp0_contrib integer|nil -- From metadata[c.name]; nil when the body was not costed --- @class ComponentMeta --- @field cycle_cost integer @@ -800,16 +804,7 @@ local function update_canonical_word_counts(corpus, 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" (atom_proc is NOT a component) ---- @field debug_skip boolean -- Mirror of the scanner-owned `a.debug_skip`; consumers read this directly ---- @field cycle_cost integer|nil -- From metadata[c.name]; nil when the body was not costed ---- @field gp0_contrib integer|nil -- From metadata[c.name]; nil when the body was not costed - ---- (internal) Populate `corpus.components` with this source's components-by-name map. +--- (internal) Populate `corpus.components` with this source's one component row per bare name. --- First declaration wins; later declarations of the same bare name are dropped and recorded as a collision via `corpus.collisions` (kind = "component"). --- The pass does NOT write to `ctx.shared.components`. --- No parallel skip map is built here; consumers that need the per-component skip state read `corpus.components[name].debug_skip` directly. @@ -818,28 +813,29 @@ end --- @param src SourceFile --- @param components Component[] --- @param metadata ComponentMetaMap +--- @param scan SourceScan --- @return nil -local function update_canonical_components(corpus, src, components, metadata) - local rel_path = src.path:gsub("\\", "/") ---@type string - for _, c in ipairs(components) do ---@type integer, Component +local function update_canonical_components(corpus, src, components, metadata, scan) + local rel_path = src.path:gsub("\\", "/") ---@type string + local line_of = scan and scan.line_of ---@type (fun(pos: integer): integer)|nil + for _, c in ipairs(components) do ---@type integer, Component -- Keyed by bare name (e.g. `yield`, `load_tri_indices`). -- The atoms_source_map pass looks up components by bare name from the corpus; -- `mac_` prefix lives at the call-site identifier and is stripped before lookup. local m = metadata and metadata[c.name] or nil ---@type ComponentMeta|nil if corpus.components[c.name] == nil then - corpus.components[c.name] = { - name = c.name, - line = c.line, - path = rel_path, - kind = c.kind or "comp_bare", - debug_skip = c.debug_skip == true, - cycle_cost = m and m.cycle_cost or nil, - gp0_contrib = m and m.gp0_contrib or nil, - } + c.path = rel_path + c.source = src.path + c.line_of = line_of + c.kind = c.kind or "comp_bare" + c.debug_skip = c.debug_skip == true + c.cycle_cost = m and m.cycle_cost or nil + c.gp0_contrib = m and m.gp0_contrib or nil + corpus.components[c.name] = c else -- A second declaration of the same bare name: record a typed collision so static-analysis + the report can surface it. -- Identical-shape declarations (same path + line) reuse the first-wins entry without a collision record. - local existing = corpus.components[c.name] ---@type ComponentDef + local existing = corpus.components[c.name] ---@type Component if existing.path ~= rel_path or existing.line ~= c.line then local kind = c.kind or "comp_bare" ---@type string local first_kind = existing.kind or "comp_bare" ---@type string @@ -856,37 +852,12 @@ local function update_canonical_components(corpus, src, components, metadata) end end ---- (internal) Populate `corpus.component_body_index` with this source's body index entries. ---- First declaration wins; later declarations are dropped (no separate collision record: the components collision is already surfaced by `update_canonical_components`). ---- The pass writes to `corpus.component_body_index` only (the corpus owns this projection). ---- @param corpus Corpus ---- @param src SourceFile ---- @param components Component[] ---- @param scan SourceScan ---- @return nil -local function update_canonical_component_body_index(corpus, src, components, scan) - local line_of = scan and scan.line_of ---@type (fun(pos: integer): integer)|nil - for _, c in ipairs(components) do ---@type integer, Component - if corpus.component_body_index[c.name] == nil then - corpus.component_body_index[c.name] = { - body_tokens = c.body_tokens, - body_off = c.body_off, - line_of = line_of, - source = src.path, - declaration = c.line, - kind = c.kind, - arg_names = c.arg_names, - } - end - end -end - --- @param ctx PassCtx --- @return PassResult function M.run(ctx) local outputs = {} ---@type MacsOutput[] - local errors = {} ---@type PassFinding[] - local warnings = {} ---@type PassFinding[] + local errors = {} ---@type Finding[] + local warnings = {} ---@type Finding[] -- Corpus ownership gate. local corpus = ctx.shared and ctx.shared.corpus ---@type Corpus|nil @@ -904,8 +875,7 @@ function M.run(ctx) -- Projection ownership: -- * `corpus.word_counts["mac_"..name]` — current component count - -- * `corpus.components[name]` — bare-name component definition - -- * `corpus.component_body_index[name]` — body / line_of / source index + -- * `corpus.components[name]` — one row: body, line_of, source, cost -- The pass writes to the corpus only; consumers read from the corpus directly. -- Per-directory aggregation: every source in the same directory contributes to one `gen/macs.h`. @@ -937,8 +907,7 @@ function M.run(ctx) for _, src in ipairs(sources) do ---@type integer, SourceFile local per_source = project_components(src.text, src.scan) or {} ---@type Component[] if #per_source > 0 then - update_canonical_components(corpus, src, per_source, metadata_per_source[src]) - update_canonical_component_body_index(corpus, src, per_source, src.scan) + update_canonical_components(corpus, src, per_source, metadata_per_source[src], src.scan) end end end diff --git a/scripts/passes/dwarf_injection.lua b/scripts/passes/dwarf_injection.lua index 5a16abb..94c50d2 100644 --- a/scripts/passes/dwarf_injection.lua +++ b/scripts/passes/dwarf_injection.lua @@ -450,7 +450,7 @@ local DEFAULT_BASENAME = "hello_gte" ---@type string --- DieSchema / FORM_WRITERS / atom-table records live on this file. --- SourceFile, Path, AtomName: see duffle.lua. ---- Corpus, PassCtx, PassResult, PassFlags, PassShared, PassFinding: see ps1_meta.lua. +--- Corpus, PassCtx, PassResult, PassFlags, PassShared, Finding: see ps1_meta.lua. --- AtomEntry, AliasEntry, TypeNameEntry, TypeField, AtomInfoEntry, BindsEntry, --- AtomViewEntry, AtomCtxEntry, AtomPhaseGroup, SourceScan, RegTypeOverride: see scan_source.lua. --- AtomPaths, WordEvent, BodyToken: see emission_model.lua. diff --git a/scripts/passes/emission_model.lua b/scripts/passes/emission_model.lua index a6a2d1c..35a2679 100644 --- a/scripts/passes/emission_model.lua +++ b/scripts/passes/emission_model.lua @@ -1,7 +1,7 @@ --- passes/emission_model.lua: Per-atom emission projection. --- --- The `emission-model` pass owns `atom.paths`, the canonical per-atom mutable surface for atoms and raw atoms with bodies in `ctx.shared.corpus.source_order`. ---- For each atom, the pass invokes `duffle.project_emission(body_text, component_index, word_counts, components)`. +--- For each atom, the pass invokes `duffle.project_emission(body_text, components, word_counts, components)`. --- It stores the ordered `items` stream plus the dense `word_events` / `markers` / `invocations` views on `atom.paths`. --- --- Public boundary: @@ -92,17 +92,7 @@ --- @field consuming_encoder string|nil --- @field consuming_arg_pos integer|nil ---- @class EmitError ---- @field kind string ---- @field line integer|nil ---- @field msg string ---- @field source string|nil ---- @field schema_name string|nil - ---- @class EmitWarning ---- @field kind string ---- @field line integer|nil ---- @field msg string +-- Finding: see ps1_meta.lua --- @class AtomPaths --- @field tokens BodyToken[] @@ -111,8 +101,8 @@ --- @field word_events WordEvent[] --- @field markers EmissionMarker[] --- @field invocations InvocationRecord[] ---- @field errors EmitError[] ---- @field warnings EmitWarning[] +--- @field errors Finding[] +--- @field warnings Finding[] --- @class EmissionModelPass --- @field run fun(ctx: PassCtx): PassResult @@ -136,7 +126,7 @@ local duffle = dofile(_bootstrap_dir .. "../duffle_paths.lua") -- * ROOT invocations (`inv.parent_id == 0`) receive body-relative `call_line` values directly from `M.LineIndex(body_text)` in the walker. -- The source `line_of` closure supplies physical lines at the close site, so this function converts each root value exactly once. -- * INNER invocations (`inv.parent_id ~= 0`) receive physical `call_line` values directly from the COMPONENT's `line_of` in the walker. --- Recursive descent forwards that closure through `corpus.component_body_index[name].line_of`; those values arrive physical and remain unchanged. +-- Recursive descent forwards that closure through `corpus.components[name].line_of`; those values arrive physical and remain unchanged. -- -- After this function, every `inv.call_line` is physical. DWARF and provenance output read it directly. -- The word-event loop forwards the already-physical `outer_inv.call_line` into `we.call_line` for words inside an invocation. @@ -155,7 +145,7 @@ local function stamp_root_provenance(projection, atom_record, src, corpus) -- The walker assigns line 2 to the body's first content line because line 1 is the trailing `\n` after `{`. Body-text line k therefore maps to `root_body_line + (k - 1)`. -- `body_off - 1` points at the opening `{`, whose line index identifies the header line. `body_off` points after `{` and would shift every word row forward by one line. local root_body_line = root_line_of(atom_record.body_off - 1) or atom_record.line or 0 ---@type integer - local component_index = corpus.component_body_index or {} ---@type table + local components = corpus.components or {} ---@type table local word_items = {} ---@type EmissionItem[] for _, item in ipairs(projection.items) do ---@type integer, EmissionItem @@ -176,7 +166,7 @@ local function stamp_root_provenance(projection, atom_record, src, corpus) local inner_id = ids[#ids] ---@type integer local inner_inv = inner_id and projection.invocations[inner_id] ---@type InvocationRecord|nil if inner_inv then - local component = component_index[inner_inv.component_name] ---@type ComponentBodyEntry|nil + local component = components[inner_inv.component_name] ---@type Component|nil if component and component.line_of then -- Walker used `comp.line_of`, which is the source's physical LineIndex. item.line is already physical. return item.line or 0 @@ -257,13 +247,13 @@ end local function project_atom(atom_record, src, corpus) local body = atom_record.body or "" ---@type string local wc = corpus.word_counts or {} ---@type WordCounts - local cbi = corpus.component_body_index or {} ---@type table + local comps = corpus.components or {} ---@type table local schema = nil ---@type RegUseSchema|nil if atom_record.reg_use_schema_name then schema = corpus.reg_use_schemas and corpus.reg_use_schemas[atom_record.reg_use_schema_name] end -- That construction site stamps `invocation.debug_skip` while appending each record to `proj.invocations`. - local proj = duffle.project_emission(body, cbi, wc, corpus.components, { ---@type EmissionProjection + local proj = duffle.project_emission(body, comps, wc, comps, { ---@type EmissionProjection reg_use_schema = schema, reg_use_param = atom_record.reg_use_param_name, atom_name = atom_record.name, @@ -271,13 +261,22 @@ local function project_atom(atom_record, src, corpus) }) if atom_record.reg_use_schema_name and not schema then proj.errors[#proj.errors + 1] = { - kind = "reguse_missing_schema", - msg = string.format("RegUse schema %q is missing", atom_record.reg_use_schema_name), + kind = "error", + check = "reguse_missing_schema", + msg = string.format("RegUse schema %q is missing", atom_record.reg_use_schema_name), + schema_name = atom_record.reg_use_schema_name, } end - for _, err in ipairs(corpus.reg_use_errors or {}) do ---@type integer, EmitError + for _, err in ipairs(corpus.reg_use_errors or {}) do ---@type integer, RegUseError if err.schema_name == atom_record.reg_use_schema_name then - proj.errors[#proj.errors + 1] = err + proj.errors[#proj.errors + 1] = { + kind = "error", + check = err.kind, + line = err.line or err.source_line or 0, + msg = err.msg or "", + source = err.source or err.source_file, + schema_name = err.schema_name, + } end end local paths = { ---@type AtomPaths @@ -303,8 +302,8 @@ end --- @return PassResult function M.run(ctx) local outputs = {} ---@type PassOutputEntry[] - local errors = {} ---@type EmitError[] - local warnings = {} ---@type EmitWarning[] + local errors = {} ---@type Finding[] + local warnings = {} ---@type Finding[] local corpus = ctx and ctx.shared and ctx.shared.corpus ---@type Corpus|nil if type(corpus) ~= "table" then error("emission_model: ctx.shared.corpus is required (canonical projection)", 0) end @@ -322,20 +321,24 @@ function M.run(ctx) return end local proj = project_atom(atom, src, corpus) ---@type EmissionProjection - for _, e in ipairs(proj.errors) do ---@type integer, EmitError - -- Preserve `kind` (cycle / count_mismatch / unbalanced) so readers dispatch on the diagnostic class and leave the message string as display text. + for _, e in ipairs(proj.errors) do ---@type integer, Finding + -- Finding.kind is severity. Finding.check holds the diagnostic code + -- (cycle / count_mismatch / unbalanced / reguse_*). errors[#errors + 1] = { - kind = e.kind, - line = e.line, - msg = e.msg, - source = e.source or src.path, + kind = "error", + check = e.check, + line = e.line, + msg = e.msg, + source = e.source or src.path, + schema_name = e.schema_name, } end - for _, w in ipairs(proj.warnings) do ---@type integer, EmitWarning + for _, w in ipairs(proj.warnings) do ---@type integer, Finding warnings[#warnings + 1] = { - kind = w.kind, - line = w.line, - msg = w.msg, + kind = "warning", + check = w.check, + line = w.line, + msg = w.msg, } end end diff --git a/scripts/passes/offsets.lua b/scripts/passes/offsets.lua index 0f34ba1..ff5f09a 100644 --- a/scripts/passes/offsets.lua +++ b/scripts/passes/offsets.lua @@ -137,7 +137,7 @@ end --- missing `consuming_encoder` -> ERROR. A lone top-level `atom_offset` is not a branch. --- @param labels table --- @param branches OffsetBranch[] ---- @param errors PassFinding[] +--- @param errors Finding[] --- @return BranchOffset[] local function compute_offsets(labels, branches, errors) local results = {} ---@type BranchOffset[] @@ -259,7 +259,7 @@ local M = {} ---@type OffsetsPass --- @param ctx PassCtx --- @param dir string --- @param sources SourceFile[] ---- @param errors PassFinding[] +--- @param errors Finding[] --- @return string|nil local function process_directory(ctx, dir, sources, errors) local atoms_data = {} ---@type AtomData[] @@ -297,8 +297,8 @@ end --- @return PassResult function M.run(ctx) local outputs = {} ---@type OffsetOutput[] - local errors = {} ---@type PassFinding[] - local warnings = {} ---@type PassFinding[] + local errors = {} ---@type Finding[] + local warnings = {} ---@type Finding[] local corpus = ctx.shared and ctx.shared.corpus ---@type Corpus|nil if type(corpus) ~= "table" then diff --git a/scripts/passes/report.lua b/scripts/passes/report.lua index 77d8b00..0f40bf0 100644 --- a/scripts/passes/report.lua +++ b/scripts/passes/report.lua @@ -54,36 +54,17 @@ local PASS_NAME = "report" ---@type string -- ════════════════════════════════════════════════════════════════════════════ -- SourceFile: see duffle.lua --- PassCtx, PassResult, PassFinding, PassOutputEntry: see ps1_meta.lua +-- PassCtx, PassResult, Finding, PassOutputEntry: see ps1_meta.lua -- AtomEntry, SourceScan, BindsEntry, AtomInfoEntry, AliasEntry, -- AtomPhaseGroup, AtomViewEntry, AtomCtxEntry, CorpusCollision: see scan_source.lua --- CheckFinding, AtomAnalysis: see static_analysis.lua +-- AtomAnalysis: see static_analysis.lua -- WordCounts: see word_count_eval.lua --- ComponentBodyEntry: see duffle_emit.lua +-- Component: see components.lua -- AtomPaths, WordEvent: see emission_model.lua -- GprAllocMap: see auto_reg.lua -- Shapes produced by `passes/annotation.lua`'s `M.validate()`. - ---- @class AnnotEntry ---- @field line integer -- Source line ---- @field macro string -- Macro name (e.g. "atom_reads") ---- @field name string -- Atom name (if a `name(...)` was given) ---- @field kind string -- "atom_info" | "atom_bind" | ... ---- @field binds string|nil -- Binds_X name if any ---- @field reads string[] -- R_* names (read targets) ---- @field writes string[] -- R_* names (write targets) ---- @field error string|nil -- Error message if annotation was malformed - ---- @class BindsField ---- @field name string -- Field name ---- @field offset integer -- Byte offset within the Binds_X struct - ---- @class BindsStruct ---- @field name string -- Struct name (e.g. "Binds_Floor") ---- @field line integer -- Source line of the typedef ---- @field bytes integer -- Total byte size ---- @field fields BindsField[] -- The field list +-- Binds_* rows are BindsEntry (scan_source.lua). Report reads src.scan.binds. --- @class MacroEntry --- @field name string -- Macro name (e.g. "WORD_COUNT(my_macro, 4)") @@ -93,12 +74,12 @@ local PASS_NAME = "report" ---@type string --- @class AnnotationResult --- @field source string -- Set by this pass; original source path --- @field atoms AtomEntry[] -- Atom declarations in this source ---- @field annots AnnotEntry[] -- Annotation entries +--- @field annots AtomInfoEntry[] -- Annotation entries --- @field macros MacroEntry[] -- Macro word-count declarations ---- @field binds BindsStruct[] -- Binds_* struct declarations ---- @field errors PassFinding[] -- Errors from validation ---- @field warnings PassFinding[] -- Warnings from validation ---- @field info PassFinding[] -- Info summary (not rendered here) +--- @field binds BindsEntry[] -- Binds_* struct declarations +--- @field errors Finding[] -- Errors from validation +--- @field warnings Finding[] -- Warnings from validation +--- @field info Finding[] -- Info summary (not rendered here) --- @class ModuleEntry --- @field dir string -- Absolute directory path @@ -196,7 +177,7 @@ local PASS_NAME = "report" ---@type string --- @field sources SourceFile[] --- @field decls AtomEntry[] --- @field schemas RegUseSchema[] ---- @field findings CheckFinding[] +--- @field findings Finding[] --- @field corpus Corpus --- @class SectionRenderer @@ -424,11 +405,11 @@ end --- @param view ModuleView --- @return nil local function render_section_components(add, view) - local rows = {} ---@type ComponentReportRow[] - local index = (view.corpus and view.corpus.component_body_index) or {} ---@type table - for _, a in ipairs(view.decls) do ---@type integer, AtomEntry + local rows = {} ---@type ComponentReportRow[] + local index = (view.corpus and view.corpus.components) or {} ---@type table + for _, a in ipairs(view.decls) do ---@type integer, AtomEntry if a.kind == "comp_bare" or a.kind == "comp_proc" then - local idx = index[a.name] or {} ---@type ComponentBodyEntry + local idx = index[a.name] or {} ---@type Component local args = idx.arg_names or {} ---@type string[] rows[#rows + 1] = { name = a.name, @@ -698,8 +679,8 @@ end --- @param view ModuleView --- @return nil local function render_section_findings(add, view) - local by_atom = {} ---@type table - for _, f in ipairs(view.findings or {}) do ---@type integer, CheckFinding + local by_atom = {} ---@type table + for _, f in ipairs(view.findings or {}) do ---@type integer, Finding local key = f.atom or "?" ---@type string by_atom[key] = by_atom[key] or {} by_atom[key][#by_atom[key] + 1] = f @@ -707,11 +688,11 @@ local function render_section_findings(add, view) if next(by_atom) == nil then add("_(none)_"); add(""); return end local seen = {} ---@type table -- bag: atom name already emitted --- @param name string - --- @param fs CheckFinding[] + --- @param fs Finding[] --- @return nil local function emit(name, fs) add("### " .. name) - for _, f in ipairs(fs) do ---@type integer, CheckFinding + for _, f in ipairs(fs) do ---@type integer, Finding local msg = f.msg or "" ---@type string local slot = slot_suffix(f.gpr_key or f.producer_destination) ---@type string|nil if slot and not msg:find("(slot ", 1, true) then @@ -766,11 +747,12 @@ local HIDDEN_UNLESS_WRITTEN = { ---@type table -- bag: GPR key R_AT = true, R_TapePtr = true, R_AtomJmp = true, } -local PHYSICAL_GPR = { ---@type table -- bag: physical GPR alias -> true - R_T0 = true, R_T1 = true, R_T2 = true, R_T3 = true, - R_T4 = true, R_T5 = true, R_T6 = true, R_T7 = true, - R_V0 = true, R_V1 = true, -} +local PHYSICAL_GPR = {} ---@type table -- bag: physical GPR alias -> true +for _, row in ipairs(duffle.GPR_ROLE) do ---@type integer, GprRole + if row.pool then + PHYSICAL_GPR[row.name] = true + end +end --- @param atom AtomEntry --- @param key string @@ -945,7 +927,7 @@ local function render_module_meta_report(view) n_macros = n_macros + #((src.scan and src.scan.macros) or {}) end local n_err, n_warn, n_info = 0, 0, 0 ---@type integer, integer, integer - for _, f in ipairs(view.findings or {}) do ---@type integer, CheckFinding + for _, f in ipairs(view.findings or {}) do ---@type integer, Finding if f.kind == "error" then n_err = n_err + 1 elseif f.kind == "warning" then n_warn = n_warn + 1 else n_info = n_info + 1 @@ -1087,7 +1069,7 @@ function M.run(ctx) n_macros = n_macros + #((src.scan and src.scan.macros) or {}) end local n_err, n_warn, n_info = 0, 0, 0 ---@type integer, integer, integer - for _, f in ipairs(view.findings or {}) do ---@type integer, CheckFinding + for _, f in ipairs(view.findings or {}) do ---@type integer, Finding if f.kind == "error" then n_err = n_err + 1 elseif f.kind == "warning" then n_warn = n_warn + 1 else n_info = n_info + 1 diff --git a/scripts/passes/scan_source.lua b/scripts/passes/scan_source.lua index 4208ee0..c7b6e74 100644 --- a/scripts/passes/scan_source.lua +++ b/scripts/passes/scan_source.lua @@ -118,6 +118,7 @@ local parse_enum_int_literal ---@type fun(text: string, start: integer): (intege --- @field ctx_atom string|nil --- @field phase string|nil --- @field info_line integer +--- @field errors string[]|nil -- parse-time atom_info errors --- @class BindsEntry --- @field line integer diff --git a/scripts/passes/static_analysis.lua b/scripts/passes/static_analysis.lua index db781fa..621936b 100644 --- a/scripts/passes/static_analysis.lua +++ b/scripts/passes/static_analysis.lua @@ -97,7 +97,7 @@ local OUTPUT_EXTENSION = ".static_analysis.txt" ---@type string -- Type declarations -- ════════════════════════════════════════════════════════════════════════════ --- SourceFile, PassCtx, PassResult: see ps1_meta.lua +-- SourceFile, PassCtx, PassResult, Finding: see ps1_meta.lua --- @alias CheckName string -- "transfer_hazards" | "control_transfer_delay_slot_use" | "mac_yield_uniformity" | "yield_load_tail_pairing" | "abi_handoff" | "gpu_portstore_shape" | "per_atom_cycle_budget" | "enum_alias_membership" | "atom_type_consistency" | "binds_no_substruct_deref" @@ -114,25 +114,18 @@ local OUTPUT_EXTENSION = ".static_analysis.txt" ---@type string --- @field ident string|nil -- Leading ident of the token (if any) --- @field kind string -- "n_words" | "mac_yield" | "gte_cmdw" | "mac_format" | "mac_gte_store" | "mac_insert_ot_tag" | "atom_label" | "atom_offset" | "other" ---- @class CheckFinding ---- @field line integer -- Source line of the finding ---- @field atom AtomName -- Atom this finding is for (or "") ---- @field check CheckName -- Check identifier ---- @field kind string -- "error" | "warning" | "info" ---- @field msg string -- Finding message - --- @class CheckRule --- @field name string ---- @field per_atom (fun(item: AtomEntry, pipe_ctx: PipeCtx, findings: CheckFinding[]): nil)|nil ---- @field per_source (fun(item: SourceFile, pipe_ctx: PipeCtx, findings: CheckFinding[]): nil)|nil ---- @field post (fun(item: nil, pipe_ctx: PipeCtx, findings: CheckFinding[]): nil)|nil ---- @field per_macro (fun(item: MacroEntry, pipe_ctx: PipeCtx, findings: CheckFinding[]): nil)|nil ---- @field per_skip_marker (fun(item: DebugSkipMarker, pipe_ctx: PipeCtx, findings: CheckFinding[]): nil)|nil +--- @field per_atom (fun(item: AtomEntry, pipe_ctx: PassScratch, findings: Finding[]): nil)|nil +--- @field per_source (fun(item: SourceFile, pipe_ctx: PassScratch, findings: Finding[]): nil)|nil +--- @field post (fun(item: nil, pipe_ctx: PassScratch, findings: Finding[]): nil)|nil +--- @field per_macro (fun(item: MacroEntry, pipe_ctx: PassScratch, findings: Finding[]): nil)|nil +--- @field per_skip_marker (fun(item: DebugSkipMarker, pipe_ctx: PassScratch, findings: Finding[]): nil)|nil --- @class AtomAnalysis --- @field atom AtomBody --- @field tokens Token[] -- Tokens in the atom body, annotated ---- @field findings CheckFinding[] -- Findings for this atom +--- @field findings Finding[] -- Findings for this atom --- @field total_cycles integer -- Sum of token cycle costs -- ════════════════════════════════════════════════════════════════════════════ @@ -174,9 +167,13 @@ end -- ════════════════════════════════════════════════════════════════════════════ -- ONE forward pass over `atom.paths.word_events` stamps the fields the --- abi / gpu / binds readers consume. Emission already sets is_yield / +-- abi / gpu / binds / yield readers consume. Emission already sets is_yield / -- is_load / is_branch; this pass re-stamps those and adds o_arg1 / o_arg2 / -- s_arg1 / mac_format_shape / R_TapePtr / R_PrimCursor finds. +-- Items are walked first to build bd_after (not a word_events walk). +-- The events ipairs then writes is_raw_yield_load / is_raw_yield_tail / is_yield +-- and the dense index lists yield_at / yield_load_at / yield_tail_at / load_at / branch_at / +-- store_at / o_arg_at / s_arg_at / imm_at / traffic_at / mfc2_at / ctc2_at / cfc2_at / gte_cmd_at. -- -- Each event has: -- ident — ev.encoder or ev.ident @@ -213,15 +210,15 @@ end --- @field s_arg1 string|nil -- Arg of S_() captures; nil for non-S_ tokens -- WordEvent, AtomPaths: see emission_model.lua (AtomPaths augmented below) --- PipeCtx: see annotation.lua (augmented below) +-- PassScratch: see ps1_meta.lua -- ForwardState, GprLatticeSlot: see report.lua (augmented below) -- AtomEntry, SourceScan, AtomInfoEntry, BindsEntry, TypeNameEntry, TypeField, -- AliasEntry, DebugSkipMarker, RegTypeDefault, RegTypeOverride: see scan_source.lua -- InstructionRow, InstructionValue, InstructionImm, GteCommandRow, GteCommandPort, -- GteCommandLatch, HardwareRelationRow, Cu2TransitionPolicy, GteCrAliasGroup, -- GtePackedSlotRelation: see duffle_isa.lua --- ComponentDef, Component: see components.lua --- ComponentBodyEntry, EmissionItem, EmissionMarker, InvocationRecord, BodyToken: see duffle_emit.lua +-- Component: see components.lua +-- EmissionItem, EmissionMarker, InvocationRecord, BodyToken: see duffle_emit.lua -- AutoRegPass: see auto_reg.lua -- Corpus, PassCtx, PassResult, PassOutputEntry, SourceFile: see ps1_meta.lua / duffle.lua @@ -283,18 +280,18 @@ end --- @class ValidateResult --- @field atoms AtomEntry[] ---- @field findings CheckFinding[] ---- @field errors CheckFinding[] ---- @field warnings CheckFinding[] ---- @field info CheckFinding[] +--- @field findings Finding[] +--- @field errors Finding[] +--- @field warnings Finding[] +--- @field info Finding[] --- @field summaries ScanSummary[] --- @class StaticAnalysisDirResult --- @field atoms AtomEntry[] ---- @field findings CheckFinding[] ---- @field errors CheckFinding[] ---- @field warnings CheckFinding[] ---- @field info CheckFinding[] +--- @field findings Finding[] +--- @field errors Finding[] +--- @field warnings Finding[] +--- @field info Finding[] --- @field summaries ScanSummary[] --- @field sources SourceFile[] @@ -339,7 +336,22 @@ end --- @field unknown_macros string[]|nil --- @field forward_state ForwardState|nil --- @field relations RelationTouch[]|nil ---- @field hazards CheckFinding[]|nil +--- @field hazards table[]|nil -- diagnostic bag; extras live on the entry, not on Finding +--- @field yield_at integer[]|nil -- 1-based word_events indices of yield sites +--- @field yield_load_at integer[]|nil -- 1-based indices of mac_yield_load / raw handshake loads +--- @field yield_tail_at integer[]|nil -- 1-based indices of mac_yield_tail / raw handshake tails +--- @field load_at integer[]|nil -- 1-based indices of isa.kind == "load" +--- @field branch_at integer[]|nil -- 1-based indices of branch/jump/call with a delay slot +--- @field store_at integer[]|nil -- 1-based indices of store_word / store_half / store_byte / gte_sw +--- @field o_arg_at integer[]|nil -- 1-based indices with a stamped O_(type, field) +--- @field s_arg_at integer[]|nil -- 1-based indices with a stamped S_(type) +--- @field imm_at integer[]|nil -- 1-based indices whose encoder has InstructionImm rules +--- @field traffic_at integer[]|nil -- 1-based indices that participate in GPR traffic +--- @field mfc2_at integer[]|nil -- 1-based indices of gte_mv_from_data_r +--- @field ctc2_at integer[]|nil -- 1-based indices of gte_mv_to_ctrl_r +--- @field cfc2_at integer[]|nil -- 1-based indices of gte_mv_from_ctrl_r +--- @field gte_cmd_at integer[]|nil -- 1-based indices of gte_cmdw_* commands +--- @field nop_at integer[]|nil -- 1-based indices of nop --- @class AtomEntry --- @field paths AtomPaths|nil @@ -347,53 +359,7 @@ end --- @field source_path string|nil --- @field info AtomInfoEntry|nil ---- @class PipeCtx ---- @field info_by_atom table|nil ---- @field binds_index table|nil ---- @field unknown_seen table|nil -- bag: macro name -> first atom line ---- @field atoms AtomEntry[]|nil ---- @field types table|nil ---- @field atom_infos_list AtomInfoEntry[]|nil ---- @field register_alias_registry table|nil ---- @field type_name_registry table|nil ---- @field components_by_name table|nil ---- @field atoms_by_name table|nil ---- @field tape_chains table|nil ---- @field source_order SourceFile[]|nil ---- @field component_atom_infos AtomInfoEntry[]|nil ---- @field atom_infos_all AtomInfoEntry[]|nil ---- @field component_body_index table|nil ---- @field gte_cr_alias_groups GteCrAliasGroup[]|nil ---- @field line_for_word_event (fun(ev: WordEvent): integer)|nil - ---- @class CheckFinding ---- @field source string|nil ---- @field relation_id string|nil ---- @field semantic string|nil ---- @field direction string|nil ---- @field producer_destination string|nil ---- @field producer_word integer|nil ---- @field producer_line integer|nil ---- @field producer_source string|nil ---- @field consumer_word integer|nil ---- @field consumer_token string|nil ---- @field gap integer|nil ---- @field required integer|nil ---- @field evidence_confidence string|nil ---- @field evidence_source string|nil ---- @field target_state string|nil ---- @field status_register integer|nil ---- @field status_value integer|nil ---- @field producer_command string|nil ---- @field nop_classification string|nil ---- @field nop_word_index integer|nil ---- @field retired_relation string|nil ---- @field slot_kind string|nil ---- @field command string|nil ---- @field role string|nil ---- @field actual_register string|nil ---- @field expected_register string|nil ---- @field relation HardwareRelationRow|nil +-- PassScratch: see ps1_meta.lua --- @class ForwardState --- @field pending PendingProducer[]|nil @@ -436,11 +402,38 @@ local S_PATTERN = "S_%(([%w_]+)%s*%)" ---@type string -- jump_rel is already INSTRUCTION.kind == "branch". Branch flags come from duffle.instr(ident).kind. ---- @param events WordEvent[]|nil +--- @param atom AtomEntry|nil --- @return nil -local function stamp_event_fields(events) - local nop_run = 0 ---@type integer - for _, ev in ipairs(events or {}) do ---@type integer, WordEvent +local function stamp_event_fields(atom) + local paths = atom and atom.paths or nil ---@type AtomPaths|nil + local events = paths and paths.word_events or {} ---@type WordEvent[] + local items = paths and paths.items or {} ---@type EmissionItem[] + local bd_after = {} ---@type table + for idx, it in ipairs(items) do ---@type integer, EmissionItem + if it.kind == "word" then + local nxt = items[idx + 1] ---@type EmissionItem|nil + if nxt and nxt.kind == "delay" and nxt.name == "BdSlot_" then + bd_after[it.i] = true + end + end + end + local nop_run = 0 ---@type integer + local yield_at = {} ---@type integer[] + local yield_load_at = {} ---@type integer[] + local yield_tail_at = {} ---@type integer[] + local load_at = {} ---@type integer[] + local branch_at = {} ---@type integer[] + local store_at = {} ---@type integer[] + local o_arg_at = {} ---@type integer[] + local s_arg_at = {} ---@type integer[] + local imm_at = {} ---@type integer[] + local traffic_at = {} ---@type integer[] + local mfc2_at = {} ---@type integer[] + local ctc2_at = {} ---@type integer[] + local cfc2_at = {} ---@type integer[] + local gte_cmd_at = {} ---@type integer[] + local nop_at = {} ---@type integer[] + for ev_idx, ev in ipairs(events) do ---@type integer, WordEvent local ident = ev.encoder or ev.ident or "" ---@type string local haystack = (ev.call_text or "") .. " " .. table.concat(ev.args or {}, ",") ---@type string local is_delay_marker = false ---@type boolean @@ -528,43 +521,103 @@ local function stamp_event_fields(events) ev.o_arg1 = o_arg1 ev.o_arg2 = o_arg2 ev.s_arg1 = s_arg1 - -- is_raw_yield_load / is_raw_yield_tail stamped after items are walked. - if nop_words > 0 then nop_run = nop_run + nop_words - else nop_run = 0 - end - end -end - --- Raw handshake: load_word(R_AtomJmp, R_TapePtr, _) … jump_reg(R_AtomJmp), BdSlot_ . --- BdSlot_ may hold nop or useful work. No extra annotation. ---- @param atom AtomEntry ---- @return nil -local function stamp_raw_yield_handshake(atom) - local items = atom.paths and atom.paths.items or {} ---@type EmissionItem[] - local events = atom.paths and atom.paths.word_events or {} ---@type WordEvent[] - local bd_after = {} ---@type table -- bag - for idx, it in ipairs(items) do ---@type integer, EmissionItem - if it.kind == "word" then - local nxt = items[idx + 1] ---@type EmissionItem|nil - if nxt and nxt.kind == "delay" and nxt.name == "BdSlot_" then - bd_after[it.i] = true - end - end - end - for _, ev in ipairs(events) do ---@type integer, WordEvent - local enc = ev.encoder or ev.ident or "" ---@type string - local args = ev.args or {} ---@type string[] - local rct = ev.root_call_text or ev.call_text or "" ---@type string + -- Raw handshake: load_word(R_AtomJmp, R_TapePtr, _) … jump_reg(R_AtomJmp), BdSlot_ . + -- BdSlot_ may hold nop or useful work. No extra annotation. + local args = ev.args or {} ---@type string[] + local rct = ev.root_call_text or ev.call_text or "" ---@type string local from_mac = type(rct) == "string" and rct:sub(1, #"mac_yield") == "mac_yield" ---@type boolean - if enc == "jump_reg" and args[1] == "R_AtomJmp" and bd_after[ev.i] and not from_mac then + if ident == "jump_reg" and args[1] == "R_AtomJmp" and bd_after[ev.i] and not from_mac then ev.is_raw_yield_tail = true ev.is_yield = true end - if (enc == "load_word" or ev.is_load) + if (ident == "load_word" or ev.is_load) and args[1] == "R_AtomJmp" and args[2] == "R_TapePtr" and not from_mac then ev.is_raw_yield_load = true end + if nop_words > 0 then nop_run = nop_run + nop_words + else nop_run = 0 + end + + if ev.is_yield then + yield_at[#yield_at + 1] = ev_idx + else + local lead = tostring(ev.call_text or ""):match("^([%w_]+)") ---@type string|nil + or tostring(ev.root_call_text or ""):match("^([%w_]+)") + if lead == "mac_yield" or lead == "mac_yield_tail" then + yield_at[#yield_at + 1] = ev_idx + end + end + if ev.is_load then + load_at[#load_at + 1] = ev_idx + end + if ev.is_raw_yield_load + or ident == "mac_yield_load" or ident == "yield_load" + or rct:sub(1, #"mac_yield_load") == "mac_yield_load" + then + yield_load_at[#yield_load_at + 1] = ev_idx + end + if ev.is_raw_yield_tail + or ident == "mac_yield_tail" or ident == "yield_tail" + or rct:sub(1, #"mac_yield_tail") == "mac_yield_tail" + then + yield_tail_at[#yield_tail_at + 1] = ev_idx + end + if isa and (isa.kind == "branch" or isa.kind == "jump" or isa.kind == "call") + and isa.delay_slot ~= false + then + branch_at[#branch_at + 1] = ev_idx + end + if o_arg1 then + o_arg_at[#o_arg_at + 1] = ev_idx + end + if s_arg1 then + s_arg_at[#s_arg_at + 1] = ev_idx + end + if ident == "store_word" or ident == "store_half" or ident == "store_byte" or ident == "gte_sw" then + store_at[#store_at + 1] = ev_idx + end + if isa and isa.imm then + imm_at[#imm_at + 1] = ev_idx + end + if ident:sub(1, 4) ~= "mac_" + and not (duffle.DELAY_MARKERS and duffle.DELAY_MARKERS[ident]) + and ident ~= "nop" and ident ~= "atom_label" and ident ~= "atom_offset" + then + traffic_at[#traffic_at + 1] = ev_idx + end + if ident == "gte_mv_from_data_r" then + mfc2_at[#mfc2_at + 1] = ev_idx + end + if ident == "gte_mv_to_ctrl_r" then + ctc2_at[#ctc2_at + 1] = ev_idx + end + if ident == "gte_mv_from_ctrl_r" then + cfc2_at[#cfc2_at + 1] = ev_idx + end + if ident:sub(1, 9) == "gte_cmdw_" then + gte_cmd_at[#gte_cmd_at + 1] = ev_idx + end + if ident == "nop" then + nop_at[#nop_at + 1] = ev_idx + end + end + if paths then + paths.yield_at = yield_at + paths.yield_load_at = yield_load_at + paths.yield_tail_at = yield_tail_at + paths.load_at = load_at + paths.branch_at = branch_at + paths.store_at = store_at + paths.o_arg_at = o_arg_at + paths.s_arg_at = s_arg_at + paths.imm_at = imm_at + paths.traffic_at = traffic_at + paths.mfc2_at = mfc2_at + paths.ctc2_at = ctc2_at + paths.cfc2_at = cfc2_at + paths.gte_cmd_at = gte_cmd_at + paths.nop_at = nop_at end end @@ -1097,7 +1150,7 @@ local function analyze_hardware_relations(atom) atom.paths.relations = {} atom.paths.hazards = {} - local hazards = atom.paths.hazards ---@type CheckFinding[] + local hazards = atom.paths.hazards ---@type table[] local relations = atom.paths.relations ---@type RelationTouch[] local pending = forward.pending ---@type PendingProducer[] @@ -1211,7 +1264,7 @@ local function analyze_hardware_relations(atom) ), } elseif required ~= nil and gap < required then - local payload = { ---@type CheckFinding + local payload = { ---@type table -- Finding plus hazard extras check = "transfer_hazards", kind = prod.violation_kind or "error", atom = atom.name, @@ -1388,12 +1441,12 @@ end -- ───────────────────────────────────────────────────────────────────────── --- @param atom AtomEntry ---- @param _pipe_ctx PipeCtx ---- @param findings CheckFinding[] +--- @param _pipe_ctx PassScratch +--- @param findings Finding[] --- @return nil local function check_transfer_hazards(atom, _pipe_ctx, findings) - local hazards = atom.paths and atom.paths.hazards or {} ---@type CheckFinding[] - for _, hazard in ipairs(hazards) do ---@type integer, CheckFinding + local hazards = atom.paths and atom.paths.hazards or {} ---@type table[] + for _, hazard in ipairs(hazards) do ---@type integer, table findings[#findings + 1] = hazard end end @@ -1407,14 +1460,14 @@ end -- ───────────────────────────────────────────────────────────────────────── --- @param atom AtomEntry ---- @param _pipe_ctx PipeCtx ---- @param findings CheckFinding[] +--- @param _pipe_ctx PassScratch +--- @param findings Finding[] --- @return nil local function check_gte_input_latch(atom, _pipe_ctx, findings) - local hazards = atom.paths and atom.paths.hazards or {} ---@type CheckFinding[] - for _, hazard in ipairs(hazards) do ---@type integer, CheckFinding + local hazards = atom.paths and atom.paths.hazards or {} ---@type table[] + for _, hazard in ipairs(hazards) do ---@type integer, table if hazard.relation_id == "command_latch_input" then - local payload = {} ---@type CheckFinding + local payload = {} ---@type table -- Finding plus hazard extras for k, v in pairs(hazard) do payload[k] = v end ---@type string, string|integer|boolean|nil payload.check = "gte_input_latch" -- Surface `producer_command` on the emitted payload: @@ -1445,60 +1498,61 @@ end -- ───────────────────────────────────────────────────────────────────────── --- @param atom AtomEntry ---- @param _pipe_ctx PipeCtx ---- @param findings CheckFinding[] +--- @param _pipe_ctx PassScratch +--- @param findings Finding[] --- @return nil local function check_gte_role_mismatch(atom, _pipe_ctx, findings) local forward = atom.paths and atom.paths.forward_state ---@type ForwardState if not forward or not forward.post_command_roles then return end - local events = atom.paths.word_events or {} ---@type WordEvent[] + local events = atom.paths.word_events or {} ---@type WordEvent[] + local mfc2_at = atom.paths.mfc2_at or {} ---@type integer[] - -- For each word event whose encoder is `gte_mv_from_data_r`, look up the register being read in `forward_state.post_command_roles`. + -- Stamped MFC2 sites. Look up the register in `forward_state.post_command_roles`. -- If a role is set, the reader's register must match the role's register (the registered "latest_" target). - for _, ev in ipairs(events) do ---@type integer, WordEvent - local ev_ident = ev.encoder ---@type string - if ev_ident == "gte_mv_from_data_r" then - local args = ev.args or {} ---@type string[] - local reg = args[2] ---@type string - -- Find any post-command `latest_screen_xy` role entry recorded by a prior command. - -- The newest projected screen coordinate is recorded under the command name. - -- Reading from C2_SXY0 (the older projection slot) when a `latest_screen_xy` role was set to C2_SXY2 by RTPS / RTPT is a semantic mismatch. - local latest_screen_xy_entry = nil ---@type PostCommandRole|nil - for r, e in pairs(forward.post_command_roles or {}) do ---@type string, PostCommandRole - if e.role == "latest_screen_xy" then - latest_screen_xy_entry = e - break - end - end - if reg and latest_screen_xy_entry then - -- The reader picked C2_SXY0 but the latest_screen_xy role was set to C2_SXY2 by the prior command. - -- This is a semantic mismatch. - if reg ~= latest_screen_xy_entry.command_register - and (reg == "C2_SXY0" or reg == "C2_SXY1") then - local ev_line = line_for_word_event(ev) ---@type integer - findings[#findings + 1] = { - check = "gte_role_mismatch", - kind = "warning", - atom = atom.name, - line = ev_line, - source = ev.def_path or ev.source or "", - relation_id = "result_role_mismatch", - semantic = "result_position", - command = latest_screen_xy_entry.command, - role = latest_screen_xy_entry.role, - actual_register = reg, - expected_register = "C2_SXY2", - producer_word = latest_screen_xy_entry.producer_word, - producer_line = latest_screen_xy_entry.producer_line, - msg = string.format("%s at line %d: reading %s after %s but the %s role is C2_SXY2 (not %s)" - , atom.name, ev_line - , reg, latest_screen_xy_entry.command - , latest_screen_xy_entry.role - , reg), - } - end + for _, ev_idx in ipairs(mfc2_at) do ---@type integer, integer + local ev = events[ev_idx] ---@type WordEvent|nil + if not ev then goto continue_mfc2 end + local args = ev.args or {} ---@type string[] + local reg = args[2] ---@type string + -- Find any post-command `latest_screen_xy` role entry recorded by a prior command. + -- The newest projected screen coordinate is recorded under the command name. + -- Reading from C2_SXY0 (the older projection slot) when a `latest_screen_xy` role was set to C2_SXY2 by RTPS / RTPT is a semantic mismatch. + local latest_screen_xy_entry = nil ---@type PostCommandRole|nil + for r, e in pairs(forward.post_command_roles or {}) do ---@type string, PostCommandRole + if e.role == "latest_screen_xy" then + latest_screen_xy_entry = e + break end end + if reg and latest_screen_xy_entry then + -- The reader picked C2_SXY0 but the latest_screen_xy role was set to C2_SXY2 by the prior command. + -- This is a semantic mismatch. + if reg ~= latest_screen_xy_entry.command_register + and (reg == "C2_SXY0" or reg == "C2_SXY1") then + local ev_line = line_for_word_event(ev) ---@type integer + findings[#findings + 1] = { + check = "gte_role_mismatch", + kind = "warning", + atom = atom.name, + line = ev_line, + source = ev.def_path or ev.source or "", + relation_id = "result_role_mismatch", + semantic = "result_position", + command = latest_screen_xy_entry.command, + role = latest_screen_xy_entry.role, + actual_register = reg, + expected_register = "C2_SXY2", + producer_word = latest_screen_xy_entry.producer_word, + producer_line = latest_screen_xy_entry.producer_line, + msg = string.format("%s at line %d: reading %s after %s but the %s role is C2_SXY2 (not %s)" + , atom.name, ev_line + , reg, latest_screen_xy_entry.command + , latest_screen_xy_entry.role + , reg), + } + end + end + ::continue_mfc2:: end end @@ -1524,196 +1578,141 @@ end -- ───────────────────────────────────────────────────────────────────────── --- @param atom AtomEntry ---- @param _pipe_ctx PipeCtx ---- @param findings CheckFinding[] +--- @param _pipe_ctx PassScratch +--- @param findings Finding[] --- @return nil local function check_hazard_nop_use(atom, _pipe_ctx, findings) - local forward = atom.paths and atom.paths.forward_state ---@type ForwardState - local events = atom.paths.word_events or {} ---@type WordEvent[] - -- GPR effects table used to resolve load destinations when classifying load-delay-slot nops. - if not events or #events == 0 then return end - -- Runtime-helper atoms / components (e.g. tape_exit, ac_yield) carry `debug_skip = true` from the bare - -- `atom_dbg_skip` marker; their structural nops are part of the fixed handshake and not author choices. + local paths = atom.paths or {} ---@type AtomPaths + local events = paths.word_events or {} ---@type WordEvent[] + local nop_at = paths.nop_at or {} ---@type integer[] + if #events == 0 or #nop_at == 0 then return end if is_runtime_helper(atom) then return end - -- The walker does not currently snapshot the pending state per event; we replay the same forward walk cheaply here. - -- The replay is observation-only (no staging); the only output is one finding per non-BD-slot nop with its classification. - local pending_snapshot = {} ---@type PendingProducer[] - local prev_ev = nil ---@type WordEvent|nil - for event_idx, ev in ipairs(events) do ---@type integer, WordEvent - local ev_ident = ev.encoder or "" ---@type string - local ev_args = ev.args or {} ---@type string[] - local ev_word = ev.i or 0 ---@type integer - local ev_line = line_for_word_event(ev) ---@type integer + local relations = paths.relations or {} ---@type RelationTouch[] + local pending = (paths.forward_state and paths.forward_state.pending) or {} ---@type PendingProducer[] - -- Classify the nop BEFORE its event is applied to the pending state. - if ev_ident == "nop" and prev_ev ~= nil then - -- Skip BD-slot nops that are exclusively owned by control_transfer_delay_slot_use - -- (the nop after a branch/jump — covered by that check separately). - -- Every BD-slot nop is structural; this check never reports on it. - -- (The earlier `if not suppressed then is_bd_slot = true end` form inverted the suppression - the `mac_yield()` handshake's `jump_reg(R_AtomJmp)` was incorrectly flagged.) - local prev_ident = prev_ev.encoder or "" ---@type string - local prev_isa = duffle.instr(prev_ident) ---@type InstructionRow|nil - local is_bd_slot = prev_isa ---@type boolean|nil - and (prev_isa.kind == "branch" or prev_isa.kind == "jump" or prev_isa.kind == "call") - and prev_isa.delay_slot ~= false - if not is_bd_slot then - -- Find a pending modeled relation that this nop would retire. - local retired = nil ---@type PendingProducer|nil - for _, prod in ipairs(pending_snapshot) do ---@type integer, PendingProducer - if prod.required and (prod.word + prod.required + 1) > ev_word then - retired = prod - break - end - end - if retired then - -- Look ahead for the would-be consumer (the next emitted command or read after the nop that the relation would retire). - -- For an MTC2 -> command relation, the consumer is the next GTE command after the nop. - local would_be_consumer = nil ---@type string|nil - for _, future_ev in ipairs(events) do ---@type integer, WordEvent - local f_word = future_ev.i or future_ev.word or 0 ---@type integer - if f_word > ev_word then - local f_ident = future_ev.encoder or future_ev.ident or "" ---@type string - local f_args = future_ev.args or {} ---@type string[] - local f_gte = duffle.gte(f_ident) ---@type GteCommandRow|nil - local canonical = duffle.gte_canon(f_ident) ---@type string - if canonical:sub(1, 9) == "gte_cmdw_" then - local cmd_inputs = f_gte and f_gte.inputs ---@type string[]|nil - if cmd_inputs then - for _, in_reg in ipairs(cmd_inputs) do ---@type integer, string - if in_reg == retired.destination then - would_be_consumer = f_ident - break - end - end - end - elseif f_ident == "gte_mv_to_data_r" or f_ident == "gte_mv_to_ctrl_r" then - if f_args[2] == retired.destination then - would_be_consumer = f_ident - end - end - if would_be_consumer then break end - end - end - findings[#findings + 1] = { - check = "hazard_nop_use", - kind = "info", - atom = atom.name, - line = ev_line, - source = ev.def_path or ev.source or "", - nop_classification = "modeled-required", - nop_word_index = ev_word, - retired_relation = retired.relation.id, - producer_destination = retired.destination, - consumer_token = would_be_consumer or "", - msg = string.format("%s at line %d: nop at word %d is modeled-required (retires %s for %s)" - , atom.name, ev_line, ev_word, retired.relation.id, retired.destination - ), - } - else - -- Track the slot_kind so the BD-separation case can assert the mac_yield handshake is still suppressed. - local slot_kind = "plain" ---@type string - -- MIPS load-delay slot: a `load_*` wrote a register in the previous slot, and the result is unavailable for 1 cycle. - -- This `nop` is structurally required; classifying it as `modeled-required` is the correct signal - -- (removing it would make the following instruction read the OLD value of the loaded register, a load-use hazard). - -- The `load_delay_violations` check (Concern 3) catches the actual read-side error; here we suppress the `modeled-redundant` misclassification. - local is_load_delay = prev_isa and prev_isa.kind == "load" ---@type boolean|nil - if is_load_delay then - -- Determine the destination register from the load's `writes` field. - local prev_writes = prev_isa.writes or {} ---@type integer[] - local dest_pos = prev_writes[1] ---@type integer|nil - local load_dest = dest_pos and (gpr_identity(prev_ev, dest_pos) or (prev_ev.args or {})[dest_pos]) or "" ---@type string - local authored = dest_pos and (prev_ev.args or {})[dest_pos] or load_dest ---@type string - local shown = authored ---@type string - if type(load_dest) == "string" and load_dest:sub(1, 7) == "reguse:" then - local slot = load_dest:match("([^:]+)$") ---@type GprLatticeSlot|nil - if slot then shown = authored .. " (slot " .. slot .. ")" end - end - findings[#findings + 1] = { - check = "hazard_nop_use", - kind = "info", - atom = atom.name, - line = ev_line, - source = ev.def_path or ev.source or "", - nop_classification = "modeled-required", - nop_word_index = ev_word, - retired_relation = "load_delay_slot", - producer_destination = load_dest, - consumer_token = "", - msg = string.format("%s at line %d: nop at word %d is modeled-required (load-delay slot for %s)" - , atom.name, ev_line, ev_word, shown - ), - } - else - findings[#findings + 1] = { - check = "hazard_nop_use", - kind = "info", - atom = atom.name, - line = ev_line, - source = ev.def_path or ev.source or "", - nop_classification = "modeled-redundant", - nop_word_index = ev_word, - retired_relation = nil, - slot_kind = slot_kind, - msg = string.format("%s at line %d: nop at word %d is modeled-redundant (no pending modeled relation)" - , atom.name, ev_line, ev_word - ), - } - end + --- @param lists integer[][] + --- @param nop_idx integer + --- @return string + local function consumer_after(lists, nop_idx) + local best_i = nil ---@type integer|nil + local ident = nil ---@type string|nil + for _, list in ipairs(lists) do ---@type integer, integer[] + for _, i in ipairs(list) do ---@type integer, integer + if i > nop_idx and (not best_i or i < best_i) then + best_i = i + local ev = events[i] ---@type WordEvent|nil + ident = ev and (ev.encoder or ev.ident) or nil end end end + return ident or "" + end - -- Update the pending snapshot for the next iteration. - -- The replay is observation-only; we mirror the walker's staging - -- behavior for MTC2 / CTC2 / LWC2 / MFC2 / CFC2 / MFC0 / command_latch. - local canonical = duffle.gte_canon(ev_ident) ---@type string - if ev_ident == "gte_mv_to_data_r" or ev_ident == "gte_mv_to_ctrl_r" then - local relations_table = duffle.HARDWARE_RELATIONS or {} ---@type HardwareRelationRow[] - for _, row in ipairs(relations_table) do ---@type integer, HardwareRelationRow - if row.token == ev_ident and row.stage ~= false then - local dest_arg = row.writes and row.writes.arg ---@type integer|nil - local destination = dest_arg and (gpr_identity(ev, dest_arg) or ev_args[dest_arg]) or nil ---@type string|nil - if destination and (not row.destination_match or row.destination_match == destination) then - local required = row.visibility and row.visibility.required ---@type integer|nil - if required == nil and not (row.visibility and row.visibility.kind == "unknown_consumer") then - required = 1 - end - pending_snapshot[#pending_snapshot + 1] = { - relation = row, - destination = destination, - word = ev_word, - required = required, - } - end - end + local consumer_lists = { ---@type integer[][] + paths.gte_cmd_at or {}, + paths.ctc2_at or {}, + paths.mfc2_at or {}, + paths.cfc2_at or {}, + } + + for _, idx in ipairs(nop_at) do ---@type integer, integer + local ev = events[idx] ---@type WordEvent|nil + if not ev then goto continue_nop end + local prev = events[idx - 1] ---@type WordEvent|nil + if not prev then goto continue_nop end + local ev_word = ev.i or 0 ---@type integer + local ev_line = line_for_word_event(ev) ---@type integer + local prev_ident = prev.encoder or "" ---@type string + local prev_isa = duffle.instr(prev_ident) ---@type InstructionRow|nil + local is_bd_slot = prev_isa ---@type boolean|nil + and (prev_isa.kind == "branch" or prev_isa.kind == "jump" or prev_isa.kind == "call") + and prev_isa.delay_slot ~= false + if is_bd_slot then goto continue_nop end + + local covered_id = nil ---@type string|nil + local covered_dest = nil ---@type string|nil + for _, rel in ipairs(relations) do ---@type integer, RelationTouch + local pw = rel.producer_word ---@type integer|nil + local cw = rel.consumer_word ---@type integer|nil + if pw and pw < ev_word and (cw == nil or cw > ev_word) then + covered_id = rel.relation_id + covered_dest = rel.destination + break end - elseif canonical:sub(1, 9) == "gte_cmdw_" then - -- Command: stage post-command latch relations (same as the walker). - local cmd_latches = (duffle.gte(canonical) or {}).latch ---@type GteCommandLatch[]|nil - if cmd_latches then - for _, latch in ipairs(cmd_latches) do ---@type integer, GteCommandLatch - if latch.register and latch.required then - pending_snapshot[#pending_snapshot + 1] = { - relation = { - id = "command_latch_input", - semantic = "command_latch", - consumer = "overwrite_same_dest", - }, - destination = latch.register, - word = ev_word, - required = latch.required, - } - end + end + if not covered_id then + for _, prod in ipairs(pending) do ---@type integer, PendingProducer + if prod.required and prod.word and prod.word < ev_word + and (prod.word + prod.required + 1) > ev_word + then + covered_id = prod.relation and prod.relation.id + covered_dest = prod.destination + break end end end - - prev_ev = ev + if covered_id then + findings[#findings + 1] = { + check = "hazard_nop_use", + kind = "info", + atom = atom.name, + line = ev_line, + source = ev.def_path or ev.source or "", + nop_classification = "modeled-required", + nop_word_index = ev_word, + retired_relation = covered_id, + producer_destination = covered_dest, + consumer_token = consumer_after(consumer_lists, idx), + msg = string.format("%s at line %d: nop at word %d is modeled-required (retires %s for %s)" + , atom.name, ev_line, ev_word, tostring(covered_id), tostring(covered_dest) + ), + } + elseif prev_isa and prev_isa.kind == "load" then + local prev_writes = prev_isa.writes or {} ---@type integer[] + local dest_pos = prev_writes[1] ---@type integer|nil + local load_dest = dest_pos and (gpr_identity(prev, dest_pos) or (prev.args or {})[dest_pos]) or "" ---@type string + local authored = dest_pos and (prev.args or {})[dest_pos] or load_dest ---@type string + local shown = authored ---@type string + if type(load_dest) == "string" and load_dest:sub(1, 7) == "reguse:" then + local slot = load_dest:match("([^:]+)$") ---@type string|nil + if slot then shown = authored .. " (slot " .. slot .. ")" end + end + findings[#findings + 1] = { + check = "hazard_nop_use", + kind = "info", + atom = atom.name, + line = ev_line, + source = ev.def_path or ev.source or "", + nop_classification = "modeled-required", + nop_word_index = ev_word, + retired_relation = "load_delay_slot", + producer_destination = load_dest, + consumer_token = "", + msg = string.format("%s at line %d: nop at word %d is modeled-required (load-delay slot for %s)" + , atom.name, ev_line, ev_word, shown + ), + } + else + findings[#findings + 1] = { + check = "hazard_nop_use", + kind = "info", + atom = atom.name, + line = ev_line, + source = ev.def_path or ev.source or "", + nop_classification = "modeled-redundant", + nop_word_index = ev_word, + retired_relation = nil, + slot_kind = "plain", + msg = string.format("%s at line %d: nop at word %d is modeled-redundant (no pending modeled relation)" + , atom.name, ev_line, ev_word + ), + } + end + ::continue_nop:: end end --- ───────────────────────────────────────────────────────────────────────── -- Check #1c: control-transfer delay-slot use. -- -- Reads `atom.paths.word_events` (the semantic emitted-word stream from `passes/emission_model.lua`). @@ -1737,16 +1736,19 @@ end -- ───────────────────────────────────────────────────────────────────────── --- @param atom AtomEntry ---- @param pipe_ctx PipeCtx ---- @param findings CheckFinding[] +--- @param pipe_ctx PassScratch +--- @param findings Finding[] --- @return nil local function check_control_transfer_delay_slot_use(atom, pipe_ctx, findings) - local events = atom.paths.word_events or {} ---@type WordEvent[] + local events = atom.paths.word_events or {} ---@type WordEvent[] + local branch_at = atom.paths.branch_at or {} ---@type integer[] if not events or #events == 0 then return end -- Runtime-helper atoms / components (e.g. tape_exit, ac_yield) carry `debug_skip = true` from the bare -- `atom_dbg_skip` marker; their structural BD slots are part of the fixed handshake. if is_runtime_helper(atom) then return end - for event_idx, event in ipairs(events) do ---@type integer, WordEvent + for _, event_idx in ipairs(branch_at) do ---@type integer, integer + local event = events[event_idx] ---@type WordEvent|nil + if not event then goto continue_branch end -- Canonical word_events use `encoder` as the leading identifier of the emitting token). -- Focused inputs may supply `ident` when constructing isolated events. local event_ident = event.encoder or event.ident ---@type string @@ -1777,6 +1779,7 @@ local function check_control_transfer_delay_slot_use(atom, pipe_ctx, findings) end end end + ::continue_branch:: end end @@ -1800,8 +1803,8 @@ end --- The check is purely structural; it does not consult the GPR-value lattice --- (no constant propagation needed for load-delay detection — the volatility window is unconditional). --- @param atom AtomEntry ---- @param pipe_ctx PipeCtx ---- @param findings CheckFinding[] +--- @param pipe_ctx PassScratch +--- @param findings Finding[] --- @return nil local function check_load_delay_slots(atom, pipe_ctx, findings) -- The load-delay check applies to every atom and component body, including debug-skipped components (`ac_*` and `atom_dbg_skip MipsAtom_(...)`). @@ -1809,17 +1812,15 @@ local function check_load_delay_slots(atom, pipe_ctx, findings) -- `atom_proc` atoms have full bodies with loads that need delay slots, so the check applies to them too. local p = atom.paths or {} ---@type AtomPaths if atom.kind ~= "atom" and atom.kind ~= "atom_proc" then return end - local events = p.word_events or {} ---@type WordEvent[] - if #events == 0 then return end + local events = p.word_events or {} ---@type WordEvent[] + local load_at = p.load_at or {} ---@type integer[] + if #events == 0 or #load_at == 0 then return end local read_positions = duffle.OPERAND_READ_POSITIONS or {} ---@type table - -- volatile_until[reg] = 1-based word_events index; the slot AFTER which the register is safe. - -- `nil` means "not currently volatile". - local volatile_until = {} ---@type table -- Compute the "net reads" of an event: read-positions MINUS write-positions. -- A position that is BOTH read and written (e.g. `add_ui rt, rs, imm` where the duffle table lists position 1 as both. - -- See `duffle.OPERAND_READ_POSITIONS["add_ui"] = {1, 2}` and `INSTRUCTION_GPR_EFFECTS["add_ui"].writes = {1}` — + -- See `duffle.OPERAND_READ_POSITIONS["add_ui"] = {1, 2}` and `INSTRUCTION_GPR_EFFECTS["add_ui"].writes = {1}` — -- and for genuine RMW ops like `add rt, rs, rt` where position 1 IS both read+written) is not a "read" for load-delay purposes: -- The write shadows whatever value the register previously held. Only positions that are reads WITHOUT a co-occurring write to the same register count as net reads. --- @param event_ident string @@ -1840,56 +1841,47 @@ local function check_load_delay_slots(atom, pipe_ctx, findings) return net end - for event_idx, event in ipairs(events) do ---@type integer, WordEvent - local event_ident = event.encoder or event.ident ---@type string - local args = event.args or {} ---@type string[] - local event_isa = duffle.instr(event_ident) ---@type InstructionRow|nil - local is_load = event_isa and event_isa.kind == "load" ---@type boolean + -- Volatility is exactly one emitted slot. Iterate stamped loads and peek events[i+1]. + -- A load in the delay slot is skipped (the load's own argument list is not a separate consumer). + for _, event_idx in ipairs(load_at) do ---@type integer, integer + local event = events[event_idx] ---@type WordEvent|nil + local slot = events[event_idx + 1] ---@type WordEvent|nil + if not event or not slot then goto continue_load end + local slot_ident = slot.encoder or slot.ident ---@type string + local slot_isa = duffle.instr(slot_ident) ---@type InstructionRow|nil + local slot_is_load = slot.is_load or (slot_isa and slot_isa.kind == "load") ---@type boolean + if slot_is_load then goto continue_load end - -- (1) Is this event reading a register that's still volatile from a previous load? - -- Skip the load instruction itself (the load's own argument list may "read" its destination via `OPERAND_READ_POSITIONS`: - -- e.g. `addiu rt, rs, imm` lists position 1 (rt) as a "read", but rt is the destination; the within-load argument list is not a separate consumer). - -- Use `net_reads` to ignore RMW positions (write shadows read within the same instruction). - if not is_load then - for _, pos in ipairs(net_reads(event_ident, args)) do ---@type integer, integer - local reg = gpr_identity(event, pos) ---@type string - if reg then - local until_idx = volatile_until[reg] ---@type integer - if until_idx and event_idx <= until_idx then - local ev_line = line_for_word_event(event) ---@type integer - local authored = args[pos] or reg ---@type string - findings[#findings + 1] = { - atom = atom.name, - line = ev_line, - check = "load_delay_violation", - kind = "error", - msg = string.format("%s at line %d reads %s at word %d, but a prior load's " - .. "delay slot is not over until word %d; insert a `nop` between the " - .. "load and this instruction.", - atom.name, ev_line, authored, event_idx, until_idx), - } - end - end + local event_ident = event.encoder or event.ident ---@type string + local event_isa = duffle.instr(event_ident) ---@type InstructionRow|nil + local dests = {} ---@type table + if event_isa and event_isa.writes then + for _, pos in ipairs(event_isa.writes) do ---@type integer, integer + local reg = gpr_identity(event, pos) ---@type string|nil + if reg then dests[reg] = true end end end - -- (2) Update the volatile set based on what this event writes. - local effect = event_isa ---@type InstructionRow|nil - if effect and effect.writes then - for _, pos in ipairs(effect.writes) do ---@type integer, integer - local reg = gpr_identity(event, pos) ---@type string - if reg then - if is_load then - -- Load: destination volatile for exactly 1 slot (the delay slot). - volatile_until[reg] = event_idx + 1 - else - -- Non-load write to this register: overwrites shadow the load; the volatile state ends. - -- If another reader comes later, it sees the overwriter's value (or unknown), not the stale load value. - volatile_until[reg] = nil - end - end + local args = slot.args or {} ---@type string[] + local until_idx = event_idx + 1 ---@type integer + for _, pos in ipairs(net_reads(slot_ident, args)) do ---@type integer, integer + local reg = gpr_identity(slot, pos) ---@type string|nil + if reg and dests[reg] then + local ev_line = line_for_word_event(slot) ---@type integer + local authored = args[pos] or reg ---@type string + findings[#findings + 1] = { + atom = atom.name, + line = ev_line, + check = "load_delay_violation", + kind = "error", + msg = string.format("%s at line %d reads %s at word %d, but a prior load's " + .. "delay slot is not over until word %d; insert a `nop` between the " + .. "load and this instruction.", + atom.name, ev_line, authored, until_idx, until_idx), + } end end + ::continue_load:: end end @@ -1911,8 +1903,8 @@ end --- --- Uses the standard `(atom, pipe_ctx, findings)` signature; `pipe_ctx` is unused. --- @param atom AtomEntry ---- @param pipe_ctx PipeCtx ---- @param findings CheckFinding[] +--- @param pipe_ctx PassScratch +--- @param findings Finding[] --- @return nil local function check_mac_yield_uniformity(atom, pipe_ctx, findings) -- Runtime-helper atoms / components (e.g. tape_exit, ac_yield) carry `debug_skip = true` from the bare @@ -1928,8 +1920,9 @@ local function check_mac_yield_uniformity(atom, pipe_ctx, findings) -- Same reasoning -- it's a function returning a MipsAtom slice, invoked from a parent atom. -- -- The GTE pipeline-fill check applies to all 3 kinds (see check_gte_pipeline_fill). Only the mac_yield rule branches on kind. - local events = atom.paths.word_events or {} ---@type WordEvent[] - local n = #events ---@type integer + local events = atom.paths.word_events or {} ---@type WordEvent[] + local yield_at = atom.paths.yield_at or {} ---@type integer[] + local n = #events ---@type integer -- Emission sets is_yield on encoder mac_yield / mac_yield_tail. -- Expanded component words keep the call_text lead; count one call, not every word. @@ -1943,12 +1936,12 @@ local function check_mac_yield_uniformity(atom, pipe_ctx, findings) return lead == "mac_yield" or lead == "mac_yield_tail" end - local count = 0 ---@type integer - local last_idx = 0 ---@type integer - local seen_inv = {} ---@type table -- bag - for ev_idx = 1, n do ---@type integer - local ev = events[ev_idx] ---@type WordEvent - if event_is_yield(ev) then + local count = 0 ---@type integer + local last_idx = 0 ---@type integer + local seen_inv = {} ---@type table -- bag + for _, ev_idx in ipairs(yield_at) do ---@type integer, integer + local ev = events[ev_idx] ---@type WordEvent|nil + if ev and event_is_yield(ev) then local inv_id = ev.outermost_invocation_id ---@type string|integer if inv_id == nil or inv_id == 0 then inv_id = "w" .. tostring(ev.i or ev_idx) @@ -1990,14 +1983,16 @@ local function check_mac_yield_uniformity(atom, pipe_ctx, findings) elseif last_idx < n then -- 1 call, but not the last event. We DON'T fail if the post-event is just `nop` or `nop2`. -- It's the standard "yield, then BD nop" idiom. - local post_non_nop = false ---@type boolean - for search_idx = last_idx + 1, n do ---@type integer + local post_non_nop = false ---@type boolean + local search_idx = last_idx + 1 ---@type integer + while search_idx <= n do local ev = events[search_idx] ---@type WordEvent local enc = ev.encoder or ev.ident or "" ---@type string if (ev.nop_words or 0) == 0 and enc ~= "" then post_non_nop = true break end + search_idx = search_idx + 1 end if post_non_nop then findings[#findings + 1] = { @@ -2051,8 +2046,8 @@ end --- Phase 10: reads invocations and markers from `atom.paths`; identifies mac_yield_load by --- `invocation.component_name == "mac_yield_load"` or `load_word` with `root_call_text` starting with "mac_yield_load". --- @param atom AtomEntry ---- @param _pipe_ctx PipeCtx ---- @param findings CheckFinding[] +--- @param _pipe_ctx PassScratch +--- @param findings Finding[] --- @return nil local function check_yield_load_tail_pairing(atom, _pipe_ctx, findings) if atom.kind ~= "atom" and atom.kind ~= "atom_proc" then return end @@ -2179,9 +2174,9 @@ local function check_yield_load_tail_pairing(atom, _pipe_ctx, findings) end -- ── Rule 1: every `mac_yield_load()` must be in a branch BD-slot, OR sit between two `atom_label`s. - for event_idx = 1, n do ---@type integer - local ev = events[event_idx] ---@type WordEvent - if not is_yield_load_first(ev, event_idx) then goto continue_rule1 end + for _, event_idx in ipairs(atom.paths.yield_load_at or {}) do ---@type integer, integer + local ev = events[event_idx] ---@type WordEvent|nil + if not ev or not is_yield_load_first(ev, event_idx) then goto continue_rule1 end local prev_i = skip_delay(event_idx - 1, -1) ---@type integer local ev_word = ev.i or 0 ---@type integer @@ -2345,9 +2340,9 @@ local function check_yield_load_tail_pairing(atom, _pipe_ctx, findings) end -- Find mac_yield_tail first-word events and validate. - for event_idx = 1, n do ---@type integer - local ev = events[event_idx] ---@type WordEvent - if not is_yield_tail_first(ev, event_idx) then goto continue_tail end + for _, event_idx in ipairs(atom.paths.yield_tail_at or {}) do ---@type integer, integer + local ev = events[event_idx] ---@type WordEvent|nil + if not ev or not is_yield_tail_first(ev, event_idx) then goto continue_tail end -- Raw handshake: jump_reg(R_AtomJmp), BdSlot_ is the atom-end tail. No label required. if ev.is_raw_yield_tail then @@ -2436,8 +2431,8 @@ end --- (built once by validate() before the per-atom loop). --- `validate()` owns per-atom iteration; this function evaluates one atom. --- @param atom AtomEntry ---- @param pipe_ctx PipeCtx ---- @param findings CheckFinding[] +--- @param pipe_ctx PassScratch +--- @param findings Finding[] --- @return nil local function check_abi_handoff(atom, pipe_ctx, findings) local info = pipe_ctx.info_by_atom[atom.name] ---@type AtomInfoEntry|nil @@ -2454,13 +2449,16 @@ local function check_abi_handoff(atom, pipe_ctx, findings) return end local events = atom.paths.word_events or {} ---@type WordEvent[] + local o_arg_at = atom.paths.o_arg_at or {} ---@type integer[] + local s_arg_at = atom.paths.s_arg_at or {} ---@type integer[] local found_field_set = {} ---@type table -- bag local found_advance = false ---@type boolean -- Reads o_arg1 / o_arg2 / s_arg1 / reads_r_tape_ptr stamped on word_events. - for _, ev in ipairs(events) do ---@type integer, WordEvent + for _, ev_idx in ipairs(o_arg_at) do ---@type integer, integer + local ev = events[ev_idx] ---@type WordEvent|nil -- scan: load_word(R_*, R_TapePtr, O_(, )) - if ev.is_load and ev.reads_r_tape_ptr and ev.o_arg1 == binds_name then + if ev and ev.is_load and ev.reads_r_tape_ptr and ev.o_arg1 == binds_name then local field = ev.o_arg2 ---@type string|TypeField|nil if field then found_field_set[field] = true @@ -2474,8 +2472,11 @@ local function check_abi_handoff(atom, pipe_ctx, findings) } end end + end + for _, ev_idx in ipairs(s_arg_at) do ---@type integer, integer + local ev = events[ev_idx] ---@type WordEvent|nil -- scan: add_ui_self(R_TapePtr, S_()) - if ev.reads_r_tape_ptr and ev.s_arg1 == binds_name then + if ev and ev.reads_r_tape_ptr and ev.s_arg1 == binds_name then found_advance = true end end @@ -2518,8 +2519,8 @@ end --- --- Applies only to `kind = "atom"` or `kind = "atom_proc"` (full-atom bodies). Components don't emit full primitives. --- @param atom AtomEntry ---- @param pipe_ctx PipeCtx ---- @param findings CheckFinding[] +--- @param pipe_ctx PassScratch +--- @param findings Finding[] --- @return nil local function check_gpu_portstore_shape(atom, pipe_ctx, findings) if atom.kind ~= "atom" and atom.kind ~= "atom_proc" then return end @@ -2529,7 +2530,10 @@ local function check_gpu_portstore_shape(atom, pipe_ctx, findings) local saw_format = false ---@type boolean local saw_prim_write = false ---@type boolean local saw_tag = false ---@type boolean - local comps = pipe_ctx.components_by_name or {} ---@type table + local comps = pipe_ctx.components_by_name or {} ---@type table + local events = atom.paths.word_events or {} ---@type WordEvent[] + local store_at = atom.paths.store_at or {} ---@type integer[] + local o_arg_at = atom.paths.o_arg_at or {} ---@type integer[] -- One gp0_contrib add per matching invocation. Inner store_word / gte_sw -- expansions are already inside that number; do not walk them again. @@ -2555,12 +2559,16 @@ local function check_gpu_portstore_shape(atom, pipe_ctx, findings) end end - for _, ev in ipairs(atom.paths.word_events or {}) do ---@type integer, WordEvent - if ev.writes_r_prim_cursor then + for _, ev_idx in ipairs(store_at) do ---@type integer, integer + local ev = events[ev_idx] ---@type WordEvent|nil + if ev and ev.writes_r_prim_cursor then saw_prim_write = true end + end + for _, ev_idx in ipairs(o_arg_at) do ---@type integer, integer + local ev = events[ev_idx] ---@type WordEvent|nil -- Source-level O_(Poly_*, tag) store is the packet tag, counted once. - if ev.o_arg1 and ev.o_arg1:match("^Poly_") and ev.o_arg2 == "tag" then + if ev and ev.o_arg1 and ev.o_arg1:match("^Poly_") and ev.o_arg2 == "tag" then if ev.is_store_word or ev.ident == "gte_sw" then if not saw_tag then contrib = contrib + 1 @@ -2573,8 +2581,10 @@ local function check_gpu_portstore_shape(atom, pipe_ctx, findings) -- Invocation contrib is 0 when no matching mac_* ran. Then count -- distinct PrimCursor stores. Skip this walk when contrib is non-zero. if contrib == 0 then - local seen_field = {} ---@type table -- bag - for _, ev in ipairs(atom.paths.word_events or {}) do ---@type integer, WordEvent + local seen_field = {} ---@type table -- bag + for _, ev_idx in ipairs(store_at) do ---@type integer, integer + local ev = events[ev_idx] ---@type WordEvent|nil + if not ev then goto continue_store end local enc = ev.encoder or "" ---@type string if enc == "store_word" or enc == "store_half" or enc == "store_byte" or enc == "gte_sw" then local text = (ev.call_text or "") .. " " .. (ev.root_call_text or "") ---@type string @@ -2599,6 +2609,7 @@ local function check_gpu_portstore_shape(atom, pipe_ctx, findings) end end end + ::continue_store:: end end @@ -2651,13 +2662,13 @@ end --- has_loops - true iff a path re-entered an event it had visited (warning; loops aren't supported) --- unknown_macros - list of unique encoder names with no cost lookup --- @param atom AtomEntry ---- @param pipe_ctx PipeCtx +--- @param pipe_ctx PassScratch --- @return nil local function analyze_atom_paths(atom, pipe_ctx) local events = atom.paths.word_events or {} ---@type WordEvent[] local markers = atom.paths.markers or {} ---@type EmissionMarker[] local n = #events ---@type integer - local comps_by_name = pipe_ctx.components_by_name or {} ---@type table + local comps_by_name = pipe_ctx.components_by_name or {} ---@type table -- Build label map from markers: label name -> 0-based word index of the next emitted word. local labels = {} ---@type table @@ -2691,7 +2702,7 @@ local function analyze_atom_paths(atom, pipe_ctx) if cost == nil then if enc:sub(1, 4) == "mac_" then local bare = enc:sub(5) ---@type string - local comp = comps_by_name[bare] ---@type ComponentDef|AtomEntry|nil + local comp = comps_by_name[bare] ---@type Component|AtomEntry|nil if comp and comp.cycle_cost ~= nil then cost = comp.cycle_cost else @@ -2883,8 +2894,8 @@ end --- (so the warning section doesn't get spammed with N copies of the same diagnostic). --- Reuses `analyze_atom_paths`'s per-atom unknown_macros discovery, which walks tokens and computes per-token cycle costs. --- @param atom AtomEntry ---- @param pipe_ctx PipeCtx ---- @param findings CheckFinding[] +--- @param pipe_ctx PassScratch +--- @param findings Finding[] --- @return nil local function check_per_atom_cycle_budget(atom, pipe_ctx, findings) local p = atom.paths or {} ---@type AtomPaths @@ -2927,8 +2938,8 @@ local function is_physical_gpr(reg) end --- @param _src SourceFile ---- @param pipe_ctx PipeCtx ---- @param findings CheckFinding[] +--- @param pipe_ctx PassScratch +--- @param findings Finding[] --- @return nil local function check_enum_alias_membership(_src, pipe_ctx, findings) local reg_registry = pipe_ctx.register_alias_registry or {} ---@type table @@ -3000,8 +3011,8 @@ end -- Missing type names are errors (the build stops) so the user adds the typedef before re-running. -- Per-source rule. --- @param _src SourceFile ---- @param pipe_ctx PipeCtx ---- @param findings CheckFinding[] +--- @param pipe_ctx PassScratch +--- @param findings Finding[] --- @return nil local function check_atom_type_consistency(_src, pipe_ctx, findings) local type_registry = pipe_ctx.type_name_registry or {} ---@type table @@ -3088,15 +3099,17 @@ local function is_field_leaf(field, type_registry) end --- @param _src SourceFile ---- @param pipe_ctx PipeCtx ---- @param findings CheckFinding[] +--- @param pipe_ctx PassScratch +--- @param findings Finding[] --- @return nil local function check_binds_no_substruct_deref(_src, pipe_ctx, findings) local type_registry = pipe_ctx.type_name_registry or {} ---@type table for _, a in ipairs(pipe_ctx.atoms or {}) do ---@type integer, string - local events = a.paths and a.paths.word_events or {} ---@type WordEvent[] - for _, ev in ipairs(events) do ---@type integer, WordEvent - if (ev.is_load or ev.is_store_word) + local events = a.paths and a.paths.word_events or {} ---@type WordEvent[] + local o_arg_at = a.paths and a.paths.o_arg_at or {} ---@type integer[] + for _, ev_idx in ipairs(o_arg_at) do ---@type integer, integer + local ev = events[ev_idx] ---@type WordEvent|nil + if ev and (ev.is_load or ev.is_store_word) and ev.o_arg1 and ev.o_arg2 then local type_name = ev.o_arg1 ---@type string|nil local field_name = ev.o_arg2 ---@type string|nil @@ -3194,23 +3207,26 @@ end local function ctrl_writes_in_atom(atom) local fs = atom.paths and atom.paths.forward_state ---@type ForwardState|nil if fs and fs.c2_ctrl_writes then return fs.c2_ctrl_writes end - local out = {} ---@type C2CtrlWrite[] - for _, ev in ipairs((atom.paths and atom.paths.word_events) or {}) do ---@type integer, WordEvent - if (ev.encoder or "") == "gte_mv_to_ctrl_r" then - local alias = ev.args and ev.args[2] ---@type string|nil - if type(alias) ~= "string" or not alias:match("^gte_cr_") then - alias = ctrl_alias_from_text(ev.call_text) or ctrl_alias_from_text(ev.root_call_text) - end - local src = ev.args and ev.args[1] ---@type string|nil - if type(src) == "string" then src = src:match("[%w_]+") end - if alias then - out[#out + 1] = { - alias = alias, - src = src, - line = ev.line or atom.line, - } - end + local out = {} ---@type C2CtrlWrite[] + local events = (atom.paths and atom.paths.word_events) or {} ---@type WordEvent[] + local ctc2_at = (atom.paths and atom.paths.ctc2_at) or {} ---@type integer[] + for _, ev_idx in ipairs(ctc2_at) do ---@type integer, integer + local ev = events[ev_idx] ---@type WordEvent|nil + if not ev then goto continue_ctc2 end + local alias = ev.args and ev.args[2] ---@type string|nil + if type(alias) ~= "string" or not alias:match("^gte_cr_") then + alias = ctrl_alias_from_text(ev.call_text) or ctrl_alias_from_text(ev.root_call_text) end + local src = ev.args and ev.args[1] ---@type string|nil + if type(src) == "string" then src = src:match("[%w_]+") end + if alias then + out[#out + 1] = { + alias = alias, + src = src, + line = ev.line or atom.line, + } + end + ::continue_ctc2:: end return out end @@ -3222,38 +3238,43 @@ end -- Severity: warning. Build continues. -- The libgte outer-product convention uses only RT-row aliases (which are NOT in `M.GTE_CR_ALIAS_GROUPS`), so the canonical convention does not trigger this check. --- @param atom AtomEntry ---- @param pipe_ctx PipeCtx ---- @param findings CheckFinding[] +--- @param pipe_ctx PassScratch +--- @param findings Finding[] --- @return nil local function check_gte_cr_alias_writes(atom, pipe_ctx, findings) local groups = pipe_ctx.gte_cr_alias_groups or {} ---@type GteCrAliasGroup[] if not next(groups) then return end - local events = atom.paths and atom.paths.word_events or {} ---@type WordEvent[] - if not next(events) then return end + local events = atom.paths and atom.paths.word_events or {} ---@type WordEvent[] + local ctc2_at = atom.paths and atom.paths.ctc2_at or {} ---@type integer[] + local cfc2_at = atom.paths and atom.paths.cfc2_at or {} ---@type integer[] + if #ctc2_at == 0 and #cfc2_at == 0 then return end -- Encoder is gte_mv_to_ctrl_r / gte_mv_from_ctrl_r. Alias lives in ev.args -- (or call_text), not a sibling token. - local touched = {} ---@type table - for _, ev in ipairs(events) do ---@type integer, WordEvent - local ident = ev.encoder or ev.ident ---@type string - if ident == "gte_mv_to_ctrl_r" or ident == "gte_mv_from_ctrl_r" then - local alias = ev.args and ev.args[2] ---@type string|nil - if type(alias) ~= "string" or not alias:match("^gte_cr_") then - alias = ctrl_alias_from_text(ev.call_text) - or ctrl_alias_from_text(ev.root_call_text) - or ctrl_alias_from_text(table.concat(ev.args or {}, ",")) - end - local group = alias and find_alias_pair_for(alias, pipe_ctx.duffle) ---@type GteCrAliasGroup|nil - if group then - touched[group[1]] = touched[group[1]] or {} - touched[group[1]][#touched[group[1]] + 1] = { - alias = alias, - line = ev.line or atom.line, - } - end + local touched = {} ---@type table + --- @param ev_idx integer + --- @return nil + local function ingest_ctrl_xfer(ev_idx) + local ev = events[ev_idx] ---@type WordEvent|nil + if not ev then return end + local alias = ev.args and ev.args[2] ---@type string|nil + if type(alias) ~= "string" or not alias:match("^gte_cr_") then + alias = ctrl_alias_from_text(ev.call_text) + or ctrl_alias_from_text(ev.root_call_text) + or ctrl_alias_from_text(table.concat(ev.args or {}, ",")) + end + local group = alias and find_alias_pair_for(alias, pipe_ctx.duffle) ---@type GteCrAliasGroup|nil + if group then + touched[group[1]] = touched[group[1]] or {} + touched[group[1]][#touched[group[1]] + 1] = { + alias = alias, + line = ev.line or atom.line, + } end end + for _, ev_idx in ipairs(ctc2_at) do ingest_ctrl_xfer(ev_idx) end ---@type integer, integer + for _, ev_idx in ipairs(cfc2_at) do ingest_ctrl_xfer(ev_idx) end ---@type integer, integer -- Fire one warning per group touched with 2+ distinct aliases. for slot, hits in pairs(touched) do ---@type string, C2CtrlWrite[] @@ -3287,8 +3308,8 @@ end -- Severity: info by default. Escalates to warning when `GTE_RT_DIAGONAL_STRICT=1` env var is set (CI / production builds). -- The bare macro IS the right call for the canonical libgte outer-product convention, so this is an opt-out hint rather than a hard warning. --- @param atom AtomEntry ---- @param _pipe_ctx PipeCtx ---- @param findings CheckFinding[] +--- @param _pipe_ctx PassScratch +--- @param findings Finding[] --- @return nil local function check_rtdiagonal_completeness(atom, _pipe_ctx, findings) local tokens = atom.paths and atom.paths.tokens or {} ---@type string[] @@ -3320,8 +3341,8 @@ end -- -- Severity: info. The convention is correct; this is a documentation-pointer check. --- @param atom AtomEntry ---- @param _pipe_ctx PipeCtx ---- @param findings CheckFinding[] +--- @param _pipe_ctx PassScratch +--- @param findings Finding[] --- @return nil local function check_gte_cr_TR_naming(atom, _pipe_ctx, findings) local tokens = atom.paths and atom.paths.tokens or {} ---@type string[] @@ -3355,8 +3376,8 @@ local function check_gte_cr_TR_naming(atom, _pipe_ctx, findings) end --- @param src SourceFile ---- @param pipe_ctx PipeCtx ---- @param findings CheckFinding[] +--- @param pipe_ctx PassScratch +--- @param findings Finding[] --- @return nil local function check_gte_cr_alias_writes_xatom(src, pipe_ctx, findings) -- Walk tape chains once (first source only). Atoms in no chain stay per-atom. @@ -3419,7 +3440,7 @@ end --- @param atom AtomEntry --- @param writes PackedWrite[] --- @param rel GtePackedSlotRelation ---- @param findings CheckFinding[] +--- @param findings Finding[] --- @return nil local function emit_packed_finding(atom, writes, rel, findings) local line = atom.line ---@type integer @@ -3442,8 +3463,8 @@ end -- Packed RT order. first_idx is persisted on the tape chain so a yield -- between the two halves of a packed slot still sees the earlier write. --- @param src SourceFile ---- @param pipe_ctx PipeCtx ---- @param findings CheckFinding[] +--- @param pipe_ctx PassScratch +--- @param findings Finding[] --- @return nil local function check_gte_packed_writes(src, pipe_ctx, findings) local first = pipe_ctx.source_order and pipe_ctx.source_order[1] ---@type SourceFile|nil @@ -3514,7 +3535,7 @@ end --- @param atom AtomEntry --- @param line integer --- @param dest string ---- @param findings CheckFinding[] +--- @param findings Finding[] --- @return nil local function emit_ctc2_finding(atom, line, dest, findings) findings[#findings + 1] = { @@ -3527,24 +3548,32 @@ local function emit_ctc2_finding(atom, line, dest, findings) } end ---- @param events WordEvent[] +--- @param atom AtomEntry --- @param start_i integer --- @param dest string ---- @return string[] -local function later_rt_ctc2_names(events, start_i, dest) - for j = start_i, #events do ---@type integer - local later = events[j] ---@type WordEvent - local later_enc = later.encoder or "" ---@type string - if later_enc:match("^gte_cmdw_") then return false end - if later_enc == "gte_mv_to_ctrl_r" then +--- @return boolean +local function later_rt_ctc2_names(atom, start_i, dest) + local events = atom.paths.word_events or {} ---@type WordEvent[] + local ctc2_at = atom.paths.ctc2_at or {} ---@type integer[] + local gte_cmd_at = atom.paths.gte_cmd_at or {} ---@type integer[] + local ci, gi = 1, 1 ---@type integer, integer + while ctc2_at[ci] and ctc2_at[ci] < start_i do ci = ci + 1 end + while gte_cmd_at[gi] and gte_cmd_at[gi] < start_i do gi = gi + 1 end + while true do + local cidx = ctc2_at[ci] ---@type integer|nil + local gidx = gte_cmd_at[gi] ---@type integer|nil + if not cidx and not gidx then return false end + if gidx and (not cidx or gidx < cidx) then return false end + local later = events[cidx] ---@type WordEvent|nil + if later then local later_src = ctc2_event_src(later) ---@type string|nil local later_alias = ctc2_event_alias(later) ---@type string|nil if later_src == dest and later_alias and later_alias:match("^gte_cr_RT") then return true end end + ci = ci + 1 end - return false end --- @param live table @@ -3563,14 +3592,30 @@ end -- A load after the last RT ctc2 in the same atom, before the command, is a legal reload. --- @param atom AtomEntry --- @param live table ---- @param findings CheckFinding[] +--- @param findings Finding[] --- @return nil local function walk_ctc2_atom(atom, live, findings) atom.paths = atom.paths or {} atom.paths.forward_state = atom.paths.forward_state or {} - local events = atom.paths.word_events or {} ---@type WordEvent[] + local events = atom.paths.word_events or {} ---@type WordEvent[] + local ctc2_at = atom.paths.ctc2_at or {} ---@type integer[] + local load_at = atom.paths.load_at or {} ---@type integer[] + local gte_cmd_at = atom.paths.gte_cmd_at or {} ---@type integer[] if #events > 0 then - for i, ev in ipairs(events) do ---@type integer, WordEvent + local ci, li, gi = 1, 1, 1 ---@type integer, integer, integer + while true do + local cidx = ctc2_at[ci] ---@type integer|nil + local lidx = load_at[li] ---@type integer|nil + local gidx = gte_cmd_at[gi] ---@type integer|nil + if not cidx and not lidx and not gidx then break end + local ev_idx = cidx or lidx or gidx ---@type integer + if lidx and lidx < ev_idx then ev_idx = lidx end + if gidx and gidx < ev_idx then ev_idx = gidx end + if cidx == ev_idx then ci = ci + 1 end + if lidx == ev_idx then li = li + 1 end + if gidx == ev_idx then gi = gi + 1 end + local ev = events[ev_idx] ---@type WordEvent|nil + if not ev then goto continue_ctc2_walk end local enc = ev.encoder or "" ---@type string if enc == "gte_mv_to_ctrl_r" then local src = ctc2_event_src(ev) ---@type string|nil @@ -3583,14 +3628,15 @@ local function walk_ctc2_atom(atom, live, findings) local rec = dest and live[dest] ---@type Ctc2LiveRec|nil if rec then local from_other = rec.atom ~= atom.name ---@type boolean - if from_other or later_rt_ctc2_names(events, i + 1, dest) then + if from_other or later_rt_ctc2_names(atom, ev_idx + 1, dest) then emit_ctc2_finding(atom, ev.line or atom.line, dest, findings) end live[dest] = nil end - elseif enc:match("^gte_cmdw_") then + elseif enc:sub(1, 9) == "gte_cmdw_" then clear_rt_live(live) end + ::continue_ctc2_walk:: end else local tokens = atom.paths.tokens or {} ---@type string[] @@ -3636,8 +3682,8 @@ local function walk_ctc2_atom(atom, live, findings) end --- @param src SourceFile ---- @param pipe_ctx PipeCtx ---- @param findings CheckFinding[] +--- @param pipe_ctx PassScratch +--- @param findings Finding[] --- @return nil local function check_ctc2_chain_source_preservation(src, pipe_ctx, findings) local first = pipe_ctx.source_order and pipe_ctx.source_order[1] ---@type SourceFile|nil @@ -3666,13 +3712,16 @@ end -- from duffle.lua. Only fires on parseable integer literals; register names, -- O_(...) offsets, atom_offset(...) markers, and enum tokens are skipped. --- @param atom AtomEntry ---- @param pipe_ctx PipeCtx ---- @param findings CheckFinding[] +--- @param pipe_ctx PassScratch +--- @param findings Finding[] --- @return nil local function check_immediate_field_width(atom, pipe_ctx, findings) local events = atom.paths and atom.paths.word_events or {} ---@type WordEvent[] + local imm_at = atom.paths and atom.paths.imm_at or {} ---@type integer[] local line_for_word_event = pipe_ctx.line_for_word_event ---@type (fun(ev: WordEvent): integer)|nil - for _, ev in ipairs(events) do ---@type integer, WordEvent + for _, ev_idx in ipairs(imm_at) do ---@type integer, integer + local ev = events[ev_idx] ---@type WordEvent|nil + if not ev then goto continue_imm end local ev_ident = ev.encoder or ev.ident or "?" ---@type string local isa = duffle.instr(ev_ident) ---@type InstructionRow|nil local rules = isa and isa.imm ---@type InstructionImm[]|nil @@ -3748,18 +3797,18 @@ local function check_immediate_field_width(atom, pipe_ctx, findings) end end end + ::continue_imm:: end end -- Atom-body temps may be omitted from atom_reads / atom_writes. --- Physical names come from auto_reg.POOL (R2-R25). R_AT is never allocated. --- R_TapePtr / R_AtomJmp are reserved carriers; yield always touches them, --- so they are optional in the contract unless the atom listed them. --- R_ScratchBase stays required when used. -local auto_reg = require("passes.auto_reg") ---@type AutoRegPass -local OPTIONAL_CONTRACT_GPRS = { R_AT = true, R_TapePtr = true, R_AtomJmp = true } ---@type table -- bag -for _, name in ipairs(auto_reg.POOL) do ---@type integer, string - OPTIONAL_CONTRACT_GPRS[name] = true +-- optional GprRole rows (pool + R_AT + carriers) need not be declared. +-- Carriers are optional so yield need not list them. +local OPTIONAL_CONTRACT_GPRS = {} ---@type table -- bag +for _, row in ipairs(duffle.GPR_ROLE) do ---@type integer, GprRole + if row.optional then + OPTIONAL_CONTRACT_GPRS[row.name] = true + end end --- @param tok string @@ -3788,46 +3837,46 @@ local function traffic_ident_args(ev) return tok:match("^([%w_]+)") or "", token_arg_list(tok) end ---- @param atom_or_tokens AtomEntry|WordEvent[]|BodyToken[] +--- @param atom_or_tokens AtomEntry|AtomPaths --- @return table, table local function collect_gpr_traffic(atom_or_tokens) local reads, writes = {}, {} ---@type table, table -- bag - local events = {} ---@type WordEvent[] + local events = nil ---@type WordEvent[]|nil + local traffic_at = nil ---@type integer[]|nil if type(atom_or_tokens) == "table" then if atom_or_tokens.paths and atom_or_tokens.paths.word_events then - events = atom_or_tokens.paths.word_events + events = atom_or_tokens.paths.word_events + traffic_at = atom_or_tokens.paths.traffic_at elseif atom_or_tokens.word_events then - events = atom_or_tokens.word_events - else - events = atom_or_tokens + events = atom_or_tokens.word_events + traffic_at = atom_or_tokens.traffic_at end end - for _, ev in ipairs(events) do ---@type integer, WordEvent + if not events or not traffic_at then return reads, writes end + for _, ev_idx in ipairs(traffic_at) do ---@type integer, integer + local ev = events[ev_idx] ---@type WordEvent|nil + if not ev then goto continue_traffic end local ident, args = traffic_ident_args(ev) ---@type string, string[] - if ident:sub(1, 4) ~= "mac_" - and not (duffle.DELAY_MARKERS and duffle.DELAY_MARKERS[ident]) - and ident ~= "nop" and ident ~= "atom_label" and ident ~= "atom_offset" - then - local fx = duffle.instr(ident) ---@type InstructionRow|nil - if fx then - for _, pos in ipairs(fx.reads or {}) do ---@type integer, integer - local g = arg_as_gpr(args[pos]) ---@type string|nil - if g then reads[g] = true end - end - for _, pos in ipairs(fx.writes or {}) do ---@type integer, integer - local g = arg_as_gpr(args[pos]) ---@type string|nil - if g then writes[g] = true end - end - else - for _, arg in ipairs(args) do ---@type integer, string - local g = arg_as_gpr(arg) ---@type string|nil - if g then - reads [g] = true - writes[g] = true - end + local fx = duffle.instr(ident) ---@type InstructionRow|nil + if fx then + for _, pos in ipairs(fx.reads or {}) do ---@type integer, integer + local g = arg_as_gpr(args[pos]) ---@type string|nil + if g then reads[g] = true end + end + for _, pos in ipairs(fx.writes or {}) do ---@type integer, integer + local g = arg_as_gpr(args[pos]) ---@type string|nil + if g then writes[g] = true end + end + else + for _, arg in ipairs(args) do ---@type integer, string + local g = arg_as_gpr(arg) ---@type string|nil + if g then + reads [g] = true + writes[g] = true end end end + ::continue_traffic:: end return reads, writes end @@ -3861,8 +3910,8 @@ local function gpr_set_keys(s) end --- @param atom AtomEntry ---- @param pipe_ctx PipeCtx ---- @param findings CheckFinding[] +--- @param pipe_ctx PassScratch +--- @param findings Finding[] --- @return nil local function check_atom_calls_inferred_traffic(atom, pipe_ctx, findings) if atom.kind ~= "atom" and atom.kind ~= "atom_proc" then return end @@ -3871,19 +3920,18 @@ local function check_atom_calls_inferred_traffic(atom, pipe_ctx, findings) if not info then return end if #(info.reads or {}) == 0 and #(info.writes or {}) == 0 then return end - local tokens = (atom.paths and atom.paths.tokens) or atom.body_tokens ---@type BodyToken[]|Token[] - local reads, writes = collect_gpr_traffic(atom) ---@type table, table -- bag - for _, t in ipairs(tokens or {}) do ---@type integer, string - local ident = ((t.tok or t) or ""):match("^([%w_]+)") or "" ---@type string - if ident:sub(1, 4) == "mac_" then - local bare = ident:sub(5) ---@type string - local idx = pipe_ctx.component_body_index and pipe_ctx.component_body_index[bare] ---@type ComponentBodyEntry|nil - local comp = (pipe_ctx.components_by_name or {})[bare] or (pipe_ctx.atoms_by_name or {})[bare] ---@type ComponentDef|AtomEntry|nil - local body_toks = (idx and idx.body_tokens) or (comp and (comp.body_tokens or (comp.paths and comp.paths.tokens))) ---@type BodyToken[]|nil - local cr, cw = collect_gpr_traffic(body_toks) ---@type table, table -- bag - for k in pairs(cr) do reads [k] = true end ---@type string - for k in pairs(cw) do writes[k] = true end ---@type string + local reads, writes = collect_gpr_traffic(atom) ---@type table, table -- bag + local atoms_by_name = pipe_ctx.atoms_by_name or {} ---@type table + for _, inv in ipairs(atom.paths.invocations or {}) do ---@type integer, InvocationRecord + if (inv.parent_id or 0) ~= 0 then goto continue_inv end + local name = inv.component_name or "" ---@type string + local comp = atoms_by_name[name] ---@type AtomEntry|nil + if comp then + local cr, cw = collect_gpr_traffic(comp) ---@type table, table -- bag + for k in pairs(cr) do reads [k] = true end ---@type string + for k in pairs(cw) do writes[k] = true end ---@type string end + ::continue_inv:: end local decl_r = gpr_set_from_list(info.reads) ---@type table -- bag @@ -3921,8 +3969,8 @@ local function check_atom_calls_inferred_traffic(atom, pipe_ctx, findings) end --- @param src SourceFile ---- @param pipe_ctx PipeCtx ---- @param findings CheckFinding[] +--- @param pipe_ctx PassScratch +--- @param findings Finding[] --- @return nil local function check_component_self_consistency(src, pipe_ctx, findings) local first = pipe_ctx.source_order and pipe_ctx.source_order[1] ---@type SourceFile|nil @@ -3933,8 +3981,7 @@ local function check_component_self_consistency(src, pipe_ctx, findings) local name = ai.atom_name or ai.name ---@type string local atom = name and atoms_by_name[name] ---@type AtomEntry if atom and not atom.debug_skip then - local tokens = (atom.paths and atom.paths.tokens) or atom.body_tokens ---@type BodyToken[]|Token[] - local reads, writes = collect_gpr_traffic(tokens) ---@type table, table -- bag + local reads, writes = collect_gpr_traffic(atom) ---@type table, table -- bag local decl_r = gpr_set_from_list(ai.reads) ---@type table -- bag local decl_w = gpr_set_from_list(ai.writes) ---@type table -- bag if not gpr_set_eq(decl_r, reads) or not gpr_set_eq(decl_w, writes) then @@ -4006,7 +4053,7 @@ local CHECK_RULES = { ---@type CheckRule[] --- A context without `ctx.shared.corpus` is rejected with an explicit corpus message. --- Callers construct the context through `build_ctx`. --- @param ctx PassCtx ---- @return PipeCtx +--- @return PassScratch local function build_corpus_pipe_ctx(ctx) local view = duffle.corpus_view(ctx) ---@type CorpusView view.components_by_name = view.components @@ -4017,15 +4064,13 @@ end --- @param ctx PassCtx --- @param src SourceFile ---- @param corpus_pipe_ctx PipeCtx +--- @param corpus_pipe_ctx PassScratch --- @return ValidateResult local function validate(ctx, src, corpus_pipe_ctx) local scan = src.scan ---@type SourceScan -- Read the corpus word_counts for the per-atom pipeline -- (`atom.paths.word_events` is the emitted projection). - local corpus = (ctx.shared and ctx.shared.corpus) or {} ---@type Corpus - -- Read atoms + binds + atom_infos from the pre-scanned SourceScan payload. -- The scan was done once upstream by duffle.scan_source(); this pass is pure. local atoms = scan.atoms ---@type AtomEntry[] @@ -4051,10 +4096,10 @@ local function validate(ctx, src, corpus_pipe_ctx) -- type_name_registry — T -> {name, kind, fields, ...} from corpus-wide merge -- All registry fields are READ from the corpus (the dep-closed scan-source merge); this pass never re-parses. local info_by_atom = {} ---@type table - for _, info in ipairs(atom_infos) do ---@type integer, CheckFinding[] + for _, info in ipairs(atom_infos) do ---@type integer, AtomInfoEntry info_by_atom[info.atom_name] = info end - local pipe_ctx = { ---@type PipeCtx + local pipe_ctx = { ---@type PassScratch info_by_atom = info_by_atom, binds_index = binds_index, unknown_seen = {}, @@ -4071,10 +4116,6 @@ local function validate(ctx, src, corpus_pipe_ctx) component_atom_infos = corpus_pipe_ctx.component_atom_infos, atom_infos_all = corpus_pipe_ctx.atom_infos, } - -- Shared cross-source component-body index is owned by the corpus (`corpus.component_body_index`, populated by `passes/components.lua`). - -- Per-atom checks consume the corpus-owned index directly. - pipe_ctx.component_body_index = (corpus and corpus.component_body_index) or {} - --- Per-atom pipeline. ONE iteration of atoms; the 5 check_* functions + analyze_atom_paths all run here, sharing a single tokenize_body + build_body_line_index per body. --- Every piece of state derived from an atom body lives on `atom.paths` (per-atom mega-struct); --- readers (analyze_atom_paths, the 5 checks, the renderers) all consume `atom.paths`. @@ -4089,7 +4130,7 @@ local function validate(ctx, src, corpus_pipe_ctx) --- --- Canonical contract: `atom.paths` and `atom.paths.word_events` MUST be populated by `passes/emission_model.run(ctx)` before this pass runs. --- The `atom.paths.word_events` projection is owned by the emission-model pass; static-analysis reads it directly. - local findings = {} ---@type CheckFinding[] + local findings = {} ---@type Finding[] for _, a in ipairs(atoms) do ---@type integer, AtomEntry if a.paths == nil then error("static_analysis: a.paths is nil; emit emission-model first") @@ -4100,8 +4141,7 @@ local function validate(ctx, src, corpus_pipe_ctx) -- `paths.tokens` / `paths.line_in_body` / `paths.items` / `paths.word_events` are populated by `passes/emission_model.lua`. -- Supply tokens when no emission projection is present. if a.paths.tokens == nil then a.paths.tokens = a.body_tokens end - stamp_event_fields(a.paths.word_events) - stamp_raw_yield_handshake(a) + stamp_event_fields(a) -- analyze_atom_paths fills the *cycles / branches / has_loops / unknown_macros* fields of a.paths. analyze_atom_paths(a, pipe_ctx) @@ -4128,14 +4168,14 @@ local function validate(ctx, src, corpus_pipe_ctx) -- The `info` list returned here is finding-level only; scan/cycle summary lines go into `summaries`. -- An invalid/missing kind is a hard error (no silent fallback to info); this prevents typos like -- kind="warn" or omitted kind fields from being misclassified as info in the rendered report. - local errors = {} ---@type CheckFinding[] - local warnings = {} ---@type CheckFinding[] - local info = {} ---@type CheckFinding[] - for _, f in ipairs(findings) do ---@type integer, CheckFinding + local errors = {} ---@type Finding[] + local warnings = {} ---@type Finding[] + local info = {} ---@type Finding[] + for _, f in ipairs(findings) do ---@type integer, Finding -- Preserve the diagnostic context so focused tests + the renderer can route by the originating check or relation id. -- Hazard readers (transfer_hazards) populate `f.check`, `f.relation_id`, `f.semantic`, `f.direction`, `f.producer_destination`, `f.gap`, `f.required`, `f.evidence_confidence`, etc.; -- Copying them through keeps the per-severity bucket schema compatible with the renderer while making the diagnostic payload queryable. - local payload = { ---@type CheckFinding + local payload = { ---@type table -- Finding plus hazard extras line = f.line, msg = f.msg, check = f.check, @@ -4226,16 +4266,16 @@ local M = {} ---@type StaticAnalysisPass --- @return PassResult function M.run(ctx) local outputs = {} ---@type PassOutputEntry[] - local errors = {} ---@type CheckFinding[] - local warnings = {} ---@type CheckFinding[] + local errors = {} ---@type Finding[] + local warnings = {} ---@type Finding[] -- `info` aggregates finding-level info across every source (the per-source validate() also -- returns a `summaries` collection for scan/cycle rollups; -- those are summary rows and never enter `info`). - local info = {} ---@type CheckFinding[] + local info = {} ---@type Finding[] -- Build the corpus-wide pipe_ctx ONCE per pass run. -- The pipe_ctx is shared across every validate() invocation in this M.run so cross-source visibility is constant. - local corpus_pipe_ctx = build_corpus_pipe_ctx(ctx) ---@type PipeCtx + local corpus_pipe_ctx = build_corpus_pipe_ctx(ctx) ---@type PassScratch local corpus = ctx.shared.corpus ---@type Corpus -- Aggregate per-DIRECTORY (per-module). @@ -4249,10 +4289,10 @@ function M.run(ctx) -- The validate() function does its own per-source analysis (Binds indexing, atom discovery, all checks) -- and attaches path-aware cycle data to each atom it finds. local all_atoms = {} ---@type AtomEntry[] - local all_findings = {} ---@type CheckFinding[] - local dir_errors = {} ---@type CheckFinding[] - local dir_warnings = {} ---@type CheckFinding[] - local dir_info = {} ---@type CheckFinding[] + local all_findings = {} ---@type Finding[] + local dir_errors = {} ---@type Finding[] + local dir_warnings = {} ---@type Finding[] + local dir_info = {} ---@type Finding[] local dir_summaries = {} ---@type ScanSummary[] for _, src in ipairs(dir_sources) do ---@type integer, SourceFile local result = validate(ctx, src, corpus_pipe_ctx) ---@type ValidateResult @@ -4262,10 +4302,10 @@ function M.run(ctx) a.source_path = src.path all_atoms[#all_atoms + 1] = a end - for _, f in ipairs(result.findings) do all_findings [#all_findings + 1] = f end ---@type integer, CheckFinding - for _, e in ipairs(result.errors) do dir_errors [#dir_errors + 1] = e end ---@type integer, CheckFinding + for _, f in ipairs(result.findings) do all_findings [#all_findings + 1] = f end ---@type integer, Finding + for _, e in ipairs(result.errors) do dir_errors [#dir_errors + 1] = e end ---@type integer, Finding for _, w in ipairs(result.warnings) do dir_warnings [#dir_warnings + 1] = w end ---@type integer, C2CtrlWrite - for _, i_ in ipairs(result.info) do dir_info [#dir_info + 1] = i_ end ---@type integer, CheckFinding + for _, i_ in ipairs(result.info) do dir_info [#dir_info + 1] = i_ end ---@type integer, Finding for _, s in ipairs(result.summaries or {}) do dir_summaries [#dir_summaries + 1] = s end ---@type integer, ScanSummary end @@ -4288,9 +4328,9 @@ function M.run(ctx) -- 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 ---@type integer, CheckFinding + for _, e in ipairs(dir_errors) do errors [#errors + 1] = e end ---@type integer, Finding for _, w in ipairs(dir_warnings) do warnings[#warnings + 1] = w end ---@type integer, C2CtrlWrite - for _, i_ in ipairs(dir_info) do info [#info + 1] = i_ end ---@type integer, CheckFinding + for _, i_ in ipairs(dir_info) do info [#info + 1] = i_ end ---@type integer, Finding -- (No per-dir emit: per-module findings are stashed on `corpus.static_analysis_results` above. -- `report.lua` reads that projection to render `.atom_meta_report.md` without re-running validate().) end diff --git a/scripts/passes/word_count_eval.lua b/scripts/passes/word_count_eval.lua index b2326a6..ae74521 100644 --- a/scripts/passes/word_count_eval.lua +++ b/scripts/passes/word_count_eval.lua @@ -4,13 +4,13 @@ --- 1. **Public utility** `M.count_token_words(token, wc)`: Used by `passes/offsets.lua`, `passes/annotation.lua`, and other passes. --- 2. **Pass entry** `M.run(ctx)`: Loads the authored `word_count.metadata.h` into `ctx.shared.corpus.word_counts` for downstream passes. --- The generated `.macs.h` files are OUTPUT artifacts and are NOT inputs to this pass; ---- Current component counts are owned by `passes/components.lua` (which populates `corpus.word_counts` and `corpus.component_body_index` +--- Current component counts are owned by `passes/components.lua` (which populates `corpus.word_counts` and `corpus.components` --- AFTER computing each current count from the just-built body + `corpus.word_counts`). --- --- **Canonical contract**: --- * `ctx.shared.corpus.word_counts` is the count table. --- * `corpus.word_counts` is the sole count table. Consumers read `corpus.word_counts` directly. ---- * `ctx.shared.components` and `ctx.shared.component_body_index` are NOT created by this pass (projections only). +--- * `ctx.shared.components` is NOT created by this pass (projections only). --- * No `.macs.h` recursive discovery (no `scan_dir`, no scan cache, no `_invalidate_scan_cache`). --- --- **Conventions**: tabs (1/level), EmmyLua annotations, no regex, diff --git a/scripts/ps1_meta.lua b/scripts/ps1_meta.lua index a477fae..f1cd390 100644 --- a/scripts/ps1_meta.lua +++ b/scripts/ps1_meta.lua @@ -76,8 +76,7 @@ local PASS_FLAG_DISPATCH_KEY = "__pass__" ---@type string --- @field atom_ctxs table --- @field atom_phases table --- @field word_counts WordCounts ---- @field components table ---- @field component_body_index table +--- @field components table --- @field collisions CorpusCollision[] --- @field resolver SourceResolver --- @field component_atom_infos AtomInfoEntry[]|nil @@ -104,9 +103,43 @@ local PASS_FLAG_DISPATCH_KEY = "__pass__" ---@type string --- @field flags PassFlags -- CLI flags + per-pass stash --- @field verbose boolean -- If true, log diagnostic info ---- @class PassFinding ---- @field line integer -- Source line (or 0 for pass-level) ---- @field msg string -- Finding message +--- CheckName: see static_analysis.lua. AtomName: see duffle.lua. +--- @class Finding +--- @field line integer +--- @field msg string +--- @field kind string|nil -- error | warning | info +--- @field atom AtomName|nil +--- @field check CheckName|nil +--- @field source string|nil -- optional; emit/reguse path +--- @field schema_name string|nil -- optional; emit/reguse + +--- @class PassScratch +--- @field corpus Corpus|nil +--- @field info_by_atom table|nil +--- @field binds_index table|nil +--- @field atom_index table|nil +--- @field annot_counts table|nil -- bag +--- @field types table|nil +--- @field atom_views table|nil +--- @field seen_defaults table|nil -- bag +--- @field seen_field table|nil -- bag +--- @field _scan SourceScan|nil +--- @field word_counts WordCounts|nil +--- @field register_alias_registry table|nil +--- @field type_name_registry table|nil +--- @field type_occurrences RegTypeOccurrence[]|nil +--- @field atom_infos_list AtomInfoEntry[]|nil +--- @field binds_list BindsEntry[]|nil +--- @field unknown_seen table|nil -- bag +--- @field atoms AtomEntry[]|nil +--- @field components_by_name table|nil +--- @field atoms_by_name table|nil +--- @field tape_chains table|nil +--- @field source_order SourceFile[]|nil +--- @field component_atom_infos AtomInfoEntry[]|nil +--- @field atom_infos_all AtomInfoEntry[]|nil +--- @field gte_cr_alias_groups GteCrAliasGroup[]|nil +--- @field line_for_word_event (fun(ev: WordEvent): integer)|nil --- @class PassOutputEntry --- @field kind string @@ -114,9 +147,9 @@ local PASS_FLAG_DISPATCH_KEY = "__pass__" ---@type string --- @class PassResult --- @field outputs PassOutputEntry[] ---- @field errors PassFinding[] -- Build-stops (per-pass kind policy) ---- @field warnings PassFinding[] -- Informational ---- @field info CheckFinding[]|nil -- static_analysis only +--- @field errors Finding[] -- Build-stops (per-pass kind policy) +--- @field warnings Finding[] -- Informational +--- @field info Finding[]|nil -- static_analysis only --- @class ParsedArgs --- @field requested_set string[] -- Pass names to run (explicit --all expanded) @@ -604,56 +637,15 @@ local function build_ctx(args) end resolution = resolved else - local source_order = {} ---@type SourceFile[] - local sources_by_path = {} ---@type table - local resolver = { ---@type SourceResolver - resolved = {}, - skipped = {}, - shadowed = {}, - } - for _, input_path in ipairs(args.sources) do ---@type integer, string - local path = duffle.normalize_path(input_path) ---@type string - local key_ok, key_or_error = pcall(duffle.canonical_path_key, path) ---@type boolean, string - if not key_ok then - error("ps1_meta: invalid --source " .. input_path .. ": " .. tostring(key_or_error), 0) - end - local file = io.open(path, "r") ---@type file*|nil - if not file then - io.stderr:write("ps1_meta: cannot open --source " .. input_path .. "\n") - os.exit(EXIT_INTERNAL_ERROR) - end - local text = file:read("*a") ---@type string - file:close() - - local source = { ---@type SourceFile - path = path, - text = text, - dir = duffle.dirname(path), - basename = duffle.basename_no_ext(path), - } - source_order[#source_order + 1] = source - local key = key_or_error ---@type string - if not sources_by_path[key] then sources_by_path[key] = source end - resolver.resolved[#resolver.resolved + 1] = { - include_path = path, - include_text = nil, - root_source = nil, - root_line = nil, - candidate_a = path, - candidate_b = nil, - selected_path = path, - disposition = "exact", - } + local ok_exact, exact = pcall(duffle.resolve_exact_sources, { ---@type boolean, Corpus|string + sources = args.sources, + project_root = project_root, + }) + if not ok_exact then + io.stderr:write("ps1_meta: cannot resolve --source: " .. tostring(exact) .. "\n") + os.exit(EXIT_INTERNAL_ERROR) end - resolution = { - unity_root = nil, - project_root = project_root, - code_root = duffle.normalize_path(project_root .. "/code"), - source_order = source_order, - sources_by_path = sources_by_path, - sources_by_dir = duffle.group_sources_by_dir(source_order), - resolver = resolver, - } + resolution = exact end local corpus = { ---@type Corpus @@ -673,7 +665,6 @@ local function build_ctx(args) atom_phases = {}, word_counts = {}, components = {}, - component_body_index = {}, collisions = {}, resolver = resolution.resolver, } @@ -791,7 +782,7 @@ end local function report_validation_errors(pass_name, pass, result) local has_errors = result.errors and #result.errors > 0 ---@type boolean if not has_errors then return false end - for _, e in ipairs(result.errors) do ---@type integer, PassFinding + for _, e in ipairs(result.errors) do ---@type integer, Finding io.stderr:write(string.format("[%s] line %d: %s\n", pass_name, e.line or 0, e.msg or "")) end return PASS_KIND_STOP_ON_ERROR[pass.kind] == true