utilizing trailing type annotations more

This commit is contained in:
ed
2026-08-19 23:35:57 -04:00
parent 449216967b
commit 2a087f735e
18 changed files with 2785 additions and 5468 deletions
+93 -185
View File
@@ -19,10 +19,8 @@
-- 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).
-- That single statement: (a) sets `package.path` + `package.cpath`, (b) at the bottom returns `require("duffle")`.
-- So the dofile's return value is the duffle module.
--- @type boolean
local _is_entry_script = arg and arg[0] and arg[0]:match("ps1_meta%.lua$") ~= nil
--- @type string
local _bootstrap_src
local _is_entry_script = arg and arg[0] and arg[0]:match("ps1_meta%.lua$") ~= nil ---@type boolean
local _bootstrap_src ---@type string
if _is_entry_script then
_bootstrap_src = arg[0]
else
@@ -30,33 +28,26 @@ else
-- strip the leading "@" so the directory match works in both cases.
_bootstrap_src = debug.getinfo(1, "S").source:sub(2)
end
--- @type DuffleExport
local duffle = dofile((_bootstrap_src:match("(.*[/\\])") or "./") .. "duffle_paths.lua")
local duffle = dofile((_bootstrap_src:match("(.*[/\\])") or "./") .. "duffle_paths.lua") ---@type DuffleExport
-- ════════════════════════════════════════════════════════════════════════════
-- Constants
-- ════════════════════════════════════════════════════════════════════════════
-- Exit codes (per the --help text and the post-build summary convention).
--- @type integer
local EXIT_OK = 0
--- @type integer
local EXIT_VALIDATION_ERRORS = 1
--- @type integer
local EXIT_INTERNAL_ERROR = 2
local EXIT_OK = 0 ---@type integer
local EXIT_VALIDATION_ERRORS = 1 ---@type integer
local EXIT_INTERNAL_ERROR = 2 ---@type integer
-- Default --out-root value if not provided.
--- @type string
local DEFAULT_OUT_ROOT = "build/gen"
local DEFAULT_OUT_ROOT = "build/gen" ---@type string
-- Sentinel for "all passes" in `PASS_FLAG_TO_NAME`. Distinguishes `--all` from the per-pass flags (which map to individual pass names).
--- @type string
local ALL_PASSES_SENTINEL = "__all__"
local ALL_PASSES_SENTINEL = "__all__" ---@type string
-- 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.
--- @type string
local PASS_FLAG_DISPATCH_KEY = "__pass__"
local PASS_FLAG_DISPATCH_KEY = "__pass__" ---@type string
-- ════════════════════════════════════════════════════════════════════════════
-- Type declarations
@@ -159,8 +150,7 @@ local PASS_FLAG_DISPATCH_KEY = "__pass__"
-- 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).
--- @type table<string, PassDescriptor>
local PASSES = {
local PASSES = { ---@type table<string, PassDescriptor>
["scan-source"] = {
module = "passes.scan_source",
kind = "shared", deps = {},
@@ -230,13 +220,10 @@ local PASSES = {
--- @param group_name string -- Build-phase group ("pre-link" | "post-link")
--- @return string[] -- Sorted root pass names belonging to that group
local function roots_for_group(group_name)
--- @type string[]
local names = {}
--- @type string, PassDescriptor
for name, pass in pairs(PASSES) do
local names = {} ---@type string[]
for name, pass in pairs(PASSES) do ---@type string, PassDescriptor
if pass.groups then
--- @type integer, string
for _, g in ipairs(pass.groups) do
for _, g in ipairs(pass.groups) do ---@type integer, string
if g == group_name then
names[#names + 1] = name
break
@@ -251,18 +238,16 @@ 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 args ParsedArgs
--- @param group_name string
--- @return nil
local function request_roots_for_group(args, group_name)
--- @type string[]
local roots = roots_for_group(group_name)
local roots = roots_for_group(group_name) ---@type string[]
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
--- @type integer, string
for _, name in ipairs(roots) do
for _, name in ipairs(roots) do ---@type integer, string
args.requested_set[#args.requested_set + 1] = name
end
end
@@ -270,8 +255,7 @@ end
-- Pass-kind taxonomy: findings always print. No pass kind stops the build.
-- Report severity is independent from process exit policy.
-- Adding a new pass kind requires listing it here explicitly; an unknown kind must not silently fall back to "true".
--- @type table<string, boolean> -- bag: pass kind -> stop-on-error
local PASS_KIND_STOP_ON_ERROR = {
local PASS_KIND_STOP_ON_ERROR = { ---@type table<string, boolean> -- bag: pass kind -> stop-on-error
["shared"] = false,
["header-output"] = false,
["validation"] = false,
@@ -283,8 +267,7 @@ local PASS_KIND_STOP_ON_ERROR = {
-- Per-pass flags (e.g. --word-counts); phase flags (--pre-link, --post-link, --all) are within 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.
--- @type table<string, string> -- bag: CLI flag -> pass name or ALL_PASSES_SENTINEL
local PASS_FLAG_TO_NAME = {
local PASS_FLAG_TO_NAME = { ---@type table<string, string> -- bag: CLI flag -> pass name or ALL_PASSES_SENTINEL
["--word-counts"] = "word-counts",
["--components"] = "components",
["--validate"] = "annotation",
@@ -301,21 +284,17 @@ local PASS_FLAG_TO_NAME = {
--- @param args ParsedArgs
--- @return nil
local function request_all_passes(args)
--- @type string[]
local names = {}
--- @type string
for name in pairs(PASSES) do names[#names + 1] = name end
local names = {} ---@type string[]
for name in pairs(PASSES) do names[#names + 1] = name end ---@type string
table.sort(names)
--- @type integer, string
for _, n in ipairs(names) do
for _, n in ipairs(names) do ---@type integer, string
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).
--- @type table<string, FlagHandler>
local FLAG_HANDLERS = {}
local FLAG_HANDLERS = {} ---@type table<string, FlagHandler>
-- ════════════════════════════════════════════════════════════════════════════
-- CLI parsing
@@ -372,8 +351,7 @@ EXAMPLES:
]])
end
--- @type table<string, string> -- bag: flag -> value metavar
local FLAG_VALUE_NAMES = {
local FLAG_VALUE_NAMES = { ---@type table<string, string> -- bag: flag -> value metavar
["--source"] = "FILE",
["--unity-root"] = "FILE",
["--metadata"] = "PATH",
@@ -388,10 +366,8 @@ local FLAG_VALUE_NAMES = {
--- @return string
--- @return integer
local function require_flag_value(argv, arg_idx, flag)
--- @type string|nil
local value = argv[arg_idx + 1]
--- @type boolean
local next_known = type(value) == "string"
local value = argv[arg_idx + 1] ---@type string|nil
local next_known = type(value) == "string" ---@type boolean
and (FLAG_HANDLERS[value] ~= nil or PASS_FLAG_TO_NAME[value] ~= nil)
if value == nil or next_known then
io.stderr:write("ps1_meta: " .. flag .. " requires " .. FLAG_VALUE_NAMES[flag] .. "\n")
@@ -417,8 +393,7 @@ FLAG_HANDLERS["--verbose"] = function(args) args.verbose = true end
--- @param arg_idx integer
--- @return integer
FLAG_HANDLERS["--source"] = function(args, argv, arg_idx)
--- @type string, integer
local value, value_idx = require_flag_value(argv, arg_idx, "--source")
local value, value_idx = require_flag_value(argv, arg_idx, "--source") ---@type string, integer
args.sources[#args.sources + 1] = value
return value_idx
end
@@ -427,8 +402,7 @@ end
--- @param arg_idx integer
--- @return integer
FLAG_HANDLERS["--unity-root"] = function(args, argv, arg_idx)
--- @type string, integer
local value, value_idx = require_flag_value(argv, arg_idx, "--unity-root")
local value, value_idx = require_flag_value(argv, arg_idx, "--unity-root") ---@type string, integer
args.unity_root = value
return value_idx
end
@@ -437,8 +411,7 @@ end
--- @param arg_idx integer
--- @return integer
FLAG_HANDLERS["--metadata"] = function(args, argv, arg_idx)
--- @type string, integer
local value, value_idx = require_flag_value(argv, arg_idx, "--metadata")
local value, value_idx = require_flag_value(argv, arg_idx, "--metadata") ---@type string, integer
args.metadata = value
return value_idx
end
@@ -447,8 +420,7 @@ end
--- @param arg_idx integer
--- @return integer
FLAG_HANDLERS["--out-root"] = function(args, argv, arg_idx)
--- @type string, integer
local value, value_idx = require_flag_value(argv, arg_idx, "--out-root")
local value, value_idx = require_flag_value(argv, arg_idx, "--out-root") ---@type string, integer
args.out_root = value
return value_idx
end
@@ -457,8 +429,7 @@ end
--- @param arg_idx integer
--- @return integer
FLAG_HANDLERS["--project-root"] = function(args, argv, arg_idx)
--- @type string, integer
local value, value_idx = require_flag_value(argv, arg_idx, "--project-root")
local value, value_idx = require_flag_value(argv, arg_idx, "--project-root") ---@type string, integer
args.project_root = value
return value_idx
end
@@ -476,8 +447,7 @@ end
--- @param arg_idx integer
--- @return integer
FLAG_HANDLERS["--elf"] = function(args, argv, arg_idx)
--- @type string, integer
local value, value_idx = require_flag_value(argv, arg_idx, "--elf")
local value, value_idx = require_flag_value(argv, arg_idx, "--elf") ---@type string, integer
args.flags = args.flags or {}
args.flags.elf_path = value
return value_idx
@@ -517,8 +487,7 @@ end
--- @param a string
--- @return nil
FLAG_HANDLERS[PASS_FLAG_DISPATCH_KEY] = function(args, a)
--- @type string|nil
local name = PASS_FLAG_TO_NAME[a]
local name = PASS_FLAG_TO_NAME[a] ---@type string|nil
if name == ALL_PASSES_SENTINEL then
request_all_passes(args)
return
@@ -530,8 +499,7 @@ end
--- @param argv string[]
--- @return ParsedArgs
local function parse_args(argv)
--- @type ParsedArgs
local args = {
local args = { ---@type ParsedArgs
requested_set = {},
sources = {},
unity_root = nil,
@@ -541,13 +509,10 @@ local function parse_args(argv)
verbose = false,
}
--- @type integer
local pos = 1
local pos = 1 ---@type integer
while pos <= #argv do
--- @type string
local a = argv[pos]
--- @type FlagHandler|nil
local handler = FLAG_HANDLERS[a]
local a = argv[pos] ---@type string
local handler = FLAG_HANDLERS[a] ---@type FlagHandler|nil
if handler then
pos = handler(args, argv, pos) or pos
elseif PASS_FLAG_TO_NAME[a] then
@@ -572,17 +537,14 @@ local function parse_args(argv)
-- `<repo>/code/duffle/word_count.metadata.h` is the canonical metadata location.
-- `project_root` names `<repo>`; the resolver derives `<project_root>/code` separately.
if not args.project_root then
--- @type string
local metadata_dir = duffle.dirname(duffle.normalize_path(args.metadata))
--- @type string
local code_root = duffle.dirname(metadata_dir)
local metadata_dir = duffle.dirname(duffle.normalize_path(args.metadata)) ---@type string
local code_root = duffle.dirname(metadata_dir) ---@type string
args.project_root = duffle.dirname(code_root)
else
args.project_root = duffle.normalize_path(args.project_root)
end
--- @type boolean
local has_unity = type(args.unity_root) == "string" and args.unity_root ~= ""
local has_unity = type(args.unity_root) == "string" and args.unity_root ~= "" ---@type boolean
if has_unity and #args.sources > 0 then
io.stderr:write("ps1_meta: --unity-root FILE and --source FILE are mutually exclusive\n")
os.exit(EXIT_INTERNAL_ERROR)
@@ -595,14 +557,10 @@ local function parse_args(argv)
-- 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.
--- @type PassFlags
local flags = args.flags or {}
--- @type string|nil
local elf_path = flags.elf_path
--- @type boolean
local has_elf = type(elf_path) == "string" and #elf_path > 0
--- @type boolean
local post_links = flags.gdb_runtime or flags.dwarf_injection
local flags = args.flags or {} ---@type PassFlags
local elf_path = flags.elf_path ---@type string|nil
local has_elf = type(elf_path) == "string" and #elf_path > 0 ---@type boolean
local post_links = flags.gdb_runtime or flags.dwarf_injection ---@type boolean
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)
@@ -621,12 +579,9 @@ end
--- @param args ParsedArgs
--- @return PassCtx
local function build_ctx(args)
--- @type string
local normalized_project_root = duffle.normalize_path(args.project_root)
--- @type string
local project_root = normalized_project_root
--- @type boolean
local project_root_is_absolute = normalized_project_root:match("^%a:/")
local normalized_project_root = duffle.normalize_path(args.project_root) ---@type string
local project_root = normalized_project_root ---@type string
local project_root_is_absolute = normalized_project_root:match("^%a:/") ---@type boolean
or normalized_project_root:sub(1, 2) == "//"
or normalized_project_root:sub(1, 1) == "/"
if not project_root_is_absolute then
@@ -637,11 +592,9 @@ local function build_ctx(args)
-- Do not route POSIX/UNC/drive-absolute paths through to_absolute_path.
duffle.canonical_path_key(project_root)
end
--- @type Corpus
local resolution
local resolution ---@type Corpus
if args.unity_root then
--- @type boolean, Corpus|string
local ok_resolve, resolved = pcall(duffle.resolve_source_corpus, {
local ok_resolve, resolved = pcall(duffle.resolve_source_corpus, { ---@type boolean, Corpus|string
unity_root = args.unity_root,
project_root = project_root,
})
@@ -651,45 +604,35 @@ local function build_ctx(args)
end
resolution = resolved
else
--- @type SourceFile[]
local source_order = {}
--- @type table<Path, SourceFile>
local sources_by_path = {}
--- @type SourceResolver
local resolver = {
local source_order = {} ---@type SourceFile[]
local sources_by_path = {} ---@type table<Path, SourceFile>
local resolver = { ---@type SourceResolver
resolved = {},
skipped = {},
shadowed = {},
}
--- @type integer, string
for _, input_path in ipairs(args.sources) do
--- @type string
local path = duffle.normalize_path(input_path)
--- @type boolean, string
local key_ok, key_or_error = pcall(duffle.canonical_path_key, path)
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
--- @type file*|nil
local file = io.open(path, "r")
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
--- @type string
local text = file:read("*a")
local text = file:read("*a") ---@type string
file:close()
--- @type SourceFile
local source = {
local source = { ---@type SourceFile
path = path,
text = text,
dir = duffle.dirname(path),
basename = duffle.basename_no_ext(path),
}
source_order[#source_order + 1] = source
--- @type string
local key = key_or_error
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,
@@ -713,8 +656,7 @@ local function build_ctx(args)
}
end
--- @type Corpus
local corpus = {
local corpus = { ---@type Corpus
unity_root = resolution.unity_root,
project_root = resolution.project_root,
code_root = resolution.code_root,
@@ -735,8 +677,7 @@ local function build_ctx(args)
collisions = {},
resolver = resolution.resolver,
}
--- @type PassCtx
local ctx = {
local ctx = { ---@type PassCtx
metadata_path = args.metadata,
shared = { corpus = corpus },
out_root = args.out_root,
@@ -765,21 +706,15 @@ end
--- Keeping these blocks local makes the topological sort self-contained.
local function topo_sort(passes, requested_set)
-- Dependency closure: include every pass transitively required by `requested_set`.
--- @type table<string, boolean> -- bag: pass name -> needed
local needed = {}
--- @type integer, string
for _, name in ipairs(requested_set) do needed[name] = true end
--- @type boolean
local changed = true
local needed = {} ---@type table<string, boolean> -- bag: pass name -> needed
for _, name in ipairs(requested_set) do needed[name] = true end ---@type integer, string
local changed = true ---@type boolean
while changed do
changed = false
--- @type string, boolean
for name, _ in pairs(needed) do
--- @type PassDescriptor
local pass = passes[name]
for name, _ in pairs(needed) do ---@type string, boolean
local pass = passes[name] ---@type PassDescriptor
if not pass then error("unknown pass '" .. name .. "' requested") end
--- @type integer, string
for _, dep in ipairs(pass.deps) do
for _, dep in ipairs(pass.deps) do ---@type integer, string
if not needed[dep] then
needed[dep] = true
changed = true
@@ -789,14 +724,10 @@ local function topo_sort(passes, requested_set)
end
-- In-degree calculation: count each needed pass's needed dependencies.
--- @type table<string, integer> -- bag: pass name -> in-degree
local in_degree = {}
--- @type string, boolean
for name, _ in pairs(needed) do in_degree[name] = 0 end
--- @type string, boolean
for name, _ in pairs(needed) do
--- @type integer, string
for _, dep in ipairs(passes[name].deps) do
local in_degree = {} ---@type table<string, integer> -- bag: pass name -> in-degree
for name, _ in pairs(needed) do in_degree[name] = 0 end ---@type string, boolean
for name, _ in pairs(needed) do ---@type string, boolean
for _, dep in ipairs(passes[name].deps) do ---@type integer, string
if needed[dep] then
in_degree[name] = in_degree[name] + 1
end
@@ -804,27 +735,21 @@ local function topo_sort(passes, requested_set)
end
-- Ready-queue seeding: add zero-in-degree passes in deterministic order.
--- @type string[]
local ready = {}
--- @type string, integer
for name, deg in pairs(in_degree) do
local ready = {} ---@type string[]
for name, deg in pairs(in_degree) do ---@type string, integer
if deg == 0 then ready[#ready + 1] = name end
end
table.sort(ready)
-- Ready-queue drain: decrement dependents when each pass is emitted.
-- Newly-zero-degree passes are inserted back into the ready queue (kept sorted).
--- @type string[]
local order = {}
local order = {} ---@type string[]
while #ready > 0 do
--- @type string
local just_finished = table.remove(ready, 1)
local just_finished = table.remove(ready, 1) ---@type string
order[#order + 1] = just_finished
--- @type string, boolean
for name, _ in pairs(needed) do
for name, _ in pairs(needed) do ---@type string, boolean
if name ~= just_finished then
--- @type integer, string
for _, dep in ipairs(passes[name].deps) do
for _, dep in ipairs(passes[name].deps) do ---@type integer, string
if dep == just_finished then
in_degree[name] = in_degree[name] - 1
if in_degree[name] == 0 then
@@ -840,13 +765,10 @@ local function topo_sort(passes, requested_set)
-- 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.
--- @type integer
local needed_count = 0
--- @type string
for _ in pairs(needed) do needed_count = needed_count + 1 end -- count hash entries; Lua's #t doesn't work
local needed_count = 0 ---@type integer
for _ in pairs(needed) do needed_count = needed_count + 1 end ---@type string -- count hash entries; Lua's #t doesn't work
if #order ~= needed_count then
--- @type string, integer
for name, deg in pairs(in_degree) do
for name, deg in pairs(in_degree) do ---@type string, integer
if deg > 0 then
error("dependency cycle detected involving pass '" .. name .. "'")
end
@@ -867,11 +789,9 @@ end
--- @param result PassResult
--- @return boolean
local function report_validation_errors(pass_name, pass, result)
--- @type boolean
local has_errors = result.errors and #result.errors > 0
local has_errors = result.errors and #result.errors > 0 ---@type boolean
if not has_errors then return false end
--- @type integer, PassFinding
for _, e in ipairs(result.errors) do
for _, e in ipairs(result.errors) do ---@type integer, PassFinding
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
@@ -882,16 +802,11 @@ end
--- @param order string[]
--- @return boolean -- true if any validation errors were reported
local function dispatch_passes(ctx, order)
--- @type boolean
local had_errors = false
--- @type integer, string
for _, pass_name in ipairs(order) do
--- @type PassDescriptor
local pass = PASSES[pass_name]
--- @type PassModule
local mod = require(pass.module)
--- @type PassResult
local result = mod.run(ctx)
local had_errors = false ---@type boolean
for _, pass_name in ipairs(order) do ---@type integer, string
local pass = PASSES[pass_name] ---@type PassDescriptor
local mod = require(pass.module) ---@type PassModule
local result = mod.run(ctx) ---@type PassResult
if report_validation_errors(pass_name, pass, result) then
had_errors = true
end
@@ -903,20 +818,14 @@ end
--- @param argv string[]
--- @return nil
local function main(argv)
--- @type boolean, string|nil
local ok, err = pcall(function()
--- @type ParsedArgs
local args = parse_args(argv)
--- @type PassCtx
local ctx = build_ctx(args)
local ok, err = pcall(function() ---@type boolean, string|nil
local args = parse_args(argv) ---@type ParsedArgs
local ctx = build_ctx(args) ---@type PassCtx
--- @type string[]
local requested = args.requested_set
--- @type string[]
local closed = topo_sort(PASSES, requested)
local requested = args.requested_set ---@type string[]
local closed = topo_sort(PASSES, requested) ---@type string[]
--- @type boolean
local had_errors = dispatch_passes(ctx, closed)
local had_errors = dispatch_passes(ctx, closed) ---@type boolean
if had_errors then os.exit(EXIT_VALIDATION_ERRORS) end
end)
@@ -931,8 +840,7 @@ end
-- Module export for in-process consumers (tests that dofile this script).
-- 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.
--- @type Ps1MetaMod
local _M = {
local _M = { ---@type Ps1MetaMod
PASSES = PASSES,
PASS_KIND_STOP_ON_ERROR = PASS_KIND_STOP_ON_ERROR,
parse_args = parse_args,