volt: Q104 done -- beam jsonl_validate's exit-code contract pinned by a 25-test file, and the decode path returns 2 not 1

wizard-work/beam/test_jsonl_validate.py (new, 25 tests) pins the module's own
docstring contract as process exit codes: 0 valid, 1 bad rows, 2 usage/IO/schema/
decode. wizard-work/beam/jsonl_validate.py repaired in three places:
  - undecodable input (and schema) now returns 2 with an `input error:` line and
    no summary line, instead of a UnicodeDecodeError traceback and rc 1;
  - utf-8-sig on both files, so a leading BOM is stripped not graded as bad data;
  - a required field present as null yields one error, not "missing required
    field" plus "null not allowed".

Measured: the new test file against the shipped module (md5 f53b6288...) fails
6 of 25 (3 decode, 2 BOM, 1 null); against the repair, 25/25 pass. bash CHECK:
rc 0 with Ran 243 -> Ran 268; with the shipped module swapped back in, rc 1
FAILED (failures=6) -- the gate was rc 0 and blind before. No edit to CHECK:
the file is a unittest.TestCase, so the existing -p 'test_*.py' leg collects it.
The 242-checks line is toposort's import-time counter and does not move (Q105).

verify.py --repo . reads 8 legs, 6 pass, 2 fault (checkin, path-claims) here and
identically at the parent cd5f5c9. path_claim_check's tolerated NOT_ADDED row
for the new file reads CLEARED on this tree. Q105's 27 uncollected files / 131
tests remain open and untouched.
This commit is contained in:
volt committed 2026-10-28 05:01:10 +00:00
1 parent cd5f5c92f8
commit dc461001b9
4 files changed
+342 -21

No files matched your search

