claude-dev-env 8.106.3 → 8.106.5

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.
@@ -119,7 +119,7 @@ def test_gate_caller_scans_staged_content_with_commit_stage_scope(
119
119
  init_repository(repository_root)
120
120
  committed_source = "def calculate_total() -> int:\n return 0\n"
121
121
  staged_source = (
122
- "def calculate_total() -> int:\n total = 9999\n return total\n"
122
+ "def calculate_total() -> int:\n total = abs(9999)\n return total\n"
123
123
  )
124
124
  file_path = write_commit_and_stage_change(
125
125
  repository_root,
@@ -13,13 +13,13 @@ A keep marker is the one comment that may be added and kept: a comment that open
13
13
 
14
14
  ## CORE PRINCIPLES
15
15
 
16
- Use clear names. Keep shared constants in `config/`. Search before adding helpers. Put construction and formatting with the data owner. Encapsulation enables cleaner naming, so prefer `isMaxLevel(level)` to `level >= MAXIMUM_LEVEL`. Session policy lives in [`code-standards.md`](../rules/code-standards.md).
16
+ Use clear names. Keep shared and tuned constants in `config/`; a value used once stays in its function under a local name. Search before adding helpers. Put construction and formatting with the data owner. Encapsulation enables cleaner naming, so prefer `isMaxLevel(level)` to `level >= MAXIMUM_LEVEL`. Session policy lives in [`code-standards.md`](../rules/code-standards.md).
17
17
 
18
18
  Open [details](code-rules/core-principles-and-config.md) when adding shared values or construction logic.
19
19
 
20
20
  ## ⚡ LINT-ENFORCED RULES
21
21
 
22
- Staged lint runs `code_rules_enforcer.py`. Keep imports at the top, parameterize logging, guard path insertion, use specific exceptions, and follow checks for values, types, docstrings, names, and tests. Magic values exempt 0, 1, and -1. Type escape hatches are allowed in boundary files. Stub bodies are allowed in abstract and Protocol classes.
22
+ Staged lint runs `code_rules_enforcer.py`. Keep imports at the top, parameterize logging, guard path insertion, use specific exceptions, and follow checks for values, types, docstrings, names, and tests. Magic values exempt 0, 1, and -1, and a number bound alone to a named local passes. Type escape hatches are allowed in boundary files. Stub bodies are allowed in abstract and Protocol classes.
23
23
 
24
24
  - UPPER_SNAKE constants belong in `config/`. Exemptions include `config/*`, `/migrations/`, and test paths or names matching `test_`, `_test.`, `.spec.`, `conftest`, or `/tests/`.
25
25
  - Workflow registries: a path that contains any of these substrings, `/workflow/`, `_tab.py`, `/states.py`, or `/modules.py`, is exempt; each matches independently as a substring.
@@ -62,7 +62,7 @@ Open [details](code-rules/design-and-structure.md) when adding a class or parame
62
62
 
63
63
  ## 7.5 SOLID PRINCIPLES
64
64
 
65
- Apply SRP throughout. Apply OCP, LSP, ISP, and DIP when two concrete implementations share a contract. Keep cohesive classes together.
65
+ Keep a function whole while it reads top to bottom. Split it when the new piece has a second caller or makes the caller easier to read, or when it reaches the 60-line cap the lint and the write-time hook enforce. Apply OCP, LSP, ISP, and DIP when two concrete implementations share a contract. Keep cohesive classes together.
66
66
 
67
67
  Open [details](code-rules/design-and-structure.md) when extracting a responsibility.
68
68
 
@@ -24,7 +24,7 @@ I search for shared code and configuration before adding a constant or repeating
24
24
 
25
25
  The code-rules enforcer runs constant, path, magic-value, and duplicate-format checks on changed *.py files. String and collection checks inspect production function bodies in *.py files.
26
26
 
27
- Search config for the exact value and a semantic match. Add timing to config/timing.py, ports and thresholds to config/constants.py, and selectors to config/selectors.py. Keep construction and formatting with the owner of the data. Constants outside config are exempt in migrations, workflow registries, and test files. Workflow registry matching uses path substrings /workflow/, _tab.py, /states.py, and /modules.py. The test-file patterns include test_, _test., .spec., conftest, and /tests/.
27
+ A value used once stays in its function under a local name, such as `retry_backoff_factor = 2.5`. A value moves to config when two modules share it or someone tunes it, such as a timeout, threshold, port, or selector. Search config for the exact value and a semantic match. Add timing to config/timing.py, ports and thresholds to config/constants.py, and selectors to config/selectors.py. Keep construction and formatting with the owner of the data. Constants outside config are exempt in migrations, workflow registries, and test files. Workflow registry matching uses path substrings /workflow/, _tab.py, /states.py, and /modules.py. The test-file patterns include test_, _test., .spec., conftest, and /tests/.
28
28
 
29
29
  ## Proving it
30
30
 
@@ -35,6 +35,7 @@ Run the named pytest node or command with the described input. A breach produces
35
35
  - check_constants_outside_config_advisory: a function-local UPPER_SNAKE constant; `python -m pytest packages/claude-dev-env/hooks/blocking/test_code_rules_enforcer_split_constants_config.py::test_advisory_should_flag_annotated_function_body_constant` reports a stderr advisory.
36
36
  - check_fstring_structural_literals: an f-string that embeds a URL path fragment; `python -m pytest packages/claude-dev-env/hooks/blocking/test_code_rules_enforcer_fstring_scan.py::test_should_flag_fstring_with_url_path` reports a named violation.
37
37
  - check_magic_values: a production function comparing a value with 2; `python -m pytest packages/claude-dev-env/hooks/blocking/test_code_rules_enforcer_magic_allowlist.py::test_check_magic_values_should_flag_literal_two_in_function_body` reports a named violation.
38
+ - check_magic_values exemption: a function binding `retry_backoff_factor = 2.5` alone on its line; `python -m pytest packages/claude-dev-env/hooks/blocking/test_code_rules_magic_values.py::test_should_pass_a_number_bound_to_a_local_name` passes with no violation.
38
39
  - check_duplicated_format_patterns: the same f-string skeleton at three call sites; `python -m pytest packages/claude-dev-env/hooks/blocking/test_code_rules_enforcer_split_optional_params.py::test_should_advise_when_fstring_skeleton_appears_three_or_more_times` reports a stderr advisory.
39
40
  - check_hardcoded_user_paths: a fixed user-home path in source; `python -m pytest packages/claude-dev-env/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py::test_should_flag_windows_user_path_with_forward_slashes` reports a named violation.
40
41
  - check_sys_path_insert_deduplication_guard: an unguarded module-level sys.path.insert call; `python -m pytest packages/claude-dev-env/hooks/blocking/test_code_rules_enforcer_sys_path_insert.py::test_should_flag_unguarded_module_level_insert` reports a named violation.
@@ -3,6 +3,9 @@
3
3
  import ast
4
4
  import re
5
5
  import sys
6
+ import textwrap
7
+ from collections import Counter
8
+ from collections.abc import Iterator
6
9
  from pathlib import Path
7
10
 
8
11
  _blocking_directory = str(Path(__file__).resolve().parent)
@@ -148,15 +151,135 @@ def _is_magic_number_inside_slice_bound(masked_line: str, number_text: str) -> b
148
151
  return False
149
152
 
150
153
 
154
+ def _named_number_binding_line_numbers(content: str) -> set[int] | None:
155
+ """Return the lines where a function binds one number literal to one name.
156
+
157
+ The name carries the meaning, so the number needs no constant::
158
+
159
+ def backoff_seconds(attempt: int) -> float:
160
+ retry_backoff_factor = 2.5
161
+
162
+ A binding sharing its line with another statement, a number in an
163
+ expression, a keyword argument, a module-level binding, and a ``global``
164
+ or ``nonlocal`` name still fire. A fragment is dedented before the
165
+ parse; content that does not parse returns None.
166
+ """
167
+ try:
168
+ syntax_tree = ast.parse(textwrap.dedent(content))
169
+ except SyntaxError:
170
+ return None
171
+
172
+ all_outer_scope_names = _global_and_nonlocal_names(syntax_tree)
173
+ statement_count_by_line = Counter(
174
+ each_node.lineno for each_node in ast.walk(syntax_tree) if isinstance(each_node, ast.stmt)
175
+ )
176
+ return {
177
+ each_node.lineno
178
+ for each_node in _nodes_inside_functions(syntax_tree)
179
+ if isinstance(each_node, ast.stmt)
180
+ and _number_bound_name(each_node) not in (None, *all_outer_scope_names)
181
+ and each_node.lineno == each_node.end_lineno
182
+ and statement_count_by_line[each_node.lineno] == 1
183
+ }
184
+
185
+
186
+ def _global_and_nonlocal_names(syntax_tree: ast.AST) -> set[str]:
187
+ """Return every name a ``global`` or ``nonlocal`` statement declares in the file."""
188
+ return {
189
+ each_name
190
+ for each_node in ast.walk(syntax_tree)
191
+ if isinstance(each_node, (ast.Global, ast.Nonlocal))
192
+ for each_name in each_node.names
193
+ }
194
+
195
+
196
+ def _nodes_inside_functions(syntax_tree: ast.AST) -> Iterator[ast.AST]:
197
+ """Yield every node whose nearest enclosing scope is a function, each one once."""
198
+ all_pending: list[tuple[ast.AST, bool]] = [(syntax_tree, False)]
199
+ while all_pending:
200
+ node, is_inside_function = all_pending.pop()
201
+ if is_inside_function:
202
+ yield node
203
+ is_child_inside_function = not isinstance(node, ast.ClassDef) and (
204
+ is_inside_function or isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
205
+ )
206
+ all_pending.extend((each_child, is_child_inside_function) for each_child in ast.iter_child_nodes(node))
207
+
208
+
209
+ def _fragment_global_and_nonlocal_names(content: str) -> set[str]:
210
+ """Return every name a ``global`` or ``nonlocal`` line declares in text that does not parse."""
211
+ return {
212
+ each_name.strip()
213
+ for each_match in re.finditer(r"^[ \t]*(?:global|nonlocal)[ \t]+([\w \t,]+)", content, re.MULTILINE)
214
+ for each_name in each_match.group(1).split(",")
215
+ }
216
+
217
+
218
+ def _is_fragment_named_number_line(
219
+ all_lines: list[str], line_number: int, function_indent: int, all_outer_scope_names: set[str]
220
+ ) -> bool:
221
+ """Return True when a line of unparseable content binds one number to one name in a function.
222
+
223
+ An Edit fragment that ends partway through a block does not parse whole::
224
+
225
+ def backoff_seconds(attempt: int) -> float:
226
+ retry_backoff_factor = 2.5
227
+ if attempt > 0:
228
+
229
+ The line must sit deeper than the last ``def`` line and parse alone as
230
+ the binding, so ``retry_backoff_factor = 2.5`` passes there. A name a
231
+ ``global`` or ``nonlocal`` line declares still fires, and so does a line
232
+ after one ending in ``,``, ``(``, ``[`` or ``{``, such as the last
233
+ keyword argument of a call split across lines.
234
+ """
235
+ line = all_lines[line_number - 1]
236
+ stripped_line = line.strip()
237
+ all_earlier_code_lines = [each_line.strip() for each_line in all_lines[: line_number - 1] if each_line.strip()]
238
+ is_inside_brackets = bool(all_earlier_code_lines) and all_earlier_code_lines[-1].endswith((",", "(", "[", "{"))
239
+ if is_inside_brackets or len(line) - len(line.lstrip()) <= function_indent:
240
+ return False
241
+ try:
242
+ line_tree = ast.parse(stripped_line)
243
+ except SyntaxError:
244
+ return False
245
+ return len(line_tree.body) == 1 and _number_bound_name(line_tree.body[0]) not in (None, *all_outer_scope_names)
246
+
247
+
248
+ def _number_bound_name(node: ast.AST) -> str | None:
249
+ """Return the name ``node`` binds one signed number literal to, else None."""
250
+ if isinstance(node, ast.Assign):
251
+ all_targets, bound_value = node.targets, node.value
252
+ elif isinstance(node, ast.AnnAssign) and node.value is not None:
253
+ all_targets, bound_value = [node.target], node.value
254
+ else:
255
+ return None
256
+ if len(all_targets) != 1 or not isinstance(all_targets[0], ast.Name):
257
+ return None
258
+ while isinstance(bound_value, ast.UnaryOp) and isinstance(bound_value.op, (ast.USub, ast.UAdd)):
259
+ bound_value = bound_value.operand
260
+ is_number_literal = isinstance(bound_value, ast.Constant) and type(bound_value.value) in (int, float)
261
+ return all_targets[0].id if is_number_literal else None
262
+
263
+
151
264
  def check_magic_values(content: str, file_path: str) -> list[str]:
152
- """Check for magic values in function bodies."""
265
+ """Check for magic values in function bodies.
266
+
267
+ A line inside a function that binds a single number literal to a name
268
+ passes, since the name explains the number. A number inside a larger
269
+ expression fires.
270
+ """
153
271
  if is_config_file(file_path) or is_test_file(file_path):
154
272
  return []
155
273
 
156
274
  issues = []
157
275
  lines = content.split("\n")
158
276
  all_docstring_line_numbers = docstring_line_numbers(content)
277
+ all_named_number_line_numbers = _named_number_binding_line_numbers(content)
278
+ all_fragment_outer_scope_names = (
279
+ _fragment_global_and_nonlocal_names(content) if all_named_number_line_numbers is None else set()
280
+ )
159
281
  is_inside_function = False
282
+ function_indent = 0
160
283
 
161
284
  number_pattern = re.compile(r"(?<![.\w])(\d+\.?\d*)(?![.\w])")
162
285
  allowed_numbers = ALL_ALLOWED_MAGIC_NUMBER_LITERALS
@@ -172,6 +295,7 @@ def check_magic_values(content: str, file_path: str) -> list[str]:
172
295
 
173
296
  if re.match(r"^(async\s+)?def\s+\w+", stripped):
174
297
  is_inside_function = True
298
+ function_indent = len(each_line) - len(each_line.lstrip())
175
299
  continue
176
300
 
177
301
  if re.match(r"^class\s+\w+", stripped):
@@ -182,6 +306,15 @@ def check_magic_values(content: str, file_path: str) -> list[str]:
182
306
  if "=" in stripped and stripped.split("=")[0].strip().isupper():
183
307
  continue
184
308
 
309
+ if all_named_number_line_numbers is None:
310
+ is_named_number_line = _is_fragment_named_number_line(
311
+ lines, each_line_number, function_indent, all_fragment_outer_scope_names
312
+ )
313
+ else:
314
+ is_named_number_line = each_line_number in all_named_number_line_numbers
315
+ if is_named_number_line:
316
+ continue
317
+
185
318
  if stripped.startswith(("return", "yield", "raise")):
186
319
  continue
187
320
 
@@ -171,7 +171,7 @@ def test_gate_caller_scans_staged_content_with_commit_stage_scope(
171
171
  committed_source = "def calculate_total() -> int:\n return 0\n"
172
172
  staged_source = (
173
173
  "def calculate_total() -> int:\n"
174
- " total = 9999\n"
174
+ " total = abs(9999)\n"
175
175
  " return total\n"
176
176
  )
177
177
  file_path = write_commit_and_stage_change(
@@ -317,7 +317,7 @@ def test_should_block_second_identical_string_magic_per_occurrence() -> None:
317
317
  def test_should_surface_magic_when_gate_defers_scope_with_head_as_old() -> None:
318
318
  source = (
319
319
  "def compute() -> int:\n"
320
- " old_number = 9999\n"
320
+ " old_number = abs(9999)\n"
321
321
  " return old_number\n"
322
322
  )
323
323
  issues = code_rules_enforcer.validate_content_for_full_gate(
@@ -52,3 +52,165 @@ def test_magic_value_check_still_flags_real_magic_number_in_code_statement() ->
52
52
  assert any(issue.endswith("Magic value 42 - extract to named constant") for issue in issues), (
53
53
  f"Expected the real magic number 42 to stay flagged, got: {issues}"
54
54
  )
55
+
56
+
57
+ def _magic_issues_for_function_line(function_body_line: str) -> list[str]:
58
+ source = f"def backoff_seconds(attempt: int) -> float:\n {function_body_line}\n return attempt\n"
59
+ return check_magic_values(source, PRODUCTION_FILE_PATH)
60
+
61
+
62
+ def test_should_pass_a_number_bound_to_a_local_name() -> None:
63
+ assert _magic_issues_for_function_line("retry_backoff_factor = 2.5") == []
64
+
65
+
66
+ def test_should_pass_an_annotated_number_bound_to_a_local_name() -> None:
67
+ assert _magic_issues_for_function_line("header_row_count: int | None = 30") == []
68
+
69
+
70
+ def test_should_pass_a_named_number_with_a_trailing_comment() -> None:
71
+ assert _magic_issues_for_function_line("header_row_count = 30 # rows") == []
72
+
73
+
74
+ def test_should_pass_a_negative_number_bound_to_a_local_name() -> None:
75
+ assert _magic_issues_for_function_line("lowest_offset = -40") == []
76
+
77
+
78
+ def test_should_flag_a_number_inside_an_expression() -> None:
79
+ assert _magic_issues_for_function_line("backoff = attempt * 2.5") == [
80
+ "Line 2: Magic value 2.5 - extract to named constant"
81
+ ]
82
+
83
+
84
+ def test_should_flag_a_number_in_a_comparison_assignment() -> None:
85
+ assert _magic_issues_for_function_line("is_slow = attempt > 40") == [
86
+ "Line 2: Magic value 40 - extract to named constant"
87
+ ]
88
+
89
+
90
+ def test_should_flag_a_keyword_argument_on_its_own_line() -> None:
91
+ source = "def backoff_seconds(attempt: int) -> float:\n wait_for(\n attempt,\n timeout=30\n )\n return attempt\n"
92
+ assert check_magic_values(source, PRODUCTION_FILE_PATH) == [
93
+ "Line 4: Magic value 30 - extract to named constant"
94
+ ]
95
+
96
+
97
+ def test_should_flag_a_module_level_binding_after_a_function() -> None:
98
+ source = "def backoff_seconds(attempt: int) -> float:\n return attempt\nretry_limit = 30\n"
99
+ assert check_magic_values(source, PRODUCTION_FILE_PATH) == [
100
+ "Line 3: Magic value 30 - extract to named constant"
101
+ ]
102
+
103
+
104
+ def test_should_pass_a_positive_signed_number_bound_to_a_local_name() -> None:
105
+ assert _magic_issues_for_function_line("lowest_offset = +40") == []
106
+
107
+
108
+ def test_should_flag_a_number_sharing_a_line_with_a_named_binding() -> None:
109
+ assert _magic_issues_for_function_line("limit = 5; scaled = attempt * 99") == [
110
+ "Line 2: Magic value 5 - extract to named constant"
111
+ ]
112
+
113
+
114
+ def test_should_flag_two_named_bindings_sharing_one_line() -> None:
115
+ assert _magic_issues_for_function_line("low_offset = 30; high_offset = 40") == [
116
+ "Line 2: Magic value 30 - extract to named constant"
117
+ ]
118
+
119
+
120
+ def test_should_flag_a_condition_number_beside_a_named_binding() -> None:
121
+ assert _magic_issues_for_function_line("if attempt > 99: limit = 40") == [
122
+ "Line 2: Magic value 99 - extract to named constant"
123
+ ]
124
+
125
+
126
+ def test_should_pass_a_named_number_in_an_indented_method_fragment() -> None:
127
+ fragment = " def backoff_seconds(self) -> float:\n retry_backoff_factor = 2.5\n return retry_backoff_factor\n"
128
+ assert check_magic_values(fragment, PRODUCTION_FILE_PATH) == []
129
+
130
+
131
+ def test_should_pass_a_doubly_signed_number_bound_to_a_local_name() -> None:
132
+ assert _magic_issues_for_function_line("lowest_offset = --40") == []
133
+
134
+
135
+ def test_should_flag_a_keyword_argument_in_an_indented_method_fragment() -> None:
136
+ fragment = " def wait(self) -> None:\n wait_for(\n self,\n timeout=30\n )\n"
137
+ assert check_magic_values(fragment, PRODUCTION_FILE_PATH) == [
138
+ "Line 4: Magic value 30 - extract to named constant"
139
+ ]
140
+
141
+
142
+ def test_should_flag_a_module_level_binding_in_unparseable_content() -> None:
143
+ source = "def build() -> int:\n return 0\nretry_limit = 30\nprint 'x'\n"
144
+ assert check_magic_values(source, PRODUCTION_FILE_PATH) == [
145
+ "Line 3: Magic value 30 - extract to named constant"
146
+ ]
147
+
148
+
149
+ def test_should_flag_a_number_bound_to_a_global_name() -> None:
150
+ source = "retry_limit = 0\ndef raise_limit() -> None:\n global retry_limit\n retry_limit = 40\n"
151
+ assert check_magic_values(source, PRODUCTION_FILE_PATH) == [
152
+ "Line 4: Magic value 40 - extract to named constant"
153
+ ]
154
+
155
+
156
+ def test_should_pass_a_named_number_in_a_fragment_ending_inside_a_block() -> None:
157
+ source = "def backoff_seconds(attempt: int) -> float:\n retry_backoff_factor = 2.5\n if attempt > 0:\n"
158
+ assert check_magic_values(source, PRODUCTION_FILE_PATH) == []
159
+
160
+
161
+ def test_should_pass_a_named_number_in_a_method_fragment_with_a_column_zero_string() -> None:
162
+ source = (
163
+ " def backoff_seconds(self) -> float:\n"
164
+ " retry_backoff_factor = 2.5\n"
165
+ ' banner = """\n'
166
+ "hello\n"
167
+ '"""\n'
168
+ " return retry_backoff_factor\n"
169
+ )
170
+ assert check_magic_values(source, PRODUCTION_FILE_PATH) == []
171
+
172
+
173
+ def test_should_flag_a_global_name_binding_in_a_fragment_ending_inside_a_block() -> None:
174
+ source = "def raise_limit(attempt: int) -> None:\n global retry_limit\n retry_limit = 40\n if attempt > 0:\n"
175
+ assert check_magic_values(source, PRODUCTION_FILE_PATH) == [
176
+ "Line 3: Magic value 40 - extract to named constant"
177
+ ]
178
+
179
+
180
+ def test_should_flag_the_last_keyword_argument_in_a_fragment_ending_inside_a_block() -> None:
181
+ source = (
182
+ "def fetch(address: str) -> None:\n"
183
+ " response = send(\n"
184
+ " address,\n"
185
+ " timeout=30\n"
186
+ " )\n"
187
+ " if response:\n"
188
+ )
189
+ assert check_magic_values(source, PRODUCTION_FILE_PATH) == [
190
+ "Line 4: Magic value 30 - extract to named constant"
191
+ ]
192
+
193
+
194
+ def test_should_flag_each_name_of_consecutive_global_lines_in_a_fragment() -> None:
195
+ source = (
196
+ "def tally(attempt: int) -> None:\n"
197
+ " global total\n"
198
+ " global count\n"
199
+ " total = 7\n"
200
+ " if attempt > 0:\n"
201
+ )
202
+ assert check_magic_values(source, PRODUCTION_FILE_PATH) == [
203
+ "Line 4: Magic value 7 - extract to named constant"
204
+ ]
205
+
206
+
207
+ def test_should_flag_a_global_name_declared_beside_a_comment_in_a_fragment() -> None:
208
+ source = (
209
+ "def tally(attempt: int) -> None:\n"
210
+ " global total # shared\n"
211
+ " total = 7\n"
212
+ " if attempt > 0:\n"
213
+ )
214
+ assert check_magic_values(source, PRODUCTION_FILE_PATH) == [
215
+ "Line 3: Magic value 7 - extract to named constant"
216
+ ]
@@ -185,7 +185,7 @@ def _write_scoped_service_file_with_a_committed_baseline(repository_root: Path)
185
185
  repository_root,
186
186
  "service.py",
187
187
  "def read_port() -> int:\n return 0\n",
188
- "def read_port() -> int:\n port_number = 9999\n return port_number\n",
188
+ "def read_port() -> int:\n port_number = abs(9999)\n return port_number\n",
189
189
  )
190
190
 
191
191
 
@@ -9,6 +9,7 @@ Note: Only checks for magic numbers. Magic string detection is not implemented.
9
9
  import ast
10
10
  import re
11
11
  import sys
12
+ from collections import Counter
12
13
  from pathlib import Path
13
14
  from typing import Dict, FrozenSet, List, Set, Tuple, Type
14
15
 
@@ -16,7 +17,7 @@ from .exempt_paths import (
16
17
  is_config_file,
17
18
  is_test_file,
18
19
  )
19
- from .validator_base import Violation
20
+ from .validator_base import Violation, run_file_validator_cli
20
21
 
21
22
 
22
23
  ALLOWED_NUMBERS: FrozenSet[int] = frozenset({-1, 0, 1})
@@ -35,6 +36,8 @@ def check_magic_values(tree: ast.AST, filename: str) -> List[Violation]:
35
36
  violations: List[Violation] = []
36
37
  negated_literal_ids: Set[int] = set()
37
38
  parent_by_child_id = _build_parent_map(tree)
39
+ all_outer_scope_names = _global_and_nonlocal_names(tree)
40
+ all_shared_statement_lines = _lines_holding_several_statements(tree)
38
41
 
39
42
  for node in ast.walk(tree):
40
43
  if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub):
@@ -55,6 +58,10 @@ def check_magic_values(tree: ast.AST, filename: str) -> List[Violation]:
55
58
  continue
56
59
  if _is_in_constant_definition(outermost_unary, parent_by_child_id):
57
60
  continue
61
+ if _is_named_function_local_binding(
62
+ outermost_unary, parent_by_child_id, all_outer_scope_names, all_shared_statement_lines
63
+ ):
64
+ continue
58
65
  violations.append(
59
66
  Violation(
60
67
  filename,
@@ -74,6 +81,10 @@ def check_magic_values(tree: ast.AST, filename: str) -> List[Violation]:
74
81
  continue
75
82
  if _is_in_constant_definition(node, parent_by_child_id):
76
83
  continue
84
+ if _is_named_function_local_binding(
85
+ node, parent_by_child_id, all_outer_scope_names, all_shared_statement_lines
86
+ ):
87
+ continue
77
88
  violations.append(
78
89
  Violation(
79
90
  filename,
@@ -130,6 +141,70 @@ def _is_in_constant_definition(
130
141
  return False
131
142
 
132
143
 
144
+ def _global_and_nonlocal_names(tree: ast.AST) -> Set[str]:
145
+ """Return every name a ``global`` or ``nonlocal`` statement declares in the tree."""
146
+ return {
147
+ each_name
148
+ for each_node in ast.walk(tree)
149
+ if isinstance(each_node, (ast.Global, ast.Nonlocal))
150
+ for each_name in each_node.names
151
+ }
152
+
153
+
154
+ def _lines_holding_several_statements(tree: ast.AST) -> Set[int]:
155
+ """Return the line numbers where more than one statement starts."""
156
+ statement_count_by_line = Counter(
157
+ each_node.lineno for each_node in ast.walk(tree) if isinstance(each_node, ast.stmt)
158
+ )
159
+ return {each_line for each_line, each_count in statement_count_by_line.items() if each_count > 1}
160
+
161
+
162
+ def _is_named_function_local_binding(
163
+ node: ast.AST,
164
+ parent_by_child_id: Dict[int, ast.AST],
165
+ all_outer_scope_names: Set[str],
166
+ all_shared_statement_lines: Set[int],
167
+ ) -> bool:
168
+ """Return True when a function binds ``node`` alone to one plain name.
169
+
170
+ ``header_row_count = 30`` on one line inside a function passes, since
171
+ the name explains the number. A number inside an expression, a default
172
+ parameter, a keyword argument, a module-level or class-level binding, a
173
+ binding that shares its line with another statement, and a binding to a
174
+ name the tree declares ``global`` or ``nonlocal`` still fire.
175
+ """
176
+ bound_value: ast.AST = node
177
+ parent = parent_by_child_id.get(id(bound_value))
178
+ while isinstance(parent, ast.UnaryOp) and isinstance(parent.op, (ast.USub, ast.UAdd)):
179
+ bound_value = parent
180
+ parent = parent_by_child_id.get(id(bound_value))
181
+ if not isinstance(parent, (ast.Assign, ast.AnnAssign)) or parent.value is not bound_value:
182
+ return False
183
+ all_targets = parent.targets if isinstance(parent, ast.Assign) else [parent.target]
184
+ if len(all_targets) != 1 or not isinstance(all_targets[0], ast.Name):
185
+ return False
186
+ is_one_line = parent.lineno == parent.end_lineno
187
+ is_alone_on_line = parent.lineno not in all_shared_statement_lines
188
+ if not (is_one_line and is_alone_on_line) or all_targets[0].id in all_outer_scope_names:
189
+ return False
190
+ return _encloses_in_function(parent, parent_by_child_id)
191
+
192
+
193
+ def _encloses_in_function(
194
+ node: ast.AST,
195
+ parent_by_child_id: Dict[int, ast.AST],
196
+ ) -> bool:
197
+ """Return True when the nearest enclosing scope of ``node`` is a function."""
198
+ current_node: ast.AST = node
199
+ while id(current_node) in parent_by_child_id:
200
+ current_node = parent_by_child_id[id(current_node)]
201
+ if isinstance(current_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
202
+ return True
203
+ if isinstance(current_node, ast.ClassDef):
204
+ return False
205
+ return False
206
+
207
+
133
208
  def _is_upper_snake_constant_assignment(node: ast.AST) -> bool:
134
209
  if isinstance(node, ast.Assign):
135
210
  for target in node.targets:
@@ -164,17 +239,7 @@ def validate_file(file_path: Path) -> List[Violation]:
164
239
 
165
240
 
166
241
  def main() -> int:
167
- if len(sys.argv) < 2:
168
- return 1
169
-
170
- all_violations: List[Violation] = []
171
- for file_arg in sys.argv[1:]:
172
- all_violations.extend(validate_file(Path(file_arg)))
173
-
174
- for violation in all_violations:
175
- print(violation)
176
-
177
- return 1 if all_violations else 0
242
+ return run_file_validator_cli(validate_file)
178
243
 
179
244
 
180
245
  if __name__ == "__main__":
@@ -27,8 +27,8 @@ def process():
27
27
  '''
28
28
 
29
29
  BAD_MAGIC_NUMBER = '''
30
- def process():
31
- timeout = 5000
30
+ def process(attempts):
31
+ timeout = attempts * 5000
32
32
  return timeout
33
33
  '''
34
34
 
@@ -113,8 +113,8 @@ SETTINGS: dict[str, int] = {"timeout": 30}
113
113
  '''
114
114
 
115
115
  NON_CONSTANT_ASSIGNMENT_IN_FUNCTION = '''
116
- def configure():
117
- delay = 30
116
+ def configure(base):
117
+ delay = base + 30
118
118
  return delay
119
119
  '''
120
120
 
@@ -415,3 +415,87 @@ class TestValidateFilePathExemptions:
415
415
  violations = validate_file(production_path)
416
416
  assert len(violations) == 1
417
417
  assert "42" in violations[0].message
418
+
419
+
420
+ NAMED_FUNCTION_LOCAL_NUMBERS = """
421
+ def configure():
422
+ header_row_count: int = 30
423
+ lowest_offset = -40
424
+ highest_offset = +40
425
+ return header_row_count, lowest_offset, highest_offset
426
+ """
427
+
428
+ DEFAULT_PARAMETER_NUMBER = """
429
+ def configure(header_row_count=30):
430
+ return header_row_count
431
+ """
432
+
433
+ CLASS_LEVEL_NUMBER = """
434
+ class Settings:
435
+ header_row_count = 30
436
+ """
437
+
438
+
439
+ def test_should_pass_numbers_bound_alone_to_function_local_names() -> None:
440
+ assert check_magic_values(ast.parse(NAMED_FUNCTION_LOCAL_NUMBERS), "test.py") == []
441
+
442
+
443
+ def test_should_flag_a_default_parameter_number() -> None:
444
+ violations = check_magic_values(ast.parse(DEFAULT_PARAMETER_NUMBER), "test.py")
445
+ assert [violation.message for violation in violations] == ["Magic number 30 - use named constant"]
446
+
447
+
448
+ def test_should_flag_a_class_level_number() -> None:
449
+ violations = check_magic_values(ast.parse(CLASS_LEVEL_NUMBER), "test.py")
450
+ assert [violation.message for violation in violations] == ["Magic number 30 - use named constant"]
451
+
452
+
453
+ GLOBAL_NAME_NUMBER = """
454
+ def raise_limit():
455
+ global retry_limit
456
+ retry_limit = 40
457
+ """
458
+
459
+ MIXED_SIGN_NAMED_LOCAL = """
460
+ def configure():
461
+ lowest_offset = -+40
462
+ return lowest_offset
463
+ """
464
+
465
+
466
+ def test_should_flag_a_number_bound_to_a_global_name() -> None:
467
+ violations = check_magic_values(ast.parse(GLOBAL_NAME_NUMBER), "test.py")
468
+ assert [violation.message for violation in violations] == ["Magic number 40 - use named constant"]
469
+
470
+
471
+ def test_should_pass_a_mixed_sign_number_bound_to_a_local_name() -> None:
472
+ assert check_magic_values(ast.parse(MIXED_SIGN_NAMED_LOCAL), "test.py") == []
473
+
474
+
475
+ MULTI_LINE_NAMED_LOCAL = """
476
+ def configure():
477
+ header_row_count = (
478
+ 30
479
+ )
480
+ return header_row_count
481
+ """
482
+
483
+
484
+ def test_should_flag_a_number_bound_across_several_lines() -> None:
485
+ violations = check_magic_values(ast.parse(MULTI_LINE_NAMED_LOCAL), "test.py")
486
+ assert [violation.message for violation in violations] == ["Magic number 30 - use named constant"]
487
+
488
+
489
+ SHARED_LINE_NAMED_LOCALS = """
490
+ def configure():
491
+ low_offset = 30; high_offset = 40
492
+ return low_offset, high_offset
493
+ """
494
+
495
+
496
+ def test_should_flag_numbers_bound_on_a_line_shared_with_another_statement() -> None:
497
+ violations = check_magic_values(ast.parse(SHARED_LINE_NAMED_LOCALS), "test.py")
498
+ assert sorted(violation.message for violation in violations) == [
499
+ "Magic number 30 - use named constant",
500
+ "Magic number 40 - use named constant",
501
+ ]
@@ -458,7 +458,7 @@ class TestBaselineScopedGate:
458
458
  "tool_input": {
459
459
  "file_path": str(target_file),
460
460
  "old_string": " return 199\n",
461
- "new_string": " threshold = 42\n return 199 + threshold\n",
461
+ "new_string": " threshold = abs(42)\n return 199 + threshold\n",
462
462
  },
463
463
  }
464
464
  )
@@ -1,8 +1,10 @@
1
1
  """Tests for TODO/FIXME detection."""
2
2
 
3
+ from pathlib import Path
4
+
3
5
  import pytest
4
6
 
5
- from .todo_checks import check_untracked_todos
7
+ from .todo_checks import check_untracked_todos, main
6
8
  from .validator_base import Violation
7
9
 
8
10
 
@@ -38,3 +40,13 @@ class TestUntrackedTodos:
38
40
  code = "# FIXME: broken\ndef foo(): pass"
39
41
  violations = check_untracked_todos(code, "test.py")
40
42
  assert len(violations) == 1
43
+
44
+
45
+ def test_main_should_report_an_untracked_todo_in_a_named_file(
46
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
47
+ ) -> None:
48
+ source_path = tmp_path / "worker.py"
49
+ source_path.write_text("def run():\n pass # TODO tidy up\n", encoding="utf-8")
50
+ monkeypatch.setattr("sys.argv", ["todo_checks", str(source_path)])
51
+ assert main() == 1
52
+ assert capsys.readouterr().out.startswith(f"{source_path}:2:")
@@ -5,7 +5,7 @@ from pathlib import Path
5
5
 
6
6
  import pytest
7
7
 
8
- from .validator_base import Violation, source_text, syntax_tree
8
+ from .validator_base import Violation, run_file_validator_cli, source_text, syntax_tree
9
9
 
10
10
 
11
11
  class TestViolation:
@@ -73,3 +73,21 @@ def test_syntax_tree_raises_syntax_error_for_unparseable_source() -> None:
73
73
  """A caller's own SyntaxError branch still fires."""
74
74
  with pytest.raises(SyntaxError):
75
75
  syntax_tree("def broken(:\n")
76
+
77
+
78
+ def _flag_every_file(file_path: Path) -> list[Violation]:
79
+ return [Violation(file=str(file_path), line=3, message="flagged")]
80
+
81
+
82
+ def test_run_file_validator_cli_should_print_violations_and_fail(capsys: pytest.CaptureFixture[str]) -> None:
83
+ exit_code = run_file_validator_cli(_flag_every_file, ["validator", "a.py", "b.py"])
84
+ assert exit_code == 1
85
+ assert capsys.readouterr().out == "a.py:3: flagged\nb.py:3: flagged\n"
86
+
87
+
88
+ def test_run_file_validator_cli_should_pass_clean_files() -> None:
89
+ assert run_file_validator_cli(lambda file_path: [], ["validator", "a.py"]) == 0
90
+
91
+
92
+ def test_run_file_validator_cli_should_fail_with_no_file_named() -> None:
93
+ assert run_file_validator_cli(_flag_every_file, ["validator"]) == 1
@@ -8,7 +8,7 @@ import sys
8
8
  from pathlib import Path
9
9
  from typing import List
10
10
 
11
- from .validator_base import Violation, source_text
11
+ from .validator_base import Violation, run_file_validator_cli, source_text
12
12
 
13
13
 
14
14
  TODO_PATTERN = re.compile(r"#\s*(TODO|FIXME)\b(?!.*#\d+)", re.IGNORECASE)
@@ -42,17 +42,7 @@ def validate_file(file_path: Path) -> List[Violation]:
42
42
 
43
43
 
44
44
  def main() -> int:
45
- if len(sys.argv) < 2:
46
- return 1
47
-
48
- all_violations: List[Violation] = []
49
- for file_arg in sys.argv[1:]:
50
- all_violations.extend(validate_file(Path(file_arg)))
51
-
52
- for violation in all_violations:
53
- print(violation)
54
-
55
- return 1 if all_violations else 0
45
+ return run_file_validator_cli(validate_file)
56
46
 
57
47
 
58
48
  if __name__ == "__main__":
@@ -1,10 +1,13 @@
1
1
  """Base classes for validators.
2
2
 
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.
3
+ Provides shared dataclasses used across all validator modules, the one
4
+ read and the one parse each of those modules shares for a given file, and
5
+ the command-line runner a validator module's ``main`` can call.
5
6
  """
6
7
 
7
8
  import ast
9
+ import sys
10
+ from collections.abc import Callable, Sequence
8
11
  from dataclasses import dataclass
9
12
  from functools import lru_cache
10
13
  from pathlib import Path
@@ -89,3 +92,31 @@ def syntax_tree(source: str) -> ast.Module:
89
92
  The parsed module.
90
93
  """
91
94
  return ast.parse(source)
95
+
96
+
97
+ def run_file_validator_cli(
98
+ validate_file: Callable[[Path], list[Violation]],
99
+ all_arguments: Sequence[str] | None = None,
100
+ ) -> int:
101
+ """Validate each file named on the command line and print its violations.
102
+
103
+ ``python -m validators.todo_checks a.py b.py`` prints one line per
104
+ violation and exits 1 when any file has one.
105
+
106
+ Args:
107
+ validate_file: The module's per-file check.
108
+ all_arguments: The command-line arguments, program name first.
109
+ Defaults to ``sys.argv``.
110
+
111
+ Returns:
112
+ 1 with no file named or any violation found, else 0.
113
+ """
114
+ all_file_arguments = list(sys.argv if all_arguments is None else all_arguments)[1:]
115
+ if not all_file_arguments:
116
+ return 1
117
+ all_violations: list[Violation] = []
118
+ for each_file_argument in all_file_arguments:
119
+ all_violations.extend(validate_file(Path(each_file_argument)))
120
+ for each_violation in all_violations:
121
+ sys.stdout.write(f"{each_violation}\n")
122
+ return 1 if all_violations else 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.106.3",
3
+ "version": "8.106.5",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -61,6 +61,9 @@ ALL_CHECK_CATALOG_ENTRIES: tuple[CheckCatalogEntry, ...] = (
61
61
  "paired-test-missing-function",
62
62
  ),
63
63
  CheckCatalogEntry("but exercises nowhere", "paired-test-omitted-function"),
64
+ CheckCatalogEntry(") - consider splitting", "file-length"),
65
+ CheckCatalogEntry(" lines (max ", "function-line-cap"),
66
+ CheckCatalogEntry(" has nesting depth ", "nesting-depth"),
64
67
  )
65
68
 
66
69
  SEVERITY_BREAKING: str = "breaking"
@@ -68,5 +71,8 @@ SEVERITY_SMELL: str = "smell"
68
71
  SEVERITY_BY_CHECK_ID: dict[str, str] = {
69
72
  "code-rules/paired-test-missing-function": SEVERITY_SMELL,
70
73
  "code-rules/paired-test-omitted-function": SEVERITY_SMELL,
74
+ "validators/file-length": SEVERITY_SMELL,
75
+ "validators/function-line-cap": SEVERITY_SMELL,
76
+ "validators/nesting-depth": SEVERITY_SMELL,
71
77
  "test-pairing": SEVERITY_SMELL,
72
78
  }
@@ -18,6 +18,7 @@ from policy_lint.config.check_catalog_constants import (
18
18
  from policy_lint.model import Diagnostic, Severity
19
19
 
20
20
  _BUNDLING_RULE_ID = "code-rules"
21
+ _VALIDATORS_RULE_ID = "validators"
21
22
  _HOOKS_DIRECTORY = Path(__file__).resolve().parents[2] / "hooks"
22
23
  _TEST_FILE_PREFIX = "test_"
23
24
  _PYTHON_GLOB = "*.py"
@@ -82,6 +83,9 @@ ALL_MESSAGE_SAMPLES: tuple[tuple[str, str], ...] = (
82
83
  " already covers another public function in that module",
83
84
  "paired-test-omitted-function",
84
85
  ),
86
+ ("src/app.py:1: File is 412 lines (max 400) - consider splitting", "file-length"),
87
+ ("src/app.py:4: Function 'run' is 37 lines (max 30)", "function-line-cap"),
88
+ ("src/app.py:9: Function 'run' has nesting depth 3 (max 2)", "nesting-depth"),
85
89
  )
86
90
 
87
91
 
@@ -169,6 +173,23 @@ def test_a_paired_test_check_reports_as_a_warning(check_name: str) -> None:
169
173
  assert severity_for_check_id(f"{_BUNDLING_RULE_ID}/{check_name}") is Severity.WARNING
170
174
 
171
175
 
176
+ @pytest.mark.parametrize(
177
+ ("sample_message", "check_name"),
178
+ tuple(
179
+ each_sample
180
+ for each_sample in ALL_MESSAGE_SAMPLES
181
+ if each_sample[1] in ("file-length", "function-line-cap", "nesting-depth")
182
+ ),
183
+ )
184
+ def test_a_validator_smell_reports_as_a_warning(
185
+ sample_message: str, check_name: str
186
+ ) -> None:
187
+ check_id = check_id_for_message(_VALIDATORS_RULE_ID, sample_message)
188
+
189
+ assert check_id == f"{_VALIDATORS_RULE_ID}/{check_name}"
190
+ assert severity_for_check_id(check_id) is Severity.WARNING
191
+
192
+
172
193
  def test_an_unclassified_check_reports_as_an_error() -> None:
173
194
  unclassified_check_id = f"{_BUNDLING_RULE_ID}/{UNCLASSIFIED_CHECK_NAME}"
174
195