claude-dev-env 2.31.0 → 2.31.1

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 (47) hide show
  1. package/docs/CODE_RULES.md +2 -1
  2. package/hooks/blocking/code_rules_duplicate_body.py +272 -100
  3. package/hooks/blocking/code_rules_enforcer.py +32 -14
  4. package/hooks/blocking/code_rules_shared.py +64 -14
  5. package/hooks/blocking/pr_description_writer_gate.py +18 -25
  6. package/hooks/blocking/tdd_enforcer.py +8 -4
  7. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +12 -1
  8. package/hooks/blocking/test_code_rules_enforcer_ephemeral_repository_root.py +118 -0
  9. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +244 -0
  10. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +22 -0
  11. package/hooks/blocking/test_pr_description_writer_gate.py +21 -3
  12. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +9 -2
  13. package/hooks/blocking/test_state_description_blocker.py +99 -53
  14. package/hooks/blocking/test_tdd_enforcer.py +64 -0
  15. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +34 -0
  16. package/hooks/hooks_constants/subprocess_budget_completeness_content.py +2 -2
  17. package/hooks/hooks_constants/test_multi_edit_reconstruction.py +1 -0
  18. package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +33 -0
  19. package/hooks/observability/pr_description_writer_spawn_tracker.py +4 -12
  20. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +6 -2
  21. package/hooks/validators/abbreviation_checks.py +3 -3
  22. package/hooks/validators/code_quality_checks.py +4 -4
  23. package/hooks/validators/config/baseline_identity_constants.py +12 -0
  24. package/hooks/validators/config/directory_exemption_constants.py +44 -1
  25. package/hooks/validators/config/validator_base_constants.py +9 -0
  26. package/hooks/validators/fast_save_validators.py +23 -80
  27. package/hooks/validators/pr_reference_checks.py +2 -2
  28. package/hooks/validators/python_antipattern_checks.py +3 -3
  29. package/hooks/validators/python_style_checks.py +3 -2
  30. package/hooks/validators/react_checks.py +2 -9
  31. package/hooks/validators/run_all_validators.py +190 -145
  32. package/hooks/validators/security_checks.py +3 -3
  33. package/hooks/validators/test_directory_exemption_constants.py +30 -24
  34. package/hooks/validators/test_fast_save_validators.py +16 -16
  35. package/hooks/validators/test_hook_timing_harness.py +10 -1
  36. package/hooks/validators/test_run_all_validators_pretooluse.py +161 -0
  37. package/hooks/validators/test_run_all_validators_repository_root.py +202 -0
  38. package/hooks/validators/test_validator_base.py +51 -2
  39. package/hooks/validators/todo_checks.py +2 -2
  40. package/hooks/validators/type_safety_checks.py +3 -3
  41. package/hooks/validators/useless_test_checks.py +3 -3
  42. package/hooks/validators/validator_base.py +73 -1
  43. package/package.json +1 -1
  44. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +15 -0
  45. package/scripts/grok_headless_runner.py +15 -12
  46. package/scripts/shared_tree_paths.py +70 -0
  47. package/scripts/test_shared_tree_paths.py +143 -0
@@ -1,9 +1,15 @@
1
1
  """Base classes for validators.
2
2
 
3
- Provides shared dataclasses used across all validator modules.
3
+ Provides shared dataclasses used across all validator modules, and the one
4
+ read and the one parse each of those modules shares for a given file.
4
5
  """
5
6
 
7
+ import ast
6
8
  from dataclasses import dataclass
9
+ from functools import lru_cache
10
+ from pathlib import Path
11
+
12
+ from .config.validator_base_constants import SOURCE_CACHE_ENTRIES, SOURCE_ENCODING
7
13
 
8
14
 
9
15
  @dataclass(frozen=True)
@@ -17,3 +23,69 @@ class Violation:
17
23
  def __str__(self) -> str:
18
24
  """Format as file:line: message."""
