claude-dev-env 2.0.1 → 2.1.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 (127) hide show
  1. package/CLAUDE.md +1 -1
  2. package/agents/clean-coder.md +31 -1
  3. package/audit-rubrics/prompts/category-e-dead-code.md +2 -2
  4. package/bin/install.test.mjs +2 -2
  5. package/docs/CLAUDE.md +6 -3
  6. package/docs/CODE_RULES.md +5 -1
  7. package/docs/agent-spawn-protocol.md +39 -0
  8. package/docs/nas-ssh-invocation.md +23 -0
  9. package/docs/worker-completion-gate.md +33 -0
  10. package/hooks/blocking/CLAUDE.md +1 -0
  11. package/hooks/blocking/code_rules_dead_module_constant.py +11 -5
  12. package/hooks/blocking/code_rules_shared.py +126 -47
  13. package/hooks/blocking/config/CLAUDE.md +2 -0
  14. package/hooks/blocking/config/verified_commit_constants.py +8 -1
  15. package/hooks/blocking/config/verified_commit_context_constants.py +21 -0
  16. package/hooks/blocking/config/verified_commit_gate_output_constants.py +14 -0
  17. package/hooks/blocking/conftest.py +36 -30
  18. package/hooks/blocking/convergence_gate_blocker.py +76 -8
  19. package/hooks/blocking/plain_language_blocker.py +8 -0
  20. package/hooks/blocking/state_description_blocker.py +4 -1
  21. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +58 -0
  22. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_alias.py +133 -0
  23. package/hooks/blocking/test_code_rules_shared.py +120 -20
  24. package/hooks/blocking/test_convergence_gate_blocker.py +146 -0
  25. package/hooks/blocking/test_plain_language_blocker.py +15 -0
  26. package/hooks/blocking/test_state_description_blocker.py +15 -0
  27. package/hooks/blocking/test_verification_verdict_store.py +12 -0
  28. package/hooks/blocking/test_verified_commit_config_bootstrap.py +18 -0
  29. package/hooks/blocking/test_verified_commit_docs_delta.py +176 -0
  30. package/hooks/blocking/test_verified_commit_gate_additional_context.py +134 -0
  31. package/hooks/blocking/tests/test_pii_prevention_blocker.py +0 -1
  32. package/hooks/blocking/tests/test_verified_commit_gate.py +41 -0
  33. package/hooks/blocking/verified_commit_config_bootstrap.py +22 -10
  34. package/hooks/blocking/verified_commit_gate.py +113 -568
  35. package/hooks/blocking/verified_commit_gate_parts/CLAUDE.md +28 -0
  36. package/hooks/blocking/verified_commit_gate_parts/__init__.py +1 -0
  37. package/hooks/blocking/verified_commit_gate_parts/command_tokenization.py +174 -0
  38. package/hooks/blocking/verified_commit_gate_parts/deny_payload.py +53 -0
  39. package/hooks/blocking/verified_commit_gate_parts/deny_reason.py +80 -0
  40. package/hooks/blocking/verified_commit_gate_parts/directory_resolution.py +170 -0
  41. package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +205 -0
  42. package/hooks/blocking/verified_commit_gate_parts/tests/conftest.py +10 -0
  43. package/hooks/blocking/verified_commit_gate_parts/tests/test_command_tokenization.py +94 -0
  44. package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_payload.py +17 -0
  45. package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_reason.py +38 -0
  46. package/hooks/blocking/verified_commit_gate_parts/tests/test_directory_resolution.py +71 -0
  47. package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +61 -0
  48. package/hooks/hooks_constants/CLAUDE.md +4 -1
  49. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  50. package/hooks/hooks_constants/convergence_gate_blocker_constants.py +36 -0
  51. package/hooks/hooks_constants/dead_module_constant_constants.py +8 -0
  52. package/hooks/hooks_constants/harness_scratchpad_constants.py +10 -9
  53. package/hooks/hooks_constants/mypy_integration_constants.py +16 -0
  54. package/hooks/hooks_constants/pii_prevention_constants.py +1 -0
  55. package/hooks/validators/mypy_integration.py +145 -24
  56. package/hooks/validators/python_antipattern_checks.py +16 -0
  57. package/hooks/validators/run_all_validators.py +9 -3
  58. package/hooks/validators/test_mypy_integration.py +154 -0
  59. package/hooks/validators/test_python_antipattern_checks.py +112 -1
  60. package/hooks/validators/test_run_all_validators_pretooluse.py +16 -0
  61. package/package.json +1 -1
  62. package/rules/CLAUDE.md +3 -3
  63. package/rules/agent-spawn-protocol.md +5 -43
  64. package/rules/code-standards.md +1 -36
  65. package/rules/env-var-table-code-drift.md +2 -21
  66. package/rules/hook-prose-matches-detector.md +5 -16
  67. package/rules/nas-ssh-invocation.md +3 -15
  68. package/rules/no-historical-clutter.md +7 -49
  69. package/rules/no-inline-destructive-literals.md +3 -5
  70. package/rules/package-inventory-stale-entry.md +7 -32
  71. package/rules/re-stage-before-commit.md +6 -23
  72. package/rules/shell-invocation-policy.md +1 -1
  73. package/rules/vault-context.md +3 -3
  74. package/rules/verified-commit-gate-skip.md +28 -0
  75. package/rules/workers-done-before-complete.md +2 -30
  76. package/scripts/claude_chain_runner.py +39 -3
  77. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +3 -0
  78. package/scripts/test_claude_chain_runner.py +112 -0
  79. package/scripts/test_grok_headless_runner.py +0 -1
  80. package/skills/auditing-claude-config/CLAUDE.md +2 -1
  81. package/skills/auditing-claude-config/SKILL.md +114 -176
  82. package/skills/auditing-claude-config/reference/probe-hook.md +74 -0
  83. package/skills/autoconverge/CLAUDE.md +1 -0
  84. package/skills/autoconverge/SKILL.md +310 -346
  85. package/skills/autoconverge/reference/CLAUDE.md +1 -0
  86. package/skills/autoconverge/reference/copilot-findings.md +51 -0
  87. package/skills/closeout/SKILL.md +7 -9
  88. package/skills/copilot-finding-triage/SKILL.md +5 -7
  89. package/skills/copilot-review/CLAUDE.md +3 -2
  90. package/skills/copilot-review/SKILL.md +119 -155
  91. package/skills/copilot-review/templates/subagent-prompt.md +49 -0
  92. package/skills/everything-search/SKILL.md +7 -1
  93. package/skills/fresh-branch/CLAUDE.md +6 -9
  94. package/skills/fresh-branch/SKILL.md +84 -33
  95. package/skills/fresh-branch/scripts/create_fresh_branch.py +445 -0
  96. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/__init__.py +1 -0
  97. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +74 -0
  98. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +670 -0
  99. package/skills/orchestrator/SKILL.md +161 -147
  100. package/skills/pr-converge/SKILL.md +277 -422
  101. package/skills/pr-converge/reference/CLAUDE.md +1 -0
  102. package/skills/pr-converge/reference/progress-checklist.md +168 -0
  103. package/skills/pr-converge/test_step5_host_branch.py +8 -6
  104. package/skills/pr-loop-cloud-transport/SKILL.md +1 -1
  105. package/skills/privacy-hygiene/SKILL.md +68 -115
  106. package/skills/privacy-hygiene/reference/sweep-procedure.md +62 -0
  107. package/skills/session-log/CLAUDE.md +2 -1
  108. package/skills/session-log/SKILL.md +4 -26
  109. package/skills/session-log/templates/frontmatter.md +40 -0
  110. package/skills/skill-builder/CLAUDE.md +8 -7
  111. package/skills/skill-builder/SKILL.md +26 -11
  112. package/skills/skill-builder/references/CLAUDE.md +3 -1
  113. package/skills/skill-builder/references/delegation-map.md +21 -12
  114. package/skills/skill-builder/references/description-field.md +9 -11
  115. package/skills/skill-builder/references/deterministic-elements.md +218 -0
  116. package/skills/skill-builder/references/self-audit-checklist.md +62 -45
  117. package/skills/skill-builder/references/skill-modularity.md +8 -9
  118. package/skills/skill-builder/templates/CLAUDE.md +2 -2
  119. package/skills/skill-builder/templates/gap-analysis.md +15 -0
  120. package/skills/skill-builder/workflows/CLAUDE.md +5 -5
  121. package/skills/skill-builder/workflows/improve-skill.md +18 -9
  122. package/skills/skill-builder/workflows/new-skill.md +23 -15
  123. package/skills/skill-builder/workflows/polish-skill.md +28 -21
  124. package/system-prompts/CLAUDE.md +3 -3
  125. package/docs/agents-md-alignment-plan.md +0 -123
  126. package/docs/emotion-informed-prompt-design.md +0 -362
  127. package/rules/es-exe-file-search.md +0 -17
