From 1f354f6da0e9bc57bbabc7e9bd9700428c51df5f Mon Sep 17 00:00:00 2001 From: agra Date: Wed, 3 Jun 2026 06:54:13 +0300 Subject: [PATCH] refactor(ir): extract ErrorFlow (error_flow.zig) for path-sensitive error-flow diagnostics (A5.2 step 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the diagnostic-only Pass 1e (ERR E1.7 cleanup-absorption + E1.8 value-slot liveness) out of lower.zig into src/ir/error_flow.zig behind an ErrorFlow *Lowering facade (Principle 5, like ErrorAnalysis/CoercionResolver). Behavior preserved exactly — pure relocation. Moved verbatim (self. -> self.l. for Lowering members; sibling calls stay on the facade; provenHas is a file-local free fn): checkErrorFlow, analyzeFnBody, flowWalk, flowStmt, flowIf, flowMatch, flowExpr, applyRefinement, provenAdd/provenClone/provenIntersect, registerFailableDestructure, checkCleanupBody/checkCleanupNode/cleanupReject, plus the FlowCtx/ProvenSet types. - lowerRoot routes the single call site through self.errorFlow().checkErrorFlow(decls); no Lowering wrapper kept (only the pipeline calls it, no unit-test caller). New errorFlow() accessor. - The pass takes AST decls + ProgramIndex + diagnostics only — independent of IR Builder state (PLAN-ARCH A5.2 success criterion). - New pub: exprIsFailable (only widening; inferExprType/errorChannelOf already pub). lower.zig -389 (->17030); error_flow.zig 407. Barrel-wired in ir.zig. - No .test.zig: diagnostic-pass altitude (functions return only bool + emit diagnostics) — guarded by example anchors 1046-1053 (incl. scaffolding 1051/1052/1053). Phase A5 complete. Gate: zig build, zig build test, bash tests/run_examples.sh -> 361/0 (anchors 1046-1053 all ok, no .ir churn). --- src/ir/error_flow.zig | 407 ++++++++++++++++++++++++++++++++++++++++++ src/ir/ir.zig | 2 + src/ir/lower.zig | 401 ++--------------------------------------- 3 files changed, 419 insertions(+), 391 deletions(-) create mode 100644 src/ir/error_flow.zig diff --git a/src/ir/error_flow.zig b/src/ir/error_flow.zig new file mode 100644 index 00000000..1d982b7b --- /dev/null +++ b/src/ir/error_flow.zig @@ -0,0 +1,407 @@ +const std = @import("std"); +const ast = @import("../ast.zig"); +const lower = @import("lower.zig"); + +const Node = ast.Node; +const Lowering = lower.Lowering; + +// ── ERR E1.7 / E1.8 — error-flow analysis ─────────────────────────────────── +// +// One structured, path-sensitive walk over each MAIN-file function body +// (imported modules are trusted) drives two checks: +// +// • E1.8 (value-slot liveness): a `v, err := failable()` destructure binds +// `v` "live only where `err` is proven absent". A read of `v` is legal +// iff `err` is proven null on the current path — established by +// `if !err { … }` (proven inside) or `if err { return/raise }` (proven on +// the fall-through). Error-set `==`/tag-compares do NOT prove absence. +// +// • E1.7 (cleanup absorption): a bare failable call in a `defer`/`onfail` +// body (with no `catch` / `or value`) is rejected — its error has nowhere +// to propagate (the block is already exiting). See `checkCleanupBody`. +// +// This is the diagnostic-only Pass 1e (architecture phase A5.2), extracted from +// `Lowering`. A `*Lowering` facade (Principle 5, like `ErrorAnalysis`/ +// `CoercionResolver`): it reads AST decls + `ProgramIndex` and emits diagnostics +// via `self.l.diagnostics`; lowering proceeds only if the diagnostics are clean +// (`core.zig` halts before codegen on any error). External `Lowering` helpers it +// consumes: `inferExprType`, `errorChannelOf`, `exprIsFailable`. + +/// The proven-null set: error-variable names known to be absent on the +/// current path. Threaded by value-clone across branches; the join after an +/// `if` is the intersection of the reachable branches' sets. +const ProvenSet = std.ArrayList([]const u8); + +fn provenHas(set: ProvenSet, name: []const u8) bool { + for (set.items) |n| if (std.mem.eql(u8, n, name)) return true; + return false; +} + +/// Per-function registration state for the flow walk. `bindings` maps each +/// failable value-slot variable to its partner error variable; `err_vars` +/// is the set of those error variables (so conditions over them refine the +/// proven-null set). Both are monotonic — entries are added as destructures +/// are encountered, never removed (out-of-scope names simply stop being read). +const FlowCtx = struct { + bindings: std.StringHashMap([]const u8), + err_vars: std.StringHashMap(void), +}; + +pub const ErrorFlow = struct { + l: *Lowering, + + fn provenAdd(self: ErrorFlow, set: *ProvenSet, name: []const u8) void { + if (!provenHas(set.*, name)) set.append(self.l.alloc, name) catch {}; + } + + fn provenClone(self: ErrorFlow, set: ProvenSet) ProvenSet { + var out = ProvenSet.empty; + out.appendSlice(self.l.alloc, set.items) catch {}; + return out; + } + + fn provenIntersect(self: ErrorFlow, a: ProvenSet, b: ProvenSet) ProvenSet { + var out = ProvenSet.empty; + for (a.items) |n| if (provenHas(b, n)) (out.append(self.l.alloc, n) catch {}); + return out; + } + + /// Pass 1e: run the error-flow checks over every function defined in the + /// main file. Library modules are assumed well-formed (and may use patterns + /// this conservative check would over-reject), so they are skipped. + pub fn checkErrorFlow(self: ErrorFlow, decls: []const *const Node) void { + if (self.l.diagnostics == null) return; + for (decls) |decl| { + if (self.l.main_file) |mf| { + if (decl.source_file) |sf| { + if (!std.mem.eql(u8, sf, mf)) continue; + } + } + switch (decl.data) { + .fn_decl => |fd| self.analyzeFnBody(fd.body), + .const_decl => |cd| { + if (cd.value.data == .fn_decl) self.analyzeFnBody(cd.value.data.fn_decl.body); + }, + else => {}, + } + } + } + + /// Analyze one function (or lambda) body as its own boundary — a fresh + /// binding context and an empty proven set. + fn analyzeFnBody(self: ErrorFlow, body: *const Node) void { + var ctx = FlowCtx{ + .bindings = std.StringHashMap([]const u8).init(self.l.alloc), + .err_vars = std.StringHashMap(void).init(self.l.alloc), + }; + var proven = ProvenSet.empty; + _ = self.flowWalk(body, &ctx, &proven); + } + + /// Walk a block or single statement. Returns whether control always + /// diverges (every path ends in return/raise/break/continue) — used by the + /// caller to mark code after a branch unreachable. + fn flowWalk(self: ErrorFlow, node: *const Node, ctx: *FlowCtx, proven: *ProvenSet) bool { + switch (node.data) { + .block => |b| { + for (b.stmts) |s| if (self.flowStmt(s, ctx, proven)) return true; + return false; + }, + else => return self.flowStmt(node, ctx, proven), + } + } + + fn flowStmt(self: ErrorFlow, node: *const Node, ctx: *FlowCtx, proven: *ProvenSet) bool { + switch (node.data) { + .destructure_decl => |dd| { + self.flowExpr(dd.value, ctx, proven.*); + self.registerFailableDestructure(&dd, ctx); + return false; + }, + .var_decl => |vd| { + if (vd.value) |v| self.flowExpr(v, ctx, proven.*); + return false; + }, + .const_decl => |cd| { + self.flowExpr(cd.value, ctx, proven.*); + return false; + }, + .assignment => |a| { + self.flowExpr(a.value, ctx, proven.*); + self.flowExpr(a.target, ctx, proven.*); + return false; + }, + .multi_assign => |ma| { + for (ma.values) |v| self.flowExpr(v, ctx, proven.*); + return false; + }, + .return_stmt => |r| { + if (r.value) |v| self.flowExpr(v, ctx, proven.*); + return true; + }, + .raise_stmt => |rs| { + self.flowExpr(rs.tag, ctx, proven.*); + return true; + }, + .break_expr, .continue_expr => return true, + .if_expr => |ie| return self.flowIf(&ie, ctx, proven), + .while_expr => |we| { + self.flowExpr(we.condition, ctx, proven.*); + var loop_proven = self.provenClone(proven.*); + _ = self.flowWalk(we.body, ctx, &loop_proven); + return false; + }, + .for_expr => |fe| { + self.flowExpr(fe.iterable, ctx, proven.*); + if (fe.range_end) |re| self.flowExpr(re, ctx, proven.*); + var loop_proven = self.provenClone(proven.*); + _ = self.flowWalk(fe.body, ctx, &loop_proven); + return false; + }, + .match_expr => |me| return self.flowMatch(&me, ctx, proven), + .push_stmt => |ps| { + self.flowExpr(ps.context_expr, ctx, proven.*); + var inner = self.provenClone(proven.*); + _ = self.flowWalk(ps.body, ctx, &inner); + return false; + }, + .defer_stmt => |ds| { + self.checkCleanupBody(ds.expr, "defer"); + self.flowExpr(ds.expr, ctx, proven.*); + return false; + }, + .onfail_stmt => |os| { + self.checkCleanupBody(os.body, "onfail"); + self.flowExpr(os.body, ctx, proven.*); + return false; + }, + else => { + self.flowExpr(node, ctx, proven.*); + return false; + }, + } + } + + /// Path-sensitive `if`: refine the proven set on each branch, recurse, and + /// join the reachable fall-through states by intersection. + fn flowIf(self: ErrorFlow, ie: *const ast.IfExpr, ctx: *FlowCtx, proven: *ProvenSet) bool { + self.flowExpr(ie.condition, ctx, proven.*); + var then_proven = self.provenClone(proven.*); + var else_proven = self.provenClone(proven.*); + self.applyRefinement(ie.condition, true, ctx, &then_proven); + self.applyRefinement(ie.condition, false, ctx, &else_proven); + + const then_div = self.flowWalk(ie.then_branch, ctx, &then_proven); + var else_div = false; + if (ie.else_branch) |eb| else_div = self.flowWalk(eb, ctx, &else_proven); + + // Reachable fall-through contributors: a branch that doesn't diverge, + // plus the implicit (empty) else when there is no `else`. + var contributors = std.ArrayList(ProvenSet).empty; + if (!then_div) contributors.append(self.l.alloc, then_proven) catch {}; + if (ie.else_branch != null) { + if (!else_div) contributors.append(self.l.alloc, else_proven) catch {}; + } else { + contributors.append(self.l.alloc, else_proven) catch {}; + } + if (contributors.items.len == 0) return true; // both branches diverge + + var result = self.provenClone(contributors.items[0]); + for (contributors.items[1..]) |c| result = self.provenIntersect(result, c); + proven.* = result; + return false; + } + + /// Refine the proven-null set for a branch taken when `cond` is `want_true`. + /// A bare error-variable is truthy when it HOLDS an error, so its falsy edge + /// proves absence; `!`, `&&`, `||` compose. Tag/equality compares prove + /// nothing (error-set `==` compares tags, not presence). + fn applyRefinement(self: ErrorFlow, cond: *const Node, want_true: bool, ctx: *FlowCtx, set: *ProvenSet) void { + switch (cond.data) { + .identifier => |id| { + if (ctx.err_vars.contains(id.name) and !want_true) self.provenAdd(set, id.name); + }, + .unary_op => |uop| { + if (uop.op == .not) self.applyRefinement(uop.operand, !want_true, ctx, set); + }, + .binary_op => |bop| { + if (bop.op == .and_op and want_true) { + self.applyRefinement(bop.lhs, true, ctx, set); + self.applyRefinement(bop.rhs, true, ctx, set); + } else if (bop.op == .or_op and !want_true) { + self.applyRefinement(bop.lhs, false, ctx, set); + self.applyRefinement(bop.rhs, false, ctx, set); + } + }, + else => {}, + } + } + + fn flowMatch(self: ErrorFlow, me: *const ast.MatchExpr, ctx: *FlowCtx, proven: *ProvenSet) bool { + self.flowExpr(me.subject, ctx, proven.*); + for (me.arms) |arm| { + var arm_proven = self.provenClone(proven.*); + _ = self.flowWalk(arm.body, ctx, &arm_proven); + } + return false; + } + + /// Check an expression for reads of a still-tainted value-slot variable and + /// recurse into nested lambdas as their own boundaries. `proven` is by value + /// — sub-expressions never publish proven-null facts back to the statement. + fn flowExpr(self: ErrorFlow, node: *const Node, ctx: *FlowCtx, proven: ProvenSet) void { + switch (node.data) { + .identifier => |id| { + if (ctx.bindings.get(id.name)) |err_var| { + if (!provenHas(proven, err_var)) { + if (self.l.diagnostics) |d| d.addFmt(.err, node.span, "value `{s}` from a failable can be used only where its error `{s}` is proven absent — guard the use with `if !{s} {{ … }}`, or return early with `if {s} {{ return; }}` before reading `{s}`", .{ id.name, err_var, err_var, err_var, id.name }); + } + } + }, + .lambda => |lam| self.analyzeFnBody(lam.body), + .binary_op => |b| { + self.flowExpr(b.lhs, ctx, proven); + // Short-circuit: the rhs of `&&` runs only when the lhs is true + // (and `||`'s rhs only when the lhs is false), so refine the + // proven-null set accordingly before checking it. This is what + // makes `if !err && use(v)` legal. + if (b.op == .and_op or b.op == .or_op) { + var rp = self.provenClone(proven); + self.applyRefinement(b.lhs, b.op == .and_op, ctx, &rp); + self.flowExpr(b.rhs, ctx, rp); + } else { + self.flowExpr(b.rhs, ctx, proven); + } + }, + .chained_comparison => |cc| { + for (cc.operands) |op| self.flowExpr(op, ctx, proven); + }, + .unary_op => |u| self.flowExpr(u.operand, ctx, proven), + .call => |c| { + self.flowExpr(c.callee, ctx, proven); + for (c.args) |a| self.flowExpr(a, ctx, proven); + }, + .field_access => |fa| self.flowExpr(fa.object, ctx, proven), + .index_expr => |ix| { + self.flowExpr(ix.object, ctx, proven); + self.flowExpr(ix.index, ctx, proven); + }, + .slice_expr => |se| { + self.flowExpr(se.object, ctx, proven); + if (se.start) |s| self.flowExpr(s, ctx, proven); + if (se.end) |e| self.flowExpr(e, ctx, proven); + }, + .try_expr => |te| self.flowExpr(te.operand, ctx, proven), + .catch_expr => |ce| { + self.flowExpr(ce.operand, ctx, proven); + self.flowExpr(ce.body, ctx, proven); + }, + .force_unwrap => |fu| self.flowExpr(fu.operand, ctx, proven), + .null_coalesce => |nc| { + self.flowExpr(nc.lhs, ctx, proven); + self.flowExpr(nc.rhs, ctx, proven); + }, + .deref_expr => |de| self.flowExpr(de.operand, ctx, proven), + .comptime_expr => |ce| self.flowExpr(ce.expr, ctx, proven), + .insert_expr => |ie| self.flowExpr(ie.expr, ctx, proven), + .spread_expr => |se| self.flowExpr(se.operand, ctx, proven), + .struct_literal => |sl| { + for (sl.field_inits) |fi| self.flowExpr(fi.value, ctx, proven); + }, + .array_literal => |al| { + for (al.elements) |el| self.flowExpr(el, ctx, proven); + }, + .tuple_literal => |tl| { + for (tl.elements) |el| self.flowExpr(el.value, ctx, proven); + }, + .if_expr => |ie| { + var tmp = self.provenClone(proven); + _ = self.flowIf(&ie, ctx, &tmp); + }, + .match_expr => |me| { + var tmp = self.provenClone(proven); + _ = self.flowMatch(&me, ctx, &tmp); + }, + .block => |b| { + var tmp = self.provenClone(proven); + for (b.stmts) |s| if (self.flowStmt(s, ctx, &tmp)) break; + }, + else => {}, + } + } + + /// Register a `v…, err := failable()` destructure. Only a complete bare + /// destructure (every slot bound, error slot a real name) creates taint — + /// an omitted or `_`-bound error slot is already rejected by the discard + /// check in `lowerDestructureDecl`, so it produces no proof obligation here. + fn registerFailableDestructure(self: ErrorFlow, dd: *const ast.DestructureDecl, ctx: *FlowCtx) void { + const ty = self.l.inferExprType(dd.value); + if (self.l.errorChannelOf(ty) == null) return; + if (ty.isBuiltin()) return; + const ti = self.l.module.types.get(ty); + if (ti != .tuple) return; + const fields = ti.tuple.fields; + if (dd.names.len != fields.len) return; + const err_name = dd.names[fields.len - 1]; + if (std.mem.eql(u8, err_name, "_")) return; + ctx.err_vars.put(err_name, {}) catch {}; + var i: usize = 0; + while (i + 1 < dd.names.len) : (i += 1) { + const vn = dd.names[i]; + if (std.mem.eql(u8, vn, "_")) continue; + ctx.bindings.put(vn, err_name) catch {}; + } + } + + /// E1.7: a `defer`/`onfail` body runs while the block is already exiting, so + /// a bare failable call has nowhere to send its error. Reject any failable + /// expression-statement that isn't absorbed locally by `catch` / `or value` + /// / a destructure binding. (Parser already bans `try`/`raise`/`return`/ + /// `break`/`continue` here, so the only escape route left for a failable is + /// local absorption.) The check is transitive through nested blocks, `if`, + /// loops, match arms, and `catch` handlers, but stops at a nested closure + /// (its own function boundary). + fn checkCleanupBody(self: ErrorFlow, body: *const Node, kind: []const u8) void { + self.checkCleanupNode(body, kind); + } + + fn checkCleanupNode(self: ErrorFlow, node: *const Node, kind: []const u8) void { + switch (node.data) { + .block => |b| for (b.stmts) |s| self.checkCleanupNode(s, kind), + .if_expr => |ie| { + self.cleanupReject(ie.condition, kind); + self.checkCleanupNode(ie.then_branch, kind); + if (ie.else_branch) |eb| self.checkCleanupNode(eb, kind); + }, + .while_expr => |we| { + self.cleanupReject(we.condition, kind); + self.checkCleanupNode(we.body, kind); + }, + .for_expr => |fe| self.checkCleanupNode(fe.body, kind), + .match_expr => |me| for (me.arms) |arm| self.checkCleanupNode(arm.body, kind), + .push_stmt => |ps| self.checkCleanupNode(ps.body, kind), + // A destructure binds the error slot → absorbed (explicit ownership). + .destructure_decl => {}, + .var_decl => |vd| if (vd.value) |v| self.cleanupReject(v, kind), + .const_decl => |cd| self.cleanupReject(cd.value, kind), + .assignment => |a| self.cleanupReject(a.value, kind), + // Closures are their own boundary; the parser-banned control-flow + // exits are handled elsewhere; nested cleanup is independent. + .lambda, .return_stmt, .raise_stmt, .break_expr, .continue_expr, .defer_stmt, .onfail_stmt => {}, + else => self.cleanupReject(node, kind), + } + } + + /// Reject `expr` if it is a bare (un-absorbed) failable in cleanup position. + /// `catch` / `or value` strip the error channel (so `exprIsFailable` is + /// false for them); only a still-failable expression has an unhandled error. + fn cleanupReject(self: ErrorFlow, expr: *const Node, kind: []const u8) void { + if (expr.data == .catch_expr) { + // The operand is absorbed; the handler body still runs in cleanup. + self.checkCleanupNode(expr.data.catch_expr.body, kind); + return; + } + if (!self.l.exprIsFailable(expr)) return; + if (self.l.diagnostics) |d| d.addFmt(.err, expr.span, "a bare failable call in a `{s}` body has nowhere to send its error — the block is already exiting; absorb it locally with `catch` or `or `", .{kind}); + } +}; diff --git a/src/ir/ir.zig b/src/ir/ir.zig index d20b8683..f4a37730 100644 --- a/src/ir/ir.zig +++ b/src/ir/ir.zig @@ -13,6 +13,7 @@ pub const generics = @import("generics.zig"); pub const protocols = @import("protocols.zig"); pub const conversions = @import("conversions.zig"); pub const error_analysis = @import("error_analysis.zig"); +pub const error_flow = @import("error_flow.zig"); pub const semantic_diagnostics = @import("semantic_diagnostics.zig"); pub const TypeId = types.TypeId; @@ -52,6 +53,7 @@ pub const ProtocolResolver = protocols.ProtocolResolver; pub const CoercionResolver = conversions.CoercionResolver; pub const CoercionPlan = conversions.CoercionResolver.CoercionPlan; pub const ErrorAnalysis = error_analysis.ErrorAnalysis; +pub const ErrorFlow = error_flow.ErrorFlow; pub const ErrorFacts = error_analysis.ErrorFacts; pub const compiler_hooks = @import("compiler_hooks.zig"); diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 5c58fb5e..ada16721 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -28,6 +28,7 @@ const GenericResolver = @import("generics.zig").GenericResolver; const ProtocolResolver = @import("protocols.zig").ProtocolResolver; const CoercionResolver = @import("conversions.zig").CoercionResolver; const ErrorAnalysis = @import("error_analysis.zig").ErrorAnalysis; +const ErrorFlow = @import("error_flow.zig").ErrorFlow; const semantic_diagnostics = @import("semantic_diagnostics.zig"); const TypeId = types.TypeId; @@ -337,7 +338,7 @@ pub const Lowering = struct { // the error-set convergence passes (so failable callees resolve) and // before body lowering — purely a diagnostic pass; `core.zig` halts on // any error before codegen. - self.checkErrorFlow(decls); + self.errorFlow().checkErrorFlow(decls); // Pass 1f: reject identifiers used in a type position that name no // declared type / primitive / in-scope generic param (issue 0064). // Runs after scanning (so every real type name is registered) and @@ -434,395 +435,9 @@ pub const Lowering = struct { } } - // ── ERR E1.7 / E1.8 — error-flow analysis ─────────────────────────────── - // - // One structured, path-sensitive walk over each MAIN-file function body - // (imported modules are trusted) drives two checks: - // - // • E1.8 (value-slot liveness): a `v, err := failable()` destructure binds - // `v` "live only where `err` is proven absent". A read of `v` is legal - // iff `err` is proven null on the current path — established by - // `if !err { … }` (proven inside) or `if err { return/raise }` (proven on - // the fall-through). Error-set `==`/tag-compares do NOT prove absence. - // - // • E1.7 (cleanup absorption): a bare failable call in a `defer`/`onfail` - // body (with no `catch` / `or value`) is rejected — its error has nowhere - // to propagate (the block is already exiting). See `checkCleanupBody`. - - /// Per-function registration state for the flow walk. `bindings` maps each - /// failable value-slot variable to its partner error variable; `err_vars` - /// is the set of those error variables (so conditions over them refine the - /// proven-null set). Both are monotonic — entries are added as destructures - /// are encountered, never removed (out-of-scope names simply stop being read). - const FlowCtx = struct { - bindings: std.StringHashMap([]const u8), - err_vars: std.StringHashMap(void), - }; - - /// The proven-null set: error-variable names known to be absent on the - /// current path. Threaded by value-clone across branches; the join after an - /// `if` is the intersection of the reachable branches' sets. - const ProvenSet = std.ArrayList([]const u8); - - fn provenHas(set: ProvenSet, name: []const u8) bool { - for (set.items) |n| if (std.mem.eql(u8, n, name)) return true; - return false; - } - - fn provenAdd(self: *Lowering, set: *ProvenSet, name: []const u8) void { - if (!provenHas(set.*, name)) set.append(self.alloc, name) catch {}; - } - - fn provenClone(self: *Lowering, set: ProvenSet) ProvenSet { - var out = ProvenSet.empty; - out.appendSlice(self.alloc, set.items) catch {}; - return out; - } - - fn provenIntersect(self: *Lowering, a: ProvenSet, b: ProvenSet) ProvenSet { - var out = ProvenSet.empty; - for (a.items) |n| if (provenHas(b, n)) (out.append(self.alloc, n) catch {}); - return out; - } - - /// Pass 1e: run the error-flow checks over every function defined in the - /// main file. Library modules are assumed well-formed (and may use patterns - /// this conservative check would over-reject), so they are skipped. - fn checkErrorFlow(self: *Lowering, decls: []const *const Node) void { - if (self.diagnostics == null) return; - for (decls) |decl| { - if (self.main_file) |mf| { - if (decl.source_file) |sf| { - if (!std.mem.eql(u8, sf, mf)) continue; - } - } - switch (decl.data) { - .fn_decl => |fd| self.analyzeFnBody(fd.body), - .const_decl => |cd| { - if (cd.value.data == .fn_decl) self.analyzeFnBody(cd.value.data.fn_decl.body); - }, - else => {}, - } - } - } - - /// Analyze one function (or lambda) body as its own boundary — a fresh - /// binding context and an empty proven set. - fn analyzeFnBody(self: *Lowering, body: *const Node) void { - var ctx = FlowCtx{ - .bindings = std.StringHashMap([]const u8).init(self.alloc), - .err_vars = std.StringHashMap(void).init(self.alloc), - }; - var proven = ProvenSet.empty; - _ = self.flowWalk(body, &ctx, &proven); - } - - /// Walk a block or single statement. Returns whether control always - /// diverges (every path ends in return/raise/break/continue) — used by the - /// caller to mark code after a branch unreachable. - fn flowWalk(self: *Lowering, node: *const Node, ctx: *FlowCtx, proven: *ProvenSet) bool { - switch (node.data) { - .block => |b| { - for (b.stmts) |s| if (self.flowStmt(s, ctx, proven)) return true; - return false; - }, - else => return self.flowStmt(node, ctx, proven), - } - } - - fn flowStmt(self: *Lowering, node: *const Node, ctx: *FlowCtx, proven: *ProvenSet) bool { - switch (node.data) { - .destructure_decl => |dd| { - self.flowExpr(dd.value, ctx, proven.*); - self.registerFailableDestructure(&dd, ctx); - return false; - }, - .var_decl => |vd| { - if (vd.value) |v| self.flowExpr(v, ctx, proven.*); - return false; - }, - .const_decl => |cd| { - self.flowExpr(cd.value, ctx, proven.*); - return false; - }, - .assignment => |a| { - self.flowExpr(a.value, ctx, proven.*); - self.flowExpr(a.target, ctx, proven.*); - return false; - }, - .multi_assign => |ma| { - for (ma.values) |v| self.flowExpr(v, ctx, proven.*); - return false; - }, - .return_stmt => |r| { - if (r.value) |v| self.flowExpr(v, ctx, proven.*); - return true; - }, - .raise_stmt => |rs| { - self.flowExpr(rs.tag, ctx, proven.*); - return true; - }, - .break_expr, .continue_expr => return true, - .if_expr => |ie| return self.flowIf(&ie, ctx, proven), - .while_expr => |we| { - self.flowExpr(we.condition, ctx, proven.*); - var loop_proven = self.provenClone(proven.*); - _ = self.flowWalk(we.body, ctx, &loop_proven); - return false; - }, - .for_expr => |fe| { - self.flowExpr(fe.iterable, ctx, proven.*); - if (fe.range_end) |re| self.flowExpr(re, ctx, proven.*); - var loop_proven = self.provenClone(proven.*); - _ = self.flowWalk(fe.body, ctx, &loop_proven); - return false; - }, - .match_expr => |me| return self.flowMatch(&me, ctx, proven), - .push_stmt => |ps| { - self.flowExpr(ps.context_expr, ctx, proven.*); - var inner = self.provenClone(proven.*); - _ = self.flowWalk(ps.body, ctx, &inner); - return false; - }, - .defer_stmt => |ds| { - self.checkCleanupBody(ds.expr, "defer"); - self.flowExpr(ds.expr, ctx, proven.*); - return false; - }, - .onfail_stmt => |os| { - self.checkCleanupBody(os.body, "onfail"); - self.flowExpr(os.body, ctx, proven.*); - return false; - }, - else => { - self.flowExpr(node, ctx, proven.*); - return false; - }, - } - } - - /// Path-sensitive `if`: refine the proven set on each branch, recurse, and - /// join the reachable fall-through states by intersection. - fn flowIf(self: *Lowering, ie: *const ast.IfExpr, ctx: *FlowCtx, proven: *ProvenSet) bool { - self.flowExpr(ie.condition, ctx, proven.*); - var then_proven = self.provenClone(proven.*); - var else_proven = self.provenClone(proven.*); - self.applyRefinement(ie.condition, true, ctx, &then_proven); - self.applyRefinement(ie.condition, false, ctx, &else_proven); - - const then_div = self.flowWalk(ie.then_branch, ctx, &then_proven); - var else_div = false; - if (ie.else_branch) |eb| else_div = self.flowWalk(eb, ctx, &else_proven); - - // Reachable fall-through contributors: a branch that doesn't diverge, - // plus the implicit (empty) else when there is no `else`. - var contributors = std.ArrayList(ProvenSet).empty; - if (!then_div) contributors.append(self.alloc, then_proven) catch {}; - if (ie.else_branch != null) { - if (!else_div) contributors.append(self.alloc, else_proven) catch {}; - } else { - contributors.append(self.alloc, else_proven) catch {}; - } - if (contributors.items.len == 0) return true; // both branches diverge - - var result = self.provenClone(contributors.items[0]); - for (contributors.items[1..]) |c| result = self.provenIntersect(result, c); - proven.* = result; - return false; - } - - /// Refine the proven-null set for a branch taken when `cond` is `want_true`. - /// A bare error-variable is truthy when it HOLDS an error, so its falsy edge - /// proves absence; `!`, `&&`, `||` compose. Tag/equality compares prove - /// nothing (error-set `==` compares tags, not presence). - fn applyRefinement(self: *Lowering, cond: *const Node, want_true: bool, ctx: *FlowCtx, set: *ProvenSet) void { - switch (cond.data) { - .identifier => |id| { - if (ctx.err_vars.contains(id.name) and !want_true) self.provenAdd(set, id.name); - }, - .unary_op => |uop| { - if (uop.op == .not) self.applyRefinement(uop.operand, !want_true, ctx, set); - }, - .binary_op => |bop| { - if (bop.op == .and_op and want_true) { - self.applyRefinement(bop.lhs, true, ctx, set); - self.applyRefinement(bop.rhs, true, ctx, set); - } else if (bop.op == .or_op and !want_true) { - self.applyRefinement(bop.lhs, false, ctx, set); - self.applyRefinement(bop.rhs, false, ctx, set); - } - }, - else => {}, - } - } - - fn flowMatch(self: *Lowering, me: *const ast.MatchExpr, ctx: *FlowCtx, proven: *ProvenSet) bool { - self.flowExpr(me.subject, ctx, proven.*); - for (me.arms) |arm| { - var arm_proven = self.provenClone(proven.*); - _ = self.flowWalk(arm.body, ctx, &arm_proven); - } - return false; - } - - /// Check an expression for reads of a still-tainted value-slot variable and - /// recurse into nested lambdas as their own boundaries. `proven` is by value - /// — sub-expressions never publish proven-null facts back to the statement. - fn flowExpr(self: *Lowering, node: *const Node, ctx: *FlowCtx, proven: ProvenSet) void { - switch (node.data) { - .identifier => |id| { - if (ctx.bindings.get(id.name)) |err_var| { - if (!provenHas(proven, err_var)) { - if (self.diagnostics) |d| d.addFmt(.err, node.span, "value `{s}` from a failable can be used only where its error `{s}` is proven absent — guard the use with `if !{s} {{ … }}`, or return early with `if {s} {{ return; }}` before reading `{s}`", .{ id.name, err_var, err_var, err_var, id.name }); - } - } - }, - .lambda => |lam| self.analyzeFnBody(lam.body), - .binary_op => |b| { - self.flowExpr(b.lhs, ctx, proven); - // Short-circuit: the rhs of `&&` runs only when the lhs is true - // (and `||`'s rhs only when the lhs is false), so refine the - // proven-null set accordingly before checking it. This is what - // makes `if !err && use(v)` legal. - if (b.op == .and_op or b.op == .or_op) { - var rp = self.provenClone(proven); - self.applyRefinement(b.lhs, b.op == .and_op, ctx, &rp); - self.flowExpr(b.rhs, ctx, rp); - } else { - self.flowExpr(b.rhs, ctx, proven); - } - }, - .chained_comparison => |cc| { - for (cc.operands) |op| self.flowExpr(op, ctx, proven); - }, - .unary_op => |u| self.flowExpr(u.operand, ctx, proven), - .call => |c| { - self.flowExpr(c.callee, ctx, proven); - for (c.args) |a| self.flowExpr(a, ctx, proven); - }, - .field_access => |fa| self.flowExpr(fa.object, ctx, proven), - .index_expr => |ix| { - self.flowExpr(ix.object, ctx, proven); - self.flowExpr(ix.index, ctx, proven); - }, - .slice_expr => |se| { - self.flowExpr(se.object, ctx, proven); - if (se.start) |s| self.flowExpr(s, ctx, proven); - if (se.end) |e| self.flowExpr(e, ctx, proven); - }, - .try_expr => |te| self.flowExpr(te.operand, ctx, proven), - .catch_expr => |ce| { - self.flowExpr(ce.operand, ctx, proven); - self.flowExpr(ce.body, ctx, proven); - }, - .force_unwrap => |fu| self.flowExpr(fu.operand, ctx, proven), - .null_coalesce => |nc| { - self.flowExpr(nc.lhs, ctx, proven); - self.flowExpr(nc.rhs, ctx, proven); - }, - .deref_expr => |de| self.flowExpr(de.operand, ctx, proven), - .comptime_expr => |ce| self.flowExpr(ce.expr, ctx, proven), - .insert_expr => |ie| self.flowExpr(ie.expr, ctx, proven), - .spread_expr => |se| self.flowExpr(se.operand, ctx, proven), - .struct_literal => |sl| { - for (sl.field_inits) |fi| self.flowExpr(fi.value, ctx, proven); - }, - .array_literal => |al| { - for (al.elements) |el| self.flowExpr(el, ctx, proven); - }, - .tuple_literal => |tl| { - for (tl.elements) |el| self.flowExpr(el.value, ctx, proven); - }, - .if_expr => |ie| { - var tmp = self.provenClone(proven); - _ = self.flowIf(&ie, ctx, &tmp); - }, - .match_expr => |me| { - var tmp = self.provenClone(proven); - _ = self.flowMatch(&me, ctx, &tmp); - }, - .block => |b| { - var tmp = self.provenClone(proven); - for (b.stmts) |s| if (self.flowStmt(s, ctx, &tmp)) break; - }, - else => {}, - } - } - - /// Register a `v…, err := failable()` destructure. Only a complete bare - /// destructure (every slot bound, error slot a real name) creates taint — - /// an omitted or `_`-bound error slot is already rejected by the discard - /// check in `lowerDestructureDecl`, so it produces no proof obligation here. - fn registerFailableDestructure(self: *Lowering, dd: *const ast.DestructureDecl, ctx: *FlowCtx) void { - const ty = self.inferExprType(dd.value); - if (self.errorChannelOf(ty) == null) return; - if (ty.isBuiltin()) return; - const ti = self.module.types.get(ty); - if (ti != .tuple) return; - const fields = ti.tuple.fields; - if (dd.names.len != fields.len) return; - const err_name = dd.names[fields.len - 1]; - if (std.mem.eql(u8, err_name, "_")) return; - ctx.err_vars.put(err_name, {}) catch {}; - var i: usize = 0; - while (i + 1 < dd.names.len) : (i += 1) { - const vn = dd.names[i]; - if (std.mem.eql(u8, vn, "_")) continue; - ctx.bindings.put(vn, err_name) catch {}; - } - } - - /// E1.7: a `defer`/`onfail` body runs while the block is already exiting, so - /// a bare failable call has nowhere to send its error. Reject any failable - /// expression-statement that isn't absorbed locally by `catch` / `or value` - /// / a destructure binding. (Parser already bans `try`/`raise`/`return`/ - /// `break`/`continue` here, so the only escape route left for a failable is - /// local absorption.) The check is transitive through nested blocks, `if`, - /// loops, match arms, and `catch` handlers, but stops at a nested closure - /// (its own function boundary). - fn checkCleanupBody(self: *Lowering, body: *const Node, kind: []const u8) void { - self.checkCleanupNode(body, kind); - } - - fn checkCleanupNode(self: *Lowering, node: *const Node, kind: []const u8) void { - switch (node.data) { - .block => |b| for (b.stmts) |s| self.checkCleanupNode(s, kind), - .if_expr => |ie| { - self.cleanupReject(ie.condition, kind); - self.checkCleanupNode(ie.then_branch, kind); - if (ie.else_branch) |eb| self.checkCleanupNode(eb, kind); - }, - .while_expr => |we| { - self.cleanupReject(we.condition, kind); - self.checkCleanupNode(we.body, kind); - }, - .for_expr => |fe| self.checkCleanupNode(fe.body, kind), - .match_expr => |me| for (me.arms) |arm| self.checkCleanupNode(arm.body, kind), - .push_stmt => |ps| self.checkCleanupNode(ps.body, kind), - // A destructure binds the error slot → absorbed (explicit ownership). - .destructure_decl => {}, - .var_decl => |vd| if (vd.value) |v| self.cleanupReject(v, kind), - .const_decl => |cd| self.cleanupReject(cd.value, kind), - .assignment => |a| self.cleanupReject(a.value, kind), - // Closures are their own boundary; the parser-banned control-flow - // exits are handled elsewhere; nested cleanup is independent. - .lambda, .return_stmt, .raise_stmt, .break_expr, .continue_expr, .defer_stmt, .onfail_stmt => {}, - else => self.cleanupReject(node, kind), - } - } - - /// Reject `expr` if it is a bare (un-absorbed) failable in cleanup position. - /// `catch` / `or value` strip the error channel (so `exprIsFailable` is - /// false for them); only a still-failable expression has an unhandled error. - fn cleanupReject(self: *Lowering, expr: *const Node, kind: []const u8) void { - if (expr.data == .catch_expr) { - // The operand is absorbed; the handler body still runs in cleanup. - self.checkCleanupNode(expr.data.catch_expr.body, kind); - return; - } - if (!self.exprIsFailable(expr)) return; - if (self.diagnostics) |d| d.addFmt(.err, expr.span, "a bare failable call in a `{s}` body has nowhere to send its error — the block is already exiting; absorb it locally with `catch` or `or `", .{kind}); - } + // ERR E1.7 / E1.8 — path-sensitive error-flow diagnostics (Pass 1e) live in + // `error_flow.zig` (`ErrorFlow`, a `*Lowering` facade). `lowerRoot` calls + // `self.errorFlow().checkErrorFlow(decls)`. /// On Android, the OS loads the .so via a Java-side Activity declared /// with `#jni_main #jni_class("...")`. The Java class drives the @@ -13863,6 +13478,10 @@ pub const Lowering = struct { return .{ .l = self }; } + pub fn errorFlow(self: *Lowering) ErrorFlow { + return .{ .l = self }; + } + /// Lower the `xx` operator (type coercion). /// Uses self.target_type for context when available. Handles: /// - Any → concrete type: unbox_any @@ -14969,7 +14588,7 @@ pub const Lowering = struct { /// operand's success value, but the expression itself routes an error) or /// any expression whose type carries an error channel (a bare failable /// call). Used to detect failable `or` chains (deferred to E1.4b). - fn exprIsFailable(self: *Lowering, node: *const Node) bool { + pub fn exprIsFailable(self: *Lowering, node: *const Node) bool { if (node.data == .try_expr) return true; return self.errorChannelOf(self.inferExprType(node)) != null; }