--- ps1_meta.lua — Orchestrator entry point for the tape-atom metaprogram. --- --- Dispatches to pass modules under `scripts/passes/`, resolving dependencies topologically (Kahn's algorithm + cycle detection). --- --- **Architecture**: --- - **PASSES table** — declarative dep graph (data, not code). --- - **FLAG_HANDLERS table** — per-flag CLI dispatchers (handler-map pattern; replaces an 8-way if/elseif chain). --- - **parse_args** → **build_ctx** (just opens + reads source files; no inline scanning) → **topo_sort** → **dispatch_passes**. --- - The first pass in the dep graph is `scan-source` (see `passes/scan_source.lua`). --- It calls `duffle.scan_source` once per source to produce the fat `SourceScan` payload, which is attached to each `src.scan`. --- Every other pass that reads source structure depends on `scan-source` and consumes `src.scan` as a read-only. --- --- **Conventions**: tabs (1/level), EmmyLua annotations, no regex, --- Lua 5.3 compatible. --- -- ════════════════════════════════════════════════════════════════════════════ -- Module-scope requires + package.path setup -- ════════════════════════════════════════════════════════════════════════════ -- Bootstrap: load `duffle_paths.lua` via this script's own path. -- Use `arg[0]` when this file is the entry script (`arg[0]` ends in -- "ps1_meta.lua"); fall back to `debug.getinfo(1, "S").source` when this -- file is being dofile()'d or require()'d (in which case `arg[0]` is the -- *caller's* path, not ours). -- -- That single statement: (a) sets `package.path` + `package.cpath` -- (via cached `git rev-parse`), (b) at the bottom returns `require("duffle")`. -- So the dofile's return value is the duffle module. local _is_entry_script = arg and arg[0] and arg[0]:match("ps1_meta%.lua$") ~= nil local _bootstrap_src if _is_entry_script then _bootstrap_src = arg[0] else -- debug.getinfo(1, "S").source returns "@" for the current chunk; -- strip the leading "@" so the directory match works in both cases. _bootstrap_src = debug.getinfo(1, "S").source:sub(2) end local duffle = dofile((_bootstrap_src:match("(.*[/\\])") or "./") .. "duffle_paths.lua") -- ════════════════════════════════════════════════════════════════════════════ -- Constants -- ════════════════════════════════════════════════════════════════════════════ -- Exit codes (per the --help text and the post-build summary convention). local EXIT_OK = 0 local EXIT_VALIDATION_ERRORS = 1 local EXIT_INTERNAL_ERROR = 2 -- Default --out-root value if not provided. local DEFAULT_OUT_ROOT = "build/gen" -- Sentinel for "all passes" in `PASS_FLAG_TO_NAME`. Distinguishes `--all` from the per-pass flags (which map to individual pass names). local ALL_PASSES_SENTINEL = "__all__" -- Sentinel key for the pass-flag dispatcher in `FLAG_HANDLERS`. -- The actual pass names are looked up via `PASS_FLAG_TO_NAME`, not direct dispatch, so this key never matches a real flag. local PASS_FLAG_DISPATCH_KEY = "__pass__" -- ════════════════════════════════════════════════════════════════════════════ -- Type declarations -- ════════════════════════════════════════════════════════════════════════════ --- @class PassDescriptor --- @field module string -- module name passed to require() --- @field kind string -- "shared" | "header-output" | "validation" | "report" --- @field deps string[] -- names of upstream passes --- @field groups string[]? -- OPTIONAL build-phase groups this pass is a root of --- -- (e.g. { "pre-link" }, { "post-link" }); absent ⇒ dependency-only --- @field desc string -- human description (used by --help + ASCII graph) --- @field out PassOutput[] -- output paths (used by --dry-run + report) --- @class PassOutput --- @field kind string -- "header" | "report" --- @field path_template string -- e.g. "/gen/.macs.h" --- @class SourceFile --- @field path string -- absolute path to the source file --- @field text string -- the full source text --- @field dir string -- the directory containing the source --- @field basename string -- filename without extension --- @class PassCtx --- @field sources SourceFile[] -- all source files in the build --- @field metadata_path string -- path to word_count.metadata.h --- @field shared table -- cross-pass shared state --- @field shared.word_counts table -- populated by word-counts pass --- @field out_root string -- output root (e.g. "build/gen") --- @field project_root string -- project root (e.g. "code/") --- @field upstream table -- per-pass output accumulator --- @field flags table -- CLI flags + per-pass stash --- @field dry_run boolean -- if true, compute but don't write --- @field verbose boolean -- if true, log diagnostic info --- @class PassOutputEntry --- @field [string] string -- dynamic shape; key is the output kind -- (e.g. "macs_h", "offsets_h", "errors_h", "annotations_txt", "static_analysis_txt", "summary_txt"), value is the path --- @class Finding --- @field line integer -- source line (or 0 for pass-level) --- @field msg string -- finding message --- @class PassResult --- @field outputs PassOutputEntry[] -- emitted file paths --- @field errors Finding[] -- build-stops (per-pass kind policy) --- @field warnings Finding[] -- informational --- @class ParsedArgs --- @field requested_set string[] -- pass names to run (explicit --all expanded) --- @field sources string[] -- --source values --- @field metadata string -- --metadata value --- @field out_root string -- --out-root value (default "build/gen") --- @field project_root string -- --project-root value (default dirname(metadata)) --- @field dry_run boolean -- if true, compute but don't write --- @field verbose boolean -- if true, log diagnostic info -- ════════════════════════════════════════════════════════════════════════════ -- PASSES table (data, not code) — the orchestrator's dep graph -- ════════════════════════════════════════════════════════════════════════════ -- Build-phase groups: each PASSES row may declare membership in one or more -- named groups via `groups = { ... }`. The CLI flags --pre-link and -- --post-link request the *roots* of their group; topo_sort then closes -- transitive dependencies from those roots, and dispatch_passes runs every -- pass in the resulting closure without phase-filtering. -- -- A row without a `groups` entry is dependency-only: it runs only when a -- transitive dep requests it, but it remains directly requestable through -- its explicit CLI flag (e.g. --atoms-source-map, --scan-source). local PASSES = { ["scan-source"] = { module = "passes.scan_source", kind = "shared", deps = {}, desc = "Walk each source once; produce the fat SourceScan payload for downstream passes", out = {}, }, ["word-counts"] = { module = "passes.word_count_eval", kind = "shared", deps = {}, desc = "Build the shared metadata table (metadata.h + .macs.h)", out = {}, }, components = { module = "passes.components", kind = "header-output", deps = {"scan-source", "word-counts"}, desc = "Emit mac_X macros from MipsAtomComp_ declarations", out = { { kind = "header", path_template = "/gen/.macs.h" } }, }, annotation = { module = "passes.annotation", kind = "validation", deps = {"scan-source", "word-counts"}, desc = "Validate atom DSL usage; emit errors.h + annotations.txt", out = { { kind = "report", path_template = "/.errors.h" }, { kind = "report", path_template = "/.annotations.txt" }, }, }, offsets = { module = "passes.offsets", kind = "header-output", deps = {"scan-source", "word-counts", "components"}, groups = { "pre-link" }, desc = "Compute branch offsets for atom_label / atom_offset", out = { { kind = "header", path_template = "/gen/.offsets.h" } }, }, ["static-analysis"] = { module = "passes.static_analysis", kind = "validation", deps = {"scan-source", "word-counts", "components"}, desc = "Static analysis: GTE pipeline-fill, mac_yield uniformity, ABI handoff, GPU port-store shape, per-atom cycle budget, type consistency", out = { { kind = "report", path_template = "/.static_analysis.txt" } }, }, ["atoms-source-map"] = { module = "passes.atoms_source_map", kind = "header-output", deps = {"word-counts", "components"}, desc = "Emit gen/.atoms.sourcemap.txt (per-.word C source line map for gdb debugging) AND gen/.atoms.provenance.txt (per-.word provenance; each word tagged with its call-site file:line and, when emitted by a mac_X(...) component invocation, the component's definition file:line). Consumed by passes/dwarf_injection.lua to synthesize DW_TAG_inlined_subroutine instances for source-level Step Into on component invocations.", out = { { kind = "report", path_template = "/.atoms.sourcemap.txt" }, { kind = "report", path_template = "/.atoms.provenance.txt" }, }, }, ["dwarf-injection"] = { module = "passes.dwarf_injection", kind = "shared", deps = {"scan-source", "atoms-source-map"}, groups = { "post-link" }, desc = "Inject per-atom .debug_line + .debug_aranges (F') + per-atom .debug_info subprogram + per-wave-context-reg .debug_info variables (G') into the ELF (post-link; writes 7 section .bin blobs plus one deterministic .gdbinit sidecar). (rbind composite) reads ctx.sources[i].scan to find atom_bind(Binds_X) atoms + their Binds_X struct fields; emits per-Binds_X DW_TAG_structure_type DIEs + per-rbind-atom DW_TAG_variable 'bind_args' DIEs with piece-chain DW_OP_bregN/DW_OP_piece location expressions.", out = { { kind = "report", path_template = "/.dwarf_line.bin" }, { kind = "report", path_template = "/.dwarf_aranges.bin" }, { kind = "report", path_template = "/.dwarf_rnglists.bin" }, { kind = "report", path_template = "/.dwarf_abbrev.bin" }, { kind = "report", path_template = "/.dwarf_info.bin" }, { kind = "report", path_template = "/.dwarf_str.bin" }, { kind = "report", path_template = "/.dwarf_loc.bin" }, { kind = "report", path_template = "/.gdbinit" }, }, }, report = { module = "passes.report", kind = "report", deps = {"annotation", "static-analysis"}, groups = { "pre-link" }, desc = "Render the per-project summary", out = { { kind = "report", path_template = "/annotation_validation.txt" } }, }, } -- ──────────────────────────────────────────────────────────────────────────── -- Phase-root selection: derive the sorted set of roots belonging to a named -- build-phase group, then append them to `args.requested_set`. topo_sort -- closes the transitive deps from there; dispatch_passes runs every resolved -- pass without phase-filtering. -- ──────────────────────────────────────────────────────────────────────────── --- @param group_name string -- the build-phase group ("pre-link" | "post-link") --- @return string[] -- sorted root pass names belonging to that group local function roots_for_group(group_name) local names = {} for name, pass in pairs(PASSES) do if pass.groups then for _, g in ipairs(pass.groups) do if g == group_name then names[#names + 1] = name break end end end end table.sort(names) return names end --- Append every root belonging to `group_name` to `args.requested_set`. --- Errors loudly if no PASSES row declares the group, so a typo'd or --- future-removed group name cannot silently fall through to pre-link --- (or any other default) and dispatch nothing. --- @param args ParsedArgs --- @param group_name string local function request_roots_for_group(args, group_name) local roots = roots_for_group(group_name) if #roots == 0 then error(string.format( "ps1_meta: build-phase group %q has zero roots in PASSES; " .. "check PASSES rows for a `groups = { %q }` field", group_name, group_name)) end for _, name in ipairs(roots) do args.requested_set[#args.requested_set + 1] = name end end -- Pass-kind taxonomy: which kinds stop the build on errors? local PASS_KIND_STOP_ON_ERROR = { ["shared"] = false, ["header-output"] = true, ["validation"] = true, ["report"] = false, } -- Closed set of CLI flags -> pass names. -- Per-pass flags (e.g. --word-counts) live here; phase flags (--pre-link, -- --post-link, --all) live in FLAG_HANDLERS because they own side effects -- or invoke group-derivation logic. --dwarf-injection is *also* a per-pass -- opt-in flag, but its selection + opt-in state are both owned by the -- explicit FLAG_HANDLERS entry below (it sets args.flags.dwarf_injection -- and appends "dwarf-injection" to requested_set), so it is intentionally -- absent from this table. local PASS_FLAG_TO_NAME = { ["--word-counts"] = "word-counts", ["--components"] = "components", ["--validate"] = "annotation", ["--offsets"] = "offsets", ["--static-analysis"] = "static-analysis", ["--atoms-source-map"] = "atoms-source-map", ["--report"] = "report", ["--scan-source"] = "scan-source", ["--all"] = ALL_PASSES_SENTINEL, } --- Append every pass name to args.requested_set. Names are derived from --- PASSES (no parallel name list); used by --all and by any caller that --- wants the full closure. --- @param args ParsedArgs local function request_all_passes(args) local names = {} for name in pairs(PASSES) do names[#names + 1] = name end table.sort(names) for _, n in ipairs(names) do args.requested_set[#args.requested_set + 1] = n end end -- Per-flag handlers. Each handler takes (args, argv, arg_idx) and returns the new arg_idx (so multi-arg flags like --source FILE advance it). -- Returning nil + os.exit() handles termination flags (--help). -- This replaces the 8-way `if/elseif/elseif...` chain that nested 4 levels deep and made the dispatch logic hard to scan. local FLAG_HANDLERS = {} -- ════════════════════════════════════════════════════════════════════════════ -- CLI parsing -- ════════════════════════════════════════════════════════════════════════════ --- Print the CLI usage to stdout and exit 0. local function print_help() io.write([[ ps1_meta.lua - Tape-atom metaprogram orchestrator USAGE: ps1_meta.lua [PASS_FLAGS] [COMMON_FLAGS] PASS_FLAGS: Pick a phase or one-or-more individual passes: --pre-link [phase; default] Run the pre-link group + transitive deps. The root set is data-driven from each PASSES row's `groups` field; no parallel name list is maintained. --post-link [phase] Run the post-link group + transitive deps. Requires --elf. Sets --gdb-runtime and --dwarf-injection opt-in flags as well. --all Select every row of the PASSES table. Pass-local opt-in guards remain active, so --dwarf-injection still requires --elf and --gdb-runtime still requires a runtime emission. Or pick any subset: --scan-source Scan sources into the fat SourceScan payload --word-counts Load metadata.h + scan for existing .macs.h --components Generate /gen/.macs.h --validate Run atom annotation DSL validation --offsets Generate /gen/.offsets.h --atoms-source-map Generate .atoms.sourcemap.txt per source --dwarf-injection [opt-in] Select the post-link dwarf-injection pass + set the opt-in flag. Requires --elf. --static-analysis Static analysis: GTE pipeline-fill, mac_yield, ABI handoff, cycle budget --report Render per-project summary COMMON_FLAGS: --source FILE Source file to process (repeatable) --metadata PATH Path to metadata.h (required) --out-root DIR Output root for reports (default: build/gen) --project-root DIR Project root for .macs.h scan (default: dirname(metadata)) --gdb-runtime Also emit /gdb_tape_atoms_runtime.gdb (post-link, requires --elf) --elf PATH Path to linked .elf (for --gdb-runtime / --dwarf-injection) --dry-run Print dep order + ASCII graph; exit 0 without running --verbose Print per-pass debug output --help Show this help and exit EXIT CODES: 0 All requested passes succeeded 1 Validation errors found 2 Metaprogram internal error EXAMPLE: ps1_meta.lua --pre-link --metadata metadata.h --source code/foo.c --source code/bar.c ps1_meta.lua --post-link --metadata metadata.h --source code/foo.c --source code/bar.c --elf build/hello_gte.elf ps1_meta.lua --all --metadata metadata.h --source code/foo.c --source code/bar.c ]]) end -- Per-flag handlers. Each takes (args, argv, arg_idx) and returns the new arg_idx (so multi-arg flags like --source FILE advance it). -- Termination flags like --help call os.exit() instead. -- This replaces the 8-way `if/elseif/elseif...` chain that nested 4 levels deep and made the dispatch logic hard to scan. -- -- Populated AFTER print_help so the --help handler can reference it as an upvalue (Lua resolves locals at closure-call time, -- but if the closure is defined before the local, it falls back to _G). FLAG_HANDLERS["--help"] = function(args) print_help() os.exit(0) end FLAG_HANDLERS["--dry-run"] = function(args) args.dry_run = true end FLAG_HANDLERS["--verbose"] = function(args) args.verbose = true end FLAG_HANDLERS["--source"] = function(args, argv, arg_idx) args.sources[#args.sources + 1] = argv[arg_idx + 1]; return arg_idx + 1 end FLAG_HANDLERS["--metadata"] = function(args, argv, arg_idx) args.metadata = argv[arg_idx + 1]; return arg_idx + 1 end FLAG_HANDLERS["--out-root"] = function(args, argv, arg_idx) args.out_root = argv[arg_idx + 1]; return arg_idx + 1 end FLAG_HANDLERS["--project-root"] = function(args, argv, arg_idx) args.project_root = argv[arg_idx + 1]; return arg_idx + 1 end -- Per-pass stash flags. Read by `passes/atoms_source_map.lua` to opt into the post-link gdb-runtime emission. -- Same shape as the existing per-flag handlers. mutates `args.flags` (which propagates into `ctx.flags`). FLAG_HANDLERS["--gdb-runtime"] = function(args) args.flags = args.flags or {}; args.flags.gdb_runtime = true end FLAG_HANDLERS["--elf"] = function(args, argv, arg_idx) args.flags = args.flags or {}; args.flags.elf_path = argv[arg_idx + 1]; return arg_idx + 1 end -- Enable DWARF injection (default OFF). Opts in to the post-link pass and -- sets the flag in one shot — the explicit handler below owns both -- selection and opt-in state, so --dwarf-injection is intentionally absent -- from PASS_FLAG_TO_NAME. FLAG_HANDLERS["--dwarf-injection"] = function(args) args.flags = args.flags or {} args.flags.dwarf_injection = true args.requested_set[#args.requested_set + 1] = "dwarf-injection" end -- Build-phase flags: --pre-link and --post-link request the roots of their -- declared groups (see roots_for_group). topo_sort closes transitive deps -- from those roots; dispatch_passes runs every pass in the resolved -- closure without phase-filtering. FLAG_HANDLERS["--pre-link"] = function(args) request_roots_for_group(args, "pre-link") end -- Batch post-link phase: gdb-runtime + dwarf-injection in one luajit cold -- start. Sets the same opt-in flags as --gdb-runtime + --dwarf-injection -- and selects the post-link build-phase group. -- --elf is required; parse_args enforces it after all flags are parsed. FLAG_HANDLERS["--post-link"] = function(args) args.flags = args.flags or {} args.flags.gdb_runtime = true args.flags.dwarf_injection = true request_roots_for_group(args, "post-link") end -- G' (atom locals) is now consolidated into --dwarf-injection; no separate flag. -- Pass-flag handler. Reads the closed-set table, expands --all, appends to requested_set. FLAG_HANDLERS[PASS_FLAG_DISPATCH_KEY] = function(args, a) local name = PASS_FLAG_TO_NAME[a] if name == ALL_PASSES_SENTINEL then request_all_passes(args) return end args.requested_set[#args.requested_set + 1] = name end --- Parse argv into a structured table. Validates against a closed enum. --- @param argv string[] --- @return ParsedArgs local function parse_args(argv) local args = { requested_set = {}, sources = {}, metadata = nil, out_root = DEFAULT_OUT_ROOT, project_root = nil, dry_run = false, verbose = false, } local pos = 1 while pos <= #argv do local a = argv[pos] local handler = FLAG_HANDLERS[a] if handler then pos = handler(args, argv, pos) or pos elseif PASS_FLAG_TO_NAME[a] then FLAG_HANDLERS[PASS_FLAG_DISPATCH_KEY](args, a) else io.stderr:write("ps1_meta: unknown flag '" .. a .. "'\n") io.stderr:write("Run with --help for usage.\n") os.exit(EXIT_INTERNAL_ERROR) end pos = pos + 1 end -- Default: --pre-link if no explicit pass flags were given. The first -- invocation of a build is always pre-link, so this avoids silently -- also invoking post-link work in builds without an ELF artifact. if #args.requested_set == 0 then request_roots_for_group(args, "pre-link") end -- Defaults: project_root = dirname(metadata). if args.metadata and not args.project_root then local d = duffle.dirname(args.metadata) if #d > 0 and (d:sub(-1) == "/" or d:sub(-1) == "\\") then d = d:sub(1, -2) end args.project_root = duffle.dirname(d) end if not args.metadata then io.stderr:write("ps1_meta: --metadata PATH is required\n") os.exit(EXIT_INTERNAL_ERROR) end if #args.sources == 0 then io.stderr:write("ps1_meta: at least one --source FILE is required\n") os.exit(EXIT_INTERNAL_ERROR) end -- Post-link opt-ins (--gdb-runtime, --dwarf-injection) write output that -- depends on the linked ELF. Without --elf the metaprogram can't satisfy -- those requests, so refuse loud and early. This covers the explicit -- --post-link batch, --dwarf-injection by itself, and --gdb-runtime by -- itself. local flags = args.flags or {} local elf_path = flags.elf_path local has_elf = type(elf_path) == "string" and #elf_path > 0 local post_links = flags.gdb_runtime or flags.dwarf_injection if post_links and not has_elf then io.stderr:write("ps1_meta: --elf PATH is required for post-link output\n") os.exit(EXIT_INTERNAL_ERROR) end return args end -- ════════════════════════════════════════════════════════════════════════════ -- Build ctx from parsed args -- ════════════════════════════════════════════════════════════════════════════ --- Build the PassCtx from parsed args. Reads each source file once at startup; --- passes consume `src.text`, not the path (path is preserved for error reporting). --- @param args ParsedArgs --- @return PassCtx local function build_ctx(args) local sources = {} for _, path in ipairs(args.sources) do -- lfs handles path metadata and directories, not file-content streams. -- Keep this io.open local so this entry point preserves its tailored diagnostic and exit path below. local f = io.open(path, "r") if not f then io.stderr:write("ps1_meta: cannot open --source " .. path .. "\n") os.exit(EXIT_INTERNAL_ERROR) end local text = f:read("*a") f:close() local dir = duffle.dirname(path) local basename = duffle.basename_no_ext(path) if #dir > 0 and (dir:sub(-1) == "/" or dir:sub(-1) == "\\") then dir = dir:sub(1, -2) end -- src.scan is populated by the "scan-source" pass (the first pass in the dep graph). -- build_ctx just opens + reads the files; the scan itself happens in the pass module, not inline in the orchestrator. sources[#sources + 1] = { path = path, text = text, dir = dir, basename = basename, } end -- Pre-compute the per-directory grouping once (Fleury: expose structure). -- Three passes (annotation, report, static-analysis) call group_sources_by_dir with the same ctx.sources; -- computing it here and stashing on ctx.by_dir eliminates 2 redundant calls. local by_dir = duffle.group_sources_by_dir(sources) return { sources = sources, by_dir = by_dir, metadata_path = args.metadata, shared = {}, upstream = {}, out_root = args.out_root, project_root = args.project_root, flags = args.flags or {}, dry_run = args.dry_run, verbose = args.verbose, } end -- ════════════════════════════════════════════════════════════════════════════ -- Topological sort (Kahn's algorithm + cycle detection) -- ════════════════════════════════════════════════════════════════════════════ --- Topologically sort the requested pass set, augmented with all transitive deps. --- Detects cycles and errors out with details. --- @param passes table --- @param requested_set string[] --- @return string[] -- execution order --- --- Implementation note: the 4 algorithm phases (dep-closure, in-degree, ready-queue, sort) are inlined as 3 small blocks within this function. --- Each was a 1-caller helper; the 2-caller rule doesn't apply, so inlining produces a single readable function --- (plex: small patterns → shared, but only when shared; here they're not). local function topo_sort(passes, requested_set) -- Phase 1: dep-closure. Include every pass name transitively required by `requested_set`. local needed = {} for _, name in ipairs(requested_set) do needed[name] = true end local changed = true while changed do changed = false for name, _ in pairs(needed) do local pass = passes[name] if not pass then error("unknown pass '" .. name .. "' requested") end for _, dep in ipairs(pass.deps) do if not needed[dep] then needed[dep] = true changed = true end end end end -- Phase 2: in-degrees for Kahn's algorithm. For each pass in `needed`, the number of its deps that are also in `needed`. local in_degree = {} for name, _ in pairs(needed) do in_degree[name] = 0 end for name, _ in pairs(needed) do for _, dep in ipairs(passes[name].deps) do if needed[dep] then in_degree[name] = in_degree[name] + 1 end end end -- Phase 3: seed the ready queue with passes whose in-degree is 0, sorted alphabetically for deterministic order. local ready = {} for name, deg in pairs(in_degree) do if deg == 0 then ready[#ready + 1] = name end end table.sort(ready) -- Phase 4: drain the ready queue. For each popped pass, decrement the in-degree of every remaining pass that depended on it. -- Newly-zero-degree passes are inserted back into the ready queue (kept sorted). local order = {} while #ready > 0 do local just_finished = table.remove(ready, 1) order[#order + 1] = just_finished for name, _ in pairs(needed) do if name ~= just_finished then for _, dep in ipairs(passes[name].deps) do if dep == just_finished then in_degree[name] = in_degree[name] - 1 if in_degree[name] == 0 then ready[#ready + 1] = name table.sort(ready) end end end end end end -- Cycle detection: if `order` doesn't include all needed passes, some are stuck with in_degree > 0 -- (the cycle closed on itself before Kahn could process them). -- Without this check, a fully-closed cycle (e.g. A -> B -> A) would silently return an empty order list, leaving the orchestrator to dispatch nothing. local needed_count = 0 for _ in pairs(needed) do needed_count = needed_count + 1 end -- count hash entries; Lua's #t doesn't work if #order ~= needed_count then for name, deg in pairs(in_degree) do if deg > 0 then error("dependency cycle detected involving pass '" .. name .. "'") end end end return order end -- ════════════════════════════════════════════════════════════════════════════ -- ASCII dep graph renderer (Decision 6 in the spec) -- ════════════════════════════════════════════════════════════════════════════ --- Render the dep graph as ASCII art. Output width capped at 78 columns. --- Falls back to the simpler "Resolved dependency order" list only if graph width exceeds terminal width. --- @param passes table --- @param closed string[] -- dep-closed execution order --- @return string local function render_dep_graph(passes, closed) local lines = {} local function add(s) lines[#lines + 1] = s end add("[ps1_meta] Resolved dependency order (closed under deps):") for pass_idx, name in ipairs(closed) do local p = passes[name] local deps_str = (#p.deps == 0) and "(no deps)" or "(deps: " .. table.concat(p.deps, ", ") .. ")" add(string.format(" %d. %-22s %-45s [%s]", pass_idx, name, deps_str, p.kind)) end add("") -- Data-driven ASCII graph built from the actual PASSES table. -- Kahn layers determine the row of each box; each pass becomes a 4-row -- box (top border, name, kind+output-count, bottom border). Boxes in -- the same layer are rendered side-by-side; layers are connected by a -- 'v' marker row whose 'v' chars are centered under each box, indicating -- the downward 'feeds into' direction. -- -- Layout invariants enforced here: -- * MAX_GRAPH_WIDTH = 78 cols: no emitted line exceeds this. The -- "simplest" way to stay under the budget is to limit each sub-row -- to MAX_BOXES_PER_ROW = 3 boxes; for the canonical 9-row PASSES -- table the largest layer has 3 boxes, so no wrap engages today. -- If a future layer grows past 3 boxes, the layer is split into -- adjacent sub-rows (each ending in its own 'v'-marker row). -- * No silent truncation: per-layer box width is computed from the -- layer's widest content (max(name length, kind-suffix length)) -- plus a 1-char leading + 1-char trailing padding + 2 wall chars. -- Long names widen the box; they are NEVER truncated. -- * Collision-safety: every PASSES row has a unique name, kind, and -- out-list, so two distinct passes cannot produce visually identical -- boxes. add("[ps1_meta] Pass graph (read top-to-bottom; edges = 'feeds into'):") add("") -- Compute Kahn layer per pass: layer L = max(deps' layer) + 1, layer 0 -- for deps-less passes. Repeated sweeps until every pass in `closed` is -- assigned (handles forward refs that resolve on the second pass). local pass_layer, max_layer = {}, 0 local sorted_closed = {} for _, name in ipairs(closed) do sorted_closed[#sorted_closed + 1] = name end table.sort(sorted_closed) local function assign_layers() local assigned_count = 0 for _, name in ipairs(sorted_closed) do if pass_layer[name] == nil then local p = passes[name] local max_dep, ready = -1, true for _, dep in ipairs(p.deps) do if pass_layer[dep] == nil then ready = false; break end if pass_layer[dep] > max_dep then max_dep = pass_layer[dep] end end if ready then pass_layer[name] = max_dep + 1 if pass_layer[name] > max_layer then max_layer = pass_layer[name] end assigned_count = assigned_count + 1 end end end return assigned_count end while assign_layers() > 0 do end -- Defensive invariant: any unresolved pass is a bug. topo_sort already -- errors on cycles before this point, so reaching here means a logic -- error in the renderer (or a synthetic call that bypassed topo_sort). -- Surface the failure loudly with the offending names; never silently -- place unresolved passes at layer 0 (which would corrupt the graph). local unresolved = {} for _, name in ipairs(sorted_closed) do if pass_layer[name] == nil then unresolved[#unresolved + 1] = name end end if #unresolved > 0 then error("render_dep_graph: unresolved Kahn layer for pass(es): " .. table.concat(unresolved, ", ") .. "; topo_sort should have caught this earlier") end -- Bucket passes by layer; sort each bucket alphabetically for stability. local layers = {} for i = 0, max_layer do layers[i] = {} end for _, name in ipairs(sorted_closed) do layers[pass_layer[name]][#layers[pass_layer[name]] + 1] = name end for i = 0, max_layer do table.sort(layers[i]) end -- Layout constants. Boxes in the same layer share the same width -- (computed as the layer's widest content + padding + walls). local MAX_GRAPH_WIDTH = 78 local MAX_BOXES_PER_ROW = 3 local GAP = 3 local function pad_right(s, width) if #s >= width then return s:sub(1, width) end return s .. string.rep(" ", width - #s) end -- Compute the box width (in chars, including both walls) for a layer. -- The interior is the wider of (a) the longest pass name + 1 leading -- space and (b) the longest " " suffix + 1 leading space. -- Then add 2 for the wall chars. -- -- Why +1 (not +2): the +1 formula means the layer's max-content row -- has 0 padding before the right wall (the `|` is immediately after -- the content). This is the collision-safe widening policy the task -- requires — long names widen the box and never get trailing padding. -- Shorter passes in the same layer get trailing padding to fill the -- interior to the layer's uniform width; they never get truncated. local function box_w_for_layer(bucket) local max_content = 0 for _, name in ipairs(bucket) do local p = passes[name] local out_n = #(p.out or {}) local kind_suf = string.format("%s %d>", p.kind, out_n) if #name > max_content then max_content = #name end if #kind_suf > max_content then max_content = #kind_suf end end -- Interior = 1 leading space + max_content + 0 trailing (the -- trailing `|` IS the right boundary); walls = 2. return max_content + 3 end -- Render one pass as a 4-row box at the given box_w. The interior is -- always padded to fit exactly; no string is ever truncated. local function render_box(name, box_w) local p = passes[name] local out_n = #(p.out or {}) local kind_suf = string.format("%s %d>", p.kind, out_n) local interior = box_w - 2 local border = "+" .. string.rep("-", interior) .. "+" return { border, "|" .. pad_right(" " .. name, interior) .. "|", "|" .. pad_right(" " .. kind_suf, interior) .. "|", border, } end -- Join a single row (1..4) across all boxes in a sub-row, with GAP -- spaces between adjacent boxes. local function join_row(box_rows, row_idx) local parts = {} for i, b in ipairs(box_rows) do parts[#parts + 1] = b[row_idx] if i < #box_rows then parts[#parts + 1] = string.rep(" ", GAP) end end return table.concat(parts) end -- 'v' marker row beneath a sub-row: one 'v' centered under each box. local function v_marker_row(box_rows) local total = 0 local centers = {} for i, b in ipairs(box_rows) do local w = #b[1] -- box width = length of the top border row local center = total + math.floor(w / 2) centers[#centers + 1] = center total = total + w + GAP end -- total now includes a trailing GAP we don't want; trim it. total = total - GAP local s = string.rep(" ", total) for _, c in ipairs(centers) do s = s:sub(1, c) .. "v" .. s:sub(c + 2) end return s end -- Render a single sub-row (a contiguous chunk of a layer's bucket). -- Emits the 4 box rows + an empty line + the 'v' marker row + an -- empty line, EXCEPT the very last sub-row of the very last layer -- omits the trailing 'v' marker (nothing flows below it). local function render_subrow(bucket_chunk, is_last_subrow, is_last_layer) local box_w = box_w_for_layer(bucket_chunk) local boxes = {} for _, name in ipairs(bucket_chunk) do boxes[#boxes + 1] = render_box(name, box_w) end add(join_row(boxes, 1)) -- top borders add(join_row(boxes, 2)) -- names add(join_row(boxes, 3)) -- kind + output-count add(join_row(boxes, 4)) -- bottom borders -- 'v' marker row beneath this sub-row connects downward to the -- next sub-row of the same layer (if any) OR to the next layer. -- Skip the trailing 'v' only on the very last sub-row of the -- final layer, where nothing flows below it. if not is_last_subrow or not is_last_layer then add("") add(v_marker_row(boxes)) add("") end end for layer_idx = 0, max_layer do local bucket = layers[layer_idx] local is_last = (layer_idx == max_layer) -- Split the layer into sub-rows of at most MAX_BOXES_PER_ROW boxes. -- With a 20-char box width (the canonical case: name "static-analysis" -- is 15 chars, suffix "header-output 2>" is 16 chars) and GAP=3, -- 3 boxes per sub-row = 3*20 + 2*3 = 66 cols + a 'v' row of 66 cols; -- well within MAX_GRAPH_WIDTH. A 4th box would push to 4*20 + 3*3 = 89, -- which is why MAX_BOXES_PER_ROW = 3 (wrap when >3). local chunk_size = math.min(MAX_BOXES_PER_ROW, #bucket) if chunk_size < 1 then chunk_size = 1 end for chunk_start = 1, #bucket, chunk_size do local chunk_end = math.min(chunk_start + chunk_size - 1, #bucket) local chunk = {} for i = chunk_start, chunk_end do chunk[#chunk + 1] = bucket[i] end local is_last_subrow = (chunk_end == #bucket) render_subrow(chunk, is_last_subrow, is_last) end end return table.concat(lines, "\n") .. "\n" end -- ════════════════════════════════════════════════════════════════════════════ -- Main orchestrator -- ════════════════════════════════════════════════════════════════════════════ -- (internal) Push a pass's outputs + warnings into `ctx.upstream[name]` for downstream passes to consume. -- @param ctx PassCtx -- @param pass_name string -- @param result PassResult local function accumulate_pass_result(ctx, pass_name, result) ctx.upstream[pass_name] = ctx.upstream[pass_name] or {} for _, out in ipairs(result.outputs or {}) do table.insert(ctx.upstream[pass_name], out) end for _, warn in ipairs(result.warnings or {}) do table.insert(ctx.upstream[pass_name], warn) end end -- (internal) If the pass's kind is in PASS_KIND_STOP_ON_ERROR and it reported errors, write each error to stderr. -- Returns true if any validation errors were reported. -- @param pass_name string -- @param pass PassDescriptor -- @param result PassResult -- @return boolean local function report_validation_errors(pass_name, pass, result) local has_errors = result.errors and #result.errors > 0 if not (has_errors and PASS_KIND_STOP_ON_ERROR[pass.kind]) then return false end for _, e in ipairs(result.errors) do io.stderr:write(string.format("[%s] line %d: %s\n", pass_name, e.line or 0, e.msg or "")) end return true end -- (internal) Run each pass in `order` in topological sequence. -- @param ctx PassCtx -- @param order string[] -- @return boolean -- true if any validation errors were reported local function dispatch_passes(ctx, order) ctx.shared = {} local had_errors = false for _, pass_name in ipairs(order) do local pass = PASSES[pass_name] io.stderr:write(string.format("[ps1_meta] %-22s running\n", pass_name)) local mod = require(pass.module) local result = mod.run(ctx) accumulate_pass_result(ctx, pass_name, result) if report_validation_errors(pass_name, pass, result) then had_errors = true end end return had_errors end --- Main entry point. Runs the requested passes in dep-topological order. --- @param argv string[] local function main(argv) local ok, err = pcall(function() local args = parse_args(argv) local ctx = build_ctx(args) local requested = args.requested_set local closed = topo_sort(PASSES, requested) -- --dry-run: print dep order + ASCII graph, exit OK. if args.dry_run then io.write(render_dep_graph(PASSES, closed)) os.exit(EXIT_OK) end local had_errors = dispatch_passes(ctx, closed) if had_errors then os.exit(EXIT_VALIDATION_ERRORS) end end) if not ok then io.stderr:write("[ps1_meta] internal error: " .. tostring(err) .. "\n") os.exit(EXIT_INTERNAL_ERROR) end os.exit(EXIT_OK) end -- Module export for in-process consumers (tests that dofile this script). -- The closure above, `render_dep_graph`, and the canonical `PASSES` table -- are exposed so a test can render the graph for synthetic PASSES tables -- without spawning a subprocess. The conditional `main(...)` call below -- only fires when this file is invoked as the entry script (arg[0] ends -- in "ps1_meta.lua"); in dofile() mode (test's arg[0] does not match), -- main() is skipped and the chunk returns `_M` to the caller. local _M = { render_dep_graph = render_dep_graph, PASSES = PASSES, } if arg and arg[0] and arg[0]:match("ps1_meta%.lua$") then main({...}) end return _M