claude-dev-env 2.9.0 → 2.10.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 (289) hide show
  1. package/CLAUDE.md +1 -1
  2. package/_shared/pr-loop/CLAUDE.md +1 -1
  3. package/_shared/pr-loop/audit-contract.md +17 -6
  4. package/_shared/pr-loop/audit-reply-template.md +4 -4
  5. package/_shared/pr-loop/code-rules-gate.md +3 -5
  6. package/_shared/pr-loop/fix-protocol.md +2 -3
  7. package/_shared/pr-loop/gh-payloads.md +1 -1
  8. package/_shared/pr-loop/scripts/CLAUDE.md +1 -1
  9. package/_shared/pr-loop/scripts/README.md +1 -1
  10. package/_shared/pr-loop/scripts/code_rules_gate.py +2 -0
  11. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_running.py +16 -1
  12. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +11 -5
  13. package/_shared/pr-loop/scripts/preflight.py +9 -4
  14. package/_shared/pr-loop/scripts/reviews_disabled.py +50 -22
  15. package/_shared/pr-loop/scripts/tests/conftest.py +20 -0
  16. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +6 -6
  17. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +50 -6
  18. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +1 -1
  19. package/_shared/pr-loop/state-schema.md +5 -14
  20. package/agents/CLAUDE.md +2 -2
  21. package/agents/clean-coder.md +58 -548
  22. package/agents/code-quality-agent.md +10 -2
  23. package/agents/code-verifier.md +1 -1
  24. package/agents/test_agent_frontmatter.py +32 -40
  25. package/audit-rubrics/CLAUDE.md +2 -1
  26. package/audit-rubrics/audit-categories.json +704 -0
  27. package/audit-rubrics/prompts/category-i-concurrency.md +1 -1
  28. package/bin/CLAUDE.md +16 -5
  29. package/bin/ever-shipped-skills.mjs +2 -0
  30. package/bin/install-plan.mjs +402 -0
  31. package/bin/install-transaction.mjs +455 -0
  32. package/bin/install.mjs +593 -147
  33. package/bin/install.plan.test.mjs +194 -0
  34. package/bin/install.profile-root.test.mjs +154 -0
  35. package/bin/install.profiles.test.mjs +253 -0
  36. package/bin/install.settings-defaults.test.mjs +200 -0
  37. package/bin/install.transaction.test.mjs +400 -0
  38. package/bin/install.uninstall-transaction.test.mjs +418 -0
  39. package/bin/merge_managed_permissions.mjs +130 -0
  40. package/bin/resolve-install-root.mjs +181 -0
  41. package/bin/select-install-targets.mjs +401 -0
  42. package/commands/CLAUDE.md +0 -2
  43. package/docs/references/CLAUDE.md +1 -0
  44. package/docs/references/advisor-tool.md +1 -1
  45. package/docs/references/prose-style-enforcement.md +25 -0
  46. package/hooks/blocking/CLAUDE.md +6 -6
  47. package/hooks/blocking/_path_setup.py +9 -5
  48. package/hooks/blocking/code_rules_docstrings.py +124 -30
  49. package/hooks/blocking/code_rules_enforcer.py +161 -16
  50. package/hooks/blocking/code_rules_shared.py +40 -23
  51. package/hooks/blocking/config/CLAUDE.md +3 -5
  52. package/hooks/blocking/config/prose_style_enforcement_constants.py +38 -0
  53. package/hooks/blocking/config/test_prose_style_enforcement_constants.py +45 -0
  54. package/hooks/blocking/eli11_reply_enforcer.py +70 -113
  55. package/hooks/blocking/hedging_language_blocker.py +103 -20
  56. package/hooks/blocking/hook_prose_detector_consistency.py +6 -0
  57. package/hooks/blocking/intent_only_ending_blocker.py +6 -0
  58. package/hooks/blocking/plain_language_blocker.py +139 -20
  59. package/hooks/blocking/pre_tool_use_dispatcher.py +102 -20
  60. package/hooks/blocking/state_description_blocker.py +7 -1
  61. package/hooks/blocking/tdd_enforcer.py +8 -0
  62. package/hooks/blocking/test__path_setup.py +28 -0
  63. package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +99 -0
  64. package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +232 -10
  65. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +1 -1
  66. package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +41 -0
  67. package/hooks/blocking/test_code_rules_enforcer_string_magic.py +98 -0
  68. package/hooks/blocking/test_eli11_reply_enforcer.py +98 -165
  69. package/hooks/blocking/test_hedging_language_blocker.py +120 -1
  70. package/hooks/blocking/test_hook_prose_detector_consistency.py +28 -8
  71. package/hooks/blocking/test_intent_only_ending_blocker.py +27 -2
  72. package/hooks/blocking/test_package_inventory_stale_blocker.py +11 -4
  73. package/hooks/blocking/test_plain_language_blocker.py +129 -19
  74. package/hooks/blocking/test_plain_language_blocker_allowlist.py +70 -26
  75. package/hooks/blocking/test_pre_tool_use_dispatcher.py +99 -26
  76. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +87 -50
  77. package/hooks/blocking/test_state_description_blocker.py +45 -2
  78. package/hooks/blocking/test_stop_dispatcher.py +11 -7
  79. package/hooks/blocking/test_volatile_path_in_post_blocker.py +12 -12
  80. package/hooks/blocking/volatile_path_in_post_blocker.py +2 -2
  81. package/hooks/hooks.json +15 -0
  82. package/hooks/hooks_constants/CLAUDE.md +14 -3
  83. package/hooks/hooks_constants/ask_user_question_shape.py +281 -0
  84. package/hooks/hooks_constants/code_rules_enforcer_constants.py +2 -1
  85. package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +5 -12
  86. package/hooks/hooks_constants/hedging_uncertainty_constants.py +42 -0
  87. package/hooks/hooks_constants/issue_tracker_session_starter_constants.py +23 -0
  88. package/hooks/hooks_constants/orchestrator_auto_starter_constants.py +23 -0
  89. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +4 -1
  90. package/hooks/hooks_constants/plain_language_blocker_constants.py +4 -1
  91. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +6 -0
  92. package/hooks/hooks_constants/project_paths_reader.py +31 -4
  93. package/hooks/hooks_constants/prose_matcher_precision_constants.py +40 -0
  94. package/hooks/hooks_constants/pytest_invocation.py +354 -0
  95. package/hooks/hooks_constants/session_start_injector.py +163 -0
  96. package/hooks/hooks_constants/session_start_injector_constants.py +46 -0
  97. package/hooks/hooks_constants/shell_command_pipeline.py +397 -0
  98. package/hooks/hooks_constants/shell_command_segments.py +5 -0
  99. package/hooks/hooks_constants/test_ask_user_question_shape.py +167 -0
  100. package/hooks/hooks_constants/test_project_paths_reader.py +29 -0
  101. package/hooks/hooks_constants/test_prose_metrics_parity.py +8 -0
  102. package/hooks/hooks_constants/test_pytest_invocation.py +130 -0
  103. package/hooks/hooks_constants/test_session_start_injector.py +168 -0
  104. package/hooks/hooks_constants/test_shell_command_pipeline.py +135 -0
  105. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +1 -1
  106. package/hooks/hooks_constants/working_style_prompt_constants.py +30 -0
  107. package/hooks/observability/CLAUDE.md +2 -0
  108. package/hooks/observability/prose_matcher_advisory.py +237 -0
  109. package/hooks/observability/test_prose_matcher_advisory.py +143 -0
  110. package/hooks/session/CLAUDE.md +9 -1
  111. package/hooks/session/_path_setup.py +13 -0
  112. package/hooks/session/issue_tracker_session_starter.py +135 -0
  113. package/hooks/session/orchestrator_auto_starter.py +100 -0
  114. package/hooks/session/test__path_setup.py +28 -0
  115. package/hooks/session/test_issue_tracker_session_starter.py +104 -0
  116. package/hooks/session/test_orchestrator_auto_starter.py +99 -0
  117. package/hooks/session/test_working_style_prompt.py +47 -0
  118. package/hooks/session/untracked_repo_detector.py +1 -24
  119. package/hooks/session/working_style_prompt.py +36 -0
  120. package/hooks/validators/_path_setup.py +19 -0
  121. package/hooks/validators/run_all_validators.py +8 -13
  122. package/installable-surfaces.manifest.json +21 -0
  123. package/output-styles/CLAUDE.md +1 -3
  124. package/package.json +4 -2
  125. package/rules/CLAUDE.md +1 -0
  126. package/rules/durable-post-artifacts.md +2 -2
  127. package/rules/eli11-replies.md +6 -1
  128. package/rules/hedging-claims.md +4 -2
  129. package/rules/long-horizon-autonomy.md +3 -1
  130. package/rules/opus5-communication-contract.md +45 -0
  131. package/rules/plain-language.md +2 -2
  132. package/rules/research-mode.md +1 -1
  133. package/scripts/CLAUDE.md +11 -0
  134. package/scripts/Sync-RepoMain.ps1 +215 -0
  135. package/scripts/active_capability_references.py +218 -0
  136. package/scripts/ci/windows-installer-lifecycle.ps1 +78 -0
  137. package/scripts/claude_chain_runner.py +394 -6
  138. package/scripts/claude_chain_usage.py +1 -1
  139. package/scripts/codex_compat_materializer.py +105 -85
  140. package/scripts/dev_env_scripts_constants/CLAUDE.md +2 -0
  141. package/scripts/dev_env_scripts_constants/active_capability_constants.py +46 -0
  142. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +74 -0
  143. package/scripts/dev_env_scripts_constants/verify_installable_package_constants.py +116 -0
  144. package/scripts/profile-isolation-launchers/config/mcp-bundles.json +25 -0
  145. package/scripts/profile-isolation-launchers/config/profile-isolation-constants.mjs +60 -0
  146. package/scripts/profile-isolation-launchers/config/profiles.manifest.json +54 -0
  147. package/scripts/profile-isolation-launchers/config/shared-allowlist.json +64 -0
  148. package/scripts/profile-isolation-launchers/launcher-runtime.mjs +180 -0
  149. package/scripts/profile-isolation-launchers/lib/profile-manifest.mjs +288 -0
  150. package/scripts/profile-isolation-launchers/mcp-bundles.mjs +275 -0
  151. package/scripts/profile-isolation-launchers/profile-isolation-contract.test.mjs +221 -0
  152. package/scripts/profile-isolation-launchers/tests/launcher-runtime.test.mjs +108 -0
  153. package/scripts/profile-isolation-launchers/tests/mcp-bundles.test.mjs +147 -0
  154. package/scripts/profile-isolation-launchers/tests/shortcut-contract.test.ps1 +102 -0
  155. package/scripts/profile-isolation-launchers/tests/version-compatibility.test.mjs +210 -0
  156. package/scripts/profile-isolation-launchers/version-compatibility.mjs +299 -0
  157. package/scripts/profile-isolation-launchers/windows/shortcut-inventory.ps1 +127 -0
  158. package/scripts/profile-isolation-launchers/windows/shortcut-manifest.json +51 -0
  159. package/scripts/profile-isolation-launchers/windows/shortcut-reconcile.ps1 +77 -0
  160. package/scripts/spawn_grok_batch.py +3 -0
  161. package/scripts/test_active_capability_references.py +108 -0
  162. package/scripts/test_claude_chain_runner.py +414 -82
  163. package/scripts/test_claude_chain_usage.py +12 -12
  164. package/scripts/test_resolve_worker_spawn.py +2 -2
  165. package/scripts/test_verify_installable_package.py +208 -0
  166. package/scripts/tests/test_codex_compat_materializer.py +33 -0
  167. package/scripts/verify_installable_package.py +612 -0
  168. package/settings.json +10 -0
  169. package/skills/CLAUDE.md +2 -0
  170. package/skills/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  171. package/skills/_shared/pr-loop/scripts/audit_category_schema.py +355 -0
  172. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +1 -0
  173. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/audit_category_schema_constants.py +32 -0
  174. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +7 -19
  175. package/skills/_shared/pr-loop/scripts/test_audit_category_schema.py +94 -0
  176. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +21 -0
  177. package/skills/autoconverge/reference/convergence.md +2 -1
  178. package/skills/autoconverge/reference/stop-conditions.md +5 -3
  179. package/skills/beat-sheet/SKILL.md +54 -0
  180. package/skills/beat-sheet/reference/visual-beats.md +29 -0
  181. package/skills/bugteam/CONSTRAINTS.md +4 -4
  182. package/skills/bugteam/EXAMPLES.md +1 -1
  183. package/skills/bugteam/reference/README.md +1 -1
  184. package/skills/e-code-review/SKILL.md +26 -5
  185. package/skills/e-code-review/reference/effort-evaluation.md +35 -0
  186. package/skills/e-code-review/reference/medium.md +15 -4
  187. package/skills/e-code-review/scripts/config/e_code_review_effort_constants/__init__.py +41 -0
  188. package/skills/e-code-review/scripts/config/e_code_review_effort_constants/effort_constants.py +40 -0
  189. package/skills/e-code-review/scripts/e_code_review_scripts_constants/finding_pipeline_constants.py +49 -0
  190. package/skills/e-code-review/scripts/effort_defaults_evidence.json +186 -0
  191. package/skills/e-code-review/scripts/effort_evaluation.py +362 -0
  192. package/skills/e-code-review/scripts/finding_pipeline.py +140 -0
  193. package/skills/e-code-review/scripts/fixtures/demanding.json +26 -0
  194. package/skills/e-code-review/scripts/fixtures/easy.json +14 -0
  195. package/skills/e-code-review/scripts/fixtures/medium.json +20 -0
  196. package/skills/e-code-review/scripts/grok_code_review.py +16 -7
  197. package/skills/e-code-review/scripts/test_effort_evaluation.py +180 -0
  198. package/skills/e-code-review/scripts/test_finding_pipeline.py +197 -0
  199. package/skills/e-code-review/scripts/test_grok_code_review.py +77 -0
  200. package/skills/plan-to-pr/scripts/create_packet.py +4 -4
  201. package/skills/plan-to-pr/scripts/load_skill_constants.py +41 -0
  202. package/skills/plan-to-pr/scripts/validate_packet.py +4 -4
  203. package/skills/plan-to-pr/scripts/validate_protocol.py +4 -1
  204. package/skills/plan-to-pr/scripts/validate_run.py +4 -1
  205. package/skills/pr-converge/scripts/check_convergence.py +21 -19
  206. package/skills/pr-converge/scripts/check_convergence_availability.py +50 -7
  207. package/skills/pr-converge/scripts/conftest.py +35 -0
  208. package/skills/pr-converge/scripts/test_check_convergence_availability.py +65 -0
  209. package/skills/pr-converge/scripts/test_check_convergence_codex.py +11 -1
  210. package/skills/pr-converge/scripts/test_check_convergence_contract.py +9 -2
  211. package/skills/pr-loop-cloud-transport/SKILL.md +1 -1
  212. package/skills/rebase/SKILL.md +15 -3
  213. package/skills/reviewer-gates/SKILL.md +2 -2
  214. package/skills/show/SKILL.md +51 -0
  215. package/skills/show/references/accessibility.md +7 -0
  216. package/skills/show/references/art.md +3 -0
  217. package/skills/show/references/charts.md +3 -0
  218. package/skills/show/references/core-design.md +14 -0
  219. package/skills/show/references/erds.md +3 -0
  220. package/skills/show/references/flowcharts.md +3 -0
  221. package/skills/show/references/host-and-html.md +3 -0
  222. package/skills/show/references/illustrative-diagrams.md +10 -0
  223. package/skills/show/references/interaction.md +3 -0
  224. package/skills/show/references/mockups.md +3 -0
  225. package/skills/show/references/quality-gates.md +7 -0
  226. package/skills/show/references/structural-diagrams.md +3 -0
  227. package/skills/show/references/subject-inventory.md +21 -0
  228. package/skills/show/references/svg-contract.md +22 -0
  229. package/skills/show/routing.yaml +30 -0
  230. package/skills/show/samples/pr1262-v2.svg +222 -0
  231. package/skills/show/scripts/README.md +6 -0
  232. package/skills/show/scripts/validate-artifact.py +91 -0
  233. package/skills/show/scripts/validate-package.py +18 -0
  234. package/skills/show/templates/html-widget.html +4 -0
  235. package/skills/show/templates/svg-base.svg +19 -0
  236. package/skills/show/tests/fixtures/css-var.svg +6 -0
  237. package/skills/show/tests/fixtures/dead-ref.svg +7 -0
  238. package/skills/show/tests/fixtures/filled-glyph.svg +8 -0
  239. package/skills/show/tests/fixtures/inherited-fill.svg +18 -0
  240. package/skills/show/tests/fixtures/invalid.svg +1 -0
  241. package/skills/show/tests/fixtures/large-canvas.svg +21 -0
  242. package/skills/show/tests/fixtures/unfilled-connector.svg +15 -0
  243. package/skills/show/tests/fixtures/valid.html +1 -0
  244. package/skills/show/tests/test_validate-artifact.py +74 -0
  245. package/skills/show/tests/test_validators.py +59 -0
  246. package/skills/show/workflows/create-visual.md +13 -0
  247. package/skills/show/workflows/review-visual.md +20 -0
  248. package/skills/split-pr/SKILL.md +85 -0
  249. package/skills/split-pr/reference/path-layers.md +16 -0
  250. package/skills/split-pr/reference/proposal-format.md +15 -0
  251. package/skills/split-pr/reference/split-further-loop.md +10 -0
  252. package/skills/split-pr/reference/splitting-principles.md +26 -0
  253. package/skills/split-pr/scripts/analyze_pr.py +279 -0
  254. package/skills/split-pr/scripts/categorize_files.py +106 -0
  255. package/skills/split-pr/scripts/config/__init__.py +1 -0
  256. package/skills/split-pr/scripts/config/dependency_constants.py +14 -0
  257. package/skills/split-pr/scripts/config/git_operations_constants.py +36 -0
  258. package/skills/split-pr/scripts/config/packing_constants.py +61 -0
  259. package/skills/split-pr/scripts/config/plan_constants.py +49 -0
  260. package/skills/split-pr/scripts/config/split_pr_constants.py +110 -0
  261. package/skills/split-pr/scripts/execute_split_slices.py +82 -0
  262. package/skills/split-pr/scripts/pack_files_into_slices.py +212 -0
  263. package/skills/split-pr/scripts/split_pr_dependency_graph.py +70 -0
  264. package/skills/split-pr/scripts/split_pr_git_operations.py +184 -0
  265. package/skills/split-pr/scripts/split_pr_layer_order.py +58 -0
  266. package/skills/split-pr/scripts/split_pr_paginate.py +119 -0
  267. package/skills/split-pr/scripts/split_pr_process_runner.py +52 -0
  268. package/skills/split-pr/scripts/split_pr_script_types.py +126 -0
  269. package/skills/split-pr/scripts/split_pr_title.py +41 -0
  270. package/skills/split-pr/scripts/test_analyze_pr.py +228 -0
  271. package/skills/split-pr/scripts/test_categorize_files.py +55 -0
  272. package/skills/split-pr/scripts/test_categorize_files_packing.py +59 -0
  273. package/skills/split-pr/scripts/test_execute_split_slices.py +99 -0
  274. package/skills/split-pr/scripts/test_split_pr_dependency_graph.py +47 -0
  275. package/skills/split-pr/scripts/test_split_pr_git_operations.py +125 -0
  276. package/skills/split-pr/scripts/test_split_pr_layer_order.py +36 -0
  277. package/skills/split-pr/scripts/test_split_pr_paginate.py +65 -0
  278. package/skills/split-pr/scripts/test_split_pr_script_types.py +73 -0
  279. package/skills/split-pr/scripts/test_split_pr_title.py +28 -0
  280. package/skills/split-pr/scripts/test_verify_dependency_graph.py +46 -0
  281. package/skills/split-pr/scripts/test_verify_plan.py +56 -0
  282. package/skills/split-pr/scripts/test_verify_plan_contract.py +50 -0
  283. package/skills/split-pr/scripts/test_verify_plan_path_normalization.py +45 -0
  284. package/skills/split-pr/scripts/verify_dependency_graph.py +111 -0
  285. package/skills/split-pr/scripts/verify_plan.py +139 -0
  286. package/system-prompts/software-engineer.xml +11 -2
  287. package/commands/initialize.md +0 -90
  288. package/commands/stubcheck.md +0 -88
  289. package/output-styles/caveman-agent.md +0 -37
