claude-dev-env 2.4.0 → 2.7.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 (309) hide show
  1. package/CLAUDE.md +26 -59
  2. package/_shared/pr-loop/scripts/_claude_permissions_common.py +84 -0
  3. package/_shared/pr-loop/scripts/code_rules_gate.py +6 -3
  4. package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +12 -2
  5. package/_shared/pr-loop/scripts/code_rules_gate_parts/baseline_import_isolation.py +309 -0
  6. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +540 -0
  7. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +206 -70
  8. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/__init__.py +1 -0
  9. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/_repo_test_helpers.py +76 -0
  10. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_baseline_import_isolation.py +248 -0
  11. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +309 -0
  12. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +91 -58
  13. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -306
  14. package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +44 -0
  15. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +202 -0
  16. package/_shared/pr-loop/scripts/pr_loop_shared_constants/copilot_quota_constants.py +24 -24
  17. package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -107
  18. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +290 -48
  19. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +42 -2
  20. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +36 -0
  21. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +100 -1
  22. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -497
  23. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +311 -2
  24. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -301
  25. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -85
  26. package/_shared/pr-loop/worker-spawn.md +1 -1
  27. package/agents/CLAUDE.md +3 -2
  28. package/agents/caveman.md +0 -1
  29. package/agents/clasp-deployment-orchestrator.md +0 -1
  30. package/agents/clean-coder.md +0 -1
  31. package/agents/code-advisor.md +0 -1
  32. package/agents/code-quality-agent.md +1 -2
  33. package/agents/code-verifier.md +36 -8
  34. package/agents/deep-research.md +0 -1
  35. package/agents/docs-agent.md +0 -1
  36. package/agents/git-commit-crafter.md +0 -1
  37. package/agents/issue-tracker.md +42 -0
  38. package/agents/plan-packet-validator.md +0 -1
  39. package/agents/pr-description-writer.md +0 -1
  40. package/agents/test_agent_frontmatter.py +67 -18
  41. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +143 -141
  42. package/bin/CLAUDE.md +68 -5
  43. package/bin/codex-compat.mjs +104 -0
  44. package/bin/codex-compat.test.mjs +51 -0
  45. package/bin/ever-shipped-skills.mjs +1 -0
  46. package/bin/install-constants.mjs +88 -0
  47. package/bin/install.mjs +1138 -114
  48. package/bin/install.prune.test.mjs +869 -19
  49. package/bin/install.test.mjs +906 -2
  50. package/codex-capability-map.json +13 -0
  51. package/commands/implement.md +1 -1
  52. package/commands/right-size.md +1 -1
  53. package/docs/CLAUDE.md +1 -0
  54. package/docs/CODE_RULES.md +2 -0
  55. package/docs/codex-compatibility.md +25 -0
  56. package/docs/host-pool-health-monitor.md +102 -0
  57. package/docs/nas-ssh-invocation.md +96 -12
  58. package/docs/references/CLAUDE.md +4 -2
  59. package/docs/references/advisor-tool.md +13 -0
  60. package/docs/references/code-review-enforcement.md +35 -0
  61. package/docs/references/team-advisor-skill.md +14 -0
  62. package/hooks/blocking/CLAUDE.md +4 -0
  63. package/hooks/blocking/code_review_pr_create_gate.py +7 -3
  64. package/hooks/blocking/code_review_push_gate.py +9 -4
  65. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +8 -0
  66. package/hooks/blocking/config/__init__.py +5 -5
  67. package/hooks/blocking/config/code_review_enforcement_constants.py +40 -7
  68. package/hooks/blocking/config/test_code_review_enforcement_constants.py +58 -0
  69. package/hooks/blocking/config/verified_commit_constants.py +160 -159
  70. package/hooks/blocking/eli11_reply_enforcer.py +479 -0
  71. package/hooks/blocking/gh_body_arg_blocker.py +1 -1
  72. package/hooks/blocking/nas_ssh_binary_enforcer.py +8 -46
  73. package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +256 -0
  74. package/hooks/blocking/pre_tool_use_dispatcher.py +24 -24
  75. package/hooks/blocking/shell_substitution_blocker.py +129 -0
  76. package/hooks/blocking/state_description_blocker.py +1 -1
  77. package/hooks/blocking/stop_dispatcher.py +1 -1
  78. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +2 -3
  79. package/hooks/blocking/test_code_review_pr_create_gate.py +14 -0
  80. package/hooks/blocking/test_code_review_push_gate.py +16 -0
  81. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +19 -0
  82. package/hooks/blocking/test_eli11_reply_enforcer.py +457 -0
  83. package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +231 -0
  84. package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -1
  85. package/hooks/blocking/test_shell_substitution_blocker.py +124 -0
  86. package/hooks/blocking/test_stop_dispatcher.py +23 -0
  87. package/hooks/blocking/test_unscoped_search_blocker.py +102 -0
  88. package/hooks/blocking/test_verdict_directory_write_blocker.py +804 -808
  89. package/hooks/blocking/test_verification_verdict_store.py +54 -0
  90. package/hooks/blocking/test_verified_commit_gate.py +581 -581
  91. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -131
  92. package/hooks/blocking/unscoped_search_blocker.py +391 -0
  93. package/hooks/blocking/verdict_directory_write_blocker.py +687 -687
  94. package/hooks/blocking/verification_verdict_store.py +1039 -1036
  95. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +167 -167
  96. package/hooks/blocking/verifier_verdict_minter.py +280 -280
  97. package/hooks/git-hooks/CLAUDE.md +3 -0
  98. package/hooks/git-hooks/conftest.py +30 -0
  99. package/hooks/git-hooks/gate_utils.py +2 -2
  100. package/hooks/git-hooks/git_hooks_constants/__init__.py +41 -2
  101. package/hooks/git-hooks/pre_push.py +75 -4
  102. package/hooks/git-hooks/pre_push_base_reference.py +166 -0
  103. package/hooks/git-hooks/test_config.py +0 -15
  104. package/hooks/git-hooks/test_gate_utils.py +3 -15
  105. package/hooks/git-hooks/test_pre_commit.py +1 -15
  106. package/hooks/git-hooks/test_pre_push.py +257 -23
  107. package/hooks/git-hooks/test_pre_push_base_reference.py +339 -0
  108. package/hooks/hooks.json +10 -12
  109. package/hooks/hooks_constants/CLAUDE.md +7 -2
  110. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -4
  111. package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +101 -0
  112. package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -18
  113. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +2 -8
  114. package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +48 -0
  115. package/hooks/hooks_constants/ruff_integration_constants.py +16 -0
  116. package/hooks/hooks_constants/shell_command_segments.py +82 -0
  117. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +67 -0
  118. package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -0
  119. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -6
  120. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +1 -0
  121. package/hooks/hooks_constants/unscoped_search_blocker_constants.py +153 -0
  122. package/hooks/lifecycle/enter_worktree_origin_prefetch.py +163 -146
  123. package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +185 -178
  124. package/hooks/pyproject.toml +1 -0
  125. package/hooks/validators/CLAUDE.md +1 -0
  126. package/hooks/validators/config/__init__.py +0 -0
  127. package/hooks/validators/config/directory_exemption_constants.py +183 -0
  128. package/hooks/validators/config/test_directory_exemption_constants.py +21 -0
  129. package/hooks/validators/conftest.py +4 -0
  130. package/hooks/validators/ruff_integration.py +49 -5
  131. package/hooks/validators/run_all_validators.py +206 -9
  132. package/hooks/validators/test_directory_exemption_constants.py +185 -0
  133. package/hooks/validators/test_python_antipattern_checks.py +110 -5
  134. package/hooks/validators/test_ruff_integration.py +92 -1
  135. package/hooks/validators/test_run_all_validators.py +115 -68
  136. package/hooks/validators/test_run_all_validators_pretooluse.py +159 -1
  137. package/package.json +13 -3
  138. package/rules/CLAUDE.md +17 -22
  139. package/rules/agent-spawn-protocol.md +6 -6
  140. package/rules/anti-corollary-tests.md +1 -1
  141. package/rules/bdd.md +1 -1
  142. package/rules/cleanup-temp-files.md +10 -4
  143. package/rules/code-standards.md +7 -0
  144. package/rules/conservative-action.md +1 -5
  145. package/rules/context7.md +0 -4
  146. package/rules/destructive-commands.md +47 -0
  147. package/rules/doc-inventory-integrity.md +48 -0
  148. package/rules/doc-prose-cuts.md +58 -0
  149. package/rules/docstring-prose-matches-implementation.md +53 -44
  150. package/rules/durable-post-artifacts.md +0 -4
  151. package/rules/eli11-replies.md +31 -0
  152. package/rules/explore-thoroughly.md +4 -4
  153. package/rules/falsify-before-green.md +68 -0
  154. package/rules/file-global-constants.md +1 -1
  155. package/rules/filesystem-search.md +51 -0
  156. package/rules/gh-cli-conventions.md +27 -0
  157. package/rules/git-workflow.md +26 -0
  158. package/rules/hedging-claims.md +9 -0
  159. package/rules/long-horizon-autonomy.md +0 -4
  160. package/rules/measurement-denominators.md +48 -0
  161. package/rules/nas-ssh-invocation.md +23 -5
  162. package/rules/parallel-tools.md +2 -2
  163. package/rules/plain-illustrative-docstrings.md +3 -7
  164. package/rules/plain-language.md +2 -0
  165. package/rules/proof-of-work-pr-comments.md +0 -4
  166. package/rules/re-stage-before-commit.md +2 -0
  167. package/rules/research-mode.md +10 -0
  168. package/rules/shell-invocation.md +21 -0
  169. package/rules/testing.md +4 -0
  170. package/rules/verified-commit-gate-skip.md +3 -27
  171. package/rules/verify-before-asking.md +5 -0
  172. package/rules/windows-filesystem-safe.md +1 -1
  173. package/rules/workers-done-before-complete.md +4 -0
  174. package/scripts/CLAUDE.md +1 -0
  175. package/scripts/Capture-PoolHealth.ps1 +410 -0
  176. package/scripts/Migrate-ShellPolicy.ps1 +1 -1
  177. package/scripts/_code_review_test_support.py +404 -0
  178. package/scripts/claude_chain_runner.py +141 -1
  179. package/scripts/codex_capability_bridge.py +171 -0
  180. package/scripts/codex_compat_materializer.py +1087 -0
  181. package/scripts/codex_compat_watcher.py +502 -0
  182. package/scripts/conftest.py +16 -1
  183. package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
  184. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +9 -0
  185. package/scripts/dev_env_scripts_constants/code_review_constants.py +37 -0
  186. package/scripts/invoke_code_review.py +11 -4
  187. package/scripts/resolve_worker_spawn.py +626 -626
  188. package/scripts/spawn_grok_batch.py +672 -672
  189. package/scripts/sync_to_cursor/rules.py +0 -10
  190. package/scripts/test_claude_chain_runner.py +131 -0
  191. package/scripts/test_invoke_code_review.py +85 -908
  192. package/scripts/test_invoke_code_review_chain.py +70 -0
  193. package/scripts/test_invoke_code_review_cli.py +192 -0
  194. package/scripts/test_invoke_code_review_contract.py +256 -0
  195. package/scripts/test_invoke_code_review_git.py +123 -0
  196. package/scripts/test_invoke_code_review_mode.py +99 -0
  197. package/scripts/test_resolve_worker_spawn.py +1014 -1014
  198. package/scripts/tests/test_code_review_constants.py +80 -0
  199. package/scripts/tests/test_codex_capability_bridge.py +91 -0
  200. package/scripts/tests/test_codex_compat_materializer.py +632 -0
  201. package/scripts/tests/test_codex_compat_watcher.py +599 -0
  202. package/scripts/tests/test_sync_to_cursor.py +0 -1
  203. package/skills/CLAUDE.md +2 -0
  204. package/skills/auditing-claude-config/SKILL.md +114 -114
  205. package/skills/autoconverge/SKILL.md +427 -427
  206. package/skills/autoconverge/reference/convergence.md +24 -3
  207. package/skills/autoconverge/workflow/CLAUDE.md +1 -0
  208. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +3 -3
  209. package/skills/autoconverge/workflow/converge.contract.test.mjs +1263 -1263
  210. package/skills/autoconverge/workflow/converge.mjs +168 -1
  211. package/skills/autoconverge/workflow/converge.p2-advance.test.mjs +202 -0
  212. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -2
  213. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -2
  214. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -2
  215. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -2
  216. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -2
  217. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -2
  218. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -2
  219. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -2
  220. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +265 -265
  221. package/skills/bugteam/reference/copilot-gap-analysis.md +1 -1
  222. package/skills/closeout/SKILL.md +33 -50
  223. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +8 -0
  224. package/skills/codex-review/scripts/run_codex_review.py +233 -1
  225. package/skills/codex-review/scripts/test_run_codex_review.py +189 -0
  226. package/skills/condensing-instructions/SKILL.md +72 -0
  227. package/skills/copilot-review/SKILL.md +119 -119
  228. package/skills/e-code-review/SKILL.md +52 -0
  229. package/skills/e-code-review/reference/fix.md +54 -0
  230. package/skills/e-code-review/reference/loop.md +43 -0
  231. package/skills/e-code-review/reference/low.md +57 -0
  232. package/skills/e-code-review/reference/medium.md +153 -0
  233. package/skills/e-code-review/reference/xhigh.md +182 -0
  234. package/skills/e-simplify/SKILL.md +97 -0
  235. package/skills/fresh-branch/CLAUDE.md +1 -1
  236. package/skills/fresh-branch/SKILL.md +5 -6
  237. package/skills/fresh-branch/scripts/create_fresh_branch.py +42 -24
  238. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -3
  239. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +30 -126
  240. package/skills/issue-tracker/SKILL.md +92 -0
  241. package/skills/issue-tracker/reference/epic-and-sub-issue-model.md +55 -0
  242. package/skills/issue-tracker/reference/handoff-schema.md +64 -0
  243. package/skills/issue-tracker/reference/operation-matrix.md +41 -0
  244. package/skills/orchestrator/SKILL.md +177 -22
  245. package/skills/orchestrator/scripts/status_gate.py +625 -0
  246. package/skills/orchestrator/scripts/status_gate_constants/__init__.py +1 -0
  247. package/skills/orchestrator/scripts/status_gate_constants/config/__init__.py +1 -0
  248. package/skills/orchestrator/scripts/status_gate_constants/config/constants.py +47 -0
  249. package/skills/orchestrator/scripts/test_status_gate.py +439 -0
  250. package/skills/orchestrator-refresh/SKILL.md +129 -35
  251. package/skills/plan-to-pr/SKILL.md +155 -0
  252. package/skills/plan-to-pr/reference/final-validation-tasks.md +15 -0
  253. package/skills/plan-to-pr/reference/model-routing.md +36 -0
  254. package/skills/plan-to-pr/reference/packet-contract.md +43 -0
  255. package/skills/plan-to-pr/reference/packet-schema.json +57 -0
  256. package/skills/plan-to-pr/reference/process-inventory.md +22 -0
  257. package/skills/plan-to-pr/reference/review-loop.md +33 -0
  258. package/skills/plan-to-pr/reference/run-record.schema.json +27 -0
  259. package/skills/plan-to-pr/reference/self-audit-tasks.md +15 -0
  260. package/skills/plan-to-pr/reference/task-seeds.md +14 -0
  261. package/skills/plan-to-pr/reference/task-ticket.md +38 -0
  262. package/skills/plan-to-pr/scripts/config/__init__.py +1 -0
  263. package/skills/plan-to-pr/scripts/config/constants.py +193 -0
  264. package/skills/plan-to-pr/scripts/create_packet.py +173 -0
  265. package/skills/plan-to-pr/scripts/test_create_packet.py +102 -0
  266. package/skills/plan-to-pr/scripts/test_validate_packet.py +256 -0
  267. package/skills/plan-to-pr/scripts/test_validate_protocol.py +135 -0
  268. package/skills/plan-to-pr/scripts/test_validate_run.py +158 -0
  269. package/skills/plan-to-pr/scripts/validate_packet.py +655 -0
  270. package/skills/plan-to-pr/scripts/validate_protocol.py +622 -0
  271. package/skills/plan-to-pr/scripts/validate_run.py +173 -0
  272. package/skills/plan-to-pr/test_skill_contract.py +207 -0
  273. package/skills/plan-to-pr/test_task_ticket_contract.py +151 -0
  274. package/skills/pr-converge/SKILL.md +472 -469
  275. package/skills/pr-converge/reference/examples.md +3 -3
  276. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  277. package/skills/pr-converge/reference/ground-rules.md +7 -4
  278. package/skills/pr-converge/reference/multi-pr-orchestration.md +4 -1
  279. package/skills/pr-converge/reference/per-tick.md +5 -5
  280. package/skills/pr-converge/reference/progress-checklist.md +1 -1
  281. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -279
  282. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -507
  283. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -84
  284. package/skills/pr-converge/test_step5_host_branch.py +1 -1
  285. package/skills/pr-fix-protocol/SKILL.md +1 -1
  286. package/skills/privacy-hygiene/SKILL.md +68 -68
  287. package/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
  288. package/skills/prototype/workflows/promotion.md +1 -1
  289. package/skills/release-notes-html/SKILL.md +164 -0
  290. package/skills/session-log/SKILL.md +1 -1
  291. package/skills/task-build/CLAUDE.md +8 -7
  292. package/skills/task-build/SKILL.md +16 -8
  293. package/skills/task-build/reference/tool-routing.md +19 -0
  294. package/rules/claude-md-orphan-file.md +0 -28
  295. package/rules/cleanup-command-forms.md +0 -23
  296. package/rules/code-reviews.md +0 -11
  297. package/rules/env-var-table-code-drift.md +0 -10
  298. package/rules/gh-body-file.md +0 -5
  299. package/rules/gh-paginate.md +0 -3
  300. package/rules/hook-prose-matches-detector.md +0 -15
  301. package/rules/no-historical-clutter.md +0 -26
  302. package/rules/no-inline-destructive-literals.md +0 -9
  303. package/rules/no-justification-noise.md +0 -61
  304. package/rules/package-inventory-stale-entry.md +0 -25
  305. package/rules/right-sized-engineering.md +0 -28
  306. package/rules/self-contained-docs.md +0 -17
  307. package/rules/shell-invocation-policy.md +0 -5
  308. package/rules/tdd.md +0 -7
  309. package/skills/closeout/reference/issue-body-templates.md +0 -108
