claude-dev-env 2.9.0 → 2.11.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 (318) hide show
  1. package/CLAUDE.md +2 -2
  2. package/_shared/advisor/CLAUDE.md +3 -2
  3. package/_shared/advisor/advisor-protocol.md +74 -108
  4. package/_shared/advisor/reference/advisor-block.md +37 -0
  5. package/_shared/advisor/reference/cli-chain.md +45 -0
  6. package/_shared/advisor/reference/consult-format.md +41 -0
  7. package/_shared/advisor/reference/lifecycle.md +21 -0
  8. package/_shared/advisor/reference/sol-rung.md +31 -0
  9. package/_shared/advisor/reference/spawn-walk-log.md +31 -0
  10. package/_shared/advisor/reference/third-party-bind.md +30 -0
  11. package/_shared/advisor/reference/warm-up.md +33 -0
  12. package/_shared/advisor/scripts/codex_sol_advisor.py +449 -0
  13. package/_shared/advisor/scripts/config/advisor_scripts_constants/advisor_route_constants.py +21 -0
  14. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +19 -17
  15. package/_shared/advisor/scripts/config/advisor_scripts_constants/sol_advisor_constants.py +28 -0
  16. package/_shared/advisor/scripts/model_tier_run_validator.py +32 -9
  17. package/_shared/advisor/scripts/tests/test_codex_sol_advisor.py +474 -0
  18. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +79 -0
  19. package/_shared/advisor/scripts/tests/test_tier_model_ids.py +39 -17
  20. package/_shared/advisor/scripts/tier_model_ids.py +24 -0
  21. package/_shared/pr-loop/CLAUDE.md +1 -1
  22. package/_shared/pr-loop/audit-contract.md +17 -6
  23. package/_shared/pr-loop/audit-reply-template.md +4 -4
  24. package/_shared/pr-loop/code-rules-gate.md +3 -5
  25. package/_shared/pr-loop/fix-protocol.md +2 -3
  26. package/_shared/pr-loop/gh-payloads.md +1 -1
  27. package/_shared/pr-loop/scripts/CLAUDE.md +1 -1
  28. package/_shared/pr-loop/scripts/README.md +1 -1
  29. package/_shared/pr-loop/scripts/code_rules_gate.py +2 -0
  30. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_running.py +16 -1
  31. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +11 -5
  32. package/_shared/pr-loop/scripts/preflight.py +9 -4
  33. package/_shared/pr-loop/scripts/reviews_disabled.py +50 -22
  34. package/_shared/pr-loop/scripts/tests/conftest.py +20 -0
  35. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +6 -6
  36. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +50 -6
  37. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +1 -1
  38. package/_shared/pr-loop/state-schema.md +5 -14
  39. package/agents/CLAUDE.md +2 -2
  40. package/agents/clean-coder.md +58 -548
  41. package/agents/code-quality-agent.md +10 -2
  42. package/agents/code-verifier.md +1 -1
  43. package/agents/test_agent_frontmatter.py +32 -40
  44. package/audit-rubrics/CLAUDE.md +2 -1
  45. package/audit-rubrics/audit-categories.json +704 -0
  46. package/audit-rubrics/prompts/category-i-concurrency.md +1 -1
  47. package/bin/CLAUDE.md +16 -5
  48. package/bin/ever-shipped-skills.mjs +2 -0
  49. package/bin/install-plan.mjs +402 -0
  50. package/bin/install-transaction.mjs +455 -0
  51. package/bin/install.mjs +593 -147
  52. package/bin/install.plan.test.mjs +194 -0
  53. package/bin/install.profile-root.test.mjs +154 -0
  54. package/bin/install.profiles.test.mjs +253 -0
  55. package/bin/install.settings-defaults.test.mjs +200 -0
  56. package/bin/install.transaction.test.mjs +400 -0
  57. package/bin/install.uninstall-transaction.test.mjs +418 -0
  58. package/bin/merge_managed_permissions.mjs +130 -0
  59. package/bin/resolve-install-root.mjs +181 -0
  60. package/bin/select-install-targets.mjs +401 -0
  61. package/commands/CLAUDE.md +0 -2
  62. package/docs/references/CLAUDE.md +3 -1
  63. package/docs/references/advisor-tool.md +25 -7
  64. package/docs/references/prose-style-enforcement.md +25 -0
  65. package/docs/references/team-advisor-skill.md +3 -3
  66. package/docs/references/weak-executor-advisor.md +91 -0
  67. package/hooks/blocking/CLAUDE.md +6 -6
  68. package/hooks/blocking/_path_setup.py +9 -5
  69. package/hooks/blocking/code_rules_docstrings.py +124 -30
  70. package/hooks/blocking/code_rules_enforcer.py +161 -16
  71. package/hooks/blocking/code_rules_shared.py +40 -23
  72. package/hooks/blocking/config/CLAUDE.md +3 -5
  73. package/hooks/blocking/config/prose_style_enforcement_constants.py +38 -0
  74. package/hooks/blocking/config/test_prose_style_enforcement_constants.py +45 -0
  75. package/hooks/blocking/eli11_reply_enforcer.py +70 -113
  76. package/hooks/blocking/hedging_language_blocker.py +103 -20
  77. package/hooks/blocking/hook_prose_detector_consistency.py +6 -0
  78. package/hooks/blocking/intent_only_ending_blocker.py +6 -0
  79. package/hooks/blocking/plain_language_blocker.py +139 -20
  80. package/hooks/blocking/pre_tool_use_dispatcher.py +102 -20
  81. package/hooks/blocking/state_description_blocker.py +7 -1
  82. package/hooks/blocking/tdd_enforcer.py +8 -0
  83. package/hooks/blocking/test__path_setup.py +28 -0
  84. package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +99 -0
  85. package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +232 -10
  86. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +1 -1
  87. package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +41 -0
  88. package/hooks/blocking/test_code_rules_enforcer_string_magic.py +98 -0
  89. package/hooks/blocking/test_eli11_reply_enforcer.py +98 -165
  90. package/hooks/blocking/test_fable_spawn_gate.py +18 -11
  91. package/hooks/blocking/test_hedging_language_blocker.py +120 -1
  92. package/hooks/blocking/test_hook_prose_detector_consistency.py +28 -8
  93. package/hooks/blocking/test_intent_only_ending_blocker.py +27 -2
  94. package/hooks/blocking/test_package_inventory_stale_blocker.py +11 -4
  95. package/hooks/blocking/test_plain_language_blocker.py +129 -19
  96. package/hooks/blocking/test_plain_language_blocker_allowlist.py +70 -26
  97. package/hooks/blocking/test_pre_tool_use_dispatcher.py +99 -26
  98. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +87 -50
  99. package/hooks/blocking/test_state_description_blocker.py +45 -2
  100. package/hooks/blocking/test_stop_dispatcher.py +11 -7
  101. package/hooks/blocking/test_volatile_path_in_post_blocker.py +12 -12
  102. package/hooks/blocking/volatile_path_in_post_blocker.py +2 -2
  103. package/hooks/hooks.json +15 -0
  104. package/hooks/hooks_constants/CLAUDE.md +14 -3
  105. package/hooks/hooks_constants/ask_user_question_shape.py +281 -0
  106. package/hooks/hooks_constants/code_rules_enforcer_constants.py +2 -1
  107. package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +5 -12
  108. package/hooks/hooks_constants/hedging_uncertainty_constants.py +42 -0
  109. package/hooks/hooks_constants/issue_tracker_session_starter_constants.py +23 -0
  110. package/hooks/hooks_constants/orchestrator_auto_starter_constants.py +23 -0
  111. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +4 -1
  112. package/hooks/hooks_constants/plain_language_blocker_constants.py +4 -1
  113. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +6 -0
  114. package/hooks/hooks_constants/project_paths_reader.py +31 -4
  115. package/hooks/hooks_constants/prose_matcher_precision_constants.py +40 -0
  116. package/hooks/hooks_constants/pytest_invocation.py +354 -0
  117. package/hooks/hooks_constants/session_start_injector.py +163 -0
  118. package/hooks/hooks_constants/session_start_injector_constants.py +46 -0
  119. package/hooks/hooks_constants/shell_command_pipeline.py +397 -0
  120. package/hooks/hooks_constants/shell_command_segments.py +5 -0
  121. package/hooks/hooks_constants/test_ask_user_question_shape.py +167 -0
  122. package/hooks/hooks_constants/test_project_paths_reader.py +29 -0
  123. package/hooks/hooks_constants/test_prose_metrics_parity.py +8 -0
  124. package/hooks/hooks_constants/test_pytest_invocation.py +130 -0
  125. package/hooks/hooks_constants/test_session_start_injector.py +168 -0
  126. package/hooks/hooks_constants/test_shell_command_pipeline.py +135 -0
  127. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +1 -1
  128. package/hooks/hooks_constants/working_style_prompt_constants.py +30 -0
  129. package/hooks/observability/CLAUDE.md +2 -0
  130. package/hooks/observability/prose_matcher_advisory.py +237 -0
  131. package/hooks/observability/test_prose_matcher_advisory.py +143 -0
  132. package/hooks/session/CLAUDE.md +9 -1
  133. package/hooks/session/_path_setup.py +13 -0
  134. package/hooks/session/issue_tracker_session_starter.py +135 -0
  135. package/hooks/session/orchestrator_auto_starter.py +100 -0
  136. package/hooks/session/test__path_setup.py +28 -0
  137. package/hooks/session/test_issue_tracker_session_starter.py +104 -0
  138. package/hooks/session/test_orchestrator_auto_starter.py +99 -0
  139. package/hooks/session/test_working_style_prompt.py +47 -0
  140. package/hooks/session/untracked_repo_detector.py +1 -24
  141. package/hooks/session/working_style_prompt.py +36 -0
  142. package/hooks/validators/_path_setup.py +19 -0
  143. package/hooks/validators/run_all_validators.py +8 -13
  144. package/installable-surfaces.manifest.json +21 -0
  145. package/output-styles/CLAUDE.md +1 -3
  146. package/package.json +4 -2
  147. package/rules/CLAUDE.md +1 -0
  148. package/rules/durable-post-artifacts.md +2 -2
  149. package/rules/eli11-replies.md +6 -1
  150. package/rules/hedging-claims.md +4 -2
  151. package/rules/long-horizon-autonomy.md +3 -1
  152. package/rules/opus5-communication-contract.md +45 -0
  153. package/rules/plain-language.md +2 -2
  154. package/rules/research-mode.md +1 -1
  155. package/scripts/CLAUDE.md +11 -0
  156. package/scripts/Sync-RepoMain.ps1 +215 -0
  157. package/scripts/active_capability_references.py +218 -0
  158. package/scripts/ci/windows-installer-lifecycle.ps1 +78 -0
  159. package/scripts/claude_chain_runner.py +394 -6
  160. package/scripts/claude_chain_usage.py +1 -1
  161. package/scripts/codex_compat_materializer.py +105 -85
  162. package/scripts/dev_env_scripts_constants/CLAUDE.md +2 -0
  163. package/scripts/dev_env_scripts_constants/active_capability_constants.py +46 -0
  164. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +74 -0
  165. package/scripts/dev_env_scripts_constants/verify_installable_package_constants.py +116 -0
  166. package/scripts/profile-isolation-launchers/config/mcp-bundles.json +25 -0
  167. package/scripts/profile-isolation-launchers/config/profile-isolation-constants.mjs +60 -0
  168. package/scripts/profile-isolation-launchers/config/profiles.manifest.json +54 -0
  169. package/scripts/profile-isolation-launchers/config/shared-allowlist.json +64 -0
  170. package/scripts/profile-isolation-launchers/launcher-runtime.mjs +180 -0
  171. package/scripts/profile-isolation-launchers/lib/profile-manifest.mjs +288 -0
  172. package/scripts/profile-isolation-launchers/mcp-bundles.mjs +275 -0
  173. package/scripts/profile-isolation-launchers/profile-isolation-contract.test.mjs +221 -0
  174. package/scripts/profile-isolation-launchers/tests/launcher-runtime.test.mjs +108 -0
  175. package/scripts/profile-isolation-launchers/tests/mcp-bundles.test.mjs +147 -0
  176. package/scripts/profile-isolation-launchers/tests/shortcut-contract.test.ps1 +102 -0
  177. package/scripts/profile-isolation-launchers/tests/version-compatibility.test.mjs +210 -0
  178. package/scripts/profile-isolation-launchers/version-compatibility.mjs +299 -0
  179. package/scripts/profile-isolation-launchers/windows/shortcut-inventory.ps1 +127 -0
  180. package/scripts/profile-isolation-launchers/windows/shortcut-manifest.json +51 -0
  181. package/scripts/profile-isolation-launchers/windows/shortcut-reconcile.ps1 +77 -0
  182. package/scripts/spawn_grok_batch.py +3 -0
  183. package/scripts/test_active_capability_references.py +108 -0
  184. package/scripts/test_claude_chain_runner.py +414 -82
  185. package/scripts/test_claude_chain_usage.py +12 -12
  186. package/scripts/test_resolve_worker_spawn.py +2 -2
  187. package/scripts/test_verify_installable_package.py +208 -0
  188. package/scripts/tests/test_codex_compat_materializer.py +33 -0
  189. package/scripts/verify_installable_package.py +612 -0
  190. package/settings.json +10 -0
  191. package/skills/CLAUDE.md +2 -0
  192. package/skills/_shared/advisor/CLAUDE.md +1 -1
  193. package/skills/_shared/advisor/scripts/README.md +2 -0
  194. package/skills/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  195. package/skills/_shared/pr-loop/scripts/audit_category_schema.py +355 -0
  196. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +1 -0
  197. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/audit_category_schema_constants.py +32 -0
  198. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +7 -19
  199. package/skills/_shared/pr-loop/scripts/test_audit_category_schema.py +94 -0
  200. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +21 -0
  201. package/skills/autoconverge/reference/convergence.md +2 -1
  202. package/skills/autoconverge/reference/stop-conditions.md +5 -3
  203. package/skills/beat-sheet/SKILL.md +54 -0
  204. package/skills/beat-sheet/reference/visual-beats.md +29 -0
  205. package/skills/bugteam/CONSTRAINTS.md +4 -4
  206. package/skills/bugteam/EXAMPLES.md +1 -1
  207. package/skills/bugteam/reference/README.md +1 -1
  208. package/skills/e-code-review/SKILL.md +26 -5
  209. package/skills/e-code-review/reference/effort-evaluation.md +35 -0
  210. package/skills/e-code-review/reference/medium.md +15 -4
  211. package/skills/e-code-review/scripts/config/e_code_review_effort_constants/__init__.py +41 -0
  212. package/skills/e-code-review/scripts/config/e_code_review_effort_constants/effort_constants.py +40 -0
  213. package/skills/e-code-review/scripts/e_code_review_scripts_constants/finding_pipeline_constants.py +49 -0
  214. package/skills/e-code-review/scripts/effort_defaults_evidence.json +186 -0
  215. package/skills/e-code-review/scripts/effort_evaluation.py +362 -0
  216. package/skills/e-code-review/scripts/finding_pipeline.py +140 -0
  217. package/skills/e-code-review/scripts/fixtures/demanding.json +26 -0
  218. package/skills/e-code-review/scripts/fixtures/easy.json +14 -0
  219. package/skills/e-code-review/scripts/fixtures/medium.json +20 -0
  220. package/skills/e-code-review/scripts/grok_code_review.py +16 -7
  221. package/skills/e-code-review/scripts/test_effort_evaluation.py +180 -0
  222. package/skills/e-code-review/scripts/test_finding_pipeline.py +197 -0
  223. package/skills/e-code-review/scripts/test_grok_code_review.py +77 -0
  224. package/skills/grokify/SKILL.md +1 -1
  225. package/skills/grokify/templates/handoff-template.md +2 -2
  226. package/skills/orchestrator/SKILL.md +5 -4
  227. package/skills/plan-to-pr/scripts/create_packet.py +4 -4
  228. package/skills/plan-to-pr/scripts/load_skill_constants.py +41 -0
  229. package/skills/plan-to-pr/scripts/validate_packet.py +4 -4
  230. package/skills/plan-to-pr/scripts/validate_protocol.py +4 -1
  231. package/skills/plan-to-pr/scripts/validate_run.py +4 -1
  232. package/skills/pr-converge/scripts/check_convergence.py +21 -19
  233. package/skills/pr-converge/scripts/check_convergence_availability.py +50 -7
  234. package/skills/pr-converge/scripts/conftest.py +35 -0
  235. package/skills/pr-converge/scripts/test_check_convergence_availability.py +65 -0
  236. package/skills/pr-converge/scripts/test_check_convergence_codex.py +11 -1
  237. package/skills/pr-converge/scripts/test_check_convergence_contract.py +9 -2
  238. package/skills/pr-loop-cloud-transport/SKILL.md +1 -1
  239. package/skills/rebase/SKILL.md +15 -3
  240. package/skills/reviewer-gates/SKILL.md +2 -2
  241. package/skills/show/SKILL.md +51 -0
  242. package/skills/show/references/accessibility.md +7 -0
  243. package/skills/show/references/art.md +3 -0
  244. package/skills/show/references/charts.md +3 -0
  245. package/skills/show/references/core-design.md +14 -0
  246. package/skills/show/references/erds.md +3 -0
  247. package/skills/show/references/flowcharts.md +3 -0
  248. package/skills/show/references/host-and-html.md +3 -0
  249. package/skills/show/references/illustrative-diagrams.md +10 -0
  250. package/skills/show/references/interaction.md +3 -0
  251. package/skills/show/references/mockups.md +3 -0
  252. package/skills/show/references/quality-gates.md +7 -0
  253. package/skills/show/references/structural-diagrams.md +3 -0
  254. package/skills/show/references/subject-inventory.md +21 -0
  255. package/skills/show/references/svg-contract.md +22 -0
  256. package/skills/show/routing.yaml +30 -0
  257. package/skills/show/samples/pr1262-v2.svg +222 -0
  258. package/skills/show/scripts/README.md +6 -0
  259. package/skills/show/scripts/validate-artifact.py +91 -0
  260. package/skills/show/scripts/validate-package.py +18 -0
  261. package/skills/show/templates/html-widget.html +4 -0
  262. package/skills/show/templates/svg-base.svg +19 -0
  263. package/skills/show/tests/fixtures/css-var.svg +6 -0
  264. package/skills/show/tests/fixtures/dead-ref.svg +7 -0
  265. package/skills/show/tests/fixtures/filled-glyph.svg +8 -0
  266. package/skills/show/tests/fixtures/inherited-fill.svg +18 -0
  267. package/skills/show/tests/fixtures/invalid.svg +1 -0
  268. package/skills/show/tests/fixtures/large-canvas.svg +21 -0
  269. package/skills/show/tests/fixtures/unfilled-connector.svg +15 -0
  270. package/skills/show/tests/fixtures/valid.html +1 -0
  271. package/skills/show/tests/test_validate-artifact.py +74 -0
  272. package/skills/show/tests/test_validators.py +59 -0
  273. package/skills/show/workflows/create-visual.md +13 -0
  274. package/skills/show/workflows/review-visual.md +20 -0
  275. package/skills/split-pr/SKILL.md +85 -0
  276. package/skills/split-pr/reference/path-layers.md +16 -0
  277. package/skills/split-pr/reference/proposal-format.md +15 -0
  278. package/skills/split-pr/reference/split-further-loop.md +10 -0
  279. package/skills/split-pr/reference/splitting-principles.md +26 -0
  280. package/skills/split-pr/scripts/analyze_pr.py +279 -0
  281. package/skills/split-pr/scripts/categorize_files.py +106 -0
  282. package/skills/split-pr/scripts/config/__init__.py +1 -0
  283. package/skills/split-pr/scripts/config/dependency_constants.py +14 -0
  284. package/skills/split-pr/scripts/config/git_operations_constants.py +36 -0
  285. package/skills/split-pr/scripts/config/packing_constants.py +61 -0
  286. package/skills/split-pr/scripts/config/plan_constants.py +49 -0
  287. package/skills/split-pr/scripts/config/split_pr_constants.py +110 -0
  288. package/skills/split-pr/scripts/execute_split_slices.py +82 -0
  289. package/skills/split-pr/scripts/pack_files_into_slices.py +212 -0
  290. package/skills/split-pr/scripts/split_pr_dependency_graph.py +70 -0
  291. package/skills/split-pr/scripts/split_pr_git_operations.py +184 -0
  292. package/skills/split-pr/scripts/split_pr_layer_order.py +58 -0
  293. package/skills/split-pr/scripts/split_pr_paginate.py +119 -0
  294. package/skills/split-pr/scripts/split_pr_process_runner.py +52 -0
  295. package/skills/split-pr/scripts/split_pr_script_types.py +126 -0
  296. package/skills/split-pr/scripts/split_pr_title.py +41 -0
  297. package/skills/split-pr/scripts/test_analyze_pr.py +228 -0
  298. package/skills/split-pr/scripts/test_categorize_files.py +55 -0
  299. package/skills/split-pr/scripts/test_categorize_files_packing.py +59 -0
  300. package/skills/split-pr/scripts/test_execute_split_slices.py +99 -0
  301. package/skills/split-pr/scripts/test_split_pr_dependency_graph.py +47 -0
  302. package/skills/split-pr/scripts/test_split_pr_git_operations.py +125 -0
  303. package/skills/split-pr/scripts/test_split_pr_layer_order.py +36 -0
  304. package/skills/split-pr/scripts/test_split_pr_paginate.py +65 -0
  305. package/skills/split-pr/scripts/test_split_pr_script_types.py +73 -0
  306. package/skills/split-pr/scripts/test_split_pr_title.py +28 -0
  307. package/skills/split-pr/scripts/test_verify_dependency_graph.py +46 -0
  308. package/skills/split-pr/scripts/test_verify_plan.py +56 -0
  309. package/skills/split-pr/scripts/test_verify_plan_contract.py +50 -0
  310. package/skills/split-pr/scripts/test_verify_plan_path_normalization.py +45 -0
  311. package/skills/split-pr/scripts/verify_dependency_graph.py +111 -0
  312. package/skills/split-pr/scripts/verify_plan.py +139 -0
  313. package/skills/team-advisor/SKILL.md +7 -4
  314. package/skills/team-advisor/reference/advisor-docs-review.md +207 -0
  315. package/system-prompts/software-engineer.xml +11 -2
  316. package/commands/initialize.md +0 -90
  317. package/commands/stubcheck.md +0 -88
  318. package/output-styles/caveman-agent.md +0 -37
