From e4cd6e48b989d091dd80da23b9c034ee9b939b00 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 20 May 2026 10:17:06 +0100 Subject: [PATCH] fix(abi-verify): tolerate non-canonical Zig switch arm `false` shorthand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `abi-verify`'s Zig FFI parser bombed on 5 cartridges' `isValidTransition` switch arms because their terminal-state arm body is the literal `false` (no outgoing transitions allowed) rather than the canonical `to == .` chunk form: fn isValidTransition(from: BspState, to: BspState) bool { return switch (from) { ... .exited => false, // <-- parser bombed here }; } The Zig is well-formed and the semantics ("empty allowed-set") are clear; the verifier just didn't accept the shorthand. `parse_arm_targets` now detects this form (body trimmed of trailing `,`/`;` and whitespace equals `false`) and returns the empty vec — equivalent to the "this state has no allowed outgoing transitions" manifest semantics, which is exactly what the cartridges intend. End-to-end verified against the 5 cartridges named in the issue (after a fresh `cargo build --release`): bsp-mcp → parses cleanly; surfaces real drift on BspCapability container-mcp → abi-verify OK dap-mcp → parses cleanly; surfaces real drift on StepGranularity lsp-mcp → parses cleanly; surfaces real drift on CompletionKind vault-mcp → parses cleanly; surfaces real drift on IdentityType All 5 now produce either exit 0 (clean) or a real drift diagnosis, which is precisely the acceptance criterion in iseriser#19. The post-parse drift findings are separate per-cartridge issues — not verifier defects — and out of scope for this PR. 44 lib tests + 9 integration tests pass. Refs hyperpolymath/standards#92 (Phase 2 allowlist expansion). Refs hyperpolymath/iseriser#19. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/abi/zig_ffi_parser.rs | 56 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/abi/zig_ffi_parser.rs b/src/abi/zig_ffi_parser.rs index 14451b0..7247837 100644 --- a/src/abi/zig_ffi_parser.rs +++ b/src/abi/zig_ffi_parser.rs @@ -246,7 +246,21 @@ fn parse_switch_arms(body: &str) -> Result>> { /// Parse `to == . or to == . or to == .` → `["v1","v2","v3"]`. /// Also accepts the singleton form `to == .`. +/// +/// Terminal-state shorthand: a body of `false` (with optional trailing +/// `,` / `;` and surrounding whitespace) is accepted as the empty +/// allowed-set — i.e. "no outgoing transitions allowed from this +/// state". Cartridges like bsp-mcp, container-mcp, dap-mcp, lsp-mcp, +/// vault-mcp use this form for their `exited` / terminal arms. fn parse_arm_targets(body: &str) -> Result> { + let trimmed = body + .trim() + .trim_end_matches(',') + .trim_end_matches(';') + .trim(); + if trimmed == "false" { + return Ok(Vec::new()); + } let mut out = Vec::new(); for chunk in body.split(" or ") { let chunk = chunk.trim(); @@ -338,4 +352,46 @@ mod tests { let tt = f.transition_table.unwrap(); assert!(tt.arms.contains_key("_else")); } + + #[test] + fn tolerates_terminal_false_arm() { + // bsp-mcp / container-mcp / dap-mcp / lsp-mcp / vault-mcp shape: + // the terminal state's arm body is the literal `false` (meaning + // no outgoing transitions allowed), not a `to == .` chunk. + // Without this tolerance the verifier mis-classifies the cartridge + // as a parser error rather than the correct "empty allowed-set" + // semantics. + let src = r#" + pub const BspState = enum(c_int) { + uninitialized = 0, + initializing = 1, + ready = 2, + exited = 3, + }; + fn isValidTransition(from: BspState, to: BspState) bool { + return switch (from) { + .uninitialized => to == .initializing, + .initializing => to == .ready or to == .exited, + .ready => to == .exited, + .exited => false, + }; + } + "#; + let f = parse(src).unwrap(); + let tt = f.transition_table.unwrap(); + // The terminal arm parses and lands in the arms map with an + // empty allowed-set; it MUST be present (otherwise the verifier + // would surface accept-by-omission as drift) but it MUST be + // empty (no targets). + assert_eq!(tt.arms.get("exited"), Some(&Vec::::new())); + // The other arms still parse correctly. + assert_eq!( + tt.arms.get("uninitialized"), + Some(&vec!["initializing".to_string()]) + ); + assert_eq!( + tt.arms.get("initializing"), + Some(&vec!["ready".to_string(), "exited".to_string()]) + ); + } }