@@ -33,6 +33,27 @@ _DECOY_CONFIG_CONSTANTS_SOURCE = (
33
33
  )
34
34
 
35
35
 
36
+ def _copy_mirrored_packages(mirrored_hooks_directory: Path, mirrored_blocking_directory: Path) -> None:
37
+ """Copy the ``config``, gate-parts, and ``hooks_constants`` packages into the mirror."""
38
+ shutil.copytree(_BLOCKING_SOURCE_DIRECTORY / "config", mirrored_blocking_directory / "config")
39
+ shutil.copytree(
40
+ _BLOCKING_SOURCE_DIRECTORY / "verified_commit_gate_parts",
41
+ mirrored_blocking_directory / "verified_commit_gate_parts",
42
+ ignore=shutil.ignore_patterns("tests", "__pycache__"),
43
+ )
44
+ shutil.copytree(_HOOKS_CONSTANTS_SOURCE_DIRECTORY, mirrored_hooks_directory / "hooks_constants")
45
+
46
+
47
+ def _write_decoy_config_package(mirrored_hooks_directory: Path) -> None:
48
+ """Write the decoy ``config`` package that must never win resolution."""
49
+ decoy_config_directory = mirrored_hooks_directory / "config"
50
+ decoy_config_directory.mkdir()
51
+ (decoy_config_directory / "__init__.py").write_text(_DECOY_CONFIG_INIT_SOURCE, encoding="utf-8")
52
+ (decoy_config_directory / "verified_commit_constants.py").write_text(
53
+ _DECOY_CONFIG_CONSTANTS_SOURCE, encoding="utf-8"
54
+ )
55
+
56
+
36
57
  def _build_shadowed_hook_tree(tmp_path: Path) -> tuple[Path, Path]:
