Skip to content

refactor(cli): bad input exits 2 through one InputError - #316

Open
napetrov wants to merge 32 commits into
intel:mainfrom
napetrov:refactor/artifact-load-or-fail
Open

napetrov wants to merge 32 commits into
intel:mainfrom
napetrov:refactor/artifact-load-or-fail

Conversation

@napetrov

@napetrov napetrov commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Part of #4.

Summary

Every input loader raises one typed error, and only the frontend turns it into an exit code.

novelty view / routing / shorten, model-compare, catalog, records
    load_*()                      library: no print, no exit
      raises InputError            names the file and the reason
            |
    cli.main()
      prints "Error: <message>"
      exit 2                       bad input

gates (--strict, eval judge-validity)
      exit 1                       verdict: the input was fine, the answer was no

Before, load_*_or_exit helpers called SystemExit from library code, so a bad file and a failed gate both looked like exit 1. Now a script can branch on the code: 2 means fix the input, 1 means the gate said no.

Evidence

  • New table test tests/test_load_or_fail.py::test_a_bad_input_file_is_an_input_error_naming_it, parametrised over every artifact kind plus atoms, judged-run and answers-records (22 loaders) and five inputs (missing, not UTF-8, bad JSON, not an object, wrong schema), 110 cases. Each case asserts pytest.raises(InputError) and that the file path is in the message. On main the atoms and artifact loaders call SystemExit instead, which that assertion rejects.
  • Architecture rule tests/test_import_architecture.py::test_only_the_frontend_ends_the_process: no module outside the frontend may call sys.exit or os._exit, through an import alias too (import sys as s, from sys import exit as bye), or raise SystemExit, except a __main__ guard. EXIT_BASELINE is empty. A planted violation (test_a_process_exit_outside_the_frontend_fails) fails the rule.
  • Exit-code tests tests/test_novelty_atoms_input.py: an unreadable --atoms file through cli.main() exits 2 with Error: and the path on stderr, for novelty probe and novelty shorten, over every malformed shape in the test's table.
  • Local checks on the commit:
    • full suite, --randomly-seed=101 and --randomly-seed=202: 11041 passed, 5 skipped each
    • mypy with the project config: no issues in 286 files
    • mypy --strict on the new module: clean; no strict error lands on a line this PR adds
    • ruff check and pre-commit run on the changed files: pass
    • scripts/check_growth.py: no violation; the three baselines went down (novelty.py 1190 → 1102, model_compare.py 1207 → 1195, cmd_novelty_routing entry removed)

Merge Danger

Door: one-way, for the exit code and the two changed messages. A script that treats any non-zero as "gate failed" will now see 2 for a bad input file.

Blast Radius: cli

  • novelty view, novelty probe, novelty shorten, novelty routing and report model-compare exit 2 instead of 1 on a bad input file (missing, unreadable, not UTF-8, not JSON, not the artifact it names). The stderr line names the file and the reason (Error: cannot read <file>: …, Error: <file> is not valid JSON: …).
  • novelty routing also exits 2 for a refused field (a missing competitor, a skill listed twice or compared with itself, an all-vs-all catalog that holds one skill twice with different content). Those messages name the skill or option, not a file.
  • report model-compare messages: File not found: <file> is now cannot read <file>: No such file or directory; Invalid JSON in <file> is now <file> is not valid JSON.
  • novelty view names the reason for an invalid artifact (is not valid JSON, is not UTF-8 text, is not a novelty artifact: ...) where it said is not a valid novelty artifact.
  • Exit 1 is unchanged for the gates (--strict, eval judge-validity) and for verdicts.
  • subjects run --novelty-artifact exits 2 on a malformed artifact, as before; its Error loading novelty artifact: line now names the reason.
  • No stored format or schema changes. Nothing needs a new flag.

Follow-ups, deliberately not in this PR: report generate and questions analyze still exit 1 on bad input (documented exception); baseline save and list; the README benchmark command; cmd_novelty_probe answer-key refusals other than unloadable (wrong_artifact, files_changed, no_file_rows) still exit 1; the remaining usage-error SystemExit calls in novelty (--all-vs-all, missing --artifact); ProbeFileError in cmd_novelty_items; command-layer exits in judge_reliability and evaluate are kept.

Checklist

  • Tests added/updated and pytest passes
  • ruff, mypy, and schema checks pass
  • Docs updated (or N/A) — see the docs-drift checklist in CONTRIBUTING.md (no doc states the old exit code for these commands)
  • Self-reviewed per "Before opening a PR" in AGENTS.md
  • No ad-hoc run artifacts committed
  • New files carry the SPDX license header (or are exempt in .github/.licenserc.yaml)

🤖 Generated with Claude Code

Input loaders raise dokimi.utils.InputError, a ValueError whose message
names the file. Only cli.main turns it into "Error: <message>" and exit 2
(bad input). Exit 1 stays the verdict of the gates (--strict,
judge-validity), so the exit code now says which kind of failure it was.

- read_json_input(path, *, flag=None) is the one read-or-fail path: a
  missing or unreadable file, non-UTF-8 bytes, and invalid or too deeply
  nested JSON each raise InputError naming the file.
- load_artifact, load_atoms_record, load_artifact_ref, records.load and
  model_compare.load_run raise InputError; the *_or_exit helpers are gone.
  The routing loaders move to commands/novelty_routing_inputs.py.
- tests/test_load_or_fail.py: every artifact kind and the atoms, judged-run
  and answers loaders, over missing, non-UTF-8, bad JSON, non-object and
  wrong-schema inputs.
- tests/test_import_architecture.py: only the frontend ends the process;
  EXIT_BASELINE is empty, and a planted exit outside the frontend fails.

Changelog fragment lists the exit-code and message changes.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
- read_json_input turns a bare ValueError (a NUL byte in the path) into
  InputError, as the other read failures are.
- The all-vs-all field loader lets InputError through unwrapped, so a
  refused skill field keeps its own message.
- novelty view tests pin the reason for each invalid file again.
- exit_sites resolves sys/os aliases (import sys as s; from sys import
  exit as bye), so an aliased exit outside the frontend is counted.
- Changelog: the novelty routing field refusals exit 2 too; the schema
  failure wording matches the code.

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

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
@napetrov
napetrov requested a review from a team as a code owner October 31, 2026 02:09
@exai-bot

exai-bot commented Oct 31, 2026 •

Copy link
Copy Markdown

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

By severity: 4 medium

Severity Category Location Finding
medium bug exit_site_scan.py:329-331 Must-fix: enclosing lookups use deferred-function rules even for eagerly executed class bodies.
medium bug exit_site_scan.py:100 Must-fix: an unaliased dotted import is recorded independently of its root binding.
medium bug exit_site_scan.py:161-162 Must-fix: deleted bindings remain in the table, so a real exit can bypass the architecture gate.
medium bug exit_site_scan.py:306-307 Must-fix: assignment aliases are settled before nonlocal bindings move to their enclosing scope, and are never recomputed afterward.
Review details
  • Commit reviewed: 5957355 against merge base d3e2a26
  • Coverage: 45 files in 7 groups
  • Engine: OpenCodeReview v1.13.3, us.openai.gpt-6.1-sol
  • Usage: 1.16M tokens (661k from cache), 1m51s
  • Tool calls: 101 (78 file_read, 12 code_search, 8 file_read_diff, 3 code_comment)
  • Architecture documentation and exit-site enforcement: 6 files
  • Shared artifact loading and record readers: 7 files
  • CLI output and evaluation error handling: 9 files
  • Model comparison and replicate handling: 5 files
  • Novelty CLI orchestration and view inputs: 7 files
  • Novelty routing inputs and routing behavior: 5 files
  • Novelty artifacts, atoms, and measurement: 6 files

names the file and the reason; for a refused field it names the skill or option at fault.
Exit 1 stays the verdict of the gates
(`--strict`, `eval judge-validity`), so a script that branches on the code must
treat 2 as "fix the input". The same exit and `Error:` line now come from any input

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

documentation · low
Nit (accuracy): “any input file the loaders reject” is broader than the implemented CLI guarantee. subjects run --novelty-artifact &lt;bad-file> calls the artifact loader but catches its InputError via ValueError in commands/subjects.py:113–115, prints Error loading novelty artifact: ..., and exits 1. Restrict this statement to errors that reach cli.main, or name the retained command-handler exceptions, so scripts do not rely on a universal exit-2 contract.

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.

Fixed in a172082. The sentence now says the same exit and Error: line come from the input files those commands reject, and names subjects run --novelty-artifact as the exception: it still prints its own message and exits 1.
The changelog fragment carries the sentence; the code is unchanged.

