--- audit_lua_nesting.lua — Walk Lua source files and flag any block nesting deeper than 5 levels. --- --- Usage: --- luajit scripts/audit_lua_nesting.lua scripts/duffle.lua scripts/ps1_meta.lua --- luajit scripts/audit_lua_nesting.lua scripts/passes/ --- --- Output: for each file, a list of {line, depth} entries where depth > 5. --- Returns exit code 1 if any violations found, 0 if clean. --- --- **Implementation**: a hand-rolled depth tracker that counts: --- - `do`, `function`, `if`, `for`, `while`, `repeat` -> depth +1 --- - `end`, `until` -> depth -1 --- - `else`, `elseif` -> depth unchanged --- --- **Caveats**: doesn't fully handle string/comment state (will miscount braces inside multi-line strings or block comments). --- For our metaprogram files (no embedded code generation), this is acceptable. local M = {} local BLOCK_OPEN = { ["do"] = true, ["function"] = true, ["if"] = true, ["for"] = true, ["while"] = true, ["repeat"] = true, } local function is_block_close(token) return token == "end" or token == "until" end -- (internal) Walk one source file and return a list of -- {line, depth, token} entries where depth > max_nesting. local function audit_file(path, max_nesting) local f = io.open(path, "r") if not f then error("Cannot open " .. path) end local content = f:read("*a") f:close() local violations = {} local depth = 0 local line = 1 local pos = 1 local src_len = #content local token_idx = 0 local function read_ident_at(start_pos) local ident_start = start_pos if ident_start > src_len then return nil end local first_ch = content:sub(ident_start, ident_start) if not (first_ch:match("[%a_]")) then return nil end local scan = start_pos + 1 while scan <= src_len do local ch = content:sub(scan, scan) if not (ch:match("[%w_]")) then break end scan = scan + 1 end return content:sub(ident_start, scan - 1), scan end -- Skip past a string literal or comment starting at `start_pos`. -- Returns the position just past the construct, or nil if `start_pos` -- is not the start of a string/comment. local function skip_string_or_comment(start_pos) local ch = content:sub(start_pos, start_pos) if ch == '"' or ch == "'" then local scan = start_pos + 1 while scan <= src_len do local c = content:sub(scan, scan) if c == "\\" then scan = scan + 2 elseif c == ch then return scan + 1 else scan = scan + 1 end end return src_len + 1 elseif ch == "-" and content:sub(start_pos + 1, start_pos + 1) == "-" then local scan = start_pos + 2 if content:sub(scan, scan + 1) == "[[" and content:sub(scan + 2, scan + 3) == "[" then -- Long bracket comment [==[ ... ]==] scan = scan + 2 local eq = "" while content:sub(scan, scan) == "=" do eq = eq .. "=" scan = scan + 1 end local close_marker = "]" .. eq .. "]" local close_pos = content:find(close_marker, scan, true) if close_pos then return close_pos + #close_marker else return src_len + 1 end else while scan <= src_len and content:sub(scan, scan) ~= "\n" do scan = scan + 1 end return scan + 1 end elseif ch == "[" and content:sub(start_pos + 1, start_pos + 1) == "[" then local scan = start_pos + 2 local eq = "" while content:sub(scan, scan) == "=" do eq = eq .. "=" scan = scan + 1 end local close_marker = "]" .. eq .. "]" local close_pos = content:find(close_marker, scan, true) if close_pos then return close_pos + #close_marker else return src_len + 1 end end return nil end while pos <= src_len do local ch = content:sub(pos, pos) if ch == "\n" then line = line + 1 end local skip_to = skip_string_or_comment(pos) if skip_to then for scan = pos, skip_to - 1 do if content:sub(scan, scan) == "\n" then line = line + 1 end end pos = skip_to elseif ch:match("[%a_]") then local tok, next_pos = read_ident_at(pos) token_idx = token_idx + 1 if BLOCK_OPEN[tok] then depth = depth + 1 if depth > max_nesting then violations[#violations + 1] = { line = line, depth = depth, token = tok, } end elseif is_block_close(tok) then depth = depth - 1 end pos = next_pos else pos = pos + 1 end end return violations end --- Audit one file. Returns nil if clean, else a list of violations. --- @param path string --- @param max_nesting integer -- default 5 --- @return table|nil function M.audit(path, max_nesting) local violations = audit_file(path, max_nesting or 5) if #violations == 0 then return nil end return violations end -- Module CLI. if arg and arg[1] then local max_nesting = 5 local files = {} for arg_idx = 1, #arg do if arg[arg_idx] == "--max" and arg[arg_idx + 1] then max_nesting = tonumber(arg[arg_idx + 1]) or 5 else files[#files + 1] = arg[arg_idx] end end -- Accept either a directory or a file path. Directory args are -- expanded via lfs.dir (native, no subprocess). local lfs = require("lfs") local function is_dir(p) return lfs.attributes(p, "mode") == "directory" end local function list_lua(dir) local out = {} if not is_dir(dir) then return out end for entry in lfs.dir(dir) do if entry:match("%.lua$") then out[#out + 1] = dir .. "/" .. entry end end return out end local to_check = {} for _, f in ipairs(files) do if is_dir(f) then for _, sub in ipairs(list_lua(f)) do to_check[#to_check + 1] = sub end else to_check[#to_check + 1] = f end end local total_violations = 0 for _, f in ipairs(to_check) do local v = M.audit(f, max_nesting) if v then io.write(string.format("\n%s\n", f)) for _, x in ipairs(v) do io.write(string.format(" line %d: depth %d (after '%s')\n", x.line, x.depth, x.token)) end total_violations = total_violations + #v end end if total_violations == 0 then io.write("OK: no files exceed max nesting of " .. max_nesting .. "\n") os.exit(0) else io.write(string.format("\n%d nesting violation(s) found.\n", total_violations)) os.exit(1) end end return M