claude-dev-env 1.92.1 → 1.93.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 (214) hide show
  1. package/CLAUDE.md +6 -2
  2. package/_shared/CLAUDE.md +2 -1
  3. package/_shared/advisor/CLAUDE.md +21 -0
  4. package/_shared/advisor/advisor-protocol.md +142 -0
  5. package/_shared/advisor/scripts/config/advisor_scripts_constants/__init__.py +1 -0
  6. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +86 -0
  7. package/_shared/advisor/scripts/model_tier_run_validator.py +246 -0
  8. package/_shared/advisor/scripts/pyproject.toml +3 -0
  9. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +302 -0
  10. package/_shared/advisor/scripts/tests/test_tier_model_ids.py +180 -0
  11. package/_shared/advisor/scripts/tier_model_ids.py +164 -0
  12. package/_shared/pr-loop/CLAUDE.md +1 -0
  13. package/_shared/pr-loop/precatch-rubric.md +65 -0
  14. package/_shared/pr-loop/scripts/CLAUDE.md +1 -1
  15. package/_shared/pr-loop/scripts/code_rules_gate.py +137 -5
  16. package/_shared/pr-loop/scripts/post_audit_thread.py +1 -1
  17. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -1
  18. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +16 -0
  19. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +2 -1
  20. package/_shared/pr-loop/scripts/reviewer_availability.py +42 -12
  21. package/_shared/pr-loop/scripts/reviews_disabled.py +65 -21
  22. package/_shared/pr-loop/scripts/terminology_sweep.py +69 -21
  23. package/_shared/pr-loop/scripts/tests/CLAUDE.md +1 -1
  24. package/_shared/pr-loop/scripts/tests/fixtures/{copilot_internal_user_jonecho.json → copilot_internal_user_example.json} +1 -1
  25. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +1 -1
  26. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +146 -2
  27. package/_shared/pr-loop/scripts/tests/test_copilot_quota.py +8 -8
  28. package/_shared/pr-loop/scripts/tests/test_reviewer_availability.py +18 -5
  29. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +56 -5
  30. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +104 -17
  31. package/agents/CLAUDE.md +3 -2
  32. package/agents/clasp-deployment-orchestrator.md +2 -2
  33. package/agents/code-advisor.md +7 -5
  34. package/agents/code-verifier.md +6 -2
  35. package/agents/session-advisor.md +27 -0
  36. package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +1 -1
  37. package/audit-rubrics/category_rubrics/category-k-codebase-conflicts.md +1 -1
  38. package/audit-rubrics/prompts/category-a-api-contracts.md +2 -2
  39. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
  40. package/audit-rubrics/prompts/category-c-resource-cleanup.md +2 -2
  41. package/audit-rubrics/prompts/category-d-scoping-and-ordering.md +2 -2
  42. package/audit-rubrics/prompts/category-e-dead-code.md +2 -2
  43. package/audit-rubrics/prompts/category-f-silent-failures.md +2 -2
  44. package/audit-rubrics/prompts/category-g-bounds-and-overflow.md +2 -2
  45. package/audit-rubrics/prompts/category-h-security-boundaries.md +2 -2
  46. package/audit-rubrics/prompts/category-i-concurrency.md +2 -2
  47. package/audit-rubrics/prompts/category-j-code-rules-compliance.md +2 -2
  48. package/audit-rubrics/prompts/category-k-codebase-conflicts.md +2 -2
  49. package/audit-rubrics/prompts/category-l-behavior-equivalence.md +2 -2
  50. package/audit-rubrics/prompts/category-m-producer-consumer-cardinality.md +2 -2
  51. package/audit-rubrics/prompts/category-n-test-name-scenario-verifier.md +2 -2
  52. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +2 -2
  53. package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +2 -2
  54. package/bin/CLAUDE.md +1 -0
  55. package/bin/expand_home_directory_tokens.mjs +75 -0
  56. package/bin/install.mjs +21 -7
  57. package/bin/install.test.mjs +220 -10
  58. package/hooks/CLAUDE.md +1 -1
  59. package/hooks/blocking/CLAUDE.md +4 -1
  60. package/hooks/blocking/_path_setup.py +13 -0
  61. package/hooks/blocking/bash_pre_tool_use_dispatcher.py +281 -0
  62. package/hooks/blocking/code_rules_dead_module_constant.py +88 -16
  63. package/hooks/blocking/code_rules_enforcer.py +6 -0
  64. package/hooks/blocking/code_rules_imports_logging.py +34 -1
  65. package/hooks/blocking/code_rules_paths_syspath.py +1 -1
  66. package/hooks/blocking/code_rules_test_layout.py +272 -0
  67. package/hooks/blocking/nas_ssh_binary_enforcer.py +22 -12
  68. package/hooks/blocking/pii_prevention_blocker.py +781 -0
  69. package/hooks/blocking/pii_scanner.py +340 -0
  70. package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
  71. package/hooks/blocking/stop_dispatcher.py +84 -0
  72. package/hooks/blocking/test__path_setup.py +39 -0
  73. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +61 -0
  74. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +294 -0
  75. package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +1 -1
  76. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +51 -0
  77. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_read_cap.py +103 -0
  78. package/hooks/blocking/test_code_rules_enforcer_dot_test_pattern.py +1 -1
  79. package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +1 -1
  80. package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +11 -11
  81. package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +1 -1
  82. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +1 -1
  83. package/hooks/blocking/test_code_rules_enforcer_test_layout.py +111 -0
  84. package/hooks/blocking/test_code_rules_enforcer_type_checking_scope.py +76 -0
  85. package/hooks/blocking/test_destructive_command_blocker.py +29 -9
  86. package/hooks/blocking/test_gh_body_arg_blocker.py +3 -3
  87. package/hooks/blocking/test_nas_ssh_binary_enforcer.py +96 -39
  88. package/hooks/blocking/test_pii_prevention_blocker.py +905 -0
  89. package/hooks/blocking/test_pii_prevention_windows_git_path.py +41 -0
  90. package/hooks/blocking/test_pii_scanner.py +165 -0
  91. package/hooks/blocking/test_pr_description_enforcer_pr_number.py +1 -1
  92. package/hooks/blocking/test_pre_tool_use_dispatcher.py +14 -13
  93. package/hooks/blocking/test_stop_dispatcher.py +187 -0
  94. package/hooks/blocking/test_verdict_directory_write_blocker.py +1 -1
  95. package/hooks/blocking/test_volatile_path_in_post_blocker.py +3 -3
  96. package/hooks/blocking/volatile_path_in_post_blocker.py +54 -5
  97. package/hooks/diagnostic/migrations/README.md +25 -26
  98. package/hooks/diagnostic/test_hook_log_extractor.py +10 -10
  99. package/hooks/hooks.json +10 -120
  100. package/hooks/hooks_constants/CLAUDE.md +12 -3
  101. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +78 -0
  102. package/hooks/hooks_constants/dead_module_constant_constants.py +1 -0
  103. package/hooks/hooks_constants/hosted_hook_runner.py +73 -0
  104. package/hooks/hooks_constants/local_identity.py +182 -0
  105. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +4 -37
  106. package/hooks/hooks_constants/pii_prevention_constants.py +299 -0
  107. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  108. package/hooks/hooks_constants/python_style_checks_constants.py +15 -0
  109. package/hooks/hooks_constants/stop_dispatcher_constants.py +26 -0
  110. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +87 -0
  111. package/hooks/hooks_constants/test_hardcoded_user_path_constants.py +2 -2
  112. package/hooks/hooks_constants/test_hosted_hook_runner.py +101 -0
  113. package/hooks/hooks_constants/test_layout_constants.py +28 -0
  114. package/hooks/hooks_constants/test_local_identity.py +88 -0
  115. package/hooks/hooks_constants/test_session_env_cleanup_constants.py +1 -1
  116. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +32 -0
  117. package/hooks/validators/README.md +5 -6
  118. package/hooks/validators/__init__.py +2 -2
  119. package/hooks/validators/python_style_checks.py +298 -243
  120. package/hooks/validators/run_all_validators.py +6 -0
  121. package/hooks/validators/test_python_style_checks.py +278 -163
  122. package/package.json +2 -2
  123. package/rules/CLAUDE.md +2 -1
  124. package/rules/bdd.md +1 -1
  125. package/rules/nas-ssh-invocation.md +6 -4
  126. package/rules/no-justification-noise.md +61 -0
  127. package/scripts/test_setup_project_paths.py +1 -1
  128. package/skills/CLAUDE.md +6 -3
  129. package/skills/_shared/pr-loop/scripts/_path_resolver.py +2 -2
  130. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +1 -1
  131. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +1 -1
  132. package/skills/_shared/pr-loop/scripts/test__path_resolver.py +42 -3
  133. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +11 -11
  134. package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +26 -3
  135. package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +49 -25
  136. package/skills/autoconverge/CLAUDE.md +3 -3
  137. package/skills/autoconverge/SKILL.md +113 -35
  138. package/skills/autoconverge/reference/CLAUDE.md +2 -2
  139. package/skills/autoconverge/reference/convergence.md +44 -22
  140. package/skills/autoconverge/reference/gotchas.md +11 -0
  141. package/skills/autoconverge/reference/stop-conditions.md +23 -13
  142. package/skills/autoconverge/workflow/CLAUDE.md +2 -1
  143. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +15 -19
  144. package/skills/autoconverge/workflow/converge.contract.test.mjs +43 -42
  145. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +141 -16
  146. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +2 -2
  147. package/skills/autoconverge/workflow/converge.mjs +329 -68
  148. package/skills/autoconverge/workflow/converge.path-aware.test.mjs +1 -1
  149. package/skills/autoconverge/workflow/converge.precatch.test.mjs +152 -0
  150. package/skills/autoconverge/workflow/converge.run-input.test.mjs +3 -3
  151. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +15 -15
  152. package/skills/autoconverge/workflow/test_convergence_summary.py +6 -6
  153. package/skills/bdd-protocol/SKILL.md +4 -5
  154. package/skills/bdd-protocol/references/anti-patterns.md +1 -1
  155. package/skills/bugteam/SKILL.md +8 -0
  156. package/skills/bugteam/reference/copilot-gap-analysis.md +20 -20
  157. package/skills/bugteam/reference/obstacles/audit-walk-categories.md +1 -1
  158. package/skills/bugteam/reference/team-setup.md +7 -5
  159. package/skills/bugteam/scripts/reflow_skill_md.py +1 -1
  160. package/skills/copilot-finding-triage/SKILL.md +124 -0
  161. package/skills/copilot-finding-triage/reference/tier-rubric.md +49 -0
  162. package/skills/copilot-finding-triage/scripts/conftest.py +8 -0
  163. package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/__init__.py +0 -0
  164. package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/config/__init__.py +0 -0
  165. package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/config/notify_ntfy_constants.py +32 -0
  166. package/skills/copilot-finding-triage/scripts/notify_ntfy.py +192 -0
  167. package/skills/copilot-finding-triage/scripts/test_notify_ntfy.py +141 -0
  168. package/skills/copilot-finding-triage/templates/notification.md +34 -0
  169. package/skills/copilot-review/SKILL.md +8 -6
  170. package/skills/findbugs/SKILL.md +4 -0
  171. package/skills/fixbugs/SKILL.md +8 -7
  172. package/skills/gotcha/CLAUDE.md +2 -2
  173. package/skills/gotcha/SKILL.md +4 -4
  174. package/skills/log-audit/SKILL.md +4 -6
  175. package/skills/monitor-open-prs/CLAUDE.md +1 -1
  176. package/skills/monitor-open-prs/SKILL.md +6 -1
  177. package/skills/orchestrator/SKILL.md +74 -78
  178. package/skills/orchestrator-refresh/SKILL.md +24 -12
  179. package/skills/post-audit-findings/SKILL.md +5 -9
  180. package/skills/pr-consistency-audit/SKILL.md +5 -1
  181. package/skills/pr-converge/CLAUDE.md +1 -1
  182. package/skills/pr-converge/SKILL.md +86 -47
  183. package/skills/pr-converge/reference/CLAUDE.md +1 -1
  184. package/skills/pr-converge/reference/convergence-gates.md +20 -17
  185. package/skills/pr-converge/reference/examples.md +63 -47
  186. package/skills/pr-converge/reference/fix-protocol.md +15 -15
  187. package/skills/pr-converge/reference/ground-rules.md +11 -7
  188. package/skills/pr-converge/reference/multi-pr-orchestration.md +6 -5
  189. package/skills/pr-converge/reference/per-tick.md +129 -107
  190. package/skills/pr-converge/reference/state-schema.md +15 -10
  191. package/skills/pr-converge/scripts/check_convergence.py +5 -4
  192. package/skills/pr-converge/scripts/test_check_convergence.py +6 -4
  193. package/skills/pr-fix-protocol/SKILL.md +3 -8
  194. package/skills/pr-loop-cloud-transport/SKILL.md +102 -0
  195. package/skills/pr-loop-cloud-transport/reference/identity-and-hooks.md +40 -0
  196. package/skills/pr-loop-cloud-transport/reference/substitution-matrix.md +48 -0
  197. package/skills/pr-loop-lifecycle/SKILL.md +5 -9
  198. package/skills/pr-scope-resolve/SKILL.md +3 -6
  199. package/skills/privacy-hygiene/SKILL.md +114 -0
  200. package/skills/qbug/SKILL.md +8 -8
  201. package/skills/rebase/SKILL.md +5 -1
  202. package/skills/refine/SKILL.md +4 -5
  203. package/skills/reviewer-gates/SKILL.md +7 -11
  204. package/skills/session-log/SKILL.md +4 -1
  205. package/skills/skill-builder/SKILL.md +3 -6
  206. package/skills/structure-prompt/SKILL.md +4 -5
  207. package/skills/team-advisor/SKILL.md +56 -0
  208. package/skills/test_markdown_link_integrity.py +10 -6
  209. package/skills/update/SKILL.md +5 -1
  210. package/skills/usage-pause/SKILL.md +14 -5
  211. package/skills/usage-pause/scripts/resolve_usage_window.py +83 -5
  212. package/skills/usage-pause/scripts/test_resolve_usage_window.py +185 -17
  213. package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +4 -3
  214. package/skills/verified-build/SKILL.md +4 -9
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ from pathlib import Path
5
+
6
+ ENFORCER_PATH = Path(__file__).resolve().parent / "code_rules_enforcer.py"
7
+ specification = importlib.util.spec_from_file_location("code_rules_enforcer", ENFORCER_PATH)
8
+ assert specification is not None and specification.loader is not None
9
+ code_rules_enforcer = importlib.util.module_from_spec(specification)
10
+ specification.loader.exec_module(code_rules_enforcer)
11
+
12
+ PRODUCTION_FILE_PATH = "packages/app/services/report.py"
13
+ TEST_FILE_PATH = "packages/app/services/test_report.py"
14
+ UNDERSCORE_TEST_FILE_PATH = "packages/app/services/report_test.py"
15
+ CONFTEST_FILE_PATH = "packages/app/tests/conftest.py"
16
+ SHARED_TEST_MODULE_PATH = "packages/app/tests/expected_values.py"
17
+ TEST_CONSTANTS_MODULE_PATH = "packages/app/hooks_constants/test_layout_constants.py"
18
+ CONFIG_UNDER_TESTS_PATH = "packages/app/tests/config/timing.py"
19
+
20
+
21
+ def _dead_constant(source: str, file_path: str) -> list[str]:
22
+ return code_rules_enforcer.check_dead_test_module_constant(source, file_path)
23
+
24
+ def _unused_parameter(source: str, file_path: str) -> list[str]:
25
+ return code_rules_enforcer.check_unused_test_helper_parameter(source, file_path)
26
+
27
+ def test_should_flag_dead_private_constant_in_test_file() -> None:
28
+ source = (
29
+ "_ABSENT_DOTENV_FILENAME = 'absent.env'\n"
30
+ "\n"
31
+ "def test_reads_environment() -> None:\n"
32
+ " assert True\n"
33
+ )
34
+ issues = _dead_constant(source, TEST_FILE_PATH)
35
+ assert any("_ABSENT_DOTENV_FILENAME" in each_issue for each_issue in issues), issues
36
+
37
+ def test_should_flag_dead_constant_in_underscore_test_module() -> None:
38
+ source = (
39
+ "_UNREAD_CONSTANT = 'x'\n"
40
+ "\n"
41
+ "def test_something() -> None:\n"
42
+ " assert True\n"
43
+ )
44
+ issues = _dead_constant(source, UNDERSCORE_TEST_FILE_PATH)
45
+ assert any("_UNREAD_CONSTANT" in each_issue for each_issue in issues), issues
46
+
47
+ def test_should_not_flag_dead_public_constant_in_test_file() -> None:
48
+ source = (
49
+ "EXPECTED_TOTAL = 3\n"
50
+ "\n"
51
+ "def test_total() -> None:\n"
52
+ " assert True\n"
53
+ )
54
+ assert _dead_constant(source, TEST_FILE_PATH) == []
55
+
56
+ def test_should_not_flag_constant_read_by_a_later_line() -> None:
57
+ source = (
58
+ "_EXPECTED_TOTAL = 208\n"
59
+ "\n"
60
+ "def test_total_matches() -> None:\n"
61
+ " assert compute_total() == _EXPECTED_TOTAL\n"
62
+ )
63
+ assert _dead_constant(source, TEST_FILE_PATH) == []
64
+
65
+ def test_should_not_flag_dead_constant_in_production_file() -> None:
66
+ source = "_UNUSED_CONSTANT = 'x'\n"
67
+ assert _dead_constant(source, PRODUCTION_FILE_PATH) == []
68
+
69
+ def test_should_not_flag_shared_constant_in_conftest() -> None:
70
+ source = (
71
+ "SHARED_TIMEOUT_SECONDS = 30\n"
72
+ "\n"
73
+ "def _database() -> object:\n"
74
+ " return connect()\n"
75
+ )
76
+ assert _dead_constant(source, CONFTEST_FILE_PATH) == []
77
+
78
+ def test_should_not_flag_shared_constant_module_under_tests() -> None:
79
+ source = "EXPECTED_TOTAL = 208\n"
80
+ assert _dead_constant(source, SHARED_TEST_MODULE_PATH) == []
81
+
82
+ def test_should_not_flag_constant_in_a_constants_module() -> None:
83
+ source = "SHARED_LAYOUT_LIMIT = 50\n"
84
+ assert _dead_constant(source, TEST_CONSTANTS_MODULE_PATH) == []
85
+
86
+ def test_should_not_flag_constant_in_config_module_under_tests() -> None:
87
+ source = "REQUEST_TIMEOUT_SECONDS = 30\n"
88
+ assert _dead_constant(source, CONFIG_UNDER_TESTS_PATH) == []
89
+
90
+ def test_should_flag_unused_private_helper_parameter() -> None:
91
+ source = "def _configuration(monkeypatch, tmp_path):\n return build_config()\n"
92
+ issues = _unused_parameter(source, TEST_FILE_PATH)
93
+ flagged_parameters = {"monkeypatch", "tmp_path"}
94
+ assert all(
95
+ any(each_name in each_issue for each_issue in issues) for each_name in flagged_parameters
96
+ ), issues
97
+
98
+ def test_should_not_flag_parameter_the_body_reads() -> None:
99
+ source = "def _configuration(database_url):\n return build_config(database_url)\n"
100
+ assert _unused_parameter(source, TEST_FILE_PATH) == []
101
+
102
+ def test_should_not_flag_fixture_parameters() -> None:
103
+ source = "import pytest\n\n@pytest.fixture\ndef _database(monkeypatch):\n return connect()\n"
104
+ assert _unused_parameter(source, TEST_FILE_PATH) == []
105
+
106
+ def test_should_not_flag_public_test_function_parameters() -> None:
107
+ source = (
108
+ "def test_writes_row(postgres_database_url) -> None:\n"
109
+ " assert postgres_database_url is not None\n"
110
+ )
111
+ assert _unused_parameter(source, TEST_FILE_PATH) == []
@@ -192,3 +192,79 @@ def test_should_skip_module_level_docstring_text() -> None:
192
192
  )