Comment on lines +438 to +440
guarded = {id(n) for stmt in tree.body
if isinstance(stmt, ast.If) and ast.unparse(stmt.test) == "__name__ == '__main__'"
for n in ast.walk(stmt)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

test · medium
Must-fix: walking the entire ast.If also exempts its else branch, which runs when the module is imported. For example, import sys\nif __name__ == '__main__':\n pass\nelse:\n sys.exit(1) produces no exit sites and passes this rule despite terminating the importing process. Exempt only descendants of stmt.body, and add this case as a regression test.

Suggestion:

Suggested change
guarded = {id(n) for stmt in tree.body
if isinstance(stmt, ast.If) and ast.unparse(stmt.test) == "__name__ == '__main__'"
for n in ast.walk(stmt)}
guarded = {id(n) for stmt in tree.body
if isinstance(stmt, ast.If) and ast.unparse(stmt.test) == "__name__ == '__main__'"
for body_stmt in stmt.body for n in ast.walk(body_stmt)}

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.

Fixed in a172082. The guard exempts only the body of the if __name__ == '__main__' block, not its else, so else: sys.exit(1) is an exit site.
Test: the planted dokimi/e.py case in tests/test_import_architecture.py.

Comment thread dokimi/tests/test_novelty_gate.py Outdated
bad = tmp_path / "novelty.json"
bad.write_text(json.dumps({"schema_version": "novelty.v1"}), encoding="utf-8")
with pytest.raises(ArtifactValidationError):
with pytest.raises(InputError, match=str(bad)):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

test · medium
Must-fix: pytest.raises(..., match=...) treats its argument as a regular expression, not a literal substring. On Windows, str(bad) contains backslashes (for example, C:\Users\...\novelty.json), so regex escapes such as \U can raise re.error instead of matching the InputError. Capture the exception and assert str(bad) in str(exc.value), or pass re.escape(str(bad)).

Suggestion:

Suggested change
with pytest.raises(InputError, match=str(bad)):
with pytest.raises(InputError) as exc:
load_novelty_artifacts([bad])
assert str(bad) in str(exc.value)

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.

Fixed in a172082. The check is assert str(bad) in str(exc.value), so the Windows path is not read as a regex.
Test: tests/test_novelty_gate.py (the artifact-load refusal).

…ent to two lines

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

codecov-commenter commented Oct 31, 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 94.79167% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.32%. Comparing base (d3e2a26) to head (5957355).
⚠️ Report is 6 commits behind head on main.

Files with missing lines Patch % Lines
dokimi/dokimi/commands/novelty_routing_inputs.py 87.30% 3 Missing and 5 partials ⚠️
dokimi/dokimi/commands/novelty.py 95.12% 2 Missing ⚠️
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #316      +/-   ##
==========================================
+ Coverage   94.20%   94.32%   +0.11%     
==========================================
  Files         304      309       +5     
  Lines       38748    39283     +535     
  Branches     6561     6651      +90     
==========================================
+ Hits        36504    37052     +548     
+ Misses       1430     1421       -9     
+ Partials      814      810       -4     
Files with missing lines Coverage Δ
dokimi/dokimi/artifacts.py 95.34% <100.00%> (+0.22%) ⬆️
dokimi/dokimi/commands/_output.py 100.00% <100.00%> (ø)
dokimi/dokimi/commands/arms.py 97.77% <100.00%> (+0.31%) ⬆️
dokimi/dokimi/commands/evaluate.py 90.14% <100.00%> (-0.03%) ⬇️
dokimi/dokimi/commands/explore.py 85.04% <100.00%> (ø)
dokimi/dokimi/commands/judge_reliability.py 93.33% <100.00%> (-0.26%) ⬇️
dokimi/dokimi/commands/novelty_view_inputs.py 100.00% <100.00%> (ø)
dokimi/dokimi/eval/question_text.py 100.00% <100.00%> (ø)
dokimi/dokimi/novelty/artifact.py 100.00% <100.00%> (ø)
dokimi/dokimi/novelty/atoms.py 98.20% <100.00%> (-0.01%) ⬇️
... and 8 more

... and 15 files with indirect coverage changes

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

…s the nesting

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
The else branch of 'if __name__ == "__main__":' runs on import, so only the
guarded body is the process boundary. Exempt stmt.body, not the whole If.

Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
…uiltins

Main's dokimi.exit_codes.usage_exit raises a SystemExit bound to a name, so
the rule saw neither it nor its callers. Count calls to usage_exit (any import
form), 'raise e' with e = SystemExit(...), and builtins.exit/quit; exempt the
helper module itself, as the spawn rule exempts dokimi.process.

Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
pytest.raises(match=) is a regex; a Windows tmp path such as C:\Users\...
raises re.error on the \U escape before the InputError is checked.

Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
@xaleryb

xaleryb commented Oct 31, 2026

Copy link
Copy Markdown
Contributor
  • Ran: the full suite, ruff, mypy, bandit and the benchmark gate on the head plus the commits below, and the architecture tests on a local merge with main.
  • Open, needs the merge with main: the changelog fragment and Merge Danger say novelty and model-compare bad-input refusals "now exit 2 instead of 1". On main they already exit 2 (fix(cli): input and usage refusals exit 2, not 1 (#94 A2) #285's fragment: "The messages are unchanged"). After the merge, what this PR changes is the message text (for example File not found: x becomes cannot read x: No such file or directory, and an Error: prefix is added to the atoms and routing refusals). The only exit-code change is model-compare on a non-object file: a traceback becomes exit 2. Recommendation: describe it as a message change, and drop the Follow-ups main already fixed (subjects --novelty-artifact, probe answer-key refusals, missing --artifact).
  • Pushed:
    • 19b1118: the __main__ exemption covers only the guarded body, so an exit in its else branch is counted.
    • 74be552: count usage_exit callers (any import form), raise e where e = SystemExit(...), and builtins.exit/quit. dokimi.exit_codes is exempt, like dokimi.process in the spawn rule. Without this, after the merge a library call to usage_exit passes the rule.
    • cccb356: match the path literally; a Windows path breaks match=.
  • Left for the author: the merge with main.

continue
if isinstance(node, ast.Raise) and node.exc is not None:
exc = node.exc.func if isinstance(node.exc, ast.Call) else node.exc
if isinstance(exc, ast.Name) and (exc.id == "SystemExit" or exc.id in bound):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

test · medium
Must-fix: the rule silently accepts qualified or imported aliases of SystemExit. For example, a scratch library module containing import builtins\nraise builtins.SystemExit(2) or from builtins import SystemExit as Stop\nraise Stop(2) produces no exit sites, so exit_problems(imports, {}) passes despite the module terminating its caller. _exit_imports does not track SystemExit aliases, and this branch only recognizes the literal name. Resolve qualified/aliased SystemExit constructors in both the raise check and the bound-exception assignment check, and add these cases to the planted-violation tests.

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.

Fixed in a172082. exit_sites resolves from builtins import SystemExit as X, builtins.SystemExit, and a SystemExit bound to a name and then raised.
Tests: planted cases in tests/test_import_architecture.py, which also pass on the merged tree.

@vlad-perevezentsev vlad-perevezentsev 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 cccb356. The design is right: loaders raise InputError naming the file, cli.main is the one place that exits 2, and the architecture test holds library code to that. The branch predates #285, though, so it conflicts with main in 12 files, and the merge decides which exit codes survive. The line comment on commands/novelty.py lists the places to watch.

Agreed with @xaleryb: describe the change as a message change after the merge, since main already exits 2 for these inputs; drop the follow-ups main already fixed.

Agreed with @exai-bot on the over-broad "any input file" sentence in the changelog and on the SystemExit aliases the exit rule misses. Its __main__ else-branch and match= path comments are fixed in 19b1118 and cccb356.

What I ran, at cccb356:

  • make ci-local --offline: 10,990 passed, 0 failed, 57 skipped. schema_compat fails on harness_profile.v1.json, which main widened after this branch's base; this PR does not touch it.
  • Bad-input runs on the merge-base, this head and current main (novelty items --probes, novelty view, report model-compare, report eval, arms plugin-delta with a bad, missing or non-object file): this head exits 2 for novelty view and report model-compare (including a non-object file, a traceback on both base and main); main exits 2 for all of them except that non-object file.
  • This branch's test_import_architecture.py on main's tree: it flags 18 usage_exit calls in four library modules, which the merge has to turn into InputError.
  • exit_sites on aliased SystemExit forms, for the open bot thread.

Checked, no issues

  • InputError as a ValueError: I scanned for loaders that now raise it inside an except ValueError / except Exception. Only arms plugin-delta, subjects run and the answer-key check wrap one, and each handles it as a refusal; this PR does not touch those handlers.
  • Loader matrix: test_load_or_fail.py covers every artifact kind, atoms, judged runs and answer records against a missing file, non-UTF-8 bytes, bad JSON, a non-object and a wrong schema, plus a NUL byte in the path.
  • New files: both carry the SPDX header; the rule is in docs/architecture.md.

Comment thread dokimi/dokimi/commands/novelty.py Outdated
if not verdict.ok:
if verdict.problem == "unloadable": # the artifact cannot be read: bad input, exit 2
raise InputError(verdict.message)
raise SystemExit(verdict.message)

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.

When you merge main, resolve the bad-input exits toward raise InputError, not toward this branch's raise SystemExit(msg): the new architecture test cannot catch the difference.

On main, #285 already made these refusals exit 2 through usage_exit. This branch was cut before it, so in commands/novelty.py it still has 19 SystemExit/sys.exit sites (exit 1) where main has 5. Examples on this head: novelty probe with a stale answer key (here, line 187), novelty shorten with a bundle entry_point_only refuses (line 553), novelty items --probes bad.json (line 243; I ran it: exit 1 here, exit 2 on main). commands/ is the frontend layer, so test_only_the_frontend_ends_the_process passes whichever side you keep. Also keep main's default code=EXIT_USAGE in write_text_or_exit (this branch moves it with code=1).

The library side is caught: I ran this branch's test_import_architecture.py on main's tree, and it fails on 18 usage_exit calls in novelty/artifact.py:48, novelty/atoms.py:672, report/model_compare.py (15) and utils.py:315. Those need to become InputError in the merge.

Suggestion: after the merge, turn main's usage_exit(...) calls in commands/novelty.py into raise InputError(...) too, and add one cli.main test for novelty items --probes <bad file> that expects exit 2.

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.

Fixed in a172082. The merge resolves the bad-input exits in commands/novelty.py to raise InputError, which cli.main maps to exit 2; the usage_exit and SystemExit(msg) sites that were refusals are gone.
The spend-cap exit stays a SystemExit, as it is not an input error. Test expectations moved to InputError in tests/test_novelty_routing.py, tests/test_novelty_journey.py, and tests/test_novelty_cli.py.

Comment on lines +563 to +564
atoms, spans, _record = load_atoms_record(path)
return atoms, spans

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.

Nit: load_atoms now goes through load_atoms_record, so novelty view also runs _check_bundle_shape, though it never reads bundle.

An atoms file with, say, "bundle": {"n_files": "3"} used to render in novelty view; now it is refused with exit 2. That may be what you want (one entry point), but the changelog does not say so.

Suggestion: mention it in the fragment, or keep load_atoms without the bundle check.

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.

Fixed in a172082 (changelog). novelty view now names its bundle check in the fragment: a malformed bundle in an atoms file is refused with exit 2.
That is the one entry point, so load_atoms keeps the check.

return records(run_data.get(section) or [], source=section, kind=section)
except RecordError as exc:
raise SystemExit(f"Error: {exc}") from exc
return records(run_data.get(section) or [], source=section, kind=section)

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.

Nit: The _section docstring still says it returns the records "or a clean exit", but it now lets a RecordError (an InputError) propagate.

Suggestion: "The answers or evaluations records of run_data; a RecordError (an InputError) if malformed." (closest changed line; the docstring is on line 96).

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.

Fixed in a172082. The _section docstring now reads: the answers or evaluations records of run_data; a RecordError (an InputError) if malformed.

Copilot AI balanced review requested due to automatic review settings November 1, 2026 05:40
Resolves the conflicts with main's exit-code work (intel#285). Bad inputs
now raise InputError from the command layer and cli.main maps it to
exit 2, instead of SystemExit(msg) or sys.exit in the commands.

- commands/novelty.py, novelty/measure.py, novelty/atoms.py,
  novelty/artifact.py, report/model_compare.py: refusals raise
  InputError; the spend-cap exit stays a SystemExit.
- commands/_output.py: an unwritable output exits EXIT_USAGE (2).
- commands/arms.py: InputError from the matrix input check propagates
  to cli.main instead of being caught as ValueError.
- tests: the refusal expectations move from SystemExit to InputError
  (test_novelty_routing, test_novelty_journey, test_model_compare_exit_codes,
  test_novelty_cli, test_novelty_routing_all_vs_all, and others).
- test_import_architecture: the exit-site scan also covers
  `from builtins import SystemExit as X` and `builtins.SystemExit`, and
  a SystemExit bound to a name and then raised.
- scripts/growth_baseline.json regenerated.
- changelog: the "any input file" sentence is narrowed to what reaches
  cli.main; the `novelty view` bundle check is named.

Threads: PRRT_kwDOVBg7g86yMjfC, PRRT_kwDOVBg7g86yMjfE, PRRT_kwDOVBg7g86yMjfK,
PRRT_kwDOVBg7g86yOv9G, PRRT_kwDOVBg7g86yVRgk, PRRT_kwDOVBg7g86yVRgn,
PRRT_kwDOVBg7g86yVRgr.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
Comment thread dokimi/dokimi/commands/novelty.py Outdated
Comment on lines +124 to +125
except (ExtractionError, ValueError) as exc: # a model failure under way
raise SystemExit(str(exc)) from None
raise InputError(str(exc)) from None

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 · medium
Must-fix: this also classifies model execution failures as bad user input. For example, with a valid artifact and an extractor transport that raises RuntimeError("service unavailable") on every call, extract_atoms raises ExtractionError, which now reaches cli.main as InputError and exits 2 instead of the previous 1. No input file or flag is invalid in that case. The same conversion occurs in cmd_novelty_atoms and in cmd_novelty_probe for measurement ValueErrors. Keep input-validation refusals as InputError, but use a separate runtime-failure exception handled by the frontend with exit 1; add a CLI-boundary test with a failing extractor transport.

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.

Fixed in fcb6bac. An extraction or measurement failure now exits 1 again (SystemExit, as on main) in analyze, atoms and probe; input refusals such as the self-judge check keep exit 2. Test: test_extraction_failure_exits_1_not_2.

Comment on lines +449 to +451
bound = {t.id for n in ast.walk(tree) if isinstance(n, ast.Assign) and isinstance(n.value, ast.Call)
and isinstance(n.value.func, ast.Name) and n.value.func.id == "SystemExit"
for t in n.targets if isinstance(t, ast.Name)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

test · medium
Must-fix: bound exceptions only recognize the literal SystemExit constructor, so the alias fix still misses from builtins import SystemExit as Stop\nerror = Stop(2)\nraise error and import builtins\nerror = builtins.SystemExit(2)\nraise error. Both produce an empty exit_sites result and let a library process exit pass the architecture rule. Resolve constructor names/attributes using the same alias logic as direct raises when building bound, and add planted cases for both combinations.

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.

Fixed in 8a8ddaa. Names bound to SystemExit under an alias (from builtins import SystemExit as Stop; builtins.SystemExit) are now resolved, so both planted cases are caught; the test adds dokimi/h.py and dokimi/i.py for them.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Both novelty handlers incorrectly map extraction failures to exit code 2 instead of the documented runtime-failure code 1.

2 open issues
What changed in this PR

2 new findings: 1 must-fix and 1 nit.

This PR advances the architecture cleanup in #4 by centralizing input failures under InputError, letting library callers handle exceptions while the CLI reports bad input with exit 2.

Changes:

  • Unifies input-loading errors across artifacts, records, novelty, and model comparison.
  • Extracts routing-input helpers and moves output-exit handling into the command layer.
  • Adds loader and process-exit architecture tests, updates existing assertions, and documents the error boundary.
File Description
dokimi/​tests/​test_replicates.py Expects typed errors for invalid replicate comparisons.
dokimi/​tests/​test_records.py Updates file-error message assertions.
dokimi/​tests/​test_record_readers.py Checks typed record and judge-input refusals.
dokimi/​tests/​test_novelty_view_inputs.py Tests frontend exit codes and detailed input errors.
dokimi/​tests/​test_novelty_shorten_artifact.py Expects typed artifact-loading failures.
dokimi/​tests/​test_novelty_settings_parity.py Adapts parity capture to typed refusals.
dokimi/​tests/​test_novelty_routing.py Updates routing refusal assertions.
dokimi/​tests/​test_novelty_routing_field.py Updates invalid-field error assertions.
dokimi/​tests/​test_novelty_routing_all_vs_all.py Updates all-vs-all refusal assertions.
dokimi/​tests/​test_novelty_journey.py Expects typed answer-key and self-judge errors.
dokimi/​tests/​test_novelty_gate.py Checks invalid artifacts raise named input errors.
dokimi/​tests/​test_novelty_cli.py Updates error assertions and adds an items exit-code test.
dokimi/​tests/​test_novelty_atoms_input.py Exercises malformed atoms through the CLI boundary.
dokimi/​tests/​test_model_compare.py Checks typed loading and comparison errors.
dokimi/​tests/​test_model_compare_exit_codes.py Expects InputError for incompatible runs.
dokimi/​tests/​test_load_or_fail.py Adds a shared malformed-input loader matrix.
dokimi/​tests/​test_import_architecture.py Enforces frontend-only process exits.
dokimi/​tests/​test_eval_output_write_errors.py Uses the relocated output helper.
dokimi/​scripts/​growth_baseline.json Lowers size and complexity baselines.
dokimi/​dokimi/​utils.py Defines the shared input-error and JSON-reading behavior.
dokimi/​dokimi/​runner/​compare.py Makes comparison errors inherit InputError.
dokimi/​dokimi/​report/​model_compare.py Replaces library exits with typed errors.
dokimi/​dokimi/​records.py Unifies record-loading failures under InputError.
dokimi/​dokimi/​novelty/​routing.py Makes routing errors inherit InputError.
dokimi/​dokimi/​novelty/​measure.py Types selected arm-validation refusals.
dokimi/​dokimi/​novelty/​atoms.py Consolidates validated atoms loading.
dokimi/​dokimi/​novelty/​artifact.py Replaces the exiting artifact loader with a raising helper.
dokimi/​dokimi/​eval/​question_text.py Makes empty-question errors inherit InputError.
dokimi/​dokimi/​commands/​novelty.py Adopts typed errors and extracted routing loaders.
dokimi/​dokimi/​commands/​novelty_view_inputs.py Delegates validation to shared loaders.
dokimi/​dokimi/​commands/​novelty_routing_inputs.py Houses routing-field loading and error conversion.
dokimi/​dokimi/​commands/​judge_reliability.py Uses shared input errors and relocated output handling.
dokimi/​dokimi/​commands/​explore.py Imports the relocated output helper.
dokimi/​dokimi/​commands/​evaluate.py Propagates record errors to the frontend.
dokimi/​dokimi/​commands/​arms.py Preserves typed matrix-input refusals.
dokimi/​dokimi/​commands/​_output.py Houses command-level text-writing exit handling.
dokimi/​dokimi/​cli.py Handles unified input errors with exit 2.
dokimi/​dokimi/​artifacts.py Wraps file and schema failures in named input errors.
dokimi/​docs/​architecture.md Documents the input-error boundary.
dokimi/​changelog.d/​20261030_120000_napetrov_arch-loader.md Describes user-facing error handling and validation changes.

Review effort: Balanced

Comment thread dokimi/dokimi/commands/novelty.py Outdated
raise InputError(str(exc)) from exc
except (ExtractionError, ValueError) as exc: # a model failure under way
raise SystemExit(str(exc)) from None
raise InputError(str(exc)) from None

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.

Same fix as the reply above (fcb6bac): ExtractionError exits 1 in both analyze and atoms (line 160), and probe's measurement ValueError too.

runs that disagree on the treatment, prompt or judge), and the skill fields that
`novelty routing` refuses (a missing competitor, a skill listed twice, a skill compared
with itself, an all-vs-all catalog that holds one skill twice with different content),
now exit 2 instead of 1, with `Error: <message>` on stderr. For a bad file the message

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.

Reworded in c03dd38: refusals that already exited 2 are no longer described as a migration. The PR description repeats the same sentence; I have not edited the body.

The merge a172082 re-added dokimi/uv.lock, which intel/main does not
track. It fails the license-header and large-file hooks.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
An extraction or measurement failure is a runtime failure, as it was
before the bad-input change: it exits 1 through SystemExit. Input
refusals (self-judge, bad files) keep exit 2.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
A name bound to SystemExit under an alias (from builtins import
SystemExit as Stop, or builtins.SystemExit) was not recorded, so
error = Stop(2); raise error passed the exit rule.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
Refusals that already exited 2 (model-compare, routing, view) are not
described as a migration; model failures stay at exit 1.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
for a in node.names:
if a.name in _EXIT_MODULES:
module_aliases[a.asname or a.name] = a.name
elif isinstance(node, ast.ImportFrom) and node.module and not node.level:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

test · medium
Must-fix: relative imports bypass this check. A library fixture containing from .exit_codes import usage_exit\nusage_exit('bad')\n has no detected exit sites, so exit_problems passes despite the call raising SystemExit. from . import exit_codes as ec; ec.usage_exit('bad') is missed too. Resolve relative imports using the caller's package before matching _EXIT_CALLS / _EXIT_MODULES, and add planted-violation tests for both forms (including a nested package).

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.

Already at 97059b0, no code change: test_relative_helper_imports_and_a_reused_module_alias_are_counted plants from .exit_codes import usage_exit (a.py) and from . import exit_codes as ec (b.py). Both give exit site [2]. Passes at 565c1fe.

Comment on lines +35 to 36
with pytest.raises(InputError, match=message):
check_run_consistency(runs, "regular")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

test · medium
Must-fix: this test no longer verifies the exit-2 behavior it names. It calls only the library function, so it would still pass if the model-compare command caught InputError and exited 1 or failed to propagate the refusal to cli.main. The existing model-compare CLI test covers only an unwritable output, not these consistency refusals. Exercise these cases through cli.main using temporary run files, and assert SystemExit.code == 2 plus the expected stderr message; retain the InputError assertion as a separate library-contract test if needed.

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.

Fixed in 565c1fe. test_inconsistent_run_files_exit_2_through_cli_main (parametrized) runs report model-compare through cli.main on the refused run pairs. It asserts exit 2, the message on stderr, and no output file.

tests/test_novelty_cli.py grew past the 1000-line test cap (1006), and the
growth ratchet refuses a new entry without a split. The two tests that
drive cli.main (exit codes) move to tests/test_novelty_cli_exit.py.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
if isinstance(node, ast.Import):
for a in node.names:
if a.name in _EXIT_MODULES:
module_aliases[a.asname or a.name] = a.name

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

test · medium
Must-fix: this module-wide alias map lets imports in unrelated functions overwrite each other. For example, def f():\n import sys as s\n s.exit(2)\ndef g():\n import os as s\n s.getcwd() leaves s mapped to os, so exit_sites() returns [] and the architecture rule passes despite f() terminating the process. Resolve aliases in their lexical scope and add this conflicting-local-alias fixture to the regression tests.

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.

Evidence, no code change. The alias map is a set per name, so a conflicting alias over-counts and does not hide an exit. Your s fixture is dokimi/c.py in test_relative_helper_imports_and_a_reused_module_alias_are_counted: f's s.exit(2) gives [3] at 565c1fe.

@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 a5a5d7c. The loaders now raise one InputError, cli.main is the single place that exits 2, and model failures and gates keep exit 1; the bad-input runs I made across novelty, eval, arms, dataset and report model-compare all exit 2 with an Error: line naming the file. Three things are worth fixing before merge: one wrong sentence in the changelog and two groups of exit-code lines that no test pins. Two nits inline.

What I ran, on a Linux review host at a5a5d7c:

  • make ci-local: 30 passed, 0 failed, 7 skipped; changed-line coverage 94%.
  • About 40 bad-input cases through cli.main (missing file, directory, bad JSON, non-UTF-8, NUL in path, wrong shape), on this head and on the base.
  • Mutation runs on the new code and tests; the survivors are in the inline comments.

Checked, no issues

  • Messages: every message the changelog quotes matches the CLI output, and each exits 2.
  • ValueError catches: InputError is a ValueError; I checked each except ValueError that wraps an InputError source. parse_arms and check_arms run before the try blocks in analyze and probe, so none turns into exit 1.
  • Mutations killed: cli.main exiting 1 or not catching InputError, a loader raising SystemExit, dropping the path from a message, and InputError no longer being a ValueError all fail the new tests.
  • Housekeeping: all 13 non-merge commits carry Signed-off-by; the growth baselines match the files.
Bot points I checked that no longer apply at this head (5)
  • The exiting-in-else point from exai-bot on the __main__ guard does not apply: only the if __name__ == '__main__' body is exempt, and the else branch is counted.
  • exai-bot's point on match= and Windows paths in the novelty gate test does not apply: the test asserts str(bad) in str(exc.value).
  • exai-bot's point on aliased SystemExit constructors does not apply: import builtins and from builtins import SystemExit as X forms are counted at this head.
  • exai-bot's point on a bound alias of SystemExit (error = Stop(2); raise error) does not apply: the bound name is resolved at this head.
  • Copilot's point on extraction failures exiting 2 does not apply: novelty atoms and novelty analyze raise SystemExit(msg) for ExtractionError, so a model failure exits 1 (the unpinned branches are in my first inline comment).

Comment thread dokimi/dokimi/commands/novelty.py Resolved
Comment thread dokimi/dokimi/report/model_compare.py Resolved
Comment thread dokimi/dokimi/commands/novelty.py Resolved
Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Comment on lines +464 to +465
bound = {t.id for n in ast.walk(tree) if isinstance(n, ast.Assign) and isinstance(n.value, ast.Call)
and is_system_exit(n.value.func) for t in n.targets if isinstance(t, ast.Name)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

test · medium
Must-fix: annotated assignments bypass the bound-exception check. For error: SystemExit = SystemExit(2)\nraise error, exit_sites() returns []: the binding is an ast.AnnAssign, and constructing SystemExit is not counted as an exit call. Thus the architecture rule accepts a library that terminates its caller. Handle annotated assignments alongside ordinary assignments and add a regression fixture asserting the raise line is detected.

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.

Fixed in 565c1fe. Annotated bindings (error: SystemExit = SystemExit(2)) are read in _exit_bindings. Pinned by test_annotated_and_scoped_bindings_of_a_raised_exit_are_counted_per_scope.

continue
if isinstance(node, ast.Raise) and node.exc is not None:
exc = node.exc.func if isinstance(node.exc, ast.Call) else node.exc
if ((isinstance(exc, ast.Name) and (exc.id == "SystemExit" or exc.id in bound or exc.id in exc_names))

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 · medium
Must-fix: bound combines names from unrelated function scopes, so an ordinary exception can be reported as a process exit. For def f():\n error = SystemExit(2)\n return error\ndef g():\n error = ValueError('bad')\n raise error, the checker reports line 6 even though neither function terminates the process. Same-scope reassignment has the same problem. Resolve a raised variable's binding within its scope rather than treating every occurrence of its name as SystemExit, and add negative fixtures for these cases.

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.

Fixed in 565c1fe. Bindings are per scope, and a raise walks outward through its enclosing scopes (_bound_to_exit). The same test covers a function that binds its own error and another that raises an unrelated one.

@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 97059b0, which fix the two nits from my review:

  • ff8a48b: dokimi/dokimi/commands/novelty.py:185: removed the redundant unloadable branch, so one raise InputError(verdict.message) remains; lowered the file's growth baseline from 1100 to 1098.
  • 97059b0: dokimi/tests/test_import_architecture.py:420: _exit_imports now resolves relative imports of exit_codes and keeps a set of modules per alias name; added a test with the three planted fixtures.

make ci-local at 97059b0: 30 passed, 0 failed, 7 skipped.

Not approving yet. Still open: the three risks from my review.

The exit-site detector resolves `error = SystemExit(2); raise error` per
scope (annotated and per-function bindings included) and is split into
helpers to stay under the complexity cap. Refused novelty and model-compare
inputs are checked through cli.main for exit 2, treatment-arm refusals name
the arm, and a model failure in analyze/probe is checked to exit 1 with its
message. The arch-loader changelog states the subjects run exit as 2.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
if isinstance(node, ast.Import):
for a in node.names:
if a.name in _EXIT_MODULES:
module_aliases.setdefault(a.asname or a.name, set()).add(a.name)

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 · medium
Must-fix: import aliases are collected across unrelated scopes, so a local import can cause compliant code elsewhere to fail the architecture gate. For def f():\n import sys as s\n s.exit(2)\ndef g(s):\n s.exit(), the checker reports both lines 3 and 5, although g may receive an object with a non-terminating exit method. _builtin_exit_names similarly makes a function-local SystemExit as Stop import affect unrelated raise Stop sites. Resolve imported names in their owning scope and respect parameter/reassignment shadowing, with regression tests ensuring only the genuine exit is counted.

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.

Fixed in d3e08f6 (annotation follow-up 43a7726): exit names now resolve per Python scope in tests/test_import_architecture.py.
A function-local import sys as s no longer makes s.exit() in an unrelated g(s) count, and a function-local SystemExit as Stop no longer affects raise Stop elsewhere.
Red first: test_a_name_in_one_scope_does_not_change_what_another_scope_means gave [3, 6] on the old checker, [3] now.

Comment on lines +501 to +503
if id(scope) not in owner: # the module: no enclosing scope
return False
scope = owner[id(scope)]

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 · medium
Must-fix: this lookup does not follow Python's lexical scoping. For error = SystemExit(2)\ndef f(error):\n raise error, exit_sites() reports line 3 even when f receives a ValueError, because parameters are not recorded as local bindings. Conversely, error = SystemExit(2)\nclass C:\n error = ValueError()\n def f(self):\n raise error returns no exit site: the checker consults the class binding, but Python skips class namespaces when resolving a method's bare names, so calling C().f() raises the module-level SystemExit. Account for parameter/local shadowing and skip class namespaces when walking outward from methods; add both regression fixtures.

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.

Fixed in d3e08f6: a parameter shadows a module-level name, and a method no longer sees its class body when it resolves a bare name.
Both fixtures are in that test: def f(error): raise error is not counted, and raise error in a method of a class that sets error = ValueError() counts the module's SystemExit.
Full suite in the worktree: 12159 passed, 28 skipped, 6 xfailed.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…li.main

exit_sites read imports and aliases from the whole file: a function-local
`import sys as s` or `from builtins import SystemExit as Stop` counted in
unrelated scopes, parameters did not shadow module names, and a method saw
its class body. Names now resolve per scope like Python's (parameters,
assignments and imports; class bodies skipped from methods), with a red
regression test.

test_skill_repo_shapes ran the commands directly, so the InputError that
cli.main now maps to exit 2 escaped as a crash (11 CI failures after the
merge of intel/main). The helper goes through cli.main as the console script does.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
Self-review nit on the scope resolution: `x: int` with no value binds x in Python, so
a later `raise x` in that function is not the module's SystemExit.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
_EXIT_MODULES = {m for m, _ in _EXIT_CALLS}


_SCOPE_NODES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

test · medium
Must-fix: comprehension targets are currently recorded as bindings in the enclosing scope. For import sys\n[x for sys in []]\nsys.exit(2)\n, exit_sites(ast.parse(source)) returns [] instead of [3], although Python leaves the module's sys binding intact. Thus an unrelated comprehension can hide a subsequent process exit from the architecture gate. Isolate targets for list/set/dict comprehensions and generator expressions, while evaluating their outermost iterable in the enclosing scope, and pin this example with a regression test.

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.

Fixed in 5629960. Comprehension targets bind in their own scope; the outermost iterable still resolves in the enclosing scope. import sys / [x for sys in []] / sys.exit(2) now returns [3]. Regression fixtures in dokimi/tests/test_exit_sites.py, red before the fix and green after.

node, scope = stack.pop()
for child in ast.iter_child_nodes(node):
owner[id(child)] = scope
stack.append((child, child if isinstance(child, _SCOPE_NODES) else scope))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

test · medium
Must-fix: definition-time expressions are assigned the function's scope, but Python evaluates defaults and decorators in the enclosing scope. For import sys\ndef f(sys=sys.exit(2)):\n pass\n, exit_sites(ast.parse(source)) returns [] instead of [2]: the recorded parameter shadows the module while resolving the default. This lets a library that exits during import pass the new gate. Keep defaults and decorators in the enclosing scope (including async functions and lambda defaults), and add regression fixtures.

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.

Fixed in 5629960. Defaults and decorators resolve in the enclosing scope, for def, async def and lambda. def f(sys=sys.exit(2)) now returns [2]. Fixtures in dokimi/tests/test_exit_sites.py.

kind = "sysexit" if _is_sysexit_expr(n.value, owner[id(n)], n.lineno, table, owner) else "other"
for t in (n.targets if isinstance(n, ast.Assign) else [n.target]):
if isinstance(t, ast.Name):
_bind(table, owner[id(n)], t.id, n.lineno, kind)

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 · medium
Must-fix: assignments declared global or nonlocal are still recorded as local bindings, allowing real library exits to pass the architecture gate. For import sys\ndef f():\n global sys\n sys.exit(2)\n sys = None\n, exit_sites(ast.parse(source)) returns [] rather than [4]: the later assignment makes _resolve treat sys as an unbound local despite the global declaration. The same problem occurs with nonlocal aliases of SystemExit. Resolve declared binding scopes before recording assignments/imports and looking up names, and add fixtures for both declarations.

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.

Fixed in 5629960. Names declared global or nonlocal resolve to the declared scope, so a later assignment no longer makes them local. Both declarations have fixtures in dokimi/tests/test_exit_sites.py; global sys / sys.exit(2) / sys = None now returns [4].

Comment on lines +521 to +522
if scope is use:
found = [b for b in found if b[0] <= lineno] or [(lineno, "other", None)]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

test · medium
Must-fix: a class body does not treat a name assigned later as an unbound local; it falls back to the enclosing scope until that assignment executes. For import sys\nclass C:\n sys.exit(2)\n sys = None\n, this lookup substitutes an other binding because the class assignment is below the use, so exit_sites(ast.parse(source)) returns [] instead of [3]. The module actually exits while defining C, but the architecture gate passes. When the current scope is a class and there is no preceding binding, continue resolving in the enclosing scope instead of returning the function-local unbound sentinel, and add this fixture.

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.

Fixed in 5629960. In a class body with no earlier binding, lookup continues in the enclosing scope, as Python does. import sys / class C: / sys.exit(2) / sys = None now returns [3]. Fixture in dokimi/tests/test_exit_sites.py.

@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 43a7726. The scope-aware exit rule and the new cli.main tests are a good step. The merge still waits for a red CI check, one changelog sentence and the legacy-format model-compare refusals, listed below. One nit inline.

What I ran, on a Linux review host at 43a7726:

  • make ci-local: 29 passed, 1 failed, 7 skipped. The failure is Comment blocks (inline comment). 12,128 tests passed, 59 skipped, 6 xfailed; changed-line coverage 96%.
  • Mutation runs on the InputError raises in report/model_compare.py and the model-failure branches in commands/novelty.py: the novelty branches and the new-format extract_scores raises are killed; two survive (below).
  • exit_sites on the fixtures from the open bot comments, with the LLM transport mocked where a model is involved.

I agree with @xaleryb that the changelog misstates the exit change. New evidence: at the PR's base, novelty view, probe --atoms (missing and non-object), shorten --atoms and items --probes on a non-object file already exit 2; the changelog still says the novelty file refusals "exited 1 before" (lines 11-12).

Still open from my earlier review:

  • dokimi/dokimi/report/model_compare.py:528 and :535: the legacy-format (no evaluations key) --treatment-arm not found / multiple non-baseline arms raises are still not pinned. Changing either to raise SystemExit(...) leaves the 106 test_model_compare* tests green, so a legacy run would exit 1 instead of 2. The new-format twins at :478 and :485 are killed. Add one cli.main case with a run file that has no evaluations key.

Checked, no issues

  • Model failures: changing the SystemExit(str(exc)) branches in analyze, atoms and probe to InputError fails a test each.
  • cli.main: exiting 1 instead of 2 on InputError fails the novelty and model-compare suites.
  • subjects run --novelty-artifact: the changelog now says it keeps exit 2, which matches the code.
  • Commits: all 18 non-merge commits carry Signed-off-by.
Bot points I checked that no longer apply at this head (13)
  • exai-bot's point on an exit in the else of the __main__ guard does not apply: only the if body is exempt and the else branch is counted.
  • exai-bot's point on match= and Windows paths in the novelty gate test does not apply: the test asserts str(bad) in str(exc.value).
  • exai-bot's point on aliased SystemExit constructors does not apply: the builtins.SystemExit and from builtins import SystemExit as X forms are counted at this head.
  • exai-bot's point on model failures exiting 2 does not apply: novelty analyze, atoms and probe raise SystemExit(msg) for a model failure, and a cli.main test pins exit 1 for analyze and probe.
  • exai-bot's point on a bound alias of SystemExit (error = Stop(2); raise error) does not apply: the bound name is resolved at this head.
  • Copilot's point on extraction failures exiting 2 does not apply: they exit 1 with the model's message at this head.
  • exai-bot's point on relative imports of usage_exit does not apply: from .exit_codes import usage_exit and from . import exit_codes as ec are counted.
  • exai-bot's point on test_model_compare_exit_codes.py not checking exit 2 does not apply: the refusals now run through cli.main and assert exit 2, stderr and no output file.
  • exai-bot's point on conflicting function-local import aliases hiding an exit does not apply: f's s.exit(2) is still counted when g imports os as s.
  • exai-bot's point on annotated assignments of SystemExit does not apply: error: SystemExit = SystemExit(2); raise error is counted.
  • exai-bot's point on bindings shared between unrelated functions does not apply: bindings are per scope and a ValueError raised in another function is not counted.
  • exai-bot's point on aliases collected across unrelated scopes does not apply: a function-local import sys as s no longer makes s.exit() in g(s) count.
  • exai-bot's point on parameters and class bodies in the lookup does not apply: a parameter shadows the module name and a method does not see its class body.

Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Comment on lines +481 to +482
elif isinstance(n, ast.Name) and isinstance(n.ctx, ast.Store) and id(n) not in assigned:
_bind(table, owner[id(n)], n.id, n.lineno, "other")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

test · medium
Must-fix: annotation-only targets shadow runtime bindings even at module scope. For import sys\nsys: object\nsys.exit(2)\n, this records the annotation as other, so exit_sites() returns [] instead of [3] and the architecture gate accepts a real library exit. Python does not rebind a module or class name for an annotation without a value; only function scopes make that name local. Exclude annotation-only targets from these bindings outside function scopes, and add module/class regression cases alongside the existing function-annotation test.

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.

Fixed in 5629960. Annotation-only targets (sys: object) no longer rebind outside function scopes: import sys / sys: object / sys.exit(2) now returns [3]. Inside a function they still make the name local. Module and class fixtures sit next to the function-annotation test in dokimi/tests/test_exit_sites.py.

"""``x = <SystemExit>`` binds x to SystemExit. In source order, so a later value sees the earlier bindings."""
assigns = [n for n in ast.walk(tree) if isinstance(n, (ast.Assign, ast.AnnAssign)) and n.value is not None]
for n in sorted(assigns, key=lambda n: n.lineno):
kind = "sysexit" if _is_sysexit_expr(n.value, owner[id(n)], n.lineno, table, owner) else "other"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

test · medium
Must-fix: assignment-based exit aliases are recorded as other, so exit_sites(ast.parse("import sys\nstop = sys.exit\nstop(2)\n")) returns [] instead of [3]. The module-alias form (s = sys; s.exit(2)) also escapes detection. This lets a library terminate the process while passing the new architecture gate. Preserve fn and mod bindings when resolving assignment values, and add regression fixtures for both forms (including annotated assignments).

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.

Fixed in 5629960 and 26c33c2. Assignments keep fn and mod kinds: stop = sys.exit / stop(2) returns [3], s = sys / s.exit(2) is caught, annotated assignments included. 26c33c2 lets an enclosing exit-kind binding count after a later non-exit rebinding. Known limit, documented in the exit_sites docstring: after import sys, a later module-level rebinding (except ImportError: sys = None) is read as the rebinding for later module code.

@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 744d212, which fix the nit from my review:

  • 744d212: dokimi/tests/test_import_architecture.py:559: the exit_sites docstring now says names resolve by function scope and lists the five known scoping gaps.

make ci-local at 744d212: 29 passed, 1 failed, 7 skipped (same Comment blocks failure as at the reviewed head).

Not approving yet. Still open: the risk from my review; the earlier thread of mine above nit level; @xaleryb on changelog says the novelty file refusals 'exited 1 before'; at the PR's base they already exit 2, so the entry misdescribes the exit change.

…ugh cli.main

The per-answer (legacy) branch of extract_scores raises InputError for an
unknown --treatment-arm and for several treatment arms, as the evaluations
branch does. Parametrise the cli.main refusal test over both formats: with
the legacy raises changed to SystemExit the new cases fail.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…ed cases

- comprehensions are scopes; the outermost iterable, defaults, decorators,
  annotations, return annotations and class bases are evaluated in the
  enclosing scope
- global and nonlocal declarations move the bindings to the scope they name
- a class body reads an enclosing name until its own assignment runs
- an annotation without a value binds nothing outside a function
- assignments copy what they name: sys.exit, sys, SystemExit, aliases

An enclosing scope is read as its first binding of the name. Known gap kept
and documented: a module-level rebinding after an import, such as except
ImportError: sys = None. The docstring block is cut to two lines (Comment blocks check).

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…r rebinding

A function reads an enclosing name as its last exit-kind binding, so
stop = None; stop = sys.exit; def f(): stop(1) is still an exit site.
Self-review found that reading the first binding missed it.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
test_import_architecture.py is over the 1000-line growth cap with the new
scoping cases. The exit-site tests move unchanged to their own module; the
new cases stay with them. No assertion changes.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
Comment on lines +509 to +510
elif isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
_bind(table, owner[id(n)], n.name, n.lineno, "other")

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 · medium
Must-fix: the definition's name shadows the existing binding before its header is evaluated. For import sys\ndef sys(x=sys.exit(2)):\n pass\n, this records sys as other on line 2, so exit_sites() returns [] rather than [2]. Python evaluates the default before binding the new function, so importing this module actually exits. The same problem affects class sys(sys.exit(2)): and async definitions. Moving headers to the enclosing scope does not fully fix definition-time resolution: exclude the definition's own new binding while resolving its defaults, decorators and bases, and add regression fixtures.

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.

Fixed in b493264: a def/class binds its name after its header, so the default sys.exit(2) is read as the module's sys.
Red-then-green case: import sys\ndef sys(x=sys.exit(2)): expects [2] (test_exit_sites_resolves_names_by_scope_and_source_order).
Head 31247c3.

Comment on lines +526 to +529
if isinstance(expr, ast.Name): # ``stop = exit_alias`` copies the binding it names
b = _resolve(expr.id, scope, lineno, table, owner)
if b is not None and b[1] in ("fn", "mod"):
return b[1], b[2]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

test · medium
Must-fix: unshadowed builtin exit and quit are recognized when called directly, but not when assigned to an alias. For stop = exit\nstop(2)\n, _resolve('exit', ...) returns None, so this branch records stop as other and exit_sites() returns [] instead of [2]. The same applies to quit, allowing a library process exit to pass the new architecture gate. Recognize these unbound builtin names as fn bindings here (while retaining shadowing behavior), and add fixtures for both aliases and a shadowed builtin.

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.

Fixed in b493264: stop = exit and stop = quit now resolve to the unshadowed builtins, so stop(2) is recorded.
Cases: stop = exit\nstop(2) -> [2]; stop = quit\nstop(2) -> [2]; exit = print -> [] (shadowed, stays silent).
Head 31247c3.

Comment on lines +544 to +545
for n in sorted(assigns, key=lambda n: n.lineno):
kind, payload = _value_binding(n.value, owner[id(n)], n.lineno, table, owner)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

test · medium
Must-fix: processing assignments once in source order misses enclosing bindings established after a function definition. For import sys\ndef f():\n stop = end\n stop(2)\nend = sys.exit\nf()\n, this records stop as other before end is recorded, so exit_sites() returns [] although calling f() exits at line 4. The same gap affects aliases of a later-bound SystemExit. Resolve enclosing assignment dependencies after their bindings are populated (e.g. a fixed-point pass), and add regression fixtures for both cases so the architecture gate rejects these library exits.

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.

Fixed in b493264: assignments are resolved to a fixed point, so an alias of a name bound later at module level is read as its exit-kind binding.
Case: import sys\ndef f():\n stop = end\n stop(2)\nend = sys.exit\nf() -> [4].
Head 31247c3.

@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 d78866b. The commits since my last review fix both open threads: the legacy-format --treatment-arm refusals now exit 2 through cli.main, and the Comment blocks check passes. The merge still waits for one changelog sentence, which was raised earlier and is unchanged.

I agree with @xaleryb that the changelog misstates the exit change (line 12). New evidence: the novelty file refusals already exit 2 at the PR's base; only the message changes.

exai-bot's three later points on exit_sites (a def or class whose header reads the name it rebinds, an alias of the builtin exit or quit, and an enclosing binding made after the function) are real gaps in the test helper, but only a hand-built module reaches them, so they need not hold the merge.

What I ran, on a Linux review host at d78866b:

  • make ci-local: 30 passed, 0 failed, 7 skipped; 12,134 tests passed, 59 skipped, 6 xfailed; changed-line coverage 97%.
  • Mutation runs: each of the four raise InputError in extract_scores changed to raise SystemExit(...) fails test_model_compare_exit_codes.py, alone and together. 37 mutants of the exit-site resolver: 12 pass the full suite, all in helper branches that only a hand-built module reaches.
  • 54 further fixtures through exit_sites (aliases, from sys import exit, lambdas, async def, os._exit, walrus, with ... as sys): every ordinary call or raise of an exit is found.
  • Bad-input runs for 18 novelty commands and inputs on the base and on this head, with the model transport blocked.

Checked, no issues

  • Commits: all 23 non-merge commits carry Signed-off-by.
  • Treatment-arm refusals: the legacy and new formats both exit 2 with the message on stderr.
  • Real tree: EXIT_BASELINE is empty and the exit rule passes.
Bot points I checked that no longer apply at this head (19)
  • exai-bot's point on an exit in the else of the __main__ guard does not apply: only the if body is exempt.
  • exai-bot's point on match= and Windows paths in the novelty gate test does not apply: the test asserts str(bad) in str(exc.value).
  • exai-bot's point on builtins.SystemExit and aliased SystemExit raises does not apply: both forms are counted at this head.
  • exai-bot's point on novelty analyze/atoms/probe model failures exiting 2 does not apply: they raise SystemExit(msg) and exit 1, pinned through cli.main.
  • exai-bot's point on a bound alias (error = Stop(2); raise error) does not apply: the bound name is resolved at this head.
  • Copilot's point on extraction failures exiting 2 does not apply: they exit 1 with the model's message at this head.
  • exai-bot's point on relative imports of usage_exit does not apply: from .exit_codes import usage_exit and from . import exit_codes as ec are counted.
  • exai-bot's point on test_model_compare_exit_codes.py not checking exit 2 does not apply: the refusals run through cli.main and assert exit 2, in both the new and the legacy format.
  • exai-bot's point on conflicting function-local import aliases hiding an exit does not apply: resolution is per scope at this head.
  • exai-bot's point on annotated assignments of SystemExit does not apply: error: SystemExit = SystemExit(2); raise error is counted.
  • exai-bot's point on bindings shared between unrelated functions does not apply: bindings are per scope at this head.
  • exai-bot's point on aliases collected across unrelated scopes does not apply: a function-local import sys as s no longer makes s.exit() in g(s) count.
  • exai-bot's point on parameters and class bodies in the lookup does not apply: a parameter shadows the module name and a method does not see its class body.
  • exai-bot's point on comprehension targets does not apply: they bind in their own scope, and [x for sys in []] followed by sys.exit(2) is counted.
  • exai-bot's point on defaults and decorators does not apply: they resolve in the enclosing scope, and def f(sys=sys.exit(2)) is counted.
  • exai-bot's point on global and nonlocal does not apply: declared names resolve to the declared scope.
  • exai-bot's point on class-body fall-through does not apply: a class body with no earlier binding resolves in the enclosing scope.
  • exai-bot's point on annotation-only names does not apply: outside function scopes they no longer rebind the name.
  • exai-bot's point on stop = sys.exit and s = sys aliases does not apply: both keep their exit kind and are counted.

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

# Conflicts:
#	dokimi/scripts/growth_baseline.json

def _assignment_bindings(tree: ast.Module, owner: dict[int, ast.AST], table: ExitTable) -> None:
"""``x = <value>`` binds x to what the value is. In source order, so a later value sees the earlier bindings."""
assigns = [n for n in ast.walk(tree) if isinstance(n, (ast.Assign, ast.AnnAssign)) and n.value is not None]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

test · medium
Must-fix: assignment expressions lose exit aliases. For import sys\nif (stop := sys.exit):\n stop(2)\n, exit_sites(ast.parse(source)) returns [] instead of [3]: the NamedExpr target is recorded as other by _shadowing_bindings, and this assignment pass never updates it. Consequently the architecture test accepts a real process exit outside the frontend. Include ast.NamedExpr in value-binding analysis and add a regression fixture; for named expressions inside comprehensions, bind their targets in the enclosing scope as Python does.

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.

Fixed in b493264: the walrus target is bound, so if (stop := sys.exit): makes stop(2) a site.
Case: import sys\nif (stop := sys.exit):\n stop(2) -> [3] (in the parametrized test).
Head 31247c3.

Comment on lines +559 to +560
if target is not None and target is not scope:
table.setdefault((id(target), name), []).extend(table.pop((id(scope), name), []))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

test · medium
Must-fix: moving bindings does not make uses obey global declarations. For import sys\ndef outer(sys):\n def inner():\n global sys\n sys.exit(2)\n return inner\n, exit_sites(ast.parse(source)) returns [], although outer(None)() exits: _resolve still walks through outer and selects its parameter instead of the module import. The same lookup problem applies to a globally declared SystemExit alias. Record declaration targets and consult them during resolution, with a regression fixture for a global name shadowed by an enclosing function parameter.

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.

Fixed in b493264, with a follow-up in 60a07d5 and 31247c3: global and nonlocal now redirect the lookup to the declared scope, past the parameter that shadows the name.
Case: import sys\ndef outer(sys):\n def inner():\n global sys\n sys.exit(2) -> [5].
Head 31247c3.

Comment on lines +601 to +603
before = [b for b in found if b[0] <= lineno]
if before:
return sorted(before, key=lambda b: b[0])[-1]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

test · medium
Must-fix: binding resolution loses statement order within a line. For import sys; stop = sys.exit; stop(2); stop = None, exit_sites(ast.parse(source)) returns [] instead of [1]: both assignments qualify as preceding the call, and the final other binding wins. The same problem affects module aliases and raised SystemExit aliases. Track column positions (and resolve assignment values before binding their targets), and add same-line regression fixtures so the architecture gate rejects these library-side exits.

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.

Fixed in b493264: bindings carry their (line, col) position, and a use reads the last binding before it.
Case: import sys; stop = sys.exit; stop(2); stop = None -> [1]; the module-alias and SystemExit-alias variants are in the same test.
Head 31247c3.

A def or class name binds after its header, so its own defaults see the
enclosing name. Builtin exit and quit can be aliased. A walrus binds its
target. A global or nonlocal declaration sends the lookup to the scope
that binds the name. Bindings in one statement count by column, and an
alias of a later module binding resolves once the assignments settle.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
_declared_scopes ran before _assignment_bindings, so a nonlocal whose binder was a
plain assignment found no binder. Global declarations still run first; nonlocal
ones now run after the assignments are bound.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…xit-site resolver

A second global declaration of a name in one function re-added its redirect to the
module, and the lookup looped on it. A scope that only declares a name nonlocal
binds nothing, so the binder search now skips it and reaches the enclosing binder.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
_bind(table, owner[id(n)], n.name, _at(n.body[0]), "other")
elif isinstance(n, ast.ExceptHandler) and n.name:
_bind(table, owner[id(n)], n.name, _at(n.body[0]), "other")
elif isinstance(n, ast.Name) and isinstance(n.ctx, ast.Store) and id(n) not in assigned:

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 · medium
Must-fix: structural-pattern captures are missing from the binding table because their names are strings, not ast.Name(Store) nodes. For import sys\ndef f(value):\n match value:\n case sys:\n sys.exit(2)\n, the detector reports line 5 even though sys is the captured argument, not the imported module. This can reject valid library code calling an unrelated object's exit method. Record MatchAs.name, MatchStar.name, and MatchMapping.rest as shadowing bindings, with regression tests.

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.

Fixed in 5957355. Match captures (case x:, [*x], {**x}) now bind their name after the pattern (_capture_bindings in tests/exit_site_scan.py), so sys.exit(2) in a case body that captured sys is not counted. Red cases are in the parametrized list of test_exit_sites_resolves_names_by_scope_and_source_order.

elif isinstance(n, ast.Name) and isinstance(n.ctx, ast.Store) and id(n) not in assigned:
# ``x: int`` binds nothing at run time outside a function, where it makes x local
if id(n) not in bare or isinstance(owner[id(n)], _FUNC_NODES):
_bind(table, owner[id(n)], n.id, _after(n), "other")

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 · medium
Must-fix: loop targets are recorded as bound before Python evaluates the iterable. For import sys\nfor sys in sys.exit(2):\n pass\n, exit_sites(ast.parse(source)) returns []: the target's position precedes the call, so it shadows the import. Python actually exits on line 2 before assigning the target. Augmented assignments such as import sys\nsys += sys.exit(2)\n have the same problem. Record these bindings after their iterable/RHS evaluation and add regression cases so the architecture gate cannot silently accept these exit sites.

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.

Fixed in 5957355. for, with and augmented-assignment targets bind after the iterable, context manager or value (_late_targets). for sys in sys.exit(2) and sys += sys.exit(2) now count their line. Red cases added to the same test.

Comment on lines +575 to +576
for t in targets:
if isinstance(t, ast.Name):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

test · medium
Must-fix: unpacked aliases silently bypass the new architecture gate. For import sys\nstop, = (sys.exit,)\nstop(2)\n, exit_sites() returns [], although line 3 exits the process. _assigned ignores tuple/list targets, while _shadowing_bindings classifies their names as other. Resolve matching tuple/list literal values into their targets (including nested unpacking), and add regression cases for exit callables, module aliases, and SystemExit aliases.

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.

Fixed in 5957355. A name unpacked from a literal tuple or list of the same shape keeps the callable or SystemExit it is given (_pairs), so stop, = (sys.exit,) and nested a, (b, c) = 1, (sys.exit, 2) count. Starred and arity-mismatched targets still bind as other names; cases added.

Comment on lines +684 to +685
if not isinstance(use, ast.ClassDef): # a function's local, not bound yet
return (pos, "other", None)

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 · medium
Must-fix: this applies function-local unbound-name rules to module scope too. For raise SystemExit(2)\nSystemExit = ValueError\n, exit_sites(ast.parse(source)) returns [], although Python exits on line 1: the later assignment does not shadow the builtin until it executes. The same bypass affects exit(2)\nexit = print\n and quit(2)\nquit = print\n. Restrict the unbound-local result to function/comprehension scopes and let a module with no preceding binding fall back to builtins; add regression cases for all three names.

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.

Fixed in 5957355. The function-local rule now applies only to function and comprehension uses, so a module-level name read before its own binding falls back to the builtin: raise SystemExit(2) before SystemExit = ValueError and exit(2) before exit = print count. Red cases added.

…_bind's docstring

The four-line comment failed the Static checks comment-block gate.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
test_import_architecture.py was 1035 lines, over the 1000-line test cap with
no baseline. The AST scan that finds exit sites moves to its own module;
the rules and tests stay. Function bodies are unchanged.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…l unpacking and module names before their binding

Four gaps the review found in the exit-site scan:
- match captures (case x:, *x, **x) bind x, so a later exit call through x is not counted
- for, with and augmented assignment targets bind after the iterable, context or value
- a name unpacked from a literal tuple or list (stop, = (sys.exit,)) keeps the callable
- a module-level name read before its own assignment is the builtin, not an unbound local

Red cases added to test_exit_sites_resolves_names_by_scope_and_source_order first.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
pos = _after(node)
if isinstance(node, ast.Import): # ``import sys as s`` binds s; ``import dokimi.exit_codes`` binds that dotted name
for a in node.names:
_bind(table, scope, a.asname or a.name, pos, "mod", frozenset({a.name}))

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 · medium
Must-fix: an unaliased dotted import is recorded independently of its root binding. For import dokimi.exit_codes\ndef f(dokimi):\n dokimi.exit_codes.usage_exit('x')\n, the scanner reports line 3 even though dokimi is the argument, not the imported package. Rebinding the root at module level has the same problem. Resolve the dotted reference through the root name's current binding before recognizing the helper, and add parameter-shadowing and root-rebinding tests.

Comment on lines +161 to +162
elif isinstance(n, ast.ExceptHandler) and n.name:
_bind(table, owner[id(n)], n.name, _at(n.body[0]), "other")

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 · medium
Must-fix: deleted bindings remain in the table, so a real exit can bypass the architecture gate. For SystemExit = ValueError\ndel SystemExit\nraise SystemExit(2)\n, the scanner returns []: it retains the other assignment even though Python deletes it and resolves the final name to builtin SystemExit. Exception targets have the same lifetime issue: after try: 1 / 0\nexcept Exception as SystemExit: pass, Python automatically clears SystemExit, but the scanner keeps its shadowing binding. Track explicit deletion and exception-target cleanup, with regression cases asserting the final raise is counted.

Comment on lines +306 to +307
_assignment_bindings(tree, owner, table)
_declared_scopes(tree, owner, table, "nonlocal")

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 · medium
Must-fix: assignment aliases are settled before nonlocal bindings move to their enclosing scope, and are never recomputed afterward. For import sys\ndef outer():\n x = None\n def set_exit():\n nonlocal x\n x = sys.exit\n def run():\n stop = x\n stop(2)\n set_exit()\n run()\nouter()\n, the scanner returns [], although line 9 exits. While resolving stop = x, the outer binding is still None; moving the exit binding later leaves stop classified as other, so the architecture gate accepts the violation. Resolve nonlocal destinations before settling assignment aliases, or settle both together, and add a regression covering this case (and the equivalent aliased SystemExit raise).

Comment on lines +329 to +331
if found and not here: # a function may run later: its exit-kind binding counts
exits = [b for b in found if b[1] != "other"] or found
return sorted(exits, key=lambda b: b[0])[-1]

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 · medium
Must-fix: enclosing lookups use deferred-function rules even for eagerly executed class bodies. For E = SystemExit\nE = ValueError\nclass C:\n raise E(2)\n, this reports line 4 as a process exit, although Python raises ValueError. The same issue affects enclosing module aliases used by class bodies and eager comprehension bodies. Preserve source-position lookup until crossing a deferred execution boundary, and add regression cases alongside the existing class-body tests.

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.

7 participants