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.
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
**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()
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
must not collapse. The malformed one still meets the unchanged grammar.
It reaches the caller as a raised SegmentationError rather than as exit 2,
which is pre-existing behaviour for every malformed plan and is left alone
here rather than repaired inside a change about empty ones. Recorded as a
finding, not fixed."""
It reaches the caller as EXIT 2, the same code every other malformed plan
already got. A grammar refusal used to escape as a traceback and exit 1,
which said "this command crashed" where the truth was "this file is not a
plan" -- and exit codes are the interface callers script against."""
plan_path = empty_proposal(tmp_path)
written = payload(plan_path)
written["entries"] = "none"
plan_path.write_text(json.dumps(written), encoding="utf-8", newline="")
with pytest.raises(SegmentationError) as excinfo:
adjudicate_empty(tmp_path, plan_path)
code, _ = 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()

View file

@ -39,6 +39,7 @@ from typing import Any
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
#: 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,
)
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__":