claude-dev-env 1.69.0 → 1.69.2
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.
- package/hooks/blocking/code_rules_dead_config_field.py +284 -50
- package/hooks/blocking/code_rules_enforcer.py +5 -0
- package/hooks/blocking/code_rules_shared.py +47 -0
- package/hooks/blocking/tdd_enforcer.py +7 -2
- package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +336 -3
- package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +383 -0
- package/hooks/blocking/test_tdd_enforcer.py +106 -1
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
- package/package.json +1 -1
|
@@ -20,10 +20,23 @@ The scan is deliberately conservative to keep false positives near zero:
|
|
|
20
20
|
- Field reads are collected as ``ast.Attribute.attr`` values (``obj.field``),
|
|
21
21
|
augmented-assignment targets (``cfg.field += 1`` reads ``field`` before
|
|
22
22
|
writing it), string literals (covers ``getattr(obj, "field")``),
|
|
23
|
-
keyword-argument names (covers
|
|
23
|
+
keyword-argument names on non-constructor calls (covers
|
|
24
24
|
``replace(cfg, debug_port=1)``), and match-pattern keyword attribute names
|
|
25
|
-
(``case Config(field=found)``).
|
|
26
|
-
|
|
25
|
+
(``case Config(field=found)``). Two field-write forms are excluded because they
|
|
26
|
+
name a field without consuming it: a keyword that writes a field in a constructor
|
|
27
|
+
of a known ``*Config`` dataclass defined under the scan root
|
|
28
|
+
(``ThemeUpdateConfig(debug_port=1)``, excluded per keyword node so a same-named
|
|
29
|
+
keyword on a ``replace`` call stays a read, and a factory function whose name
|
|
30
|
+
merely ends in ``"Config"`` is not excluded), and a self-referential attribute
|
|
31
|
+
read inside a ``*Config`` field's own default-value expression in the class body
|
|
32
|
+
whose attribute name equals the field being defined
|
|
33
|
+
(``debug_port: int = source.debug_port``) — a field written only these ways and
|
|
34
|
+
read by no module is the dead-config case this check exists to catch. A default
|
|
35
|
+
that sources a differently-named field on another object
|
|
36
|
+
(``timeout_ms: int = other_config.base_timeout``) leaves that read counted, so
|
|
37
|
+
``base_timeout`` stays a live consumer. Plain
|
|
38
|
+
``ast.Name`` references are excluded — a local variable named ``debug_port`` is
|
|
39
|
+
not a read of ``config.debug_port``.
|
|
27
40
|
- A production module that reflectively reads a whole instance — a bare or
|
|
28
41
|
``dataclasses``-qualified call to ``asdict``, ``astuple``, ``fields``,
|
|
29
42
|
``replace``, or ``vars``, or a read of ``obj.__dict__`` — consumes every field
|
|
@@ -50,6 +63,7 @@ before writing it) is precise and remains a counted read.
|
|
|
50
63
|
import ast
|
|
51
64
|
import os
|
|
52
65
|
import sys
|
|
66
|
+
from collections.abc import Iterator
|
|
53
67
|
from pathlib import Path
|
|
54
68
|
|
|
55
69
|
_blocking_directory = str(Path(__file__).resolve().parent)
|
|
@@ -137,79 +151,242 @@ def _reads_whole_instance_reflectively(tree: ast.Module) -> bool:
|
|
|
137
151
|
return False
|
|
138
152
|
|
|
139
153
|
|
|
140
|
-
def
|
|
141
|
-
"""Return
|
|
154
|
+
def _config_dataclass_names(tree: ast.Module) -> set[str]:
|
|
155
|
+
"""Return names of ``*Config`` ``@dataclass`` classes defined in a module.
|
|
156
|
+
|
|
157
|
+
A constructor-keyword exclusion fires only for a callee that names a genuine
|
|
158
|
+
config dataclass, so the caller first gathers the config dataclass names a
|
|
159
|
+
module defines, then unions those names across the scan root.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
tree: The parsed module to inspect.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Every class name in the module that is a ``@dataclass`` whose name ends in
|
|
166
|
+
the config class name suffix.
|
|
167
|
+
"""
|
|
168
|
+
config_dataclass_names: set[str] = set()
|
|
169
|
+
for each_node in ast.walk(tree):
|
|
170
|
+
if isinstance(each_node, ast.ClassDef) and _is_config_dataclass(each_node):
|
|
171
|
+
config_dataclass_names.add(each_node.name)
|
|
172
|
+
return config_dataclass_names
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _call_constructs_config_class(
|
|
176
|
+
call_node: ast.Call, all_known_config_class_names: set[str]
|
|
177
|
+
) -> bool:
|
|
178
|
+
"""Return whether a call constructs a known ``*Config`` dataclass.
|
|
179
|
+
|
|
180
|
+
A call whose callee names a ``*Config`` dataclass defined under the scan root —
|
|
181
|
+
``AppInfoConfig(...)`` or a qualified ``module.AppInfoConfig(...)`` —
|
|
182
|
+
constructs a config instance, and its keyword arguments write the named fields
|
|
183
|
+
rather than read them. A factory function whose name merely ends in
|
|
184
|
+
``"Config"`` (``getThemeConfig(...)``) is not a known config dataclass, so its
|
|
185
|
+
keyword arguments stay genuine reads.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
call_node: The call expression to test.
|
|
189
|
+
all_known_config_class_names: Names of ``*Config`` dataclasses defined under
|
|
190
|
+
the scan root.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
True when the callee names a known ``*Config`` dataclass.
|
|
194
|
+
"""
|
|
195
|
+
callee_node = call_node.func
|
|
196
|
+
if isinstance(callee_node, ast.Name):
|
|
197
|
+
return callee_node.id in all_known_config_class_names
|
|
198
|
+
if isinstance(callee_node, ast.Attribute):
|
|
199
|
+
return callee_node.attr in all_known_config_class_names
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _config_constructor_keyword_node_ids(
|
|
204
|
+
tree: ast.Module, all_known_config_class_names: set[str]
|
|
205
|
+
) -> set[int]:
|
|
206
|
+
"""Return ids of keyword nodes that write fields in a known ``*Config`` constructor.
|
|
207
|
+
|
|
208
|
+
A keyword in an ``AppInfoConfig(field=value)`` call sets ``field`` rather than
|
|
209
|
+
reading it, so its node id is collected for the caller to exclude. The
|
|
210
|
+
exclusion is keyed per keyword node, not by name, so a same-named keyword in a
|
|
211
|
+
``replace(cfg, field=value)`` call — which reuses a live instance and stays a
|
|
212
|
+
read — keeps its own distinct node and is not stripped.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
tree: The parsed module to inspect.
|
|
216
|
+
all_known_config_class_names: Names of ``*Config`` dataclasses defined under
|
|
217
|
+
the scan root.
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
The ``id()`` of every keyword node passed to a known ``*Config``
|
|
221
|
+
constructor call.
|
|
222
|
+
"""
|
|
223
|
+
constructor_keyword_node_ids: set[int] = set()
|
|
224
|
+
for each_node in ast.walk(tree):
|
|
225
|
+
if not isinstance(each_node, ast.Call):
|
|
226
|
+
continue
|
|
227
|
+
if not _call_constructs_config_class(each_node, all_known_config_class_names):
|
|
228
|
+
continue
|
|
229
|
+
for each_keyword in each_node.keywords:
|
|
230
|
+
if each_keyword.arg is not None:
|
|
231
|
+
constructor_keyword_node_ids.add(id(each_keyword))
|
|
232
|
+
return constructor_keyword_node_ids
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _self_referential_default_attribute_node_ids(
|
|
236
|
+
field_name: str, default_value: ast.expr
|
|
237
|
+
) -> set[int]:
|
|
238
|
+
"""Return ids of attribute reads in a default whose name equals the field.
|
|
239
|
+
|
|
240
|
+
Walks a ``*Config`` field's default-value expression and collects the ``id()``
|
|
241
|
+
of each ``ast.Attribute`` read whose ``.attr`` equals ``field_name``. Such a
|
|
242
|
+
read — ``sound_upload_timeout_ms: int = submission_timing.sound_upload_timeout_ms``
|
|
243
|
+
— names the field being defined inside the class body, so it is not a consumer
|
|
244
|
+
of the field. An attribute read of a differently-named field
|
|
245
|
+
(``timeout_ms: int = other_config.base_timeout``) is a genuine consumer and is
|
|
246
|
+
left out of the returned set.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
field_name: The name of the field being defined.
|
|
250
|
+
default_value: The default-value expression of that field.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
The ``id()`` of every self-referential attribute read inside the
|
|
254
|
+
default-value expression.
|
|
255
|
+
"""
|
|
256
|
+
self_referential_node_ids: set[int] = set()
|
|
257
|
+
for each_inner_node in ast.walk(default_value):
|
|
258
|
+
if isinstance(each_inner_node, ast.Attribute) and each_inner_node.attr == field_name:
|
|
259
|
+
self_referential_node_ids.add(id(each_inner_node))
|
|
260
|
+
return self_referential_node_ids
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _config_field_default_value_nodes(tree: ast.Module) -> set[int]:
|
|
264
|
+
"""Return ids of self-referential attribute reads in ``*Config`` field defaults.
|
|
265
|
+
|
|
266
|
+
A field default such as ``sound_upload_timeout_ms: int =``
|
|
267
|
+
``submission_timing.sound_upload_timeout_ms`` is an attribute read whose name
|
|
268
|
+
matches the field being defined. That self-referential read inside the config
|
|
269
|
+
class body is not a consumer of the field, so its node id is collected here for
|
|
270
|
+
the caller to exclude from the attribute-read set. Only the attribute read
|
|
271
|
+
whose ``.attr`` equals the field name is collected; a default that sources a
|
|
272
|
+
differently-named field on another object
|
|
273
|
+
(``timeout_ms: int = other_config.base_timeout``) leaves that read counted, so
|
|
274
|
+
``base_timeout`` stays a live consumer.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
tree: The parsed module to inspect.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
The ``id()`` of every self-referential attribute read within the
|
|
281
|
+
default-value expression of a field declared in a ``*Config`` dataclass
|
|
282
|
+
body.
|
|
283
|
+
"""
|
|
284
|
+
default_value_node_ids: set[int] = set()
|
|
285
|
+
for each_node in ast.walk(tree):
|
|
286
|
+
if not isinstance(each_node, ast.ClassDef) or not _is_config_dataclass(each_node):
|
|
287
|
+
continue
|
|
288
|
+
for each_statement in each_node.body:
|
|
289
|
+
if not isinstance(each_statement, ast.AnnAssign):
|
|
290
|
+
continue
|
|
291
|
+
if each_statement.value is None:
|
|
292
|
+
continue
|
|
293
|
+
if not isinstance(each_statement.target, ast.Name):
|
|
294
|
+
continue
|
|
295
|
+
default_value_node_ids |= _self_referential_default_attribute_node_ids(
|
|
296
|
+
each_statement.target.id, each_statement.value
|
|
297
|
+
)
|
|
298
|
+
return default_value_node_ids
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _attribute_read_names_in_tree(
|
|
302
|
+
tree: ast.Module, all_known_config_class_names: set[str]
|
|
303
|
+
) -> tuple[set[str], bool]:
|
|
304
|
+
"""Return attribute names read in a parsed module and a suppression flag.
|
|
142
305
|
|
|
143
306
|
Collects attribute names via five mechanisms: ``ast.Attribute.attr`` values
|
|
144
307
|
in Load context, augmented-assignment targets (so ``cfg.debug_port += 1``
|
|
145
308
|
contributes ``"debug_port"`` because ``+=`` reads the attribute before
|
|
146
309
|
writing it), string literals (so ``getattr(obj, "field")`` contributes
|
|
147
|
-
``"field"``), keyword-argument names (so ``
|
|
148
|
-
|
|
149
|
-
``
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
310
|
+
``"field"``), keyword-argument names (so ``replace(cfg, debug_port=1)``
|
|
311
|
+
contributes ``"debug_port"``), and ``ast.MatchClass.kwd_attrs`` names (so
|
|
312
|
+
``case Config(field=x)`` contributes ``"field"``). Two field-write forms are
|
|
313
|
+
excluded because they name a field without consuming it: a keyword that writes
|
|
314
|
+
a field in a known ``*Config`` constructor (``AppInfoConfig(field=value)``,
|
|
315
|
+
excluded per keyword node so a same-named ``replace`` keyword stays a read),
|
|
316
|
+
and a self-referential attribute read inside a ``*Config`` dataclass field's
|
|
317
|
+
own default-value expression (``field: int = source.field`` in the class body,
|
|
318
|
+
excluded only when the read name equals the field name) — counting either
|
|
319
|
+
would hide a field that is written but read by no module. A keyword passed to a
|
|
320
|
+
factory function whose name merely ends in ``"Config"`` is not a known config
|
|
321
|
+
constructor, so it stays a read. The boolean reports whether the module
|
|
322
|
+
suppresses the dead-field check, which it does only when it reflectively reads a
|
|
323
|
+
whole instance — a bare or ``dataclasses``-qualified
|
|
324
|
+
``asdict``/``astuple``/``fields``/``replace``/``vars`` call, or an
|
|
325
|
+
``obj.__dict__`` read — because that pattern reads every field at once without
|
|
326
|
+
naming any single field, so the caller treats it as "cannot prove any field
|
|
327
|
+
dead".
|
|
157
328
|
|
|
158
329
|
Args:
|
|
159
|
-
|
|
330
|
+
tree: The parsed module to inspect.
|
|
331
|
+
all_known_config_class_names: Names of ``*Config`` dataclasses defined under
|
|
332
|
+
the scan root, used to scope the constructor-keyword exclusion to
|
|
333
|
+
genuine config constructors.
|
|
160
334
|
|
|
161
335
|
Returns:
|
|
162
336
|
A (read_names, suppresses_dead_field_check) pair. The name set is every
|
|
163
|
-
attribute name the module reads via
|
|
164
|
-
|
|
165
|
-
|
|
337
|
+
attribute name the module reads via the mechanisms above, excluding known
|
|
338
|
+
``*Config`` constructor keyword nodes and self-referential config-field
|
|
339
|
+
default-value attribute reads; suppresses_dead_field_check is True only when
|
|
340
|
+
a reflective whole-instance read is present.
|
|
166
341
|
"""
|
|
167
|
-
try:
|
|
168
|
-
tree = ast.parse(source)
|
|
169
|
-
except SyntaxError:
|
|
170
|
-
return set(), False
|
|
171
342
|
all_read_names: set[str] = _augmented_assignment_attribute_names(tree)
|
|
343
|
+
config_constructor_keyword_node_ids = _config_constructor_keyword_node_ids(
|
|
344
|
+
tree, all_known_config_class_names
|
|
345
|
+
)
|
|
346
|
+
config_field_default_node_ids = _config_field_default_value_nodes(tree)
|
|
172
347
|
for each_node in ast.walk(tree):
|
|
173
|
-
if
|
|
348
|
+
if (
|
|
349
|
+
isinstance(each_node, ast.Attribute)
|
|
350
|
+
and isinstance(each_node.ctx, ast.Load)
|
|
351
|
+
and id(each_node) not in config_field_default_node_ids
|
|
352
|
+
):
|
|
174
353
|
all_read_names.add(each_node.attr)
|
|
175
354
|
elif isinstance(each_node, ast.Constant) and isinstance(each_node.value, str):
|
|
176
355
|
all_read_names.add(each_node.value)
|
|
177
356
|
elif isinstance(each_node, ast.MatchClass):
|
|
178
357
|
all_read_names.update(each_node.kwd_attrs)
|
|
179
|
-
elif
|
|
358
|
+
elif (
|
|
359
|
+
isinstance(each_node, ast.keyword)
|
|
360
|
+
and each_node.arg is not None
|
|
361
|
+
and id(each_node) not in config_constructor_keyword_node_ids
|
|
362
|
+
):
|
|
180
363
|
all_read_names.add(each_node.arg)
|
|
181
364
|
suppresses_dead_field_check = _reads_whole_instance_reflectively(tree)
|
|
182
365
|
return all_read_names, suppresses_dead_field_check
|
|
183
366
|
|
|
184
367
|
|
|
185
|
-
def
|
|
368
|
+
def _iter_production_module_sources(
|
|
186
369
|
scan_root: Path,
|
|
187
370
|
written_path: Path,
|
|
188
371
|
written_content: str,
|
|
189
|
-
) ->
|
|
190
|
-
"""
|
|
372
|
+
) -> Iterator[str | None]:
|
|
373
|
+
"""Yield the source of each production module under the scan root.
|
|
191
374
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
``replace``/``vars`` call, or an ``obj.__dict__`` read — sets the suppression
|
|
198
|
-
flag, signalling the caller that no field can be proven dead.
|
|
375
|
+
Yields ``written_content`` for the written module so the current edit is
|
|
376
|
+
included, then each sibling production module's source (excluding test and
|
|
377
|
+
migration files). A sibling whose text cannot be read is skipped. A single
|
|
378
|
+
``None`` is yielded when the production module count exceeds the configured
|
|
379
|
+
file cap, signalling the caller that no field can be proven dead.
|
|
199
380
|
|
|
200
381
|
Args:
|
|
201
382
|
scan_root: The directory tree to scan.
|
|
202
383
|
written_path: The resolved path of the module being written.
|
|
203
384
|
written_content: The post-edit text of the written module.
|
|
204
385
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
set is the union of attribute reads across every scanned production
|
|
208
|
-
module; cap_was_hit is True when the scan stopped at the configured file
|
|
209
|
-
cap before finishing the tree; suppresses_dead_field_check is True when
|
|
210
|
-
any scanned module reflectively reads a whole instance.
|
|
386
|
+
Yields:
|
|
387
|
+
Each production module's source text, or a single ``None`` on a cap hit.
|
|
211
388
|
"""
|
|
212
|
-
|
|
389
|
+
yield written_content
|
|
213
390
|
written_path_key = os.path.normcase(str(written_path))
|
|
214
391
|
scanned_file_count = 1
|
|
215
392
|
for each_path in scan_root.rglob("*" + PYTHON_SOURCE_SUFFIX):
|
|
@@ -223,17 +400,71 @@ def _all_production_read_names_under_root(
|
|
|
223
400
|
continue
|
|
224
401
|
scanned_file_count += 1
|
|
225
402
|
if scanned_file_count > MAX_SCAN_ROOT_FILE_COUNT:
|
|
226
|
-
|
|
403
|
+
yield None
|
|
404
|
+
return
|
|
227
405
|
try:
|
|
228
|
-
|
|
406
|
+
yield each_path.read_text(encoding="utf-8")
|
|
229
407
|
except (OSError, UnicodeDecodeError):
|
|
230
408
|
continue
|
|
231
|
-
|
|
232
|
-
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _all_production_read_names_under_root(
|
|
412
|
+
scan_root: Path,
|
|
413
|
+
written_path: Path,
|
|
414
|
+
written_content: str,
|
|
415
|
+
) -> tuple[set[str], bool, bool]:
|
|
416
|
+
"""Return read names, a cap-hit flag, and a suppression flag for the tree.
|
|
417
|
+
|
|
418
|
+
Reads and AST-parses every production ``.py`` module under ``scan_root``
|
|
419
|
+
(excluding test and migration files) at most once: the module sources are
|
|
420
|
+
materialized once, a first pass over the cached sources gathers every
|
|
421
|
+
``*Config`` dataclass name defined under the root so the constructor-keyword
|
|
422
|
+
exclusion fires only for a genuine config constructor, and a second pass over
|
|
423
|
+
the same cached sources collects attribute reads. The written module's
|
|
424
|
+
post-edit content replaces its on-disk text so the current edit is included.
|
|
425
|
+
Scanning stops at the configured file cap. A module that reflectively reads a
|
|
426
|
+
whole instance — a bare or ``dataclasses``-qualified
|
|
427
|
+
``asdict``/``astuple``/``fields``/``replace``/``vars`` call, or an
|
|
428
|
+
``obj.__dict__`` read — sets the suppression flag, signalling the caller that
|
|
429
|
+
no field can be proven dead.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
scan_root: The directory tree to scan.
|
|
433
|
+
written_path: The resolved path of the module being written.
|
|
434
|
+
written_content: The post-edit text of the written module.
|
|
435
|
+
|
|
436
|
+
Returns:
|
|
437
|
+
A (read_names, cap_was_hit, suppresses_dead_field_check) triple. The name
|
|
438
|
+
set is the union of attribute reads across every scanned production
|
|
439
|
+
module; cap_was_hit is True when the scan stopped at the configured file
|
|
440
|
+
cap before finishing the tree; suppresses_dead_field_check is True when
|
|
441
|
+
any scanned module reflectively reads a whole instance.
|
|
442
|
+
"""
|
|
443
|
+
all_module_sources = list(
|
|
444
|
+
_iter_production_module_sources(scan_root, written_path, written_content)
|
|
445
|
+
)
|
|
446
|
+
if None in all_module_sources:
|
|
447
|
+
return set(), True, False
|
|
448
|
+
all_production_trees: list[ast.Module] = []
|
|
449
|
+
for each_source in all_module_sources:
|
|
450
|
+
if each_source is None:
|
|
451
|
+
continue
|
|
452
|
+
try:
|
|
453
|
+
all_production_trees.append(ast.parse(each_source))
|
|
454
|
+
except SyntaxError:
|
|
455
|
+
continue
|
|
456
|
+
all_known_config_class_names: set[str] = set()
|
|
457
|
+
for each_tree in all_production_trees:
|
|
458
|
+
all_known_config_class_names |= _config_dataclass_names(each_tree)
|
|
459
|
+
all_read_names: set[str] = set()
|
|
460
|
+
suppresses_dead_field_check = False
|
|
461
|
+
for each_tree in all_production_trees:
|
|
462
|
+
module_read_names, module_suppresses_dead_field_check = _attribute_read_names_in_tree(
|
|
463
|
+
each_tree, all_known_config_class_names
|
|
233
464
|
)
|
|
234
|
-
all_read_names |=
|
|
465
|
+
all_read_names |= module_read_names
|
|
235
466
|
suppresses_dead_field_check = (
|
|
236
|
-
suppresses_dead_field_check or
|
|
467
|
+
suppresses_dead_field_check or module_suppresses_dead_field_check
|
|
237
468
|
)
|
|
238
469
|
return all_read_names, False, suppresses_dead_field_check
|
|
239
470
|
|
|
@@ -247,9 +478,12 @@ def check_dead_config_dataclass_fields(
|
|
|
247
478
|
in ``"Config"``. For each such config dataclass in the written file, every
|
|
248
479
|
instance field whose name does not appear as an attribute read (``obj.field``),
|
|
249
480
|
augmented-assignment target (``cfg.field += 1``), string literal,
|
|
250
|
-
keyword-argument name (
|
|
481
|
+
non-constructor keyword-argument name (``replace`` keyword), or match-pattern
|
|
251
482
|
keyword attribute in any production module under the enclosing scan root is
|
|
252
|
-
flagged as dead.
|
|
483
|
+
flagged as dead. A keyword that writes a field in a ``*Config`` constructor
|
|
484
|
+
(``ThemeUpdateConfig(debug_port=1)``) is a write, not a read, so it does not
|
|
485
|
+
clear a field — a field set by a constructor keyword and read by no module is
|
|
486
|
+
flagged. When any production module under the scan root reflectively
|
|
253
487
|
reads a whole instance — a bare or ``dataclasses``-qualified call to
|
|
254
488
|
``asdict``, ``astuple``, ``fields``, ``replace``, or ``vars``, or a read of
|
|
255
489
|
``obj.__dict__`` — the check is suppressed for the whole tree and returns
|
|
@@ -108,6 +108,7 @@ from code_rules_paths_syspath import ( # noqa: E402
|
|
|
108
108
|
from code_rules_shared import ( # noqa: E402
|
|
109
109
|
changed_line_numbers,
|
|
110
110
|
get_file_extension,
|
|
111
|
+
is_ephemeral_script_path,
|
|
111
112
|
is_hook_infrastructure,
|
|
112
113
|
is_test_file,
|
|
113
114
|
)
|
|
@@ -395,6 +396,8 @@ def _is_validated_target(file_path: str) -> bool:
|
|
|
395
396
|
"""
|
|
396
397
|
if not file_path:
|
|
397
398
|
return False
|
|
399
|
+
if is_ephemeral_script_path(file_path):
|
|
400
|
+
return False
|
|
398
401
|
if is_hook_infrastructure(file_path):
|
|
399
402
|
return False
|
|
400
403
|
return get_file_extension(file_path) in ALL_CODE_EXTENSIONS
|
|
@@ -416,6 +419,8 @@ def _is_hook_infrastructure_python_target(file_path: str) -> bool:
|
|
|
416
419
|
"""
|
|
417
420
|
if not file_path:
|
|
418
421
|
return False
|
|
422
|
+
if is_ephemeral_script_path(file_path):
|
|
423
|
+
return False
|
|
419
424
|
if not is_hook_infrastructure(file_path):
|
|
420
425
|
return False
|
|
421
426
|
return get_file_extension(file_path) in ALL_PYTHON_EXTENSIONS
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import ast
|
|
4
4
|
import difflib
|
|
5
|
+
import os
|
|
5
6
|
import sys
|
|
6
7
|
from collections.abc import Iterator
|
|
7
8
|
from pathlib import Path
|
|
@@ -15,10 +16,16 @@ if _hooks_directory not in sys.path:
|
|
|
15
16
|
|
|
16
17
|
from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
17
18
|
ALL_DIFF_CHANGED_OPCODE_TAGS,
|
|
19
|
+
ALL_EPHEMERAL_EXEMPT_DISABLE_TRUTHY_VALUES,
|
|
18
20
|
ALL_HOOK_INFRASTRUCTURE_PATTERNS,
|
|
19
21
|
ALL_MIGRATION_PATH_PATTERNS,
|
|
22
|
+
ALL_ROOT_ANCHORED_EPHEMERAL_DIRECTORIES,
|
|
20
23
|
ALL_TEST_PATH_PATTERNS,
|
|
21
24
|
ALL_WORKFLOW_REGISTRY_PATTERNS,
|
|
25
|
+
CLAUDE_JOB_DIR_ENVIRONMENT_VARIABLE_NAME,
|
|
26
|
+
CLAUDE_JOB_DIR_SCRATCH_SUBDIRECTORY,
|
|
27
|
+
EPHEMERAL_EXEMPT_DISABLE_ENVIRONMENT_VARIABLE_NAME,
|
|
28
|
+
LEADING_DRIVE_LETTER_PATTERN,
|
|
22
29
|
)
|
|
23
30
|
from hooks_constants.unused_module_import_constants import ( # noqa: E402
|
|
24
31
|
TYPE_CHECKING_IDENTIFIER,
|
|
@@ -201,6 +208,46 @@ def _extract_fstring_literal_parts(
|
|
|
201
208
|
return "".join(display_segments), "".join(shape_segments)
|
|
202
209
|
|
|
203
210
|
|
|
211
|
+
def is_ephemeral_script_path(file_path: str) -> bool:
|
|
212
|
+
"""Return True when the path is rooted at a throwaway scratch directory.
|
|
213
|
+
|
|
214
|
+
Checks these sources in order:
|
|
215
|
+
- ``$CLAUDE_JOB_DIR/tmp`` — only when ``CLAUDE_JOB_DIR`` is set.
|
|
216
|
+
- Root-anchored ``/tmp`` and ``/temp`` (drive-letter tolerant).
|
|
217
|
+
|
|
218
|
+
The shared OS temp directory is deliberately not a source: pytest writes
|
|
219
|
+
its sandbox fixtures there, so matching it would exempt the suite's own
|
|
220
|
+
targets. Returns False when ``CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT``
|
|
221
|
+
is truthy, when ``file_path`` is empty, and when no root matches. Path
|
|
222
|
+
classification is string-only; the file need not exist.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
file_path: The candidate path to classify.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
True when the path is rooted at a recognized ephemeral scratch directory.
|
|
229
|
+
"""
|
|
230
|
+
if not file_path:
|
|
231
|
+
return False
|
|
232
|
+
disable_value = os.environ.get(EPHEMERAL_EXEMPT_DISABLE_ENVIRONMENT_VARIABLE_NAME, "").strip().lower()
|
|
233
|
+
if disable_value in ALL_EPHEMERAL_EXEMPT_DISABLE_TRUTHY_VALUES:
|
|
234
|
+
return False
|
|
235
|
+
normalized = LEADING_DRIVE_LETTER_PATTERN.sub("", os.path.abspath(file_path).replace("\\", "/").lower())
|
|
236
|
+
all_temp_roots: list[str] = []
|
|
237
|
+
job_dir = os.environ.get(CLAUDE_JOB_DIR_ENVIRONMENT_VARIABLE_NAME)
|
|
238
|
+
if job_dir:
|
|
239
|
+
job_dir_scratch = LEADING_DRIVE_LETTER_PATTERN.sub(
|
|
240
|
+
"", os.path.join(job_dir, CLAUDE_JOB_DIR_SCRATCH_SUBDIRECTORY).replace("\\", "/").lower()
|
|
241
|
+
)
|
|
242
|
+
all_temp_roots.append(job_dir_scratch)
|
|
243
|
+
for each_root in ALL_ROOT_ANCHORED_EPHEMERAL_DIRECTORIES:
|
|
244
|
+
all_temp_roots.append(each_root)
|
|
245
|
+
for each_temp_root in all_temp_roots:
|
|
246
|
+
if normalized == each_temp_root or normalized.startswith(each_temp_root + "/"):
|
|
247
|
+
return True
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
|
|
204
251
|
def is_migration_file(file_path: str) -> bool:
|
|
205
252
|
"""Check if file is a Django migration (must be self-contained)."""
|
|
206
253
|
path_lower = file_path.lower().replace("\\", "/")
|
|
@@ -16,10 +16,15 @@ from pathlib import Path
|
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
_hooks_root_path_string = str(Path(__file__).resolve().parent.parent)
|
|
19
|
+
_blocking_directory_path_string = str(Path(__file__).resolve().parent)
|
|
19
20
|
if _hooks_root_path_string not in sys.path:
|
|
20
21
|
sys.path.insert(0, _hooks_root_path_string)
|
|
22
|
+
if _blocking_directory_path_string not in sys.path:
|
|
23
|
+
sys.path.insert(0, _blocking_directory_path_string)
|
|
21
24
|
|
|
22
|
-
from
|
|
25
|
+
from code_rules_shared import is_ephemeral_script_path # noqa: E402
|
|
26
|
+
|
|
27
|
+
from hooks_constants.messages import USER_FACING_TDD_NOTICE # noqa: E402
|
|
23
28
|
|
|
24
29
|
PRODUCTION_EXTENSIONS = {'.py', '.ts', '.tsx', '.js', '.jsx'}
|
|
25
30
|
SKIP_PATTERNS = {
|
|
@@ -581,7 +586,7 @@ def main() -> None:
|
|
|
581
586
|
if not file_path:
|
|
582
587
|
sys.exit(0)
|
|
583
588
|
|
|
584
|
-
if _is_inside_dotclaude_segment(file_path):
|
|
589
|
+
if _is_inside_dotclaude_segment(file_path) or is_ephemeral_script_path(file_path):
|
|
585
590
|
sys.exit(0)
|
|
586
591
|
|
|
587
592
|
path = Path(file_path)
|