Skip to content

Commit 3f06d72

Browse files
fix(abi-emit-manifest): skip GADT-style data … where declarations (#20)
`iseriser abi-emit-manifest` bombed with "data declaration has no `=`" on any Idris2 module that declared a GADT — e.g. boj-server's vordr-mcp `MonotonicDegradation` proof relation: data MonotonicDegradation : IntegrityState -> IntegrityState -> Type where StayHealthy : MonotonicDegradation Healthy Healthy ... These are proof / relation types, not exported enums, and have no place in the ABI manifest. The emitter must walk past them without choking the way it must already walk past records, interfaces, and function definitions. `collect_data_body` now detects GADT form (body starts with `:`, not `=`) and delegates to a new `skip_gadt_block` helper that consumes the type signature + indented constructor block. Empty variant lists were already silently dropped by `parse_enum_declarations` so no further plumbing is needed. Two unit tests added: a vordr-mcp shape (real ADT + sibling GADT + to-int mapping) and a degenerate signature-only GADT (`data Phantom : Type` with no `where`). Both pass; all 8 emitter tests + 9 integration tests stay green. Verified end-to-end against the real cartridge: $ iseriser abi-emit-manifest \ --idris …/vordr-mcp/abi/VordrMcp/SafeVordr.idr \ --cartridge vordr-mcp --out /tmp/v.json abi-emit-manifest: wrote /tmp/v.json (0 enums, 0 transitions) $ iseriser abi-verify --manifest /tmp/v.json --zig-ffi …/vordr_ffi.zig abi-verify: OK exit: 0 Refs hyperpolymath/standards#92 (Phase 2 allowlist expansion). Refs hyperpolymath/boj-server#111 (the cartridge that motivated the fix). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 84f9e31 commit 3f06d72

1 file changed

Lines changed: 112 additions & 0 deletions

File tree

src/abi/idris_emitter.rs

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,23 @@ fn find_data_keyword(src: &str) -> Option<usize> {
214214
/// After the enum name, collect the variant list. Handles both
215215
/// `= A | B | C` (one-line) and `\n = A\n | B\n | C` (multi-line).
216216
/// Returns the variant names + the number of bytes consumed from `src`.
217+
///
218+
/// Also handles GADT-style declarations (`data Foo : Type -> Type where …`)
219+
/// by skipping them: they are proof/relation types, not exported enums,
220+
/// and have no place in the ABI manifest. Returns an empty variant list
221+
/// with the byte-count needed to walk past the GADT block.
217222
fn collect_data_body(src: &str) -> Result<(Vec<String>, usize)> {
223+
// GADT detection: the body starts with `:` (signature form), not `=`
224+
// (variant-list form). Examples seen in the wild:
225+
// `data MonotonicDegradation : State -> State -> Type where
226+
// StayHealthy : MonotonicDegradation Healthy Healthy
227+
// …`
228+
// These declare proof relations, not data with variants.
229+
let trimmed = src.trim_start();
230+
if trimmed.starts_with(':') {
231+
return Ok((Vec::new(), skip_gadt_block(src)));
232+
}
233+
218234
// Find the `=` that opens the variant list.
219235
let eq_idx = src
220236
.find('=')
@@ -266,6 +282,51 @@ fn collect_data_body(src: &str) -> Result<(Vec<String>, usize)> {
266282
Ok((variants, consumed))
267283
}
268284

285+
/// Skip past a GADT-style `data Foo : … where …` declaration. The block
286+
/// ends at the first non-indented, non-blank line (or end of input). The
287+
/// declaration itself contributes nothing to the manifest.
288+
fn skip_gadt_block(src: &str) -> usize {
289+
// First: consume the type signature line(s) up to `where` (or end of
290+
// declaration if there is no `where`, e.g. `data Empty : Type`).
291+
let where_pos = src.find("where");
292+
let header_end = match where_pos {
293+
Some(w) => {
294+
// Consume through the rest of that line.
295+
src[w..]
296+
.find('\n')
297+
.map(|i| w + i + 1)
298+
.unwrap_or(src.len())
299+
}
300+
None => {
301+
// No `where` — single-line `data Foo : ...`. Consume through eol.
302+
src.find('\n').map(|i| i + 1).unwrap_or(src.len())
303+
}
304+
};
305+
306+
// No constructor block to walk if we already hit EOF.
307+
if header_end >= src.len() {
308+
return src.len();
309+
}
310+
311+
// Now consume the indented constructor block (if any).
312+
let mut consumed = header_end;
313+
for line in src[header_end..].split_inclusive('\n') {
314+
let trimmed = line.trim_end_matches('\n');
315+
// Blank lines are tolerated inside the block; they don't end it.
316+
if trimmed.is_empty() {
317+
consumed += line.len();
318+
continue;
319+
}
320+
// A line not starting with whitespace ends the GADT block.
321+
let starts_with_ws = trimmed.starts_with(' ') || trimmed.starts_with('\t');
322+
if !starts_with_ws {
323+
break;
324+
}
325+
consumed += line.len();
326+
}
327+
consumed
328+
}
329+
269330
/// Parse all `<fn> <variant> = <int>` equations grouped by function name.
270331
/// Returns `fn_name → (variant_name → integer)`. Patterns of the form
271332
/// `(VariantName _)` are accepted (parameterised constructor); patterns
@@ -540,4 +601,55 @@ fooToInt B = 1
540601
let m = emit_from_idris_src(src, "demo", "Foo.idr").unwrap();
541602
assert_eq!(m.enums[0].variants.len(), 2);
542603
}
604+
605+
#[test]
606+
fn skips_gadt_data_declaration() {
607+
// vordr-mcp shape: a real ADT `IntegrityState` plus a GADT proof
608+
// relation `MonotonicDegradation` over it. The GADT must be
609+
// skipped — it has no `=`-form variant list — but the surrounding
610+
// enum and the to-int mapping must still be picked up.
611+
let src = "
612+
data IntegrityState = Healthy | Drifted | Tampered | Unknown
613+
614+
data MonotonicDegradation : IntegrityState -> IntegrityState -> Type where
615+
StayHealthy : MonotonicDegradation Healthy Healthy
616+
HealthyDrift : MonotonicDegradation Healthy Drifted
617+
HealthyTamp : MonotonicDegradation Healthy Tampered
618+
DriftedStay : MonotonicDegradation Drifted Drifted
619+
DriftedTamp : MonotonicDegradation Drifted Tampered
620+
TamperedStay : MonotonicDegradation Tampered Tampered
621+
622+
stateToInt : IntegrityState -> Int
623+
stateToInt Healthy = 0
624+
stateToInt Drifted = 1
625+
stateToInt Tampered = 2
626+
stateToInt Unknown = 3
627+
";
628+
let m = emit_from_idris_src(src, "vordr-mcp", "SafeVordr.idr").unwrap();
629+
// The GADT must have been skipped, not emitted as an enum.
630+
assert_eq!(m.enums.len(), 1, "expected only IntegrityState in manifest");
631+
let e = &m.enums[0];
632+
assert_eq!(e.name, "IntegrityState");
633+
let names: Vec<&str> = e.variants.iter().map(|v| v.name.as_str()).collect();
634+
assert_eq!(names, vec!["Healthy", "Drifted", "Tampered", "Unknown"]);
635+
assert_eq!(e.variants[3].value, 3);
636+
}
637+
638+
#[test]
639+
fn skips_signature_only_gadt_data_declaration() {
640+
// Degenerate GADT with no `where` block (single-line empty type)
641+
// followed by a real ADT that must still be picked up.
642+
let src = "
643+
data Phantom : Type
644+
645+
data Real = Yes | No
646+
647+
realToInt : Real -> Int
648+
realToInt Yes = 1
649+
realToInt No = 0
650+
";
651+
let m = emit_from_idris_src(src, "demo", "P.idr").unwrap();
652+
assert_eq!(m.enums.len(), 1);
653+
assert_eq!(m.enums[0].name, "Real");
654+
}
543655
}

0 commit comments

Comments
 (0)