@@ -1,966 +1,143 @@
1
- """Behavioral tests for the host-aware ``/code-review`` invoker and stamps."""
1
+ """Specifications for what a blocked mint tells the person it blocked.
2
+
3
+ Enforcement refuses every push until a clean stamp covers the branch surface,
4
+ and ``invoke_code_review.py --record-stamp`` is the only way to mint one. When
5
+ that call cannot run, the reason it names is the person's only route back to a
6
+ working push.
7
+ """
2
8
 
3
9
  from __future__ import annotations
4
10
 
5
- import json
6
- import subprocess
7
- import sys
8
- from collections.abc import Sequence
9
- from dataclasses import dataclass, field
10
11
  from pathlib import Path
11
12
 
12
13
  import pytest
13
14
 
14
- _SCRIPTS_DIR = Path(__file__).resolve().parent
15
- if str(_SCRIPTS_DIR) not in sys.path:
16
- sys.path.insert(0, str(_SCRIPTS_DIR))
17
-
18
- import claude_chain_runner as chain_runner # noqa: E402
19
- import invoke_code_review as invoker # noqa: E402
20
- from claude_chain_runner import ( # noqa: E402
21
- ChainAttempt,
22
- ChainConfigurationError,
23
- ChainInvocationOutcome,
24
- )
25
- from dev_env_scripts_constants.claude_chain_constants import ( # noqa: E402
26
- CHAIN_CONFIG_ERROR_EXIT_CODE,
27
- )
28
- from dev_env_scripts_constants.code_review_constants import ( # noqa: E402
29
- CLI_SESSION_MODEL_FLAG,
30
- CODE_REVIEW_MODEL_ALIAS,
15
+ import invoke_code_review as invoker
16
+ from claude_chain_runner import ChainConfigurationError, ChainInvocationOutcome
17
+ from _code_review_test_support import FIXTURE_SESSION_OPUS
18
+ from dev_env_scripts_constants.code_review_constants import (
31
19
  DEFAULT_CODE_REVIEW_EFFORT,
32
- GIT_BINARY,
33
- GIT_PORCELAIN_FLAG,
34
- GIT_STATUS_SUBCOMMAND,
35
- HOST_PROFILE_ERROR_RETURNCODE,
36
- IN_SESSION_RETURNCODE,
37
- MODE_CHAIN,
38
- MODE_IN_SESSION,
20
+ PERMISSION_MODE_ACCEPT_EDITS,
39
21
  PERMISSION_MODE_BYPASS,
40
- PERMISSION_MODE_FLAG,
41
- RESULT_KEY_DIRTY_TREE,
42
- RESULT_KEY_MODE,
43
- RESULT_KEY_RETURNCODE,
44
- RESULT_KEY_SERVED_COMMAND,
45
- )
46
- from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
47
- CLI_TIMEOUT_FLAG,
48
- CWD_FLAG,
49
- MODEL_FLAG,
50
- OUTPUT_FORMAT_FLAG,
51
- OUTPUT_FORMAT_JSON,
52
- SINGLE_TURN_FLAG,
53
- )
54
- from dev_env_scripts_constants.timing import ( # noqa: E402
55
- DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS,
22
+ REVIEW_PERMISSION_MODE,
56
23
  )