@@ -0,0 +1,397 @@
1
+ """Quote-aware shell command pipeline parsing.
2
+
3
+ Turns one Bash command string into operator-paired simple-command segments after
4
+ line-continuation join, comment strip, heredoc body drop, and parenthesis-group
5
+ join::
6
+
7
+ scannable_command_lines("pytest tests | tee out.log")
8
+ -> ["pytest tests | tee out.log"]
9
+ segments_with_following_operator(["pytest", "tests", "|", "tee", "out.log"])
10
+ -> [(["pytest", "tests"], "|"), (["tee", "out.log"], "")]
11
+
12
+ No pytest classification and no deny decision live here. Callers that need a
13
+ program verdict compose on top of these segments.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import shlex
19
+ from typing import NamedTuple
20
+
21
+ from hooks_constants.piped_pytest_blocker_constants import (
22
+ ALL_OPERATOR_TOKENS_LONGEST_FIRST,
23
+ ALL_PIPE_OPERATOR_TOKENS,
24
+ ALL_REDIRECTION_SUFFIX_CHARACTERS,
25
+ ALL_SEGMENT_RESET_OPERATOR_TOKENS,
26
+ CLOSED_GROUP_DEPTH,
27
+ COMMAND_LINE_SPLIT_PATTERN,
28
+ COMMENT_START_CHARACTER,
29
+ COMMENT_START_GROUP,
30
+ COMMENT_START_SCAN_PATTERN,
31
+ DISABLED_LEXER_COMMENTERS,
32
+ GROUP_CLOSE_CHARACTER,
33
+ GROUP_OPEN_CHARACTER,
34
+ HEREDOC_OPENER_OPERATOR,
35
+ HEREDOC_OPENER_PATTERN,
36
+ HEREDOC_STRIPPED_INDENT_CHARACTERS,
37
+ HEREDOC_TAB_STRIP_GROUP,
38
+ HEREDOC_TAB_STRIP_MARKER,
39
+ HEREDOC_TERMINATOR_GROUP,
40
+ LINE_CONTINUATION_JOIN,
41
+ LINE_CONTINUATION_PATTERN,
42
+ NO_FOLLOWING_OPERATOR,
43
+ PAREN_GROUP_LINE_JOIN,
44
+ PUNCTUATION_ONLY_TOKEN_PATTERN,
45
+ QUOTED_REGION_PATTERN,
46
+ QUOTED_REGION_REPLACEMENT,
47
+ )
48
+
49
+ __all__ = [
50
+ "PendingHeredoc",
51
+ "all_operator_aware_tokenizations",
52
+ "all_operator_split_tokens",
53
+ "all_punctuation_token_parts",
54
+ "closes_the_heredoc",
55
+ "command_line_without_comment",
56
+ "join_line_continuations",
57
+ "live_command_lines",
58
+ "paren_group_joined_lines",
59
+ "pending_heredoc_opened_by",
60
+ "pipeline_segments_for_command",
61
+ "scannable_command_lines",
62
+ "segments_with_following_operator",
63
+ ]
64
+
65
+
66
+ class PendingHeredoc(NamedTuple):
67
+ """The word an open heredoc closes on, and how its opener lets that word be indented."""
68
+
69
+ terminator: str
70
+ allows_leading_tabs: bool
71
+
72
+
73
+ def join_line_continuations(command: str) -> str:
74
+ """Return the command with backslash line continuations joined.
75
+
76
+ ::
77
+
78
+ pytest tests \\\\
79
+ | tee out.log -> "pytest tests | tee out.log"
80
+
81
+ Args:
82
+ command: One Bash command that may carry trailing-backslash continuations.
83
+
84
+ Returns:
85
+ The same command with each continuation removed so the next line sits
86
+ on the same physical line.
87
+ """
88
+ return LINE_CONTINUATION_PATTERN.sub(LINE_CONTINUATION_JOIN, command)
89
+
90
+
91
+ def all_operator_aware_tokenizations(command: str) -> list[list[str]]:
92
+ """Return quote-aware tokenizations that carry operators as their own tokens.
93
+
94
+ POSIX mode resolves quoting the way Git Bash does; raw mode keeps a Windows
95
+ interpreter path such as ``C:\\Python313\\python.exe`` whole, since POSIX
96
+ mode reads its backslashes as escapes. Both are returned so a caller can
97
+ grade either spelling.
98
+
99
+ The lexer's own commenters are cleared, because it cuts at a ``#`` anywhere
100
+ in a word while a shell starts a comment only at a word's start.
101
+ ``command_line_without_comment`` owns comment removal.
102
+
103
+ Args:
104
+ command: One logical Bash command line (continuations already joined).
105
+
106
+ Returns:
107
+ Zero, one, or two non-empty token lists (POSIX and/or raw).
108
+ """
109
+ all_tokenizations: list[list[str]] = []
110
+ for each_posix_mode in (True, False):
111
+ lexer = shlex.shlex(command, posix=each_posix_mode, punctuation_chars=True)
112
+ lexer.whitespace_split = True
113
+ lexer.commenters = DISABLED_LEXER_COMMENTERS
114
+ try:
115
+ all_tokens = list(lexer)
116
+ except ValueError:
117
+ continue
118
+ if all_tokens:
119
+ all_tokenizations.append(all_tokens)
120
+ return all_tokenizations
121
+
122
+
123
+ def _trailing_operator_token(token: str) -> str | None:
124
+ """Return the longest control operator the token ends with, or None."""
125
+ for each_operator in ALL_OPERATOR_TOKENS_LONGEST_FIRST:
126
+ if token.endswith(each_operator):
127
+ return each_operator
128
+ return None
129
+
130
+
131
+ def all_punctuation_token_parts(token: str) -> list[str]:
132
+ """Split a punctuation-only token into its leading text and trailing operators.
133
+
134
+ ::
135
+
136
+ )| [')', '|']
137
+ )|| [')', '||']
138
+ tests ['tests']
139
+
140
+ Args:
141
+ token: One token from an operator-aware tokenization.
142
+
143
+ Returns:
144
+ The token's parts in original order — a one-item list when no split is needed.
145
+ """
146
+ if PUNCTUATION_ONLY_TOKEN_PATTERN.fullmatch(token) is None:
147
+ return [token]
148
+ all_peeled_operators: list[str] = []
149
+ remaining_text = token
150
+ while True:
151
+ trailing_operator = _trailing_operator_token(remaining_text)
152
+ if trailing_operator is None or trailing_operator == remaining_text:
153
+ break
154
+ leading_text = remaining_text[: -len(trailing_operator)]
155
+ if leading_text.endswith(ALL_REDIRECTION_SUFFIX_CHARACTERS):
156
+ break
157
+ all_peeled_operators.append(trailing_operator)
158
+ remaining_text = leading_text
159
+ return [remaining_text, *reversed(all_peeled_operators)]
160
+
161
+
162
+ def all_operator_split_tokens(all_command_tokens: list[str]) -> list[str]:
163
+ """Return the tokens with every glued punctuation-and-operator token split apart.
164
+
165
+ Args:
166
+ all_command_tokens: Tokens from an operator-aware tokenization.
167
+
168
+ Returns:
169
+ Tokens with glued punctuation operators peeled into their own items.
170
+ """
171
+ all_split_tokens: list[str] = []
172
+ for each_token in all_command_tokens:
173
+ all_split_tokens.extend(all_punctuation_token_parts(each_token))
174
+ return all_split_tokens
175
+
176
+
177
+ def segments_with_following_operator(
178
+ all_command_tokens: list[str],
179
+ ) -> list[tuple[list[str], str]]:
180
+ """Pair each simple-command segment with the control operator that ends it.
181
+
182
+ A redirection token stays inside its segment. A close paren stays inside too,
183
+ so a subshell's program survives to the pipe that follows it.
184
+
185
+ Args:
186
+ all_command_tokens: Tokens from an operator-aware tokenization.
187
+
188
+ Returns:
189
+ ``(segment_tokens, following_operator)`` pairs; the last pair carries
190
+ an empty following operator when the command ends without one.
191
+ """
192
+ all_segments: list[tuple[list[str], str]] = []
193
+ current_segment: list[str] = []
194
+ for each_token in all_operator_split_tokens(all_command_tokens):
195
+ if (
196
+ each_token in ALL_PIPE_OPERATOR_TOKENS
197
+ or each_token in ALL_SEGMENT_RESET_OPERATOR_TOKENS
198
+ ):
199
+ all_segments.append((current_segment, each_token))
200
+ current_segment = []
201
+ continue
202
+ current_segment.append(each_token)
203
+ all_segments.append((current_segment, NO_FOLLOWING_OPERATOR))
204
+ return all_segments
205
+
206
+
207
+ def pending_heredoc_opened_by(command_line: str) -> PendingHeredoc | None:
208
+ """Return the heredoc the line opens, or None when it opens none.
209
+
210
+ ::
211
+
212
+ cat > run.sh <<EOF PendingHeredoc('EOF', allows_leading_tabs=False)
213
+ cat > run.sh <<-EOF PendingHeredoc('EOF', allows_leading_tabs=True)
214
+ cat file <<<word None
215
+ echo hi None
216
+
217
+ Args:
218
+ command_line: One physical command line.
219
+
220
+ Returns:
221
+ The pending heredoc, or None when the line opens none.
222
+ """
223
+ heredoc_opener = HEREDOC_OPENER_PATTERN.search(command_line)
224
+ if heredoc_opener is None:
225
+ return None
226
+ return PendingHeredoc(
227
+ terminator=heredoc_opener.group(HEREDOC_TERMINATOR_GROUP),
228
+ allows_leading_tabs=(
229
+ heredoc_opener.group(HEREDOC_TAB_STRIP_GROUP) == HEREDOC_TAB_STRIP_MARKER
230
+ ),
231
+ )
232
+
233
+
234
+ def closes_the_heredoc(command_line: str, pending_heredoc: PendingHeredoc) -> bool:
235
+ """Return True when the line is the terminator its opener spelled.
236
+
237
+ Args:
238
+ command_line: One physical command line inside an open heredoc.
239
+ pending_heredoc: The opener that is still waiting to close.
240
+
241
+ Returns:
242
+ True when this line ends the heredoc body.
243
+ """
244
+ if pending_heredoc.allows_leading_tabs:
245
+ return (
246
+ command_line.lstrip(HEREDOC_STRIPPED_INDENT_CHARACTERS)
247
+ == pending_heredoc.terminator
248
+ )
249
+ return command_line == pending_heredoc.terminator
250
+
251
+
252
+ def live_command_lines(all_command_lines: list[str]) -> list[str]:
253
+ """Return the lines the call runs, dropping every heredoc body and terminator.
254
+
255
+ Args:
256
+ all_command_lines: The physical lines of one Bash command, in order.
257
+
258
+ Returns:
259
+ The lines outside every heredoc body, in their original order.
260
+ """
261
+ all_live_lines: list[str] = []
262
+ pending_heredoc: PendingHeredoc | None = None
263
+ for each_line in all_command_lines:
264
+ if pending_heredoc is not None:
265
+ if closes_the_heredoc(each_line, pending_heredoc):
266
+ pending_heredoc = None
267
+ continue
268
+ all_live_lines.append(each_line)
269
+ pending_heredoc = pending_heredoc_opened_by(each_line)
270
+ return all_live_lines
271
+
272
+
273
+ def command_line_without_comment(command_line: str) -> str:
274
+ """Return the line with a shell comment and everything after it removed.
275
+
276
+ ::
277
+
278
+ python -m pytest tests # fast -> python -m pytest tests
279
+ pytest -k "a#b" -> unchanged (hash inside quotes)
280
+ tee run#1.log -> unchanged (hash inside a word)
281
+
282
+ Args:
283
+ command_line: One physical command line.
284
+
285
+ Returns:
286
+ The line up to its comment, or the whole line when it carries none.
287
+ """
288
+ for each_match in COMMENT_START_SCAN_PATTERN.finditer(command_line):
289
+ if each_match.group(COMMENT_START_GROUP) is not None:
290
+ return command_line[: each_match.start(COMMENT_START_GROUP)]
291
+ return command_line
292
+
293
+
294
+ def _comment_free_lines(all_command_lines: list[str]) -> list[str]:
295
+ """Return every line with its shell comment removed.
296
+
297
+ Args:
298
+ all_command_lines: The physical lines of one Bash command, in order.
299
+
300
+ Returns:
301
+ The comment-free lines, in their original order and count.
302
+ """
303
+ return [command_line_without_comment(each_line) for each_line in all_command_lines]
304
+
305
+
306
+ def _paren_depth_change(command_line: str) -> int:
307
+ """Return how many parenthesis groups the line opens, minus the ones it closes."""
308
+ unquoted_line = QUOTED_REGION_PATTERN.sub(QUOTED_REGION_REPLACEMENT, command_line)
309
+ return unquoted_line.count(GROUP_OPEN_CHARACTER) - unquoted_line.count(
310
+ GROUP_CLOSE_CHARACTER
311
+ )
312
+
313
+
314
+ def paren_group_joined_lines(all_command_lines: list[str]) -> list[str]:
315
+ """Return the lines with each open parenthesis group joined into one logical line.
316
+
317
+ Args:
318
+ all_command_lines: Live command lines, comments removed and heredoc bodies dropped.
319
+
320
+ Returns:
321
+ One line per closed parenthesis group, and the unchanged line for every other.
322
+ """
323
+ all_joined_lines: list[str] = []
324
+ all_pending_lines: list[str] = []
325
+ open_group_depth = CLOSED_GROUP_DEPTH
326
+ for each_line in all_command_lines:
327
+ all_pending_lines.append(each_line)
328
+ open_group_depth = max(
329
+ open_group_depth + _paren_depth_change(each_line), CLOSED_GROUP_DEPTH
330
+ )
331
+ if open_group_depth > CLOSED_GROUP_DEPTH:
332
+ continue
333
+ all_joined_lines.append(PAREN_GROUP_LINE_JOIN.join(all_pending_lines))
334
+ all_pending_lines = []
335
+ if all_pending_lines:
336
+ all_joined_lines.append(PAREN_GROUP_LINE_JOIN.join(all_pending_lines))
337
+ return all_joined_lines
338
+
339
+
340
+ def scannable_command_lines(joined_command: str) -> list[str]:
341
+ """Return the lines to tokenize, running only the passes the command's text calls for.
342
+
343
+ ::
344
+
345
+ pytest tests | tee x split only
346
+ pytest tests # | tee x comment pass runs
347
+ (pytest tests) | tee x parenthesis-group join runs
348
+ cat <<EOF ... EOF heredoc pass runs
349
+
350
+ Args:
351
+ joined_command: One Bash command, its line continuations already joined.
352
+
353
+ Returns:
354
+ The command lines ready for tokenization.
355
+ """
356
+ all_command_lines = COMMAND_LINE_SPLIT_PATTERN.split(joined_command)
357
+ if COMMENT_START_CHARACTER in joined_command:
358
+ all_command_lines = _comment_free_lines(all_command_lines)
359
+ if HEREDOC_OPENER_OPERATOR in joined_command:
360
+ all_command_lines = live_command_lines(all_command_lines)
361
+ if GROUP_OPEN_CHARACTER not in joined_command:
362
+ return all_command_lines
363
+ return paren_group_joined_lines(all_command_lines)
364
+
365
+
366
+ def pipeline_segments_for_command(
367
+ command: str,
368
+ ) -> list[tuple[list[str], str]]:
369
+ """Return every operator-paired segment across every quote-aware tokenization.
370
+
371
+ Joins line continuations, builds scannable lines, tokenizes each, and pairs
372
+ segments with the control operator that follows them. Duplicate segment
373
+ pairs from the two tokenizations are kept only once, in first-seen order.
374
+
375
+ Args:
376
+ command: One Bash command as the tool would receive it.
377
+
378
+ Returns:
379
+ ``(segment_tokens, following_operator)`` pairs ready for a caller to
380
+ grade, with no program classification applied.
381
+ """
382
+ joined_command = join_line_continuations(command)
383
+ all_pairs: list[tuple[list[str], str]] = []
384
+ seen_pair_keys: set[tuple[tuple[str, ...], str]] = set()
385
+ for each_line in scannable_command_lines(joined_command):
386
+ if not each_line.strip():
387
+ continue
388
+ for each_tokenization in all_operator_aware_tokenizations(each_line):
389
+ for each_segment, each_operator in segments_with_following_operator(
390
+ each_tokenization
391
+ ):
392
+ pair_key = (tuple(each_segment), each_operator)
393
+ if pair_key in seen_pair_keys:
394
+ continue
395
+ seen_pair_keys.add(pair_key)
396
+ all_pairs.append((each_segment, each_operator))
397
+ return all_pairs
@@ -4,6 +4,11 @@ Tokenizes a command into simple-command segments on control operators and finds
4
4
  each segment's effective leading program after env assignments and launcher
5
5
  wrappers. NAS ssh enforcement and unscoped-search blocking both need this
6
6
  shape, so one module owns it.
7
+
8
+ ``split_into_segments`` is not quote-aware: a pipe inside ``-k "a|b"`` still
9
+ splits. Quote-aware pipeline parsing — operators as their own tokens, heredoc
10
+ bodies dropped, parenthesis groups joined — lives in
11
+ ``shell_command_pipeline``.
7
12
  """
