claude-dev-env 1.71.0 → 1.72.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.
- package/CLAUDE.md +8 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +5 -3
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +39 -0
- package/agents/clean-coder.md +1 -0
- package/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/code_rules_docstrings.py +60 -0
- package/hooks/blocking/code_rules_enforcer.py +4 -0
- package/hooks/blocking/code_rules_test_assertions.py +152 -1
- package/hooks/blocking/code_rules_type_escape.py +447 -2
- package/hooks/blocking/test_code_rules_enforcer_docstring_no_consumer.py +93 -0
- package/hooks/blocking/test_code_rules_enforcer_object_parameter.py +499 -0
- package/hooks/blocking/test_code_rules_enforcer_stale_test_name.py +103 -0
- package/hooks/hooks_constants/blocking_check_limits.py +14 -0
- package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +1 -1
- package/scripts/CLAUDE.md +1 -0
- package/scripts/Show-Asset.ps1 +106 -0
- package/skills/autoconverge/SKILL.md +30 -3
- package/skills/autoconverge/reference/convergence.md +41 -1
- package/skills/autoconverge/workflow/converge.contract.test.mjs +90 -0
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +98 -0
- package/skills/autoconverge/workflow/converge.mjs +176 -6
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +47 -3
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +34 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Type escape-hatch and boundary-type checks: Any imports, cast(), unjustified type: ignore, and Any in signatures."""
|
|
1
|
+
"""Type escape-hatch and boundary-type checks: Any imports, cast(), unjustified type: ignore, object-typed dereferenced parameters, and Any in signatures."""
|
|
2
2
|
|
|
3
3
|
import ast
|
|
4
4
|
import re
|
|
@@ -190,8 +190,443 @@ def _file_path_matches_any_exemption(file_path: str) -> bool:
|
|
|
190
190
|
return filename in {each_pattern.lower() for each_pattern in ALL_ANY_ALLOWED_PATTERNS}
|
|
191
191
|
|
|
192
192
|
|
|
193
|
+
def _annotation_is_bare_object(annotation_node: Optional[ast.expr]) -> bool:
|
|
194
|
+
"""Return True when an annotation is the bare builtin ``object`` name."""
|
|
195
|
+
return isinstance(annotation_node, ast.Name) and annotation_node.id == "object"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _positional_and_keyword_arguments(
|
|
199
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
200
|
+
) -> list[ast.arg]:
|
|
201
|
+
"""Return only the positional and keyword parameters, excluding ``*args``/``**kwargs``.
|
|
202
|
+
|
|
203
|
+
Annotating ``*args: object`` types each element as ``object`` while the
|
|
204
|
+
parameter binding itself is ``tuple[object, ...]``; ``**kwargs: object``
|
|
205
|
+
binds ``dict[str, object]``. Method access on those concrete tuple/dict
|
|
206
|
+
bindings (``args.count(...)``, ``kwargs.get(...)``) is type-safe and is not
|
|
207
|
+
an unchecked ``object`` dereference, so the vararg and kwarg slots are out
|
|
208
|
+
of scope for the object-dereference check.
|
|
209
|
+
"""
|
|
210
|
+
arguments = function_node.args
|
|
211
|
+
return [*arguments.posonlyargs, *arguments.args, *arguments.kwonlyargs]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _comprehension_target_names(comprehension_node: ast.AST) -> set[str]:
|
|
215
|
+
"""Return the loop-target names a comprehension binds as its own locals."""
|
|
216
|
+
target_names: set[str] = set()
|
|
217
|
+
generators = getattr(comprehension_node, "generators", [])
|
|
218
|
+
for each_generator in generators:
|
|
219
|
+
for each_target in ast.walk(each_generator.target):
|
|
220
|
+
if isinstance(each_target, ast.Name):
|
|
221
|
+
target_names.add(each_target.id)
|
|
222
|
+
return target_names
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _positional_and_keyword_arguments_of_lambda(lambda_node: ast.Lambda) -> list[ast.arg]:
|
|
226
|
+
"""Return a lambda's positional and keyword parameters, excluding ``*args``/``**kwargs``."""
|
|
227
|
+
arguments = lambda_node.args
|
|
228
|
+
return [*arguments.posonlyargs, *arguments.args, *arguments.kwonlyargs]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _names_a_scope_rebinds(scope_node: ast.AST) -> frozenset[str]:
|
|
232
|
+
"""Return the names a single nested scope binds as its own locals.
|
|
233
|
+
|
|
234
|
+
A nested function or lambda binds its positional and keyword parameters; a
|
|
235
|
+
comprehension binds its loop targets. A name in this set, read inside the
|
|
236
|
+
scope, resolves to the scope's own binding rather than to an enclosing
|
|
237
|
+
parameter.
|
|
238
|
+
"""
|
|
239
|
+
if isinstance(scope_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
240
|
+
return frozenset(each_argument.arg for each_argument in _positional_and_keyword_arguments(scope_node))
|
|
241
|
+
if isinstance(scope_node, ast.Lambda):
|
|
242
|
+
return frozenset(each_argument.arg for each_argument in _positional_and_keyword_arguments_of_lambda(scope_node))
|
|
243
|
+
if isinstance(scope_node, (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)):
|
|
244
|
+
return frozenset(_comprehension_target_names(scope_node))
|
|
245
|
+
return frozenset()
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _parent_node_by_child(root_node: ast.AST) -> dict[int, ast.AST]:
|
|
249
|
+
"""Return a parent lookup keyed by each descendant node's identity."""
|
|
250
|
+
parent_by_child_id: dict[int, ast.AST] = {}
|
|
251
|
+
for each_parent in ast.walk(root_node):
|
|
252
|
+
for each_child in ast.iter_child_nodes(each_parent):
|
|
253
|
+
parent_by_child_id[id(each_child)] = each_parent
|
|
254
|
+
return parent_by_child_id
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _read_is_shadowed_by_a_nested_scope(
|
|
258
|
+
read_node: ast.Name,
|
|
259
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
260
|
+
parent_by_child_id: dict[int, ast.AST],
|
|
261
|
+
) -> bool:
|
|
262
|
+
"""Return True when a name read sits inside a nested scope that rebinds that name.
|
|
263
|
+
|
|
264
|
+
The walk climbs from the read node up to the function node. A name-rebinding
|
|
265
|
+
scope between the read and the function — a nested function or lambda whose
|
|
266
|
+
parameter, or a comprehension whose loop target, reuses the read's name —
|
|
267
|
+
means the read resolves to that inner binding, not the enclosing parameter.
|
|
268
|
+
A class body is not such a scope: a method nested in a class still reads an
|
|
269
|
+
enclosing-function local from the function scope, so the climb passes
|
|
270
|
+
through ``ClassDef`` without suppressing the read.
|
|
271
|
+
"""
|
|
272
|
+
read_name = read_node.id
|
|
273
|
+
current_node: ast.AST = read_node
|
|
274
|
+
while current_node is not function_node:
|
|
275
|
+
enclosing_node = parent_by_child_id.get(id(current_node))
|
|
276
|
+
if enclosing_node is None or enclosing_node is function_node:
|
|
277
|
+
return False
|
|
278
|
+
if read_name in _names_a_scope_rebinds(enclosing_node):
|
|
279
|
+
return True
|
|
280
|
+
current_node = enclosing_node
|
|
281
|
+
return False
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _index_of_statement_in_enclosing_block(
|
|
285
|
+
statement_node: ast.stmt,
|
|
286
|
+
enclosing_node: ast.AST,
|
|
287
|
+
) -> tuple[int, int] | None:
|
|
288
|
+
"""Return the (block list identity, index) of a statement within its parent's statement block.
|
|
289
|
+
|
|
290
|
+
A statement lives in one of its parent's statement-body lists (``body``,
|
|
291
|
+
``orelse``, ``finalbody``, and similar). The expression-level list fields a
|
|
292
|
+
parent also holds — an ``Assign``'s ``targets``, a ``Call``'s ``args`` — are
|
|
293
|
+
not statement blocks and are skipped, so the position locates the statement
|
|
294
|
+
on its control-flow path rather than its slot inside an expression.
|
|
295
|
+
"""
|
|
296
|
+
for _, each_field_value in ast.iter_fields(enclosing_node):
|
|
297
|
+
if not isinstance(each_field_value, list):
|
|
298
|
+
continue
|
|
299
|
+
for each_index, each_block_member in enumerate(each_field_value):
|
|
300
|
+
if each_block_member is statement_node:
|
|
301
|
+
return (id(each_field_value), each_index)
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _statement_chain(
|
|
306
|
+
target_node: ast.AST,
|
|
307
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
308
|
+
parent_by_child_id: dict[int, ast.AST],
|
|
309
|
+
) -> list[tuple[int, int]]:
|
|
310
|
+
"""Return the (block list identity, index) path of enclosing statements from the function body to a node.
|
|
311
|
+
|
|
312
|
+
Each entry locates an enclosing statement within its parent's statement
|
|
313
|
+
block, walking up from the target to the function body. Only statement nodes
|
|
314
|
+
contribute a step, so a rebind's path and a read's path are both expressed in
|
|
315
|
+
statement positions and compare directly: a rebind dominates a read only when
|
|
316
|
+
its statement is a straight-line predecessor on the read's path, never a
|
|
317
|
+
branch the read does not also enter.
|
|
318
|
+
"""
|
|
319
|
+
all_statement_steps: list[tuple[int, int]] = []
|
|
320
|
+
current_node: ast.AST = target_node
|
|
321
|
+
while current_node is not function_node:
|
|
322
|
+
enclosing_node = parent_by_child_id.get(id(current_node))
|
|
323
|
+
if enclosing_node is None:
|
|
324
|
+
break
|
|
325
|
+
if isinstance(current_node, ast.stmt):
|
|
326
|
+
block_position = _index_of_statement_in_enclosing_block(current_node, enclosing_node)
|
|
327
|
+
if block_position is not None:
|
|
328
|
+
all_statement_steps.append(block_position)
|
|
329
|
+
current_node = enclosing_node
|
|
330
|
+
all_statement_steps.reverse()
|
|
331
|
+
return all_statement_steps
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _body_block_header_chain(
|
|
335
|
+
all_body_statements: list[ast.stmt],
|
|
336
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
337
|
+
parent_by_child_id: dict[int, ast.AST],
|
|
338
|
+
) -> list[tuple[int, int]] | None:
|
|
339
|
+
"""Return a chain that locates the head of a statement body, before its first statement.
|
|
340
|
+
|
|
341
|
+
An ``except ... as`` handler body, a ``match`` case body, and an
|
|
342
|
+
``if isinstance(...)`` then-branch each bind or narrow a name for the
|
|
343
|
+
statements inside that body, the way a ``for`` header binds its loop target
|
|
344
|
+
for the loop body. The chain reuses the first body statement's path with the
|
|
345
|
+
final index replaced by a header sentinel that orders before every statement
|
|
346
|
+
in the block, so it dominates exactly the reads inside that one body and no
|
|
347
|
+
sibling body that shares the same parent.
|
|
348
|
+
"""
|
|
349
|
+
if not all_body_statements:
|
|
350
|
+
return None
|
|
351
|
+
header_index_before_first_statement = -1
|
|
352
|
+
first_statement_chain = _statement_chain(all_body_statements[0], function_node, parent_by_child_id)
|
|
353
|
+
if not first_statement_chain:
|
|
354
|
+
return None
|
|
355
|
+
body_block_identity, _ = first_statement_chain[-1]
|
|
356
|
+
return [*first_statement_chain[:-1], (body_block_identity, header_index_before_first_statement)]
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _rebind_dominates_read(
|
|
360
|
+
all_rebind_steps: list[tuple[int, int]],
|
|
361
|
+
all_read_steps: list[tuple[int, int]],
|
|
362
|
+
) -> bool:
|
|
363
|
+
"""Return True when a rebind statement is a straight-line predecessor of a read.
|
|
364
|
+
|
|
365
|
+
The rebind's ancestor blocks above its own statement must match the read's
|
|
366
|
+
path exactly (same block, same index), and at the rebind's own level the two
|
|
367
|
+
share a block with the rebind ordered before the read. A rebind nested
|
|
368
|
+
deeper than that shared level lies on a branch the read need not enter, so it
|
|
369
|
+
does not dominate.
|
|
370
|
+
|
|
371
|
+
A header rebind — a ``for`` target, a ``with ... as`` target, or a walrus
|
|
372
|
+
``:=`` in an ``if``/``while`` test — locates at the same (block, index) as the
|
|
373
|
+
compound statement it heads, while a read nested in that statement's body
|
|
374
|
+
locates one level deeper at that same position. When the rebind chain is a
|
|
375
|
+
strict prefix of the read chain, the rebind heads a compound statement whose
|
|
376
|
+
body contains the read, so it dominates every read inside that body.
|
|
377
|
+
"""
|
|
378
|
+
if not all_rebind_steps or len(all_rebind_steps) > len(all_read_steps):
|
|
379
|
+
return False
|
|
380
|
+
all_ancestor_steps = all_rebind_steps[:-1]
|
|
381
|
+
if all_ancestor_steps != all_read_steps[: len(all_ancestor_steps)]:
|
|
382
|
+
return False
|
|
383
|
+
rebind_block, rebind_index = all_rebind_steps[-1]
|
|
384
|
+
read_block, read_index = all_read_steps[len(all_ancestor_steps)]
|
|
385
|
+
if rebind_block != read_block:
|
|
386
|
+
return False
|
|
387
|
+
if rebind_index == read_index:
|
|
388
|
+
return len(all_rebind_steps) < len(all_read_steps)
|
|
389
|
+
return rebind_index < read_index
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _rebind_chains_by_name(
|
|
393
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
394
|
+
parent_by_child_id: dict[int, ast.AST],
|
|
395
|
+
all_object_parameter_names: frozenset[str],
|
|
396
|
+
) -> dict[str, list[list[tuple[int, int]]]]:
|
|
397
|
+
"""Return, per object-parameter name, the statement chain of each own-scope ``Store`` rebind.
|
|
398
|
+
|
|
399
|
+
A ``Store`` inside a nested function, lambda, or comprehension binds that
|
|
400
|
+
scope's own local rather than the enclosing parameter, so only ``Store``
|
|
401
|
+
targets that resolve to the function's own scope are collected.
|
|
402
|
+
"""
|
|
403
|
+
rebind_chains_by_name: dict[str, list[list[tuple[int, int]]]] = {}
|
|
404
|
+
for each_node in ast.walk(function_node):
|
|
405
|
+
if not isinstance(each_node, ast.Name) or not isinstance(each_node.ctx, ast.Store):
|
|
406
|
+
continue
|
|
407
|
+
if each_node.id not in all_object_parameter_names:
|
|
408
|
+
continue
|
|
409
|
+
if _read_is_shadowed_by_a_nested_scope(each_node, function_node, parent_by_child_id):
|
|
410
|
+
continue
|
|
411
|
+
all_rebind_steps = _statement_chain(each_node, function_node, parent_by_child_id)
|
|
412
|
+
rebind_chains_by_name.setdefault(each_node.id, []).append(all_rebind_steps)
|
|
413
|
+
return rebind_chains_by_name
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _capture_names_a_match_pattern_binds(pattern_node: ast.pattern) -> set[str]:
|
|
417
|
+
"""Return the names a ``case`` pattern binds to the matched subject.
|
|
418
|
+
|
|
419
|
+
A bare capture (``case node``), an ``as`` binding (``case Point() as node``),
|
|
420
|
+
and a star or double-star rest (``case [*rest]``, ``case {**rest}``) each bind
|
|
421
|
+
a name via a string attribute rather than an ``ast.Name`` ``Store`` node, so
|
|
422
|
+
the ``Store``-based collector never sees them.
|
|
423
|
+
"""
|
|
424
|
+
bound_names: set[str] = set()
|
|
425
|
+
for each_descendant in ast.walk(pattern_node):
|
|
426
|
+
if isinstance(each_descendant, (ast.MatchAs, ast.MatchStar)) and each_descendant.name is not None:
|
|
427
|
+
bound_names.add(each_descendant.name)
|
|
428
|
+
if isinstance(each_descendant, ast.MatchMapping) and each_descendant.rest is not None:
|
|
429
|
+
bound_names.add(each_descendant.rest)
|
|
430
|
+
return bound_names
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _handler_and_case_binding_chains_by_name(
|
|
434
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
435
|
+
parent_by_child_id: dict[int, ast.AST],
|
|
436
|
+
all_object_parameter_names: frozenset[str],
|
|
437
|
+
) -> dict[str, list[list[tuple[int, int]]]]:
|
|
438
|
+
"""Return, per object-parameter name, the body-header chain of each ``except as`` or ``case`` rebind.
|
|
439
|
+
|
|
440
|
+
An ``except E as name`` clause binds ``name`` to the caught exception via
|
|
441
|
+
``ExceptHandler.name`` (a string); a ``case`` capture binds via
|
|
442
|
+
``MatchAs``/``MatchStar``/``MatchMapping`` name attributes. Neither is an
|
|
443
|
+
``ast.Name`` ``Store`` node, so each is collected here as a body-header rebind
|
|
444
|
+
that dominates only the reads inside its own handler or case body.
|
|
445
|
+
"""
|
|
446
|
+
binding_chains_by_name: dict[str, list[list[tuple[int, int]]]] = {}
|
|
447
|
+
for each_node in ast.walk(function_node):
|
|
448
|
+
if isinstance(each_node, ast.ExceptHandler) and each_node.name in all_object_parameter_names:
|
|
449
|
+
body_header_chain = _body_block_header_chain(each_node.body, function_node, parent_by_child_id)
|
|
450
|
+
if body_header_chain is not None:
|
|
451
|
+
binding_chains_by_name.setdefault(each_node.name, []).append(body_header_chain)
|
|
452
|
+
if isinstance(each_node, ast.match_case):
|
|
453
|
+
for each_bound_name in _capture_names_a_match_pattern_binds(each_node.pattern):
|
|
454
|
+
if each_bound_name not in all_object_parameter_names:
|
|
455
|
+
continue
|
|
456
|
+
body_header_chain = _body_block_header_chain(each_node.body, function_node, parent_by_child_id)
|
|
457
|
+
if body_header_chain is not None:
|
|
458
|
+
binding_chains_by_name.setdefault(each_bound_name, []).append(body_header_chain)
|
|
459
|
+
return binding_chains_by_name
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _isinstance_narrowed_name(isinstance_test: ast.expr) -> str | None:
|
|
463
|
+
"""Return the parameter name an ``isinstance(name, ...)`` test narrows, or None."""
|
|
464
|
+
if not isinstance(isinstance_test, ast.Call):
|
|
465
|
+
return None
|
|
466
|
+
if not isinstance(isinstance_test.func, ast.Name) or isinstance_test.func.id != "isinstance":
|
|
467
|
+
return None
|
|
468
|
+
if not isinstance_test.args:
|
|
469
|
+
return None
|
|
470
|
+
narrowed_subject = isinstance_test.args[0]
|
|
471
|
+
if isinstance(narrowed_subject, ast.Name):
|
|
472
|
+
return narrowed_subject.id
|
|
473
|
+
return None
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _branch_body_always_exits(all_body_statements: list[ast.stmt]) -> bool:
|
|
477
|
+
"""Return True when a statement body ends in a control-flow exit (return/raise/continue/break)."""
|
|
478
|
+
if not all_body_statements:
|
|
479
|
+
return False
|
|
480
|
+
last_statement = all_body_statements[-1]
|
|
481
|
+
return isinstance(last_statement, (ast.Return, ast.Raise, ast.Continue, ast.Break))
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _isinstance_narrowing_chains_by_name(
|
|
485
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
486
|
+
parent_by_child_id: dict[int, ast.AST],
|
|
487
|
+
all_object_parameter_names: frozenset[str],
|
|
488
|
+
) -> dict[str, list[list[tuple[int, int]]]]:
|
|
489
|
+
"""Return, per object-parameter name, the chains over which an ``isinstance`` guard narrows it.
|
|
490
|
+
|
|
491
|
+
A positive ``if isinstance(name, T):`` narrows ``name`` for its then-branch, so
|
|
492
|
+
its body-header chain dominates the reads inside that branch. A negated
|
|
493
|
+
``if not isinstance(name, T):`` whose body always exits narrows ``name`` on the
|
|
494
|
+
fall-through, so the ``if`` statement's own chain dominates the same-block reads
|
|
495
|
+
after it. A type checker checks ``name.attribute`` over either narrowed region,
|
|
496
|
+
so a read there is not an unchecked ``object`` access.
|
|
497
|
+
"""
|
|
498
|
+
narrowing_chains_by_name: dict[str, list[list[tuple[int, int]]]] = {}
|
|
499
|
+
for each_node in ast.walk(function_node):
|
|
500
|
+
if not isinstance(each_node, ast.If):
|
|
501
|
+
continue
|
|
502
|
+
guard_test = each_node.test
|
|
503
|
+
is_negated_guard = isinstance(guard_test, ast.UnaryOp) and isinstance(guard_test.op, ast.Not)
|
|
504
|
+
isinstance_test = guard_test.operand if isinstance(guard_test, ast.UnaryOp) else guard_test
|
|
505
|
+
narrowed_name = _isinstance_narrowed_name(isinstance_test)
|
|
506
|
+
if narrowed_name is None or narrowed_name not in all_object_parameter_names:
|
|
507
|
+
continue
|
|
508
|
+
if is_negated_guard:
|
|
509
|
+
if not _branch_body_always_exits(each_node.body):
|
|
510
|
+
continue
|
|
511
|
+
fall_through_chain = _statement_chain(each_node, function_node, parent_by_child_id)
|
|
512
|
+
narrowing_chains_by_name.setdefault(narrowed_name, []).append(fall_through_chain)
|
|
513
|
+
continue
|
|
514
|
+
body_header_chain = _body_block_header_chain(each_node.body, function_node, parent_by_child_id)
|
|
515
|
+
if body_header_chain is not None:
|
|
516
|
+
narrowing_chains_by_name.setdefault(narrowed_name, []).append(body_header_chain)
|
|
517
|
+
return narrowing_chains_by_name
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _all_suppression_chains_by_name(
|
|
521
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
522
|
+
parent_by_child_id: dict[int, ast.AST],
|
|
523
|
+
all_object_parameter_names: frozenset[str],
|
|
524
|
+
) -> dict[str, list[list[tuple[int, int]]]]:
|
|
525
|
+
"""Return, per object-parameter name, every statement chain that suppresses a dereference.
|
|
526
|
+
|
|
527
|
+
A read is suppressed when a chain in this map dominates it: an own-scope
|
|
528
|
+
``Store`` rebind, an ``except as`` or ``case`` capture rebind, or an
|
|
529
|
+
``isinstance`` narrowing guard. A read no chain dominates still resolves to the
|
|
530
|
+
bare ``object`` parameter and counts as an unchecked dereference.
|
|
531
|
+
"""
|
|
532
|
+
suppression_chains_by_name: dict[str, list[list[tuple[int, int]]]] = {}
|
|
533
|
+
for each_collector in (
|
|
534
|
+
_rebind_chains_by_name,
|
|
535
|
+
_handler_and_case_binding_chains_by_name,
|
|
536
|
+
_isinstance_narrowing_chains_by_name,
|
|
537
|
+
):
|
|
538
|
+
for each_name, each_chain_list in each_collector(
|
|
539
|
+
function_node, parent_by_child_id, all_object_parameter_names
|
|
540
|
+
).items():
|
|
541
|
+
suppression_chains_by_name.setdefault(each_name, []).extend(each_chain_list)
|
|
542
|
+
return suppression_chains_by_name
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _parameter_names_dereferenced_while_live(
|
|
546
|
+
function_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
547
|
+
all_object_parameter_names: frozenset[str],
|
|
548
|
+
) -> frozenset[str]:
|
|
549
|
+
"""Return object-parameter names read as ``name.attribute`` while still bound to the parameter and unchecked.
|
|
550
|
+
|
|
551
|
+
A read counts only when it resolves to the bare ``object`` parameter at the
|
|
552
|
+
read site and no suppressor on the straight-line path to it makes the access
|
|
553
|
+
type-checked. It is not counted when it sits in a scope that rebinds the name
|
|
554
|
+
(a nested function, lambda, or comprehension that reuses the name), and not
|
|
555
|
+
counted when a dominating suppressor precedes it: an own-scope ``Store``
|
|
556
|
+
rebind, an ``except as`` or ``case`` capture rebind, or an ``isinstance``
|
|
557
|
+
narrowing guard. A suppressor on a branch the read need not enter leaves the
|
|
558
|
+
read bound to the bare parameter, so the read still counts.
|
|
559
|
+
"""
|
|
560
|
+
parent_by_child_id = _parent_node_by_child(function_node)
|
|
561
|
+
suppression_chains_by_name = _all_suppression_chains_by_name(
|
|
562
|
+
function_node, parent_by_child_id, all_object_parameter_names
|
|
563
|
+
)
|
|
564
|
+
dereferenced_names: set[str] = set()
|
|
565
|
+
for each_node in ast.walk(function_node):
|
|
566
|
+
if not isinstance(each_node, ast.Attribute) or not isinstance(each_node.value, ast.Name):
|
|
567
|
+
continue
|
|
568
|
+
base_name = each_node.value.id
|
|
569
|
+
if base_name not in all_object_parameter_names:
|
|
570
|
+
continue
|
|
571
|
+
if _read_is_shadowed_by_a_nested_scope(each_node.value, function_node, parent_by_child_id):
|
|
572
|
+
continue
|
|
573
|
+
all_read_steps = _statement_chain(each_node.value, function_node, parent_by_child_id)
|
|
574
|
+
all_dominating_suppressors = suppression_chains_by_name.get(base_name, [])
|
|
575
|
+
if any(
|
|
576
|
+
_rebind_dominates_read(each_suppressor_chain, all_read_steps)
|
|
577
|
+
for each_suppressor_chain in all_dominating_suppressors
|
|
578
|
+
):
|
|
579
|
+
continue
|
|
580
|
+
dereferenced_names.add(base_name)
|
|
581
|
+
return frozenset(dereferenced_names)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _find_object_annotated_parameter_lines(source: str) -> list[tuple[int, str]]:
|
|
585
|
+
"""Return (line, parameter) for positional/keyword parameters typed ``object`` then dereferenced.
|
|
586
|
+
|
|
587
|
+
A positional or keyword parameter annotated as the bare builtin ``object``
|
|
588
|
+
whose body reads an unchecked attribute on it is a type escape hatch: a read
|
|
589
|
+
of a bare ``object`` value goes unchecked, because ``object`` declares no
|
|
590
|
+
attributes. The decision is per read, so a parameter is flagged when at least
|
|
591
|
+
one read of it resolves to the bare parameter at the read site. A parameter
|
|
592
|
+
typed ``object`` that the body never dereferences (identity-only use) is
|
|
593
|
+
honest and not flagged. A single read does not count when a dominating
|
|
594
|
+
suppressor precedes it on the straight-line path — an own-scope ``Store``
|
|
595
|
+
rebind, an ``except as`` or ``case`` capture rebind, or an ``isinstance``
|
|
596
|
+
narrowing guard — or when the read sits inside a nested function, lambda, or
|
|
597
|
+
comprehension that reuses the name as its own binding; a class body is not
|
|
598
|
+
such a scope, so a method nested in a class that reads an enclosing-function
|
|
599
|
+
parameter still counts. A read on a branch a non-dominating suppressor does
|
|
600
|
+
not reach, and a top-level read whose name a later nested scope reuses, both
|
|
601
|
+
still count. The ``*args``/``**kwargs`` slots are out of scope: ``object``
|
|
602
|
+
there types the elements, while the binding is a concrete ``tuple``/``dict``.
|
|
603
|
+
"""
|
|
604
|
+
try:
|
|
605
|
+
parsed_tree = ast.parse(source)
|
|
606
|
+
except SyntaxError:
|
|
607
|
+
return []
|
|
608
|
+
|
|
609
|
+
offending_parameters: list[tuple[int, str]] = []
|
|
610
|
+
for each_node in _walk_skipping_type_checking_blocks(parsed_tree):
|
|
611
|
+
if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
612
|
+
continue
|
|
613
|
+
object_parameters = [
|
|
614
|
+
each_argument
|
|
615
|
+
for each_argument in _positional_and_keyword_arguments(each_node)
|
|
616
|
+
if _annotation_is_bare_object(each_argument.annotation)
|
|
617
|
+
]
|
|
618
|
+
if not object_parameters:
|
|
619
|
+
continue
|
|
620
|
+
object_parameter_names = frozenset(each_argument.arg for each_argument in object_parameters)
|
|
621
|
+
dereferenced_names = _parameter_names_dereferenced_while_live(each_node, object_parameter_names)
|
|
622
|
+
for each_argument in object_parameters:
|
|
623
|
+
if each_argument.arg in dereferenced_names:
|
|
624
|
+
offending_parameters.append((each_argument.lineno, each_argument.arg))
|
|
625
|
+
return offending_parameters
|
|
626
|
+
|
|
627
|
+
|
|
193
628
|
def check_type_escape_hatches(content: str, file_path: str) -> list[str]:
|
|
194
|
-
"""Flag Any annotations, Any imports, cast() calls, and unjustified # type: ignore."""
|
|
629
|
+
"""Flag Any annotations, Any imports, cast() calls, object-typed dereferenced parameters, and unjustified # type: ignore."""
|
|
195
630
|
if is_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
196
631
|
return []
|
|
197
632
|
|
|
@@ -225,6 +660,16 @@ def check_type_escape_hatches(content: str, file_path: str) -> list[str]:
|
|
|
225
660
|
)
|
|
226
661
|
issues.extend(cast_issues[:MAX_TYPE_ESCAPE_HATCH_ISSUES])
|
|
227
662
|
|
|
663
|
+
object_parameter_issues: list[str] = []
|
|
664
|
+
for each_object_line, each_parameter_name in _find_object_annotated_parameter_lines(content):
|
|
665
|
+
object_parameter_issues.append(
|
|
666
|
+
f"Line {each_object_line}: parameter '{each_parameter_name}' typed 'object' but read as "
|
|
667
|
+
f"'{each_parameter_name}.attribute' on a path where its type is not narrowed - a bare "
|
|
668
|
+
"'object' read goes unchecked; narrow it with an isinstance guard before the read, or name "
|
|
669
|
+
"the concrete type the body relies on"
|
|
670
|
+
)
|
|
671
|
+
issues.extend(object_parameter_issues[:MAX_TYPE_ESCAPE_HATCH_ISSUES])
|
|
672
|
+
|
|
228
673
|
type_ignore_issues: list[str] = []
|
|
229
674
|
for each_ignore_line in _find_unjustified_type_ignore_lines(content):
|
|
230
675
|
type_ignore_issues.append(
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Tests for check_docstring_no_consumer_claim — Category O8 producer/consumer drift.
|
|
2
|
+
|
|
3
|
+
A producer docstring claiming "no consumer reads it yet" or "producer-only
|
|
4
|
+
artifact" is a transitional statement that drifts the moment a reader lands and
|
|
5
|
+
contradicts any companion SKILL.md that documents the consumer. This is the
|
|
6
|
+
deterministic slice of Category O8 (docstring / companion-doc producer-consumer
|
|
7
|
+
drift) and a no-transitional-language violation in its own right.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib.util
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from types import ModuleType
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _load_enforcer_module() -> ModuleType:
|
|
18
|
+
module_path = Path(__file__).parent / "code_rules_enforcer.py"
|
|
19
|
+
spec = importlib.util.spec_from_file_location("code_rules_enforcer", module_path)
|
|
20
|
+
assert spec is not None
|
|
21
|
+
assert spec.loader is not None
|
|
22
|
+
module = importlib.util.module_from_spec(spec)
|
|
23
|
+
spec.loader.exec_module(module)
|
|
24
|
+
return module
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
code_rules_enforcer = _load_enforcer_module()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def check_docstring_no_consumer_claim(content: str, file_path: str) -> list[str]:
|
|
31
|
+
return code_rules_enforcer.check_docstring_no_consumer_claim(content, file_path)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
PRODUCTION_FILE_PATH = "/project/scripts/scan_priority_queue.py"
|
|
35
|
+
TEST_FILE_PATH = "/project/scripts/test_scan_priority_queue.py"
|
|
36
|
+
HOOK_INFRASTRUCTURE_PATH = "/home/user/.claude/hooks/blocking/example.py"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_flags_producer_only_artifact_claim() -> None:
|
|
40
|
+
content = (
|
|
41
|
+
"def write_skip_list(skip_list_path: str) -> int:\n"
|
|
42
|
+
' """Merge the at-risk names into the skip-list JSON.\n'
|
|
43
|
+
"\n"
|
|
44
|
+
" This is a producer-only artifact; no submission-run consumer reads it yet.\n"
|
|
45
|
+
"\n"
|
|
46
|
+
" Returns:\n"
|
|
47
|
+
" How many names the merged list holds.\n"
|
|
48
|
+
' """\n'
|
|
49
|
+
" return 0\n"
|
|
50
|
+
)
|
|
51
|
+
issues = check_docstring_no_consumer_claim(content, PRODUCTION_FILE_PATH)
|
|
52
|
+
assert len(issues) == 1
|
|
53
|
+
assert "write_skip_list" in issues[0]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_flags_no_submission_run_consumer_phrase() -> None:
|
|
57
|
+
content = (
|
|
58
|
+
"def write_skip_list(skip_list_path: str) -> int:\n"
|
|
59
|
+
' """Write the JSON. No submission-run consumer reads it yet."""\n'
|
|
60
|
+
" return 0\n"
|
|
61
|
+
)
|
|
62
|
+
assert len(check_docstring_no_consumer_claim(content, PRODUCTION_FILE_PATH)) == 1
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_passes_when_docstring_names_the_consumer() -> None:
|
|
66
|
+
content = (
|
|
67
|
+
"def write_skip_list(skip_list_path: str) -> int:\n"
|
|
68
|
+
' """Write the JSON. The new-theme submission run reads it and applies the skip.\n'
|
|
69
|
+
"\n"
|
|
70
|
+
" Returns:\n"
|
|
71
|
+
" How many names the merged list holds.\n"
|
|
72
|
+
' """\n'
|
|
73
|
+
" return 0\n"
|
|
74
|
+
)
|
|
75
|
+
assert check_docstring_no_consumer_claim(content, PRODUCTION_FILE_PATH) == []
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_test_files_are_exempt() -> None:
|
|
79
|
+
content = (
|
|
80
|
+
"def write_skip_list(skip_list_path: str) -> int:\n"
|
|
81
|
+
' """This is a producer-only artifact; no consumer reads it yet."""\n'
|
|
82
|
+
" return 0\n"
|
|
83
|
+
)
|
|
84
|
+
assert check_docstring_no_consumer_claim(content, TEST_FILE_PATH) == []
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_hook_infrastructure_is_exempt() -> None:
|
|
88
|
+
content = (
|
|
89
|
+
"def write_skip_list(skip_list_path: str) -> int:\n"
|
|
90
|
+
' """This is a producer-only artifact; no consumer reads it yet."""\n'
|
|
91
|
+
" return 0\n"
|
|
92
|
+
)
|
|
93
|
+
assert check_docstring_no_consumer_claim(content, HOOK_INFRASTRUCTURE_PATH) == []
|