fix(tools): okf_adjudicate exits 2 on a malformed plan

A SegmentationError raised by the plan grammar escaped main() as a
traceback and exit 1, while every other malformed-plan case in the same
file already returned 2. Exit codes are the interface a caller scripts
against, and exit 1 with a traceback is the code an unhandled bug
produces -- it says "this command broke" where the truth is "this file
is not a plan".

The refusal itself is unchanged: nothing was written before and nothing
is written now, and the grammar in src/ is untouched. What changes is
one line on stderr naming the error code, and the exit code.

Both branches that can raise are covered: the pre-write parse of a
non-empty plan, and the required-field check reached through the empty
branch.

The old behaviour was pinned by
test_an_entries_value_that_is_not_a_list_is_still_refused, which
asserted that a wrongly-typed `entries` reaches the caller as a raised
SegmentationError and recorded that as a finding rather than fixing it.
That test is rewritten here, in the same commit as the code, to assert
exit 2 plus the code on stderr. A second test pins the one-line stderr
shape on the non-empty branch.

Suite 1072 -> 1073 passed; ruff and mypy --strict clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-05 07:31:00 +02:00
commit a72053f66f
3 changed files with 68 additions and 8 deletions

View file

@ -260,6 +260,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
construction; the explicit argument preserves that outcome across the bump. construction; the explicit argument preserves that outcome across the bump.
A consumer sees the same rejections, with the same reasons, before and after. A consumer sees the same rejections, with the same reasons, before and after.
### Fixed
- **`tools/okf_adjudicate.py` exits 2 on a malformed plan instead of raising.**
A `SegmentationError` from the plan grammar used to escape `main()` as a
traceback and exit 1, while every other malformed-plan case in the same file
already returned 2. Exit codes are the interface a caller scripts against,
and exit 1 with a traceback says "this command broke" where the truth is
"this file is not a plan" -- the two are the same code an unhandled bug would
produce. The refusal itself is unchanged: nothing is written either way, and
the grammar is untouched. Now one line on stderr naming the error code, and
exit 2. A caller that treated a non-zero exit as failure sees no difference;
one that distinguished 1 from 2 sees a malformed plan move into the class it
belonged to. Pre-existing since the empty-verdict branch landed, and pinned
until now by a test that recorded it as a finding rather than fixing it.
## [0.5.0a2] — 2026-07-31 ## [0.5.0a2] — 2026-07-31
**This is the pre-release the pilots pin. `v0.5.0a1` was tagged and abandoned **This is the pre-release the pilots pin. `v0.5.0a1` was tagged and abandoned

View file

@ -325,21 +325,57 @@ def test_an_empty_plan_missing_a_required_field_is_still_refused(
assert not verdict.exists() assert not verdict.exists()
def test_an_entries_value_that_is_not_a_list_is_still_refused(tmp_path: Path) -> None: def test_an_entries_value_that_is_not_a_list_is_still_refused(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""Empty is a judgement; the wrong TYPE is a malformed plan, and the two """Empty is a judgement; the wrong TYPE is a malformed plan, and the two
must not collapse. The malformed one still meets the unchanged grammar. must not collapse. The malformed one still meets the unchanged grammar.
It reaches the caller as a raised SegmentationError rather than as exit 2, It reaches the caller as EXIT 2, the same code every other malformed plan
which is pre-existing behaviour for every malformed plan and is left alone already got. A grammar refusal used to escape as a traceback and exit 1,
here rather than repaired inside a change about empty ones. Recorded as a which said "this command crashed" where the truth was "this file is not a
finding, not fixed.""" plan" -- and exit codes are the interface callers script against."""
plan_path = empty_proposal(tmp_path) plan_path = empty_proposal(tmp_path)
written = payload(plan_path) written = payload(plan_path)
written["entries"] = "none" written["entries"] = "none"
plan_path.write_text(json.dumps(written), encoding="utf-8", newline="") plan_path.write_text(json.dumps(written), encoding="utf-8", newline="")
with pytest.raises(SegmentationError) as excinfo: code, _ = adjudicate_empty(tmp_path, plan_path)
adjudicate_empty(tmp_path, plan_path)
assert excinfo.value.code == "segmentation_plan_invalid" assert code == 2
assert "segmentation_plan_invalid" in capsys.readouterr().err
assert not (tmp_path / "verdict.json").exists()
def test_a_malformed_non_empty_plan_exits_two_with_one_stderr_line(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""The same refusal on the other branch: a plan with entries is parsed
BEFORE anything is written, and that parse failing is a malformed plan too.
One line, because a caller reading stderr to tell malformed from missing
should not have to parse a traceback to do it."""
plan_path = proposal(tmp_path)
written = payload(plan_path)
written["entries"][0]["span"] = [10, 3]
plan_path.write_text(json.dumps(written), encoding="utf-8", newline="")
capsys.readouterr() # the proposer's own report is not what is under test
code = okf_adjudicate.main(
[
"--plan",
str(plan_path),
"--out",
str(tmp_path / "verdict.json"),
"--adjudicator",
ADJUDICATOR,
"--adjudicated-at",
AT,
]
)
assert code == 2
stderr = capsys.readouterr().err
assert len(stderr.strip().splitlines()) == 1
assert "segmentation_span_invalid" in stderr
assert not (tmp_path / "verdict.json").exists() assert not (tmp_path / "verdict.json").exists()

View file

@ -39,6 +39,7 @@ from typing import Any
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from llm_ingestion_okf.errors import SegmentationError # noqa: E402
from llm_ingestion_okf.segmentation import PLAN_FIELDS, parse_segmentation_plan # noqa: E402 from llm_ingestion_okf.segmentation import PLAN_FIELDS, parse_segmentation_plan # noqa: E402
#: This tool's identity, written into the artifact so an operator reading a #: This tool's identity, written into the artifact so an operator reading a
@ -265,6 +266,14 @@ def main(argv: list[str] | None = None) -> int:
file=sys.stderr, file=sys.stderr,
) )
return 2 return 2
except SegmentationError as exc:
# A malformed plan is a refusal, not a crash. Letting the grammar's
# error escape gave a traceback and exit 1, which a caller scripting on
# exit codes reads as "this command broke" -- the same code an unhandled
# bug would produce. One line, and the same 2 every other malformed plan
# already got.
print(f"{ADJUDICATOR_ID}: FAILED - malformed plan [{exc.code}]: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__": if __name__ == "__main__":