Skip to content

fix(cli): input and usage refusals exit 2, not 1 (#94 A2) - #285

Open
napetrov wants to merge 29 commits into
intel:mainfrom
napetrov:fix/cli-exit-codes-audit
Open

napetrov wants to merge 29 commits into
intel:mainfrom
napetrov:fix/cli-exit-codes-audit

Conversation

@napetrov

Copy link
Copy Markdown
Contributor

Summary

Input and usage refusals exit 2, not 1, per the exit-code table in CLI API v2 §2.6. Exit 1 is kept for a verdict or gate that ran and failed, and for a runtime or model failure. Messages are unchanged. Round 3 narrows each try block to its validation step, so a failure while the operation runs stays 1. Round 4 splits the clone out of repo validate, scan, inspect and budget (malformed source, unpinned remote and manifest 2; clone, fetch or pin resolution 1), keeps catalog trigger-set --strict at 1 as a lint gate, refuses a no-text question before any --rewrite call, and restores main's up-front matrix probe.

exit 0  pass
exit 1  verdict or gate failed (incl. trigger-set --strict); runtime or model failure   (unchanged)
exit 2  usage or input error                               (this PR)
        missing / unreadable / malformed input
        flag combination refused
        output file cannot be written
        benchmark run or preflight: missing credential, unwritable output dir,
          missing or invalid question set or doc source
        benchmark batch: nothing to run
        arms run: bad spec or setup input (validation only)
        repo validate / scan / inspect / budget / fetch / reach: malformed source string,
          unpinned remote, manifest or profile error, or scenario selection (a failed
          clone, fetch or pin resolution is 1)
        catalog trigger-set: duplicate question id, question without text
exit 3  inconclusive                                       (open, see exit_audit)
exit 4  invalid                                            (open, see exit_audit)
exit 5  infra                                              (open, see exit_audit)
dokimi/exit_codes.py                   new: EXIT_USAGE = 2, usage_exit(message)
dokimi/commands/*.py                   SystemExit(<msg>) and sys.exit(1) on input paths -> usage_exit / EXIT_USAGE
dokimi/commands/_output.py             exit_on_unwritable exits 2
dokimi/utils.py                        write_text_or_exit default code = 2
dokimi/commands/arms.py                cmd_arms_run: setup try (_prepare_arms) exits 2, run try (_run_arms) exits 1;
                                       matrix-run: _probe_setup before the first answer; _check_judge_inputs, _build_judge helpers
dokimi/commands/arms_sampling.py       new: _check_sampling_support (static, validation) and _probe_sampling (one model call, run)
dokimi/commands/arms_matrix.py         new: prepare_matrix_cells, every cell set up (2) and probed (1) before any answer
dokimi/commands/benchmark.py           preflight: refusals exit 2, failed check alone 1; _check_doc_source (invalid 2, unreachable 1);
                                       batch with no libraries exits 2
dokimi/commands/repo.py                validate, scan, inspect, budget, fetch: malformed source and unpinned remote 2, clone, fetch or pin resolution 1 (_source_tree);
                                       reach: validation and scenario selection 2, harness run 1; _check_reach_flags helper
dokimi/commands/catalog.py             @field rule resolving for no question: usage_exit (2); TriggerSetInputError: 2;
                                       --rewrite returning nothing: 1; --strict refusal: 1 (lint gate, as main)
dokimi/commands/evaluate.py            _require_ragas back to the main behaviour (exit 1)
dokimi/external/trigger_set.py         new TriggerSetInputError(ValueError) for duplicate ids and no-text questions;
                                       build_trigger_set validates every question before the first rewrite call
dokimi/scripts/growth_baseline.json    lowered entries only (round 3: cmd_benchmark_preflight 17 -> 14,
                                       cmd_catalog_trigger_set 15 -> 14, arms.py::_build_arms_output removed)
dokimi/novelty/{artifact,atoms}.py, dokimi/report/model_compare.py   same rule

Counts by class: 291 table rows on main 114cb6c (284 before round 3, plus 3 round-3 splits and 4 round-4 clone splits: repo.py validate, scan, inspect, budget). Changed 173 rows: IN 171 (of 179), CRED 1, O4 1 (benchmark.py:306, batch). Kept 118: IN 8 (the arms run, rewrite, the five fetch halves and evaluate ragas, all 1 as on main); K2 48 already 2; VER 17 (including catalog.py:224, --strict, which stays 1 as on main); O3 6; O4 12; O5 12; MIX 7; K2O3/K2O4/K2O5 8. The full table is in exit_audit.md.

Test expectations that pinned exit 1 for input paths now pin 2 (exact-line changes plus renames). The two --strict assertions in test_skill_selection.py stay at 1 as on main; test_trigger_set_strict_refusal_exits_1 pins it on the boundary file. Preflight check-failure tests now use a real failure (RuntimeError("doc source unreachable")) so they test the failed-check path. No verdict or gate test changed its code.

Evidence

  • Round 1, before: new parametrized tests on main, 10 failing (all exit 1 or a string code where 2 is required):

    FAILED tests/test_exit_codes_input_errors.py::test_input_or_usage_refusal_exits_2_with_a_message[personas_approve]
    FAILED tests/test_exit_codes_input_errors.py::test_input_or_usage_refusal_exits_2_with_a_message[report_eval_missing]
    ... (8 more: discover, tools preference, report generate, baseline save, novelty routing, library list)
    FAILED tests/test_exit_codes_input_errors.py::test_explore_list_without_a_bundled_task_root_exits_2
    FAILED tests/test_exit_codes_input_errors.py::test_benchmark_preflight_missing_credential_exits_2
    10 failed in 3.39s
    
  • Round 2 (preflight input refusals, @field rule): the updated expectations and new cases fail first on the round-1 head (12 failed; test_benchmark_command_question_set.py, test_skill_selection.py). A failed check alone stays 1, pinned by a guard test.

  • Round 3, red: the new file tests/test_exit_codes_boundaries.py (15 cases: one per boundary above, plus the matrix probe and ragas checks) on the round-2 head: 11 failed, 4 passed (logs/red_round3.log).

  • Round 3, green: targeted run over the new file and the changed test modules: 126 passed (logs/green_round3_targeted.log).

  • Round 4, red: the round-4 tests run against the round-3 head 7b938bc7 (extracted to a scratch copy, PYTHONPATH on that tree): 8 failed, 9 passed in the selected set (logs/r4_red_on_7b938bc7.log). The 8 are the matrix probe ordering, the no-text rewrite call, the four repo reach-split cases, and the strict exit. The four unpinned-remote guards pass on both heads: they pin input behaviour that was already 2.

  • Round 4, green: targeted run over the boundary file and the changed test modules: 220 passed (logs/r3fix_targeted.log).

  • After, all checks on the branch head:

    pytest tests -n 8 (full suite, venv): 9752 passed, 6 skipped in 59.58s   (logs/r4_full_suite.log)
    ruff check: All checks passed!
    mypy dokimi: Success: no issues found in 274 source files
    growth ratchet (scripts/check_growth.py): clean, no baseline change in round 4
    

Merge Danger

Door: two-way. Reverting restores exit 1 on every path; no data or state changes.

Behaviour changes to review:

  • Matrix probe ordering restored to main: arms matrix run sets up and probes every cell before the first answer, as main does. A refused probe exits 1 (main also exits 1) before any answer call: no answer run and no cell artifact. The probe calls are model calls and can still fail (exit 1). Probe calls are one per distinct key (cached per process), no more than main.
  • arms_sampling.py is a new module; arms.py no longer holds the probe.
  • TriggerSetInputError is a new class; duplicate ids and no-text questions exit 2, and a no-text question is refused before any --rewrite call. A --rewrite that returns nothing exits 1 (judgement call, recorded). --strict stays 1 (lint gate).
  • Batch empty selection: benchmark batch with nothing to run was a question (4) in the audit; it now exits 2 as a usage refusal.
  • Doc source split: an invalid doc source is an input refusal (2); an unreachable one is a failed check (1).
  • novelty._refuse_no_queries exits 2 but writes the no_queries artifact before the refusal, so a refused run leaves a file behind. Kept; open whether the artifact should be written only on success.
  • novelty._role_models raises ValueError, not SystemExit; its only callers are the USD estimate path and _clients, both updated.

Open PRs that touch the same files and need a rebase or a merge order (recorded, not blocking):

Found, not fixed (out of §2.6): explore list with a missing --tasks-root while the bundled root exists returns an empty list with exit 0 (dokimi/explore/tasks.py, _task_files). Follow-up.

Open questions (not changed):

  • 3/4/5 sites and MIX sites (novelty.py:122/161/214, repo_run.py:198/271/279, arms.py:148, repo.py:241): mapping them needs a decision on infra vs tool-output failures.
  • Uncaught exception at cli.py:115 still exits 1; §2.6 has no code for it.
  • baseline.py:263 "No baselines found" stays 1 (nothing selected); §2.6 gives 4 or 2.
  • Missing RAGAS extra (evaluate.py:64) stays 1 as on main; §2.6 has no row for a missing extra (2 or 5).
  • open_source "inside a repository; give its root" stays 1 (runtime fetch failure).
  • _probe_sampling with user-supplied unsupported values is a real model call, so a rejected parameter there exits 1, though it is an input problem.
  • Unresolvable pin → 2? A pin that cannot be resolved (pinned local source that is not a git repo, pin absent locally, remote pin missing from origin) stays 1 as on main; whether it is an input refusal (2) is open.
  • repo compare CompareError (repo_run.py:370-372): a bad --baseline, arm selector, --metric or --model stays 1 as on main. It is an input refusal in §2.6 terms, so 2 is the candidate. Open, not changed in this PR.
  • @field rule resolves for no question (catalog.py:213): the refusal (exit 2) comes after the --rewrite calls, so those calls are spent before it. Open.
  • novelty._refuse_no_queries artifact-before-refusal (see above).
  • Pre-existing, not changed (open): answers.py:138, evaluate.py:234, judge_reliability.py:125-128. Their exit codes predate this audit; left as they are for a follow-up.

Blast Radius: CLI exit codes for input refusals across about 25 commands. Scripts that branch on $? -eq 1 for these cases must also accept 2 (called out in the changelog).

Refs #94. Not closing #275: the audit is not complete (3/4/5 and MIX sites are open questions).

🤖 Generated with Claude Code

The exit-code table (CLI API v2 §2.6) gives 2 to a missing, unreadable or
malformed input, a flag combination a command refuses, an output file that
cannot be written, and a benchmark run or preflight with no provider
credential. Exit 1 stays for a verdict or a gate that ran and failed.

Refusals raised as SystemExit(<message>) now go through usage_exit, which
prints the message to stderr and exits 2. Messages are unchanged.
Output-write failures (exit_on_unwritable, write_text_or_exit) exit 2.
Benchmark preflight exits 2 on a missing credential and keeps 1 for its
question-set checks. Verdict, gate, infra and tool-output sites are not
changed; see exit_audit for the open questions.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
Drop the try/except around _role_models in novelty; the callers
already handle ValueError. Tighten the exit-2 tests: assert on stderr
instead of falling back to str(code), give the report-generate case a
readable eval file, rename the non-list evaluations test to exit_2.
Narrow the changelog credential wording to "any missing credential".

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
benchmark preflight exits 2 for a missing credential, an unwritable
output dir, or a missing or invalid question or golden set, the same
inputs benchmark run refuses. A failed check alone (doc source) stays
1. The golden-set check moves to a helper to keep preflight under its
complexity baseline; growth_baseline.json is lowered for novelty.py
and preflight.

The catalog trigger-set @field refusal (a path typo) goes through
usage_exit, so it exits 2.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
Each try block that maps to exit 2 now covers only the input validation
step. A failure while the operation runs (model call, git fetch, harness
run, --rewrite, sampling probe) keeps exit 1, as on main.

- arms run: setup (_prepare_arms) exits 2; run (_run_arms) exits 1. The
  sampling probe runs per matrix cell inside the run loop (arms_sampling.py)
- repo fetch and reach: parse, pin, profile, manifest and scenario
  selection exit 2; git fetch and harness run exit 1
- benchmark batch with no libraries exits 2
- benchmark preflight: an invalid doc source exits 2, an unreachable one 1
- catalog trigger-set: duplicate id and no-text question
  (TriggerSetInputError) exit 2; a --rewrite that returns nothing exits 1;
  the --strict refusal exits 2
- evaluate: missing ragas extra back to main's exit 1 (open question)

Tests: tests/test_exit_codes_boundaries.py (15 cases; 11 red on the
round-2 head, all green), updated strict and preflight expectations.
scripts/growth_baseline.json lowered entries only.

Refs intel#94

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
- repo validate, scan, inspect, budget: parse and pin, manifest and
  profile are usage errors (2); the clone or fetch is a runtime failure
  (1), split out in _source_tree as fetch already was.
- catalog trigger-set --strict is a lint gate: back to 1, as on main.
- trigger-set refuses a question without text before any --rewrite call.
- arms matrix-run sets up and probes every cell before the first answer
  (arms_matrix.prepare_matrix_cells): a refused probe exits 1 with no
  answer run and no cell artifact, as on main.
- Tests: boundary cases for the four repo commands, the matrix probe
  ordering, the no-text rewrite refusal, and the strict exit.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
Pin-resolution failures (pinned local non-git source, pin absent
locally, remote pin missing from origin) stay 1 as on main. The repo.py
comment and the changelog fragment now say: 2 for a malformed source
string, an unpinned remote, manifest or profile errors; 1 for a
clone, fetch or pin resolution. Probe calls are model calls and can
fail, so "no spend" applies to answers only.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
@codecov-commenter

codecov-commenter commented Oct 29, 2026 •

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 88.77756% with 56 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.58%. Comparing base (f0f95a2) to head (f0ef4d0).

Files with missing lines Patch % Lines
dokimi/dokimi/commands/benchmark.py 87.75% 4 Missing and 2 partials ⚠️
dokimi/dokimi/commands/questions.py 60.00% 6 Missing ⚠️
dokimi/dokimi/commands/novelty.py 86.48% 5 Missing ⚠️
dokimi/dokimi/commands/subjects.py 54.54% 5 Missing ⚠️
dokimi/dokimi/commands/arms.py 94.44% 2 Missing and 2 partials ⚠️
dokimi/dokimi/commands/explore.py 50.00% 4 Missing ⚠️
dokimi/dokimi/report/model_compare.py 76.47% 4 Missing ⚠️
dokimi/dokimi/commands/evaluate.py 40.00% 3 Missing ⚠️
dokimi/dokimi/commands/report.py 78.57% 3 Missing ⚠️
dokimi/dokimi/commands/tasks.py 78.57% 3 Missing ⚠️
... and 11 more
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #285      +/-   ##
==========================================
+ Coverage   93.48%   93.58%   +0.09%     
==========================================
  Files         272      275       +3     
  Lines       35363    35513     +150     
  Branches     6001     6002       +1     
==========================================
+ Hits        33060    33235     +175     
+ Misses       1489     1471      -18     
+ Partials      814      807       -7     
Files with missing lines Coverage Δ
dokimi/dokimi/commands/_output.py 100.00% <100.00%> (ø)
dokimi/dokimi/commands/arms_sampling.py 100.00% <100.00%> (ø)
dokimi/dokimi/commands/inputs.py 100.00% <100.00%> (ø)
dokimi/dokimi/commands/novelty_view_inputs.py 100.00% <100.00%> (ø)
dokimi/dokimi/commands/repo.py 97.75% <100.00%> (+1.27%) ⬆️
dokimi/dokimi/commands/repo_run.py 94.76% <100.00%> (+0.80%) ⬆️
dokimi/dokimi/commands/run.py 91.59% <100.00%> (+0.07%) ⬆️
dokimi/dokimi/exit_codes.py 100.00% <100.00%> (ø)
dokimi/dokimi/external/trigger_set.py 94.84% <100.00%> (+0.15%) ⬆️
dokimi/dokimi/novelty/artifact.py 100.00% <100.00%> (ø)
... and 23 more

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread dokimi/dokimi/commands/repo.py Outdated
Comment thread dokimi/changelog.d/20261028_120000_napetrov_fix-cli-exit-codes-audit.md Outdated
Comment on lines +38 to +42
probe(setup)
except ValueError as exc:
# A model call refused: a runtime failure, so the matrix stops with 1 and no answer was paid for.
print(f"Error probing matrix cell {cell.name!r}: {exc}", file=sys.stderr)
sys.exit(1)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same sampling probe refusal now exits 1 in arms but 2 in eval.

judge_reliability._check_judge_temperature calls the same _probe_sampling and exits 2 when the judge rejects --judge-temperature (as docs/benchmarking-and-comparison.md:392 says). Here, and in cmd_arms_run (the probe runs at the top of _run_arms, so it lands in the "Error running arms" branch), the same refusal exits 1. So eval cross-judge --judge-temperature 0.5 with a gpt-5 judge exits 2, and arms run --judge --judge-temperature 0.5 exits 1. A script that reads 2 as "fix your flags" treats the arms case as a run failure.

I see the reason: the probe catches any exception, so a network error also lands here. Could the two paths agree? Either both 2 (the value is the problem in the common case), or both 1 with the eval docs changed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Waiting on a decision before I change this: should the sampling-probe refusal give the same exit code in arms and eval? Context: eval (_check_judge_temperature) exits 2 when the judge rejects --judge-temperature, arms run / arms matrix-run exit 1, and _probe_sampling catches every exception, so a network error lands in the same branch. Options: (A) both 2, and the message says the probe failed and that the setting or the provider may be the cause; (B) both 1, and docs/benchmarking-and-comparison.md:392 is changed to say 1; (C) split by cause, 2 for a provider rejection of the setting and 1 for a transport error, in both paths, which needs the probe to tell the two apart. My recommendation is (A): it makes arms agree with what the docs already promise for eval, and in the common case the value is the problem. No code change until you pick. The thread stays open.

Comment thread dokimi/dokimi/external/trigger_set.py
Comment thread dokimi/dokimi/commands/repo.py
@vlad-perevezentsev

Copy link
Copy Markdown
Contributor

Review at 0cdea25. The split of 2 (validation) from 1 (a run or a gate that failed) is consistent across the commands I checked. One regression needs a fix: repo reach now shows a traceback when the harness binary cannot run. The docs still give exit 1 in two places. The rest are smaller points in the line comments.

What I ran, at 0cdea25:

  • make ci-local --offline: all 17 local jobs pass, 9,701 tests passed, 0 failed, 57 skipped.
  • The two new test files on the merge-base: every case in test_exit_codes_input_errors.py fails there (exit 1, not 2). test_exit_codes_boundaries.py imports the new arms_sampling module, so it cannot run on the base; its asserts check the 1/2 boundary that this PR adds.
  • repo reach with --bin /nonexistent/claude (no model call): a ProfileError traceback, exit 1. On main it printed a message.
  • repo inspect on a missing path, an http:// source, a .. revision and a subdirectory of a repo: 2, 2, 2 and 1.

Checked, no issues

  • usage_exit: it prints the message and raises SystemExit with code 2. When nothing catches it, Python prints nothing more, so the message is not printed twice. Nothing in the package catches SystemExit.
  • Matrix setup: every cell is set up and probed before out_dir is created and before the first answer.
  • benchmark preflight: a missing credential, output dir, question set or golden set gives 2, even next to a failed check. A doc source that cannot be built stays 1.
  • CI scripts: repo-eval.yml only checks for a non-zero status, so it still fails the job.

Comments on lines outside the diff:

  • dokimi/dokimi/commands/tools.py:99-108: Nit: The docstrings of _write_items and _save_result still say "exit 1", but both now exit 2 for an unwritable path.

Suggestion: "exit 2 naming it when it cannot be written" (and say that a schema rejection in _save_result stays 1).

@antonwolfy antonwolfy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review at 0cdea25. The 2-for-validation, 1-for-run split holds in the commands I checked, and the new boundary tests pin most of it. The problems are at the edges: a narrowed try now lets an error escape as a traceback, and some of the new exit codes are not pinned by any test. I'd fix the inline comments and the changelog claim below before merge.

I agree with @vlad-perevezentsev on the repo reach traceback with a missing --bin and on the stale exit codes in two docs. Their questions on the sampling-probe codes, the @field check after --rewrite and a local source inside a repository wait for the author.

What I ran, on a Linux review host at 0cdea25:

  • make ci-local: 26 passed, 2 failed, 7 skipped. 9,753 tests passed and 1 failed; changed-line coverage passes. The test failure is test_reference_checks.py::test_the_regex_limit_is_shared_by_every_pattern_of_one_check, a wall-clock ratio assertion on a loaded machine; the file passes on its own (62 passed). pip-audit fails on the lock files, which this PR does not touch.
  • Reproductions for the code findings, with the LLM transport mocked.
  • Mutation runs of the new exit codes: 4 of the repo mutants and 1 matrix mutant pass the full targeted suite (see the inline comments).
  • All six commits carry a Signed-off-by.

Findings outside the diff or across files:

  1. The changelog and docs claim more than the code does. The fragment lists repo run among the commands whose input refusals exit 2. In repo_run.py only repo report and repo compare changed. repo run with an unknown arm still prints ✗ unknown arm 'none' and exits 1, and repo tasks check with no manifest exits 1 while repo validate on the same missing manifest exits 2. Also outside the diff: docs/repo-evaluation-contract.md:955 and the --static help text (repo.py:422) say an invalid --static document makes repo scan exit 1. A file that is not JSON or cannot be read exits 2 (repo.py:104); only a schema-invalid one exits 1. A script that reads 1 as "bad static output" misclassifies a truncated file. Suggestion: either convert the remaining repo run / repo tasks check refusals or drop them from the fragment, and say in the docs which --static failures give 2 and which 1.

Checked, no issues

  • usage_exit: prints once and exits 2; the message text is unchanged at the converted SystemExit sites I compared.
  • Matrix ordering: every cell is set up and probed before out_dir is created and before the first answer; a probe-order mutant fails the boundary tests.
  • Gates: baseline compare --strict, catalog trigger-set --strict, and a --rewrite that returns nothing stay at 1.
  • Preflight: a refusal gives 2, a failed check alone gives 1; mutants of each branch fail.
  • Repo rules: no generated artifact or exit_audit.md committed, growth baseline only lowered, a changelog fragment is present, no schema changed.

Nit: stale docstrings still say "exit 1" for paths that now exit 2: repo.py:25, questions.py:20, questions.py:44, questions.py:53, library.py:19, library.py:44, catalog.py:142, novelty_view_inputs.py:4, novelty_view_inputs.py:20, subjects.py:279 (with the two in tools.py that @vlad-perevezentsev named). Suggestion: change each to "exit 2".

Comment thread dokimi/dokimi/commands/repo.py Outdated
Comment thread dokimi/dokimi/commands/repo.py Outdated
Comment thread dokimi/dokimi/commands/arms_matrix.py
Signed-off-by: Anton Volkov <antonwolfy@gmail.com>

@antonwolfy antonwolfy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pushed a commit per nit, up to 5612e8d, which fix the nit from my review:

  • 5612e8d: repo.py:25, questions.py:20,44,53, library.py:19,44, catalog.py:142, novelty_view_inputs.py:4,20, subjects.py:279, tools.py:99,108: the docstrings now say exit 2 where the code exits with EXIT_USAGE.

make ci-local at 5612e8d: 25 passed, 3 failed (host: gitleaks download, gitleaks scan, pip-audit), 7 skipped.

GitHub CI has not run on these commits: the branch conflicts with main, and Actions runs no workflow on a conflicting PR. CI will check them once main is merged in.

Not approving yet. Still open: the bug and the three risks from my review; @vlad-perevezentsev on repo reach: ProfileError/SourceError from run_reach (missing --bin) escapes as a traceback; the except at repo.py:271 catches only ReachError (code shows run_reach calls profile.version(), reach.py:311); @vlad-perevezentsev on docs still give exit 1 for baseline save/list bad input (benchmarking-and-comparison.md:793) and benchmark preflight missing golden set (external-content.md:189); the changelog cites an exit-code table that is not in the docs.

@vchamarthi

vchamarthi commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

🔍 OpenCodeReview found 7 issue(s) in this PR.

By severity: 2 medium, 5 low

Severity Category Location Finding
medium test test_exit_codes_boundaries.py:229-230 This test shells out to the real git.
medium test test_exit_codes_input_errors.py:80-81 This assertion cannot tell the intended refusal from any other exit 2.
low test test_novelty_journey.py:210 Nit: the name promises no model call, but nothing asserts it.
low documentation external-content.md:189 This change makes the preflight refusals exit 2.
low bug trigger_set.py:220 Nit: this check also fires for --trigger-set sources, which _trigger_set_sources always passes with the rule @expected_skill.
low test test_arm_runner.py:523-525 Nit: the test only checks SystemExit and ran == [].
low maintainability arms_matrix.py:41-42 Nit: the probe-refusal exit uses a literal 1, while every other exit in this module uses a named constant.
Review details
  • Commit reviewed: f0ef4d0 against merge base f0f95a2
  • Coverage: 83 files in 15 groups
  • Engine: OpenCodeReview v1.13.3, us.anthropic.claude-sonnet-5-5
  • Usage: 2.86M tokens (2.22M from cache), 1m39s
  • Tool calls: 133 (63 file_read, 57 code_search, 6 code_comment, 5 file_find, 2 file_read_diff)
  • Changelog and docs for CLI exit codes: 5 files
  • Exit codes module and shared utilities/output: 5 files
  • Arms commands (arms, matrix, sampling) and related tests: 7 files
  • Answers, baseline, questions, personas commands: 4 files
  • Benchmark command and tests: 2 files
  • Catalog, dashboard, evaluate, explore, inputs, library commands: 7 files
  • Novelty commands and modules: 6 files
  • Repo commands, source, and trigger set: 4 files
  • Report, run, subjects, tasks, tools commands and model compare: 7 files
  • New exit code tests: 2 files
  • Tests for answers/baseline/dashboard/eval/explore/library CLI inputs: 8 files
  • Tests for personas and questions: 7 files
  • Repo-related tests: 8 files
  • Report, run, runner, skill, subjects, tools, ragas, record tests: 10 files
  • Report, run, runner, skill, subjects, tools, ragas, record tests: 1 file
- 1 finding(s) point at lines outside the diff, so they are listed below

test · low

📄 dokimi/tests/test_arm_runner.py (L523-L525)

This line is outside the PR diff, so GitHub cannot hold an inline comment here.

Nit: the test only checks SystemExit and ran == []. It passes if any cell fails setup for any reason, for example the ok cell failing before m-bad is reached. It also still passes if the exit code changes from 2 to 1. Assert exc.value.code == 2 and that stderr names the bad cell (Error building matrix cell 'bad') so the test shows m-bad is what stopped the matrix.

Comment thread dokimi/dokimi/commands/arms.py
Comment thread dokimi/dokimi/commands/novelty.py
Comment thread dokimi/dokimi/commands/repo.py Outdated
Comment thread dokimi/dokimi/commands/repo.py Outdated
Comment thread dokimi/dokimi/report/model_compare.py
Comment thread dokimi/tests/test_benchmark_command_question_set.py
Comment thread dokimi/tests/test_exit_codes_boundaries.py Outdated
Comment thread dokimi/tests/test_exit_codes_boundaries.py
Comment thread dokimi/tests/test_exit_codes_boundaries.py
…model exit 2

check_answer_key's refusal (atoms that do not fit --artifact) and SelfJudgeError
(--judge-model equal to --model) are input refusals, like the other check_*
refusals; both still exited 1 with SystemExit(message). Model failures under way
(ExtractionError, an empty reply) keep 1. novelty probe now checks --arms and
--placebo-text before it builds a client, as analyze does, so an unknown arm
exits 2 before any model call.

Thread: PRRT_kwDOVBg7g86xlNZ0
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…exit 2

The six consistency refusals (mixed or differing judge_context, differing
baseline_arm, differing treatment arms, differing prompt digests, duplicate
replicates without --pool-replicates) raised SystemExit(message), exit 1,
while a missing --treatment-arm in the same module already exited 2.

Thread: PRRT_kwDOVBg7g86xlNu-
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…al refusal next to a failed check

Mutants checked: dropping the ValueError branch, turning a failed doc-source
check into a refusal (exit 2), and exiting 1 or always 2 whatever the
refusals are, each fail a test.

Thread: PRRT_kwDOVBg7g86xlN2F
Thread: PRRT_kwDOVBg7g86xlOKM
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
The check moves from the command into build_trigger_set, right after the
per-question validation and before the rewrite loop, so a mistyped @field path
exits 2 without spending a model call per question.

Tests: the refusal sends no rewrite call and writes no file; "has no text" is
pinned for missing, empty, whitespace, null and non-string text; the --strict
refusal asserts the label_unresolved summary; the arm-spec test passes explicit
argv; the unreachable-source test stubs open_source and asserts the git fetch
message. Mutants checked: dropping .strip(), dropping the isinstance check and
dropping label_unresolved from the --strict condition each fail a test.

Thread: PRRT_kwDOVBg7g86xgoYp
Thread: PRRT_kwDOVBg7g86xlN81
Thread: PRRT_kwDOVBg7g86xlOC8
Thread: PRRT_kwDOVBg7g86xlOQ9
Thread: PRRT_kwDOVBg7g86xlOae
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…the repository root

open_source raised a plain SourceError before any git operation ran, and
_source_tree mapped every SourceError to exit 1. A new SourceArgumentError
subclass marks the one refusal that is a mistyped argument, and _source_tree
exits 2 for it. Clone, fetch and pin failures keep exit 1.

Thread: PRRT_kwDOVBg7g86xgoaW
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…that is not the repository root

Same refusal as the other source commands (a33186e): the mistyped argument
is a usage error here too, and a failed clone or fetch keeps exit 1.

Thread: PRRT_kwDOVBg7g86xgoaW
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…; fix the fragment's claims

docs/quickstart.md gets an "Exit codes" table (0, 1, 2), which the changelog
fragment and exit_codes.py now cite instead of an unlinked "CLI API v2 §2.6".
baseline save/list and benchmark preflight (missing golden set) say exit 2 in
the docs. The --static help and the repo contract say which failures exit 2
(unreadable, not JSON) and which 1 (schema-invalid). The fragment no longer
lists `repo run`, whose input refusals still exit 1, says so, and names the
pinned local path that is not a repository root.

Thread: PRRT_kwDOVBg7g86xgoV9
Review: PRR_kwDOVBg7g88AAAABU5N1-w
Review: PRR_kwDOVBg7g88AAAABU5fRKA
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
Same status as before; the three sites now read the constant like the
neighbouring _write_items and _save_result.

Comment: IC_kwDOVBg7g88AAAABg1eeQg
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…ite_items and _save_result docstrings say exit 2)

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…es to their own file

The round's changes grew novelty.py, test_subjects.py and test_model_compare.py
past their baselines. The model-compare refusals are pinned by one
parametrized test in test_model_compare_exit_codes.py (every case fails when
its site goes back to SystemExit), test_model_compare.py is back to its
size, the forwarded-fields guard scans the arms module's functions in fewer
lines, novelty.py joins message literals that fit one line, and an unused
import in catalog.py is removed.

Thread: PRRT_kwDOVBg7g86xlNu-
Thread: PRRT_kwDOVBg7g86xlNTa
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…ad of the manifest

The manifest handler caught every SourceError as exit 2, which also covered a
failed `git show` (a blob fetched lazily from origin that is gone). Only the
path refusals of the tree (a path that leaves it, a link that goes nowhere,
too many links) raise the SourceArgumentError subclass and exit 2; any other
SourceError is a fetch failure and exits 1, as on main. The two handlers move
into _manifest_or_exit, which also lowers the complexity baselines of
cmd_repo_validate and cmd_repo_scan.

Thread: PRRT_kwDOVBg7g86xiOS9
Thread: PRRT_kwDOVBg7g86xlNfy
Thread: PRRT_kwDOVBg7g86xlNlc
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…ong the exceptions

Docstrings and test comments cite docs/quickstart.md instead of "CLI API v2
§2.6", the quickstart no longer says every command follows the table, and the
TriggerSetInputError and placebo_text_for_arms_or_exit docstrings say what they
raise for and who calls them.

Thread: PRRT_kwDOVBg7g86xgoV9
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…st (intel#94 A2)

repo reach caught every SourceError around the manifest load and the probe
as a usage error, so a failed `git show` (a lazily fetched blob that is
gone) exited 2 where main exits 1. Only a path that leaves the tree
(SourceArgumentError), a bad manifest and a bad scenario selection are
usage errors now; a plain SourceError is a fetch failure. The boundary test
for validate and scan covers reach too.

Thread: PRRT_kwDOVBg7g86xiOS9
Thread: PRRT_kwDOVBg7g86xlNfy
Thread: PRRT_kwDOVBg7g86xlNlc
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
… names its sources

Moving the @field check into build_trigger_set made the library refuse an
@field rule that labels none of a source's questions even when the caller
(the readers' round-trip fixtures) means those questions to stay
unlabelled. The check now runs only when `specs` is passed, as
`catalog trigger-set` does, so the refusal still comes before any
--rewrite call and the library default is unchanged.

Thread: PRRT_kwDOVBg7g86xgoYp
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…t and the changelog fragment

Thread: PRRT_kwDOVBg7g86xiOS9
Thread: PRRT_kwDOVBg7g86xgoYp
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
@napetrov

Copy link
Copy Markdown
Contributor Author

"The changelog and docs claim more than the code does": fixed in 98608b1. The fragment no longer lists repo run and says repo run and repo tasks check still exit 1 for their input refusals (I did not convert them in this PR). docs/repo-evaluation-contract.md and the --static help now say a file that cannot be read or is not JSON exits 2 and a schema-invalid document exits 1. The four unpinned repo/matrix mutants are covered by the inline threads. The :955 line you cite is not in the file at this head; the --static sentence was at :1133.

@napetrov

Copy link
Copy Markdown
Contributor Author

Thanks for the docstring commit. Mapping the open items you list: repo reach traceback in 5494c06; docs exit codes and the exit-code table in 1658728; changelog claims in 98608b1. The sampling-probe question is held for the author.

@napetrov

Copy link
Copy Markdown
Contributor Author

The report is the patch coverage at the earlier head. The repo's gate is changed lines at 80%; make ci-local --offline on this head measures "changed lines measured: 495, covered: 89%". The lines still uncovered are listed in that run's report; I added tests for the sites the reviewers pointed at, not for every one-line conversion.

@napetrov

Copy link
Copy Markdown
Contributor Author

Already fixed in 5bfdcbe by antonwolfy (5612e8d): the _write_items and _save_result docstrings say exit 2. The repo reach traceback, the docs and the repo inspect subdirectory case from this review are answered in their threads.

@napetrov

Copy link
Copy Markdown
Contributor Author

Fixed in f3a6722: the three literal SystemExit(2) sites in tools.py use EXIT_USAGE; behaviour is unchanged. The other findings of this summary are the inline threads answered above.

@napetrov

Copy link
Copy Markdown
Contributor Author

Round 1 on the open items of the "Not approving yet" review (5612e8d). Each is answered in its own thread; here is where each landed.

  • The repo reach traceback with a harness binary that cannot run: 5494c06. It exits 1 with a message again.
  • repo validate / repo scan traceback when the manifest path leaves the tree: f0ef4d0.
  • A pinned local source that is not the repository root: 17052ca. It exits 2 for inspect, validate, scan, budget, fetch and reach.
  • --rewrite calls paid before an unresolved @field rule is refused: f0ef4d0.
  • Docs with the old exit codes, the missing exit-code table, and the changelog claims about repo run and --static: 1658728 and 98608b1.
  • The unpinned exit-2 branches and the matrix setup branch: 3d0497c and 7927ec6.

One item waits for the author: whether the sampling-probe refusal should give the same exit code in arms and eval (the thread asks it with options and a recommendation). repo run and repo tasks check are not converted in this PR; the fragment now says so.


`benchmark run` logs a warning and continues with generated questions only.
`benchmark preflight` treats a missing golden set as an error and exits 1.
`benchmark preflight` refuses a missing golden set and exits 2.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

documentation · low
This change makes the preflight refusals exit 2. The transcript note a few lines below (line 216) still says "It exited 1 here because no API keys were set". A missing credential is a refusal (refuse(...) in cmd_benchmark_preflight), so that run now exits 2. Update that sentence.

Comment on lines +41 to +42
print(f"Error probing matrix cell {cell.name!r}: {exc}", file=sys.stderr)
sys.exit(1)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · low
Nit: the probe-refusal exit uses a literal 1, while every other exit in this module uses a named constant. exit_codes.py defines only EXIT_USAGE, so the failure code is easy to change by accident. Consider adding an EXIT_FAILURE = 1 constant and using it here and in cmd_arms_run / cmd_arms_matrix_run (sys.exit(1)).

chunk = out[first:]
if specs is not None and rule.startswith("@") and chunk and not any(tq.labelled for tq in chunk):
# A typo in the path, not a set of negatives.
raise TriggerSetInputError(f"{specs[index]}: {rule} resolves for none of {len(chunk)} questions")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · low
Nit: this check also fires for --trigger-set sources, which _trigger_set_sources always passes with the rule @expected_skill. A questions-format trigger set in which no question carries expected_skill is valid. Before this change it was read as all unlabelled and only produced a warning. For example, a --format questions output from an all-unlabelled run, fed back in with --trigger-set, now exits 2. The "typo in the path" reasoning does not apply to that case, and the message is misleading. Consider restricting the refusal to --questions sources, or saying in the message that the file has no labels.

Comment on lines +229 to +230
for args in (["init", "-q"], ["add", "-A"],
["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "init"]):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test · medium
This test shells out to the real git. It has no skip when git is missing, so it fails with FileNotFoundError on a host without git. A global commit.gpgsign=true also makes the commit step fail, because only the identity is overridden. Both make the result depend on the host. Add pytest.mark.skipif(shutil.which("git") is None, ...) and pass -c commit.gpgsign=false.

The Windows case is also left out. The source spec C:\...\sub@HEAD goes through rpartition("@") and the location check in RepoSource.parse. Nothing here shows that a drive-letter path is parsed as local on Windows.

Suggestion:

Suggested change
for args in (["init", "-q"], ["add", "-A"],
["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "init"]):
for args in (["init", "-q"], ["add", "-A"],
["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false",
"commit", "-q", "-m", "init"]):

Comment on lines +80 to +81
assert err.strip(), "a refusal must say what is wrong"
assert exc.value.code == 2

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test · medium
This assertion cannot tell the intended refusal from any other exit 2. It only checks that stderr is non-empty and has no usage:. If library list --domain or personas discover started exiting 2 for an unrelated reason, such as a different missing input, every case would still pass. Parametrize each case with a command-specific expected substring, for example the missing file path or the unknown domain name, and assert it appears in err.

The comment above says a refusal may be raised as a string. A string SystemExit has code equal to that string, so == 2 would fail. Either drop that remark or handle it.

assert "is the answering model" in capsys.readouterr().err


def test_probe_refuses_an_unknown_arm_before_any_model_call(tmp_path, skill, stub_models, capsys):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test · low
Nit: the name promises no model call, but nothing asserts it. If the arm check moved back after the client build or the probe wordings, the refusal would still exit 2 with the same message and this test would pass. Assert the scripted transport recorded no call, for example by comparing its call count before and after, or make the stub transport raise.

@exai-bot

exai-bot commented Oct 30, 2026 •

Copy link
Copy Markdown

🔍 OpenCodeReview found 3 issue(s) in this PR.

By severity: 3 low

Severity Category Location Finding
low bug questions.py:184-186 This try wraps all of refiner.refine(...), including the gap-filler model calls.
low other repo_run.py:416-418 The same command now exits 2 for "not a directory" but still exits 1 for CompareError.
low maintainability repo.py:127 json.JSONDecodeError here does nothing.
Review details
  • Commit reviewed: f0ef4d0 against merge base f0f95a2
  • Coverage: 34 files in 9 groups
  • Engine: OpenCodeReview v1.13.3, us.anthropic.claude-sonnet-5-5
  • Usage: 1.71M tokens (1.25M from cache), 1m3s
  • Tool calls: 72 (35 code_search, 30 file_read, 3 file_find, 2 code_comment, 2 file_read_diff)
  • Exit codes module and shared output/utility helpers: 4 files
  • Arms commands (arms, matrix, sampling): 3 files
  • Benchmark, baseline, evaluate, run, orchestrate commands: 5 files
  • Catalog and library commands: 4 files
  • Novelty commands and novelty modules: 4 files
  • Repo commands and source: 3 files
  • Data entity commands (answers, inputs, questions, personas, tasks): 5 files
  • Explore, subjects, tools, report commands and model compare: 5 files
  • External trigger set: 1 file
  • 1 finding(s) point at lines outside the diff, so they are listed below

other · low

📄 dokimi/dokimi/commands/repo_run.py (L416-L418)

This line is outside the PR diff, so GitHub cannot hold an inline comment here.

The same command now exits 2 for "not a directory" but still exits 1 for CompareError. Most CompareError raises in repos/compare.py are bad arguments, not failed verdicts. Examples are a bad arm selector, --pass-k < 1, a treatment that is the baseline, and no cell matching --baseline or --model. CellFileError is an unreadable or malformed input file. The PR's table says flag refusals and unreadable or malformed input exit 2. Either map these to EXIT_USAGE or state in the description why repo compare and repo report are exempt.

Comment on lines 184 to +186
except ValueError as exc: # ArtifactValidationError: the input set cannot be gap-filled
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)
sys.exit(EXIT_USAGE)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · low
This try wraps all of refiner.refine(...), including the gap-filler model calls. Any ValueError raised by model or runtime code (for example, parsing a model response) now exits 2 (usage) instead of 1 (runtime or model failure). That goes against the PR's rule of narrowing each try to its validation step. Catch ArtifactValidationError specifically, or validate the input set before the model calls start.

print(f"✗ {exc}", file=sys.stderr)
sys.exit(1)
_, tree = _source_tree(args)
manifest = _manifest_or_exit(tree, args, guidance_default=True, also_usage=(OSError, json.JSONDecodeError))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · low
json.JSONDecodeError here does nothing. _load_manifest parses YAML and wraps its own failures in ManifestError, so it never raises a JSON error, and --static is read separately in _read_static. The OSError entry is the only live one. The old handler also caught ValueError and RecursionError, and those are no longer caught here. Drop the json.JSONDecodeError entry, or catch what can actually be raised.

Suggestion:

Suggested change
manifest = _manifest_or_exit(tree, args, guidance_default=True, also_usage=(OSError, json.JSONDecodeError))
manifest = _manifest_or_exit(tree, args, guidance_default=True, also_usage=(OSError,))

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants