Skip to content

Commit c9fc10e

Browse files
authored
test(json): pin README §JSON output schema contract for failure reports (#82) (#86)
1 parent d2f6581 commit c9fc10e

2 files changed

Lines changed: 249 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
1818
cases in one module makes it obvious which builder × boundary
1919
pairs are pinned and prevents the per-builder leakage pattern
2020
that produced #35 and #52. (#83)
21+
- New `test/json_schema_contract_test.gleam` module pins the JSON
22+
failure-report schema declared stable in README §JSON output.
23+
Triggers each public failure shape that emits JSON
24+
(`forall`, `forall_morph`, `forall_round_trip`) and asserts that
25+
every one of the 17 README-listed top-level keys is present, plus
26+
the documented value-type for `config_seed` / `runs_done` /
27+
`runs_total` / `shrinks_done` (Int), `shrink_capped` (Bool),
28+
`annotations` / `footnotes` (Array), and `coverage` (Object or
29+
null). Locks down the contract that downstream `jq` pipelines,
30+
GitHub Actions annotations, and LLM-driven analysis steps depend
31+
on. (#82)
2132

2233
## [0.7.0] - 2026-05-11
2334

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
//// Schema-contract tests for the JSON failure-report format. (#82)
2+
////
3+
//// `README.md` §JSON output declares the schema is stable and lists 17
4+
//// top-level keys: `mr_name`, `test_name`, `config_seed`, `runs_done`,
5+
//// `runs_total`, `shrinks_done`, `shrink_capped`, `source`,
6+
//// `morph_mode`, `relation`, `source_input`, `followup_input`,
7+
//// `source_output`, `followup_output`, `annotations`, `footnotes`,
8+
//// `coverage`. Downstream tooling (`jq` pipelines, GitHub Actions
9+
//// annotations, LLM analysis steps) depends on every one of those
10+
//// keys being present in every failure shape.
11+
////
12+
//// `test/json_output_test.gleam` covers basic emission for `forall` and
13+
//// `forall_morph`, but only spot-checks five of the seventeen keys.
14+
//// This module triggers each public failure shape that emits JSON
15+
//// (`forall`, `forall_morph`, `forall_round_trip`) and asserts that
16+
//// **every** README-listed key appears, plus the documented value-type
17+
//// for the keys whose type is part of the contract (`config_seed`,
18+
//// `runs_done`, `runs_total`, `shrinks_done` → Int; `shrink_capped` →
19+
//// Bool; `annotations`, `footnotes` → Array; `coverage` → Object or
20+
//// null).
21+
////
22+
//// Stateful failures (`metamon/stateful.assert_passed`) panic with
23+
//// their own text-only header and do not flow through the JSON
24+
//// formatter, so they are intentionally out of scope for this contract
25+
//// — the README §JSON output section similarly says nothing about a
26+
//// stateful JSON shape.
27+
28+
import gleam/list
29+
import gleam/string
30+
import gleeunit/should
31+
import metamon
32+
import metamon/config
33+
import metamon/generator
34+
import metamon/generator/range
35+
import metamon/relation
36+
import metamon/transform
37+
38+
// ---------- panic capture ----------
39+
40+
@external(erlang, "metamon_ffi", "capture_panic")
41+
@external(javascript, "./metamon_ffi.mjs", "capture_panic")
42+
fn capture_panic(thunk: fn() -> Nil) -> #(Bool, String)
43+
44+
fn json_from_failure(thunk: fn() -> Nil) -> String {
45+
let #(panicked, message) = capture_panic(thunk)
46+
should.equal(panicked, True)
47+
let trimmed = string.trim(message)
48+
// Sanity: the failure path produced a JSON object, not the text
49+
// header. If this fires, a future runner refactor has stopped
50+
// routing failures through the JSON renderer for this shape.
51+
should.be_true(string.starts_with(trimmed, "{"))
52+
should.be_true(string.ends_with(trimmed, "}"))
53+
trimmed
54+
}
55+
56+
// ---------- failure triggers (one per public JSON-emitting shape) ----------
57+
58+
fn json_for_forall_failure() -> String {
59+
let cfg =
60+
metamon.default_config()
61+
|> metamon.with_output_format(config.Json)
62+
json_from_failure(fn() {
63+
metamon.forall_with(cfg, generator.int(range.constant(0, 10)), fn(_n) {
64+
False
65+
})
66+
})
67+
}
68+
69+
fn json_for_forall_morph_failure() -> String {
70+
let increment = transform.new("+1", fn(n: Int) { n + 1 })
71+
let bad_mr =
72+
metamon.mr(
73+
name: "false_invariant",
74+
transform: increment,
75+
relation: relation.equal(),
76+
)
77+
let cfg =
78+
metamon.default_config()
79+
|> metamon.with_output_format(config.Json)
80+
json_from_failure(fn() {
81+
metamon.forall_morph_with(
82+
cfg,
83+
generator.int(range.constant(1, 10)),
84+
bad_mr,
85+
fn(n) { n },
86+
)
87+
})
88+
}
89+
90+
fn json_for_forall_round_trip_failure() -> String {
91+
let cfg =
92+
metamon.default_config()
93+
|> metamon.with_output_format(config.Json)
94+
json_from_failure(fn() {
95+
// A trivially broken codec: encoder and decoder do not invert.
96+
metamon.forall_round_trip_with(
97+
cfg: cfg,
98+
gen: generator.int(range.constant(1, 10)),
99+
name: "broken_codec",
100+
encode: fn(n: Int) { n + 1 },
101+
decode: fn(n: Int) { Ok(n) },
102+
)
103+
})
104+
}
105+
106+
// ---------- contract assertions ----------
107+
108+
const required_keys: List(String) = [
109+
"mr_name", "test_name", "config_seed", "runs_done", "runs_total",
110+
"shrinks_done", "shrink_capped", "source", "morph_mode", "relation",
111+
"source_input", "followup_input", "source_output", "followup_output",
112+
"annotations", "footnotes", "coverage",
113+
]
114+
115+
fn assert_key_present(json: String, key: String) -> Nil {
116+
// Schema keys appear as `"<key>":` in the output. Substring is
117+
// sufficient to assert presence; a future schema rename would fail
118+
// the test and force a coordinated README + impl + test update.
119+
let needle = "\"" <> key <> "\":"
120+
case string.contains(json, needle) {
121+
True -> Nil
122+
False -> {
123+
// Surface the missing key in the gleeunit failure output so a
124+
// schema drift is immediately diagnosable.
125+
let _ = key
126+
should.fail()
127+
}
128+
}
129+
}
130+
131+
fn assert_all_required_keys_present(json: String) -> Nil {
132+
list.each(required_keys, fn(key) { assert_key_present(json, key) })
133+
}
134+
135+
fn assert_int_typed(json: String, key: String) -> Nil {
136+
// An Int value renders as `"<key>":<digit>` (with a possible
137+
// leading `-`). A non-Int would render as `"key":"..."` (string),
138+
// `"key":[...]` (array), `"key":{...}` (object), `"key":true|false`
139+
// (bool), or `"key":null`. Asserting the next character after the
140+
// colon is `-` or a digit is the cheapest contract for "still Int".
141+
let needle = "\"" <> key <> "\":"
142+
let assert Ok(#(_, after)) = string.split_once(json, on: needle)
143+
let first_char = string.slice(after, at_index: 0, length: 1)
144+
let valid =
145+
first_char == "-"
146+
|| first_char == "0"
147+
|| first_char == "1"
148+
|| first_char == "2"
149+
|| first_char == "3"
150+
|| first_char == "4"
151+
|| first_char == "5"
152+
|| first_char == "6"
153+
|| first_char == "7"
154+
|| first_char == "8"
155+
|| first_char == "9"
156+
should.be_true(valid)
157+
}
158+
159+
fn assert_bool_typed(json: String, key: String) -> Nil {
160+
let needle_true = "\"" <> key <> "\":true"
161+
let needle_false = "\"" <> key <> "\":false"
162+
should.be_true(
163+
string.contains(json, needle_true) || string.contains(json, needle_false),
164+
)
165+
}
166+
167+
fn assert_array_typed(json: String, key: String) -> Nil {
168+
let needle = "\"" <> key <> "\":["
169+
should.be_true(string.contains(json, needle))
170+
}
171+
172+
fn assert_object_or_null_typed(json: String, key: String) -> Nil {
173+
// `coverage` is `Object` when a coverage snapshot exists and `null`
174+
// when none was recorded. Both are part of the documented contract.
175+
let needle_obj = "\"" <> key <> "\":{"
176+
let needle_null = "\"" <> key <> "\":null"
177+
should.be_true(
178+
string.contains(json, needle_obj) || string.contains(json, needle_null),
179+
)
180+
}
181+
182+
fn assert_full_contract(json: String) -> Nil {
183+
assert_all_required_keys_present(json)
184+
assert_int_typed(json, "config_seed")
185+
assert_int_typed(json, "runs_done")
186+
assert_int_typed(json, "runs_total")
187+
assert_int_typed(json, "shrinks_done")
188+
assert_bool_typed(json, "shrink_capped")
189+
assert_array_typed(json, "annotations")
190+
assert_array_typed(json, "footnotes")
191+
assert_object_or_null_typed(json, "coverage")
192+
}
193+
194+
// ---------- schema contract per failure shape ----------
195+
196+
pub fn json_schema_contract_forall_test() {
197+
assert_full_contract(json_for_forall_failure())
198+
}
199+
200+
pub fn json_schema_contract_forall_morph_test() {
201+
assert_full_contract(json_for_forall_morph_failure())
202+
}
203+
204+
pub fn json_schema_contract_forall_round_trip_test() {
205+
assert_full_contract(json_for_forall_round_trip_failure())
206+
}
207+
208+
// ---------- value-shape spot checks ----------
209+
210+
pub fn json_source_is_object_with_kind_test() {
211+
// `source` is an object discriminating between random and edge
212+
// sources via a `kind` field. Pin both the wrapper shape and the
213+
// discriminator presence so a future flat / string encoding fails
214+
// loudly. Each failure shape is exercised so the contract can't
215+
// hold in one shape but drift in another.
216+
let json = json_for_forall_failure()
217+
should.be_true(string.contains(json, "\"source\":{"))
218+
should.be_true(string.contains(json, "\"kind\":"))
219+
}
220+
221+
pub fn json_morph_mode_is_plain_string_for_forall_test() {
222+
// For `forall` (no MR), `morph_mode` renders as the literal string
223+
// "plain", not as an object. Pin the discriminator value so the
224+
// README's "morph_mode" key is unambiguous about the no-MR case.
225+
let json = json_for_forall_failure()
226+
should.be_true(string.contains(json, "\"morph_mode\":\"plain\""))
227+
}
228+
229+
pub fn json_morph_mode_is_object_for_forall_morph_test() {
230+
// For `forall_morph`, `morph_mode` is an object with `kind` and the
231+
// transform name(s). The plain-vs-equivariant discrimination lives
232+
// inside this object, so downstream consumers must be able to
233+
// detect both shapes — the contract is "string OR object", not
234+
// "always string".
235+
let json = json_for_forall_morph_failure()
236+
should.be_true(string.contains(json, "\"morph_mode\":{"))
237+
should.be_true(string.contains(json, "\"kind\":\"plain\""))
238+
}

0 commit comments

Comments
 (0)