193
193
  issues = check_imports_at_top(content)
194
194
  assert issues == []
195
+
196
+
197
+ def test_should_flag_module_level_import_after_top_level_def() -> None:
198
+ """A module-scope import placed below a top-level function must flag.
199
+
200
+ This is the "imports mid-file after test functions" drift: the imports sit
201
+ at indent zero (module scope), so the inside-function tracker never sees
202
+ them, yet they violate the imports-at-top rule.
203
+ """
204
+ content = (
205
+ "import os\n"
206
+ "\n"
207
+ "def first_test():\n"
208
+ " return os.getcwd()\n"
209
+ "\n"
210
+ "from pathlib import Path\n"
211
+ "\n"
212
+ "def second_test():\n"
213
+ " return Path('.')\n"
214
+ )
215
+ issues = check_imports_at_top(content)
216
+ assert len(issues) == 1
217
+ assert issues[0].startswith("Line 6:")
218
+ assert "after top-level definition" in issues[0]
219
+
220
+
221
+ def test_should_flag_module_level_import_after_top_level_class() -> None:
222
+ content = (
223
+ "class Widget:\n"
224
+ " pass\n"
225
+ "\n"
226
+ "from collections import OrderedDict\n"
227
+ )
228
+ issues = check_imports_at_top(content)
229
+ assert len(issues) == 1
230
+ assert issues[0].startswith("Line 4:")
231
+ assert "after top-level definition" in issues[0]
232
+
233
+
234
+ def test_should_allow_type_checking_import_after_top_level_def() -> None:
235
+ content = (
236
+ "from typing import TYPE_CHECKING\n"
237
+ "\n"
238
+ "def helper():\n"
239
+ " return 1\n"
240
+ "\n"
241
+ "if TYPE_CHECKING:\n"
242
+ " from foo import Bar\n"
243
+ )
244
+ issues = check_imports_at_top(content)
245
+ assert issues == []
246
+
247
+
248
+ def test_should_allow_indented_import_in_main_guard_after_def() -> None:
249
+ content = (
250
+ "def helper():\n"
251
+ " return 1\n"
252
+ "\n"
253
+ "if __name__ == '__main__':\n"
254
+ " import argparse\n"
255
+ " helper()\n"
256
+ )
257
+ issues = check_imports_at_top(content)
258
+ assert issues == []
259
+
260
+
261
+ def test_should_not_flag_top_level_imports_before_any_definition() -> None:
262
+ content = (
263
+ "import os\n"
264
+ "from pathlib import Path\n"
265
+ "\n"
266
+ "def helper():\n"
267
+ " return Path(os.getcwd())\n"
268
+ )
269
+ issues = check_imports_at_top(content)
270
+ assert issues == []
@@ -14,6 +14,14 @@ GH_REDIRECT_ACTIVE_ENV_VAR = "CLAUDE_GH_REDIRECT_ACTIVE"
14
14
  GH_REDIRECT_ACTIVE_VALUE = "1"
