Skip to content

Commit ec64ca6

Browse files
hyperpolymathclaude
andcommitted
feat(affinescript): Stage 7 — typed-wasm ownership verifier (Tw_verify)
Add lib/tw_verify.ml: static verifier for typed-wasm Level 7 (aliasing safety) and Level 10 (linearity) constraints on the emitted Wasm IR. - verify_module: checks each function's body for LinearNotUsed, LinearUsedMultiple, and ExclBorrowAliased violations using a recursive LocalGet count with branch-max semantics for if/else - verify_from_module: parses the affinescript.ownership custom section and calls verify_module — self-contained pipeline entry point - pp_error / pp_report: human-readable violation output bin/main.ml: add `affinescript verify FILE` subcommand that runs the full frontend pipeline then calls verify_from_module; exit 0 = clean, exit 1 = violations. test/test_e2e.ml: 9 new E2E tests (E2E Ownership Verify suite): - 7 synthetic Wasm module tests (linear/excl ok/violation + if-branch) - pipeline: ownership_codegen.affine → LinearNotUsed expected - pipeline: verify_ownership_clean.affine → OK 157/157 tests passing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 2abdb35 commit ec64ca6

5 files changed

Lines changed: 504 additions & 2 deletions

File tree

bin/main.ml

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -610,6 +610,73 @@ let lint_file face json path =
610610
`Error (false, "Parse error")
611611
end
612612

613+
(** {1 Stage 7: typed-wasm ownership verifier subcommand} *)
614+
615+
(** Verify typed-wasm Level 7/10 ownership constraints on a compiled AffineScript
616+
source file.
617+
618+
Runs the full frontend pipeline (parse → resolve → typecheck → borrow →
619+
quantity → codegen) and then passes the resulting Wasm module through
620+
[Tw_verify.verify_from_module], which reads the [affinescript.ownership]
621+
custom section and checks each function body for linearity (Level 10) and
622+
aliasing-safety (Level 7) violations.
623+
624+
Exit code 0 = clean. Exit code 1 = violations found.
625+
626+
Usage: affinescript verify FILE *)
627+
let verify_file face path =
628+
let face = resolve_face face path in
629+
try
630+
let prog = parse_with_face face path in
631+
let loader_config = Affinescript.Module_loader.default_config () in
632+
let loader = Affinescript.Module_loader.create loader_config in
633+
(match Affinescript.Resolve.resolve_program_with_loader prog loader with
634+
| Error (e, _span) ->
635+
Format.eprintf "Resolution error: %s@."
636+
(Affinescript.Face.format_resolve_error face e);
637+
`Error (false, "Resolution error")
638+
| Ok (resolve_ctx, _type_ctx) ->
639+
(match Affinescript.Typecheck.check_program resolve_ctx.symbols prog with
640+
| Error e ->
641+
Format.eprintf "%s@."
642+
(Affinescript.Face.format_type_error face e);
643+
`Error (false, "Type error")
644+
| Ok _type_ctx ->
645+
(match Affinescript.Borrow.check_program resolve_ctx.symbols prog with
646+
| Error e ->
647+
Format.eprintf "Borrow error: %s@."
648+
(Affinescript.Face.format_borrow_error face e);
649+
`Error (false, "Borrow error")
650+
| Ok () ->
651+
(match Affinescript.Quantity.check_program_quantities prog with
652+
| Error (err, _span) ->
653+
Format.eprintf "Quantity error: %s@."
654+
(Affinescript.Face.format_quantity_error face err);
655+
`Error (false, "Quantity error")
656+
| Ok () ->
657+
let optimized_prog = Affinescript.Opt.fold_constants_program prog in
658+
(match Affinescript.Codegen.generate_module optimized_prog with
659+
| Error e ->
660+
Format.eprintf "Codegen error: %s@."
661+
(Affinescript.Codegen.show_codegen_error e);
662+
`Error (false, "Codegen error")
663+
| Ok wasm_mod ->
664+
(match Affinescript.Tw_verify.verify_from_module wasm_mod with
665+
| Ok () ->
666+
Format.printf "typed-wasm ownership verification: OK@.";
667+
`Ok ()
668+
| Error errs ->
669+
Affinescript.Tw_verify.pp_report Format.std_formatter errs;
670+
`Error (false, "Ownership violations found")))))))
671+
with
672+
| Affinescript.Lexer.Lexer_error (msg, pos) ->
673+
Format.eprintf "%s:%d:%d: lexer error: %s@." path pos.line pos.col msg;
674+
`Error (false, "Lexer error")
675+
| Affinescript.Parse_driver.Parse_error (msg, span) ->
676+
Format.eprintf "%a: parse error: %s@."
677+
Affinescript.Span.pp_short span msg;
678+
`Error (false, "Parse error")
679+
613680
(** {1 CLI command definitions} *)
614681
open Cmdliner
615682