37
58
  """Mirror the hook tree with a decoy ``config`` package beside ``blocking``.
38
59
 
@@ -51,22 +72,20 @@ def _build_shadowed_hook_tree(tmp_path: Path) -> tuple[Path, Path]:
51
72
  if source_file.exists():
52
73
  shutil.copy2(source_file, mirrored_blocking_directory / each_file_name)
53
74
 
54
- shutil.copytree(
55
- _BLOCKING_SOURCE_DIRECTORY / "config",
56
- mirrored_blocking_directory / "config",
57
- )
58
- shutil.copytree(
59
- _HOOKS_CONSTANTS_SOURCE_DIRECTORY,
60
- mirrored_hooks_directory / "hooks_constants",
61
- )
75
+ _copy_mirrored_packages(mirrored_hooks_directory, mirrored_blocking_directory)
76
+ _write_decoy_config_package(mirrored_hooks_directory)
77
+ return mirrored_hooks_directory, mirrored_blocking_directory
62
78
 
63
- decoy_config_directory = mirrored_hooks_directory / "config"
64
- decoy_config_directory.mkdir()
65
- (decoy_config_directory / "__init__.py").write_text(_DECOY_CONFIG_INIT_SOURCE, encoding="utf-8")
66
- (decoy_config_directory / "verified_commit_constants.py").write_text(
67
- _DECOY_CONFIG_CONSTANTS_SOURCE, encoding="utf-8"
79
+
80
+ def _probe_command(mirrored_hooks_directory: Path, mirrored_blocking_directory: Path) -> str:
81
+ """Build the ``sys.path`` preamble that shadows ``blocking`` with the decoy config."""
82
+ return textwrap.dedent(
83
+ f"""\
84
+ import sys
85
+ sys.path.insert(0, {str(mirrored_blocking_directory)!r})
86
+ sys.path.insert(0, {str(mirrored_hooks_directory)!r})
87
+ """
68
88
  )
69
- return mirrored_hooks_directory, mirrored_blocking_directory
70
89
 
71
90
 
72
91
  @pytest.fixture
@@ -75,15 +94,8 @@ def run_under_config_shadow(
75
94
  ) -> Callable[[str], subprocess.CompletedProcess[str]]:
76
95
  """Return a runner that executes a driver under a foreign-``config`` sys.path.
77
96
 
78
- ::
79
-
80
- completed = run_under_config_shadow("import verified_commit_gate")
81
- ok: completed.returncode == 0 once the bootstrap seeds the real module
82
- flag: nonzero with ModuleNotFoundError while the decoy config wins
83
-
84
- The runner prefixes the driver with a ``sys.path`` preamble that puts the
85
- mirrored ``hooks`` directory (holding the decoy ``config``) ahead of
86
- ``blocking``, then runs the probe with the active interpreter.
97
+ ``run_under_config_shadow("import verified_commit_gate")`` returns 0 once
98
+ the bootstrap seeds the real module, nonzero while the decoy config wins.
87
99
 
88
100
  Args:
89
101
  tmp_path: Pytest's per-test temporary directory, fixture-injected.