8
13
 
9
14
  from __future__ import annotations
@@ -0,0 +1,167 @@
1
+ """Payload-shape fixtures for the pure AskUserQuestion analyzer.
2
+
3
+ Grades lean-block shape without hook I/O. Each case names one marker or cap
4
+ the PR #720 contract preserves.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import pathlib
10
+ import sys
11
+
12
+ _HOOKS_ROOT = pathlib.Path(__file__).resolve().parent.parent
13
+ if str(_HOOKS_ROOT) not in sys.path:
14
+ sys.path.insert(0, str(_HOOKS_ROOT))
15
+
16
+ from hooks_constants.ask_user_question_shape import ( # noqa: E402
17
+ analyze_ask_user_question_shape,
18
+ count_prose_words,
19
+ find_question_block_violations,
20
+ mask_inline_code,
21
+ normalize_line_endings,
22
+ )
23
+ from hooks_constants.plain_language_blocker_constants import ( # noqa: E402
24
+ MAXIMUM_OPTION_DESCRIPTION_WORD_COUNT,
25
+ MAXIMUM_QUESTION_SENTENCE_COUNT,
26
+ MAXIMUM_QUESTION_WORD_COUNT,
27
+ )
28
+
29
+ LEAN_QUESTION = "Which gate should run first?"
30
+ LEAN_DESCRIPTION = "Runs on every write."
31
+
32
+
33
+ def _ask_payload(
34
+ question_text: str,
35
+ all_descriptions: list[str],
36
+ ) -> dict[str, object]:
37
+ return {
38
+ "questions": [
39
+ {
40
+ "question": question_text,
41
+ "options": [
42
+ {"label": f"Option {each_index}", "description": each_description}
43
+ for each_index, each_description in enumerate(all_descriptions, start=1)
44
+ ],
45
+ }
46
+ ]
47
+ }
48
+
49
+
50
+ def test_lean_payload_is_clean() -> None:
51
+ result = analyze_ask_user_question_shape(
52
+ _ask_payload(LEAN_QUESTION, [LEAN_DESCRIPTION])
53
+ )
54
+ assert result.is_lean
55
+ assert result.all_violations == ()
56
+
57
+
58
+ def test_list_marker_under_question_is_flagged() -> None:
59
+ plan_question = f"{LEAN_QUESTION}\n- Split the file\n- Wire the gate"
60
+ result = analyze_ask_user_question_shape(
61
+ _ask_payload(plan_question, [LEAN_DESCRIPTION])
62
+ )
63
+ assert not result.is_lean
64
+ assert any("list marker" in each for each in result.all_violations)
65
+
66
+
67
+ def test_fenced_block_is_flagged() -> None:
68
+ fenced_question = f"{LEAN_QUESTION}\n```\nrun_gate()\n```"
69
+ result = analyze_ask_user_question_shape(
70
+ _ask_payload(fenced_question, [LEAN_DESCRIPTION])
71
+ )
72
+ assert any("fenced code block" in each for each in result.all_violations)
73
+
74
+
75
+ def test_second_paragraph_is_flagged() -> None:
76
+ two_paragraph_question = f"{LEAN_QUESTION}\n\nThe write gate reads every file."
77
+ result = analyze_ask_user_question_shape(
78
+ _ask_payload(two_paragraph_question, [LEAN_DESCRIPTION])
79
+ )
80
+ assert any("more than one paragraph" in each for each in result.all_violations)
81
+
82
+
83
+ def test_carriage_return_paragraph_break_is_flagged() -> None:
84
+ crlf_question = f"{LEAN_QUESTION}\r\n\r\nThe write gate reads every file."
85
+ result = analyze_ask_user_question_shape(
86
+ _ask_payload(crlf_question, [LEAN_DESCRIPTION])
87
+ )
88
+ assert any("more than one paragraph" in each for each in result.all_violations)
89
+
90
+
91
+ def test_heading_is_flagged() -> None:
92
+ headed_question = f"{LEAN_QUESTION}\n## The write gate\nIt reads every file."
93
+ result = analyze_ask_user_question_shape(
94
+ _ask_payload(headed_question, [LEAN_DESCRIPTION])
95
+ )
96
+ assert any("heading" in each for each in result.all_violations)
97
+
98
+
99
+ def test_table_row_in_option_is_flagged() -> None:
100
+ result = analyze_ask_user_question_shape(
101
+ _ask_payload(LEAN_QUESTION, ["| gate | 12 ms |"])
102
+ )
103
+ assert any("table row" in each for each in result.all_violations)
104
+
105
+
106
+ def test_question_over_word_cap_is_flagged() -> None:
107
+ over_cap = " ".join(["word"] * (MAXIMUM_QUESTION_WORD_COUNT + 1))
108
+ result = analyze_ask_user_question_shape(_ask_payload(over_cap, [LEAN_DESCRIPTION]))
109
+ assert any("word" in each and "cap" in each for each in result.all_violations)
110
+
111
+
112
+ def test_question_over_sentence_cap_is_flagged() -> None:
113
+ three_sentences = "One. Two. Three."
114
+ assert MAXIMUM_QUESTION_SENTENCE_COUNT < 3
115
+ result = analyze_ask_user_question_shape(
116
+ _ask_payload(three_sentences, [LEAN_DESCRIPTION])
117
+ )
118
+ assert any("sentence" in each and "cap" in each for each in result.all_violations)
119
+
120
+
121
+ def test_option_description_over_word_cap_is_flagged() -> None:
122
+ over_cap = " ".join(["word"] * (MAXIMUM_OPTION_DESCRIPTION_WORD_COUNT + 1))
123
+ result = analyze_ask_user_question_shape(_ask_payload(LEAN_QUESTION, [over_cap]))
124
+ assert any(
125
+ "option description" in each and "word" in each for each in result.all_violations
126
+ )
127
+
128
+
129
+ def test_option_description_over_sentence_cap_is_flagged() -> None:
130
+ result = analyze_ask_user_question_shape(
131
+ _ask_payload(LEAN_QUESTION, ["Runs on write. Reads every file."])
132
+ )
133
+ assert any(
134
+ "option description" in each and "sentence" in each
135
+ for each in result.all_violations
136
+ )
137
+
138
+
139
+ def test_inline_code_counts_as_one_word() -> None:
140
+ masked = mask_inline_code("Does `git diff --cached --stat` list it?")
141
+ assert count_prose_words(masked) == count_prose_words("Does code list it?")
142
+
143
+
144
+ def test_normalize_line_endings_folds_crlf() -> None:
145
+ assert normalize_line_endings("a\r\nb\rc") == "a\nb\nc"
146
+
147
+
148
+ def test_duplicate_faults_report_once() -> None:
149
+ tool_input: dict[str, object] = {
150
+ "questions": [
151
+ {
152
+ "question": LEAN_QUESTION,
153
+ "options": [
154
+ {"label": "A", "description": "| a | b |"},
155
+ {"label": "B", "description": "| c | d |"},
156
+ ],
157
+ }
158
+ ]
159
+ }
160
+ all_violations = find_question_block_violations(tool_input)
161
+ table_faults = [each for each in all_violations if "table row" in each]
162
+ assert len(table_faults) == 1
163
+
164
+
165
+ def test_non_dict_questions_list_is_clean() -> None:
166
+ result = analyze_ask_user_question_shape({"questions": "not-a-list"})
167
+ assert result.is_lean
@@ -4,6 +4,7 @@ import inspect
4
4
  import json
5
5
  import sys
6
6
  from pathlib import Path
7
+ from unittest.mock import patch
7
8
 
8
9
  import pytest
9
10
 
@@ -13,6 +14,7 @@ if str(_HOOKS_ROOT) not in sys.path:
13
14
 
14
15
  from hooks_constants import project_paths_reader
15
16
  from hooks_constants.project_paths_reader import (
17
+ find_git_root,
16
18
  load_registry,
17
19
  registry_contains_path,
18
20
  registry_file_path,
@@ -147,3 +149,30 @@ def test_load_registry_uses_shared_utf8_encoding_constant() -> None:
147
149
  assert 'encoding="utf-8"' not in source, (
148
150
  'load_registry must use UTF8_ENCODING constant, not the bare literal "utf-8"'
149
151
  )
152
+
153
+
154
+ def test_find_git_root_returns_root_for_nested_path(tmp_path: Path) -> None:
155
+ repo_root = tmp_path / "repo"
156
+ nested = repo_root / "pkg"
157
+ nested.mkdir(parents=True)
158
+ (repo_root / ".git").mkdir()
159
+ found = find_git_root(str(nested))
160
+ assert found is not None
161
+ assert Path(found).resolve() == repo_root.resolve()
162
+
163
+
164
+ def test_find_git_root_returns_none_without_git(tmp_path: Path) -> None:
165
+ bare = tmp_path / "bare"
166
+ bare.mkdir()
167
+ original_exists = Path.exists
168
+
169
+ def exists_without_ambient_git(self_path: Path) -> bool:
170
+ if self_path.name == ".git":
171
+ resolved_parent = self_path.resolve().parent
172
+ if resolved_parent == bare.resolve() or bare.resolve() in resolved_parent.parents:
173
+ return original_exists(self_path)
174
+ return False
175
+ return original_exists(self_path)
176
+
177
+ with patch.object(Path, "exists", exists_without_ambient_git):
178
+ assert find_git_root(str(bare)) is None
@@ -35,6 +35,9 @@ from hooks_constants.eli11_reply_enforcer_constants import (
35
35
  from hooks_constants.eli11_reply_enforcer_constants import (
36
36
  COUNTABLE_WORD_PATTERN as ELI11_COUNTABLE_WORD_PATTERN,
37
37
  )
38
+ from hooks_constants.ask_user_question_shape import (
39
+ COUNTABLE_WORD_PATTERN as SHAPE_COUNTABLE_WORD_PATTERN,
40
+ )
38
41
  from hooks_constants.plain_language_blocker_constants import ALL_CHAT_DETAIL_MARKERS
39
42
  from hooks_constants.plain_language_blocker_constants import (
40
43
  COUNTABLE_WORD_PATTERN as PLAIN_LANGUAGE_COUNTABLE_WORD_PATTERN,
@@ -102,6 +105,11 @@ def test_countable_word_pattern_copies_stay_byte_equal() -> None:
102
105
  )
103
106
 
104
107
 
108
+ def test_shape_analyzer_binds_the_plain_language_countable_word_pattern() -> None:
109
+ """The pure shape analyzer reuses the shared pattern object, not a private copy."""
110
+ assert SHAPE_COUNTABLE_WORD_PATTERN is PLAIN_LANGUAGE_COUNTABLE_WORD_PATTERN
111
+
112
+
105
113
  def test_table_row_pattern_copies_stay_byte_equal() -> None:
106
114
  plain_language_table_row = _chat_detail_marker_named(_TABLE_ROW_MARKER_LABEL)
107
115
  assert plain_language_table_row.pattern == TABLE_ROW_PATTERN.pattern