19
25
  return f"{self.file}:{self.line}: {self.message}"
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class ValidatorResult:
30
+ """Result from running a validator."""
31
+
32
+ name: str
33
+ checks: str
34
+ passed: bool
35
+ output: str
36
+ skipped: bool = False
37
+
38
+
39
+ @lru_cache(maxsize=SOURCE_CACHE_ENTRIES)
40
+ def _source_text_for_stat(
41
+ file_path_text: str, modified_nanoseconds: int, size_in_bytes: int
42
+ ) -> str:
43
+ """Read one file, keyed on the stat that identifies this version of it."""
44
+ return Path(file_path_text).read_text(encoding=SOURCE_ENCODING)
45
+
46
+
47
+ def source_text(file_path: Path) -> str:
48
+ """Return the file's text, reading it once however many checks ask.
49
+
50
+ Every check the save-path gate runs reads the same file. The gate hands
51
+ each of them one freshly written target, so the read is repeated work.
52
+ The cache key carries the modification time and size, so an edited file
53
+ is read again.
54
+
55
+ That key has one blind spot: a rewrite that leaves the size unchanged
56
+ inside a single modification-time tick is served from the cache. A hook
57
+ process handles one payload, so it cannot happen there; a long CLI pass
58
+ over many files is where a reader needs to know.
59
+
60
+ Raises whatever ``read_text`` raises, so a caller's own error handling
61
+ is unchanged.
62
+
63
+ Args:
64
+ file_path: The file to read.
65
+
66
+ Returns:
67
+ The file's decoded text.
68
+ """
69
+ file_status = file_path.stat()
70
+ return _source_text_for_stat(
71
+ str(file_path), file_status.st_mtime_ns, file_status.st_size
72
+ )
73
+
74
+
75
+ @lru_cache(maxsize=SOURCE_CACHE_ENTRIES)
76
+ def syntax_tree(source: str) -> ast.Module:
77
+ """Return the parsed tree for source, parsing it once however many checks ask.
78
+
79
+ No check mutates the tree; each one walks it. So one parse serves all of
80
+ them, and the tree is shared rather than copied.
81
+
82
+ Raises ``SyntaxError`` exactly as ``ast.parse`` does, so a caller's own
83
+ error handling is unchanged.
84
+
85
+ Args:
86
+ source: The file's text.
87
+
88
+ Returns:
89
+ The parsed module.
90
+ """
91
+ return ast.parse(source)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "2.31.0",
3
+ "version": "2.31.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -56,6 +56,21 @@ DEFAULT_PING_TIMEOUT_SECONDS: int = 120
56
56
  CLAUDE_HOME_SUBDIRECTORY: str = ".claude"
57
57
  """Per-user directory under the home directory that holds Claude config."""
58
58
 
59
+ CLAUDE_CONFIG_DIR_ENV_VAR: str = "CLAUDE_CONFIG_DIR"
60
+ """Environment variable naming the managed Claude config root."""
61
+
62
+ PROCESS_TREE_KILL_MODULE_FILENAME: str = "process_tree_kill.py"
63
+ """Filename of the shared process-tree kill helper."""
64
+
65
+ SHARED_PACKAGE_DIRECTORY_NAME: str = "_shared"
66
+ """Directory name of the shared install tree under the managed root."""
67
+
68
+ PROCESS_TREE_DIRECTORY_NAME: str = "process-tree"
69
+ """Directory name of the process-tree helper package under ``_shared``."""
70
+
71
+ SCRIPTS_DIRECTORY_NAME: str = "scripts"
72
+ """Directory name holding Python helpers under a process-tree package."""
73
+
59
74
  MANIFEST_FILENAME: str = ".claude-dev-env-manifest.json"
60
75
  """Install manifest written by ``claude-dev-env`` into the user Claude home."""
61
76
 
@@ -25,24 +25,14 @@ Import ``run_headless_worker`` for the outcome object::
25
25
 
26
26
  from __future__ import annotations
27
27
 
28
+ import os
28
29
  import subprocess
29
30
  import sys
30
31
  import uuid
31
32
  from dataclasses import dataclass
32
33
  from pathlib import Path
33
34
 
34
- _shared_process_tree_scripts_directory = (
35
- Path(__file__).resolve().parents[1] / "_shared" / "process-tree" / "scripts"
36
- )
37
- if str(_shared_process_tree_scripts_directory) not in sys.path:
38
- sys.path.insert(0, str(_shared_process_tree_scripts_directory))
39
-
40
- from process_tree_kill import ( # noqa: E402
41
- should_start_new_session,
42
- terminate_process_tree,
43
- )
44
-
45
- from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
35
+ from dev_env_scripts_constants.grok_worker_constants import (
46
36
  AGENT_FLAG,
47
37
  ALL_AUTH_FAILURE_SIGNATURES,
48
38
  ALL_USAGE_LIMIT_SIGNATURES,
@@ -81,6 +71,19 @@ from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
81
71
  UTF8_ENCODING,
82
72
  WORKER_SPEC_TIMEOUT_KEY,
83
73
  )
74
+ from shared_tree_paths import resolve_shared_process_tree_scripts_directory
75
+
76
+ _shared_process_tree_scripts_directory = resolve_shared_process_tree_scripts_directory(
77
+ __file__,
78
+ all_environment=os.environ,
79
+ )
80
+ if str(_shared_process_tree_scripts_directory) not in sys.path:
81
+ sys.path.insert(0, str(_shared_process_tree_scripts_directory))
82
+
83
+ from process_tree_kill import ( # noqa: E402
84
+ should_start_new_session,
85
+ terminate_process_tree,
86
+ )
84
87
 