@@ -93,16 +105,10 @@ def run_under_config_shadow(
93
105
  subprocess result.
94
106
  """
95
107
  mirrored_hooks_directory, mirrored_blocking_directory = _build_shadowed_hook_tree(tmp_path)
108
+ sys_path_preamble = _probe_command(mirrored_hooks_directory, mirrored_blocking_directory)
96
109
 
97
110
  def run_driver(driver_body: str) -> subprocess.CompletedProcess[str]:
98
111
  probe_script = tmp_path / "probe.py"
99
- sys_path_preamble = textwrap.dedent(
100
- f"""\
101
- import sys
102
- sys.path.insert(0, {str(mirrored_blocking_directory)!r})
103
- sys.path.insert(0, {str(mirrored_hooks_directory)!r})
104
- """
105
- )
106
112
  probe_script.write_text(sys_path_preamble + textwrap.dedent(driver_body), encoding="utf-8")
107
113
  return subprocess.run(
108
114
  [sys.executable, str(probe_script)],
@@ -18,9 +18,81 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
18
18
  if _hooks_dir not in sys.path:
19
19
  sys.path.insert(0, _hooks_dir)
20
20
 
21
+ from hooks_constants.convergence_gate_blocker_constants import ( # noqa: E402
22
+ COMMAND_SEPARATOR_PATTERN,
23
+ GH_PR_READY_ANCHOR_PATTERN,
24
+ PR_URL_OWNER_REPO_NUMBER_PATTERN,
25
+ REPO_OVERRIDE_FLAG_PATTERN,
26
+ )
21
27
  from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
22
28
 
23
29
 
30
+ def _ready_command_segment(command: str) -> str:
31
+ """Return the ``gh pr ready`` invocation, clipped at the next command separator.
32
+
33
+ ::
34
+
35
+ gh pr ready 161 && gh pr comment 999 --repo other-owner/other-repo
36
+ ^^^^^^^^^^^^^^^^ clipped -> returned segment
37
+
38
+ Scanning only this segment keeps a ``--repo`` flag or PR URL that belongs to
39
+ a chained command from binding the gate to the wrong PR.
40
+ """
41
+ ready_match = re.search(GH_PR_READY_ANCHOR_PATTERN, command)
42
+ if ready_match is None:
43
+ return command
44
+ ready_tail = command[ready_match.start() :]
45
+ separator_match = re.search(COMMAND_SEPARATOR_PATTERN, ready_tail)
46
+ if separator_match is None:
47
+ return ready_tail
48
+ return ready_tail[: separator_match.start()]
49
+
50
+
51
+ def _parse_pr_url(command: str) -> tuple[str, str, int] | None:
52
+ """Return (owner, repo, pr_number) from a full PR URL in the command."""
53
+ url_match = re.search(PR_URL_OWNER_REPO_NUMBER_PATTERN, command)
54
+ if url_match is None:
55
+ return None
56
+ return url_match.group("owner"), url_match.group("repo"), int(url_match.group("number"))
57
+
58
+
59
+ def _parse_repo_flag(command: str) -> tuple[str, str] | None:
60
+ """Return (owner, repo) from a --repo/-R flag in the command."""
61
+ flag_match = re.search(REPO_OVERRIDE_FLAG_PATTERN, command)
62
+ if flag_match is None:
63
+ return None
64
+ return flag_match.group("owner"), flag_match.group("repo")
65
+
66
+
67
+ def _resolve_target_identity(command: str, cwd: str | None) -> tuple[str, str, int] | None:
68
+ """Resolve the (owner, repo, pr_number) the gate keys its evidence to.
69
+
70
+ A full PR URL in the command yields all three. A --repo/-R flag yields
71
+ the repository while the PR number resolves from the command number or
72
+ the cwd. With neither present, both the repository and the number
73
+ resolve from the cwd.
74
+ """
75
+ ready_segment = _ready_command_segment(command)
76
+ pr_url_identity = _parse_pr_url(ready_segment)
77
+ if pr_url_identity is not None:
78
+ return pr_url_identity
79
+
80
+ pr_number = _resolve_pr_number(ready_segment, cwd)
81
+ if pr_number is None:
82
+ return None
83
+
84
+ repo_flag_identity = _parse_repo_flag(ready_segment)
85
+ if repo_flag_identity is not None:
86
+ flag_owner, flag_repo = repo_flag_identity
87
+ return flag_owner, flag_repo, pr_number
88
+
89
+ cwd_identity = _resolve_owner_repo(cwd)
90
+ if cwd_identity is None:
91
+ return None
92
+ cwd_owner, cwd_repo = cwd_identity
93
+ return cwd_owner, cwd_repo, pr_number
94
+
95
+
24
96
  def _resolve_pr_number(command: str, cwd: str | None) -> int | None:
25
97
  direct_match = re.search(r"\bgh\s+pr\s+ready\s+(\d+)", command)
26
98
  if direct_match:
@@ -107,19 +179,15 @@ def main() -> None:
107
179
  sys.exit(0)
108
180
 
109
181
  command = hook_input.get("tool_input", {}).get("command", "")
110
- gh_pr_ready_pattern = re.compile(r"\bgh\s+pr\s+ready\b(?![^&|;\n]*--undo)")
182
+ gh_pr_ready_pattern = re.compile(GH_PR_READY_ANCHOR_PATTERN)
111
183
  if not gh_pr_ready_pattern.search(command):
112
184
  sys.exit(0)
113
185
 
114
186
  cwd = hook_input.get("cwd")
115
- pr_number = _resolve_pr_number(command, cwd)
116
- if pr_number is None:
117
- sys.exit(0)
118
-
119
- owner_repo = _resolve_owner_repo(cwd)
120
- if owner_repo is None:
187
+ target_identity = _resolve_target_identity(command, cwd)
188
+ if target_identity is None:
121
189
  sys.exit(0)
122
- owner, repo = owner_repo
190
+ owner, repo, pr_number = target_identity
123
191
 
124
192
  completed_process = _run_convergence_check(
125
193
  check_convergence_script, owner, repo, pr_number, cwd
@@ -19,6 +19,7 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
19
19
  if _hooks_dir not in sys.path:
20
20
  sys.path.insert(0, _hooks_dir)
21
21
 
22
+ from blocking.code_rules_shared import is_ephemeral_path # noqa: E402
22
23
  from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
23
24
  from hooks_constants.plain_language_blocker_constants import ( # noqa: E402
24
25
  ALL_SOFTWARE_TERMS,
@@ -324,6 +325,13 @@ def evaluate(payload_by_key: dict[str, object]) -> str | None:
324
325
  if not isinstance(raw_tool_name, str) or not isinstance(raw_tool_input, dict):
325
326
  return None
326
327
 
328
+ if raw_tool_name in ALL_WRITE_EDIT_TOOL_NAMES:
329
+ target_file_path = raw_tool_input.get("file_path", "")
330
+ if isinstance(target_file_path, str) and is_ephemeral_path(
331
+ target_file_path, payload_by_key
332
+ ):
333
+ return None
334
+
327
335
  prose_text = _collect_prose_for_tool(raw_tool_name, raw_tool_input)
328
336
  if not prose_text:
329
337
  return None
@@ -19,8 +19,9 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
19
19
  if _hooks_dir not in sys.path:
20
20
  sys.path.insert(0, _hooks_dir)
21
21
 
22
- from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin # noqa: E402
22
+ from blocking.code_rules_shared import is_ephemeral_path # noqa: E402
23
23
  from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
24
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin # noqa: E402
24
25
  from hooks_constants.state_description_blocker_constants import ( # noqa: E402
25
26
  ALL_BLOCK_COMMENT_EXTENSIONS,
26
27
  ALL_BLOCK_COMMENT_ONLY_EXTENSIONS,
@@ -297,6 +298,8 @@ def evaluate(payload_by_key: dict[str, object]) -> str | None:
297
298
  file_path = tool_input.get("file_path", "")
298
299
  if not isinstance(file_path, str) or not file_path:
299
300
  return None
301
+ if is_ephemeral_path(file_path, payload_by_key):
302
+ return None
300
303
  if not (is_markdown_file(file_path) or is_comment_bearing_file(file_path)):
301
304
  return None
302
305
 
@@ -448,3 +448,61 @@ def test_widened_scan_reads_each_file_at_most_once(
448
448
  assert not over_read_paths, (
449
449
  f"Widening must read each .py file at most once, got over-reads: {over_read_paths}"
450
450
  )
451
+
452
+
453
+ def _build_settings_package(workflow_directory: Path, constants_body: str) -> Path:
454
+ constants_package = workflow_directory / "app_settings"
455
+ constants_package.mkdir(parents=True)
456
+ (constants_package / "__init__.py").write_text("", encoding="utf-8")
457
+ constants_path = constants_package / "app_settings_constants.py"
458
+ constants_path.write_text(constants_body, encoding="utf-8")
459
+ return constants_path
460
+
461
+
462
+ def test_deny_once_records_intent_then_retry_passes_after_consumer(
463
+ neutral_root: Path,
464
+ ) -> None:
465
+ constants_body = 'PATH_ENV_NAME = "THEME_PATH"\n'
466
+ workflow_directory = neutral_root / "workflow"
467
+ constants_path = _build_settings_package(workflow_directory, constants_body)
468
+ first_issues = _check(constants_body, str(constants_path))
469
+ assert any("PATH_ENV_NAME" in each_issue for each_issue in first_issues), first_issues
470
+ assert any(
471
+ "app_settings_constants.py" in each_issue for each_issue in first_issues
472
+ ), f"Deny message must record the target constants module, got: {first_issues}"
473
+ assert any(
474
+ "re-issue this write" in each_issue for each_issue in first_issues
475
+ ), f"Deny message must instruct the retry escape, got: {first_issues}"
476
+ consumer_body = (
477
+ "from app_settings.app_settings_constants import PATH_ENV_NAME\n"
478
+ "\n"
479
+ "def resolve() -> str:\n"
480
+ " return PATH_ENV_NAME\n"
481
+ )
482
+ (workflow_directory / "consumer_service.py").write_text(
483
+ consumer_body, encoding="utf-8"
484
+ )
485
+ retry_issues = _check(constants_body, str(constants_path))
486
+ assert retry_issues == [], (
487
+ f"Once the consumer is on disk the retry passes, got: {retry_issues}"
488
+ )
489
+
490
+
491
+ def test_dead_constant_stays_denied_on_every_attempt(neutral_root: Path) -> None:
492
+ constants_body = 'PATH_ENV_NAME = "THEME_PATH"\n'
493
+ workflow_directory = neutral_root / "workflow"
494
+ constants_path = _build_settings_package(workflow_directory, constants_body)
495
+ (workflow_directory / "unrelated.py").write_text(
496
+ "def noop() -> int:\n return 1\n", encoding="utf-8"
497
+ )
498
+ first_issues = _check(constants_body, str(constants_path))
499
+ second_issues = _check(constants_body, str(constants_path))
500
+ for each_attempt in (first_issues, second_issues):
501
+ assert any("PATH_ENV_NAME" in each_issue for each_issue in each_attempt), each_attempt
502
+ assert any(
503
+ "app_settings_constants.py" in each_issue for each_issue in each_attempt
504
+ ), f"Every deny attempt records the target module, got: {each_attempt}"
505
+ assert any(
506
+ "re-issue this write" in each_issue for each_issue in each_attempt
507
+ ), f"A dead constant stays denied with the retry escape, got: {each_attempt}"
508
+
@@ -0,0 +1,133 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ import os
5
+ import shutil
6
+ import stat
7
+ import tempfile
8
+ from collections.abc import Callable, Iterator
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+
13
+ ENFORCER_PATH = Path(__file__).resolve().parent / "code_rules_enforcer.py"
14
+ specification = importlib.util.spec_from_file_location("code_rules_enforcer", ENFORCER_PATH)
15
+ assert specification is not None and specification.loader is not None
16
+ code_rules_enforcer = importlib.util.module_from_spec(specification)
17
+ specification.loader.exec_module(code_rules_enforcer)
18
+
19
+ CONSTANTS_BODY = 'MEDIUM_TERMINAL = "terminal"\nMEDIUM_CODE = "code"\nMEDIUM_TEXT = "text"\n'
20
+
21
+
22
+ def _strip_read_only_and_retry(
23
+ removal_function: Callable[[str], object],
24
+ target_path: str,
25
+ _exc_info: BaseException,
26
+ ) -> None:
27
+ try:
28
+ os.chmod(target_path, stat.S_IWRITE)
29
+ removal_function(target_path)
30
+ except OSError:
31
+ pass
32
+
33
+
34
+ @pytest.fixture
35
+ def neutral_root() -> Iterator[Path]:
36
+ """Yield a temp directory whose path carries no ``test_`` segment.
37
+
38
+ ::
39
+
40
+ tmp_path (pytest default) -> embeds "test_..." in its name
41
+ -> is_test_file() misreads the path
42
+ mkdtemp(prefix="deadconst-") -> no "test_" segment -> reads as
43
+ a production path ok
44
+
45
+ A ``.git`` marker is planted at the root so the cross-tree widening
46
+ resolves the repository root to this synthetic tree.
47
+ """
48
+ neutral_directory = Path(tempfile.mkdtemp(prefix="deadconst-")).resolve()
49
+ (neutral_directory / ".git").mkdir()
50
+ try:
51
+ yield neutral_directory
52
+ finally:
53
+ shutil.rmtree(neutral_directory, onexc=_strip_read_only_and_retry)
54
+
55
+
56
+ def _check(source: str, file_path: str) -> list[str]:
57
+ return code_rules_enforcer.check_dead_module_constants(source, file_path)
58
+
59
+
60
+ def _build_constants_package(
61
+ workflow_directory: Path,
62
+ constants_body: str,
63
+ consumer_body: str,
64
+ ) -> Path:
65
+ constants_package = workflow_directory / "report_constants"
66
+ constants_package.mkdir(parents=True)
67
+ (constants_package / "__init__.py").write_text("", encoding="utf-8")
68
+ constants_path = constants_package / "render_report_constants.py"
69
+ constants_path.write_text(constants_body, encoding="utf-8")
70
+ (workflow_directory / "render_report.py").write_text(consumer_body, encoding="utf-8")
71
+ return constants_path
72
+
73
+
74
+ def _assert_alias_read_constants_live(issues: list[str]) -> None:
75
+ assert not any(
76
+ "MEDIUM_TERMINAL" in each_issue or "MEDIUM_CODE" in each_issue for each_issue in issues
77
+ ), f"A constant read as attribute of an imported module stays live, got: {issues}"
78
+ assert any("MEDIUM_TEXT" in each_issue for each_issue in issues), (
79
+ f"A constant read by no module must still be flagged, got: {issues}"
80
+ )
81
+
82
+
83
+ def test_does_not_flag_constant_read_via_from_import_module_alias_attribute(
84
+ neutral_root: Path,
85
+ ) -> None:
86
+ consumer_body = (
87
+ "from report_constants import render_report_constants as constants\n"
88
+ "\n"
89
+ "def panel_class(medium: str) -> str:\n"
90
+ " if medium == constants.MEDIUM_TERMINAL:\n"
91
+ " return 'terminal'\n"
92
+ " return 'code' if medium == constants.MEDIUM_CODE else 'other'\n"
93
+ )
94
+ constants_path = _build_constants_package(
95
+ neutral_root / "workflow", CONSTANTS_BODY, consumer_body
96
+ )
97
+ _assert_alias_read_constants_live(_check(CONSTANTS_BODY, str(constants_path)))
98
+
99
+
100
+ def test_does_not_flag_constant_read_via_import_as_alias_attribute(
101
+ neutral_root: Path,
102
+ ) -> None:
103
+ consumer_body = (
104
+ "import report_constants.render_report_constants as constants\n"
105
+ "\n"
106
+ "def label(medium: str) -> str:\n"
107
+ " if medium == constants.MEDIUM_TERMINAL:\n"
108
+ " return 'terminal'\n"
109
+ " return 'code' if medium == constants.MEDIUM_CODE else 'other'\n"
110
+ )
111
+ constants_path = _build_constants_package(
112
+ neutral_root / "workflow", CONSTANTS_BODY, consumer_body
113
+ )
114
+ _assert_alias_read_constants_live(_check(CONSTANTS_BODY, str(constants_path)))
115
+
116
+
117
+ def test_does_not_flag_constant_read_via_plain_module_name_attribute(
118
+ neutral_root: Path,
119
+ ) -> None:
120
+ consumer_body = (
121
+ "from report_constants import render_report_constants\n"
122
+ "\n"
123
+ "def label(medium: str) -> str:\n"
124
+ " if medium == render_report_constants.MEDIUM_TERMINAL:\n"
125
+ " return 'terminal'\n"
126
+ " if medium == render_report_constants.MEDIUM_CODE:\n"
127
+ " return 'code'\n"
128
+ " return 'other'\n"
129
+ )
130
+ constants_path = _build_constants_package(
131
+ neutral_root / "workflow", CONSTANTS_BODY, consumer_body
132
+ )
133
+ _assert_alias_read_constants_live(_check(CONSTANTS_BODY, str(constants_path)))
@@ -12,6 +12,9 @@ SHARED_MODULE_PATH = Path(__file__).parent / "code_rules_shared.py"
12
12
  FIXED_USER_ID = 4242
13
13
  WORKING_DIRECTORY = "/home/user/project"
14
14
  SESSION_ID = "session-abc-123"
15
+ HARNESS_USER_DIRECTORY_WINDOWS = "claude"
16
+ WINDOWS_MANGLED_WORKING_DIRECTORY = "c--Users-dev--claude-project"
17
+ SESSION_ID_ENVIRONMENT_VARIABLE = "CLAUDE_CODE_SESSION_ID"
15
18
 
16
19
 
17
20
  def _load_shared_module() -> ModuleType:
@@ -27,10 +30,6 @@ def _load_shared_module() -> ModuleType:
27
30
  _SHARED_MODULE = _load_shared_module()
28
31
 
29
32
 
30
- def _install_fixed_user_id(monkeypatch: pytest.MonkeyPatch) -> None:
31
- monkeypatch.setattr(os, "getuid", lambda: FIXED_USER_ID, raising=False)
32
-
33
-
34
33
  def _point_temporary_directory_at(monkeypatch: pytest.MonkeyPatch, temporary_root: Path) -> None:
35
34
  monkeypatch.setattr(tempfile, "gettempdir", lambda: str(temporary_root))
36
35
 
@@ -48,6 +47,24 @@ def _build_scratchpad_directory(temporary_root: Path) -> Path:
48
47
  return scratchpad_directory
49
48
 
50
49
 
50
+ def _build_windows_scratchpad_directory(temporary_root: Path) -> Path:
51
+ """Build the Windows-shaped scratchpad directory that carries no user id segment."""
52
+ scratchpad_directory = (
53
+ temporary_root
54
+ / HARNESS_USER_DIRECTORY_WINDOWS
55
+ / WINDOWS_MANGLED_WORKING_DIRECTORY
56
+ / SESSION_ID
57
+ / "scratchpad"
58
+ )
59
+ scratchpad_directory.mkdir(parents=True)
60
+ return scratchpad_directory
61
+
62
+
63
+ def _simulate_windows_platform(monkeypatch: pytest.MonkeyPatch, temporary_root: Path) -> None:
64
+ monkeypatch.delattr(os, "getuid", raising=False)
65
+ monkeypatch.setattr(tempfile, "gettempdir", lambda: str(temporary_root))
66
+
67
+
51
68
  def _session_payload() -> dict[str, str]:
52
69
  return {"cwd": WORKING_DIRECTORY, "session_id": SESSION_ID}
53
70
 
@@ -55,7 +72,6 @@ def _session_payload() -> dict[str, str]:
55
72
  def test_returns_true_for_file_inside_reconstructed_scratchpad(
56
73
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch
57
74
  ) -> None:
58
- _install_fixed_user_id(monkeypatch)
59
75
  _point_temporary_directory_at(monkeypatch, tmp_path)
60
76
  scratchpad_directory = _build_scratchpad_directory(tmp_path)
61
77
  throwaway_script = scratchpad_directory / "one_off_tool.py"
@@ -69,7 +85,6 @@ def test_returns_true_for_file_inside_reconstructed_scratchpad(
69
85
  def test_returns_false_for_file_outside_scratchpad(
70
86
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch
71
87
  ) -> None:
72
- _install_fixed_user_id(monkeypatch)
73
88
  _point_temporary_directory_at(monkeypatch, tmp_path)
74
89
  _build_scratchpad_directory(tmp_path)
75
90
  project_module = tmp_path / "elsewhere" / "orders.py"
@@ -82,7 +97,6 @@ def test_returns_false_for_file_outside_scratchpad(
82
97
  def test_resolves_symlink_into_scratchpad_to_true(
83
98
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch
84
99
  ) -> None:
85
- _install_fixed_user_id(monkeypatch)
86
100
  _point_temporary_directory_at(monkeypatch, tmp_path)
87
101
  scratchpad_directory = _build_scratchpad_directory(tmp_path)
88
102
  real_script = scratchpad_directory / "real_tool.py"
@@ -96,7 +110,6 @@ def test_resolves_symlink_into_scratchpad_to_true(
96
110
  def test_symlink_resolving_outside_scratchpad_is_false(
97
111
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch
98
112
  ) -> None:
99
- _install_fixed_user_id(monkeypatch)
100
113
  _point_temporary_directory_at(monkeypatch, tmp_path)
101
114
  scratchpad_directory = _build_scratchpad_directory(tmp_path)
102
115
  real_target_outside = tmp_path / "outside_target.py"
@@ -113,8 +126,8 @@ def test_symlink_resolving_outside_scratchpad_is_false(
113
126
  def test_missing_session_id_signal_returns_false(
114
127
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch
115
128
  ) -> None:
116
- _install_fixed_user_id(monkeypatch)
117
129
  _point_temporary_directory_at(monkeypatch, tmp_path)
130
+ monkeypatch.delenv(SESSION_ID_ENVIRONMENT_VARIABLE, raising=False)
118
131
  scratchpad_directory = _build_scratchpad_directory(tmp_path)
119
132
  throwaway_script = scratchpad_directory / "one_off_tool.py"
120
133
  payload_without_session = {"cwd": WORKING_DIRECTORY}
@@ -125,10 +138,12 @@ def test_missing_session_id_signal_returns_false(
125
138
  )
126
139
 
127
140
 
128
- def test_missing_working_directory_signal_returns_false(
141
+ def test_exempt_without_working_directory_signal(
129
142
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch
130
143
  ) -> None:
131
- _install_fixed_user_id(monkeypatch)
144
+ """The platform-safe predicate keys on the session id and the temp-directory path
145
+ shape, so a payload carrying the session id alone still exempts a real scratchpad
146
+ target even when it omits the working directory."""
132
147
  _point_temporary_directory_at(monkeypatch, tmp_path)
133
148
  scratchpad_directory = _build_scratchpad_directory(tmp_path)
134
149
  throwaway_script = scratchpad_directory / "one_off_tool.py"
@@ -136,27 +151,28 @@ def test_missing_working_directory_signal_returns_false(
136
151
 
137
152
  assert (
138
153
  _SHARED_MODULE.is_under_session_scratchpad(str(throwaway_script), payload_without_cwd)
139
- is False
154
+ is True
140
155
  )
141
156
 
142
157
 
143
- def test_absent_getuid_returns_false(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
144
- _install_fixed_user_id(monkeypatch)
145
- _point_temporary_directory_at(monkeypatch, tmp_path)
146
- scratchpad_directory = _build_scratchpad_directory(tmp_path)
158
+ def test_exempt_on_windows_without_getuid(
159
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
160
+ ) -> None:
161
+ """On Windows the os.getuid attribute is absent, and a real scratchpad target still
162
+ resolves as exempt through its temp-directory path shape and session id."""
163
+ _simulate_windows_platform(monkeypatch, tmp_path)
164
+ scratchpad_directory = _build_windows_scratchpad_directory(tmp_path)
147
165
  throwaway_script = scratchpad_directory / "one_off_tool.py"
148
- monkeypatch.delattr(os, "getuid", raising=False)
149
166
 
150
167
  assert (
151
168
  _SHARED_MODULE.is_under_session_scratchpad(str(throwaway_script), _session_payload())
152
- is False
169
+ is True
153
170
  )
154
171
 
155
172
 
156
173
  def test_nonexistent_scratchpad_directory_returns_false(
157
174
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch
158
175
  ) -> None:
159
- _install_fixed_user_id(monkeypatch)
160
176
  _point_temporary_directory_at(monkeypatch, tmp_path)
161
177
  mangled_working_directory = WORKING_DIRECTORY.replace("/", "-")
162
178
  unbuilt_target = (
@@ -174,8 +190,92 @@ def test_nonexistent_scratchpad_directory_returns_false(
174
190
 
175
191
 
176
192
  def test_empty_file_path_returns_false(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
177
- _install_fixed_user_id(monkeypatch)
178
193
  _point_temporary_directory_at(monkeypatch, tmp_path)
179
194
  _build_scratchpad_directory(tmp_path)
180
195
 
181
196
  assert _SHARED_MODULE.is_under_session_scratchpad("", _session_payload()) is False
197
+
198
+
199
+ def test_windows_shape_repository_file_is_not_exempt(
200
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
201
+ ) -> None:
202
+ _simulate_windows_platform(monkeypatch, tmp_path)
203
+ _build_windows_scratchpad_directory(tmp_path)
204
+ repository_module = tmp_path / "repository" / "src" / "orders.py"
205
+ repository_module.parent.mkdir(parents=True)
206
+
207
+ assert (
208
+ _SHARED_MODULE.is_under_session_scratchpad(str(repository_module), _session_payload())
209
+ is False
210
+ )
211
+
212
+
213
+ def test_temp_path_outside_scratchpad_shape_is_not_exempt(
214
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
215
+ ) -> None:
216
+ """A path under the harness user directory but off the session/scratchpad shape
217
+ keeps full enforcement."""
218
+ _simulate_windows_platform(monkeypatch, tmp_path)
219
+ _build_windows_scratchpad_directory(tmp_path)
220
+ off_shape_target = tmp_path / HARNESS_USER_DIRECTORY_WINDOWS / "unrelated" / "notes.py"
221
+ off_shape_target.parent.mkdir(parents=True)
222
+
223
+ assert (
224
+ _SHARED_MODULE.is_under_session_scratchpad(str(off_shape_target), _session_payload())
225
+ is False
226
+ )
227
+
228
+
229
+ def test_repository_path_containing_scratchpad_word_is_not_exempt(
230
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
231
+ ) -> None:
232
+ """A repository path outside the temp directory that merely carries a scratchpad
233
+ segment keeps full enforcement."""
234
+ _simulate_windows_platform(monkeypatch, tmp_path)
235
+ _build_windows_scratchpad_directory(tmp_path)
236
+ repository_lookalike = tmp_path.parent / "project_repo" / "scratchpad" / "tool.py"
237
+
238
+ assert (
239
+ _SHARED_MODULE.is_under_session_scratchpad(str(repository_lookalike), _session_payload())
240
+ is False
241
+ )
242
+
243
+
244
+ def test_is_ephemeral_path_true_for_windows_scratchpad(
245
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
246
+ ) -> None:
247
+ _simulate_windows_platform(monkeypatch, tmp_path)
248
+ scratchpad_directory = _build_windows_scratchpad_directory(tmp_path)
249
+ throwaway_script = scratchpad_directory / "probe.py"
250
+
251
+ assert _SHARED_MODULE.is_ephemeral_path(str(throwaway_script), _session_payload()) is True
252
+
253
+
254
+ def test_is_ephemeral_path_true_for_root_anchored_tmp(monkeypatch: pytest.MonkeyPatch) -> None:
255
+ monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
256
+
257
+ assert _SHARED_MODULE.is_ephemeral_path("/tmp/scratch.py") is True
258
+
259
+
260
+ def test_is_ephemeral_path_false_for_repository_file(
261
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
262
+ ) -> None:
263
+ _simulate_windows_platform(monkeypatch, tmp_path)
264
+ monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
265
+ repository_file = tmp_path / "repository" / "orders.py"
266
+ repository_file.parent.mkdir(parents=True)
267
+
268
+ assert _SHARED_MODULE.is_ephemeral_path(str(repository_file), _session_payload()) is False
269
+
270
+
271
+ def test_is_ephemeral_path_reads_session_id_from_environment(
272
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
273
+ ) -> None:
274
+ """With no payload, the session scratchpad match reads the session id from the
275
+ harness environment variable, so a caller that holds no payload still matches."""
276
+ _simulate_windows_platform(monkeypatch, tmp_path)
277
+ monkeypatch.setenv(SESSION_ID_ENVIRONMENT_VARIABLE, SESSION_ID)
278
+ scratchpad_directory = _build_windows_scratchpad_directory(tmp_path)
279
+ throwaway_script = scratchpad_directory / "probe.py"
280
+
281
+ assert _SHARED_MODULE.is_ephemeral_path(str(throwaway_script)) is True