@@ -0,0 +1,228 @@
1
+ """Boundary tests for the 199/200/599/600 hand-written line gates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ _SCRIPTS_DIR = Path(__file__).resolve().parent
13
+ if str(_SCRIPTS_DIR) not in sys.path:
14
+ sys.path.insert(0, str(_SCRIPTS_DIR))
15
+
16
+ from analyze_pr import (
17
+ _fetch_pr_files,
18
+ _file_records_from_gh,
19
+ _flatten_paginated_file_pages,
20
+ build_analysis_from_files,
21
+ )
22
+ from config.split_pr_constants import (
23
+ DEFAULT_SPLIT_HAND_WRITTEN_LINE_THRESHOLD,
24
+ EXIT_CODE_SUCCESS,
25
+ FILE_KEY_ADDITIONS,
26
+ FILE_KEY_DELETIONS,
27
+ FILE_KEY_PATH,
28
+ GH_API_SUBCOMMAND,
29
+ GH_PAGINATE_FLAG,
30
+ GH_SLURP_FLAG,
31
+ PAYLOAD_KEY_ATOMIC_EXCEPTION,
32
+ PAYLOAD_KEY_DEFAULT_SPLIT,
33
+ PAYLOAD_KEY_EXCLUDED_CHURN_LINES,
34
+ PAYLOAD_KEY_FILE_COUNT,
35
+ PAYLOAD_KEY_HAND_WRITTEN_LINES,
36
+ PAYLOAD_KEY_REQUIRES_SPLIT_ANALYSIS,
37
+ SPLIT_ANALYSIS_HAND_WRITTEN_LINE_THRESHOLD,
38
+ )
39
+
40
+
41
+ def _records_for_hand_written_lines(line_count: int) -> list[dict[str, object]]:
42
+ return [
43
+ {
44
+ FILE_KEY_PATH: "src/module.py",
45
+ FILE_KEY_ADDITIONS: line_count,
46
+ FILE_KEY_DELETIONS: 0,
47
+ }
48
+ ]
49
+
50
+
51
+ def test_hand_written_199_does_not_require_split_analysis() -> None:
52
+ analysis = build_analysis_from_files(
53
+ _records_for_hand_written_lines(
54
+ SPLIT_ANALYSIS_HAND_WRITTEN_LINE_THRESHOLD - 1
55
+ )
56
+ )
57
+ assert analysis[PAYLOAD_KEY_HAND_WRITTEN_LINES] == 199
58
+ assert analysis[PAYLOAD_KEY_REQUIRES_SPLIT_ANALYSIS] is False
59
+ assert analysis[PAYLOAD_KEY_DEFAULT_SPLIT] is False
60
+
61
+
62
+ def test_hand_written_200_requires_recorded_split_analysis() -> None:
63
+ analysis = build_analysis_from_files(
64
+ _records_for_hand_written_lines(SPLIT_ANALYSIS_HAND_WRITTEN_LINE_THRESHOLD)
65
+ )
66
+ assert analysis[PAYLOAD_KEY_HAND_WRITTEN_LINES] == 200
67
+ assert analysis[PAYLOAD_KEY_REQUIRES_SPLIT_ANALYSIS] is True
68
+ assert analysis[PAYLOAD_KEY_DEFAULT_SPLIT] is False
69
+
70
+
71
+ def test_hand_written_599_requires_analysis_but_not_default_split() -> None:
72
+ analysis = build_analysis_from_files(
73
+ _records_for_hand_written_lines(
74
+ DEFAULT_SPLIT_HAND_WRITTEN_LINE_THRESHOLD - 1
75
+ )
76
+ )
77
+ assert analysis[PAYLOAD_KEY_HAND_WRITTEN_LINES] == 599
78
+ assert analysis[PAYLOAD_KEY_REQUIRES_SPLIT_ANALYSIS] is True
79
+ assert analysis[PAYLOAD_KEY_DEFAULT_SPLIT] is False
80
+
81
+
82
+ def test_hand_written_600_defaults_to_multiple_slices() -> None:
83
+ analysis = build_analysis_from_files(
84
+ _records_for_hand_written_lines(DEFAULT_SPLIT_HAND_WRITTEN_LINE_THRESHOLD)
85
+ )
86
+ assert analysis[PAYLOAD_KEY_HAND_WRITTEN_LINES] == 600
87
+ assert analysis[PAYLOAD_KEY_REQUIRES_SPLIT_ANALYSIS] is True
88
+ assert analysis[PAYLOAD_KEY_DEFAULT_SPLIT] is True
89
+
90
+
91
+ def test_atomic_exception_records_reason_and_fable_verdict() -> None:
92
+ analysis = build_analysis_from_files(
93
+ _records_for_hand_written_lines(DEFAULT_SPLIT_HAND_WRITTEN_LINE_THRESHOLD),
94
+ atomic_exception_reason="single schema migration cannot be sliced",
95
+ fable_verdict="ENDORSE",
96
+ )
97
+ assert analysis[PAYLOAD_KEY_DEFAULT_SPLIT] is False
98
+ atomic = analysis[PAYLOAD_KEY_ATOMIC_EXCEPTION]
99
+ assert isinstance(atomic, dict)
100
+ assert atomic["reason"] == "single schema migration cannot be sliced"
101
+ assert atomic["fable_verdict"] == "ENDORSE"
102
+
103
+
104
+ def test_atomic_exception_without_fable_verdict_is_rejected() -> None:
105
+ with pytest.raises(ValueError, match="Fable verdict"):
106
+ build_analysis_from_files(
107
+ _records_for_hand_written_lines(
108
+ DEFAULT_SPLIT_HAND_WRITTEN_LINE_THRESHOLD
109
+ ),
110
+ atomic_exception_reason="cannot split",
111
+ )
112
+
113
+
114
+ def test_excluded_churn_does_not_inflate_hand_written_threshold() -> None:
115
+ analysis = build_analysis_from_files(
116
+ [
117
+ {
118
+ FILE_KEY_PATH: "src/a.py",
119
+ FILE_KEY_ADDITIONS: 50,
120
+ FILE_KEY_DELETIONS: 0,
121
+ },
122
+ {
123
+ FILE_KEY_PATH: "package-lock.json",
124
+ FILE_KEY_ADDITIONS: 5000,
125
+ FILE_KEY_DELETIONS: 0,
126
+ },
127
+ ]
128
+ )
129
+ assert analysis[PAYLOAD_KEY_HAND_WRITTEN_LINES] == 50
130
+ assert analysis[PAYLOAD_KEY_EXCLUDED_CHURN_LINES] == 5000
131
+ assert analysis[PAYLOAD_KEY_REQUIRES_SPLIT_ANALYSIS] is False
132
+ assert analysis[PAYLOAD_KEY_FILE_COUNT] == 2
133
+
134
+
135
+ def test_file_count_is_reported_but_not_a_hard_gate() -> None:
136
+ analysis = build_analysis_from_files(
137
+ [
138
+ {
139
+ FILE_KEY_PATH: f"src/file_{each_index}.py",
140
+ FILE_KEY_ADDITIONS: 1,
141
+ FILE_KEY_DELETIONS: 0,
142
+ }
143
+ for each_index in range(20)
144
+ ]
145
+ )
146
+ assert analysis[PAYLOAD_KEY_FILE_COUNT] == 20
147
+ assert analysis[PAYLOAD_KEY_HAND_WRITTEN_LINES] == 20
148
+ assert analysis[PAYLOAD_KEY_REQUIRES_SPLIT_ANALYSIS] is False
149
+
150
+
151
+ def test_fetch_pr_files_uses_paginated_pulls_files_api(
152
+ monkeypatch: pytest.MonkeyPatch,
153
+ ) -> None:
154
+ all_captured_commands: list[list[str]] = []
155
+
156
+ def _fake_run(all_command: list[str], **_kwargs: object) -> object:
157
+ all_captured_commands.append(list(all_command))
158
+
159
+ class _Completed:
160
+ returncode = EXIT_CODE_SUCCESS
161
+ stdout = json.dumps(
162
+ [
163
+ [
164
+ {
165
+ "filename": "src/a.py",
166
+ "additions": 3,
167
+ "deletions": 1,
168
+ }
169
+ ]
170
+ ]
171
+ )
172
+ stderr = ""
173
+
174
+ return _Completed()
175
+
176
+ monkeypatch.setattr("analyze_pr.subprocess.run", _fake_run)
177
+ all_records = _fetch_pr_files(895, "jl-cmd/claude-dev-env")
178
+ assert len(all_captured_commands) == 1
179
+ assert all_captured_commands[0][0:2] == ["gh", GH_API_SUBCOMMAND]
180
+ assert "repos/jl-cmd/claude-dev-env/pulls/895/files" in all_captured_commands[0]
181
+ assert GH_PAGINATE_FLAG in all_captured_commands[0]
182
+ assert GH_SLURP_FLAG in all_captured_commands[0]
183
+ assert all_records[0][FILE_KEY_PATH] == "src/a.py"
184
+ assert all_records[0][FILE_KEY_ADDITIONS] == 3
185
+ assert all_records[0][FILE_KEY_DELETIONS] == 1
186
+
187
+
188
+ def test_flatten_paginated_file_pages_merges_pages() -> None:
189
+ all_files = _flatten_paginated_file_pages(
190
+ [
191
+ [{"filename": "a.py", "additions": 1, "deletions": 0}],
192
+ [{"filename": "b.py", "additions": 2, "deletions": 0}],
193
+ ]
194
+ )
195
+ assert len(all_files) == 2
196
+ all_records = _file_records_from_gh(all_files)
197
+ assert {each[FILE_KEY_PATH] for each in all_records} == {"a.py", "b.py"}
198
+
199
+
200
+ def test_analyze_pr_cli_files_json(tmp_path: Path) -> None:
201
+ files_path = tmp_path / "files.json"
202
+ files_path.write_text(
203
+ json.dumps(
204
+ [
205
+ {"path": "src/a.py", "additions": 200, "deletions": 0},
206
+ {"path": "yarn.lock", "additions": 900, "deletions": 0},
207
+ ]
208
+ ),
209
+ encoding="utf-8",
210
+ )
211
+ completed = subprocess.run(
212
+ [
213
+ sys.executable,
214
+ str(_SCRIPTS_DIR / "analyze_pr.py"),
215
+ "--files-json",
216
+ str(files_path),
217
+ "--pretty",
218
+ ],
219
+ cwd=str(_SCRIPTS_DIR),
220
+ capture_output=True,
221
+ text=True,
222
+ check=False,
223
+ )
224
+ assert completed.returncode == EXIT_CODE_SUCCESS, completed.stderr
225
+ analysis = json.loads(completed.stdout)
226
+ assert analysis[PAYLOAD_KEY_HAND_WRITTEN_LINES] == 200
227
+ assert analysis[PAYLOAD_KEY_EXCLUDED_CHURN_LINES] == 900
228
+ assert analysis[PAYLOAD_KEY_REQUIRES_SPLIT_ANALYSIS] is True
@@ -0,0 +1,55 @@
1
+ """Classification tests for hand-written vs excluded churn paths."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ _SCRIPTS_DIR = Path(__file__).resolve().parent
9
+ if str(_SCRIPTS_DIR) not in sys.path:
10
+ sys.path.insert(0, str(_SCRIPTS_DIR))
11
+
12
+ from categorize_files import annotate_files, classify_path_churn, sum_churn_by_class
13
+ from config.split_pr_constants import (
14
+ CHURN_CLASS_GENERATED,
15
+ CHURN_CLASS_HAND_WRITTEN,
16
+ CHURN_CLASS_LOCKFILE,
17
+ CHURN_CLASS_MINIFIED,
18
+ CHURN_CLASS_VENDOR,
19
+ FILE_KEY_ADDITIONS,
20
+ FILE_KEY_DELETIONS,
21
+ FILE_KEY_PATH,
22
+ )
23
+
24
+
25
+ def test_classify_hand_written_source_paths() -> None:
26
+ assert classify_path_churn("packages/app/main.py") == CHURN_CLASS_HAND_WRITTEN
27
+ assert classify_path_churn("src/components/Button.tsx") == CHURN_CLASS_HAND_WRITTEN
28
+
29
+
30
+ def test_classify_excluded_churn_classes() -> None:
31
+ assert classify_path_churn("package-lock.json") == CHURN_CLASS_LOCKFILE
32
+ assert classify_path_churn("vendor/lib/util.js") == CHURN_CLASS_VENDOR
33
+ assert classify_path_churn("assets/app.min.js") == CHURN_CLASS_MINIFIED
34
+ assert classify_path_churn("src/generated/types.ts") == CHURN_CLASS_GENERATED
35
+
36
+
37
+ def test_annotate_and_sum_separates_hand_written_from_excluded() -> None:
38
+ all_annotated = annotate_files(
39
+ [
40
+ {
41
+ FILE_KEY_PATH: "src/a.py",
42
+ FILE_KEY_ADDITIONS: 10,
43
+ FILE_KEY_DELETIONS: 5,
44
+ },
45
+ {
46
+ FILE_KEY_PATH: "package-lock.json",
47
+ FILE_KEY_ADDITIONS: 1000,
48
+ FILE_KEY_DELETIONS: 0,
49
+ },
50
+ ]
51
+ )
52
+ assert sum_churn_by_class(
53
+ all_annotated, churn_class=CHURN_CLASS_HAND_WRITTEN
54
+ ) == 15
55
+ assert sum_churn_by_class(all_annotated, churn_class=CHURN_CLASS_LOCKFILE) == 1000
@@ -0,0 +1,59 @@
1
+ """Packing stays under the 200 hand-written review budget per slice."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ _SCRIPTS_DIR = Path(__file__).resolve().parent
11
+ if str(_SCRIPTS_DIR) not in sys.path:
12
+ sys.path.insert(0, str(_SCRIPTS_DIR))
13
+
14
+ from config.packing_constants import REVIEW_BUDGET_HAND_WRITTEN_LINES
15
+ from config.split_pr_constants import (
16
+ FILE_KEY_ADDITIONS,
17
+ FILE_KEY_DELETIONS,
18
+ FILE_KEY_PATH,
19
+ )
20
+ from pack_files_into_slices import infer_path_layer, pack_files_into_slices
21
+
22
+
23
+ def test_infer_path_layer_maps_tests_and_docs() -> None:
24
+ assert infer_path_layer("packages/x/tests/test_a.py") == "tests"
25
+ assert infer_path_layer("README.md") == "docs"
26
+ assert infer_path_layer("src/app.py") == "backend"
27
+
28
+
29
+ def test_pack_splits_when_budget_exceeded() -> None:
30
+ all_files = [
31
+ {FILE_KEY_PATH: "a.py", FILE_KEY_ADDITIONS: 120, FILE_KEY_DELETIONS: 0},
32
+ {FILE_KEY_PATH: "b.py", FILE_KEY_ADDITIONS: 120, FILE_KEY_DELETIONS: 0},
33
+ ]
34
+ plan = pack_files_into_slices("deadbeef", all_files)
35
+ assert len(plan["all_slices"]) == 2
36
+ for each_slice in plan["all_slices"]:
37
+ assert len(each_slice["all_paths"]) == 1
38
+
39
+
40
+ def test_pack_keeps_same_layer_under_budget() -> None:
41
+ all_files = [
42
+ {FILE_KEY_PATH: "a.py", FILE_KEY_ADDITIONS: 40, FILE_KEY_DELETIONS: 0},
43
+ {FILE_KEY_PATH: "b.py", FILE_KEY_ADDITIONS: 40, FILE_KEY_DELETIONS: 0},
44
+ ]
45
+ plan = pack_files_into_slices("deadbeef", all_files)
46
+ assert len(plan["all_slices"]) == 1
47
+ assert set(plan["all_slices"][0]["all_paths"]) == {"a.py", "b.py"}
48
+
49
+
50
+ def test_pack_rejects_single_file_above_hard_cap() -> None:
51
+ all_files = [
52
+ {
53
+ FILE_KEY_PATH: "huge.py",
54
+ FILE_KEY_ADDITIONS: REVIEW_BUDGET_HAND_WRITTEN_LINES * 4,
55
+ FILE_KEY_DELETIONS: 0,
56
+ }
57
+ ]
58
+ with pytest.raises(ValueError, match="hard cap"):
59
+ pack_files_into_slices("deadbeef", all_files)
@@ -0,0 +1,99 @@
1
+ """Local slice materialization walks dependency order with restore on error."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ _SCRIPTS_DIR = Path(__file__).resolve().parent
11
+ if str(_SCRIPTS_DIR) not in sys.path:
12
+ sys.path.insert(0, str(_SCRIPTS_DIR))
13
+
14
+ from execute_split_slices import materialize_plan_locally
15
+ from split_pr_process_runner import CapturedProcessOutcome
16
+
17
+
18
+ class _SequenceRunner:
19
+ def __init__(self) -> None:
20
+ self.head = "sha0"
21
+ self.commit_count = 0
22
+ self.fail_on_commit_index: int | None = None
23
+
24
+ def __call__(
25
+ self, all_command: list[str], working_directory: str
26
+ ) -> CapturedProcessOutcome:
27
+ all_git_args = all_command[1:]
28
+ if all_git_args[:2] == ["status", "--porcelain"]:
29
+ return CapturedProcessOutcome(0, "", "")
30
+ if all_git_args[:2] == ["rev-parse", "HEAD"]:
31
+ return CapturedProcessOutcome(0, self.head + "\n", "")
32
+ if all_git_args[:1] == ["add"]:
33
+ return CapturedProcessOutcome(0, "", "")
34
+ if all_git_args[:1] == ["commit"]:
35
+ self.commit_count += 1
36
+ if self.fail_on_commit_index == self.commit_count:
37
+ return CapturedProcessOutcome(1, "", "boom")
38
+ self.head = f"sha{self.commit_count}"
39
+ return CapturedProcessOutcome(0, "", "")
40
+ if all_git_args[:1] == ["checkout"]:
41
+ self.head = all_git_args[-1]
42
+ return CapturedProcessOutcome(0, "", "")
43
+ if all_git_args[:1] in (["restore"], ["clean"]):
44
+ return CapturedProcessOutcome(0, "", "")
45
+ return CapturedProcessOutcome(1, "", f"unexpected {all_command}")
46
+
47
+
48
+ def test_materialize_plan_locally_commits_in_layer_order(tmp_path: Path) -> None:
49
+ plan = {
50
+ "schema_version": 1,
51
+ "source_commit": "sha0",
52
+ "all_changed_paths": ["a.py", "b.md"],
53
+ "all_slices": [
54
+ {
55
+ "id": "02-docs",
56
+ "title": "docs: b",
57
+ "layer": "docs",
58
+ "all_paths": ["b.md"],
59
+ },
60
+ {
61
+ "id": "01-backend",
62
+ "title": "feat: a",
63
+ "layer": "backend",
64
+ "all_paths": ["a.py"],
65
+ },
66
+ ],
67
+ }
68
+ runner = _SequenceRunner()
69
+ all_commit_records = materialize_plan_locally(tmp_path, plan, run=runner)
70
+ assert [each["commit_sha"] for each in all_commit_records] == ["sha1", "sha2"]
71
+ assert all_commit_records[0]["all_paths"] == ["a.py"]
72
+ assert all_commit_records[1]["all_paths"] == ["b.md"]
73
+
74
+
75
+ def test_materialize_plan_locally_restores_source_on_failure(tmp_path: Path) -> None:
76
+ plan = {
77
+ "schema_version": 1,
78
+ "source_commit": "sha0",
79
+ "all_changed_paths": ["a.py", "b.py"],
80
+ "all_slices": [
81
+ {
82
+ "id": "01",
83
+ "title": "feat: a",
84
+ "layer": "backend",
85
+ "all_paths": ["a.py"],
86
+ },
87
+ {
88
+ "id": "02",
89
+ "title": "feat: b",
90
+ "layer": "backend",
91
+ "all_paths": ["b.py"],
92
+ },
93
+ ],
94
+ }
95
+ runner = _SequenceRunner()
96
+ runner.fail_on_commit_index = 2
97
+ with pytest.raises(RuntimeError, match="boom"):
98
+ materialize_plan_locally(tmp_path, plan, run=runner)
99
+ assert runner.head == "sha0"
@@ -0,0 +1,47 @@
1
+ """Dependency graph is deterministic and layer-ordered."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ _SCRIPTS_DIR = Path(__file__).resolve().parent
11
+ if str(_SCRIPTS_DIR) not in sys.path:
12
+ sys.path.insert(0, str(_SCRIPTS_DIR))
13
+
14
+ from split_pr_dependency_graph import build_dependency_graph
15
+
16
+
17
+ def test_graph_orders_config_before_docs() -> None:
18
+ all_slices = [
19
+ {"id": "02-docs", "layer": "docs"},
20
+ {"id": "01-config", "layer": "config"},
21
+ ]
22
+ graph = build_dependency_graph(all_slices)
23
+ assert graph["topological_order"] == ["01-config", "02-docs"]
24
+ assert {"from": "01-config", "to": "02-docs"} in graph["edges"]
25
+
26
+
27
+ def test_duplicate_id_fails() -> None:
28
+ with pytest.raises(ValueError, match="duplicate"):
29
+ build_dependency_graph(
30
+ [{"id": "x", "layer": "backend"}, {"id": "x", "layer": "tests"}]
31
+ )
32
+
33
+
34
+ def test_unknown_layer_fails() -> None:
35
+ with pytest.raises(ValueError, match="unknown layer"):
36
+ build_dependency_graph([{"id": "x", "layer": "mystery"}])
37
+
38
+
39
+ def test_same_layer_no_edge() -> None:
40
+ graph = build_dependency_graph(
41
+ [
42
+ {"id": "a", "layer": "backend"},
43
+ {"id": "b", "layer": "backend"},
44
+ ]
45
+ )
46
+ assert graph["edges"] == []
47
+ assert graph["topological_order"] == ["a", "b"]
@@ -0,0 +1,125 @@
1
+ """Git materialization stages only slice paths and restores on failure."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ _SCRIPTS_DIR = Path(__file__).resolve().parent
11
+ if str(_SCRIPTS_DIR) not in sys.path:
12
+ sys.path.insert(0, str(_SCRIPTS_DIR))
13
+
14
+ from split_pr_git_operations import (
15
+ assert_clean_worktree,
16
+ materialize_slice_commit,
17
+ read_head_sha,
18
+ restore_repository_state,
19
+ )
20
+ from split_pr_process_runner import CapturedProcessOutcome
21
+
22
+
23
+ class _FakeRunner:
24
+ def __init__(self) -> None:
25
+ self.all_calls: list[list[str]] = []
26
+ self.head_sha = "base111"
27
+ self.fail_commit = False
28
+ self.is_dirty = False
29
+
30
+ def __call__(
31
+ self, all_command: list[str], working_directory: str
32
+ ) -> CapturedProcessOutcome:
33
+ self.all_calls.append(list(all_command))
34
+ all_git_args = all_command[1:]
35
+ if all_git_args[:2] == ["status", "--porcelain"]:
36
+ text = " M dirty.py\n" if self.is_dirty else ""
37
+ return CapturedProcessOutcome(0, text, "")
38
+ if all_git_args[:2] == ["rev-parse", "HEAD"]:
39
+ return CapturedProcessOutcome(0, self.head_sha + "\n", "")
40
+ if all_git_args[:1] == ["add"]:
41
+ return CapturedProcessOutcome(0, "", "")
42
+ if all_git_args[:1] == ["commit"]:
43
+ if self.fail_commit:
44
+ return CapturedProcessOutcome(1, "", "commit failed")
45
+ self.head_sha = "commit222"
46
+ return CapturedProcessOutcome(0, "", "")
47
+ if all_git_args[:1] == ["checkout"]:
48
+ self.head_sha = all_git_args[-1]
49
+ return CapturedProcessOutcome(0, "", "")
50
+ if all_git_args[:1] == ["restore"]:
51
+ return CapturedProcessOutcome(0, "", "")
52
+ if all_git_args[:1] == ["clean"]:
53
+ return CapturedProcessOutcome(0, "", "")
54
+ return CapturedProcessOutcome(1, "", f"unexpected {all_command}")
55
+
56
+
57
+ def test_read_head_sha_returns_current_head(tmp_path: Path) -> None:
58
+ runner = _FakeRunner()
59
+ assert read_head_sha(tmp_path, run=runner) == "base111"
60
+
61
+
62
+ def test_assert_clean_worktree_passes_on_empty_status(tmp_path: Path) -> None:
63
+ runner = _FakeRunner()
64
+ assert_clean_worktree(tmp_path, run=runner)
65
+
66
+
67
+ def test_restore_repository_state_checks_out_target(tmp_path: Path) -> None:
68
+ runner = _FakeRunner()
69
+ restore_repository_state(tmp_path, "restore999", run=runner)
70
+ assert runner.head_sha == "restore999"
71
+
72
+
73
+ def test_materialize_slice_commit_stages_only_slice_paths(tmp_path: Path) -> None:
74
+ runner = _FakeRunner()
75
+ commit_record = materialize_slice_commit(
76
+ repository_path=tmp_path,
77
+ all_slice_paths=["a.py", "b.py"],
78
+ commit_message="chore: slice",
79
+ expected_base_sha="base111",
80
+ run=runner,
81
+ )
82
+ assert commit_record["commit_sha"] == "commit222"
83
+ assert commit_record["all_paths"] == ["a.py", "b.py"]
84
+ add_call = next(each for each in runner.all_calls if each[1] == "add")
85
+ assert add_call[3:] == ["a.py", "b.py"]
86
+
87
+
88
+ def test_materialize_slice_commit_refuses_dirty_tree(tmp_path: Path) -> None:
89
+ runner = _FakeRunner()
90
+ runner.is_dirty = True
91
+ with pytest.raises(RuntimeError, match="dirty"):
92
+ materialize_slice_commit(
93
+ repository_path=tmp_path,
94
+ all_slice_paths=["a.py"],
95
+ commit_message="chore: x",
96
+ expected_base_sha="base111",
97
+ run=runner,
98
+ )
99
+
100
+
101
+ def test_materialize_slice_commit_restores_on_commit_failure(tmp_path: Path) -> None:
102
+ runner = _FakeRunner()
103
+ runner.fail_commit = True
104
+ with pytest.raises(RuntimeError, match="commit failed"):
105
+ materialize_slice_commit(
106
+ repository_path=tmp_path,
107
+ all_slice_paths=["a.py"],
108
+ commit_message="chore: x",
109
+ expected_base_sha="base111",
110
+ run=runner,
111
+ )
112
+ assert any(each[1] == "checkout" for each in runner.all_calls)
113
+ assert runner.head_sha == "base111"
114
+
115
+
116
+ def test_materialize_slice_commit_rejects_base_mismatch(tmp_path: Path) -> None:
117
+ runner = _FakeRunner()
118
+ with pytest.raises(RuntimeError, match="does not match"):
119
+ materialize_slice_commit(
120
+ repository_path=tmp_path,
121
+ all_slice_paths=["a.py"],
122
+ commit_message="chore: x",
123
+ expected_base_sha="other999",
124
+ run=runner,
125
+ )
@@ -0,0 +1,36 @@
1
+ """Layer order is deterministic and config-first."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ _SCRIPTS_DIR = Path(__file__).resolve().parent
9
+ if str(_SCRIPTS_DIR) not in sys.path:
10
+ sys.path.insert(0, str(_SCRIPTS_DIR))
11
+
12
+ from config.plan_constants import SLICE_KEY_ID, SLICE_KEY_LAYER
13
+ from split_pr_layer_order import layer_rank, sort_slices_by_layer_order
14
+
15
+
16
+ def test_layer_rank_orders_config_before_docs() -> None:
17
+ assert layer_rank("config") < layer_rank("docs")
18
+ assert layer_rank("mystery") > layer_rank("other")
19
+
20
+
21
+ def test_sort_places_config_before_docs() -> None:
22
+ all_slices = [
23
+ {SLICE_KEY_ID: "docs", SLICE_KEY_LAYER: "docs"},
24
+ {SLICE_KEY_ID: "config", SLICE_KEY_LAYER: "config"},
25
+ ]
26
+ all_ordered = sort_slices_by_layer_order(all_slices)
27
+ assert [each[SLICE_KEY_ID] for each in all_ordered] == ["config", "docs"]
28
+
29
+
30
+ def test_unknown_layer_sorts_after_known() -> None:
31
+ all_slices = [
32
+ {SLICE_KEY_ID: "z", SLICE_KEY_LAYER: "mystery"},
33
+ {SLICE_KEY_ID: "a", SLICE_KEY_LAYER: "backend"},
34
+ ]
35
+ all_ordered = sort_slices_by_layer_order(all_slices)
36
+ assert [each[SLICE_KEY_ID] for each in all_ordered] == ["a", "z"]