Skip to content

Commit f578f07

Browse files
CarlesUIPathclaude
andcommitted
Merge remote-tracking branch 'origin/main' into akshaya/fix_referencing
Resolves an ID collision in the merge: this branch's dead-private-helper / acquire-inside-try / config-error-escalates BaseRule lint checks (CE036/ CE037/CE038) are renumbered CE037/CE038/CE039, since main independently claimed CE036 (#126) for its live_verdict determinism + monotonicity contract. Renamed the three rule files, their ids, the runner imports, the pyproject `external` noqa list, and the two in-code references (reference_comparison noqa, test_docker_runner_mounts docstring). CLAUDE.md merged: keeps main's CE026-CE031/CE033-CE036 test-class paragraph (CE036 = live_verdict contract) and this branch's renumbered CE037/CE038/CE039 paragraph. make check / make lint / make test (4180 passed, 9 skipped) all pass; make typecheck unchanged at the 3 pre-existing openai_codex import errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 parents de2d94e + d854004 commit f578f07

19 files changed

Lines changed: 1041 additions & 35 deletions

.github/workflows/pr-checks.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,15 @@ jobs:
9696
- name: Type check with pyright
9797
run: .venv/bin/pyright
9898

99+
# The CE036 contract engine lives under tests/, which [tool.pyright] excludes
100+
# -- and `exclude` beats both a CLI file arg and an `include` entry, so it can
101+
# only be reached through a config of its own, derived from [tool.pyright] so
102+
# the two passes cannot drift. Mirrors `make typecheck`.
103+
- name: Type check the CE036 contract engine
104+
run: |
105+
.venv/bin/python -m tests.lint.pyright_config .pyright-tests.json
106+
.venv/bin/pyright -p .pyright-tests.json
107+
99108
# PHASE 3: Security scanning
100109
- name: Security - Dependency vulnerabilities (pip-audit)
101110
run: .venv/bin/pip-audit --desc --skip-editable --ignore-vuln CVE-2026-4539 --ignore-vuln CVE-2026-3219 --ignore-vuln PYSEC-2025-183 # pygments 2.19.2 ReDoS + pip 26.0.1 tar/ZIP ambiguity + pyjwt 2.12.1 weak-encryption (disputed by supplier; key length is application-chosen); no fixes available on PyPI yet — revisit quarterly
@@ -387,6 +396,11 @@ jobs:
387396
- name: Type check with pyright
388397
run: .venv/Scripts/pyright
389398

399+
- name: Type check the CE036 contract engine
400+
run: |
401+
.venv/Scripts/python -m tests.lint.pyright_config .pyright-tests.json
402+
.venv/Scripts/pyright -p .pyright-tests.json
403+
390404
- name: Run test suite
391405
run: .venv/Scripts/pytest tests/ -v -m "not live and not lint"
392406

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,6 @@ refs/
6969

7070
# SkillsBench tasks for testing
7171
/resources/
72+
73+
# Derived pyright config for the CE036 contract engine (tests/lint/pyright_config.py)
74+
.pyright-tests.json

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,9 +216,9 @@ make plugin-reference # the plugin's bundled criteria reference from the models
216216

217217
Editing `src/coder_eval/pricing.py` means editing `evalboard/lib/pricing.ts` too — it is a hand-copied mirror, and `evalboard/lib/__tests__/pricing-parity.test.ts` fails the build on drift in either direction.
218218