15
15
 
16
16
 
17
+ def _windows_style_system_temporary_root() -> str:
18
+ return tempfile.gettempdir().replace("/", "\\")
19
+
20
+
21
+ def _posix_style_system_temporary_root() -> str:
22
+ return tempfile.gettempdir().replace("\\", "/")
23
+
24
+
17
25
  def _run_hook(
18
26
  payload: dict,
19
27
  gh_redirect_active: bool = True,
@@ -42,7 +50,7 @@ def _make_bash_payload(command: str) -> dict:
42
50
 
43
51
  def test_denies_gh_issue_comment_as_redirect_duplicate_guard() -> None:
44
52
  payload = _make_bash_payload(
45
- 'gh issue comment 83 --repo jl-cmd/claude-code-config --body "hello"'
53
+ 'gh issue comment 83 --repo jl-cmd/claude-dev-env --body "hello"'
46
54
  )
47
55
  result = _run_hook(payload)
48
56
  response = json.loads(result.stdout)
@@ -298,7 +306,8 @@ def test_rm_rf_allowed_when_leading_cd_into_ephemeral_subdirectory_unquoted() ->
298
306
 
299
307
  def test_rm_rf_allowed_when_leading_cd_into_windows_temp_worktree_subdirectory() -> None:
300
308
  windows_style_temp_worktree = (
301
- r"C:\Users\jon\AppData\Local\Temp\bugteam-pr-58-20260424071040\pr-58\worktree"
309
+ _windows_style_system_temporary_root()
310
+ + r"\bugteam-pr-58-20260424071040\pr-58\worktree"
302
311
  )
303
312
  payload = _make_bash_payload(
304
313
  f'cd "{windows_style_temp_worktree}" && rm -rf .bugteam-tmp'
@@ -563,7 +572,9 @@ def test_rm_rf_asks_when_leading_cd_target_is_relative_path() -> None:
563
572
 
564
573
  def test_rm_rf_allowed_for_chat_observed_bugteam_backslash_worktree_scratch_cleanup() -> None:
565
574
  payload = _make_bash_payload(
566
- r'cd "C:\Users\jon\AppData\Local\Temp\bugteam-pr-58-20260424071040\pr-58\worktree" && rm -rf .bugteam-tmp'
575
+ 'cd "'
576
+ + _windows_style_system_temporary_root()
577
+ + r'\bugteam-pr-58-20260424071040\pr-58\worktree" && rm -rf .bugteam-tmp'
567
578
  )
568
579
 
569
580
  result = _run_rm_hook(payload)
@@ -574,7 +585,9 @@ def test_rm_rf_allowed_for_chat_observed_bugteam_backslash_worktree_scratch_clea
574
585
 
575
586
  def test_rm_rf_allowed_for_chat_observed_bugteam_forward_slash_worktree_scratch_cleanup() -> None:
576
587
  payload = _make_bash_payload(
577
- 'cd "C:/Users/jon/AppData/Local/Temp/bugteam-pr-58-20260424071040/pr-58/worktree" && rm -rf .bugteam-tmp'
588
+ 'cd "'
589
+ + _posix_style_system_temporary_root()
590
+ + '/bugteam-pr-58-20260424071040/pr-58/worktree" && rm -rf .bugteam-tmp'
578
591
  )
579
592
 
580
593
  result = _run_rm_hook(payload)
@@ -585,7 +598,9 @@ def test_rm_rf_allowed_for_chat_observed_bugteam_forward_slash_worktree_scratch_
585
598
 
586
599
  def test_rm_rf_allowed_for_chat_observed_bugfix_reply_scratch_file_cleanup() -> None:
587
600
  payload = _make_bash_payload(
588
- 'cd "C:/Users/jon/AppData/Local/Temp/bugteam-pr-58-20260424071040/pr-58/worktree" && rm -rf tmp_reply_loop1-1.md'
601
+ 'cd "'
602
+ + _posix_style_system_temporary_root()
603
+ + '/bugteam-pr-58-20260424071040/pr-58/worktree" && rm -rf tmp_reply_loop1-1.md'
589
604
  )
590
605
 
591
606
  result = _run_rm_hook(payload)
@@ -596,7 +611,9 @@ def test_rm_rf_allowed_for_chat_observed_bugfix_reply_scratch_file_cleanup() ->
596
611
 
597
612
  def test_rm_rf_allowed_for_chat_observed_bugfind_multiple_scratch_files_cleanup() -> None:
598
613
  payload = _make_bash_payload(
599
- 'cd "C:/Users/jon/AppData/Local/Temp/bugteam-pr-58-20260424071040/pr-256/worktree" && rm -rf tmp_review_body.md tmp_finding_1.md'
614
+ 'cd "'
615
+ + _posix_style_system_temporary_root()
616
+ + '/bugteam-pr-58-20260424071040/pr-256/worktree" && rm -rf tmp_review_body.md tmp_finding_1.md'
600
617
  )
601
618
 
602
619
  result = _run_rm_hook(payload)
@@ -610,7 +627,8 @@ def test_rm_rf_allowed_via_tool_input_cwd_pointing_at_chat_observed_bugteam_work
610
627
  "tool_name": "Bash",
611
628
  "tool_input": {
612
629
  "command": "rm -rf .bugteam-tmp",
613
- "cwd": "C:/Users/jon/AppData/Local/Temp/bugteam-pr-58-20260424071040/pr-58/worktree",
630
+ "cwd": _posix_style_system_temporary_root()
631
+ + "/bugteam-pr-58-20260424071040/pr-58/worktree",
614
632
  },
615
633
  }
616
634
 
@@ -622,7 +640,9 @@ def test_rm_rf_allowed_via_tool_input_cwd_pointing_at_chat_observed_bugteam_work
622
640
 
623
641
  def test_rm_rf_allowed_for_chat_observed_absolute_path_in_bugteam_windows_worktree_scratch() -> None:
624
642
  payload = _make_bash_payload(
625
- 'rm -rf "C:/Users/jon/AppData/Local/Temp/bugteam-pr-58-20260424071040/pr-58/worktree/.bugteam-tmp"'
643
+ 'rm -rf "'
644
+ + _posix_style_system_temporary_root()
645
+ + '/bugteam-pr-58-20260424071040/pr-58/worktree/.bugteam-tmp"'
626
646
  )
627
647
 
628
648
  result = _run_rm_hook(payload)
@@ -839,7 +859,7 @@ def test_rm_rf_allowed_when_unquoted_windows_backslash_target_contains_worktrees
839
859
 
840
860
  def test_rm_rf_allowed_when_finding_example_windows_backslash_ephemeral_target() -> None:
841
861
  payload = _make_bash_payload(
842
- r"rm -rf C:\Users\jon\AppData\Local\Temp\scratch"
862
+ "rm -rf " + _windows_style_system_temporary_root() + r"\scratch"
843
863
  )
844
864
 
845
865
  result = _run_rm_hook(payload)
@@ -138,7 +138,7 @@ def test_no_false_positive_body_as_title_value() -> None:
138
138
  def test_no_false_positive_windows_path_in_body_file() -> None:
139
139
  """Unquoted Windows path with backslashes in --body-file must be approved without token corruption."""
140
140
  assert not _uses_body_string_arg(
141
- r'gh pr create --title "T" --body-file C:\Users\jon\tmp\body.md'
141
+ r'gh pr create --title "T" --body-file C:\Users\example\tmp\body.md'
142
142
  )
143
143
 
144
144
 
@@ -222,7 +222,7 @@ def test_blocks_body_file_followed_by_body_string() -> None:
222
222
 
223
223
 
224
224
  def test_blocks_windows_path_with_trailing_backslash_continuation() -> None:
225
- """C1: prior line ending in `C:\\Users\\jon\\` must still continue lines.
225
+ """C1: prior line ending in `C:\\Users\\example\\` must still continue lines.
226
226
 
227
227
  Naive `count("\\\\") % 2 == 1` mis-classifies this (count=4, even -> no
228
228
  continuation) and misses --body on the next line. Counting only the
@@ -230,7 +230,7 @@ def test_blocks_windows_path_with_trailing_backslash_continuation() -> None:
230
230
  backslash as a continuation marker.
231
231
  """
232
232
  command = (
233
- 'gh pr create --title C:\\Users\\jon\\ \\\n'
233
+ 'gh pr create --title C:\\Users\\example\\ \\\n'
234
234
  ' --body "real body text"\n'
235
235
  )
236
236
  assert _uses_body_string_arg(command)
@@ -1,12 +1,24 @@
1
- """Unit tests for nas-ssh-binary-enforcer PreToolUse hook."""
1
+ """Unit tests for nas-ssh-binary-enforcer PreToolUse hook.
2
+
3
+ The real NAS host, ssh user, and port are private, so these tests set them via
4
+ the ``CLAUDE_NAS_*`` environment variables and drive the real hook, asserting
5
+ the runtime-built host pattern and deny messages.
6
+ """
2
7
 
3
8
  import importlib.util
9
+ import os
4
10
  import pathlib
5
11
  import sys
12
+ from collections.abc import Iterator
13
+ from unittest.mock import patch
14
+
15
+ import pytest
6
16
 
7
17
  _HOOK_DIR = pathlib.Path(__file__).parent
8
18
  if str(_HOOK_DIR) not in sys.path:
9
19
  sys.path.insert(0, str(_HOOK_DIR))
20
+ if str(_HOOK_DIR.parent) not in sys.path:
21
+ sys.path.insert(0, str(_HOOK_DIR.parent))
10
22
 
11
23
  hook_spec = importlib.util.spec_from_file_location(
12
24
  "nas_ssh_binary_enforcer",
@@ -18,55 +30,88 @@ hook_module = importlib.util.module_from_spec(hook_spec)
18
30
  hook_spec.loader.exec_module(hook_module)
19
31
  _find_nas_ssh_violation = hook_module._find_nas_ssh_violation
20
32
 
21
- from hooks_constants.nas_ssh_binary_enforcer_constants import (
22
- BARE_SSH_BINARY_MESSAGE as _BARE_SSH_BINARY_MESSAGE,
23
- MISSING_BATCH_MODE_MESSAGE as _MISSING_BATCH_MODE_MESSAGE,
33
+ from hooks_constants.local_identity import ( # noqa: E402
34
+ bare_ssh_binary_deny_message,
35
+ missing_batch_mode_deny_message,
24
36
  )
25
37
 
38
+ NAS_HOST = "test-nas.example.net"
39
+ NAS_SSH_USER = "tester"
40
+ NAS_SSH_PORT = "2222"
41
+
42
+
43
+ @pytest.fixture(autouse=True)
44
+ def _set_nas_identity_env() -> Iterator[None]:
45
+ with patch.dict(
46
+ os.environ,
47
+ {
48
+ "CLAUDE_NAS_HOST": NAS_HOST,
49
+ "CLAUDE_NAS_SSH_USER": NAS_SSH_USER,
50
+ "CLAUDE_NAS_SSH_PORT": NAS_SSH_PORT,
51
+ },
52
+ clear=False,
53
+ ):
54
+ yield
55
+
26
56
 
27
57
  def test_bare_ssh_to_nas_is_denied_with_binary_message() -> None:
28
58
  assert (
29
- _find_nas_ssh_violation('ssh -p 9222 jon@192.168.1.100 "ls /volume1"')
30
- == _BARE_SSH_BINARY_MESSAGE
59
+ _find_nas_ssh_violation(
60
+ f'ssh -p {NAS_SSH_PORT} {NAS_SSH_USER}@{NAS_HOST} "ls /volume1"'
61
+ )
62
+ == bare_ssh_binary_deny_message()
31
63
  )
32
64
 
33
65
 
34
66
  def test_bare_scp_to_nas_is_denied() -> None:
35
67
  assert (
36
- _find_nas_ssh_violation("scp -P 9222 file.txt jon@192.168.1.100:/volume1/")
37
- == _BARE_SSH_BINARY_MESSAGE
68
+ _find_nas_ssh_violation(
69
+ f"scp -P {NAS_SSH_PORT} file.txt {NAS_SSH_USER}@{NAS_HOST}:/volume1/"
70
+ )
71
+ == bare_ssh_binary_deny_message()
38
72
  )
39
73
 
40
74
 
41
75
  def test_bare_sftp_to_nas_is_denied() -> None:
42
- assert _find_nas_ssh_violation("sftp -P 9222 jon@192.168.1.100") == _BARE_SSH_BINARY_MESSAGE
76
+ assert (
77
+ _find_nas_ssh_violation(f"sftp -P {NAS_SSH_PORT} {NAS_SSH_USER}@{NAS_HOST}")
78
+ == bare_ssh_binary_deny_message()
79
+ )
43
80
 
44
81
 
45
82
  def test_bare_ssh_to_nas_behind_launcher_wrapper_is_denied() -> None:
46
83
  assert (
47
- _find_nas_ssh_violation('timeout 10 ssh -p 9222 jon@192.168.1.100 "uptime"')
48
- == _BARE_SSH_BINARY_MESSAGE
84
+ _find_nas_ssh_violation(
85
+ f'timeout 10 ssh -p {NAS_SSH_PORT} {NAS_SSH_USER}@{NAS_HOST} "uptime"'
86
+ )
87
+ == bare_ssh_binary_deny_message()
49
88
  )
50
89
 
51
90
 
52
91
  def test_bare_ssh_to_nas_after_shell_operator_is_denied() -> None:
53
92
  assert (
54
- _find_nas_ssh_violation('cd /tmp && ssh -p 9222 jon@192.168.1.100 "uptime"')
55
- == _BARE_SSH_BINARY_MESSAGE
93
+ _find_nas_ssh_violation(
94
+ f'cd /tmp && ssh -p {NAS_SSH_PORT} {NAS_SSH_USER}@{NAS_HOST} "uptime"'
95
+ )
96
+ == bare_ssh_binary_deny_message()
56
97
  )
57
98
 
58
99
 
59
100
  def test_bare_ssh_to_nas_after_glued_operator_is_denied() -> None:
60
101
  assert (
61
- _find_nas_ssh_violation('true;ssh -p 9222 jon@192.168.1.100 "uptime"')
62
- == _BARE_SSH_BINARY_MESSAGE
102
+ _find_nas_ssh_violation(
103
+ f'true;ssh -p {NAS_SSH_PORT} {NAS_SSH_USER}@{NAS_HOST} "uptime"'
104
+ )
105
+ == bare_ssh_binary_deny_message()
63
106
  )
64
107
 
65
108
 
66
109
  def test_bare_ssh_to_nas_after_pipe_ampersand_operator_is_denied() -> None:
67
110
  assert (
68
- _find_nas_ssh_violation('echo start |& ssh -p 9222 jon@192.168.1.100 "echo hi"')
69
- == _BARE_SSH_BINARY_MESSAGE
111
+ _find_nas_ssh_violation(
112
+ f'echo start |& ssh -p {NAS_SSH_PORT} {NAS_SSH_USER}@{NAS_HOST} "echo hi"'
113
+ )
114
+ == bare_ssh_binary_deny_message()
70
115
  )
71
116
 
72
117
 
@@ -74,18 +119,19 @@ def test_full_openssh_binary_to_nas_without_batchmode_is_denied() -> None:
74
119
  assert (
75
120
  _find_nas_ssh_violation(
76
121
  '"/c/Windows/System32/OpenSSH/ssh.exe" -o ConnectTimeout=10 '
77
- '-p 9222 jon@192.168.1.100 "uptime"'
122
+ f'-p {NAS_SSH_PORT} {NAS_SSH_USER}@{NAS_HOST} "uptime"'
78
123
  )
79
- == _MISSING_BATCH_MODE_MESSAGE
124
+ == missing_batch_mode_deny_message()
80
125
  )
81
126
 
82
127
 
83
128
  def test_full_openssh_scp_to_nas_without_batchmode_is_denied() -> None:
84
129
  assert (
85
130
  _find_nas_ssh_violation(
86
- '"/c/Windows/System32/OpenSSH/scp.exe" -P 9222 file.txt jon@192.168.1.100:/volume1/'
131
+ '"/c/Windows/System32/OpenSSH/scp.exe" '
132
+ f"-P {NAS_SSH_PORT} file.txt {NAS_SSH_USER}@{NAS_HOST}:/volume1/"
87
133
  )
88
- == _MISSING_BATCH_MODE_MESSAGE
134
+ == missing_batch_mode_deny_message()
89
135
  )
90
136
 
91
137
 
@@ -93,7 +139,7 @@ def test_full_openssh_binary_to_nas_with_batchmode_is_allowed() -> None:
93
139
  assert (
94
140
  _find_nas_ssh_violation(
95
141
  '"/c/Windows/System32/OpenSSH/ssh.exe" -o BatchMode=yes '
96
- '-o ConnectTimeout=10 -p 9222 jon@192.168.1.100 "uptime"'
142
+ f'-o ConnectTimeout=10 -p {NAS_SSH_PORT} {NAS_SSH_USER}@{NAS_HOST} "uptime"'
97
143
  )
98
144
  is None
99
145
  )
@@ -103,61 +149,72 @@ def test_full_openssh_binary_glued_batchmode_flag_is_allowed() -> None:
103
149
  assert (
104
150
  _find_nas_ssh_violation(
105
151
  '"/c/Windows/System32/OpenSSH/ssh.exe" -oBatchMode=yes '
106
- '-p 9222 jon@192.168.1.100 "uptime"'
152
+ f'-p {NAS_SSH_PORT} {NAS_SSH_USER}@{NAS_HOST} "uptime"'
107
153
  )
108
154
  is None
109
155
  )
110
156
 
111
157
 
112
158
  def test_bare_ssh_to_other_host_is_allowed() -> None:
113
- assert _find_nas_ssh_violation('ssh -p 22 jon@example.com "uptime"') is None
159
+ assert _find_nas_ssh_violation(f'ssh -p 22 {NAS_SSH_USER}@example.com "uptime"') is None
114
160
 
115
161
 
116
162
  def test_full_openssh_binary_to_other_host_without_batchmode_is_allowed() -> None:
117
163
  assert (
118
164
  _find_nas_ssh_violation(
119
- '"/c/Windows/System32/OpenSSH/ssh.exe" -p 22 jon@example.com "uptime"'
165
+ f'"/c/Windows/System32/OpenSSH/ssh.exe" -p 22 {NAS_SSH_USER}@example.com "uptime"'
120
166
  )
121
167
  is None
122
168
  )
123
169
 
124
170
 
125
- def test_nas_ip_as_echoed_data_is_allowed() -> None:
126
- assert _find_nas_ssh_violation('echo "connect via ssh to 192.168.1.100"') is None
171
+ def test_nas_host_as_echoed_data_is_allowed() -> None:
172
+ assert _find_nas_ssh_violation(f'echo "connect via ssh to {NAS_HOST}"') is None
127
173
 
128
174
 
129
- def test_nas_ip_mentioned_without_ssh_command_word_is_allowed() -> None:
130
- assert _find_nas_ssh_violation("ping -c 1 192.168.1.100") is None
175
+ def test_nas_host_mentioned_without_ssh_command_word_is_allowed() -> None:
176
+ assert _find_nas_ssh_violation(f"ping -c 1 {NAS_HOST}") is None
131
177
 
132
178
 
133
179
  def test_ssh_family_word_only_in_remote_command_argument_is_allowed() -> None:
134
180
  assert (
135
181
  _find_nas_ssh_violation(
136
182
  '"/c/Windows/System32/OpenSSH/ssh.exe" -o BatchMode=yes '
137
- '-p 9222 jon@192.168.1.100 "grep ssh /etc/config"'
183
+ f'-p {NAS_SSH_PORT} {NAS_SSH_USER}@{NAS_HOST} "grep ssh /etc/config"'
138
184
  )
139
185
  is None
140
186
  )
141
187
 
142
188
 
143
- def test_nas_ip_in_different_segment_than_ssh_is_allowed() -> None:
144
- assert _find_nas_ssh_violation('echo 192.168.1.100 && ssh -p 22 jon@example.com "id"') is None
189
+ def test_nas_host_in_different_segment_than_ssh_is_allowed() -> None:
190
+ assert (
191
+ _find_nas_ssh_violation(
192
+ f'echo {NAS_HOST} && ssh -p 22 {NAS_SSH_USER}@example.com "id"'
193
+ )
194
+ is None
195
+ )
145
196
 
146
197
 
147
- def test_ssh_to_other_host_with_nas_ip_in_remote_command_is_allowed() -> None:
198
+ def test_ssh_to_other_host_with_nas_host_in_remote_command_is_allowed() -> None:
148
199
  assert (
149
- _find_nas_ssh_violation('ssh -p 22 jon@example.com "ping -c1 192.168.1.100"') is None
200
+ _find_nas_ssh_violation(
201
+ f'ssh -p 22 {NAS_SSH_USER}@example.com "ping -c1 {NAS_HOST}"'
202
+ )
203
+ is None
150
204
  )
151
205
 
152
206
 
153
- def test_scp_to_other_host_with_nas_ip_in_remote_path_is_allowed() -> None:
207
+ def test_scp_to_other_host_with_nas_host_in_remote_path_is_allowed() -> None:
154
208
  assert (
155
- _find_nas_ssh_violation("scp -P 22 f.txt jon@example.com:/backup/192.168.1.100/") is None
209
+ _find_nas_ssh_violation(
210
+ f"scp -P 22 f.txt {NAS_SSH_USER}@example.com:/backup/{NAS_HOST}/"
211
+ )
212
+ is None
156
213
  )
157
214
 
158
215
 
159
- def test_similar_ip_prefix_is_not_matched() -> None:
160
- assert _find_nas_ssh_violation('ssh -p 22 jon@192.168.1.1000 "id"') is None
216
+ def test_similar_host_suffix_is_not_matched() -> None:
217
+ assert _find_nas_ssh_violation(f'ssh -p 22 {NAS_SSH_USER}@{NAS_HOST}x "id"') is None
161
218
 
162
219
 
163
220
  def test_empty_command_is_allowed() -> None:
@@ -165,4 +222,4 @@ def test_empty_command_is_allowed() -> None:
165
222
 
166
223
 
167
224
  def test_unbalanced_quotes_command_is_allowed() -> None:
168
- assert _find_nas_ssh_violation("ssh 'unterminated 192.168.1.100") is None
225
+ assert _find_nas_ssh_violation(f"ssh 'unterminated {NAS_HOST}") is None