From 9b24a8a123b6f2d7918286c7fdd3399b12b54a8e Mon Sep 17 00:00:00 2001 From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:31:34 -0400 Subject: [PATCH] fix(rank): report malformed scoring results in apply instead of a traceback (#635) * fix(rank): report malformed scoring results in apply instead of a traceback A result that is not an object, a non-string key or a non-object scores value raised AttributeError/TypeError and discarded the batch. They now become errors rows and valid results in the same file still apply. Co-Authored-By: Claude Sonnet 5.5 * changelog: place the #635 entry under [Unreleased] Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Mads Lorentzen --- CHANGELOG.md | 6 ++++++ tests/test_rank_state.py | 28 ++++++++++++++++++++++++++++ tools/rank_state.py | 7 ++++++- 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 284f8a7d..b87adaed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,12 @@ per-file diff commands. `AttributeError` traceback on `.rstrip`, although the audit already reports other malformed state with a one-line error and exit 1. It now names the offending key the same way. +- **`rank_state.py apply` reports a malformed scoring result instead of crashing** (#635, `tools/rank_state.py`, + `tests/test_rank_state.py`) - a result that is not an object, a `key` that is not a string, or a + `scores` value that is not an object raised an `AttributeError`/`TypeError` traceback and discarded the + whole batch, although `apply` already collects per-result problems in `errors`. Each now becomes an + `errors` row (exit 1) and the valid results in the same file are still written. + ## [1.7.3] - 2026-10-19 ### Added diff --git a/tests/test_rank_state.py b/tests/test_rank_state.py index 5aeba349..fe15b238 100644 --- a/tests/test_rank_state.py +++ b/tests/test_rank_state.py @@ -805,6 +805,34 @@ class Unreachable(RankStateCase): self.assertIn("unknown status 'unreachble'", out["errors"][0]["error"]) self.assertEqual(self.read_state()["a"]["status"], "new", "a rejected result never half-writes an entry") + def test_malformed_results_are_errors_not_a_traceback(self): + self.write_state({"a": entry(), "b": entry(title="Other")}) + out = self.run_tool( + "apply", + "--results", + self.results( + [ + 7, + "a", + {"key": ["a"], "scores": {}}, + {"key": "a", "scores": [90, 80]}, + {"key": "b", "scores": {"technical": 80, "experience": 80, "behavioral": 80, "career": 80}}, + ] + ), + expect=1, + ) + self.assertEqual( + [e["error"] for e in out["errors"]], + [ + "result is not an object", + "result is not an object", + "no such key in seen_jobs.json", + "scores must be an object", + ], + ) + self.assertEqual([r["key"] for r in out["ranked"]], ["b"], "valid results in the same batch still apply") + self.assertEqual(self.read_state()["a"]["status"], "new") + if __name__ == "__main__": unittest.main() diff --git a/tools/rank_state.py b/tools/rank_state.py index 3d4999d8..89c47e83 100644 --- a/tools/rank_state.py +++ b/tools/rank_state.py @@ -273,6 +273,8 @@ def cmd_sweep(args) -> int: def overall_score(scores: dict) -> int: + if not isinstance(scores, dict): + raise ValueError("scores must be an object") total = 0.0 for dim, weight in WEIGHTS.items(): value = scores.get(dim) @@ -306,8 +308,11 @@ def cmd_apply(args) -> int: rows, expired, unreachable, gave_up, errors = [], [], [], [], [] for result in results: + if not isinstance(result, dict): + errors.append({"key": None, "error": "result is not an object"}) + continue key = result.get("key") - entry = seen.get(key) + entry = seen.get(key) if isinstance(key, str) else None if entry is None: errors.append({"key": key, "error": "no such key in seen_jobs.json"}) continue