Skip to content

Commit 570d337

Browse files
feat(abi-verify): teach the converter Zig reserved words (standards#92 follow-up) (#15)
Phase 2's spot-check across the boj-server cartridge corpus found that when an Idris2 variant snake_cases to a Zig reserved word, the cartridge renames the Zig identifier to avoid the collision (e.g. `Error → err` in airtable-mcp, postgresql-mcp). The verifier raised this as `variant-missing-in-zig` + `variant-extra-in-zig` — a false-positive class that masked real findings. ## Changes - `manifest_schema.rs`: - `ZIG_RESERVED` — Zig 0.15.x reserved-word list (50 keywords) - `is_zig_reserved(word)` — predicate - `zig_reserved_workaround(word)` — cartridge convention table (`error → err` is the verified case; generic `<name>_` fallback for future cartridges that pick a different rename) - `zig_variant_candidates(idris_name)` — returns the snake_case form, plus the workaround when the snake_case is reserved - `verify.rs`: - enum-variant lookup tries every candidate; first match wins - `manifest_keys` records every candidate so the accept-by-omission check doesn't flag a Zig variant the manifest legitimately covers - transition-table resolution uses candidates symmetrically (so `Error → SomeState` in the manifest matches `err → some_state` in the Zig switch) - finding messages now name every candidate tried, not just the snake_case form ## Verified - 43 lib tests green (was 39; +4 new in `manifest_schema::tests`) - `airtable-mcp` was DRIFT (Error→err false-positive), is now CLEAN - `postgresql-mcp` now passes the reserved-word check; remaining drift is a separate naming-convention issue (`BeginTransaction → begin_tx`, abbreviation, not reserved-word — out of scope for this PR) - `ssg-mcp`, `k9iser-mcp` unchanged (still clean) - `007-mcp` still DRIFT (`ToolRisk` missing in Zig — that's a real cartridge defect, not a verifier defect, will be fixed separately) ## Note on `type` `type` is a Zig **primitive** (alongside `bool`, `u32`, etc.), not a reserved keyword — it's a legal identifier in enum contexts. Explicit test pinning this so a future reader doesn't add it to the reserved list by mistake. Refs hyperpolymath/standards#92 Refs hyperpolymath/standards#89 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent aa2484c commit 570d337

2 files changed

Lines changed: 118 additions & 12 deletions

File tree

src/abi/manifest_schema.rs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,51 @@ pub fn to_snake_case(s: &str) -> String {
6868
out
6969
}
7070

71+
/// Zig 0.15.x reserved words that can collide with a snake_case-converted
72+
/// Idris2 variant name. When the converted name matches one of these,
73+
/// the cartridge convention is to rename the variant in Zig — the
74+
/// verifier accepts the cartridge convention as a valid alternative.
75+
const ZIG_RESERVED: &[&str] = &[
76+
"addrspace", "align", "allowzero", "and", "anyframe", "anytype", "asm",
77+
"async", "await", "break", "callconv", "catch", "comptime", "const",
78+
"continue", "defer", "else", "enum", "errdefer", "error", "export",
79+
"extern", "fn", "for", "if", "inline", "linksection", "noalias",
80+
"noinline", "nosuspend", "null", "opaque", "or", "orelse", "packed",
81+
"pub", "resume", "return", "struct", "suspend", "switch", "test",
82+
"threadlocal", "try", "union", "unreachable", "usingnamespace", "var",
83+
"volatile", "while",
84+
];
85+
86+
pub fn is_zig_reserved(word: &str) -> bool {
87+
ZIG_RESERVED.contains(&word)
88+
}
89+
90+
/// Cartridge-convention workaround for a Zig reserved word. Verified
91+
/// against the actual `*_ffi.zig` corpus on `boj-server/main`:
92+
/// `error` → `err` (airtable-mcp, postgresql-mcp, others)
93+
/// _other_ → `<name>_` generic suffix fallback (Zig accepts this and
94+
/// no current cartridge uses a non-`error` reserved word; will be
95+
/// refined if a real cartridge picks a different convention)
96+
fn zig_reserved_workaround(reserved: &str) -> String {
97+
match reserved {
98+
"error" => "err".to_string(),
99+
other => format!("{}_", other),
100+
}
101+
}
102+
103+
/// Candidate Zig identifiers for a variant name. Returns the snake_case
104+
/// form first; if that's a Zig reserved word, the cartridge-convention
105+
/// workaround is appended as a fallback. The verifier accepts a match
106+
/// against any candidate.
107+
pub fn zig_variant_candidates(idris_name: &str) -> Vec<String> {
108+
let snake = to_snake_case(idris_name);
109+
if is_zig_reserved(&snake) {
110+
vec![snake.clone(), zig_reserved_workaround(&snake)]
111+
} else {
112+
vec![snake]
113+
}
114+
}
115+
71116
#[cfg(test)]
72117
mod tests {
73118
use super::*;
@@ -82,4 +127,38 @@ mod tests {
82127
assert_eq!(to_snake_case("ReadyToDeploy"), "ready_to_deploy");
83128
assert_eq!(to_snake_case("Hugo"), "hugo");
84129
}
130+
131+
#[test]
132+
fn detects_zig_reserved_words() {
133+
assert!(is_zig_reserved("error"));
134+
assert!(is_zig_reserved("test"));
135+
assert!(is_zig_reserved("struct"));
136+
assert!(!is_zig_reserved("foo"));
137+
assert!(!is_zig_reserved("err"));
138+
// `type` is a Zig PRIMITIVE, not a reserved keyword, so the
139+
// identifier is legal — no workaround needed.
140+
assert!(!is_zig_reserved("type"));
141+
}
142+
143+
#[test]
144+
fn candidates_pass_through_non_reserved() {
145+
let c = zig_variant_candidates("Empty");
146+
assert_eq!(c, vec!["empty".to_string()]);
147+
}
148+
149+
#[test]
150+
fn candidates_include_workaround_for_reserved() {
151+
// The real airtable-mcp / postgresql-mcp case: Error → err.
152+
let c = zig_variant_candidates("Error");
153+
assert_eq!(c, vec!["error".to_string(), "err".to_string()]);
154+
}
155+
156+
#[test]
157+
fn candidates_use_generic_suffix_for_other_reserved() {
158+
// No current cartridge uses these; lock in the fallback behaviour
159+
// for a genuinely-reserved keyword (`test` is reserved; `type` is
160+
// a primitive and would not trigger the workaround).
161+
let c = zig_variant_candidates("Test");
162+
assert_eq!(c, vec!["test".to_string(), "test_".to_string()]);
163+
}
85164
}

src/abi/verify.rs

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use std::path::Path;
1616
use anyhow::{Context, Result};
1717
use serde::Serialize;
1818

19-
use crate::abi::manifest_schema::{AbiManifest, to_snake_case};
19+
use crate::abi::manifest_schema::{AbiManifest, to_snake_case, zig_variant_candidates};
2020
use crate::abi::zig_ffi_parser::{ZigEnum, ZigFfi, parse as parse_zig};
2121

2222
#[derive(Debug, Clone, Serialize)]
@@ -95,17 +95,34 @@ pub fn verify(
9595
};
9696
let mut manifest_keys: BTreeSet<String> = BTreeSet::new();
9797
for v in &manifest_enum.variants {
98-
let zig_name = to_snake_case(&v.name);
99-
manifest_keys.insert(zig_name.clone());
100-
match zig_enum.variants.get(&zig_name) {
98+
let candidates = zig_variant_candidates(&v.name);
99+
// Pick the first candidate that appears in the Zig enum;
100+
// if none do, the variant is missing.
101+
let resolved: Option<(String, i64)> = candidates
102+
.iter()
103+
.find_map(|c| zig_enum.variants.get(c).map(|&val| (c.clone(), val)));
104+
// Record every candidate as "claimed by the manifest" so the
105+
// accept-by-omission check downstream doesn't flag a Zig
106+
// variant that the manifest legitimately covers via either
107+
// its primary name or its reserved-word workaround.
108+
for c in &candidates {
109+
manifest_keys.insert(c.clone());
110+
}
111+
match resolved {
101112
None => findings.push(Finding {
102113
kind: "variant-missing-in-zig".into(),
103114
detail: format!(
104-
"enum `{}` variant `{}` (Zig: `{}`) is in the manifest but absent from the Zig FFI",
105-
manifest_enum.name, v.name, zig_name
115+
"enum `{}` variant `{}` (Zig candidates: {}) is in the manifest but absent from the Zig FFI",
116+
manifest_enum.name,
117+
v.name,
118+
candidates
119+
.iter()
120+
.map(|c| format!("`{}`", c))
121+
.collect::<Vec<_>>()
122+
.join(" / ")
106123
),
107124
}),
108-
Some(&actual) if actual != v.value => findings.push(Finding {
125+
Some((zig_name, actual)) if actual != v.value => findings.push(Finding {
109126
kind: "variant-value-mismatch".into(),
110127
detail: format!(
111128
"enum `{}` variant `{}` (Zig: `{}`) — manifest says {}, Zig FFI says {}",
@@ -171,11 +188,21 @@ pub fn verify(
171188
// Build the accepted-pair set from the manifest (only `allowed: true`
172189
// counts; `allowed: false` is the manifest's way of pinning a
173190
// safety invariant — see e.g. `ContentLoaded → Previewing` in
174-
// ssg-mcp).
191+
// ssg-mcp). Variants are resolved through `zig_variant_candidates`
192+
// so a Zig-reserved-word-renamed variant (e.g. Error → err)
193+
// matches its manifest entry.
194+
let resolve = |idris_name: &str| -> String {
195+
for c in zig_variant_candidates(idris_name) {
196+
if zig_pairs.iter().any(|(f, t)| f == &c || t == &c) {
197+
return c;
198+
}
199+
}
200+
to_snake_case(idris_name)
201+
};
175202
let mut manifest_allowed: BTreeSet<(String, String)> = BTreeSet::new();
176203
for row in &tt.rows {
177-
let f = to_snake_case(&row.from);
178-
let t = to_snake_case(&row.to);
204+
let f = resolve(&row.from);
205+
let t = resolve(&row.to);
179206
if row.allowed {
180207
manifest_allowed.insert((f, t));
181208
} else if zig_pairs.contains(&(f.clone(), t.clone())) {
@@ -205,8 +232,8 @@ pub fn verify(
205232
// explicitly list — accept-by-omission is itself drift.
206233
let listed_as_forbidden = tt.rows.iter().any(|r| {
207234
!r.allowed
208-
&& to_snake_case(&r.from) == pair.0
209-
&& to_snake_case(&r.to) == pair.1
235+
&& zig_variant_candidates(&r.from).contains(&pair.0)
236+
&& zig_variant_candidates(&r.to).contains(&pair.1)
210237
});
211238
if !listed_as_forbidden {
212239
findings.push(Finding {

0 commit comments

Comments
 (0)