diff --git a/docs/language_status/cobolrt_api.json b/docs/language_status/cobolrt_api.json index 12b9faef4..f9d1b63d0 100644 --- a/docs/language_status/cobolrt_api.json +++ b/docs/language_status/cobolrt_api.json @@ -1,7 +1,7 @@ { "schema": "cobolrt-api/1", "about": ["The cobolrt public API (#4718). Written by tests/tools/cobolrt_api.py generate: edit only a `visibility`, to public or internal; docs/cobolrt_api_contract.md says what they promise.", "`evidence` records who called the item when it was last refreshed, and is never checked: det-port, model-port (committed ports), frozen-overlay (a frozen runtime copy's call to a live class), generated (translator output), emitter (a translator string), emitter-dynamic (a name a computed emission may take), runtime, harness; empty: no caller was found."], - "summary": {"types": 128, "types_public": 105, "members": 1201, "members_public": 810, "members_internal": 391}, + "summary": {"types": 128, "types_public": 105, "members": 1202, "members_public": 811, "members_internal": 391}, "types": [ {"id": "batch.CobolAbend", "kind": "class", "source": "gitgalaxy/tools/cobol_to_java/cobol_to_java_batch_forge.py", "variants": ["forge-batch-jobs", "forge-batch-files"], "supers": ["RuntimeException"], "visibility": "public", "evidence": ["det-port", "emitter", "model-port", "signature", "harness"], "members": [ {"id": "batch.CobolAbend.(String,String)", "kind": "constructor", "visibility": "internal", "evidence": []}, @@ -1019,6 +1019,7 @@ {"id": "cobolrt.JsonGenerate.Node", "kind": "class", "source": "gitgalaxy/tools/cobol_to_java/det/cobolrt/JsonGenerate.java", "variants": ["det-batch", "det-standalone", "sort-job"], "supers": [], "visibility": "public", "evidence": ["signature", "harness", "runtime"], "members": [ ]}, {"id": "cobolrt.JsonParse", "kind": "class", "source": "gitgalaxy/tools/cobol_to_java/det/cobolrt/JsonParse.java", "variants": ["det-batch", "det-standalone", "sort-job"], "supers": [], "visibility": "public", "evidence": ["emitter", "emitter-dynamic", "signature", "harness", "runtime"], "members": [ + {"id": "cobolrt.JsonParse.array(String,Node...)", "kind": "method", "static": true, "returns": "cobolrt.JsonParse.Node", "visibility": "public", "evidence": ["emitter-dynamic", "harness"]}, {"id": "cobolrt.JsonParse.group(String,Node...)", "kind": "method", "static": true, "returns": "cobolrt.JsonParse.Node", "visibility": "public", "evidence": ["emitter-dynamic", "harness"]}, {"id": "cobolrt.JsonParse.number(String,Field)", "kind": "method", "static": true, "returns": "cobolrt.JsonParse.Node", "visibility": "public", "evidence": ["harness"]}, {"id": "cobolrt.JsonParse.parse(Field,Node,Charset)", "kind": "method", "static": true, "returns": "void", "visibility": "public", "evidence": ["emitter", "harness"]}, diff --git a/docs/language_status/register/X77.md b/docs/language_status/register/X77.md index f7a0a2795..a4f56db03 100644 --- a/docs/language_status/register/X77.md +++ b/docs/language_status/register/X77.md @@ -4,12 +4,13 @@ family: "X" status: "ASSUMED" title: "JSON PARSE into a group of DISPLAY, COMP, COMP-3 and COMP-5 items is translated from IBM's text alone — ASSUMED, oracle-unbacked (#7524)" area: "COBOL" -summary: "JSON PARSE source [WITH DETAIL] INTO group [NAME OF ... IS] [SUPPRESS item] over DISPLAY alphanumeric text and fixed-point numbers (DISPLAY, COMP, COMP-3 / PACKED-DECIMAL, COMP-5) is generated as a runtime call that reads one JSON object and stores its members by name. GnuCOBOL has no JSON statement, so nothing backs it: no L2+ claim. ON EXCEPTION / NOT ON EXCEPTION are modelled for malformed text, a non-object, a type mismatch, empty text, text after the closing brace and no name matching (the exception condition). JSON-CODE after an exception (100 invalid text, 101 empty, 102 text after the closing brace, 104 incompatible value, 106 no name matched) is set from IBM's Programming Guide table; JSON-STATUS is set to the nonexception bits of a successful parse (a program naming it after an exception stays a runtime Hole); SUPPRESS WHEN / EVERY, CONVERTING, NAME OMITTED, tables, float items, an elementary receiver and any character beyond ASCII stay holes or runtime Holes" +summary: "JSON PARSE source [WITH DETAIL] INTO group [NAME OF ... IS] [SUPPRESS item] over DISPLAY alphanumeric text and fixed-point numbers (DISPLAY, COMP, COMP-3 / PACKED-DECIMAL, COMP-5) is generated as a runtime call that reads one JSON object and stores its members by name. GnuCOBOL has no JSON statement, so nothing backs it: no L2+ claim. ON EXCEPTION / NOT ON EXCEPTION are modelled for malformed text, a non-object, a type mismatch, empty text, text after the closing brace and no name matching (the exception condition). JSON-CODE after an exception (100 invalid text, 101 empty, 102 text after the closing brace, 104 incompatible value, 106 no name matched) is set from IBM's Programming Guide table; JSON-STATUS is set to the nonexception bits of a successful parse (a program naming it after an exception stays a runtime Hole); SUPPRESS WHEN / EVERY, CONVERTING, NAME OMITTED, OCCURS DEPENDING ON tables, float items, an elementary receiver and any character beyond ASCII stay holes or runtime Holes" reached: "no (no burned program uses JSON; fixture tests/cobol_mainframe/test_det_json_parse.py)" --- - **What IBM says** (Enterprise COBOL for z/OS 6.3 Language Reference, JSON PARSE: https://www.ibm.com/docs/SS6SG3_6.3.0/lr/ref/rlpsjsop.html and its sub-pages). The members of the JSON object are matched by name to the subordinate items of the receiving group; a JSON name with no data item is ignored and an item with no JSON member is left unchanged; NAME OF renames an item, SUPPRESS leaves one out; a string goes to an alphanumeric item (left-justified, padded with spaces) and a number to a numeric item (aligned on the decimal point); WITH DETAIL only widens what the exception condition reports. A text that is not valid JSON, or a value that does not fit or has the wrong type, raises the exception condition (JSON-CODE nonzero, ON EXCEPTION). - **Modelled.** `det/stmt.py` `_json_parse` parses the statement, `det/gen.py` `json_parse` builds the receiving tree (shared with JSON GENERATE through `json_tree`) and refuses the shapes below by name, `cobolrt/JsonParse.java` reads and stores. A statement it cannot take keeps the `JSON PARSE (...): not modelled` hole text, with the reason for the shape appended. - **What we assume.** (1) The text is one JSON object whose members map to the group's subordinates; the group's own name is not in the text (unlike the GENERATE wrapper). (2) Names match case-sensitively and exactly. (3) The exception condition (#7524): with ON EXCEPTION / NOT ON EXCEPTION, malformed text, text that is not a JSON object, a value of the wrong type, empty text, text after the closing brace and no name matching raise it (ON EXCEPTION runs, else NOT ON EXCEPTION); without the phrases each is a runtime `Hole`. Malformed text and text after the closing brace store nothing; after a type or size failure the items matched before it keep the values stored (assumed: IBM's text is read as leaving the receiver unspecified, the model stores left to right). A string longer than its item and a number too large for it are nonexception in IBM (JSON-STATUS 256 / 128, stored anyway, in a form the text does not state), so they are a `Hole`; so are a JSON array, `true`, `false`, `null`, a non-object top level (no JSON-CODE stated), and JSON-STATUS after an exception. A number with more decimals than the item, a negative number for an unsigned item, an exponent, a repeated name in one object and any character beyond ASCII are a `Hole` even with the phrases, never a guess. JSON-CODE (S9(9) BINARY) is the exception code of the Programming Guide's Table 109 and 0 on success; JSON-STATUS (#7709): a program that names it and has a JSON PARSE gets a hidden item of IBM's declared shape, `PICTURE S9(9) USAGE BINARY VALUE 0` (a JSON GENERATE alone does not: IBM does not say it sets the register, so the name stays unresolved and the port is refused). After a parse with no exception it holds the runtime's bits 1 (a data item had no pair) and 2 (a pair matched no item) of Table 108, 0 when neither happened. Assumed: the register is reset at each JSON PARSE (IBM: it holds the conditions "at termination of a JSON PARSE statement"; the reset rule is not stated). After an exception its value (the bits seen before it) is a runtime `Hole`, never a guess. A program's own JSON-STATUS item is kept. A program's own JSON-CODE item is kept. The hidden JSON-CODE register is S9(9) BINARY, IBM's declared shape (#7709). (4) WITH DETAIL changes nothing the model reports, since only the exception condition uses it. -- **Packed and binary numbers (#7524, #7709).** A COMP / COMP-3 / COMP-5 numeric receiver takes the number by the same rules as a DISPLAY one (decimal alignment, no extra decimals, no negative for an unsigned item, a value past the PICTURE digits is the exception condition); the store is the runtime's `Cobol.store`, IBM's text states no other byte layout. A table receiver stays refused for PARSE. -- **Status:** ASSUMED, **oracle-unbacked**; a port using it is reported as assumed (IBM-documented). JSON-STATUS bits 4-512 (duplicate names, tables, null, which the model refuses or does not reach), JSON-STATUS after an exception, SUPPRESS WHEN / EVERY, CONVERTING and the wider item kinds are later slices of #7524. -- **Census reach (2026-11-06, #7709).** Re-surveyed by the tool at HEAD 54ad62852 (9 census estates, 39 programs, aggregates only): 12 translate whole, 36 are accounted, and no gap row names JSON PARSE or JSON GENERATE. What this entry and X76 still refuse (PARSE tables and floats, CONVERTING, a GENERATE elementary source, JSON-STATUS after an exception) is needed by no census or burned program; the census does not back this entry, which stays oracle-unbacked. +- **Packed and binary numbers (#7524, #7709).** A COMP / COMP-3 / COMP-5 numeric receiver takes the number by the same rules as a DISPLAY one (decimal alignment, no extra decimals, no negative for an unsigned item, a value past the PICTURE digits is the exception condition); the store is the runtime's `Cobol.store`, IBM's text states no other byte layout. A fixed OCCURS table receiver is taken (next bullet); an OCCURS DEPENDING ON receiver stays refused for PARSE. +- **Fixed tables (#7524).** A fixed `OCCURS` item (elementary or group elements, nested fixed tables too) in the receiving group is a JSON array under the item's name (IBM's table format, the one JSON GENERATE writes): element k of the array goes to occurrence k, by the scalar rules above (a group element is an object matched by name). A value that is not an array is a type mismatch (JSON-CODE 104). Assumed-not-guessed: an array with more or fewer elements than the OCCURS count is a runtime `Hole` (IBM's pages do not state it); a top-level array stays a `Hole`; `null`, `true` and `false` elements stay a `Hole`. Items stored before a failing element keep their values (as above). A table as the receiving item itself is refused. +- **Status:** ASSUMED, **oracle-unbacked**; a port using it is reported as assumed (IBM-documented). JSON-STATUS bits 4-512 (duplicate names, null, which the model refuses or does not reach), JSON-STATUS after an exception, CONVERTING, ODO tables and float items are later slices of #7524 (SUPPRESS WHEN / EVERY is a JSON GENERATE phrase in IBM's syntax, not a PARSE gap). +- **Census reach (2026-11-06, #7709).** Re-surveyed by the tool at HEAD 54ad62852 (9 census estates, 39 programs, aggregates only): 12 translate whole, 36 are accounted, and no gap row names JSON PARSE or JSON GENERATE. What this entry and X76 still refuse (PARSE ODO tables and floats, CONVERTING, a GENERATE elementary source, JSON-STATUS after an exception) is needed by no census or burned program; the census does not back this entry, which stays oracle-unbacked. diff --git a/gitgalaxy/tools/cobol_to_java/det/cobolrt/JsonParse.java b/gitgalaxy/tools/cobol_to_java/det/cobolrt/JsonParse.java index 9ec373233..fb8dd97c7 100644 --- a/gitgalaxy/tools/cobol_to_java/det/cobolrt/JsonParse.java +++ b/gitgalaxy/tools/cobol_to_java/det/cobolrt/JsonParse.java @@ -59,6 +59,11 @@ public static Node number(String name, Field f) { return new Num(name, f); } + /** A fixed OCCURS table: a JSON array under the item's name, one element node per occurrence. */ + public static Node array(String name, Node... elements) { + return new Array(name, List.of(elements)); + } + /** The exception condition of IBM's definition (JSON-CODE nonzero): caught by tryParse, else a Hole. */ private static final class JsonError extends Hole { /** IBM's JSON-CODE for this exception, or -1 when the Programming Guide does not state it for this shape. */ @@ -124,6 +129,9 @@ private static void parse(Field src, Node root, Charset cs, int[] status) { } Object v = r.value(); r.ws(); + if (v instanceof List) { + throw new Hole("JSON PARSE: a top-level JSON array is not modelled; the Programming Guide states no JSON-CODE for it"); + } if (!(v instanceof Members)) { throw new JsonError("JSON PARSE: the text is not a JSON object; the Programming Guide states no JSON-CODE for it", -1); } @@ -189,6 +197,31 @@ void accept(Object value, Ctx c) { } } + private static final class Array extends Node { + final List elements; + + Array(String name, List elements) { + super(name); + this.elements = elements; + } + + @Override + void accept(Object value, Ctx c) { + if (!(value instanceof List)) { + throw new JsonError("JSON PARSE: " + name + " is a table but the JSON value is not an array (JSON-CODE 104)", 104); + } + List a = (List) value; + if (a.size() != elements.size()) { + // IBM's pages do not say what an array of another length does to a fixed OCCURS table + throw new Hole("JSON PARSE: " + a.size() + " array elements for " + name + " of OCCURS " + elements.size() + + "; IBM does not state the result (not modelled)"); + } + for (int k = 0; k < a.size(); k++) { + elements.get(k).accept(a.get(k), c); + } + } + } + private static final class Text extends Node { final Field f; @@ -277,12 +310,39 @@ Object value() { if (c == '-' || (c >= '0' && c <= '9')) { return number(); } - if (c == '[' || s.startsWith("true", i) || s.startsWith("false", i) || s.startsWith("null", i)) { - throw new Hole("JSON PARSE: a JSON array, true, false or null at offset " + i + " is not modelled"); + if (c == '[') { + return array(); + } + if (s.startsWith("true", i) || s.startsWith("false", i) || s.startsWith("null", i)) { + throw new Hole("JSON PARSE: true, false or null at offset " + i + " is not modelled"); } throw bad("a malformed value"); } + List array() { + List a = new ArrayList<>(); + i++; + ws(); + if (i < s.length() && s.charAt(i) == ']') { + i++; + return a; + } + while (true) { + ws(); + a.add(value()); + ws(); + if (i < s.length() && s.charAt(i) == ',') { + i++; + continue; + } + if (i < s.length() && s.charAt(i) == ']') { + i++; + return a; + } + throw bad("a comma or closing bracket was expected"); + } + } + Members object() { Members m = new Members(); i++; diff --git a/gitgalaxy/tools/cobol_to_java/det/gen.py b/gitgalaxy/tools/cobol_to_java/det/gen.py index 3086f8678..3c3d4461e 100644 --- a/gitgalaxy/tools/cobol_to_java/det/gen.py +++ b/gitgalaxy/tools/cobol_to_java/det/gen.py @@ -3274,7 +3274,7 @@ def json_tree(self, d: dict, cls: str, what: str, src_ref: E.Ref) -> str: if src.category != "GROUP" or src_ref.subscripts or src_ref.refmod is not None: raise Untranslatable(f"{what}: {src.name} is not a plain group (an elementary item is not modelled)") seen: set[str] = set() - wide = cls == "JsonGenerate" # #7524: tables are modelled for GENERATE only + wide = cls == "JsonGenerate" # #7524: floats and OCCURS DEPENDING ON tables are modelled for GENERATE only leaves = [0] def elem(it: L.Item, name: str, idx: tuple[int, ...], top: bool = False) -> str | None: @@ -3341,8 +3341,10 @@ def node(it: L.Item, top: bool, idx: tuple[int, ...]) -> str | None: """The Java node of `it`, or None when IBM leaves it out (FILLER, REDEFINES, SUPPRESSed, an empty group).""" if it.name.upper() == "FILLER" or it.redefines: return None - if it.occurs != 1 and (not wide or top): + if it.occurs != 1 and top: raise Untranslatable(f"{what}: {it.name} is a table (OCCURS is not modelled)") + if it.depending and not wide: + raise Untranslatable(f"{what}: {it.name} is OCCURS DEPENDING ON (modelled for JSON GENERATE only)") if it.depending and idx: # IBM's pages do not say how a variable-length table inside another table is generated: refused raise Untranslatable(f"{what}: {it.name} is OCCURS DEPENDING ON inside another table (not modelled)") diff --git a/tests/cobol_mainframe/test_det_json_generate.py b/tests/cobol_mainframe/test_det_json_generate.py index 6761b9781..884240eb6 100644 --- a/tests/cobol_mainframe/test_det_json_generate.py +++ b/tests/cobol_mainframe/test_det_json_generate.py @@ -343,7 +343,7 @@ def test_an_odo_table_inside_another_table_is_refused(tmp_path): def test_parse_still_refuses_an_odo_table(tmp_path): r = _wide(tmp_path, "JSON PARSE OUT INTO REC", data=ODO_DATA) - assert len(r.stats["holes"]) == 1 and "table" in str(r.stats["holes"][0]) + assert len(r.stats["holes"]) == 1 and "OCCURS DEPENDING ON" in str(r.stats["holes"][0]) def test_a_table_as_the_source_itself_is_still_refused(tmp_path): @@ -351,12 +351,11 @@ def test_a_table_as_the_source_itself_is_still_refused(tmp_path): assert len(r.stats["holes"]) == 1 and "table" in str(r.stats["holes"][0]) -def test_parse_does_not_take_what_only_generate_models(tmp_path): - """JSON PARSE takes COMP numbers (#7524, X77) but still refuses tables (a sibling slice).""" +def test_parse_takes_a_fixed_table_but_not_what_only_generate_models(tmp_path): + """JSON PARSE takes COMP numbers and fixed tables (#7524, X77); floats and ODO tables stay GENERATE-only.""" r = _wide(tmp_path, "JSON PARSE OUT INTO REC") - assert ( - len(r.stats["holes"]) == 1 and "JSON PARSE" in str(r.stats["holes"][0]) and "table" in str(r.stats["holes"][0]) - ) + assert r.stats["holes"] == [] or "JSON PARSE" in str(r.stats["holes"][0]) + assert not any("is a table" in str(h) for h in r.stats["holes"]) @pytest.mark.skipif(_jdk() is None, reason="needs a JDK 17 (JAVA_HOME / JDK_17)") diff --git a/tests/cobol_mainframe/test_det_json_parse.py b/tests/cobol_mainframe/test_det_json_parse.py index 702cf5973..627aadc44 100644 --- a/tests/cobol_mainframe/test_det_json_parse.py +++ b/tests/cobol_mainframe/test_det_json_parse.py @@ -109,9 +109,41 @@ def test_comp_comp3_and_comp5_numbers_are_translated_for_parse(tmp_path): assert f'number("{name}"' in line -def test_a_table_receiver_is_still_refused_for_parse(tmp_path): +def test_a_fixed_table_is_an_array_node_with_one_element_per_occurrence(tmp_path): r = _nums(tmp_path, "JSON PARSE JIN INTO TBL") - assert len(r.stats["holes"]) == 1 and "table" in str(r.stats["holes"][0]) + assert r.stats["holes"] == [] and 'array("PK-T"' in r.java + r = _translate_tbl(tmp_path, "JSON PARSE JIN INTO REC") + assert r.stats["holes"] == [] + line = next(x for x in r.java.splitlines() if "JsonParse.parse" in x) + assert line.count('number("", ') == 2 and 'array("T"' in line and line.count('group("", ') == 2 + assert 'array("ROWS"' in line + + +def test_an_occurs_depending_on_table_stays_refused_for_parse(tmp_path): + r = _translate_tbl(tmp_path, "JSON PARSE JIN INTO ODOREC") + assert len(r.stats["holes"]) == 1 and "OCCURS DEPENDING ON" in str(r.stats["holes"][0]) + + +TBLS = """ IDENTIFICATION DIVISION. + PROGRAM-ID. PROG. + DATA DIVISION. + WORKING-STORAGE SECTION. + 01 REC. + 05 T PIC 9(3) OCCURS 2. + 05 ROWS OCCURS 2. + 10 RN PIC X(2). + 01 ODOREC. + 05 CNT PIC 9. + 05 OT PIC 9 OCCURS 1 TO 3 DEPENDING ON CNT. + 01 JIN PIC X(100). + PROCEDURE DIVISION. +""" + + +def _translate_tbl(tmp_path, *body: str): + src = tmp_path / "PROG.cbl" + src.write_text(TBLS + "".join(f" {b}\n" for b in body) + " GOBACK.\n", encoding="utf-8") + return P.translate(src, [], "public class ProgService { }", "t") @pytest.mark.skipif(_jdk() is None, reason="needs a JDK 17 (JAVA_HOME / JDK_17)") @@ -405,3 +437,63 @@ def test_no_hidden_json_status_without_a_json_parse(tmp_path): assert "GG_JSON_STATUS" not in r.java r = _translate(tmp_path, "JSON PARSE JIN INTO REC") assert "GG_JSON_STATUS" not in r.java # not named: no register + + +@pytest.mark.skipif(_jdk() is None, reason="needs a JDK 17 (JAVA_HOME / JDK_17)") +def test_the_runtime_fills_a_fixed_table_from_a_json_array(tmp_path): + from gitgalaxy.tools.cobol_to_java.cobol_to_java_transaction_forge import CICS_SPEC_JAVA, CICS_TASK_JAVA + + src = tmp_path / "src" + files = {"t/cics/CicsTask.java": CICS_TASK_JAVA.replace("__PACKAGE__", "t").replace("__ZONE__", "UTC"), + "t/cics/CicsSpec.java": CICS_SPEC_JAVA.replace("__PACKAGE__", "t"), + **{f"t/{k}": v for k, v in P.runtime_files("t", batch=False).items()}} # fmt: skip + files["Main.java"] = """ +import java.nio.charset.Charset; +import t.cobolrt.Field; +import t.cobolrt.JsonParse; +import t.cobolrt.Storage; +public class Main { + static Charset CS = Charset.forName("ISO-8859-1"); + static void run(String json) { + Storage st = new Storage(300); + for (int i = 0; i < 20; i++) st.bytes[i] = ' '; + byte[] j = json.getBytes(CS); + System.arraycopy(j, 0, st.bytes, 100, j.length); + for (int i = j.length; i < 100; i++) st.bytes[100 + i] = ' '; + try { + Field in = Field.alphanumeric(st, 100, 100, false); + JsonParse.Node g = JsonParse.group("G", + JsonParse.array("A", JsonParse.text("", Field.alphanumeric(st, 0, 2, false)), + JsonParse.text("", Field.alphanumeric(st, 2, 2, false)))); + boolean exc = JsonParse.tryParse(in, g, CS); + System.out.println((exc ? "EXC " : "OK ") + new String(st.bytes, 0, 4, CS)); + } catch (RuntimeException e) { + System.out.println("HOLE " + e.getMessage()); + } + } + public static void main(String[] x) { + run("{\\"A\\":[\\"ab\\",\\"c\\"]}"); + run("{\\"A\\":[\\"ab\\"]}"); + run("{\\"A\\":[\\"a\\",\\"b\\",\\"c\\"]}"); + run("{\\"A\\":\\"ab\\"}"); + run("{\\"A\\":[\\"a\\",7]}"); + run("{\\"A\\":[\\"a\\",}"); + run("[\\"a\\"]"); + } +} +""" + for rel, text in files.items(): + (src / rel).parent.mkdir(parents=True, exist_ok=True) + (src / rel).write_text(text, encoding="utf-8") + jdk = _jdk() + built = javac_run([str(jdk / "javac"), "-encoding", "UTF-8", "-nowarn", "-d", str(tmp_path / "classes"), + *map(str, src.rglob("*.java"))], capture_output=True, text=True, encoding="utf-8", check=False) # fmt: skip + assert built.returncode == 0, built.stderr + out = subprocess.run([str(jdk / "java"), "-Dfile.encoding=UTF-8", "-Dstdout.encoding=UTF-8", "-cp", str(tmp_path / "classes"), "Main"], # noqa: S603 + capture_output=True, text=True, encoding="utf-8", check=True).stdout.splitlines() # fmt: skip + assert out[0] == "OK abc " + assert out[1].startswith("HOLE") and "OCCURS 2" in out[1] and out[2].startswith("HOLE") + assert out[3] == "EXC " + " " * 4 # a string for a table: JSON-CODE 104, nothing stored + assert out[4] == "EXC a " # the element before the number was stored (X77: left to right) + assert out[5] == "EXC " + " " * 4 # malformed text stores nothing + assert out[6].startswith("HOLE") and "top-level JSON array" in out[6]