claude-dev-env 1.95.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) hide show
  1. package/_shared/advisor/CLAUDE.md +2 -2
  2. package/_shared/advisor/advisor-protocol.md +20 -20
  3. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +15 -12
  4. package/_shared/advisor/scripts/model_tier_run_validator.py +11 -10
  5. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +25 -19
  6. package/_shared/advisor/scripts/tests/test_tier_model_ids.py +17 -17
  7. package/_shared/advisor/scripts/tier_model_ids.py +18 -18
  8. package/_shared/pr-loop/CLAUDE.md +1 -0
  9. package/_shared/pr-loop/scripts/CLAUDE.md +2 -1
  10. package/_shared/pr-loop/scripts/README.md +1 -0
  11. package/_shared/pr-loop/scripts/code_rules_gate.py +253 -1980
  12. package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +32 -0
  13. package/_shared/pr-loop/scripts/code_rules_gate_parts/__init__.py +7 -0
  14. package/_shared/pr-loop/scripts/code_rules_gate_parts/added_line_maps.py +268 -0
  15. package/_shared/pr-loop/scripts/code_rules_gate_parts/enforcer_loading.py +172 -0
  16. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +70 -0
  17. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_running.py +326 -0
  18. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +85 -0
  19. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_file_sets.py +331 -0
  20. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +369 -0
  21. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/conftest.py +14 -0
  22. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_added_line_maps.py +118 -0
  23. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +17 -0
  24. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_arguments.py +29 -0
  25. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +99 -0
  26. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_blob_readers.py +69 -0
  27. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_file_sets.py +137 -0
  28. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +116 -0
  29. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_violation_scoping.py +75 -0
  30. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_wrapper_plumb_check.py +49 -0
  31. package/_shared/pr-loop/scripts/code_rules_gate_parts/violation_scoping.py +328 -0
  32. package/_shared/pr-loop/scripts/code_rules_gate_parts/wrapper_plumb_check.py +206 -0
  33. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +24 -17
  34. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
  35. package/_shared/pr-loop/scripts/reviews_disabled.py +19 -2
  36. package/_shared/pr-loop/scripts/test_code_rules_gate.py +278 -0
  37. package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +6 -39
  38. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +43 -0
  39. package/_shared/pr-loop/worker-spawn.md +186 -0
  40. package/agents/code-verifier.md +1 -1
  41. package/bin/ever-shipped-skills.mjs +3 -0
  42. package/bin/expand_home_directory_tokens.mjs +1 -1
  43. package/bin/install.mjs +5 -2
  44. package/hooks/advisory/refactor_guard.py +3 -4
  45. package/hooks/blocking/CLAUDE.md +7 -1
  46. package/hooks/blocking/block_main_commit.py +2 -2
  47. package/hooks/blocking/claude_md_orphan_file_blocker.py +75 -699
  48. package/hooks/blocking/claude_md_orphan_file_blocker_parts/CLAUDE.md +28 -0
  49. package/hooks/blocking/claude_md_orphan_file_blocker_parts/__init__.py +1 -0
  50. package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/__init__.py +1 -0
  51. package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/orphan_blocker_constants.py +18 -0
  52. package/hooks/blocking/claude_md_orphan_file_blocker_parts/decision.py +81 -0
  53. package/hooks/blocking/claude_md_orphan_file_blocker_parts/references.py +307 -0
  54. package/hooks/blocking/claude_md_orphan_file_blocker_parts/scan_plan.py +124 -0
  55. package/hooks/blocking/claude_md_orphan_file_blocker_parts/subtree_scan.py +179 -0
  56. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/conftest.py +10 -0
  57. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_decision.py +34 -0
  58. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_references.py +42 -0
  59. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_scan_plan.py +27 -0
  60. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_subtree_scan.py +30 -0
  61. package/hooks/blocking/code_rules_boolean_mustcheck.py +1 -1
  62. package/hooks/blocking/code_rules_mock_completeness.py +1 -1
  63. package/hooks/blocking/code_rules_optional_params.py +2 -2
  64. package/hooks/blocking/code_rules_shared.py +1 -1
  65. package/hooks/blocking/code_rules_test_assertions.py +1 -1
  66. package/hooks/blocking/code_rules_typeddict_stub.py +1 -1
  67. package/hooks/blocking/gh_pr_author_enforcer.py +1 -1
  68. package/hooks/blocking/inventory_intent_records/CLAUDE.md +26 -0
  69. package/hooks/blocking/inventory_intent_records/__init__.py +1 -0
  70. package/hooks/blocking/inventory_intent_records/config/__init__.py +1 -0
  71. package/hooks/blocking/inventory_intent_records/config/intent_records_constants.py +20 -0
  72. package/hooks/blocking/inventory_intent_records/records.py +271 -0
  73. package/hooks/blocking/inventory_intent_records/tests/conftest.py +10 -0
  74. package/hooks/blocking/inventory_intent_records/tests/test_records.py +80 -0
  75. package/hooks/blocking/package_inventory_stale_blocker.py +54 -384
  76. package/hooks/blocking/package_inventory_stale_blocker_parts/CLAUDE.md +26 -0
  77. package/hooks/blocking/package_inventory_stale_blocker_parts/__init__.py +1 -0
  78. package/hooks/blocking/package_inventory_stale_blocker_parts/config/__init__.py +1 -0
  79. package/hooks/blocking/package_inventory_stale_blocker_parts/config/inventory_blocker_constants.py +16 -0
  80. package/hooks/blocking/package_inventory_stale_blocker_parts/decision.py +84 -0
  81. package/hooks/blocking/package_inventory_stale_blocker_parts/inventory_detection.py +307 -0
  82. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/conftest.py +10 -0
  83. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_decision.py +38 -0
  84. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_inventory_detection.py +61 -0
  85. package/hooks/blocking/pii_payload_scan.py +138 -42
  86. package/hooks/blocking/pii_prevention_blocker.py +185 -291
  87. package/hooks/blocking/pii_prevention_blocker_parts/CLAUDE.md +24 -0
  88. package/hooks/blocking/pii_prevention_blocker_parts/__init__.py +1 -0
  89. package/hooks/blocking/pii_prevention_blocker_parts/config/__init__.py +1 -0
  90. package/hooks/blocking/pii_prevention_blocker_parts/config/repository_resolution_constants.py +28 -0
  91. package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +214 -0
  92. package/hooks/blocking/pii_prevention_blocker_parts/repository_resolution.py +208 -0
  93. package/hooks/blocking/pr_description_command_parser.py +8 -4
  94. package/hooks/blocking/precommit_code_rules_gate.py +3 -3
  95. package/hooks/blocking/tdd_enforcer.py +97 -608
  96. package/hooks/blocking/tdd_enforcer_parts/CLAUDE.md +30 -0
  97. package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -0
  98. package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +142 -0
  99. package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +1 -0
  100. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +32 -0
  101. package/hooks/blocking/tdd_enforcer_parts/content_analysis.py +268 -0
  102. package/hooks/blocking/tdd_enforcer_parts/decisions.py +92 -0
  103. package/hooks/blocking/tdd_enforcer_parts/freshness.py +80 -0
  104. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +63 -0
  105. package/hooks/blocking/tdd_enforcer_parts/path_classification.py +119 -0
  106. package/hooks/blocking/tdd_enforcer_parts/tests/conftest.py +10 -0
  107. package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +31 -0
  108. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_analysis.py +30 -0
  109. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +34 -0
  110. package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +28 -0
  111. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +48 -0
  112. package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +36 -0
  113. package/hooks/blocking/test_inventory_deadlock_resolution.py +154 -0
  114. package/hooks/blocking/test_pii_payload_scan.py +168 -0
  115. package/hooks/blocking/test_tdd_enforcer_restore.py +108 -0
  116. package/hooks/blocking/tests/conftest.py +10 -0
  117. package/hooks/blocking/tests/test_pii_prevention_blocker.py +260 -0
  118. package/hooks/blocking/tests/test_repository_exemption.py +105 -0
  119. package/hooks/blocking/tests/test_repository_resolution.py +108 -0
  120. package/hooks/diagnostic/hook_log_extractor.py +12 -10
  121. package/hooks/git-hooks/post_commit.py +3 -4
  122. package/hooks/hooks_constants/CLAUDE.md +2 -2
  123. package/hooks/hooks_constants/banned_identifiers_constants.py +0 -1
  124. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
  125. package/hooks/hooks_constants/local_identity.py +59 -8
  126. package/hooks/hooks_constants/pii_prevention_constants.py +0 -6
  127. package/hooks/hooks_constants/test_local_identity.py +105 -3
  128. package/hooks/pyproject.toml +13 -36
  129. package/hooks/session/plugin_data_dir_cleanup.py +0 -1
  130. package/hooks/validation/mypy_validator.py +2 -2
  131. package/hooks/validators/health_check.py +1 -0
  132. package/hooks/validators/mypy_integration.py +2 -0
  133. package/hooks/validators/ruff_integration.py +3 -0
  134. package/hooks/workflow/auto_formatter.py +5 -4
  135. package/package.json +1 -1
  136. package/scripts/CLAUDE.md +4 -0
  137. package/scripts/dev_env_scripts_constants/CLAUDE.md +6 -4
  138. package/scripts/dev_env_scripts_constants/code_review_constants.py +71 -0
  139. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +435 -0
  140. package/scripts/dev_env_scripts_constants/timing.py +7 -1
  141. package/scripts/grok_headless_runner.py +294 -0
  142. package/scripts/grok_worker_preflight.py +410 -0
  143. package/scripts/invoke_code_review.py +463 -0
  144. package/scripts/resolve_worker_spawn.py +619 -0
  145. package/scripts/spawn_grok_batch.py +672 -0
  146. package/scripts/test_grok_headless_runner.py +626 -0
  147. package/scripts/test_grok_worker_preflight.py +1054 -0
  148. package/scripts/test_invoke_code_review.py +672 -0
  149. package/scripts/test_resolve_worker_spawn.py +1014 -0
  150. package/scripts/test_spawn_grok_batch.py +1017 -0
  151. package/skills/CLAUDE.md +5 -3
  152. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +72 -13
  153. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +121 -14
  154. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +78 -0
  155. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +121 -0
  156. package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +196 -6
  157. package/skills/autoconverge/CLAUDE.md +3 -3
  158. package/skills/autoconverge/SKILL.md +9 -3
  159. package/skills/autoconverge/reference/CLAUDE.md +2 -2
  160. package/skills/autoconverge/reference/convergence.md +33 -11
  161. package/skills/autoconverge/reference/stop-conditions.md +16 -5
  162. package/skills/autoconverge/workflow/CLAUDE.md +2 -1
  163. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +7 -2
  164. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +300 -0
  165. package/skills/autoconverge/workflow/converge.contract.test.mjs +5 -5
  166. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +29 -29
  167. package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
  168. package/skills/autoconverge/workflow/converge.mjs +200 -16
  169. package/skills/bugteam/CLAUDE.md +2 -2
  170. package/skills/bugteam/CONSTRAINTS.md +3 -2
  171. package/skills/bugteam/PROMPTS.md +7 -6
  172. package/skills/bugteam/SKILL.md +18 -13
  173. package/skills/bugteam/reference/audit-and-teammates.md +215 -35
  174. package/skills/bugteam/reference/design-rationale.md +1 -1
  175. package/skills/bugteam/reference/obstacles/CLAUDE.md +1 -1
  176. package/skills/bugteam/reference/team-setup.md +8 -2
  177. package/skills/codex-review/CLAUDE.md +46 -0
  178. package/skills/codex-review/SKILL.md +181 -0
  179. package/skills/codex-review/reference/CLAUDE.md +15 -0
  180. package/skills/codex-review/reference/cli-contract.md +253 -0
  181. package/skills/codex-review/reference/loop-integration.md +118 -0
  182. package/skills/codex-review/scripts/codex_down_classifier.py +98 -0
  183. package/skills/codex-review/scripts/codex_review_scripts_constants/CLAUDE.md +18 -0
  184. package/skills/codex-review/scripts/codex_review_scripts_constants/__init__.py +1 -0
  185. package/skills/codex-review/scripts/codex_review_scripts_constants/classifier_constants.py +35 -0
  186. package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +86 -0
  187. package/skills/codex-review/scripts/codex_review_scripts_constants/findings_constants.py +18 -0
  188. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +45 -0
  189. package/skills/codex-review/scripts/codex_usage_probe.py +573 -0
  190. package/skills/codex-review/scripts/fixtures/auth_failure_synthetic.txt +1 -0
  191. package/skills/codex-review/scripts/fixtures/config_load_failure_v0.125.0.txt +1 -0
  192. package/skills/codex-review/scripts/fixtures/freeform_findings_v0.144.3.txt +6 -0
  193. package/skills/codex-review/scripts/fixtures/model_rejection_v0.125.0.jsonl +5 -0
  194. package/skills/codex-review/scripts/fixtures/structured_findings.txt +13 -0
  195. package/skills/codex-review/scripts/fixtures/success_stream_v0.144.3.jsonl +6 -0
  196. package/skills/codex-review/scripts/fixtures/unknown_failure_synthetic.txt +1 -0
  197. package/skills/codex-review/scripts/fixtures/usage_limit_synthetic.txt +1 -0
  198. package/skills/codex-review/scripts/parse_codex_findings.py +207 -0
  199. package/skills/codex-review/scripts/run_codex_review.py +415 -0
  200. package/skills/codex-review/scripts/test_codex_down_classifier.py +143 -0
  201. package/skills/codex-review/scripts/test_codex_usage_probe.py +678 -0
  202. package/skills/codex-review/scripts/test_parse_codex_findings.py +130 -0
  203. package/skills/codex-review/scripts/test_run_codex_review.py +812 -0
  204. package/skills/codex-review/test_skill_scaffold.py +192 -0
  205. package/skills/grok-spawn/CLAUDE.md +28 -0
  206. package/skills/grok-spawn/SKILL.md +226 -0
  207. package/skills/grok-spawn/reference/flag-profiles.md +132 -0
  208. package/skills/grok-spawn/reference/worker-briefs.md +152 -0
  209. package/skills/grokify/SKILL.md +9 -1
  210. package/skills/grokify/capability-claims.test.mjs +28 -0
  211. package/skills/grokify/evals/README.md +72 -0
  212. package/skills/grokify/evals/parse-payload.test.mjs +171 -0
  213. package/skills/grokify/evals/run-capability-evals.mjs +545 -0
  214. package/skills/orchestrator/SKILL.md +15 -11
  215. package/skills/orchestrator-refresh/SKILL.md +5 -5
  216. package/skills/pr-converge/SKILL.md +34 -13
  217. package/skills/pr-converge/reference/convergence-gates.md +42 -15
  218. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  219. package/skills/pr-converge/reference/ground-rules.md +1 -1
  220. package/skills/pr-converge/reference/per-tick.md +130 -42
  221. package/skills/pr-converge/reference/state-schema.md +10 -0
  222. package/skills/pr-converge/scripts/CLAUDE.md +2 -0
  223. package/skills/pr-converge/scripts/_pr_converge_path_setup.py +5 -1
  224. package/skills/pr-converge/scripts/check_convergence.py +605 -29
  225. package/skills/pr-converge/scripts/check_convergence_availability.py +232 -0
  226. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -235
  227. package/skills/pr-converge/scripts/check_convergence_thread_gates.py +1 -1
  228. package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -2
  229. package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +4 -0
  230. package/skills/pr-converge/scripts/test_check_convergence.py +71 -3
  231. package/skills/pr-converge/scripts/test_check_convergence_availability.py +326 -0
  232. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -0
  233. package/skills/pr-converge/scripts/test_check_convergence_contract.py +89 -17
  234. package/skills/pr-converge/scripts/test_check_convergence_fixture.py +179 -0
  235. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -68
  236. package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +24 -0
  237. package/skills/pr-converge/test_step5_host_branch.py +106 -0
  238. package/skills/pr-loop-cloud-transport/SKILL.md +2 -0
  239. package/skills/reviewer-gates/SKILL.md +7 -5
  240. package/skills/team-advisor/SKILL.md +7 -7