@@ -683,6 +750,21 @@ let router_bridge_cmd =
683750
let info = Cmd.info "router-bridge" ~doc in
684751
Cmd.v info Term.(ret (const router_bridge_cmd_fn $ output_arg))
685752

753+
let verify_cmd =
754+
let doc = "Verify typed-wasm Level 7/10 ownership constraints on compiled output" in
755+
let man = [
756+
`S "DESCRIPTION";
757+
`P "Compiles FILE through the full AffineScript pipeline and then verifies \
758+
that the emitted Wasm module satisfies typed-wasm ownership contracts:";
759+
`P "Level 10 (Linearity): each parameter annotated [own] must be consumed \
760+
exactly once in the function body.";
761+
`P "Level 7 (Aliasing safety): each parameter annotated [mut] must be \
762+
referenced at most once simultaneously.";
763+
`P "Exit 0 if all constraints are satisfied; exit 1 if violations are found.";
764+
] in
765+
let info = Cmd.info "verify" ~doc ~man in
766+
Cmd.v info Term.(ret (const verify_file $ face_arg $ path_arg))
767+
686768
let repl_cmd =
687769
let doc = "Start the interactive REPL" in
688770
let info = Cmd.info "repl" ~doc in
@@ -893,7 +975,7 @@ let default_cmd =
893975
Cmd.group info ~default [
894976
lex_cmd; parse_cmd; check_cmd; eval_cmd; repl_cmd; compile_cmd;
895977
fmt_cmd; lint_cmd;
896-
tea_bridge_cmd; router_bridge_cmd;
978+
tea_bridge_cmd; router_bridge_cmd; verify_cmd;
897979
hover_cmd; goto_def_cmd; complete_cmd; server_cmd;
898980
preview_python_cmd; preview_js_cmd; preview_pseudocode_cmd
899981
]

lib/dune

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
(name affinescript)
33
(public_name affinescript)
44
(modes byte native)
5-
(modules ast borrow codegen codegen_gc desugar_traits effect error error_collector error_formatter face formatter interp js_face julia_codegen json_output lexer linter lsp_server module_loader opt parse_driver parse parser parser_errors pseudocode_face python_face quantity resolve span symbol tea_bridge tea_router token trait typecheck types unify value wasm wasm_encode wasm_gc wasm_gc_encode wasi_runtime)
5+
(modules ast borrow codegen codegen_gc desugar_traits effect error error_collector error_formatter face formatter interp js_face julia_codegen json_output lexer linter lsp_server module_loader opt parse_driver parse parser parser_errors pseudocode_face python_face quantity resolve span symbol tea_bridge tea_router token trait tw_verify typecheck types unify value wasm wasm_encode wasm_gc wasm_gc_encode wasi_runtime)
66
(libraries str unix sedlex fmt menhirLib yojson)
77
(preprocess
88
(pps ppx_deriving.show ppx_deriving.eq ppx_deriving.ord sedlex.ppx)))

lib/tw_verify.ml

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
(* SPDX-License-Identifier: PMPL-1.0-or-later *)
2+
(* SPDX-FileCopyrightText: 2024-2026 hyperpolymath *)
3+
4+
(** typed-wasm ownership verifier — Stage 7.
5+
6+
Statically verifies typed-wasm Level 7 (aliasing safety) and Level 10
7+
(linearity) constraints on AffineScript's Wasm IR.
8+
9+
The verifier runs after codegen on the in-memory [Wasm.wasm_module]
10+
together with the ownership annotations collected during codegen.
11+
It is a second line of defence: the QTT quantity checker (Stage 1) already
12+
enforces these rules at the AffineScript source level; this module
13+
re-checks them on the emitted Wasm IR to catch any codegen bugs.
14+
15+
Level 10 — Linearity: a parameter annotated [Linear] (TyOwn) must be
16+
loaded exactly once in the function body. Loading zero times means the
17+
owned value is dropped; loading more than once means it may be duplicated.
18+
19+
Level 7 — Aliasing safety: a parameter annotated [ExclBorrow] (TyMut)
20+
must be loaded at most once. Multiple loads create aliased references to
21+
the same mutable memory, violating exclusive-borrow invariants.
22+
23+
[SharedBorrow] (TyRef) and [Unrestricted] params are not checked by this
24+
pass (no constraints violated by multiple reads of a shared reference).
25+
26+
Branch semantics: for [If] instructions, we take the {b max} of the
27+
use-counts across the two branches (since only one branch executes at
28+
runtime). This is conservative: a linear param used exactly once in
29+
each branch of an if/else is counted as 1 (correct), not 2. Stage 8
30+
will refine this with a per-path analysis if needed.
31+
32+
@see typed-wasm LEVEL-STATUS.md — Level 7 and Level 10 sections.
33+
*)
34+
35+
open Codegen
36+
37+
(* ============================================================================
38+
Error types
39+
============================================================================ *)
40+
41+
(** An ownership violation found in a Wasm function body. *)
42+
type ownership_error =
43+
| LinearNotUsed of { func_idx : int; param_idx : int }
44+
(** Level 10: Linear parameter was never loaded — dropped without consumption.
45+
The owned resource leaks. *)
46+
| LinearUsedMultiple of { func_idx : int; param_idx : int; count : int }
47+
(** Level 10: Linear parameter was loaded [count] times — potential duplication.
48+
Only one consumer is permitted. *)
49+
| ExclBorrowAliased of { func_idx : int; param_idx : int; count : int }
50+
(** Level 7: ExclBorrow parameter was loaded [count] times — aliasing violation.
51+
An exclusive borrow must not have multiple simultaneous references. *)
52+
53+
(* ============================================================================
54+
Use-count analysis
55+
============================================================================ *)
56+
57+
(** Count how many times [local_idx] is loaded inside [instrs].
58+
59+
For [If] nodes the branch counts are combined with [max] rather than
60+
summed, reflecting that only one branch executes. All other nodes are
61+
summed. Nested [Block] and [Loop] bodies are descended into. *)
62+
let rec count_uses (local_idx : int) (instrs : Wasm.instr list) : int =
63+
List.fold_left (fun acc instr -> acc + uses_in_instr local_idx instr) 0 instrs
64+
65+
and uses_in_instr (local_idx : int) (instr : Wasm.instr) : int =
66+
match instr with
67+
| Wasm.LocalGet n -> if n = local_idx then 1 else 0
68+
| Wasm.Block (_, body) | Wasm.Loop (_, body) ->
69+
count_uses local_idx body
70+
| Wasm.If (_, then_, else_) ->
71+
(* Only one branch executes: take max so that
72+
If { then: LocalGet 0 } { else: LocalGet 0 } counts as 1, not 2. *)
73+
max (count_uses local_idx then_) (count_uses local_idx else_)
74+
| _ -> 0
75+
76+
(* ============================================================================
77+
Per-function verification
78+
============================================================================ *)
79+
80+
(** Verify ownership constraints for one function.
81+
82+
[func] is the Wasm function body.
83+
[param_kinds] are the ownership annotations for each parameter (in order).
84+
[func_idx] is the global function index (for error reporting).
85+
Returns all violations found (empty list = clean). *)
86+
let verify_function
87+
(func : Wasm.func)
88+
(param_kinds : ownership_kind list)
89+
(func_idx : int)
90+
: ownership_error list =
91+
List.concat_map (fun (param_idx, kind) ->
92+
let uses = count_uses param_idx func.Wasm.f_body in
93+
match kind with
94+
| Linear ->
95+
(* Exactly once: zero = dropped, >1 = may be duplicated. *)
96+
if uses = 0 then
97+
[LinearNotUsed { func_idx; param_idx }]
98+
else if uses > 1 then
99+
[LinearUsedMultiple { func_idx; param_idx; count = uses }]
100+
else
101+
[]
102+
| ExclBorrow ->
103+
(* At most once: >1 creates simultaneous aliases. *)
104+
if uses > 1 then
105+
[ExclBorrowAliased { func_idx; param_idx; count = uses }]
106+
else
107+
[]
108+
| Unrestricted | SharedBorrow ->
109+
(* No constraints on these ownership kinds. *)
110+
[]
111+
) (List.mapi (fun i k -> (i, k)) param_kinds)
112+
113+
(* ============================================================================
114+
Module-level verification
115+
============================================================================ *)
116+
117+
(** Verify ownership constraints across an entire Wasm module.
118+
119+
[wasm_mod] is the compiled module.
120+
[annots] is the list of [(func_idx, param_kinds, ret_kind)] annotations
121+
collected by codegen and stored in the [affinescript.ownership] custom
122+
section (but here provided in structured form directly from [Codegen]).
123+
124+
Imported functions have no body and are skipped.
125+
Returns all violations found. *)
126+
let verify_module
127+
(wasm_mod : Wasm.wasm_module)
128+
(annots : (int * ownership_kind list * ownership_kind) list)
129+
: ownership_error list =
130+
let import_count = List.length wasm_mod.Wasm.imports in
131+
List.concat_map (fun (func_idx, param_kinds, _ret_kind) ->
132+
let local_idx = func_idx - import_count in
133+
if local_idx < 0 || local_idx >= List.length wasm_mod.Wasm.funcs then
134+
[] (* Imported function: no IR to inspect *)
135+
else
136+
let func = List.nth wasm_mod.Wasm.funcs local_idx in
137+
verify_function func param_kinds func_idx
138+
) annots
139+
140+
(* ============================================================================
141+
Pipeline integration — parse ownership section from the module
142+
============================================================================ *)
143+
144+
(** Parse the [affinescript.ownership] custom section payload into structured
145+
[(func_idx, param_kinds, ret_kind)] annotations.
146+
147+
Binary encoding (from [Codegen.build_ownership_section]):
148+
u32le count
149+
for each entry:
150+
u32le func_idx
151+
u8 n_params
152+
u8[n] param_kinds (0=Unrestricted, 1=Linear, 2=SharedBorrow, 3=ExclBorrow)
153+
u8 ret_kind *)
154+
let parse_ownership_section_payload
155+
(payload : bytes)
156+
: (int * ownership_kind list * ownership_kind) list =
157+
let kind_of_byte = function
158+
| 1 -> Linear
159+
| 2 -> SharedBorrow
160+
| 3 -> ExclBorrow
161+
| _ -> Unrestricted
162+
in
163+
let pos = ref 0 in
164+
let len = Bytes.length payload in
165+
let read_u32_le () =
166+
if !pos + 4 > len then 0
167+
else begin
168+
let b0 = Char.code (Bytes.get payload !pos) in
169+
let b1 = Char.code (Bytes.get payload (!pos + 1)) in
170+
let b2 = Char.code (Bytes.get payload (!pos + 2)) in
171+
let b3 = Char.code (Bytes.get payload (!pos + 3)) in
172+
pos := !pos + 4;
173+
b0 lor (b1 lsl 8) lor (b2 lsl 16) lor (b3 lsl 24)
174+
end
175+
in
176+
let read_u8 () =
177+
if !pos >= len then 0
178+
else begin
179+
let b = Char.code (Bytes.get payload !pos) in
180+
pos := !pos + 1;
181+
b
182+
end
183+
in
184+
let count = read_u32_le () in
185+
List.init count (fun _ ->
186+
let func_idx = read_u32_le () in
187+
let n_params = read_u8 () in
188+
let param_kinds = List.init n_params (fun _ -> kind_of_byte (read_u8 ())) in
189+
let ret_kind = kind_of_byte (read_u8 ()) in
190+
(func_idx, param_kinds, ret_kind)
191+
)
192+
193+
(** Verify a Wasm module using the embedded [affinescript.ownership] custom
194+
section. This is the primary entry point for the pipeline and the CLI.
195+
196+
Returns [Ok ()] if no violations are found, [Error errs] otherwise. *)
197+
let verify_from_module
198+
(wasm_mod : Wasm.wasm_module)
199+
: (unit, ownership_error list) Result.t =
200+
match List.assoc_opt "affinescript.ownership" wasm_mod.Wasm.custom_sections with
201+
| None ->
202+
(* No ownership section: nothing to verify. This is not an error —
203+
modules compiled without ownership qualifiers have no constraints. *)
204+
Ok ()
205+
| Some payload ->
206+
let annots = parse_ownership_section_payload payload in
207+
let errors = verify_module wasm_mod annots in
208+
if errors = [] then Ok () else Error errors
209+
210+
(* ============================================================================
211+
Pretty-printing
212+
============================================================================ *)
213+
214+
(** Format a single ownership error for human-readable output. *)
215+
let pp_error (fmt : Format.formatter) (err : ownership_error) : unit =
216+
match err with
217+
| LinearNotUsed { func_idx; param_idx } ->
218+
Format.fprintf fmt
219+
"Level 10 violation: function %d, param %d — Linear (own) param dropped \
220+
(must be consumed exactly once)"
221+
func_idx param_idx
222+
| LinearUsedMultiple { func_idx; param_idx; count } ->
223+
Format.fprintf fmt
224+
"Level 10 violation: function %d, param %d — Linear (own) param loaded \
225+
%d times (exactly 1 required; possible duplication)"
226+
func_idx param_idx count
227+
| ExclBorrowAliased { func_idx; param_idx; count } ->
228+
Format.fprintf fmt
229+
"Level 7 violation: function %d, param %d — ExclBorrow (mut) param \
230+
aliased (%d simultaneous references; at most 1 permitted)"
231+
func_idx param_idx count
232+
233+
(** Format a full verification report. Prints "OK" if no errors. *)
234+
let pp_report (fmt : Format.formatter) (errs : ownership_error list) : unit =
235+
match errs with
236+
| [] ->
237+
Format.fprintf fmt "typed-wasm ownership verification: OK@."
238+
| _ ->
239+
Format.fprintf fmt "typed-wasm ownership verification: %d violation(s)@."
240+
(List.length errs);
241+
List.iter (fun e ->
242+
Format.fprintf fmt " %a@." pp_error e
243+
) errs
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// Stage 7 verify integration fixture — clean case.
3+
//
4+
// This fixture compiles cleanly and the verifier must report OK.
5+
// All functions have only Unrestricted (plain Int) params so that no
6+
// LinearNotUsed or ExclBorrowAliased violations can fire.
7+
// The ownership section will be present with all-zero (Unrestricted) entries.
8+
9+
fn identity(n: Int) -> Int {
10+
n
11+
}
12+
13+
fn add(a: Int, b: Int) -> Int {
14+
a + b
15+
}
16+
17+
fn double(x: Int) -> Int {
18+
x + x
19+
}
20+
21+
fn const_zero(n: Int) -> Int {
22+
0
23+
}

0 commit comments

Comments
 (0)