57
24
 
58
- HOST_PROFILE_CLAUDE = "Claude"
59
- HOST_PROFILE_THIRD_PARTY = "ThirdParty"
60
25
 
61
- FIXTURE_SERVED_COMMAND = "claude"
62
- FIXTURE_CHAIN_RETURNCODE = 0
63
- FIXTURE_FAILED_RETURNCODE = 1
64
- FIXTURE_GIT_STATUS_FAILURE_RETURNCODE = 128
65
- FIXTURE_CHAIN_STDOUT = '{"result":"review done"}'
66
- FIXTURE_SESSION_OPUS = "opus"
67
- FIXTURE_SESSION_OPUS_UPPER = "Opus"
68
- FIXTURE_SESSION_SONNET = "sonnet"
69
- FIXTURE_SESSION_HAIKU = "haiku"
70
- FIXTURE_CHAIN_CONFIG_ERROR_MESSAGE = "chain config missing"
71
- FIXTURE_HOST_PROFILE_ERROR_MESSAGE = "unknown host profile"
72
- DIRTY_FILE_NAME = "review_fix.txt"
73
- DIRTY_FILE_CONTENTS = "applied fix\n"
74
- GIT_INIT_TIMEOUT_SECONDS = 30
26
+ def test_review_arguments_carry_the_permission_mode_this_caller_resolves() -> None:
27
+ """The review command asks for a permission mode the binary accepts here.
75
28
 
29
+ The binary refuses the bypass mode outright for a root caller, so asking
30
+ for it there means no review runs and no stamp is ever minted.
31
+ """
32
+ all_arguments = invoker.build_code_review_arguments(DEFAULT_CODE_REVIEW_EFFORT)
76
33
 