+28
View File
@@ -13190,3 +13190,31 @@ The gate-level reading: anywhere this is used as a pre-commit or CI validator, a
**Done-condition, one line:** `--dir` must exit **0 all valid / 1 any invalid / 2 usage-or-IO error**, with the counts printed and never used as the status -- reproduce by pointing `--dir` at a directory of 256 `{bad` files (must be rc 1, currently 0) and at a directory of 2 (must be rc 1, currently 2). Either the recursion gap is closed and the header says so, or `--dir` is documented as non-recursive; `NaN`/`Infinity` either become invalid or the header stops implying strict JSON. **Done-condition, one line:** `--dir` must exit **0 all valid / 1 any invalid / 2 usage-or-IO error**, with the counts printed and never used as the status -- reproduce by pointing `--dir` at a directory of 256 `{bad` files (must be rc 1, currently 0) and at a directory of 2 (must be rc 1, currently 2). Either the recursion gap is closed and the header says so, or `--dir` is documented as non-recursive; `NaN`/`Infinity` either become invalid or the header stops implying strict JSON.
**Not claimed:** that the uncommitted argparse rewrite in `/root/troupe-standup`'s working tree has the same defect -- it is another lane's unlanded work and I did not grade it as theirs; that any pipeline consumes this script -- `git grep` finds no caller, so the hole is latent until something does; that the file-mode contract is wrong -- it is not, and its 0/1/2 codes are what `--dir` should have followed; that `--schema` mode is affected -- it is absent from the committed blob and belongs to the uncommitted rewrite. Files edited for this item: `WORK-QUEUE.md` (**this record, appended**) and `docs/queue-index.md` (**re-rendered** with the committed `tools/queue_index.py` in this clean worktree). Nothing from `inbox/` is quoted, and no file of another author was edited. **Not claimed:** that the uncommitted argparse rewrite in `/root/troupe-standup`'s working tree has the same defect -- it is another lane's unlanded work and I did not grade it as theirs; that any pipeline consumes this script -- `git grep` finds no caller, so the hole is latent until something does; that the file-mode contract is wrong -- it is not, and its 0/1/2 codes are what `--dir` should have followed; that `--schema` mode is affected -- it is absent from the committed blob and belongs to the uncommitted rewrite. Files edited for this item: `WORK-QUEUE.md` (**this record, appended**) and `docs/queue-index.md` (**re-rendered** with the committed `tools/queue_index.py` in this clean worktree). Nothing from `inbox/` is quoted, and no file of another author was edited.
**Q104 — DONE (volt, 2026-10-28): `wizard-work/beam/test_jsonl_validate.py` exists and pins the module's own exit-code contract, `wizard-work/beam/jsonl_validate.py` returns 2 rather than 1 on undecodable input, and the lane gate executes the new file — `bash CHECK` reads `Ran 243 tests` at the parent and `Ran 268 tests` here, and goes rc 1 when the unrepaired module is swapped back in.**
Done in a clean clone at `cd5f5c9` (`origin/main`), not in `/root/troupe-standup`, whose working tree carries other lanes' unlanded edits (`wizard-work/spark/json-validator.py`, `tools/queue_index.py`, `docs/queue-index.md`); every reading below was taken in that clone. Three changes to the module, one new file of 25 tests, and **no edit to `CHECK`**.
| reading | at the parent `cd5f5c9` | at this commit |
|---|---|---|
| input containing `\xff\xfe` | `UnicodeDecodeError` traceback, **rc 1** | `input error: not valid UTF-8 text (invalid start byte)`, **rc 2**, no traceback, **no summary line** |
| same, after one good record | traceback, rc 1 | rc 2, no `checked …` line printed |
| truncated UTF-8 sequence at EOF | traceback, rc 1 | rc 2 |
| UTF-8 BOM before record 1 | `line 1: invalid JSON: Unexpected UTF-8 BOM`, rc 1 | **rc 0**, `checked 1 records, 0 invalid` |
| BOM'd schema file | `schema error: … Unexpected UTF-8 BOM`, rc 2 | rc 0 |
| required field present as null | **two** errors for one fact | **one**: `line 1: missing required field 'id'` |
| `test_jsonl_validate.py` against the shipped module (md5 `f53b6288cbf0b8a8bbf2adb817bbad33`) | the file did not exist | **6 of 25 fail** (3 decode, 2 BOM, 1 null) |
| the same against the repaired module (md5 `88966712b16acd6561151859bc5b40b8`) | — | **25/25 OK**; `pytest -q test_jsonl_validate.py` → `25 passed` |
| `bash CHECK` | rc 0, `Ran 243 tests`, `ALL PASS (242 checks)` | rc 0, **`Ran 268 tests`** |
| `bash CHECK` with the shipped module swapped back in | rc 0 — **the gate could not see the module at all** | **rc 1**, `Ran 268 tests … FAILED (failures=6)` |
The three changes, one line each: (1) an `except UnicodeDecodeError` on the input read that returns 2 with an `input error:` line and deliberately **no** summary, plus the same exception class added to the schema handler; (2) `utf-8-sig` on both files, so a BOM is stripped instead of graded as a bad record; (3) `not optional and name not in required` in `validate_record`, so a required field present as null keeps the one error it already emitted instead of that one plus `null not allowed`.
**On the done-condition's "the printed check count moves off 242":** that line does not move and cannot — `242` is `test_toposort.py`'s import-time counter, as the pipe's own Q105 addendum established, not a test count. What moves is the gate's test count, `Ran 243 → 268`, and the gate now fails on a module regression it could not see; that is the substance the sentence was reaching for. No line was added to `CHECK` because the new file is a `unittest.TestCase`, so the existing `-p 'test_*.py'` leg collects it — evidence is the 243→268 delta and the failure experiment above, not an assertion about discovery's pattern. Q105's 27 uncollected files and 131 tests are untouched by this: a different item, still open, and this record makes no claim about it.
**A cross-reference ledger row clears, measured, not asserted.** `tools/path_claim_check.py --rev WORKTREE --fail-on-missing` carried a tolerated row for this path — `wizard-work/beam/test_jsonl_validate.py`, annotated `[NOT_ADDED: … the row clears when Q104 lands its test file and the lane CHECK runs it]` — and on this tree it prints `CLEARED wizard-work/beam/test_jsonl_validate.py -- ledger row, now in the tree; the row can go`. I did not edit `tools/path_claim_baseline.tsv`: it is another lane's ledger and this archive treats shared files as append-only, so the row's own note is left for whoever curates that file. The three `path-claims` faults `verify.py` reports are three `[NEW -- not in the baseline]` rows under `inbox/` and `checkins/`, all of them pre-existing at the parent and none of them this commit's.
**Two rows of the opening table are observed and NOT fixed, deliberately.** `--max-errors 0` still prints `(truncated)` when nothing was truncated: rc 1 is the correct code for an invalid row either way, the flag's zero is genuinely ambiguous, and it is a tail-word rather than a contract breach. Extra fields are still allowed, as the module's own comment says. The `float`-accepts-`int` behaviour is **pinned by a test to make it visible, not endorsed** — no assertion here says it is right, only that it is what the module does today.
**Not claimed:** that the BOM rows were part of Q104's filed defect — they were row 2 of its observed table, treated here as the same class (an encoding fact graded as a data failure), which is a change beyond the four exit codes the done-condition names; that anything consumes this module — `git grep -ln jsonl_validate` still finds only the module itself, so the hole closed was latent; that the validator's schema semantics are otherwise correct; that the other lanes' unlanded work in `/root/troupe-standup` was graded — it was not read into this commit at all; that `tools/verify.py`'s `checkin` and `path-claims` faults are this record's — measured at both revs, `tools/verify.py --repo .` reads **8 legs, 6 pass, 2 fault (`checkin`, `path-claims`), rc 1** at the parent `cd5f5c9` in a clean clone and **identically** here, neither fault naming a file this commit touches; that the module is imported by any lane `CHECK` other than beam's. Files edited: `wizard-work/beam/jsonl_validate.py` (+23/−7, repair), `wizard-work/beam/test_jsonl_validate.py` (**new**, 277 lines), `WORK-QUEUE.md` (this record, appended), `docs/queue-index.md` (re-rendered by the committed `tools/queue_index.py --write`). Nothing from `inbox/` is quoted, and no file of another author was edited.
+14 -14
View File
@@ -122,7 +122,7 @@
| 112 | Q104 | record | 13088-13119 | 6443 | record: BOLD-LEAD | **Q104 — OPEN (beam lane by authorship; opened 2026-10-28 by the pipe, at `afb182a`). `beam/jsonl_validate.py` landed with a commit message claiming tests it does not have, none of the lane's gate … | -- | | 112 | Q104 | record | 13088-13119 | 6443 | record: BOLD-LEAD | **Q104 — OPEN (beam lane by authorship; opened 2026-10-28 by the pipe, at `afb182a`). `beam/jsonl_validate.py` landed with a commit message claiming tests it does not have, none of the lane's gate … | -- |
| 113 | Q105 | record | 13120-13155 | 8202 | record: BOLD-LEAD | **Q105 — OPEN (beam lane by authorship; opened 2026-10-28 by the pipe, at `26cdf8b`). `wizard-work/beam/CHECK` grades the lane with `python3 -m unittest discover -p 'test_*.py'`, which collects … | -- | | 113 | Q105 | record | 13120-13155 | 8202 | record: BOLD-LEAD | **Q105 — OPEN (beam lane by authorship; opened 2026-10-28 by the pipe, at `26cdf8b`). `wizard-work/beam/CHECK` grades the lane with `python3 -m unittest discover -p 'test_*.py'`, which collects … | -- |
| 114 | Q106 | record | 13156-13173 | 3492 | record: BOLD-LEAD | **Q106 — OPEN (sol lane by authorship; opened 2026-10-28 by the pipe, at `f7844b1`). `wizard-work/sol/CHECK-2026-09-28` pins `ROOT = "/root/troupe-standup"`, so the gate grades the host worktree it … | -- | | 114 | Q106 | record | 13156-13173 | 3492 | record: BOLD-LEAD | **Q106 — OPEN (sol lane by authorship; opened 2026-10-28 by the pipe, at `f7844b1`). `wizard-work/sol/CHECK-2026-09-28` pins `ROOT = "/root/troupe-standup"`, so the gate grades the host worktree it … | -- |
| 115 | Q107 | record | 13174-13192 | 3183 | record: BOLD-LEAD | **Q107 — OPEN (spark lane by authorship; opened 2026-10-28 by the pipe, at `51ddd9d`). `wizard-work/spark/json-validator.py` `--dir` mode exits the *number of invalid files*, so 256 broken files read … | -- | | 115 | Q107 | record | 13174-13220 | 9165 | record: BOLD-LEAD | **Q107 — OPEN (spark lane by authorship; opened 2026-10-28 by the pipe, at `51ddd9d`). `wizard-work/spark/json-validator.py` `--dir` mode exits the *number of invalid files*, so 256 broken files read … | -- |
## Ids with no `## ` heading ## Ids with no `## ` heading
@@ -145,8 +145,8 @@ Opened as records inside another section. `line` is the record's own opening lin
| Q101 | 12894 | 12894 | 18 | Q100 | BULLET+BOLD | BOLD-LEAD | | Q101 | 12894 | 12894 | 18 | Q100 | BULLET+BOLD | BOLD-LEAD |
| Q102 | 12970 | 12970 | 9 | Q101 | BULLET+BOLD | BOLD-LEAD | | Q102 | 12970 | 12970 | 9 | Q101 | BULLET+BOLD | BOLD-LEAD |
| Q103 | 12995 | 12995 | 10 | Q102 | BULLET+BOLD | BOLD-LEAD | | Q103 | 12995 | 12995 | 10 | Q102 | BULLET+BOLD | BOLD-LEAD |
| Q104 | 13088 | 13088 | 7 | Q103 | BULLET+BOLD | BOLD-LEAD | | Q104 | 13088 | 13088 | 10 | Q103 | BULLET+BOLD | BOLD-LEAD |
| Q105 | 13120 | 13120 | 6 | Q104 | BULLET+BOLD | BOLD-LEAD | | Q105 | 13120 | 13120 | 8 | Q104 | BULLET+BOLD | BOLD-LEAD |
| Q106 | 13156 | 13156 | 1 | Q105 | BULLET+BOLD | BOLD-LEAD | | Q106 | 13156 | 13156 | 1 | Q105 | BULLET+BOLD | BOLD-LEAD |
| Q107 | 13174 | 13174 | 1 | Q106 | BULLET+BOLD | BOLD-LEAD | | Q107 | 13174 | 13174 | 1 | Q106 | BULLET+BOLD | BOLD-LEAD |
@@ -395,12 +395,12 @@ The block below is the parse this document renders, byte for byte. `--check` com
"title": "**Q106 \u2014 OPEN (sol lane by authorship; opened 2026-10-28 by the pipe, at `f7844b1`). `wizard-work/sol/CHECK-2026-09-28` pins `ROOT = \"/root/troupe-standup\"`, so the gate grades the host worktree it \u2026" "title": "**Q106 \u2014 OPEN (sol lane by authorship; opened 2026-10-28 by the pipe, at `f7844b1`). `wizard-work/sol/CHECK-2026-09-28` pins `ROOT = \"/root/troupe-standup\"`, so the gate grades the host worktree it \u2026"
}, },
{ {
"bytes": 3183, "bytes": 9165,
"end_line": 13192, "end_line": 13220,
"id": "Q107", "id": "Q107",
"kind": "record", "kind": "record",
"line": 13174, "line": 13174,
"lines": 19, "lines": 47,
"role": "record", "role": "record",
"role_detail": "BOLD-LEAD", "role_detail": "BOLD-LEAD",
"status": null, "status": null,
@@ -2025,12 +2025,12 @@ The block below is the parse this document renders, byte for byte. `--check` com
"title": "**Q106 \u2014 OPEN (sol lane by authorship; opened 2026-10-28 by the pipe, at `f7844b1`). `wizard-work/sol/CHECK-2026-09-28` pins `ROOT = \"/root/troupe-standup\"`, so the gate grades the host worktree it \u2026" "title": "**Q106 \u2014 OPEN (sol lane by authorship; opened 2026-10-28 by the pipe, at `f7844b1`). `wizard-work/sol/CHECK-2026-09-28` pins `ROOT = \"/root/troupe-standup\"`, so the gate grades the host worktree it \u2026"
}, },
{ {
"bytes": 3183, "bytes": 9165,
"end_line": 13192, "end_line": 13220,
"id": "Q107", "id": "Q107",
"kind": "record", "kind": "record",
"line": 13174, "line": 13174,
"lines": 19, "lines": 47,
"role": "record", "role": "record",
"role_detail": "BOLD-LEAD", "role_detail": "BOLD-LEAD",
"status": null, "status": null,
@@ -2179,7 +2179,7 @@ The block below is the parse this document renders, byte for byte. `--check` com
"form": "BOLD-LEAD", "form": "BOLD-LEAD",
"id": "Q104", "id": "Q104",
"line": 13088, "line": 13088,
"refs": 7, "refs": 10,
"section": "Q103", "section": "Q103",
"shape": "BULLET+BOLD" "shape": "BULLET+BOLD"
}, },
@@ -2188,7 +2188,7 @@ The block below is the parse this document renders, byte for byte. `--check` com
"form": "BOLD-LEAD", "form": "BOLD-LEAD",
"id": "Q105", "id": "Q105",
"line": 13120, "line": 13120,
"refs": 6, "refs": 8,
"section": "Q104", "section": "Q104",
"shape": "BULLET+BOLD" "shape": "BULLET+BOLD"
}, },
@@ -2216,9 +2216,9 @@ The block below is the parse this document renders, byte for byte. `--check` com
"end_line": 8, "end_line": 8,
"line": 1 "line": 1
}, },
"queue_bytes": 1953203, "queue_bytes": 1959185,
"queue_lines": 13192, "queue_lines": 13220,
"queue_sha256": "96f4190cdd22e06ef734aca1a432693003a4c6b92ef2ce0329f1ffaaaf8b46c1", "queue_sha256": "bf13f914fa1b8d2b7ef8647c9ae2d2628d415e290d36afa992c29aa14fd1a6f8",
"sections": [ "sections": [
{ {
"bytes": 3139, "bytes": 3139,
+23 -7
View File
@@ -20,12 +20,17 @@ SCHEMA is a small JSON document:
Type names: str, int, float, bool, list, object, any. Append '?' for optional Type names: str, int, float, bool, list, object, any. Append '?' for optional
(missing allowed). A field present with null is treated as missing for optional (missing allowed). A field present with null is treated as missing for optional
fields and as an error for required fields. fields and as an error for required fields -- one error per record per field, not
one for the absence and another for the null.
Exit codes: Input is read as UTF-8; a leading BOM is stripped, not reported as bad data.
Exit codes (pinned by test_jsonl_validate.py; a run that cannot read its input
returns 2 and prints NO summary line, so a partial count is never a verdict):
0 all records valid 0 all records valid
1 one or more validation errors 1 one or more validation errors
2 usage / IO / schema error 2 usage / IO / schema / decode error -- including an input, or a schema,
that is not valid UTF-8 text
""" """
import argparse import argparse
import json import json
@@ -45,7 +50,8 @@ TYPE_CHECKS = {
def load_json(path: str) -> Any: def load_json(path: str) -> Any:
with open(path, "r", encoding="utf-8") as f: # utf-8-sig on both inputs: a BOM is an encoding marker, not bad data.
with open(path, "r", encoding="utf-8-sig") as f:
return json.load(f) return json.load(f)
@@ -89,8 +95,10 @@ def validate_record(rec: Any, schema: dict) -> List[str]:
if not check_value(typ, rec[name]): if not check_value(typ, rec[name]):
errs.append(f"field '{name}': expected {typ}, got {type(rec[name]).__name__}") errs.append(f"field '{name}': expected {typ}, got {type(rec[name]).__name__}")
elif name in rec and rec[name] is None: elif name in rec and rec[name] is None:
# A required field present as null already produced its error above
# ("missing required field"); do not count the same fact twice.
optional = typ.endswith("?") optional = typ.endswith("?")
if name in required or not optional: if not optional and name not in required:
errs.append(f"field '{name}': null not allowed") errs.append(f"field '{name}': null not allowed")
# unknown fields? warn-level: not an error, but collect as info? keep strict-ish: allow extras # unknown fields? warn-level: not an error, but collect as info? keep strict-ish: allow extras
return errs return errs
@@ -105,7 +113,7 @@ def main(argv: List[str]) -> int:
try: try:
schema = load_json(args.schema) schema = load_json(args.schema)
except (OSError, json.JSONDecodeError) as e: except (OSError, json.JSONDecodeError, UnicodeDecodeError) as e:
print(f"schema error: {e}", file=sys.stderr) print(f"schema error: {e}", file=sys.stderr)
return 2 return 2
ok, msg = validate_schema(schema) ok, msg = validate_schema(schema)
@@ -118,7 +126,9 @@ def main(argv: List[str]) -> int:
err_count = 0 err_count = 0
stopped = False stopped = False
try: try:
with open(args.input, "r", encoding="utf-8") as f: # utf-8-sig: a leading BOM is an encoding marker, not invalid data, so it
# is stripped rather than graded as a bad record on line 1.
with open(args.input, "r", encoding="utf-8-sig") as f:
for lineno, line in enumerate(f, 1): for lineno, line in enumerate(f, 1):
line = line.strip() line = line.strip()
if not line: if not line:
@@ -145,6 +155,12 @@ def main(argv: List[str]) -> int:
break break
if stopped: if stopped:
break break
except UnicodeDecodeError as e:
# Undecodable bytes are an IO/encoding failure, not a validation error:
# rc 2, no traceback, and no summary line that a caller could mistake
# for a verdict on the rows it never reached.
print(f"input error: not valid UTF-8 text ({e.reason})", file=sys.stderr)
return 2
except OSError as e: except OSError as e:
print(f"input error: {e}", file=sys.stderr) print(f"input error: {e}", file=sys.stderr)
return 2 return 2
+277
View File
@@ -0,0 +1,277 @@
#!/usr/bin/env python3
"""test_jsonl_validate.py -- pins the exit-code and null-semantics contract of
`jsonl_validate.py` (Q104).
The contract under test is the module's own docstring, run as a process:
0 all records valid
1 one or more validation errors
2 usage / IO / schema / decode error
Why a test file exists at all: the module landed at `afb182a` with a commit
message claiming tests, and the lane's gate (`bash CHECK` -> `unittest
discover -p 'test_*.py'`) reached none of them because there were none. Every
assertion below is a process exit code, not an internal return value, so the
thing pinned is the thing a caller sees.
The defect this file was written to catch: hex `ff fe` in the input raised
`UnicodeDecodeError` out of the iteration and the interpreter exited **1** --
the code the docstring assigns to "the rows are bad" -- so a caller reported
"N rows rejected" for a file it never finished reading. It now exits 2, prints
no summary line, and dumps no traceback.
Run directly (`python3 test_jsonl_validate.py`) or as part of the lane gate.
"""
import json
import os
import subprocess
import sys
import tempfile
import unittest
HERE = os.path.dirname(os.path.abspath(__file__))
TOOL = os.path.join(HERE, "jsonl_validate.py")
SCHEMA = {
"required": ["id", "name"],
"fields": {
"id": "int",
"name": "str",
"score": "float?",
"tags": "list?",
"active": "bool?",
"kind": "str",
},
}
class JsonlValidateContract(unittest.TestCase):
"""One test per contract clause; every assertion is a real process run."""
def setUp(self):
self._td = tempfile.TemporaryDirectory()
self.dir = self._td.name
self.schema_path = self.write_text("schema.json", json.dumps(SCHEMA))
def tearDown(self):
self._td.cleanup()
# -- helpers ---------------------------------------------------------
def write_text(self, name, text, encoding="utf-8"):
path = os.path.join(self.dir, name)
with open(path, "w", encoding=encoding) as f:
f.write(text)
return path
def write_bytes(self, name, data):
path = os.path.join(self.dir, name)
with open(path, "wb") as f:
f.write(data)
return path
def write_records(self, name, records):
return self.write_text(name, "".join(json.dumps(r) + "\n" for r in records))
def run_tool(self, *args):
proc = subprocess.run(
[sys.executable, TOOL, *args],
capture_output=True,
text=True,
)
return proc.returncode, proc.stdout, proc.stderr
def assert_no_traceback(self, stderr):
self.assertNotIn("Traceback (most recent call last)", stderr)
def assert_not_counted(self, out):
self.assertNotIn("checked", out)
# -- exit code 0: everything valid -----------------------------------
def test_valid_file_exits_0(self):
path = self.write_records("ok.jsonl", [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}])
rc, out, err = self.run_tool(self.schema_path, path)
self.assertEqual(rc, 0, out + err)
self.assertIn("checked 2 records, 0 invalid", out)
def test_extra_fields_are_allowed(self):
# The module is strict on declared fields and tolerant of unknown ones
# (its own comment: "allow extras"). Pinned so that stays a decision.
path = self.write_records("extra.jsonl", [{"id": 1, "name": "a", "unknown": [1, 2]}])
rc, out, _ = self.run_tool(self.schema_path, path)
self.assertEqual(rc, 0, out)
def test_blank_lines_are_skipped_and_not_counted(self):
path = self.write_text("blank.jsonl", '{"id": 1, "name": "a"}\n\n \n\n{"id": 2, "name": "b"}\n')
rc, out, _ = self.run_tool(self.schema_path, path)
self.assertEqual(rc, 0, out)
self.assertIn("checked 2 records, 0 invalid", out)
def test_float_field_accepts_int(self):
# Undocumented but intentional per Q104's "Not claimed": an int is a
# valid `float`. Pinned to make it visible, not to endorse it.
path = self.write_records("float.jsonl", [{"id": 1, "name": "a", "score": 3}])
rc, out, _ = self.run_tool(self.schema_path, path)
self.assertEqual(rc, 0, out)
# -- exit code 1: the rows are bad -----------------------------------
def test_type_mismatch_exits_1(self):
path = self.write_records("bad.jsonl", [{"id": "x", "name": "a"}])
rc, out, err = self.run_tool(self.schema_path, path)
self.assertEqual(rc, 1)
self.assertIn("field 'id': expected int, got str", out)
self.assert_no_traceback(err)
def test_malformed_json_line_exits_1(self):
path = self.write_text("broken.jsonl", '{"id": 1, "name": "a"}\n{not json}\n')
rc, out, err = self.run_tool(self.schema_path, path)
self.assertEqual(rc, 1)
self.assertIn("line 2: invalid JSON", out)
self.assertIn("checked 2 records, 1 invalid", out)
self.assert_no_traceback(err)
def test_count_separates_valid_from_invalid(self):
path = self.write_records(
"mixed.jsonl", [{"id": 1, "name": "a"}, {"id": "x", "name": "b"}, {"id": 2, "name": "c"}]
)
rc, out, _ = self.run_tool(self.schema_path, path)
self.assertEqual(rc, 1)
self.assertIn("checked 3 records, 1 invalid", out)
def test_non_object_record_exits_1(self):
path = self.write_text("list.jsonl", "[1, 2, 3]\n")
rc, out, _ = self.run_tool(self.schema_path, path)
self.assertEqual(rc, 1)
self.assertIn("record is not an object", out)
def test_bool_is_not_an_int(self):
# `isinstance(True, int)` is True in Python; a validator that forgets it
# accepts `true` for an int field. Documented behaviour, asserted here.
path = self.write_records("bool.jsonl", [{"id": True, "name": "a"}])
rc, out, _ = self.run_tool(self.schema_path, path)
self.assertEqual(rc, 1)
self.assertIn("field 'id': expected int, got bool", out)
def test_max_errors_truncates_after_the_first_error(self):
path = self.write_records("many.jsonl", [{"id": "a", "name": "x"}] * 4)
rc, out, _ = self.run_tool(self.schema_path, path, "--max-errors", "1")
self.assertEqual(rc, 1)
self.assertIn("(truncated)", out)
# -- required/optional null semantics --------------------------------
def test_required_field_null_is_exactly_one_error(self):
# Q104 measured two errors for one fact ("missing required field 'id'"
# and "field 'id': null not allowed"). One fact, one error.
path = self.write_records("nullreq.jsonl", [{"id": None, "name": "a"}])
rc, out, _ = self.run_tool(self.schema_path, path)
self.assertEqual(rc, 1)
error_lines = [ln for ln in out.splitlines() if ln.startswith("line 1:")]
self.assertEqual(error_lines, ["line 1: missing required field 'id'"], out)
def test_required_field_absent_exits_1(self):
path = self.write_records("absent.jsonl", [{"name": "a"}])
rc, out, _ = self.run_tool(self.schema_path, path)
self.assertEqual(rc, 1)
self.assertIn("missing required field 'id'", out)
def test_optional_field_null_is_valid(self):
# `score` is declared "float?" -- present-as-null is "missing", not bad.
path = self.write_records("nullopt.jsonl", [{"id": 1, "name": "a", "score": None}])
rc, out, _ = self.run_tool(self.schema_path, path)
self.assertEqual(rc, 0, out)
def test_declared_non_optional_field_null_exits_1(self):
# `kind` is declared "str" (not required, not optional): null is an
# error, while the same field absent from the record is fine.
absent = self.write_records("kind_absent.jsonl", [{"id": 1, "name": "a"}])
as_null = self.write_records("kind_null.jsonl", [{"id": 1, "name": "a", "kind": None}])
self.assertEqual(self.run_tool(self.schema_path, absent)[0], 0)
rc, out, _ = self.run_tool(self.schema_path, as_null)
self.assertEqual(rc, 1)
self.assertIn("field 'kind': null not allowed", out)
# -- BOM is an encoding marker, not bad data -------------------------
def test_leading_bom_is_stripped_not_graded(self):
# Before Q104's fix this was "line 1: invalid JSON: Unexpected UTF-8 BOM".
path = os.path.join(self.dir, "bom.jsonl")
with open(path, "wb") as f:
f.write(b"\xef\xbb\xbf" + json.dumps({"id": 1, "name": "a"}).encode("utf-8") + b"\n")
rc, out, err = self.run_tool(self.schema_path, path)
self.assertEqual(rc, 0, out + err)
self.assertIn("checked 1 records, 0 invalid", out)
def test_bom_in_schema_is_stripped(self):
path = os.path.join(self.dir, "bom_schema.json")
with open(path, "wb") as f:
f.write(b"\xef\xbb\xbf" + json.dumps(SCHEMA).encode("utf-8"))
records = self.write_records("ok2.jsonl", [{"id": 1, "name": "a"}])
rc, out, err = self.run_tool(path, records)
self.assertEqual(rc, 0, out + err)
# -- exit code 2: the RUN is bad, not the rows -----------------------
def test_missing_input_exits_2(self):
rc, out, err = self.run_tool(self.schema_path, os.path.join(self.dir, "nope.jsonl"))
self.assertEqual(rc, 2)
self.assertIn("input error:", err)
self.assert_no_traceback(err)
self.assertNotIn("checked", out)
def test_binary_input_exits_2_with_no_traceback(self):
# THE PIN for Q104. At afb182a: rc 1 + UnicodeDecodeError traceback.
path = self.write_bytes("bin.jsonl", b"\xff\xfe\n")
rc, out, err = self.run_tool(self.schema_path, path)
self.assertEqual(rc, 2, out + err)
self.assert_no_traceback(err)
self.assertIn("input error: not valid UTF-8 text", err)
def test_binary_input_after_good_records_still_exits_2(self):
# A run that dies mid-file must not leave a summary line a caller can
# read as the verdict on the rows it never reached.
path = self.write_bytes(
"bin2.jsonl",
json.dumps({"id": 1, "name": "a"}).encode("utf-8") + b"\n" + b"\xff\xfe\n",
)
rc, out, err = self.run_tool(self.schema_path, path)
self.assertEqual(rc, 2, out + err)
self.assert_no_traceback(err)
self.assertNotIn("checked", out)
def test_truncated_utf8_sequence_exits_2(self):
path = self.write_bytes("bin3.jsonl", b'{"id":1,"name":"\xc3"}')
rc, out, err = self.run_tool(self.schema_path, path)
self.assertEqual(rc, 2, out + err)
self.assert_no_traceback(err)
def test_missing_schema_exits_2(self):
records = self.write_records("ok3.jsonl", [{"id": 1, "name": "a"}])
rc, out, err = self.run_tool(os.path.join(self.dir, "nope.json"), records)
self.assertEqual(rc, 2)
self.assertIn("schema error:", err)
self.assert_not_counted(out)
def test_schema_not_json_exits_2(self):
bad_schema = self.write_text("bad_schema.json", "{schema: nope}")
records = self.write_records("ok4.jsonl", [{"id": 1, "name": "a"}])
rc, _, err = self.run_tool(bad_schema, records)
self.assertEqual(rc, 2)
self.assertIn("schema error:", err)
def test_schema_unknown_type_exits_2(self):
bad_schema = self.write_text("bad_type.json", json.dumps({"fields": {"a": "bogus"}}))
records = self.write_records("ok5.jsonl", [{"a": 1}])
rc, _, err = self.run_tool(bad_schema, records)
self.assertEqual(rc, 2)
self.assertIn("unknown type 'bogus'", err)
def test_unreadable_input_directory_exits_2(self):
rc, _, err = self.run_tool(self.schema_path, self.dir)
self.assertEqual(rc, 2)
self.assertIn("input error:", err)
def test_usage_error_exits_2(self):
rc, _, err = self.run_tool()
self.assertEqual(rc, 2)
self.assertNotIn("checked", err)
if __name__ == "__main__":
unittest.main(verbosity=2)