volt: Q107 fixed -- dir-mode exit code is a class, not a count (2 invalid rc2->1, 256 invalid rc0->1); 11-cell test red on the pre-repair module 5/11 and on the count-as-rc mutant exactly 2/11; CHECK-json-validator maps 0/1/2

This commit is contained in:
ledger committed 2026-10-28 16:56:50 +00:00
1 parent 9e0cc7662a
commit 5b343f8937
5 files changed
+296 -67

No files matched your search

+10
View File
@@ -13309,3 +13309,13 @@ Done-condition read off the item above, each leg measured this tick. (1) **The c
(4) **This item's own CHECK** — `python3 tools/queue_index.py --repo . --index | grep -qE '^\s*[0-9]+\s+Q10[0-9]\s'` — exits **0** at HEAD. `tools/verify.py --repo .` reads `index-current PASS` (`174 items … 32 nested ids`) within `8 legs, 6 pass, 2 fault`; the two faults (`checkin`, `path-claims`) reproduce at `74a0e5e` and belong to no change of mine. The fix landed as `e3bbe87`, which the pipe's 2026-10-28 addendum at this file's line 13153 already recorded ("Q103 landed at e3bbe87 (volt)") without appending a closure; this is that closure.
**Not claimed:** that `verify.py`'s `index-current` leg could ever have caught the blind parse — it grades render-against-queue and the render was faithful to a parser that could not see the items, exactly as the item says, so the selftest is the only leg that pins the class and this record does not claim otherwise; that the probe's version of the counting rule is the only defensible one — it is the queue's stated rule re-implemented, and its first draft omitted `(` from the separators and therefore missed `Q46`/`Q47`, a defect I found by diffing it against the tool and fixed before the readings above; that the two `verify.py` faults belong to Q103 or to any lane here; that `Q54`/`Q55`/`Q56`/`Q1000` are items — they are mentioned by the file and open no record, and the probe reports them as mentions rather than requiring rows; that the mutant's 17 selftest failures are all this item's — they are the cells that name the id class and its width ceiling, counted, not itemized. Files touched for this item: `WORK-QUEUE.md` (this record, appended) and `docs/queue-index.md` (re-rendered with the committed `tools/queue_index.py --write` in this tree). No other lane's artifact was edited and nothing from `inbox/` is quoted.
**Q107 — DONE (volt, 2026-10-28): the `--dir` exit code is a class, not a count — both named readings are fixed, and both are now cells that go red on the pre-repair module.** Reproduced at HEAD (`9e0cc76`) before the file was touched: `--dir` over 3 valid + 2 broken files exits **2** (the tally, read as a usage error) and `--dir` over 256 broken files exits **0** (`256 % 256`), against a docstring that says 0 = all valid, 1 = one or more invalid, 2 = usage error and a line 11 that says the opposite ("Returns count of invalid files as exit code for --dir mode"). A 7-case probe over the shipped script read **2 mismatches** (`dir 2 invalid` → 2, `dir 256 invalid` → 0); the other five already matched (all-valid 0, missing dir 2, no args 2, invalid file 1, valid file 0).
**The repair** — `wizard-work/spark/json-validator.py`, md5 `3886f580dd7e02bcd7413ddd4833ae91`, 171 lines — routes `--dir` through one `exit_code_for(invalid)` that maps the count onto the contract (`0 if invalid == 0 else 1`), and replaces the docstring's count line with the contract sentence so file and behavior agree. Three further defects in the same file, measured before anything was changed, are fixed because certifying a contract the file does not keep would be the same fault one layer up: (a) a positional invalid file exited **1 with an empty stdout** — the error string was dropped, so the rc carried no reason; it now prints one `INVALID: <path> - <reason>` line per failing file in both modes; (b) `--quiet`/`-q`, documented as "Suppress VALID messages", was never read (`grep -n quiet` matched only its own `add_argument` line) and is now threaded into `validate_json_file`; (c) the docstring advertised "JSON syntax highlighting support" that no line of the file implements — that line is gone. One behavior change beyond the two named cases, stated rather than smuggled: a **missing positional path** now exits **2**, matching `--dir` on a missing directory (both are bad invocations, not bad data), and a cell pins it.
**The cells** — `wizard-work/spark/test_json_validator_exit_codes.py`, md5 `6d508d01221d7b850fbcbf5b9647bed9`, 11 tests — run the shipped script as a subprocess and assert on exit status; the module path resolves from `__file__` (`Path(__file__).resolve().with_name(...)`), so the cell grades the tree it sits in instead of a pinned `/root` worktree (the Q106/Q109 class, checked for rather than assumed). `wizard-work/spark/CHECK-json-validator` (md5 `5254361ca61566b6d7a28fc6c2f25b6b`) runs the cell and maps unittest's codes onto the repo contract: 0 clean, 1 a failed cell, anything else 2 (could not grade).
**Evidence — three readings on the same cell.** (1) Against the repaired module in this tree: `Ran 11 tests … OK`, rc **0**, and the 7-case probe reads **0 mismatches**. (2) Against the pre-repair module taken from HEAD (`git show HEAD:wizard-work/spark/json-validator.py`, md5 `3a406ab1ecefdc0bd31463ee28001f5e`) in a scratch directory beside the same cell: `Ran 11 tests … FAILED (failures=5)`, rc **1** — `test_dir_256_invalid_exits_1`, `test_dir_two_invalid_exits_1`, `test_positional_invalid_exits_1_and_names_the_file`, `test_positional_missing_exits_2`, `test_quiet_suppresses_valid_banner` — and the gate prints `contract broken -- a cell failed`, rc **1**. (3) The mutation that keeps the cell from being vacuous: the repaired file with only the repaired line reverted (`sys.exit(exit_code_for(result['invalid']))` → `sys.exit(result["invalid"])`) fails **exactly the two named cells** and nothing else (`FAILED (failures=2)`, rc 1), so what the cell pins is the map, not the file's other edits. Schema path re-measured on the repaired file with jsonschema 4.26.0 importable: a document missing a required key exits **1**, a conforming one exits **0**.
**Not claimed:** that `f3d812f`'s sweep caused this defect — it did not; the defect is present at the opening rev `51ddd9d` (md5 `361e20d67551d0ca6f97d983dfdc87d1`, same `sys.exit(result['invalid'])` at line 87) and at HEAD (`3a406ab1…`, line 122), so this repair is written against HEAD and the swept variant's additions (argparse `--schema`, explicit encoding) are kept, not graded; that `wizard-work/spark/CHECK` now runs this cell — it does not, and the lane gate's blindness to its own tests is the Q109/Q120 family, not closed here; that the file has no other defect — only the six behaviors above were measured; that `quick-json-validator.py` was graded — only its usage line was read, to establish that nothing imports this module. Files touched: `wizard-work/spark/json-validator.py`, `wizard-work/spark/test_json_validator_exit_codes.py`, `wizard-work/spark/CHECK-json-validator`, `WORK-QUEUE.md` (this record, appended), `docs/queue-index.md` (re-rendered with the committed `tools/queue_index.py --write` in this tree). Report: `/root/.hermes/wizard-outputs/volt/q107-report.md`. Nothing from `inbox/` is quoted.
+46 -19
View File
@@ -4,7 +4,7 @@
**What this is.** One row per item of `WORK-QUEUE.md`: its line span, its size, and the first `Status:` marker in its body verbatim. An item is a `## ` section, or a record that opens an id with no heading of its own, or a later block-initial record for an id that is already an item (`kind` and `role` say which, and each item's block runs to the line before the next item opens). Whether an item is *open* is not decided here -- that verdict is `tools/queue_claim_check.py`'s, and this document does not overrule it.
**What it exposes.** The queue is two-tier. 96 sections carry 85 ids because 11 ids own a second, continuation section; 32 further ids are opened by a record and own no heading at all, and they have rows and extents here -- 79 record blocks in all, 47 of them a later closure or correction for an id already opened. The same **32 ids** are listed again in the table below, which carries what a bounds row cannot -- the form that opened each one and how often the file names it: grepping `^## Q41` finds nothing while Q41 is a live item the file names 29 times.
**What it exposes.** The queue is two-tier. 96 sections carry 85 ids because 11 ids own a second, continuation section; 32 further ids are opened by a record and own no heading at all, and they have rows and extents here -- 80 record blocks in all, 48 of them a later closure or correction for an id already opened. The same **32 ids** are listed again in the table below, which carries what a bounds row cannot -- the form that opened each one and how often the file names it: grepping `^## Q41` finds nothing while Q41 is a live item the file names 29 times.
| # | id | kind | lines | bytes | role | title | first `Status:` |
|---|---|---|---|---|---|---|---|
@@ -182,7 +182,8 @@
| 172 | Q118 | record | 13285-13289 | 3210 | record: BOLD-LEAD | **Q118 — OPEN (pipe tooling; opened 2026-10-28 by the pipe, per reply 147 §0.1; no lane owns the monitor, it lives outside the repo at `/root/.hermes/scripts/troupe_pipe_monitor.py`). The pipe's own … | -- |
| 173 | Q119 | record | 13290-13292 | 3751 | record: BOLD-LEAD | **Q119 — OPEN (sol lane by authorship; opened 2026-10-28 by the pipe, per reply 148 §2, at `c166bf1`). `wizard-work/sol/classify.py` is right about 8 of 8 demands and right about one of them by … | -- |
| 174 | Q120 | record | 13293-13300 | 6244 | record: BOLD-LEAD | **Q120 — OPEN (spark lane by authorship; opened 2026-10-28 by the pipe, per reply 148 §3, at `c166bf1`). `wizard-work/spark/python-env-check.py` calls itself a "Python environment health check … | -- |
| 175 | Q103 | record | 13301-13311 | 4024 | cont: DONE | **Q103 — DONE at `e3bbe87` (volt), re-measured and closed at `74a0e5e`: the id class carries no width, every record-opened id from `Q100` up has a row, and the repair was falsified rather than … | -- |
| 175 | Q103 | record | 13301-13312 | 4025 | cont: DONE | **Q103 — DONE at `e3bbe87` (volt), re-measured and closed at `74a0e5e`: the id class carries no width, every record-opened id from `Q100` up has a row, and the repair was falsified rather than … | -- |
| 176 | Q107 | record | 13313-13321 | 4964 | cont: DONE | **Q107 — DONE (volt, 2026-10-28): the `--dir` exit code is a class, not a count — both named readings are fixed, and both are now cells that go red on the pre-repair module.** Reproduced at HEAD … | -- |
## Ids with no `## ` heading
@@ -207,10 +208,10 @@ Opened as records inside another section. `line` is the record's own opening lin
| Q103 | 12995 | 12995 | 13 | Q102 | BULLET+BOLD | BOLD-LEAD |
| Q104 | 13088 | 13088 | 18 | Q101 | BULLET+BOLD | BOLD-LEAD |
| Q105 | 13120 | 13120 | 15 | Q104 | BULLET+BOLD | BOLD-LEAD |
| Q106 | 13156 | 13156 | 9 | Q105 | BULLET+BOLD | BOLD-LEAD |
| Q107 | 13174 | 13174 | 7 | Q106 | BULLET+BOLD | BOLD-LEAD |
| Q106 | 13156 | 13156 | 10 | Q105 | BULLET+BOLD | BOLD-LEAD |
| Q107 | 13174 | 13174 | 8 | Q106 | BULLET+BOLD | BOLD-LEAD |
| Q108 | 13252 | 13248 | 3 | Q106 | BULLET+BOLD | BOLD-LEAD |
| Q109 | 13255 | 13255 | 2 | Q104 | BULLET+BOLD | BOLD-LEAD |
| Q109 | 13255 | 13255 | 4 | Q104 | BULLET+BOLD | BOLD-LEAD |
| Q110 | 13257 | 13257 | 2 | Q109 | BULLET+BOLD | BOLD-LEAD |
| Q111 | 13259 | 13259 | 7 | Q110 | BULLET+BOLD | BOLD-LEAD |
| Q112 | 13261 | 13261 | 4 | Q111 | BULLET+BOLD | BOLD-LEAD |
@@ -221,7 +222,7 @@ Opened as records inside another section. `line` is the record's own opening lin
| Q117 | 13275 | 13273 | 3 | Q96 | BULLET+BOLD | BOLD-LEAD |
| Q118 | 13285 | 13285 | 2 | Q111 | BULLET+BOLD | BOLD-LEAD |
| Q119 | 13290 | 13290 | 2 | Q118 | BULLET+BOLD | BOLD-LEAD |
| Q120 | 13293 | 13293 | 3 | Q119 | BULLET+BOLD | BOLD-LEAD |
| Q120 | 13293 | 13293 | 4 | Q119 | BULLET+BOLD | BOLD-LEAD |
**Not in this document.** Ids the prose corpus names that the queue has never mentioned (`checkins/`, `replies/`, `recoveries/`, `audits/`, `docs/`, the top-level documents; `tools/` excluded). That set moves with the prose, not with the queue, so embedding it here would make `--check` go RED for a reason that has nothing to do with the queue being current. It is reported on demand: `python3 tools/queue_index.py --outside`.
@@ -1248,17 +1249,30 @@ The block below is the parse this document renders, byte for byte. `--check` com
"title": "**Q120 \u2014 OPEN (spark lane by authorship; opened 2026-10-28 by the pipe, per reply 148 \u00a73, at `c166bf1`). `wizard-work/spark/python-env-check.py` calls itself a \"Python environment health check \u2026"
},
{
"bytes": 4024,
"end_line": 13311,
"bytes": 4025,
"end_line": 13312,
"id": "Q103",
"kind": "record",
"line": 13301,
"lines": 11,
"lines": 12,
"role": "continuation",
"role_detail": "DONE",
"status": null,
"status_line": null,
"title": "**Q103 \u2014 DONE at `e3bbe87` (volt), re-measured and closed at `74a0e5e`: the id class carries no width, every record-opened id from `Q100` up has a row, and the repair was falsified rather than \u2026"
},
{
"bytes": 4964,
"end_line": 13321,
"id": "Q107",
"kind": "record",
"line": 13313,
"lines": 9,
"role": "continuation",
"role_detail": "DONE",
"status": null,
"status_line": null,
"title": "**Q107 \u2014 DONE (volt, 2026-10-28): the `--dir` exit code is a class, not a count \u2014 both named readings are fixed, and both are now cells that go red on the pre-repair module.** Reproduced at HEAD \u2026"
}
],
"ids_multi_section": {
@@ -3658,17 +3672,30 @@ The block below is the parse this document renders, byte for byte. `--check` com
"title": "**Q120 \u2014 OPEN (spark lane by authorship; opened 2026-10-28 by the pipe, per reply 148 \u00a73, at `c166bf1`). `wizard-work/spark/python-env-check.py` calls itself a \"Python environment health check \u2026"
},
{
"bytes": 4024,
"end_line": 13311,
"bytes": 4025,
"end_line": 13312,
"id": "Q103",
"kind": "record",
"line": 13301,
"lines": 11,
"lines": 12,
"role": "continuation",
"role_detail": "DONE",
"status": null,
"status_line": null,
"title": "**Q103 \u2014 DONE at `e3bbe87` (volt), re-measured and closed at `74a0e5e`: the id class carries no width, every record-opened id from `Q100` up has a row, and the repair was falsified rather than \u2026"
},
{
"bytes": 4964,
"end_line": 13321,
"id": "Q107",
"kind": "record",
"line": 13313,
"lines": 9,
"role": "continuation",
"role_detail": "DONE",
"status": null,
"status_line": null,
"title": "**Q107 \u2014 DONE (volt, 2026-10-28): the `--dir` exit code is a class, not a count \u2014 both named readings are fixed, and both are now cells that go red on the pre-repair module.** Reproduced at HEAD \u2026"
}
],
"nested": [
@@ -3830,7 +3857,7 @@ The block below is the parse this document renders, byte for byte. `--check` com
"form": "BOLD-LEAD",
"id": "Q106",
"line": 13156,
"refs": 9,
"refs": 10,
"section": "Q105",
"shape": "BULLET+BOLD"
},
@@ -3839,7 +3866,7 @@ The block below is the parse this document renders, byte for byte. `--check` com
"form": "BOLD-LEAD",
"id": "Q107",
"line": 13174,
"refs": 7,
"refs": 8,
"section": "Q106",
"shape": "BULLET+BOLD"
},
@@ -3857,7 +3884,7 @@ The block below is the parse this document renders, byte for byte. `--check` com
"form": "BOLD-LEAD",
"id": "Q109",
"line": 13255,
"refs": 2,
"refs": 4,
"section": "Q104",
"shape": "BULLET+BOLD"
},
@@ -3956,7 +3983,7 @@ The block below is the parse this document renders, byte for byte. `--check` com
"form": "BOLD-LEAD",
"id": "Q120",
"line": 13293,
"refs": 3,
"refs": 4,
"section": "Q119",
"shape": "BULLET+BOLD"
}
@@ -3966,9 +3993,9 @@ The block below is the parse this document renders, byte for byte. `--check` com
"end_line": 8,
"line": 1
},
"queue_bytes": 2029536,
"queue_lines": 13311,
"queue_sha256": "d74d1b3b129a501d0e1ea24c023e5e15edf1b9a2d7f24c2c034c4acebbd9dbc9",
"queue_bytes": 2034501,
"queue_lines": 13321,
"queue_sha256": "3f09dd89e8ad6013dca03a69b2229e3bc9a21b10af61f2bf77c0f532c2bc1cac",
"sections": [
{
"bytes": 3139,
+33
View File
@@ -0,0 +1,33 @@
#!/bin/bash
# CHECK-json-validator: grade wizard-work/spark/json-validator.py's exit contract.
#
# Q107. The lane's own CHECK grades a different artifact, so this file exists to
# give the validator a gate of its own rather than leave its cell unrun. The cell
# (test_json_validator_exit_codes.py) invokes the shipped script as a subprocess
# and asserts on exit status, so it grades the tree it sits in -- the module path
# is resolved from the test file's own location, never from a pinned /root path.
#
# Exit codes follow the repo's tools/ contract:
# 0 = the contract holds
# 1 = the contract is broken (a test failed)
# 2 = could not grade (no python3, or the cell does not import/collect)
set -e
set -o pipefail
cd "$(dirname "$(readlink -f "$0")")"
if ! command -v python3 >/dev/null 2>&1; then
echo "CHECK: python3 not on PATH -- cannot grade." >&2
exit 2
fi
set +e
python3 -m unittest -q test_json_validator_exit_codes >/tmp/.q107-check.out 2>&1
rc=$?
set -e
tail -3 /tmp/.q107-check.out
case $rc in
0) echo "CHECK: exit-code contract holds."; exit 0 ;;
1) echo "CHECK: contract broken -- a cell failed." >&2; exit 1 ;;
*) echo "CHECK: unittest exited $rc -- collection error, could not grade." >&2; exit 2 ;;
esac
+74 -48
View File
@@ -2,16 +2,22 @@
"""Enhanced JSON Validator - validates JSON files with proper exit codes.
Exit 0: All valid
Exit 1: One or more invalid
Exit 2: Usage error
Exit 1: One or more inputs could not be validated (invalid JSON, schema mismatch)
Exit 2: Usage error (bad arguments, a path that does not exist, unusable schema)
The exit code reports the *outcome of the request*, never a count: `--dir` mode
exits 1 when any file in the directory is invalid, however many that is (a count
as an exit status silently reads as success at 256 and as a usage error at 2).
The per-run summary lines carry the counts.
Features:
- Validate individual files: json-validator.py <file.json> ...
- Validate directory: json-validator.py --dir <directory>
- Returns count of invalid files as exit code for --dir mode
- Enhanced error reporting with file paths
- JSON syntax highlighting support
- Error reporting with file paths, one INVALID line per failing file
- Schema validation support via --schema flag
- --quiet suppresses the VALID banner for passing files
Requires no third-party package unless --schema is used (then: jsonschema).
"""
import sys
@@ -20,13 +26,14 @@ import argparse
from pathlib import Path
def validate_json_file(filepath: str, schema: dict = None) -> tuple[bool, str]:
def validate_json_file(filepath: str, schema: dict | None = None, quiet: bool = False) -> tuple[bool, str]:
"""Validate a JSON file.
Args:
filepath: Path to the JSON file
schema: Optional JSON schema dict for schema validation
quiet: Suppress the VALID line for a passing file
Returns:
(True, "") if valid
(False, error_message) if invalid
@@ -34,52 +41,53 @@ def validate_json_file(filepath: str, schema: dict = None) -> tuple[bool, str]:
try:
with open(filepath, 'r', encoding='utf-8') as f:
data = json.load(f)
if schema:
import jsonschema
jsonschema.validate(instance=data, schema=schema)
print(f"VALID: {filepath}")
if not quiet:
print(f"VALID: {filepath}")
return True, ""
except json.JSONDecodeError as e:
error_msg = f"Invalid JSON at line {e.lineno}, column {e.colno}: {e.msg}"
return False, error_msg
return False, f"Invalid JSON at line {e.lineno}, column {e.colno}: {e.msg}"
except FileNotFoundError:
return False, "File not found"
except Exception as e:
return False, f"Error: {e}"
def validate_json_directory(directory: str, schema: dict = None) -> dict:
"""Validate all .json files in a directory.
def validate_json_directory(directory: str, schema: dict | None = None, quiet: bool = False) -> dict:
"""Validate every .json file in a directory (non-recursive).
Args:
directory: Path to directory
schema: Optional JSON schema for validation
quiet: Suppress the VALID lines for passing files
Returns:
Dict with totals and counts
Dict with totals and counts, or {"error": ...} when the directory is unusable
"""
dir_path = Path(directory)
if not dir_path.exists():
return {"error": f"Directory not found: {directory}", "total": 0, "valid": 0, "invalid": 0}
if not dir_path.is_dir():
return {"error": f"Not a directory: {directory}", "total": 0, "valid": 0, "invalid": 0}
json_files = sorted(dir_path.glob("*.json"))
valid = 0
invalid = 0
invalid_files = []
for jf in json_files:
is_valid, error = validate_json_file(str(jf), schema)
is_valid, error = validate_json_file(str(jf), schema, quiet)
if is_valid:
valid += 1
else:
invalid += 1
invalid_files.append((jf.name, error))
print(f"INVALID: {jf.name} - {error}")
return {
"total": len(json_files),
"valid": valid,
@@ -88,30 +96,43 @@ def validate_json_directory(directory: str, schema: dict = None) -> dict:
}
def exit_code_for(invalid: int) -> int:
"""Map an invalid-input count onto the documented exit contract (0 or 1).
Never returns the count itself: an exit status is a class, not a tally.
"""
return 0 if invalid == 0 else 1
def main():
parser = argparse.ArgumentParser(description='Enhanced JSON Validator')
parser.add_argument('paths', nargs='*', help='JSON files or directories to validate')
parser.add_argument('--dir', dest='directory', help='Directory to validate')
parser.add_argument('--schema', help='JSON Schema file for validation')
parser.add_argument('--quiet', '-q', action='store_true', help='Suppress VALID messages')
args = parser.parse_args()
if not args.paths and not args.directory:
parser.print_help()
sys.exit(2)
schema = None
if args.schema:
try:
with open(args.schema, 'r') as f:
with open(args.schema, 'r', encoding='utf-8') as f:
schema = json.load(f)
except Exception as e:
print(f"Error loading schema: {e}", file=sys.stderr)
sys.exit(2)
try:
import jsonschema # noqa: F401
except ImportError:
print("Error: --schema requires the jsonschema package, which is not importable", file=sys.stderr)
sys.exit(2)
if args.directory:
result = validate_json_directory(args.directory, schema)
result = validate_json_directory(args.directory, schema, args.quiet)
if "error" in result:
print(f"Error: {result['error']}", file=sys.stderr)
sys.exit(2)
@@ -119,27 +140,32 @@ def main():
print(f"Total: {result['total']}")
print(f"Valid: {result['valid']}")
print(f"Invalid: {result['invalid']}")
sys.exit(result['invalid'])
else:
paths = args.paths
all_valid = True
for path in paths:
p = Path(path)
if p.is_dir():
for json_file in sorted(p.rglob("*.json")):
is_valid, error = validate_json_file(str(json_file), schema)
if not is_valid:
all_valid = False
elif p.is_file():
is_valid, error = validate_json_file(str(p), schema)
sys.exit(exit_code_for(result['invalid']))
invalid = 0
missing = 0
for path in args.paths:
p = Path(path)
if p.is_dir():
for json_file in sorted(p.rglob("*.json")):
is_valid, error = validate_json_file(str(json_file), schema, args.quiet)
if not is_valid:
all_valid = False
else:
print(f"NOT FOUND: {path}", file=sys.stderr)
all_valid = False
sys.exit(0 if all_valid else 1)
invalid += 1
print(f"INVALID: {json_file} - {error}")
elif p.is_file():
is_valid, error = validate_json_file(str(p), schema, args.quiet)
if not is_valid:
invalid += 1
print(f"INVALID: {p} - {error}")
else:
print(f"NOT FOUND: {path}", file=sys.stderr)
missing += 1
if missing:
# A path that does not exist is a bad invocation, not bad data.
sys.exit(2)
sys.exit(exit_code_for(invalid))
if __name__ == "__main__":
main()
main()
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Q107 cell: json-validator.py's exit-code contract, measured as a subprocess.
The module's own docstring defines the contract:
0 = all valid, 1 = one or more inputs could not be validated, 2 = usage error
These tests invoke the shipped script by path (resolved from __file__, so they
grade the tree they sit in rather than a pinned host worktree) and assert on the
process exit status, not on internal functions. The two cases the queue item
named are `test_dir_two_invalid_exits_1` (was rc 2, read as a usage error) and
`test_dir_256_invalid_exits_1` (was rc 0, read as success at the 256-byte wrap).
Run: python3 test_json_validator_exit_codes.py (or: python3 -m unittest -q ...)
"""
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
MODULE = Path(__file__).resolve().with_name("json-validator.py")
def write_json(path: Path, payload) -> None:
with open(path, "w", encoding="utf-8") as f:
json.dump(payload, f)
def write_bad(path: Path) -> None:
with open(path, "w", encoding="utf-8") as f:
f.write("{not json")
class JsonValidatorExitCodes(unittest.TestCase):
def setUp(self):
self.tmp = Path(tempfile.mkdtemp(prefix="q107-cell-", dir=tempfile.gettempdir()))
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def run_module(self, *args):
return subprocess.run(
[sys.executable, str(MODULE), *[str(a) for a in args]],
capture_output=True, text=True,
)
def a_dir(self, name, n_valid=0, n_invalid=0) -> Path:
d = self.tmp / name
d.mkdir(parents=True, exist_ok=True)
for i in range(n_valid):
write_json(d / ("ok%03d.json" % i), {"i": i})
for i in range(n_invalid):
write_bad(d / ("bad%03d.json" % i))
return d
# --- --dir mode -------------------------------------------------------
def test_dir_all_valid_exits_0(self):
p = self.run_module("--dir", self.a_dir("all_ok", n_valid=5))
self.assertEqual(p.returncode, 0, p.stdout + p.stderr)
self.assertIn("Invalid: 0", p.stdout)
def test_dir_two_invalid_exits_1(self):
# Named case 1: two broken files used to read as a usage error (rc 2).
p = self.run_module("--dir", self.a_dir("two_bad", n_valid=3, n_invalid=2))
self.assertEqual(p.returncode, 1, p.stdout + p.stderr)
self.assertIn("Invalid: 2", p.stdout)
def test_dir_256_invalid_exits_1(self):
# Named case 2: 256 broken files used to read as success (rc 0 == 256 % 256).
p = self.run_module("--dir", self.a_dir("many_bad", n_invalid=256))
self.assertEqual(p.returncode, 1, p.stdout + p.stderr)
self.assertIn("Invalid: 256", p.stdout)
def test_dir_lists_each_invalid_file(self):
d = self.a_dir("named", n_invalid=2)
p = self.run_module("--dir", d)
self.assertIn("INVALID: bad000.json", p.stdout)
self.assertIn("INVALID: bad001.json", p.stdout)
def test_dir_missing_exits_2(self):
p = self.run_module("--dir", self.tmp / "no_such_dir")
self.assertEqual(p.returncode, 2, p.stdout + p.stderr)
self.assertIn("Directory not found", p.stderr)
# --- positional mode --------------------------------------------------
def test_positional_valid_exits_0(self):
d = self.a_dir("one_ok", n_valid=1)
p = self.run_module(d / "ok000.json")
self.assertEqual(p.returncode, 0, p.stdout + p.stderr)
self.assertIn("VALID:", p.stdout)
def test_positional_invalid_exits_1_and_names_the_file(self):
d = self.a_dir("one_bad", n_invalid=1)
p = self.run_module(d / "bad000.json")
self.assertEqual(p.returncode, 1, p.stdout + p.stderr)
# An rc with no reason is the same silent-failure family as the count-as-rc.
self.assertIn("INVALID:", p.stdout)
self.assertIn("bad000.json", p.stdout)
def test_positional_missing_exits_2(self):
p = self.run_module(self.tmp / "absent.json")
self.assertEqual(p.returncode, 2, p.stdout + p.stderr)
self.assertIn("NOT FOUND", p.stderr)
# --- documented option ------------------------------------------------
def test_quiet_suppresses_valid_banner(self):
d = self.a_dir("quiet", n_valid=2)
loud = self.run_module("--dir", d)
quiet = self.run_module("--dir", d, "--quiet")
self.assertIn("VALID:", loud.stdout)
self.assertNotIn("VALID:", quiet.stdout)
self.assertEqual(quiet.returncode, 0, quiet.stdout + quiet.stderr)
# --- usage ------------------------------------------------------------
def test_no_args_exits_2(self):
p = self.run_module()
self.assertEqual(p.returncode, 2, p.stdout + p.stderr)
def test_unreadable_schema_exits_2(self):
p = self.run_module("--schema", self.tmp / "nope.json", self.a_dir("ok1", n_valid=1))
self.assertEqual(p.returncode, 2, p.stdout + p.stderr)
if __name__ == "__main__":
unittest.main(verbosity=2)