77
- def _claude_served(
78
- *,
79
- returncode: int = FIXTURE_CHAIN_RETURNCODE,
80
- stdout: str = FIXTURE_CHAIN_STDOUT,
81
- ) -> ChainInvocationOutcome:
82
- return ChainInvocationOutcome(
83
- served_command=FIXTURE_SERVED_COMMAND,
84
- returncode=returncode,
85
- stdout=stdout,
86
- stderr="",
87
- attempts=(
88
- ChainAttempt(command=FIXTURE_SERVED_COMMAND, status="served"),
89
- ),
90
- )
91
-
92
-
93
- def _claude_failed() -> ChainInvocationOutcome:
94
- return ChainInvocationOutcome(
95
- served_command=None,
96
- returncode=FIXTURE_FAILED_RETURNCODE,
97
- stdout="",
98
- stderr="chain exhausted",
99
- attempts=(
100
- ChainAttempt(command=FIXTURE_SERVED_COMMAND, status="usage_limited"),
101
- ),
102
- )
103
-
104
-
105
- def _init_git_repository(repository_directory: Path) -> Path:
106
- repository_directory.mkdir(parents=True, exist_ok=True)
107
- subprocess.run(
108
- [GIT_BINARY, "init"],
109
- cwd=str(repository_directory),
110
- check=True,
111
- capture_output=True,
112
- text=True,
113
- timeout=GIT_INIT_TIMEOUT_SECONDS,
114
- )
115
- subprocess.run(
116
- [GIT_BINARY, "config", "user.email", "reviewer@example.com"],
117
- cwd=str(repository_directory),
118
- check=True,
119
- capture_output=True,
120
- text=True,
121
- timeout=GIT_INIT_TIMEOUT_SECONDS,
122
- )
123
- subprocess.run(
124
- [GIT_BINARY, "config", "user.name", "Reviewer"],
125
- cwd=str(repository_directory),
126
- check=True,
127
- capture_output=True,
128
- text=True,
129
- timeout=GIT_INIT_TIMEOUT_SECONDS,
130
- )
131
- tracked_file = repository_directory / "README.md"
132
- tracked_file.write_text("baseline\n", encoding="utf-8")
133
- subprocess.run(
134
- [GIT_BINARY, "add", "README.md"],
135
- cwd=str(repository_directory),
136
- check=True,
137
- capture_output=True,
138
- text=True,
139
- timeout=GIT_INIT_TIMEOUT_SECONDS,
140
- )
141
- subprocess.run(
142
- [GIT_BINARY, "commit", "-m", "baseline"],
143
- cwd=str(repository_directory),
144
- check=True,
145
- capture_output=True,
146
- text=True,
147
- timeout=GIT_INIT_TIMEOUT_SECONDS,
148
- )
149
- return repository_directory
150
-
151
-
152
- @dataclass
153
- class SeamCallLog:
154
- claude_calls: int = 0
155
- claude_arguments: list[str] | None = None
156
- host_profile_calls: int = 0
157
- is_stdin_empty: bool = False
158
- claude_working_directory: Path | None = None
159
- all_observed_working_directories: list[Path] = field(default_factory=list)
160
- all_git_status_commands: list[list[str]] = field(default_factory=list)
161
-
162
-
163
- def _install_seams(
164
- monkeypatch: pytest.MonkeyPatch,
165
- *,
166
- host_profile: str = HOST_PROFILE_CLAUDE,
167
- claude_outcome: ChainInvocationOutcome | BaseException | None = None,
168
- should_dirty_tree_on_chain: bool = False,
169
- working_directory: Path | None = None,
170
- ) -> SeamCallLog:
171
- call_log = SeamCallLog()
34
+ assert REVIEW_PERMISSION_MODE in all_arguments
172
35
 
173
- def fake_host_profile(
174
- setting_by_name: object | None = None,
175
- ) -> str:
176
- del setting_by_name
177
- call_log.host_profile_calls += 1
178
- return host_profile
179
36
 
