diff --git a/code/duffle/gte.h b/code/duffle/gte.h index 9ed3143..a5acd73 100644 --- a/code/duffle/gte.h +++ b/code/duffle/gte.h @@ -440,6 +440,8 @@ enum { _C2_TX_SUBS_ = 0 #define gte_cmdw_rtpt (gte_cmd_base | enc_gte_cmd(gte_cmd_rtpt ) | gte_cmdw_psyq_compat) #define gte_cmdw_nclip (gte_cmd_base | enc_gte_cmd(gte_cmd_nclip)) #define gte_cmdw_op (gte_cmd_base | enc_gte_cmd(gte_cmd_op )) +#define gte_cmdw_outer_product gte_cmdw_op /* "outer product" -- NOCASH/Sdk terminology */ +#define gte_cmdw_wedge gte_cmdw_op /* "wedge product" -- geometric-algebra terminology */ #define gte_cmdw_mvmva (gte_cmd_base | enc_gte_cmd(gte_cmd_mvmva)) #define gte_cmdw_rotate_translate_perspective_single gte_cmdw_rtps @@ -713,14 +715,14 @@ enum { asm_words( \ load_word(R_T5, R_T4, 0) \ , load_word(R_T6, R_T4, 4) \ - , gte_mt( R_T5, 0) \ - , gte_mt( R_T6, 1) \ + , gte_mv_to_data_r( R_T5, 0) \ + , gte_mv_to_data_r( R_T6, 1) \ , load_word(R_T5, R_T4, 8) \ , load_word(R_T6, R_T4, 12) \ , load_word(R_T4, R_T4, 16) \ - , gte_mt( R_T5, 2) \ - , gte_mt( R_T6, 3) \ - , gte_mt( R_T4, 4) \ + , gte_mv_to_data_r( R_T5, 2) \ + , gte_mv_to_data_r( R_T6, 3) \ + , gte_mv_to_data_r( R_T4, 4) \ ) \ , r_use(r0) \ asm_clobber: clbr_volatile_gprs, rlit(R_T4), rlit(R_T5), rlit(R_T6) \ diff --git a/scripts/duffle.lua b/scripts/duffle.lua index 0433dd5..e29dd95 100644 --- a/scripts/duffle.lua +++ b/scripts/duffle.lua @@ -520,6 +520,84 @@ M.TAPE_ATOM_MACROS = { ["atom_info"] = { kind = "info", binds = false }, } +-- GTE pipeline-fill latency table (static-analysis Phase 1). +-- +-- For each `gte_cmdw_*` macro in code/duffle/gte.h, the minimum number +-- of consecutive COP2 "nop" words that MUST appear before any other +-- COP2 read or non-nop instruction (so the GTE pipeline latency is +-- fully retired). Latencies are sourced from the doxygen comments +-- in gte.h (e.g. `* @brief Rotate, Translate and Perspective Triple +-- (23 cycles)` with body `Two nop words fill the COP2 pipeline +-- latency`). +-- +-- The check (`scripts/passes/static_analysis.lua :: +-- check_gte_pipeline_fill`) walks each atom body, counts the +-- consecutive nop words after every `gte_cmdw_*` invocation, and +-- reports a finding if the count is below this minimum. Aliases +-- are dereferenced before lookup (gté_cmdw_rtps_alias -> +-- gte_cmdw_rtps -> 2). +-- +-- Values verified against PSX-SPX gte.txt (rtpt 23cy / 8cy per divide +-- => 2 nops; nclip 8cy => 2 nops; avsz3/avsz4 14cy => 2 nops; op +-- single-cycle atomic => 0 nops; mvmva 8cy matrix-vector => 2 nops). +M.GTE_PIPELINE_LATENCY = { + -- Minimum number of consecutive `nop` words that must appear + -- IMMEDIATELY BEFORE a `gte_cmdw_` invocation -- to retire + -- any preceding `lwc2` / `swc2` / pre-existing C2 state writes + -- before the GTE pipeline starts reading from V0/V1/V2 or + -- MAC0..3 / OTZ / IR0..3 at the command's issue cycle. + -- + -- Values are from the doxygen comments in code/duffle/gte.h and + -- cross-checked against PSX-SPX `geometrytransformationenginegte.md`: + -- + -- cmd cycles min pre-nops rationale + -- rtps 14 2 8c per perspective divide + 6c for IR1..4 + mac write + -- rptt 22 2 3x rtps worth of pipeline depth + -- nclip 7 2 MAC0 write + 5c for sign + -- avsz3 14 2 14c to compute average + write OTZ + -- avsz4 16 2 avsz3 + 2c extra for avg over 4 + -- mvmva 8 2 IR1..4 write + matrix work + -- op 5 0 output to MAC0 only (atomic 5c calc) + -- + -- The `gte_rtpt()` / `gte_nclip()` / `gte_avsz3()` wrapper macros in + -- gte.h emit the pre-cmd nops internally (asm_words(nop, nop, ...)), + -- but THOSE WRAPPERS ARE NOT USED INSIDE ATOM BODIES in this + -- codebase. Every MipsAtom_(name) body uses raw `nop2, + -- gte_cmdw_, ...` form instead -- that `nop2,` is the pre-fill + -- this check validates. So values here must reflect the source-level + -- convention, NOT the wrapper-internal pre-fill (which is invisible + -- at the source level). + -- + -- Existing clean-atom bodies (cube_g4_face, floor_f3_face, + -- diag_gte) all emit `nop2,` before every `gte_cmdw_` (which + -- matches values >= 2). The check passes them all. + -- + -- Aliases are listed separately because source code may use either + -- the alias or the canonical name. The check looks up the EXACT + -- macro text, so both forms must be in the table. + + -- Canonical macros (from code/duffle/gte.h) + ["gte_cmdw_rtps"] = 2, + ["gte_cmdw_rtpt"] = 2, + ["gte_cmdw_nclip"] = 2, + ["gte_cmdw_op"] = 0, + ["gte_cmdw_mvmva"] = 2, + ["gte_cmdw_avsz3"] = 2, + ["gte_cmdw_avsz4"] = 2, + + -- Aliases (must have the same value as their canonical target) + ["gte_cmdw_rotate_translate_perspective_single"] = 2, + ["gte_cmdw_rotate_translate_perspective_triple"] = 2, + ["gte_cmdw_avg_sort_z4"] = 2, + + -- Outer product aliases (same canonical op, 0 pre-fill nops). + -- gte_cmdw_op = canonical GTE-internal short form + -- gte_cmdw_outer_product = NOCASH / SDK-readable form + -- gte_cmdw_wedge = geometric-algebra (exterior-product) form + ["gte_cmdw_outer_product"] = 0, + ["gte_cmdw_wedge"] = 0, +} + -- Expose the lpeg_ok flag so callers can detect the LPeg-back path. -- True when LPeg was successfully required and the patterns above were -- compiled at module load time. False when running in fallback mode. diff --git a/scripts/passes/static_analysis.lua b/scripts/passes/static_analysis.lua index 87f822e..6b01888 100644 --- a/scripts/passes/static_analysis.lua +++ b/scripts/passes/static_analysis.lua @@ -1,7 +1,496 @@ -- passes/static_analysis.lua -- --- [FUTURE] Per-atom static-analysis checks. Stub for now; the upcoming --- static_analysis_atoms_20260708 track will deliver the 5 (+1) checks. +-- Per-atom static-analysis checks for the tape-atom build pipeline. +-- Currently ships Phase 1 checks (GTE pipeline-fill + mac_yield +-- uniformity). Phases 2/3 (ABI handoff discipline, GPU port-store +-- shape, per-atom cycle budget) extend this file. +-- +-- Workspace boundary: same conventions as annotation.lua +-- - primitives from duffle.lua (read_parens, read_braces, scan_to_char, LineIndex) +-- - LPeg not needed: pure hand-rolled string scanning +-- - 5.3-compatible (no , no continue keyword) +-- - no :match/:gmatch +-- - tab indent, EmmyLua @class/@param annotations +-- +-- The orchestrator (ps1_meta.lua) wires this module in via the +-- PASSES table: +-- ["static-analysis"] = { +-- module = "passes.static_analysis", +-- kind = "validation", -- errors stop the build +-- deps = {"word-counts", "components"}, +-- out = { { kind = "report", +-- path_template = "/.static_analysis.txt" } }, +-- } + +-- ════════════════════════════════════════════════════════════════════════════ +-- Module-scope requires + package.path setup +-- ════════════════════════════════════════════════════════════════════════════ + +local script_path = arg and arg[0] or "?" +local last_sep = 0 +for i = 1, #script_path do + local c = script_path:sub(i, i) + if c == "/" or c == "\\" then last_sep = i end +end +local script_dir = last_sep == 0 and "./" or script_path:sub(1, last_sep) +package.path = script_dir .. "../?.lua;" .. script_dir .. "../?/init.lua;" .. script_dir .. "?.lua;" .. package.path + +local duffle = require("duffle") +local read_ident = duffle.read_ident +local skip_ws_and_cmt = duffle.skip_ws_and_cmt +local read_parens = duffle.read_parens +local read_braces = duffle.read_braces +local read_brackets = duffle.read_brackets +local scan_to_char = duffle.scan_to_char +local split_top_level_commas = duffle.split_top_level_commas +local trim = duffle.trim +local ensure_dir = duffle.ensure_dir +local write_file = duffle.write_file +local basename_no_ext = duffle.basename_no_ext + +-- Latency table lives in duffle.lua (shared between this pass + future +-- per-atom cycle-budget pass). Lazily read on first use. +local GTE_PIPELINE_LATENCY = duffle.GTE_PIPELINE_LATENCY + +-- ════════════════════════════════════════════════════════════════════════════ +-- Source walkers +-- ════════════════════════════════════════════════════════════════════════════ + +--- Walk source-as-written, return a list of `{line, name, body, body_off}` +--- for every `MipsAtom_(name) { body }` declaration. Comments / strings +--- inside `name` and `body` are tolerated; `body` is the raw brace +--- inner text (with surrounding whitespace, no leading/trailing `{` `}`). +--- `body_off` is the character offset of `body[1]` in `source_text`, +--- used to compute per-token line numbers later. +local function find_atom_bodies(source_text) + local line_of = duffle.LineIndex(source_text) + local out = {} + local len = #source_text + local i = 1 + while i <= len do + i = skip_ws_and_cmt(source_text, i); if i > len then break end + local ident, after = read_ident(source_text, i) + if not ident then + i = i + 1 + elseif ident == "MipsAtom_" then + local open = skip_ws_and_cmt(source_text, after) + if source_text:sub(open, open) ~= "(" then + i = open + 1 + else + local inner, after_paren = read_parens(source_text, open) + local n = 1 + while n <= #inner and inner:sub(n, n):match("[%s]") do n = n + 1 end + local m = n + while m <= #inner and inner:sub(m, m):match("[%w_]") do m = m + 1 end + local name = inner:sub(n, m - 1) + if name ~= "" then + local brace = scan_to_char(source_text, "{", after_paren) + if brace then + local body, after_brace = read_braces(source_text, brace) + -- `body` starts at brace + 1 (the char after `{`). + local body_off = brace + 1 + out[#out + 1] = { + line = line_of(i), + name = name, + body = body, + body_off = body_off, + } + i = after_brace + else + i = open + 1 + end + else + i = open + 1 + end + end + else + i = after + end + end + return out +end + +-- ════════════════════════════════════════════════════════════════════════════ +-- Body tokenizer (top-level comma splitter + per-token classification) +-- ════════════════════════════════════════════════════════════════════════════ + +--- Build a map: `body_relative_char_offset` -> `body_relative_line`. +--- Used by the checks to convert per-token offsets in the body to line +--- numbers relative to the start of `body`. The atom's source-line of +--- the body-start is added by the caller. +--- +--- Simple line-counting: count `\n` chars from offset 1 up to the +--- offset; that count + 1 is the line number (1-based). +local function build_body_line_index(body) + local index = {} + local len = #body + local newline_count = 0 + for i = 1, len do + if i > 1 then + index[i] = newline_count + 1 -- line of `i` relative to body + end + if body:byte(i) == 10 then -- '\n' + newline_count = newline_count + 1 + end + end + -- Offsets beyond the body still resolve to the final line + index[len + 1] = newline_count + 1 + return index +end + +--- Count of COP2-nop words contributed by a single top-level token. +-- `nop` -> 1 +-- `nop2` -> 2 (i.e. `nop, nop` baked into one asm arg) +-- `nop,` / `nop2,` -> same as above; strip trailing comma defensively +-- anything else -> 0 +-- +-- (Branch-delay-slot nops like `branch_*(..., nop)` are tokenized +-- separately by split_top_level_commas: the branch arg ends before +-- the trailing comma, and `nop` becomes its own token. So no special +-- handling is needed here.) +local function nop_word_count(token) + local s = trim(token) + -- strip trailing comma(s) (defensive against raw text via, but our + -- tokenize_body already strips them; this is a safety net) + s = s:gsub(",$", "") + s = trim(s) + if s == "nop" then return 1 end + if s == "nop2" then return 2 end + return 0 +end + +--- Tokenize the body inner-text into a flat list of `(token, body_rel_offset)` +--- pairs (nested parens/braces/brackets are honored; comments and strings +--- are skipped). `body_rel_offset` is the char offset within `body` of the +--- start of the token — callers add it to the atom's `body_off` to get +--- an absolute source position for line tracking. +local function tokenize_body(body) + local out = {} + local len = #body + local rel = 1 + while rel <= len do + -- Find next non-whitespace, non-comment start + local ws_end = skip_ws_and_cmt(body, rel) + if ws_end > rel then + rel = ws_end + end + if rel > len then break end + + -- Find comma/newline/semicolon after this token. Read balanced + -- groups so commas inside parens/braces/brackets aren't treated + -- as separators. Comments / strings are skipped. + local i = rel + while i <= len do + local c = body:byte(i) + if c == 44 then break end -- ',' + if c == 10 then break end -- '\n' + if c == 59 then break end -- ';' + if c == 40 then -- '(' + local _, a = read_parens(body, i); i = a + elseif c == 123 then -- '{' + local _, a = read_braces(body, i); i = a + elseif c == 91 then -- '[' + local _, a = read_brackets(body, i); i = a + elseif c == 34 or c == 39 then -- '"' or '\'' + i = duffle.skip_str_or_cmt(body, i) + 1 + else + i = i + 1 + end + end + -- Extract token [rel .. i-1] + local tok = trim(body:sub(rel, i - 1)) + if tok ~= "" then + out[#out + 1] = { tok = tok, rel = rel } + end + -- Move past the separator + if i <= len then + i = i + 1 + -- Also skip whitespace before next token + local w = skip_ws_and_cmt(body, i) + if w > i then i = w end + end + rel = i + end + return out +end + +-- ════════════════════════════════════════════════════════════════════════════ +-- Check #1: GTE pipeline-fill +-- ════════════════════════════════════════════════════════════════════════════ + +--- Walk the token list. Whenever we hit a `gte_cmdw_` token, count +--- consecutive nop words starting at the next token. If count < the +--- minimum declared in `GTE_PIPELINE_LATENCY[X]`, record a finding. +--- +--- Aliases (`gte_cmdw_rotate_translate_perspective_single` etc.) are +--- resolved against the lookup table directly; if a macro name is not +--- in the table, emit a soft warning (the user might have added a new +--- gte_cmdw_* but not updated duffle.lua). +local function check_gte_pipeline_fill(atoms, findings, line_of) + -- Walk the token list. Whenever we hit a `gte_cmdw_` token, + -- count consecutive `nop` words IMMEDIATELY PRECEDING it (the + -- source-level `nop2, gte_cmdw_X` idiom provides the pre-pipeline + -- fill that gte.h's wrapper functions provide internally). If + -- count < `GTE_PIPELINE_LATENCY[X]`, record a finding. + -- + -- We count nops going backwards from the cmdw token, stopping at + -- the first non-nop token. Tokens like `mem_share` or `port_write` + -- (any non-nop) break the count. `gte_mv_to_data_r` (writes to + -- C2_DR registers) are non-nops in this sense -- they count as + -- "previous GTE state" but don't themselves count as pipeline + -- fill. + for _, a in ipairs(atoms) do + local tokens = tokenize_body(a.body) + local line_in_body = build_body_line_index(a.body) + local tn = #tokens + local ti = 1 + while ti <= tn do + local tok = tokens[ti].tok + local cmdw_full = tok:match("^(gte_cmdw_[%w_]+)%s*[,%)]") + or tok:match("^(gte_cmdw_[%w_]+)%s*$") + if cmdw_full then + local variant = cmdw_full:match("^gte_cmdw_(.+)$") + local need = GTE_PIPELINE_LATENCY[cmdw_full] + if need == nil then + -- alias or new gte_cmdw_ not yet in latency table + local line = a.line + line_in_body[tokens[ti].rel] + findings[#findings + 1] = { + atom = a.name, + line = line, + check = "gte_pipeline_fill", + kind = "warning", + msg = string.format( + "%s at line %d uses `gte_cmdw_%s` but that macro is not in duffle.GTE_PIPELINE_LATENCY -- add a min_nops entry", + a.name, line, variant), + } + ti = ti + 1 + elseif need > 0 then + -- Count consecutive nops immediately BEFORE the cmdw + -- token. We walk tokens[ti - n] backwards, accumulating + -- nop_word_count, stopping at the first non-nop. + local have = 0 + local where_ti = ti - 1 + while where_ti >= 1 do + local n = nop_word_count(tokens[where_ti].tok) + if n == 0 then break end + have = have + n + where_ti = where_ti - 1 + end + if have < need then + local line = a.line + line_in_body[tokens[ti].rel] + findings[#findings + 1] = { + atom = a.name, + line = line, + check = "gte_pipeline_fill", + kind = "error", + msg = string.format( + "%s at line %d needs %d nop word%s immediately BEFORE `gte_cmdw_%s`; only %d found", + a.name, line, need, need == 1 and "" or "s", variant, have), + } + end + ti = ti + 1 + else + ti = ti + 1 + end + else + ti = ti + 1 + end + end + end +end + +-- ════════════════════════════════════════════════════════════════════════════ +-- Check #2: mac_yield uniformity +-- ════════════════════════════════════════════════════════════════════════════ + +--- Every atom body must contain exactly one `mac_yield()` call and it +--- must be the LAST top-level token in the body (so the tape runtime +--- can pick up cleanly at the next atom's bound registers). +--- +--- Empty bodies are not currently flagged — runtime infrastructure +--- atoms like `MipsAtom_(yield) { mac_yield() }` and `MipsAtom_(tape_exit) +--- { jump_reg(rret_addr), nop }` are valid as-is; mac_yield at the end +--- is the contract. +local function check_mac_yield_uniformity(atoms, findings) + for _, a in ipairs(atoms) do + local tokens = tokenize_body(a.body) + local line_in_body = build_body_line_index(a.body) + local count = 0 + local last_idx = 0 + for i, t in ipairs(tokens) do + local tok = t.tok + -- Match `mac_yield(...)` or just `mac_yield`. The bareword + -- variant is rare in modern style but tolerated. + if tok:match("^mac_yield%s*%(") or tok == "mac_yield" then + count = count + 1 + last_idx = i + end + end + local function line_for(idx) + return a.line + line_in_body[tokens[idx].rel] + end + if count == 0 then + findings[#findings + 1] = { + atom = a.name, + line = a.line, + check = "mac_yield_uniformity", + msg = string.format( + "%s at line %d has no `mac_yield()`; every atom must hand control to the next via mac_yield at end", + a.name, a.line), + } + elseif count > 1 then + findings[#findings + 1] = { + atom = a.name, + line = line_for(last_idx), + check = "mac_yield_uniformity", + msg = string.format( + "%s at line %d has %d `mac_yield()` calls; exactly 1 is allowed", + a.name, line_for(last_idx), count), + } + elseif last_idx < #tokens then + -- 1 call, but not the last token. Check if there's anything + -- AFTER mac_yield (compiler tolerance is `macro_yield, nop`). + -- We DON'T fail if the post-token is just `nop` or `nop2` or + -- a branch with `, nop` delay slot -- it's the standard + -- "yield, then BD nop" idiom. + local post_non_nop = false + for j = last_idx + 1, #tokens do + local t = tokens[j].tok + if t ~= "" and t ~= "nop" and t ~= "nop2" and not t:match("%,%s*nop%)%s*$") then + post_non_nop = true + break + end + end + if post_non_nop then + findings[#findings + 1] = { + atom = a.name, + line = line_for(last_idx), + check = "mac_yield_uniformity", + msg = string.format( + "%s at line %d has `mac_yield()` at token %d/%d; the yield must be the LAST non-nop token in the body", + a.name, line_for(last_idx), last_idx, #tokens), + } + end + end + end +end + +-- ════════════════════════════════════════════════════════════════════════════ +-- Per-source validation +-- ════════════════════════════════════════════════════════════════════════════ + +local function validate(ctx, src) + local source = src.text + local atoms = find_atom_bodies(source) + + local findings = {} + check_gte_pipeline_fill(atoms, findings) + check_mac_yield_uniformity(atoms, findings) + + -- Phase 2 (ABI handoff / GPU port-store shape) and Phase 3 (cycle + -- budget) hooks go here when those tracks are reactivated. + -- check_abi_handoff(atoms, findings) + -- check_gpu_portstore_shape(atoms, findings) + -- emit per-atom cycle counts via INSTRUCTION_LATENCY table + + local errors = {} + local warnings = {} + local info = {} + for _, f in ipairs(findings) do + -- Per-finding severity is set by the check via `f.kind` + -- ("error" or "warning"). A `gte_pipeline_fill` finding can be + -- either severity (errors for missing nops; warnings for unknown + -- cmdw macros not in the latency table). Bin by `kind`, not by + -- check name. + if f.kind == "error" then + errors[#errors + 1] = { line = f.line, msg = f.msg } + else + warnings[#warnings + 1] = { line = f.line, msg = f.msg } + end + end + info[#info + 1] = { + line = 0, + msg = string.format("scanned: %d atom bodies; %d findings", #atoms, #findings), + } + + return { + atoms = atoms, + findings = findings, + errors = errors, + warnings = warnings, + info = info, + } +end + +-- ════════════════════════════════════════════════════════════════════════════ +-- Per-source output: build/gen/.static_analysis.txt +-- ════════════════════════════════════════════════════════════════════════════ + +local function emit_static_analysis_txt(ctx, src, result) + local out_path = ctx.out_root .. "/" .. src.basename .. ".static_analysis.txt" + if ctx.dry_run then return out_path end + ensure_dir(ctx.out_root) + + local lines = {} + local function add(s) lines[#lines + 1] = s end + + add("========================================================") + add("STATIC ANALYSIS PASS -- " .. src.path) + add("========================================================") + add("") + add(string.format("Atoms: %d Findings: %d Errors: %d Warnings: %d", + #result.atoms, #result.findings, #result.errors, #result.warnings)) + add("") + + -- Group findings by atom for readability + local by_atom = {} + for _, f in ipairs(result.findings) do + by_atom[f.atom] = by_atom[f.atom] or {} + by_atom[f.atom][#by_atom[f.atom] + 1] = f + end + + if next(by_atom) == nil then + add(" (no findings -- every atom passed all checks)") + else + add("── Findings by atom ─────────────────────────────────────") + for _, a in ipairs(result.atoms) do + local fs = by_atom[a.name] + if fs then + add(string.format(" %s line %d", a.name, a.line)) + for _, f in ipairs(fs) do + add(string.format(" [%s] %s", f.check, f.msg)) + end + end + end + end + + add("") + add("── Errors ──────────────────────────────────────────────") + if #result.errors == 0 then add(" (none)") end + for _, e in ipairs(result.errors) do + add(string.format(" X line %d %s", e.line, e.msg)) + end + + add("") + add("── Warnings ────────────────────────────────────────────") + if #result.warnings == 0 then add(" (none)") end + for _, w in ipairs(result.warnings) do + add(string.format(" ! line %d %s", w.line, w.msg)) + end + + add("") + add("── Info ────────────────────────────────────────────────") + for _, i_ in ipairs(result.info) do + add(string.format(" %s", i_.msg)) + end + + write_file(out_path, table.concat(lines, "\n") .. "\n") + return out_path +end + +-- ════════════════════════════════════════════════════════════════════════════ +-- M.run — orchestrator entry +-- ════════════════════════════════════════════════════════════════════════════ --- @class M @@ -10,7 +499,25 @@ local M = {} --- @param ctx PassCtx --- @return PassResult function M.run(ctx) - return { outputs = {}, errors = {}, warnings = {} } + local outputs = {} + local errors = {} + local warnings = {} + + for _, src in ipairs(ctx.sources) do + local result = validate(ctx, src) + local out_path = emit_static_analysis_txt(ctx, src, result) + if out_path then + table.insert(outputs, { static_analysis_txt = out_path }) + end + for _, e in ipairs(result.errors) do + errors[#errors + 1] = { line = e.line, msg = e.msg } + end + for _, w in ipairs(result.warnings) do + warnings[#warnings + 1] = { line = w.line, msg = w.msg } + end + end + + return { outputs = outputs, errors = errors, warnings = warnings } end -return M \ No newline at end of file +return M