219-
Recent additions, each traceable to a shipped defect: **CE036** (no unreferenced module-level private helper in `src/` — a helper whose docstring documents a bug the live code still has is worse than none), **CE037** (in an `@asynccontextmanager`, the acquire must sit INSIDE the `try` whose `finally` releases it — `asyncio.shield` protects the inner task, NOT the await, so a cancel on `__aenter__` skips the unwind while the work completes), **CE038** (a criterion checker must not return a gating `score=0.0` from an `except OSError` over a path the *task author* named — that books an eval-config error as an agent failure; raise `CheckerMisuseError` instead, and `# noqa: CE038` the cases that really are the agent's).
219+
Recent additions, each traceable to a shipped defect: **CE037** (no unreferenced module-level private helper in `src/` — a helper whose docstring documents a bug the live code still has is worse than none), **CE038** (in an `@asynccontextmanager`, the acquire must sit INSIDE the `try` whose `finally` releases it — `asyncio.shield` protects the inner task, NOT the await, so a cancel on `__aenter__` skips the unwind while the work completes), **CE039** (a criterion checker must not return a gating `score=0.0` from an `except OSError` over a path the *task author* named — that books an eval-config error as an agent failure; raise `CheckerMisuseError` instead, and `# noqa: CE039` the cases that really are the agent's).
220220

221-
When fixing a bug, ask: *could a custom lint rule have prevented this?* If the root cause is a mechanically detectable pattern (e.g., "always import from `coder_eval.models`", "never call blocking IO in async"), add a rule to `tests/lint/rules/` following the CE001+ pattern and wire it up in `tests/lint/runner.py`. This turns a one-time fix into permanent enforcement. See `tests/test_custom_lint.py` for how rules are tested. (Doc-surface / whole-tree rules that reason over Markdown/YAML or the entire `src/` tree rather than one `.py` AST at a time — CE026–CE031, CE033, CE034, CE035 — are not `BaseRule`s in the runner; they are wired as dedicated `@pytest.mark.lint` test classes. CE035 resolves every `steps.<id>.outputs.<key>` / `needs.<job>.outputs.<key>` reference in `.github/workflows/**` to a writer that actually produces that key — GitHub expands an unwritten output to the empty string, so a typo degrades a gate silently and actionlint models `steps.*.outputs` as an open string map. CE034 scans `tasks/` and forces an armed, live-*passable* `command_executed` to set `require_success` — a crashed invocation would otherwise latch a live PASS, fire `on_pass: stop`, and let FIRED-ONLY armed gating report SUCCESS without ever consulting the unarmed criteria (negative assertions are fail-only and are exempt). CE033 keeps the plugin's bundled `reference/criteria.md` in parity with the `SuccessCriterion` union that generates it (`make plugin-reference` writes it; the rule re-renders and diffs — never hand-edit the file). CE031 guards against dead config: a behavior-driving field on `SimulationConfig`/`RunLimits`/`Dataset` that no code reads by name. CE026 keeps the GitHub Action's onboarding surfaces honest — `README.md`, `docs/CI_GATE.md`, `docs/tutorials/02-ci-pipeline.md`, and the plugin's `ci` skill, whose emitted workflow users copy into their own repos: a page's *first* Action snippet must show the agent-runtime prerequisite steps (pinned to the `action-dogfood` job that proves them in CI), a zero-install absolute next to such a snippet must name the channel it means, every `github.com/marketplace/actions/<slug>` link plus the shields badge label must match `action.yml`'s `name:`, and every `with:` key on a snippet's action step must be a real `action.yml` input (GitHub ignores unknown inputs, so a rename would silently degrade every copied workflow). Renaming an action input or changing its runtime prerequisites therefore means updating the skill too.)
221+
When fixing a bug, ask: *could a custom lint rule have prevented this?* If the root cause is a mechanically detectable pattern (e.g., "always import from `coder_eval.models`", "never call blocking IO in async"), add a rule to `tests/lint/rules/` following the CE001+ pattern and wire it up in `tests/lint/runner.py`. This turns a one-time fix into permanent enforcement. See `tests/test_custom_lint.py` for how rules are tested. (Doc-surface / whole-tree rules that reason over Markdown/YAML or the entire `src/` tree rather than one `.py` AST at a time — CE026–CE031, CE033–CE036 — are not `BaseRule`s in the runner; they are wired as dedicated `@pytest.mark.lint` test classes. CE036 enforces the `live_verdict` determinism + monotonicity contract (`criteria/base.py`) that `EarlyStopWatcher`'s latching, deferred fail-stop, and flip-attribution silently depend on: monotonicity over arbitrary Python is undecidable, so instead of a static check it REPLAYS each live criterion against every prefix of recorded trajectories (`tests/lint/live_verdict_contract.py::CASES`) — on the authored ordering AND under seeded shuffles (`permuted_violations`, which catch order-sensitive bugs the authored walk misses) — and asserts the property directly, plus registry-derived coverage — every `LiveSuccessCriterion` in the union must have cases, and every polarity its instances claim via `live_decidable_polarities()` must actually be reached by one (otherwise a single always-`undecided` fixture would "cover" a type while proving nothing). Adding a live criterion therefore means adding `ContractCase`s in the same change. CE035 resolves every `steps.<id>.outputs.<key>` / `needs.<job>.outputs.<key>` reference in `.github/workflows/**` to a writer that actually produces that key — GitHub expands an unwritten output to the empty string, so a typo degrades a gate silently and actionlint models `steps.*.outputs` as an open string map. CE034 scans `tasks/` and forces an armed, live-*passable* `command_executed` to set `require_success` — a crashed invocation would otherwise latch a live PASS, fire `on_pass: stop`, and let FIRED-ONLY armed gating report SUCCESS without ever consulting the unarmed criteria (negative assertions are fail-only and are exempt). CE033 keeps the plugin's bundled `reference/criteria.md` in parity with the `SuccessCriterion` union that generates it (`make plugin-reference` writes it; the rule re-renders and diffs — never hand-edit the file). CE031 guards against dead config: a behavior-driving field on `SimulationConfig`/`RunLimits`/`Dataset` that no code reads by name. CE026 keeps the GitHub Action's onboarding surfaces honest — `README.md`, `docs/CI_GATE.md`, `docs/tutorials/02-ci-pipeline.md`, and the plugin's `ci` skill, whose emitted workflow users copy into their own repos: a page's *first* Action snippet must show the agent-runtime prerequisite steps (pinned to the `action-dogfood` job that proves them in CI), a zero-install absolute next to such a snippet must name the channel it means, every `github.com/marketplace/actions/<slug>` link plus the shields badge label must match `action.yml`'s `name:`, and every `with:` key on a snippet's action step must be a real `action.yml` input (GitHub ignores unknown inputs, so a rename would silently degrade every copied workflow). Renaming an action input or changing its runtime prerequisites therefore means updating the skill too.)
222222

223223
Adding a user-facing field to one of the models CE030 tracks (`TaskDefinition`, `RunLimits`, `Dataset`, `SimulationConfig` — see `tests/lint/doc_schema_parity.py`) means documenting it in its guide (mention the field name as inline code) or adding an `EXEMPT` entry with a reason it is not user-authored. `make lint` fails otherwise.
224224

Makefile

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@ plugin-reference: ## Regenerate the plugin's bundled criteria reference from th
3838

3939
typecheck: ## Run type checking with pyright
4040
uv run pyright
41+
# The CE036 contract engine executes checker code and feeds the early-stop
42+
# design; it is the one tests/ surface worth type-checking. It needs its own
43+
# config: pyproject.toml excludes "tests", and pyright's `exclude` beats BOTH
44+
# an explicitly-passed CLI file arg AND an `include` entry naming the file --
45+
# either shortcut analyzes ZERO files and exits 0, a gate that checks nothing.
46+
# The config below is DERIVED from [tool.pyright] (same rules, only
47+
# include/exclude swapped), so the two passes cannot drift apart.
48+
uv run python -m tests.lint.pyright_config .pyright-tests.json
49+
uv run pyright -p .pyright-tests.json
4150

4251
test: ## Run test suite (excludes live + lint tests; run `make lint` for those)
4352
uv run pytest -n auto -m "not live and not lint" tests/

docs/EXTENDING.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,21 @@ Notes:
223223
LiveSuccessCriterion)` directly, no separate checker-side flag. A lint rule
224224
(`tests/test_custom_lint.py::TestCE025LiveVerdictConsistency`) keeps the
225225
model subclassing and the checker's `live_verdict` override paired.
226+
- Your `live_verdict` must be **deterministic** (a pure function of the
227+
`turn_records` prefix — no wall-clock, randomness, or hidden instance state)
228+
and **monotonic** (once it returns `"pass"`/`"fail"` for some prefix, every
229+
longer prefix returns that same verdict) — `EarlyStopWatcher`'s verdict
230+
latching and deferred stops silently depend on both. Lint rule CE036
231+
(`tests/lint/live_verdict_contract.py`) enforces this by replaying each live
232+
criterion against every prefix of recorded trajectories, and **fails until
233+
you add `ContractCase` fixtures** for the new type in the same change,
234+
reaching every polarity its instances claim via
235+
`live_decidable_polarities()`. An out-of-tree plugin criterion is invisible
236+
to CE036's union walk — and the module lives under `tests/`, which is not
237+
shipped in the PyPI wheel — so copy the replay pattern (a `ContractCase`-style
238+
fixture plus the prefix-by-prefix determinism/monotonicity walk) into your
239+
plugin's own test suite, using `tests/lint/live_verdict_contract.py` in this
240+
repo as the reference implementation.
226241

227242
> A duplicate `criterion_type` **overwrites** the earlier checker with a warning (not
228243
> a hard error, unlike agents) — keep type strings unique.

docs/TASK_DEFINITION_GUIDE.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,26 @@ Semantics:
468468
before weighting) — only the combination rule (weighted average vs strict
469469
AND) changes, which is what makes the `gate_threshold=1.0` default an exact
470470
equivalence with the strict `all(...)` rule.
471+
- **Why the bounds stop at the armed subset (design decision).** The
472+
ceiling/floor rule deliberately does **not** extend to unarmed or
473+
non-observable criteria (`llm_judge`, `reference_comparison`, the file
474+
checks). A mid-run score bound for those would require either scoring the
475+
unfinished sandbox (the end-state peeking `live_verdict` forbids by
476+
construction — it reads only `turn_records`) or re-running judges on every
477+
tool call (expensive, and judge scores over a partial trajectory are not
478+
monotonic — exactly the false-stop risk the bound design exists to rule
479+
out). So a non-observable criterion's bound can never tighten past the
480+
vacuous `[0, 1]` — and folding permanently-vacuous bounds into the gate
481+
degenerates to "never stop": an undecided criterion holds the ceiling up
482+
(suppressing every fail-stop) and the floor down (vetoing every pass-stop)
483+
for the whole run. Scoping the gate to the armed subset is therefore not a
484+
simplification but the design: **arming is the author's declaration of
485+
which criteria the smoke verdict is allowed to hinge on**, and the
486+
authoritative full-set score always comes from the kill-switched run. If a
487+
run should end early on overall-score grounds, arm the observable criteria
488+
with appropriate `weight`s and lower `stop_early_gate_threshold` — that is
489+
the weighted-score break, expressed over the subset that can actually
490+
decide mid-run.
471491
- **Decision-step timeout.** `stop_early: {decide_within: N}`. If the
472492
criterion is still **undecided**
473493
after N tool-call steps, the watcher latches an **effective fail** for it and

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,9 +184,9 @@ external = [
184184
"CE018",
185185
"CE033",
186186
"CE035",
187-
"CE036",
188187
"CE037",
189188
"CE038",
189+
"CE039",
190190
] # custom architectural lint rules (tests/lint/)
191191

192192
[tool.ruff.lint.pylint]

src/coder_eval/criteria/base.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,16 @@
3737
# (early_stop.py::_prev_verdicts) are correct only because both existing
3838
# implementations (skill_triggered, command_executed) honor this. A non-monotonic or
3939
# non-deterministic override compiles and passes CE025 (which only checks
40-
# LiveSuccessCriterion subclassing / live_verdict pairing, not this) but silently corrupts the stop
41-
# logic — there is currently no automated enforcement beyond this docstring.
40+
# LiveSuccessCriterion subclassing / live_verdict pairing, not this) but silently corrupts
41+
# the stop logic.
42+
#
43+
# ENFORCEMENT: lint rule CE036 (tests/lint/live_verdict_contract.py) replays every live
44+
# criterion against every prefix of recorded trajectories and asserts both properties —
45+
# monotonicity over arbitrary Python is undecidable, so replay is the only sound check.
46+
# Adding a LiveSuccessCriterion REQUIRES adding ContractCase fixtures for it in the same
47+
# change (CE036 fails on a live type with no cases, and on a polarity its instances claim
48+
# decidable but no fixture reaches). Note the limit: CE036 proves the contract on the
49+
# trajectories an author supplied, not in general — honoring it is still on the author.
4250
LiveVerdict = Literal["pass", "fail", "undecided"]
4351

4452

@@ -432,8 +440,9 @@ def live_verdict(
432440
source of truth for "is this criterion type live-observable", checked
433441
by ``validate_early_stop`` / ``EarlyStopWatcher`` and enforced by lint
434442
rule CE025. An override MUST also satisfy the deterministic + monotonic
435-
contract documented on the ``LiveVerdict`` type above (not enforced by
436-
CE025 or any other automated check).
443+
contract documented on the ``LiveVerdict`` type above, enforced by lint
444+
rule CE036 — which requires this criterion type to supply replay fixtures
445+
(``tests/lint/live_verdict_contract.py::CASES``) in the same change.
437446
"""
438447
return "undecided"
439448

src/coder_eval/criteria/reference_comparison.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,10 @@ def _check_impl(
9999
try:
100100
agent_code = sandbox.get_file_content(criterion.agent_file)
101101
except FileNotFoundError:
102-
# CE038 exemption: genuinely the AGENT's failure, unlike reference_file
102+
# CE039 exemption: genuinely the AGENT's failure, unlike reference_file
103103
# above: the task asked for this file and the agent did not produce
104104
# it, which is exactly what a gating 0.0 means.
105-
return CriterionResult( # noqa: CE038
105+
return CriterionResult( # noqa: CE039
106106
criterion_type="reference_comparison",
107107
description=criterion.description,
108108
score=0.0,

tests/_fixtures/live_criteria.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Shared builders for live-criterion trajectories (early-stop tests + CE036).
2+
3+
``tests/test_early_stop.py`` (the watcher's behavioral suite) and
4+
``tests/lint/live_verdict_contract.py`` (the CE036 contract-replay fixtures) both
5+
hand-build ``CommandTelemetry``/``TurnRecord`` trajectories for the same two live
6+
checkers. The primitives live here so a telemetry field addition is threaded
7+
through once; the *criterion* builders deliberately stay in each file — they
8+
encode different defaults (armed with ``stop_early`` blocks vs unarmed contract
9+
instances) and sharing them would just move the divergence into keyword soup.
10+
11+
The timestamp is frozen: CE036's determinism replay requires fixtures that carry
12+
no nondeterminism of their own, and the watcher tests never read wall-clock off
13+
telemetry either.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
from datetime import datetime
19+
from typing import Any, Literal
20+
21+
from coder_eval.models import CommandTelemetry, TurnRecord
22+
23+
24+
FROZEN_TS = datetime(2026, 1, 1, 0, 0, 0)
25+
26+
27+
def make_command(
28+
tool_name: str,
29+
parameters: dict[str, Any],
30+
*,
31+
tool_id: str | None = None,
32+
sequence_number: int = 0,
33+
result_status: Literal["success", "error", "unknown"] = "success",
34+
) -> CommandTelemetry:
35+
"""One recorded tool call. ``tool_id`` defaults to ``tool-<sequence_number>``."""
36+
return CommandTelemetry(
37+
tool_name=tool_name,
38+
tool_id=tool_id if tool_id is not None else f"tool-{sequence_number}",
39+
timestamp=FROZEN_TS,
40+
parameters=parameters,
41+
result_status=result_status,
42+
sequence_number=sequence_number,
43+
)
44+
45+
46+
def make_turn(*commands: CommandTelemetry, iteration: int = 1) -> TurnRecord:
47+
return TurnRecord(iteration=iteration, user_input="", agent_output="", commands=list(commands))

0 commit comments

Comments
 (0)