180
- def fake_claude(
181
- all_claude_arguments: list[str], *, timeout_seconds: int
182
- ) -> ChainInvocationOutcome:
183
- call_log.claude_calls += 1
184
- call_log.claude_arguments = list(all_claude_arguments)
185
- chain_runner.chain_subprocess_runner(
186
- ["claude", *all_claude_arguments],
187
- capture_output=True,
188
- text=True,
189
- timeout=timeout_seconds,
190
- check=False,
191
- )
192
- if should_dirty_tree_on_chain and working_directory is not None:
193
- dirty_file = working_directory / DIRTY_FILE_NAME
194
- dirty_file.write_text(DIRTY_FILE_CONTENTS, encoding="utf-8")
195
- if isinstance(claude_outcome, BaseException):
196
- raise claude_outcome
197
- assert isinstance(claude_outcome, ChainInvocationOutcome)
198
- return claude_outcome
199
-
200
- def _tracking_subprocess_runner(
201
- all_invocation_tokens: Sequence[str],
202
- *all_positionals: object,
203
- **all_keywords: object,
204
- ) -> subprocess.CompletedProcess[str]:
205
- del all_positionals
206
- maybe_stdin = all_keywords.get("stdin")
207
- if maybe_stdin is subprocess.DEVNULL:
208
- call_log.is_stdin_empty = True
209
- elif maybe_stdin is not None:
210
- maybe_read = getattr(maybe_stdin, "read", None)
211
- if callable(maybe_read):
212
- call_log.is_stdin_empty = maybe_read() == ""
213
- maybe_seek = getattr(maybe_stdin, "seek", None)
214
- if callable(maybe_seek):
215
- maybe_seek(0)
216
- maybe_cwd = all_keywords.get("cwd")
217
- if maybe_cwd is not None:
218
- resolved_directory = Path(str(maybe_cwd))
219
- call_log.claude_working_directory = resolved_directory
220
- call_log.all_observed_working_directories.append(resolved_directory)
221
- return subprocess.CompletedProcess(
222
- args=list(all_invocation_tokens),
223
- returncode=0,
224
- stdout="{}",
225
- stderr="",
226
- )
227
-
228
- monkeypatch.setattr(invoker, "review_host_profile_detector", fake_host_profile)
229
- monkeypatch.setattr(invoker, "review_claude_runner", fake_claude)
230
- monkeypatch.setattr(
231
- chain_runner, "chain_subprocess_runner", _tracking_subprocess_runner
232
- )
233
- return call_log
234
-
235
-
236
- @pytest.mark.parametrize(
237
- ("host_profile", "session_model", "expected_mode"),
238
- [
239
- (HOST_PROFILE_CLAUDE, FIXTURE_SESSION_OPUS, MODE_IN_SESSION),
240
- (HOST_PROFILE_CLAUDE, FIXTURE_SESSION_OPUS_UPPER, MODE_IN_SESSION),
241
- (HOST_PROFILE_CLAUDE, FIXTURE_SESSION_SONNET, MODE_CHAIN),
242
- (HOST_PROFILE_CLAUDE, FIXTURE_SESSION_HAIKU, MODE_CHAIN),
243
- (HOST_PROFILE_THIRD_PARTY, FIXTURE_SESSION_OPUS, MODE_CHAIN),
244
- (HOST_PROFILE_THIRD_PARTY, FIXTURE_SESSION_SONNET, MODE_CHAIN),
245
- ],
246
- )
247
- def test_mode_decision_host_and_model_matrix(
248
- monkeypatch: pytest.MonkeyPatch,
249
- tmp_path: Path,
250
- host_profile: str,
251
- session_model: str,
252
- expected_mode: str,
253
- ) -> None:
254
- working_directory = _init_git_repository(tmp_path / "repo")
255
- claude_outcome = _claude_served() if expected_mode == MODE_CHAIN else None
256
- call_log = _install_seams(
257
- monkeypatch,
258
- host_profile=host_profile,
259
- claude_outcome=claude_outcome,
260
- working_directory=working_directory,
261
- )
262
-
263
- review_outcome = invoker.invoke_code_review(
264
- working_directory=working_directory,
265
- session_model=session_model,
266
- timeout_seconds=DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS,
267
- )
268
-
269
- assert review_outcome.mode == expected_mode
270
- assert call_log.host_profile_calls == 1
271
- if expected_mode == MODE_IN_SESSION:
272
- assert call_log.claude_calls == 0
273
- assert review_outcome.served_command is None
274
- assert review_outcome.returncode == IN_SESSION_RETURNCODE
275
- assert review_outcome.is_dirty_tree is False
276
- else:
277
- assert call_log.claude_calls == 1
278
-
279
-
280
- def test_chain_argv_assembly(
281
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
282
- ) -> None:
283
- working_directory = _init_git_repository(tmp_path / "repo")
284
- call_log = _install_seams(
285
- monkeypatch,
286
- host_profile=HOST_PROFILE_THIRD_PARTY,
287
- claude_outcome=_claude_served(),
288
- working_directory=working_directory,
289
- )
290
-
291
- invoker.invoke_code_review(
292
- working_directory=working_directory,
293
- session_model=FIXTURE_SESSION_SONNET,
294
- timeout_seconds=DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS,
295
- )
296
-
297
- assert call_log.claude_arguments == [
298
- SINGLE_TURN_FLAG,
299
- invoker.build_code_review_prompt(DEFAULT_CODE_REVIEW_EFFORT),
300
- MODEL_FLAG,
301
- CODE_REVIEW_MODEL_ALIAS,
302
- OUTPUT_FORMAT_FLAG,
303
- OUTPUT_FORMAT_JSON,
304
- PERMISSION_MODE_FLAG,
37
+ def test_the_resolved_permission_mode_is_one_the_binary_knows() -> None:
38
+ assert REVIEW_PERMISSION_MODE in (
39
+ PERMISSION_MODE_ACCEPT_EDITS,
305
40
  PERMISSION_MODE_BYPASS,
306
- ]
307
-
308
-
309
- def test_chain_redirects_empty_stdin_and_sets_cwd(
310
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
311
- ) -> None:
312
- working_directory = _init_git_repository(tmp_path / "repo")
313
- call_log = _install_seams(
314
- monkeypatch,
315
- host_profile=HOST_PROFILE_THIRD_PARTY,
316
- claude_outcome=_claude_served(),
317
- working_directory=working_directory,
318
- )
319
-
320
- invoker.invoke_code_review(
321
- working_directory=working_directory,
322
- session_model=FIXTURE_SESSION_OPUS,
323
- timeout_seconds=DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS,
324
- )
325
-
326
- assert call_log.is_stdin_empty is True
327
- assert call_log.claude_working_directory == working_directory
328
-
329
-
330
- def test_dirty_tree_true_after_chain_writes_file(
331
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
332
- ) -> None:
333
- working_directory = _init_git_repository(tmp_path / "repo")
334
- _install_seams(
335
- monkeypatch,
336
- host_profile=HOST_PROFILE_THIRD_PARTY,
337
- claude_outcome=_claude_served(),
338
- should_dirty_tree_on_chain=True,
339
- working_directory=working_directory,
340
- )
341
-
342
- review_outcome = invoker.invoke_code_review(
343
- working_directory=working_directory,
344
- session_model=FIXTURE_SESSION_OPUS,
345
- timeout_seconds=DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS,
346
- )
347
-
348
- assert review_outcome.mode == MODE_CHAIN
349
- assert review_outcome.is_dirty_tree is True
350
- assert review_outcome.served_command == FIXTURE_SERVED_COMMAND
351
- assert review_outcome.returncode == FIXTURE_CHAIN_RETURNCODE
352
-
353
-
354
- def test_dirty_tree_false_when_chain_leaves_tree_clean(
355
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
356
- ) -> None:
357
- working_directory = _init_git_repository(tmp_path / "repo")
358
- _install_seams(
359
- monkeypatch,
360
- host_profile=HOST_PROFILE_THIRD_PARTY,
361
- claude_outcome=_claude_served(),
362
- should_dirty_tree_on_chain=False,
363
- working_directory=working_directory,
364
- )
365
-
366
- review_outcome = invoker.invoke_code_review(
367
- working_directory=working_directory,
368
- session_model=FIXTURE_SESSION_OPUS,
369
- timeout_seconds=DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS,
370
- )
371
-
372
- assert review_outcome.is_dirty_tree is False
373
-
374
-
375
- def test_cli_prints_result_json_only(
376
- monkeypatch: pytest.MonkeyPatch,
377
- tmp_path: Path,
378
- capsys: pytest.CaptureFixture[str],
379
- ) -> None:
380
- working_directory = _init_git_repository(tmp_path / "repo")
381
- _install_seams(
382
- monkeypatch,
383
- host_profile=HOST_PROFILE_CLAUDE,
384
- claude_outcome=None,
385
- working_directory=working_directory,
386
- )
387
-
388
- exit_code = invoker.main(
389
- [
390
- CWD_FLAG,
391
- str(working_directory),
392
- CLI_SESSION_MODEL_FLAG,
393
- FIXTURE_SESSION_OPUS,
394
- CLI_TIMEOUT_FLAG,
395
- str(DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS),
396
- ]
397
- )
398
-
399
- assert exit_code == IN_SESSION_RETURNCODE
400
- captured = capsys.readouterr()
401
- assert captured.err == ""
402
- parsed_payload = json.loads(captured.out)
403
- assert parsed_payload == {
404
- RESULT_KEY_MODE: MODE_IN_SESSION,
405
- RESULT_KEY_SERVED_COMMAND: None,
406
- RESULT_KEY_RETURNCODE: IN_SESSION_RETURNCODE,
407
- RESULT_KEY_DIRTY_TREE: False,
408
- }
409
-
410
-
411
- def test_build_code_review_arguments_matches_contract() -> None:
412
- all_arguments = invoker.build_code_review_arguments()
413
- assert all_arguments == [
414
- SINGLE_TURN_FLAG,
415
- invoker.build_code_review_prompt(DEFAULT_CODE_REVIEW_EFFORT),
416
- MODEL_FLAG,
417
- CODE_REVIEW_MODEL_ALIAS,
418
- OUTPUT_FORMAT_FLAG,
419
- OUTPUT_FORMAT_JSON,
420
- PERMISSION_MODE_FLAG,
421
- PERMISSION_MODE_BYPASS,
422
- ]
423
-
424
-
425
- def test_is_working_tree_dirty_against_real_git_repo(tmp_path: Path) -> None:
426
- working_directory = _init_git_repository(tmp_path / "repo")
427
- assert invoker.is_working_tree_dirty(working_directory) is False
428
- (working_directory / DIRTY_FILE_NAME).write_text(
429
- DIRTY_FILE_CONTENTS, encoding="utf-8"
430
- )
431
- assert invoker.is_working_tree_dirty(working_directory) is True
432
-
433
-
434
- def test_git_status_command_uses_porcelain(
435
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
436
- ) -> None:
437
- working_directory = _init_git_repository(tmp_path / "repo")
438
- all_commands: list[list[str]] = []
439
-
440
- def fake_git_status(
441
- all_command_tokens: Sequence[str],
442
- *all_positionals: object,
443
- **all_keywords: object,
444
- ) -> subprocess.CompletedProcess[str]:
445
- del all_positionals, all_keywords
446
- all_commands.append(list(all_command_tokens))
447
- return subprocess.CompletedProcess(
448
- args=list(all_command_tokens),
449
- returncode=0,
450
- stdout="",
451
- stderr="",
452
- )
453
-
454
- monkeypatch.setattr(invoker, "review_git_status_runner", fake_git_status)
455
- invoker.is_working_tree_dirty(working_directory)
456
- assert all_commands == [
457
- [GIT_BINARY, GIT_STATUS_SUBCOMMAND, GIT_PORCELAIN_FLAG]
458
- ]
459
-
460
-
461
- def test_chain_failure_preserves_returncode(
462
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
463
- ) -> None:
464
- working_directory = _init_git_repository(tmp_path / "repo")
465
- _install_seams(
466
- monkeypatch,
467
- host_profile=HOST_PROFILE_THIRD_PARTY,
468
- claude_outcome=_claude_failed(),
469
- working_directory=working_directory,
470
- )
471
-
472
- review_outcome = invoker.invoke_code_review(
473
- working_directory=working_directory,
474
- session_model=FIXTURE_SESSION_OPUS,
475
- timeout_seconds=DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS,
476
41
  )
477
42
 
478
- assert review_outcome.mode == MODE_CHAIN
479
- assert review_outcome.served_command is None
480
- assert review_outcome.returncode == FIXTURE_FAILED_RETURNCODE
481
- assert review_outcome.is_dirty_tree is False
482
- assert invoker.is_successful_code_review(review_outcome) is False
483
- assert invoker.is_code_review_clean_stamp_allowed(review_outcome) is False
484
43
 
485
-
486
- def test_is_working_tree_dirty_nonzero_returncode_is_not_clean(
487
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
488
- ) -> None:
489
- working_directory = _init_git_repository(tmp_path / "repo")
490
-
491
- def fake_git_status(
492
- all_command_tokens: Sequence[str],
493
- *all_positionals: object,
494
- **all_keywords: object,
495
- ) -> subprocess.CompletedProcess[str]:
496
- del all_positionals, all_keywords
497
- return subprocess.CompletedProcess(
498
- args=list(all_command_tokens),
499
- returncode=FIXTURE_GIT_STATUS_FAILURE_RETURNCODE,
500
- stdout="",
501
- stderr="fatal: not a git repository",
502
- )
503
-
504
- monkeypatch.setattr(invoker, "review_git_status_runner", fake_git_status)
505
- assert invoker.is_working_tree_dirty(working_directory) is True
44
+ CHAIN_CONFIG_REMEDY_TEXT: str = (
45
+ "Claude chain config not found at the path this specification names. "
46
+ "Copy the example config there and list your account binaries."
47
+ )
48
+ HOST_PROFILE_FAILURE_TEXT: str = "session model alias carries no host profile"
49
+ MINT_TIMEOUT_SECONDS: int = 1
50
+ SERVED_COMMAND_NAME: str = "claude"
51
+ REVIEW_BINARY_REFUSAL_TEXT: str = (
52
+ "--dangerously-skip-permissions cannot be used with root privileges"
53
+ )
54
+ REVIEW_FAILURE_RETURNCODE: int = 1
55
+ EMPTY_REVIEW_STDOUT: str = ""
56
+ ROOT_USER_ID: int = 0
57
+ UNPRIVILEGED_USER_ID: int = 1000
506
58
 
507
59
 
508
- def test_cli_emits_json_on_chain_configuration_error(
509
- monkeypatch: pytest.MonkeyPatch,
510
- tmp_path: Path,
511
- capsys: pytest.CaptureFixture[str],
512
- ) -> None:
513
- working_directory = _init_git_repository(tmp_path / "repo")
514
- _install_seams(
515
- monkeypatch,
516
- host_profile=HOST_PROFILE_THIRD_PARTY,
517
- claude_outcome=ChainConfigurationError(FIXTURE_CHAIN_CONFIG_ERROR_MESSAGE),
518
- working_directory=working_directory,
519
- )
520
-
521
- exit_code = invoker.main(
522
- [
523
- CWD_FLAG,
524
- str(working_directory),
525
- CLI_SESSION_MODEL_FLAG,
526
- FIXTURE_SESSION_OPUS,
527
- CLI_TIMEOUT_FLAG,
528
- str(DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS),
529
- ]
60
+ def _serve_a_refusing_binary(
61
+ *_all_positional: object, **_all_keyword: object
62
+ ) -> ChainInvocationOutcome:
63
+ return ChainInvocationOutcome(
64
+ served_command=SERVED_COMMAND_NAME,
65
+ returncode=REVIEW_FAILURE_RETURNCODE,
66
+ stdout=EMPTY_REVIEW_STDOUT,
67
+ stderr=REVIEW_BINARY_REFUSAL_TEXT,
68
+ attempts=(),
530
69
  )
531
70
 
532
- assert exit_code == CHAIN_CONFIG_ERROR_EXIT_CODE
533
- captured = capsys.readouterr()
534
- parsed_payload = json.loads(captured.out)
535
- assert parsed_payload == {
536
- RESULT_KEY_MODE: MODE_CHAIN,
537
- RESULT_KEY_SERVED_COMMAND: None,
538
- RESULT_KEY_RETURNCODE: CHAIN_CONFIG_ERROR_EXIT_CODE,
539
- RESULT_KEY_DIRTY_TREE: False,
540
- }
541
- assert invoker.is_code_review_clean_stamp_allowed(
542
- invoker.CodeReviewOutcome(
543
- mode=MODE_CHAIN,
544
- served_command=None,
545
- returncode=CHAIN_CONFIG_ERROR_EXIT_CODE,
546
- is_dirty_tree=False,
547
- )
548
- ) is False
549
-
550
71
 
551
- def test_cli_emits_json_on_host_profile_value_error(
72
+ def test_failed_review_reports_what_the_served_binary_wrote(
552
73
  monkeypatch: pytest.MonkeyPatch,
553
74
  tmp_path: Path,
554
75
  capsys: pytest.CaptureFixture[str],
555
76
  ) -> None:
556
- working_directory = _init_git_repository(tmp_path / "repo")
557
-
558
- def fake_host_profile_raises(
559
- setting_by_name: object | None = None,
560
- ) -> str:
561
- del setting_by_name
562
- raise ValueError(FIXTURE_HOST_PROFILE_ERROR_MESSAGE)
77
+ """A review that a served binary refused names the refusal.
563
78
 
79
+ The binary can decline for reasons the caller must act on, such as a
80
+ permission mode it will not accept. Dropping its words leaves a bare
81
+ exit code, and no stamp is minted either way.
82
+ """
564
83
  monkeypatch.setattr(
565
- invoker, "review_host_profile_detector", fake_host_profile_raises
84
+ invoker, "_run_claude_with_empty_stdin", _serve_a_refusing_binary
566
85
  )
567
86
 
568
- exit_code = invoker.main(
569
- [
570
- CWD_FLAG,
571
- str(working_directory),
572
- CLI_SESSION_MODEL_FLAG,
573
- FIXTURE_SESSION_OPUS,
574
- CLI_TIMEOUT_FLAG,
575
- str(DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS),
576
- ]
577
- )
578
-
579
- assert exit_code == HOST_PROFILE_ERROR_RETURNCODE
580
- captured = capsys.readouterr()
581
- parsed_payload = json.loads(captured.out)
582
- assert parsed_payload == {
583
- RESULT_KEY_MODE: MODE_CHAIN,
584
- RESULT_KEY_SERVED_COMMAND: None,
585
- RESULT_KEY_RETURNCODE: HOST_PROFILE_ERROR_RETURNCODE,
586
- RESULT_KEY_DIRTY_TREE: False,
587
- }
588
-
589
-
590
- def test_clean_stamp_allowed_only_on_successful_clean_serve() -> None:
591
- clean_success = invoker.CodeReviewOutcome(
592
- mode=MODE_CHAIN,
593
- served_command=FIXTURE_SERVED_COMMAND,
594
- returncode=FIXTURE_CHAIN_RETURNCODE,
595
- is_dirty_tree=False,
596
- )
597
- dirty_success = invoker.CodeReviewOutcome(
598
- mode=MODE_CHAIN,
599
- served_command=FIXTURE_SERVED_COMMAND,
600
- returncode=FIXTURE_CHAIN_RETURNCODE,
601
- is_dirty_tree=True,
602
- )
603
- failed_serve = invoker.CodeReviewOutcome(
604
- mode=MODE_CHAIN,
605
- served_command=None,
606
- returncode=FIXTURE_FAILED_RETURNCODE,
607
- is_dirty_tree=False,
87
+ outcome = invoker._run_chain_review(
88
+ working_directory=tmp_path,
89
+ timeout_seconds=MINT_TIMEOUT_SECONDS,
90
+ effort=DEFAULT_CODE_REVIEW_EFFORT,
608
91
  )
609
- in_session_ready = invoker.CodeReviewOutcome(
610
- mode=MODE_IN_SESSION,
611
- served_command=None,
612
- returncode=IN_SESSION_RETURNCODE,
613
- is_dirty_tree=False,
614
- )
615
-
616
- assert invoker.is_code_review_clean_stamp_allowed(clean_success) is True
617
- assert invoker.is_code_review_clean_stamp_allowed(dirty_success) is False
618
- assert invoker.is_code_review_clean_stamp_allowed(failed_serve) is False
619
- assert invoker.is_code_review_clean_stamp_allowed(in_session_ready) is True
620
- assert invoker.is_successful_code_review(failed_serve) is False
621
- assert invoker.is_successful_code_review(clean_success) is True
622
92
 
93
+ captured_streams = capsys.readouterr()
94
+ assert outcome.returncode == REVIEW_FAILURE_RETURNCODE
95
+ assert REVIEW_BINARY_REFUSAL_TEXT in captured_streams.err
623
96
 
624
- @pytest.mark.parametrize(
625
- ("session_model", "expected_is_opus"),
626
- [
627
- (FIXTURE_SESSION_OPUS, True),
628
- (FIXTURE_SESSION_OPUS_UPPER, True),
629
- (FIXTURE_SESSION_SONNET, False),
630
- (" opus ", True),
631
- ],
632
- )
633
- def test_is_opus_session_model(
634
- session_model: str, expected_is_opus: bool
635
- ) -> None:
636
- assert invoker.is_opus_session_model(session_model) is expected_is_opus
637
-
638
-
639
- @pytest.mark.parametrize(
640
- ("host_profile", "session_model", "expected_mode"),
641
- [
642
- (HOST_PROFILE_CLAUDE, FIXTURE_SESSION_OPUS, MODE_IN_SESSION),
643
- (HOST_PROFILE_CLAUDE, FIXTURE_SESSION_SONNET, MODE_CHAIN),
644
- (HOST_PROFILE_THIRD_PARTY, FIXTURE_SESSION_OPUS, MODE_CHAIN),
645
- ],
646
- )
647
- def test_decide_review_mode(
648
- host_profile: str, session_model: str, expected_mode: str
649
- ) -> None:
650
- assert (
651
- invoker.decide_review_mode(
652
- host_profile=host_profile,
653
- session_model=session_model,
654
- )
655
- == expected_mode
656
- )
657
-
658
-
659
- def test_encode_code_review_outcome_shape() -> None:
660
- review_outcome = invoker.CodeReviewOutcome(
661
- mode=MODE_CHAIN,
662
- served_command=FIXTURE_SERVED_COMMAND,
663
- returncode=FIXTURE_CHAIN_RETURNCODE,
664
- is_dirty_tree=True,
665
- )
666
- encoded_payload = invoker.encode_code_review_outcome(review_outcome)
667
- assert encoded_payload == {
668
- RESULT_KEY_MODE: MODE_CHAIN,
669
- RESULT_KEY_SERVED_COMMAND: FIXTURE_SERVED_COMMAND,
670
- RESULT_KEY_RETURNCODE: FIXTURE_CHAIN_RETURNCODE,
671
- RESULT_KEY_DIRTY_TREE: True,
672
- }
673
-
674
-
675
- EFFORT_LOW = "low"
676
- REJECTED_ULTRA_EFFORT = "ultra"
677
- RECORD_STAMP_MINT_CAP = 3
678
- SINGLE_PASS_CAP = 1
679
- INVALID_EFFORT_EXIT_CODE = 2
680
- DID_NOT_CONVERGE_EXIT_CODE = 1
681
- RECORD_STAMP_CLI_FLAG = "--record-stamp"
682
- RESULT_STAMP_MINTED_KEY = "stamp_minted"
683
- RESULT_PASS_COUNT_KEY = "pass_count"
684
- RESULT_BOUND_HASH_KEY = "bound_hash"
685
- MISSING_STORE_FILE_NAME = "code_review_stamp_store_absent.py"
686
- SURFACE_SOURCE = "def add(left: int, right: int) -> int:\n return left + right\n"
687
- SURFACE_CHANGE_SOURCE = "def add(left: int, right: int) -> int:\n return left - right\n"
688
-
689
-
690
- def _run_git(repository_directory: Path, *git_arguments: str) -> None:
691
- subprocess.run(
692
- [GIT_BINARY, "-C", str(repository_directory), *git_arguments],
693
- check=True,
694
- capture_output=True,
695
- text=True,
696
- timeout=GIT_INIT_TIMEOUT_SECONDS,
697
- )
698
-
699
-
700
- def _make_repo_with_change_surface(tmp_path: Path) -> Path:
701
- origin_directory = tmp_path / "origin.git"
702
- work_directory = tmp_path / "work"
703
- work_directory.mkdir()
704
- subprocess.run(
705
- [GIT_BINARY, "init", "--bare", "--initial-branch=main", str(origin_directory)],
706
- check=True,
707
- capture_output=True,
708
- text=True,
709
- timeout=GIT_INIT_TIMEOUT_SECONDS,
710
- )
711
- _run_git(work_directory, "init", "--initial-branch=main")
712
- _run_git(work_directory, "config", "user.email", "tests@example.com")
713
- _run_git(work_directory, "config", "user.name", "Reviewer")
714
- (work_directory / "app.py").write_text(SURFACE_SOURCE, encoding="utf-8")
715
- _run_git(work_directory, "add", "-A")
716
- _run_git(work_directory, "commit", "-m", "base")
717
- _run_git(work_directory, "remote", "add", "origin", str(origin_directory))
718
- _run_git(work_directory, "push", "-u", "origin", "main")
719
- (work_directory / "app.py").write_text(SURFACE_CHANGE_SOURCE, encoding="utf-8")
720
- return work_directory
721
-
722
-
723
- def _isolate_home(monkeypatch: pytest.MonkeyPatch, fake_home: Path) -> None:
724
- home_text = str(fake_home)
725
- monkeypatch.setenv("HOME", home_text)
726
- monkeypatch.setenv("USERPROFILE", home_text)
727
- monkeypatch.delenv("HOMEDRIVE", raising=False)
728
- monkeypatch.delenv("HOMEPATH", raising=False)
729
-
730
-
731
- def _prepared_surface_repo(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
732
- fake_home = tmp_path / "home"
733
- fake_home.mkdir()
734
- _isolate_home(monkeypatch, fake_home)
735
- return _make_repo_with_change_surface(tmp_path)
736
-
737
-
738
- def _chain_clean_outcome() -> invoker.CodeReviewOutcome:
739
- return invoker.CodeReviewOutcome(
740
- mode=MODE_CHAIN,
741
- served_command=FIXTURE_SERVED_COMMAND,
742
- returncode=FIXTURE_CHAIN_RETURNCODE,
743
- is_dirty_tree=True,
744
- )
745
97
 
746
-
747
- def _stable_clean_review(**_review_keywords: object) -> invoker.CodeReviewOutcome:
748
- return _chain_clean_outcome()
749
-
750
-
751
- def _surface_changing_review(
752
- *, working_directory: Path, **_review_keywords: object
753
- ) -> invoker.CodeReviewOutcome:
754
- applied_fix_path = working_directory / DIRTY_FILE_NAME
755
- applied_fix_path.write_text(DIRTY_FILE_CONTENTS, encoding="utf-8")
756
- return _chain_clean_outcome()
757
-
758
-
759
- class _DriftingReview:
760
- def __init__(self) -> None:
761
- self.pass_count = 0
762
-
763
- def __call__(
764
- self, *, working_directory: Path, **_review_keywords: object
765
- ) -> invoker.CodeReviewOutcome:
766
- self.pass_count += 1
767
- drift_path = working_directory / f"fix_{self.pass_count}.txt"
768
- drift_path.write_text(str(self.pass_count), encoding="utf-8")
769
- return _chain_clean_outcome()
770
-
771
-
772
- @pytest.mark.parametrize("valid_effort", ["low", "medium", "high", "xhigh", "max"])
773
- def test_validate_effort_token_accepts_known_tokens(valid_effort: str) -> None:
774
- assert invoker.validate_effort_token(valid_effort) is None
775
-
776
-
777
- def test_validate_effort_token_rejects_ultra_loudly() -> None:
778
- error_message = invoker.validate_effort_token(REJECTED_ULTRA_EFFORT)
779
- assert error_message is not None
780
- assert REJECTED_ULTRA_EFFORT in error_message
781
-
782
-
783
- def test_validate_effort_token_rejects_unknown_token() -> None:
784
- error_message = invoker.validate_effort_token("bogus")
785
- assert error_message is not None
786
- assert "bogus" in error_message
787
-
788
-
789
- def test_build_code_review_prompt_reads_as_slash_command() -> None:
790
- assert invoker.build_code_review_prompt(EFFORT_LOW) == "/code-review low --fix"
791
- assert invoker.build_code_review_prompt("xhigh") == "/code-review xhigh --fix"
792
-
793
-
794
- def test_cli_rejects_ultra_effort_with_nonzero_exit(
98
+ def test_missing_chain_config_reports_its_remedy_on_stderr(
795
99
  monkeypatch: pytest.MonkeyPatch,
796
100
  tmp_path: Path,
797
101
  capsys: pytest.CaptureFixture[str],
798
102
  ) -> None:
799
- working_directory = _init_git_repository(tmp_path / "repo")
800
- _install_seams(
801
- monkeypatch,
802
- host_profile=HOST_PROFILE_CLAUDE,
803
- claude_outcome=None,
804
- working_directory=working_directory,
805
- )
806
- exit_code = invoker.main(
807
- [
808
- CWD_FLAG,
809
- str(working_directory),
810
- CLI_SESSION_MODEL_FLAG,
811
- FIXTURE_SESSION_OPUS,
812
- REJECTED_ULTRA_EFFORT,
813
- ]
814
- )
815
- assert exit_code == INVALID_EFFORT_EXIT_CODE
816
- assert REJECTED_ULTRA_EFFORT in capsys.readouterr().err
103
+ def raise_chain_configuration_error(**_all_keyword_arguments: object) -> None:
104
+ raise ChainConfigurationError(CHAIN_CONFIG_REMEDY_TEXT)
817
105
 
818
-
819
- def test_record_stamp_mints_on_surface_stable_clean_pass(
820
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
821
- ) -> None:
822
- working_directory = _prepared_surface_repo(monkeypatch, tmp_path)
823
- monkeypatch.setattr(invoker, "invoke_code_review", _stable_clean_review)
824
- mint_outcome = invoker.invoke_code_review_and_record_stamp(
825
- working_directory=working_directory,
826
- session_model=CODE_REVIEW_MODEL_ALIAS,
827
- timeout_seconds=DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS,
828
- effort=EFFORT_LOW,
829
- )
830
- assert mint_outcome.is_stamp_minted is True
831
- assert mint_outcome.bound_hash is not None
832
- store_module = invoker.load_code_review_stamp_store()
833
- assert store_module.stamp_covers_surface(
834
- str(working_directory), mint_outcome.bound_hash, EFFORT_LOW
106
+ monkeypatch.setattr(
107
+ invoker, "invoke_code_review_and_record_stamp", raise_chain_configuration_error
835
108
  )
836
109
 
837
-
838
- def test_record_stamp_does_not_mint_when_review_changes_surface(
839
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
840
- ) -> None:
841
- working_directory = _prepared_surface_repo(monkeypatch, tmp_path)
842
- monkeypatch.setattr(invoker, "invoke_code_review", _surface_changing_review)
843
- mint_outcome = invoker.invoke_code_review_and_record_stamp(
844
- working_directory=working_directory,
845
- session_model=CODE_REVIEW_MODEL_ALIAS,
846
- timeout_seconds=DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS,
847
- effort=EFFORT_LOW,
848
- maximum_passes=SINGLE_PASS_CAP,
110
+ outcome = invoker._mint_or_config_outcome(
111
+ working_directory=tmp_path,
112
+ session_model=FIXTURE_SESSION_OPUS,
113
+ timeout_seconds=MINT_TIMEOUT_SECONDS,
114
+ effort=DEFAULT_CODE_REVIEW_EFFORT,
849
115
  )
850
- assert mint_outcome.is_stamp_minted is False
851
- assert mint_outcome.pass_count == SINGLE_PASS_CAP
852
- assert mint_outcome.bound_hash is None
853
-
854
116
 
855
- def test_record_stamp_hits_cap_without_minting(
856
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
857
- ) -> None:
858
- working_directory = _prepared_surface_repo(monkeypatch, tmp_path)
859
- monkeypatch.setattr(invoker, "invoke_code_review", _DriftingReview())
860
- mint_outcome = invoker.invoke_code_review_and_record_stamp(
861
- working_directory=working_directory,
862
- session_model=CODE_REVIEW_MODEL_ALIAS,
863
- timeout_seconds=DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS,
864
- effort=EFFORT_LOW,
865
- maximum_passes=RECORD_STAMP_MINT_CAP,
866
- )
867
- assert mint_outcome.is_stamp_minted is False
868
- assert mint_outcome.pass_count == RECORD_STAMP_MINT_CAP
117
+ captured_streams = capsys.readouterr()
118
+ assert outcome.is_stamp_minted is False
119
+ assert CHAIN_CONFIG_REMEDY_TEXT in captured_streams.err
869
120
 
870
121
 
871
- def test_cli_record_stamp_returns_non_convergence_code_on_cap(
122
+ def test_host_profile_failure_reports_its_reason_on_stderr(
872
123
  monkeypatch: pytest.MonkeyPatch,
873
124
  tmp_path: Path,
874
125
  capsys: pytest.CaptureFixture[str],
875
126
  ) -> None:
876
- working_directory = _prepared_surface_repo(monkeypatch, tmp_path)
877
- monkeypatch.setattr(invoker, "invoke_code_review", _DriftingReview())
878
- exit_code = invoker.main(
879
- [
880
- CWD_FLAG,
881
- str(working_directory),
882
- CLI_SESSION_MODEL_FLAG,
883
- CODE_REVIEW_MODEL_ALIAS,
884
- RECORD_STAMP_CLI_FLAG,
885
- EFFORT_LOW,
886
- ]
887
- )
888
- assert exit_code == DID_NOT_CONVERGE_EXIT_CODE
889
- parsed_payload = json.loads(capsys.readouterr().out)
890
- assert parsed_payload[RESULT_STAMP_MINTED_KEY] is False
891
- assert parsed_payload[RESULT_PASS_COUNT_KEY] == RECORD_STAMP_MINT_CAP
892
- assert parsed_payload[RESULT_BOUND_HASH_KEY] is None
893
-
894
-
895
- def test_load_code_review_stamp_store_records_and_covers_surface(
896
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
897
- ) -> None:
898
- working_directory = _prepared_surface_repo(monkeypatch, tmp_path)
899
- store_module = invoker.load_code_review_stamp_store()
900
- surface_hash = store_module.live_surface_hash(str(working_directory))
901
- assert surface_hash is not None
902
- stamp_path = store_module.record_clean_stamp(
903
- str(working_directory), surface_hash, EFFORT_LOW
904
- )
905
- assert stamp_path.exists()
906
- assert store_module.stamp_covers_surface(
907
- str(working_directory), surface_hash, EFFORT_LOW
908
- )
909
-
127
+ def raise_host_profile_error(**_all_keyword_arguments: object) -> None:
128
+ raise ValueError(HOST_PROFILE_FAILURE_TEXT)
910
129
 
911
- def test_load_code_review_stamp_store_raises_when_file_absent(
912
- monkeypatch: pytest.MonkeyPatch,
913
- ) -> None:
914
130
  monkeypatch.setattr(
915
- invoker, "STAMP_STORE_MODULE_FILE_NAME", MISSING_STORE_FILE_NAME
131
+ invoker, "invoke_code_review_and_record_stamp", raise_host_profile_error
916
132
  )
917
- with pytest.raises(ModuleNotFoundError):
918
- invoker.load_code_review_stamp_store()
919
-
920
-
921
- def test_cli_record_stamp_reports_missing_store_dependency(
922
- monkeypatch: pytest.MonkeyPatch,
923
- tmp_path: Path,
924
- capsys: pytest.CaptureFixture[str],
925
- ) -> None:
926
- working_directory = _init_git_repository(tmp_path / "repo")
927
-
928
- def raise_missing_store(*all_args: object, **all_keywords: object) -> object:
929
- del all_args, all_keywords
930
- raise ModuleNotFoundError("store missing", name="code_review_stamp_store")
931
133
 
932
- monkeypatch.setattr(invoker, "load_code_review_stamp_store", raise_missing_store)
933
- exit_code = invoker.main(
934
- [
935
- CWD_FLAG,
936
- str(working_directory),
937
- CLI_SESSION_MODEL_FLAG,
938
- CODE_REVIEW_MODEL_ALIAS,
939
- RECORD_STAMP_CLI_FLAG,
940
- EFFORT_LOW,
941
- ]
134
+ outcome = invoker._mint_or_config_outcome(
135
+ working_directory=tmp_path,
136
+ session_model=FIXTURE_SESSION_OPUS,
137
+ timeout_seconds=MINT_TIMEOUT_SECONDS,
138
+ effort=DEFAULT_CODE_REVIEW_EFFORT,
942
139
  )
943
- assert exit_code == INVALID_EFFORT_EXIT_CODE
944
- captured = capsys.readouterr()
945
- assert "stamp store" in captured.err
946
- parsed_payload = json.loads(captured.out)
947
- assert parsed_payload[RESULT_STAMP_MINTED_KEY] is False
948
140
 
949
-
950
- def test_encode_stamp_mint_outcome_includes_mint_metadata() -> None:
951
- review_outcome = invoker.CodeReviewOutcome(
952
- mode=MODE_CHAIN,
953
- served_command=FIXTURE_SERVED_COMMAND,
954
- returncode=FIXTURE_CHAIN_RETURNCODE,
955
- is_dirty_tree=False,
956
- )
957
- mint_outcome = invoker.StampMintOutcome(
958
- review_outcome=review_outcome,
959
- is_stamp_minted=True,
960
- pass_count=SINGLE_PASS_CAP,
961
- bound_hash="abc123",
962
- )
963
- encoded_payload = invoker.encode_stamp_mint_outcome(mint_outcome)
964
- assert encoded_payload[RESULT_STAMP_MINTED_KEY] is True
965
- assert encoded_payload[RESULT_PASS_COUNT_KEY] == SINGLE_PASS_CAP
966
- assert encoded_payload[RESULT_BOUND_HASH_KEY] == "abc123"
141
+ captured_streams = capsys.readouterr()
142
+ assert outcome.is_stamp_minted is False
143
+ assert HOST_PROFILE_FAILURE_TEXT in captured_streams.err