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.
@@ -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) == []