85
88
  runner_popen = subprocess.Popen
86
89
 
@@ -0,0 +1,70 @@
1
+ """Resolve directories inside the installed shared tree."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+
8
+ from dev_env_scripts_constants.grok_worker_constants import (
9
+ CLAUDE_CONFIG_DIR_ENV_VAR,
10
+ PROCESS_TREE_DIRECTORY_NAME,
11
+ PROCESS_TREE_KILL_MODULE_FILENAME,
12
+ SCRIPTS_DIRECTORY_NAME,
13
+ SHARED_PACKAGE_DIRECTORY_NAME,
14
+ )
15
+
16
+
17
+ def _process_tree_scripts_directory(from_root: Path) -> Path:
18
+ return (
19
+ from_root
20
+ / SHARED_PACKAGE_DIRECTORY_NAME
21
+ / PROCESS_TREE_DIRECTORY_NAME
22
+ / SCRIPTS_DIRECTORY_NAME
23
+ )
24
+
25
+
26
+ def _has_process_tree_kill_module(from_directory: Path) -> bool:
27
+ return (from_directory / PROCESS_TREE_KILL_MODULE_FILENAME).is_file()
28
+
29
+
30
+ def resolve_shared_process_tree_scripts_directory(
31
+ module_file: str | Path,
32
+ all_environment: Mapping[str, str],
33
+ ) -> Path:
34
+ """Return the first process-tree scripts directory that holds the kill helper.
35
+
36
+ ::
37
+
38
+ scripts dir is a junction; the kill helper sits beside the un-resolved parent
39
+ ok: Path(module_file).absolute().parent.parent then the _shared scripts dir
40
+ flag: Path(module_file).resolve().parents[1] walks the junction and misses it
41
+
42
+ Tries the un-resolved parent first, then the configured managed root, then
43
+ the resolved parent. When none hold the helper, return the un-resolved
44
+ candidate so import failure names a readable path.
45
+
46
+ Args:
47
+ module_file: Path of the importing module (``__file__``).
48
+ all_environment: Mapping that may hold ``CLAUDE_CONFIG_DIR``.
49
+
50
+ Returns:
51
+ Directory that should hold ``process_tree_kill.py``.
52
+ """
53
+ un_resolved_candidate = _process_tree_scripts_directory(
54
+ Path(module_file).absolute().parent.parent
55
+ )
56
+ all_candidates = [un_resolved_candidate]
57
+ configured_root_text = all_environment.get(
58
+ CLAUDE_CONFIG_DIR_ENV_VAR, ""
59
+ ).strip()
60
+ if configured_root_text:
61
+ all_candidates.append(
62
+ _process_tree_scripts_directory(Path(configured_root_text))
63
+ )
64
+ all_candidates.append(
65
+ _process_tree_scripts_directory(Path(module_file).resolve().parents[1])
66
+ )
67
+ for each_candidate in all_candidates:
68
+ if _has_process_tree_kill_module(each_candidate):
69
+ return each_candidate
70
+ return un_resolved_candidate
@@ -0,0 +1,143 @@
1
+ """Behavioral tests for shared-tree directory resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ _SCRIPTS_DIR = Path(__file__).resolve().parent
13
+ if str(_SCRIPTS_DIR) not in sys.path:
14
+ sys.path.insert(0, str(_SCRIPTS_DIR))
15
+
16
+ import shared_tree_paths # noqa: E402
17
+ from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
18
+ CLAUDE_CONFIG_DIR_ENV_VAR,
19
+ PROCESS_TREE_DIRECTORY_NAME,
20
+ PROCESS_TREE_KILL_MODULE_FILENAME,
21
+ SCRIPTS_DIRECTORY_NAME,
22
+ SHARED_PACKAGE_DIRECTORY_NAME,
23
+ )
24
+
25
+
26
+ def _create_directory_link(*, from_link: Path, to_target: Path) -> None:
27
+ if sys.platform.startswith("win32"):
28
+ importlib.import_module("_winapi").CreateJunction(
29
+ str(to_target), str(from_link)
30
+ )
31
+ return
32
+ try:
33
+ os.symlink(to_target, from_link, target_is_directory=True)
34
+ except OSError:
35
+ pytest.skip("directory symlink creation is not available")
36
+
37
+
38
+ def _build_junction_scripts_layout(tmp_path: Path) -> Path:
39
+ real_scripts_directory = tmp_path / "real" / SCRIPTS_DIRECTORY_NAME
40
+ real_scripts_directory.mkdir(parents=True)
41
+ (real_scripts_directory / "runner.py").write_text("", encoding="utf-8")
42
+ managed_root = tmp_path / "root"
43
+ managed_root.mkdir()
44
+ _create_directory_link(
45
+ from_link=managed_root / SCRIPTS_DIRECTORY_NAME,
46
+ to_target=real_scripts_directory,
47
+ )
48
+ return managed_root / SCRIPTS_DIRECTORY_NAME / "runner.py"
49
+
50
+
51
+ def _write_process_tree_kill_stub(from_root: Path) -> Path:
52
+ scripts_directory = (
53
+ from_root
54
+ / SHARED_PACKAGE_DIRECTORY_NAME
55
+ / PROCESS_TREE_DIRECTORY_NAME
56
+ / SCRIPTS_DIRECTORY_NAME
57
+ )
58
+ scripts_directory.mkdir(parents=True)
59
+ (scripts_directory / PROCESS_TREE_KILL_MODULE_FILENAME).write_text(
60
+ "", encoding="utf-8"
61
+ )
62
+ return scripts_directory
63
+
64
+
65
+ def test_junction_scripts_dir_finds_shared_tree_on_unresolved_parent(
66
+ tmp_path: Path,
67
+ ) -> None:
68
+ module_file = _build_junction_scripts_layout(tmp_path)
69
+ expected_scripts_directory = _write_process_tree_kill_stub(tmp_path / "root")
70
+ resolved_scripts_directory = (
71
+ shared_tree_paths.resolve_shared_process_tree_scripts_directory(
72
+ module_file, all_environment={}
73
+ )
74
+ )
75
+ assert resolved_scripts_directory == expected_scripts_directory
76
+
77
+
78
+ def test_resolved_parent_is_used_when_link_side_has_no_shared_tree(
79
+ tmp_path: Path,
80
+ ) -> None:
81
+ module_file = _build_junction_scripts_layout(tmp_path)
82
+ expected_scripts_directory = _write_process_tree_kill_stub(tmp_path / "real")
83
+ resolved_scripts_directory = (
84
+ shared_tree_paths.resolve_shared_process_tree_scripts_directory(
85
+ module_file, all_environment={}
86
+ )
87
+ )
88
+ assert resolved_scripts_directory == expected_scripts_directory
89
+
90
+
91
+ def test_configured_root_is_used_when_neither_parent_holds_the_module(
92
+ tmp_path: Path,
93
+ ) -> None:
94
+ module_file = _build_junction_scripts_layout(tmp_path)
95
+ configured_root = tmp_path / "configured"
96
+ expected_scripts_directory = _write_process_tree_kill_stub(configured_root)
97
+ resolved_scripts_directory = (
98
+ shared_tree_paths.resolve_shared_process_tree_scripts_directory(
99
+ module_file,
100
+ all_environment={
101
+ CLAUDE_CONFIG_DIR_ENV_VAR: str(configured_root)
102
+ },
103
+ )
104
+ )
105
+ assert resolved_scripts_directory == expected_scripts_directory
106
+
107
+
108
+ def test_unresolved_parent_wins_when_configured_root_also_holds_the_module(
109
+ tmp_path: Path,
110
+ ) -> None:
111
+ module_file = _build_junction_scripts_layout(tmp_path)
112
+ expected_scripts_directory = _write_process_tree_kill_stub(tmp_path / "root")
113
+ configured_root = tmp_path / "configured"
114
+ _write_process_tree_kill_stub(configured_root)
115
+ resolved_scripts_directory = (
116
+ shared_tree_paths.resolve_shared_process_tree_scripts_directory(
117
+ module_file,
118
+ all_environment={
119
+ CLAUDE_CONFIG_DIR_ENV_VAR: str(configured_root)
120
+ },
121
+ )
122
+ )
123
+ assert resolved_scripts_directory == expected_scripts_directory
124
+
125
+
126
+ def test_missing_module_returns_unresolved_candidate(tmp_path: Path) -> None:
127
+ module_file = _build_junction_scripts_layout(tmp_path)
128
+ expected_scripts_directory = (
129
+ tmp_path
130
+ / "root"
131
+ / SHARED_PACKAGE_DIRECTORY_NAME
132
+ / PROCESS_TREE_DIRECTORY_NAME
133
+ / SCRIPTS_DIRECTORY_NAME
134
+ )
135
+ resolved_scripts_directory = (
136
+ shared_tree_paths.resolve_shared_process_tree_scripts_directory(
137
+ module_file, all_environment={}
138
+ )
139
+ )
140
+ assert resolved_scripts_directory == expected_scripts_directory
141
+ assert not (
142
+ expected_scripts_directory / PROCESS_TREE_KILL_MODULE_FILENAME
143
+ ).is_file()