@@ -0,0 +1,812 @@
1
+ """Behavioral tests for the headless codex review wrapper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Callable
10
+
11
+ import pytest
12
+
13
+ SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
14
+ if str(SCRIPTS_DIRECTORY) not in sys.path:
15
+ sys.path.insert(0, str(SCRIPTS_DIRECTORY))
16
+
17
+ import run_codex_review as wrapper # noqa: E402
18
+ from codex_review_scripts_constants.run_constants import ( # noqa: E402
19
+ ALL_SHAPE_PROBE_REQUIRED_FLAGS,
20
+ BASE_TARGET_FLAG,
21
+ CODEX_BINARY_NAME,
22
+ CODEX_HOME_ENV_VAR,
23
+ COMMIT_TARGET_FLAG,
24
+ CUSTOM_INSTRUCTIONS_PROMPT,
25
+ DEFAULT_TIMEOUT_SECONDS,
26
+ EXEC_SUBCOMMAND,
27
+ HELP_FLAG,
28
+ JSON_FLAG,
29
+ JSONL_CAPTURE_FILENAME,
30
+ MISSING_BINARY_EXIT_CODE,
31
+ MODEL_FLAG,
32
+ OUTCOME_CLASS_CODEX_DOWN,
33
+ OUTCOME_CLASS_COMPLETED,
34
+ REVIEW_SUBCOMMAND,
35
+ SUBPROCESS_DECODE_EXIT_CODE,
36
+ TIMEOUT_EXIT_CODE,
37
+ UNCOMMITTED_TARGET_FLAG,
38
+ UTF8_ENCODING,
39
+ VERSION_FLAG,
40
+ )
41
+
42
+ HELP_TEXT_WITH_TARGETS = (
43
+ "Usage: codex exec review [OPTIONS] [PROMPT]\n"
44
+ " --uncommitted\n"
45
+ " --base <BRANCH>\n"
46
+ " --commit <SHA>\n"
47
+ " --json\n"
48
+ )
49
+ VERSION_STDOUT = "codex-cli 0.144.3\n"
50
+ EXPECTED_BINARY_VERSION = "0.144.3"
51
+ AGENT_MESSAGE_BODY = "No issues found.\n\n```json\n[]\n```"
52
+ CARRIAGE_RETURN_LINE_ENDING = b"\r\n"
53
+ SUCCESS_JSONL = "\n".join(
54
+ [
55
+ json.dumps({"type": "thread.started", "thread_id": "thread-1"}),
56
+ json.dumps({"type": "turn.started"}),
57
+ json.dumps(
58
+ {
59
+ "type": "item.completed",
60
+ "item": {
61
+ "type": "agent_message",
62
+ "text": AGENT_MESSAGE_BODY,
63
+ },
64
+ }
65
+ ),
66
+ json.dumps(
67
+ {
68
+ "type": "turn.completed",
69
+ "usage": {
70
+ "input_tokens": 10,
71
+ "output_tokens": 5,
72
+ },
73
+ }
74
+ ),
75
+ ]
76
+ ) + "\n"
77
+
78
+
79
+ @pytest.fixture
80
+ def repository_directory(tmp_path: Path) -> Path:
81
+ existing_repository_directory = tmp_path / "repo"
82
+ existing_repository_directory.mkdir()
83
+ return existing_repository_directory
84
+
85
+
86
+ @pytest.fixture
87
+ def run_state_directory(tmp_path: Path) -> Path:
88
+ existing_run_state_directory = tmp_path / "run_state"
89
+ existing_run_state_directory.mkdir()
90
+ return existing_run_state_directory
91
+
92
+
93
+ class _SubprocessRecorder:
94
+ def __init__(
95
+ self,
96
+ behavior_for_invocation: Callable[
97
+ [list[str]], subprocess.CompletedProcess[str] | BaseException
98
+ ],
99
+ ) -> None:
100
+ self.behavior_for_invocation = behavior_for_invocation
101
+ self.all_invocations: list[list[str]] = []
102
+ self.all_keyword_arguments: list[dict[str, object]] = []
103
+
104
+ def __call__(
105
+ self,
106
+ invocation: list[str],
107
+ **keyword_arguments: object,
108
+ ) -> subprocess.CompletedProcess[str]:
109
+ self.all_invocations.append(list(invocation))
110
+ self.all_keyword_arguments.append(keyword_arguments)
111
+ behavior = self.behavior_for_invocation(list(invocation))
112
+ if isinstance(behavior, BaseException):
113
+ raise behavior
114
+ return behavior
115
+
116
+
117
+ def _completed(
118
+ all_arguments: list[str],
119
+ returncode: int,
120
+ stdout: str = "",
121
+ stderr: str = "",
122
+ ) -> subprocess.CompletedProcess[str]:
123
+ return subprocess.CompletedProcess(
124
+ args=all_arguments,
125
+ returncode=returncode,
126
+ stdout=stdout,
127
+ stderr=stderr,
128
+ )
129
+
130
+
131
+ def _is_version_probe(all_arguments: list[str]) -> bool:
132
+ return all_arguments == [CODEX_BINARY_NAME, VERSION_FLAG]
133
+
134
+
135
+ def _is_shape_probe(all_arguments: list[str]) -> bool:
136
+ return all_arguments == [
137
+ CODEX_BINARY_NAME,
138
+ EXEC_SUBCOMMAND,
139
+ REVIEW_SUBCOMMAND,
140
+ HELP_FLAG,
141
+ ]
142
+
143
+
144
+ def _is_review_run(all_arguments: list[str]) -> bool:
145
+ return (
146
+ len(all_arguments) >= 4
147
+ and all_arguments[0] == CODEX_BINARY_NAME
148
+ and all_arguments[1] == EXEC_SUBCOMMAND
149
+ and REVIEW_SUBCOMMAND in all_arguments
150
+ and JSON_FLAG in all_arguments
151
+ and HELP_FLAG not in all_arguments
152
+ and VERSION_FLAG not in all_arguments
153
+ )
154
+
155
+
156
+ def _install_recorder(
157
+ monkeypatch: pytest.MonkeyPatch,
158
+ behavior_for_invocation: Callable[
159
+ [list[str]], subprocess.CompletedProcess[str] | BaseException
160
+ ],
161
+ ) -> _SubprocessRecorder:
162
+ recorder = _SubprocessRecorder(behavior_for_invocation)
163
+ monkeypatch.setattr(wrapper, "codex_subprocess_runner", recorder)
164
+ monkeypatch.setattr(
165
+ wrapper, "_resolve_codex_command_prefix", lambda: [CODEX_BINARY_NAME]
166
+ )
167
+ return recorder
168
+
169
+
170
+ def _healthy_probe_then_review(
171
+ review_returncode: int = 0,
172
+ review_stdout: str = SUCCESS_JSONL,
173
+ review_stderr: str = "",
174
+ ) -> Callable[[list[str]], subprocess.CompletedProcess[str] | BaseException]:
175
+ def behavior(
176
+ all_arguments: list[str],
177
+ ) -> subprocess.CompletedProcess[str] | BaseException:
178
+ if _is_version_probe(all_arguments):
179
+ return _completed(all_arguments, 0, stdout=VERSION_STDOUT)
180
+ if _is_shape_probe(all_arguments):
181
+ return _completed(all_arguments, 0, stdout=HELP_TEXT_WITH_TARGETS)
182
+ if _is_review_run(all_arguments):
183
+ return _completed(
184
+ all_arguments,
185
+ review_returncode,
186
+ stdout=review_stdout,
187
+ stderr=review_stderr,
188
+ )
189
+ raise AssertionError(f"unexpected invocation: {all_arguments!r}")
190
+
191
+ return behavior
192
+
193
+
194
+ def _healthy_probe_then_failing_shape(
195
+ shape_stdout: str,
196
+ ) -> Callable[[list[str]], subprocess.CompletedProcess[str] | BaseException]:
197
+ def behavior(
198
+ all_arguments: list[str],
199
+ ) -> subprocess.CompletedProcess[str] | BaseException:
200
+ if _is_version_probe(all_arguments):
201
+ return _completed(all_arguments, 0, stdout=VERSION_STDOUT)
202
+ if _is_shape_probe(all_arguments):
203
+ return _completed(all_arguments, 0, stdout=shape_stdout)
204
+ raise AssertionError("review must not run after a failed shape probe")
205
+
206
+ return behavior
207
+
208
+
209
+ def _only_review_invocation(recorder: _SubprocessRecorder) -> list[str]:
210
+ return next(
211
+ each_invocation
212
+ for each_invocation in recorder.all_invocations
213
+ if _is_review_run(each_invocation)
214
+ )
215
+
216
+
217
+ def test_argv_assembly_for_uncommitted_target(
218
+ monkeypatch: pytest.MonkeyPatch,
219
+ repository_directory: Path,
220
+ run_state_directory: Path,
221
+ ) -> None:
222
+ recorder = _install_recorder(monkeypatch, _healthy_probe_then_review())
223
+
224
+ review_outcome = wrapper.run_codex_review(
225
+ repository_directory=repository_directory,
226
+ run_state_directory=run_state_directory,
227
+ is_uncommitted=True,
228
+ )
229
+
230
+ review_invocation = _only_review_invocation(recorder)
231
+ assert review_invocation == [
232
+ CODEX_BINARY_NAME,
233
+ EXEC_SUBCOMMAND,
234
+ REVIEW_SUBCOMMAND,
235
+ JSON_FLAG,
236
+ UNCOMMITTED_TARGET_FLAG,
237
+ ]
238
+ assert CUSTOM_INSTRUCTIONS_PROMPT not in review_invocation
239
+ assert review_outcome.outcome_class == OUTCOME_CLASS_COMPLETED
240
+
241
+
242
+ def test_argv_assembly_for_base_branch_target(
243
+ monkeypatch: pytest.MonkeyPatch,
244
+ repository_directory: Path,
245
+ run_state_directory: Path,
246
+ ) -> None:
247
+ recorder = _install_recorder(monkeypatch, _healthy_probe_then_review())
248
+ base_branch = "origin/main"
249
+
250
+ wrapper.run_codex_review(
251
+ repository_directory=repository_directory,
252
+ run_state_directory=run_state_directory,
253
+ base_branch=base_branch,
254
+ )
255
+
256
+ review_invocation = _only_review_invocation(recorder)
257
+ assert review_invocation == [
258
+ CODEX_BINARY_NAME,
259
+ EXEC_SUBCOMMAND,
260
+ REVIEW_SUBCOMMAND,
261
+ JSON_FLAG,
262
+ BASE_TARGET_FLAG,
263
+ base_branch,
264
+ ]
265
+ assert CUSTOM_INSTRUCTIONS_PROMPT not in review_invocation
266
+
267
+
268
+ def test_argv_assembly_for_commit_target(
269
+ monkeypatch: pytest.MonkeyPatch,
270
+ repository_directory: Path,
271
+ run_state_directory: Path,
272
+ ) -> None:
273
+ recorder = _install_recorder(monkeypatch, _healthy_probe_then_review())
274
+ commit_sha = "abc123def456"
275
+
276
+ wrapper.run_codex_review(
277
+ repository_directory=repository_directory,
278
+ run_state_directory=run_state_directory,
279
+ commit_sha=commit_sha,
280
+ )
281
+
282
+ review_invocation = _only_review_invocation(recorder)
283
+ assert review_invocation == [
284
+ CODEX_BINARY_NAME,
285
+ EXEC_SUBCOMMAND,
286
+ REVIEW_SUBCOMMAND,
287
+ JSON_FLAG,
288
+ COMMIT_TARGET_FLAG,
289
+ commit_sha,
290
+ ]
291
+ assert CUSTOM_INSTRUCTIONS_PROMPT not in review_invocation
292
+
293
+
294
+ def test_argv_assembly_for_prompt_target(
295
+ monkeypatch: pytest.MonkeyPatch,
296
+ repository_directory: Path,
297
+ run_state_directory: Path,
298
+ ) -> None:
299
+ recorder = _install_recorder(monkeypatch, _healthy_probe_then_review())
300
+
301
+ review_outcome = wrapper.run_codex_review(
302
+ repository_directory=repository_directory,
303
+ run_state_directory=run_state_directory,
304
+ is_prompt_target=True,
305
+ )
306
+
307
+ review_invocation = _only_review_invocation(recorder)
308
+ assert review_invocation == [
309
+ CODEX_BINARY_NAME,
310
+ EXEC_SUBCOMMAND,
311
+ REVIEW_SUBCOMMAND,
312
+ JSON_FLAG,
313
+ CUSTOM_INSTRUCTIONS_PROMPT,
314
+ ]
315
+ assert UNCOMMITTED_TARGET_FLAG not in review_invocation
316
+ assert BASE_TARGET_FLAG not in review_invocation
317
+ assert COMMIT_TARGET_FLAG not in review_invocation
318
+ assert review_outcome.outcome_class == OUTCOME_CLASS_COMPLETED
319
+
320
+
321
+ def test_shape_probe_missing_target_flags_returns_codex_down(
322
+ monkeypatch: pytest.MonkeyPatch,
323
+ repository_directory: Path,
324
+ run_state_directory: Path,
325
+ ) -> None:
326
+ recorder = _install_recorder(
327
+ monkeypatch,
328
+ _healthy_probe_then_failing_shape("Usage: codex exec review\n --legacy-only\n"),
329
+ )
330
+
331
+ review_outcome = wrapper.run_codex_review(
332
+ repository_directory=repository_directory,
333
+ run_state_directory=run_state_directory,
334
+ is_uncommitted=True,
335
+ )
336
+
337
+ assert review_outcome.outcome_class == OUTCOME_CLASS_CODEX_DOWN
338
+ assert review_outcome.binary_version == EXPECTED_BINARY_VERSION
339
+ assert review_outcome.agent_message == ""
340
+ assert review_outcome.jsonl_path is None
341
+ assert not any(
342
+ _is_review_run(each_invocation)
343
+ for each_invocation in recorder.all_invocations
344
+ )
345
+
346
+
347
+ def test_shape_probe_lookalike_flags_do_not_match_required_tokens(
348
+ monkeypatch: pytest.MonkeyPatch,
349
+ repository_directory: Path,
350
+ run_state_directory: Path,
351
+ ) -> None:
352
+ lookalike_help_text = (
353
+ "Usage: codex exec review [OPTIONS]\n"
354
+ " --baseline <BRANCH>\n"
355
+ " --commit-message <TEXT>\n"
356
+ " --uncommitted-only\n"
357
+ " --json-output\n"
358
+ )
359
+ recorder = _install_recorder(
360
+ monkeypatch, _healthy_probe_then_failing_shape(lookalike_help_text)
361
+ )
362
+
363
+ review_outcome = wrapper.run_codex_review(
364
+ repository_directory=repository_directory,
365
+ run_state_directory=run_state_directory,
366
+ is_uncommitted=True,
367
+ )
368
+
369
+ assert review_outcome.outcome_class == OUTCOME_CLASS_CODEX_DOWN
370
+ assert review_outcome.exit_code == 0
371
+ assert review_outcome.jsonl_path is None
372
+ assert not any(
373
+ _is_review_run(each_invocation)
374
+ for each_invocation in recorder.all_invocations
375
+ )
376
+ for each_required_flag in ALL_SHAPE_PROBE_REQUIRED_FLAGS:
377
+ assert each_required_flag in lookalike_help_text
378
+
379
+
380
+ def test_shape_probe_nonzero_help_returns_codex_down(
381
+ monkeypatch: pytest.MonkeyPatch,
382
+ repository_directory: Path,
383
+ run_state_directory: Path,
384
+ ) -> None:
385
+ def behavior(
386
+ all_arguments: list[str],
387
+ ) -> subprocess.CompletedProcess[str] | BaseException:
388
+ if _is_version_probe(all_arguments):
389
+ return _completed(all_arguments, 0, stdout=VERSION_STDOUT)
390
+ if _is_shape_probe(all_arguments):
391
+ return _completed(
392
+ all_arguments,
393
+ 2,
394
+ stderr="error: unrecognized subcommand 'review'\n",
395
+ )
396
+ raise AssertionError("review must not run after a failed shape probe")
397
+
398
+ _install_recorder(monkeypatch, behavior)
399
+
400
+ review_outcome = wrapper.run_codex_review(
401
+ repository_directory=repository_directory,
402
+ run_state_directory=run_state_directory,
403
+ is_uncommitted=True,
404
+ )
405
+
406
+ assert review_outcome.outcome_class == OUTCOME_CLASS_CODEX_DOWN
407
+ assert review_outcome.exit_code == 2
408
+
409
+
410
+ def test_missing_binary_returns_codex_down(
411
+ monkeypatch: pytest.MonkeyPatch,
412
+ repository_directory: Path,
413
+ run_state_directory: Path,
414
+ ) -> None:
415
+ def behavior(
416
+ all_arguments: list[str],
417
+ ) -> subprocess.CompletedProcess[str] | BaseException:
418
+ raise FileNotFoundError(all_arguments[0])
419
+
420
+ _install_recorder(monkeypatch, behavior)
421
+
422
+ review_outcome = wrapper.run_codex_review(
423
+ repository_directory=repository_directory,
424
+ run_state_directory=run_state_directory,
425
+ is_uncommitted=True,
426
+ )
427
+
428
+ assert review_outcome.outcome_class == OUTCOME_CLASS_CODEX_DOWN
429
+ assert review_outcome.exit_code == MISSING_BINARY_EXIT_CODE
430
+ assert review_outcome.jsonl_path is None
431
+
432
+
433
+ def test_jsonl_capture_and_agent_message_extraction(
434
+ monkeypatch: pytest.MonkeyPatch,
435
+ repository_directory: Path,
436
+ run_state_directory: Path,
437
+ ) -> None:
438
+ recorder = _install_recorder(monkeypatch, _healthy_probe_then_review())
439
+
440
+ review_outcome = wrapper.run_codex_review(
441
+ repository_directory=repository_directory,
442
+ run_state_directory=run_state_directory,
443
+ is_uncommitted=True,
444
+ )
445
+
446
+ expected_jsonl_path = run_state_directory / JSONL_CAPTURE_FILENAME
447
+ assert review_outcome.outcome_class == OUTCOME_CLASS_COMPLETED
448
+ assert review_outcome.exit_code == 0
449
+ assert review_outcome.jsonl_path == expected_jsonl_path
450
+ assert expected_jsonl_path.read_text(encoding=UTF8_ENCODING) == SUCCESS_JSONL
451
+ assert review_outcome.agent_message == AGENT_MESSAGE_BODY
452
+ assert review_outcome.binary_version == EXPECTED_BINARY_VERSION
453
+ review_keyword_arguments = next(
454
+ each_keyword_arguments
455
+ for each_invocation, each_keyword_arguments in zip(
456
+ recorder.all_invocations,
457
+ recorder.all_keyword_arguments,
458
+ strict=True,
459
+ )
460
+ if _is_review_run(each_invocation)
461
+ )
462
+ assert review_keyword_arguments["cwd"] == str(repository_directory)
463
+ assert review_keyword_arguments["timeout"] == DEFAULT_TIMEOUT_SECONDS
464
+ assert review_keyword_arguments["capture_output"] is True
465
+ assert review_keyword_arguments["text"] is True
466
+ assert review_keyword_arguments["encoding"] == UTF8_ENCODING
467
+ assert review_keyword_arguments["check"] is False
468
+
469
+
470
+ def test_jsonl_capture_preserves_stream_line_endings(
471
+ monkeypatch: pytest.MonkeyPatch,
472
+ repository_directory: Path,
473
+ run_state_directory: Path,
474
+ ) -> None:
475
+ _install_recorder(monkeypatch, _healthy_probe_then_review())
476
+
477
+ review_outcome = wrapper.run_codex_review(
478
+ repository_directory=repository_directory,
479
+ run_state_directory=run_state_directory,
480
+ is_uncommitted=True,
481
+ )
482
+
483
+ assert review_outcome.jsonl_path is not None
484
+ captured_bytes = review_outcome.jsonl_path.read_bytes()
485
+ assert CARRIAGE_RETURN_LINE_ENDING not in captured_bytes
486
+ assert captured_bytes == SUCCESS_JSONL.encode(UTF8_ENCODING)
487
+
488
+
489
+ def test_unicode_decode_error_returns_codex_down(
490
+ monkeypatch: pytest.MonkeyPatch,
491
+ repository_directory: Path,
492
+ run_state_directory: Path,
493
+ ) -> None:
494
+ def behavior(
495
+ all_arguments: list[str],
496
+ ) -> subprocess.CompletedProcess[str] | BaseException:
497
+ raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte")
498
+
499
+ _install_recorder(monkeypatch, behavior)
500
+
501
+ review_outcome = wrapper.run_codex_review(
502
+ repository_directory=repository_directory,
503
+ run_state_directory=run_state_directory,
504
+ is_uncommitted=True,
505
+ )
506
+
507
+ assert review_outcome.outcome_class == OUTCOME_CLASS_CODEX_DOWN
508
+ assert review_outcome.exit_code == SUBPROCESS_DECODE_EXIT_CODE
509
+ assert review_outcome.jsonl_path is None
510
+
511
+
512
+ def test_timeout_returns_codex_down(
513
+ monkeypatch: pytest.MonkeyPatch,
514
+ repository_directory: Path,
515
+ run_state_directory: Path,
516
+ ) -> None:
517
+ def behavior(
518
+ all_arguments: list[str],
519
+ ) -> subprocess.CompletedProcess[str] | BaseException:
520
+ if _is_version_probe(all_arguments):
521
+ return _completed(all_arguments, 0, stdout=VERSION_STDOUT)
522
+ if _is_shape_probe(all_arguments):
523
+ return _completed(all_arguments, 0, stdout=HELP_TEXT_WITH_TARGETS)
524
+ if _is_review_run(all_arguments):
525
+ return subprocess.TimeoutExpired(cmd=all_arguments, timeout=1)
526
+ raise AssertionError(f"unexpected invocation: {all_arguments!r}")
527
+
528
+ _install_recorder(monkeypatch, behavior)
529
+
530
+ review_outcome = wrapper.run_codex_review(
531
+ repository_directory=repository_directory,
532
+ run_state_directory=run_state_directory,
533
+ is_uncommitted=True,
534
+ timeout_seconds=1,
535
+ )
536
+
537
+ assert review_outcome.outcome_class == OUTCOME_CLASS_CODEX_DOWN
538
+ assert review_outcome.exit_code == TIMEOUT_EXIT_CODE
539
+
540
+
541
+ def test_codex_home_env_is_passed_through_when_set(
542
+ monkeypatch: pytest.MonkeyPatch,
543
+ tmp_path: Path,
544
+ repository_directory: Path,
545
+ run_state_directory: Path,
546
+ ) -> None:
547
+ codex_home_directory = tmp_path / "codex_home"
548
+ codex_home_directory.mkdir()
549
+ monkeypatch.setenv(CODEX_HOME_ENV_VAR, str(codex_home_directory))
550
+ recorder = _install_recorder(monkeypatch, _healthy_probe_then_review())
551
+
552
+ wrapper.run_codex_review(
553
+ repository_directory=repository_directory,
554
+ run_state_directory=run_state_directory,
555
+ is_uncommitted=True,
556
+ )
557
+
558
+ for each_keyword_arguments in recorder.all_keyword_arguments:
559
+ process_environment = each_keyword_arguments.get("env")
560
+ assert process_environment is not None
561
+ assert isinstance(process_environment, dict)
562
+ assert process_environment[CODEX_HOME_ENV_VAR] == str(codex_home_directory)
563
+
564
+
565
+ def test_rejects_multiple_targets(
566
+ monkeypatch: pytest.MonkeyPatch,
567
+ repository_directory: Path,
568
+ run_state_directory: Path,
569
+ ) -> None:
570
+ recorder = _install_recorder(monkeypatch, _healthy_probe_then_review())
571
+
572
+ with pytest.raises(ValueError):
573
+ wrapper.run_codex_review(
574
+ repository_directory=repository_directory,
575
+ run_state_directory=run_state_directory,
576
+ is_uncommitted=True,
577
+ base_branch="main",
578
+ )
579
+
580
+ assert recorder.all_invocations == []
581
+
582
+
583
+ def test_rejects_missing_target(
584
+ monkeypatch: pytest.MonkeyPatch,
585
+ repository_directory: Path,
586
+ run_state_directory: Path,
587
+ ) -> None:
588
+ recorder = _install_recorder(monkeypatch, _healthy_probe_then_review())
589
+
590
+ with pytest.raises(ValueError):
591
+ wrapper.run_codex_review(
592
+ repository_directory=repository_directory,
593
+ run_state_directory=run_state_directory,
594
+ )
595
+
596
+ assert recorder.all_invocations == []
597
+
598
+
599
+ def test_shape_probe_missing_json_flag_returns_codex_down(
600
+ monkeypatch: pytest.MonkeyPatch,
601
+ repository_directory: Path,
602
+ run_state_directory: Path,
603
+ ) -> None:
604
+ help_without_json = (
605
+ "Usage: codex exec review [OPTIONS]\n"
606
+ " --uncommitted\n"
607
+ " --base <BRANCH>\n"
608
+ " --commit <SHA>\n"
609
+ )
610
+ recorder = _install_recorder(
611
+ monkeypatch, _healthy_probe_then_failing_shape(help_without_json)
612
+ )
613
+
614
+ review_outcome = wrapper.run_codex_review(
615
+ repository_directory=repository_directory,
616
+ run_state_directory=run_state_directory,
617
+ is_uncommitted=True,
618
+ )
619
+
620
+ assert review_outcome.outcome_class == OUTCOME_CLASS_CODEX_DOWN
621
+ assert review_outcome.jsonl_path is None
622
+ assert not any(
623
+ _is_review_run(each_invocation)
624
+ for each_invocation in recorder.all_invocations
625
+ )
626
+ assert JSON_FLAG not in help_without_json
627
+ assert JSON_FLAG in ALL_SHAPE_PROBE_REQUIRED_FLAGS
628
+
629
+
630
+ def test_missing_repository_directory_raises_value_error(
631
+ monkeypatch: pytest.MonkeyPatch,
632
+ tmp_path: Path,
633
+ run_state_directory: Path,
634
+ ) -> None:
635
+ missing_repository_directory = tmp_path / "missing-repo"
636
+ recorder = _install_recorder(monkeypatch, _healthy_probe_then_review())
637
+
638
+ with pytest.raises(ValueError, match="repository_directory"):
639
+ wrapper.run_codex_review(
640
+ repository_directory=missing_repository_directory,
641
+ run_state_directory=run_state_directory,
642
+ is_uncommitted=True,
643
+ )
644
+
645
+ assert recorder.all_invocations == []
646
+
647
+
648
+ def test_missing_run_state_directory_raises_before_any_codex_process(
649
+ monkeypatch: pytest.MonkeyPatch,
650
+ tmp_path: Path,
651
+ repository_directory: Path,
652
+ ) -> None:
653
+ missing_run_state_directory = tmp_path / "missing-run-state"
654
+ recorder = _install_recorder(monkeypatch, _healthy_probe_then_review())
655
+
656
+ with pytest.raises(ValueError, match="run_state_directory"):
657
+ wrapper.run_codex_review(
658
+ repository_directory=repository_directory,
659
+ run_state_directory=missing_run_state_directory,
660
+ is_uncommitted=True,
661
+ )
662
+
663
+ assert recorder.all_invocations == []
664
+
665
+
666
+ def test_custom_instructions_prompt_demands_finding_bullet_format() -> None:
667
+ assert "- [P1]" in CUSTOM_INSTRUCTIONS_PROMPT
668
+ assert "Review comment:" in CUSTOM_INSTRUCTIONS_PROMPT
669
+ assert "fenced JSON" not in CUSTOM_INSTRUCTIONS_PROMPT
670
+ assert "JSON array" not in CUSTOM_INSTRUCTIONS_PROMPT
671
+
672
+
673
+ def test_model_pin_assembles_model_flag_before_review_subcommand(
674
+ monkeypatch: pytest.MonkeyPatch,
675
+ repository_directory: Path,
676
+ run_state_directory: Path,
677
+ ) -> None:
678
+ model_pin = "gpt-5-codex"
679
+ monkeypatch.setattr(wrapper, "CODEX_MODEL_PIN", model_pin)
680
+ recorder = _install_recorder(monkeypatch, _healthy_probe_then_review())
681
+
682
+ wrapper.run_codex_review(
683
+ repository_directory=repository_directory,
684
+ run_state_directory=run_state_directory,
685
+ is_uncommitted=True,
686
+ )
687
+
688
+ review_invocation = _only_review_invocation(recorder)
689
+ model_flag_index = review_invocation.index(MODEL_FLAG)
690
+ review_subcommand_index = review_invocation.index(REVIEW_SUBCOMMAND)
691
+ assert review_invocation[model_flag_index : model_flag_index + 2] == [
692
+ MODEL_FLAG,
693
+ model_pin,
694
+ ]
695
+ assert model_flag_index < review_subcommand_index
696
+
697
+
698
+ def test_review_exit_1_config_error_classifies_codex_down(
699
+ monkeypatch: pytest.MonkeyPatch,
700
+ repository_directory: Path,
701
+ run_state_directory: Path,
702
+ ) -> None:
703
+ config_error_stderr = (
704
+ "Error loading config.toml: unknown variant 'default', "
705
+ "expected 'fast' or 'flex' in 'service_tier'\n"
706
+ )
707
+ recorder = _install_recorder(
708
+ monkeypatch,
709
+ _healthy_probe_then_review(
710
+ review_returncode=1,
711
+ review_stdout="",
712
+ review_stderr=config_error_stderr,
713
+ ),
714
+ )
715
+
716
+ review_outcome = wrapper.run_codex_review(
717
+ repository_directory=repository_directory,
718
+ run_state_directory=run_state_directory,
719
+ is_uncommitted=True,
720
+ )
721
+
722
+ assert review_outcome.outcome_class == OUTCOME_CLASS_CODEX_DOWN
723
+ assert review_outcome.exit_code == 1
724
+ assert review_outcome.agent_message == config_error_stderr
725
+ assert review_outcome.jsonl_path is not None
726
+ assert review_outcome.jsonl_path.read_text(encoding=UTF8_ENCODING) == ""
727
+ assert any(
728
+ _is_review_run(each_invocation)
729
+ for each_invocation in recorder.all_invocations
730
+ )
731
+
732
+
733
+ def test_review_exit_2_argument_error_classifies_codex_down(
734
+ monkeypatch: pytest.MonkeyPatch,
735
+ repository_directory: Path,
736
+ run_state_directory: Path,
737
+ ) -> None:
738
+ argument_error_stderr = (
739
+ "error: the argument '--uncommitted' cannot be used with '[PROMPT]'\n"
740
+ "Usage: codex exec review --json --uncommitted [PROMPT]\n"
741
+ )
742
+ recorder = _install_recorder(
743
+ monkeypatch,
744
+ _healthy_probe_then_review(
745
+ review_returncode=2,
746
+ review_stdout="",
747
+ review_stderr=argument_error_stderr,
748
+ ),
749
+ )
750
+
751
+ review_outcome = wrapper.run_codex_review(
752
+ repository_directory=repository_directory,
753
+ run_state_directory=run_state_directory,
754
+ is_uncommitted=True,
755
+ )
756
+
757
+ assert review_outcome.outcome_class == OUTCOME_CLASS_CODEX_DOWN
758
+ assert review_outcome.exit_code == 2
759
+ assert review_outcome.agent_message == argument_error_stderr
760
+ assert review_outcome.jsonl_path is not None
761
+ assert review_outcome.jsonl_path.read_text(encoding=UTF8_ENCODING) == ""
762
+ assert any(
763
+ _is_review_run(each_invocation)
764
+ for each_invocation in recorder.all_invocations
765
+ )
766
+
767
+
768
+ def test_review_exit_0_stays_completed(
769
+ monkeypatch: pytest.MonkeyPatch,
770
+ repository_directory: Path,
771
+ run_state_directory: Path,
772
+ ) -> None:
773
+ _install_recorder(
774
+ monkeypatch,
775
+ _healthy_probe_then_review(review_returncode=0, review_stdout=SUCCESS_JSONL),
776
+ )
777
+
778
+ review_outcome = wrapper.run_codex_review(
779
+ repository_directory=repository_directory,
780
+ run_state_directory=run_state_directory,
781
+ is_uncommitted=True,
782
+ )
783
+
784
+ assert review_outcome.outcome_class == OUTCOME_CLASS_COMPLETED
785
+ assert review_outcome.exit_code == 0
786
+ assert review_outcome.agent_message == AGENT_MESSAGE_BODY
787
+
788
+
789
+ def test_resolve_prefix_wraps_windows_shim(monkeypatch: pytest.MonkeyPatch) -> None:
790
+ shim_path = r"C:\\Users\\dev\\AppData\\Roaming\\npm\\codex.CMD"
791
+ monkeypatch.setattr(wrapper.shutil, "which", lambda _name: shim_path)
792
+ monkeypatch.setattr(wrapper.os, "name", "nt")
793
+
794
+ assert wrapper._resolve_codex_command_prefix() == ["cmd", "/c", shim_path]
795
+
796
+
797
+ def test_resolve_prefix_uses_resolved_path_on_posix(
798
+ monkeypatch: pytest.MonkeyPatch,
799
+ ) -> None:
800
+ resolved_path = "/usr/local/bin/codex"
801
+ monkeypatch.setattr(wrapper.shutil, "which", lambda _name: resolved_path)
802
+ monkeypatch.setattr(wrapper.os, "name", "posix")
803
+
804
+ assert wrapper._resolve_codex_command_prefix() == [resolved_path]
805
+
806
+
807
+ def test_resolve_prefix_falls_back_to_bare_name_when_absent(
808
+ monkeypatch: pytest.MonkeyPatch,
809
+ ) -> None:
810
+ monkeypatch.setattr(wrapper.shutil, "which", lambda _name: None)
811
+
812
+ assert wrapper._resolve_codex_command_prefix() == [CODEX_BINARY_NAME]