claude-dev-env 1.71.0 → 1.73.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.
Files changed (68) hide show
  1. package/CLAUDE.md +8 -0
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +5 -3
  3. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +39 -0
  4. package/agents/clean-coder.md +1 -0
  5. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
  6. package/bin/install.mjs +73 -5
  7. package/bin/install.test.mjs +360 -4
  8. package/docs/CODE_RULES.md +1 -1
  9. package/hooks/blocking/CLAUDE.md +3 -1
  10. package/hooks/blocking/claude_md_orphan_file_blocker.py +5 -6
  11. package/hooks/blocking/code_rules_dead_config_field.py +69 -56
  12. package/hooks/blocking/code_rules_docstrings.py +676 -0
  13. package/hooks/blocking/code_rules_enforcer.py +26 -0
  14. package/hooks/blocking/code_rules_shared.py +19 -0
  15. package/hooks/blocking/code_rules_test_assertions.py +152 -1
  16. package/hooks/blocking/code_rules_type_escape.py +447 -2
  17. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +420 -0
  18. package/hooks/blocking/md_to_html_blocker.py +7 -8
  19. package/hooks/blocking/open_questions_in_plans_blocker.py +5 -6
  20. package/hooks/blocking/plain_language_blocker.py +51 -16
  21. package/hooks/blocking/pr_converge_bugteam_enforcer.py +5 -5
  22. package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
  23. package/hooks/blocking/pytest_testpaths_orphan_blocker.py +358 -0
  24. package/hooks/blocking/state_description_blocker.py +75 -36
  25. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
  26. package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
  27. package/hooks/blocking/test_code_rules_enforcer_docstring_no_consumer.py +93 -0
  28. package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
  29. package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
  30. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
  31. package/hooks/blocking/test_code_rules_enforcer_object_parameter.py +499 -0
  32. package/hooks/blocking/test_code_rules_enforcer_stale_test_name.py +103 -0
  33. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +456 -0
  34. package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
  35. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
  36. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
  37. package/hooks/blocking/test_shared_stdin_adoption.py +166 -0
  38. package/hooks/blocking/verdict_directory_write_blocker.py +12 -7
  39. package/hooks/hooks.json +9 -79
  40. package/hooks/hooks_constants/CLAUDE.md +3 -1
  41. package/hooks/hooks_constants/blocking_check_limits.py +75 -0
  42. package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
  43. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
  44. package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
  45. package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
  46. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +69 -0
  47. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +135 -0
  48. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
  49. package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
  50. package/hooks/validation/mypy_validator.py +215 -17
  51. package/hooks/validation/post_tool_use_dispatcher.py +344 -0
  52. package/hooks/validation/test_mypy_validator.py +184 -1
  53. package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
  54. package/hooks/workflow/test_auto_formatter.py +10 -9
  55. package/package.json +1 -1
  56. package/rules/docstring-prose-matches-implementation.md +3 -2
  57. package/scripts/CLAUDE.md +1 -0
  58. package/scripts/Show-Asset.ps1 +106 -0
  59. package/skills/autoconverge/SKILL.md +123 -3
  60. package/skills/autoconverge/reference/convergence.md +41 -1
  61. package/skills/autoconverge/workflow/converge.contract.test.mjs +90 -0
  62. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +98 -0
  63. package/skills/autoconverge/workflow/converge.mjs +203 -8
  64. package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
  65. package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
  66. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +100 -0
  67. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +47 -3
  68. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +34 -0
@@ -0,0 +1,499 @@
1
+ """Tests for object-typed dereferenced parameter detection in production.
2
+
3
+ CODE_RULES.md §6 (complete type hints) requires concrete parameter types. A
4
+ parameter annotated as the bare builtin ``object`` whose body reads an attribute
5
+ on it is a type escape hatch in the same family as ``Any``: ``object`` declares
6
+ no attributes, so every ``param.attribute`` access goes unchecked. A parameter
7
+ typed ``object`` the body never dereferences is honest and not flagged.
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
+ check_type_escape_hatches = code_rules_enforcer.check_type_escape_hatches
29
+ check_collection_prefix = code_rules_enforcer.check_collection_prefix
30
+ check_loop_variable_naming = code_rules_enforcer.check_loop_variable_naming
31
+
32
+ PRODUCTION_FILE_PATH = "/project/src/module.py"
33
+ TEST_FILE_PATH = "/project/src/test_module.py"
34
+ TYPE_ESCAPE_MODULE_PATH = Path(__file__).parent / "code_rules_type_escape.py"
35
+
36
+
37
+ def test_type_escape_module_has_no_collection_prefix_violations() -> None:
38
+ source = TYPE_ESCAPE_MODULE_PATH.read_text(encoding="utf-8")
39
+ issues = check_collection_prefix(source, str(TYPE_ESCAPE_MODULE_PATH))
40
+ assert issues == [], f"Collection-parameter naming must be clean, got: {issues!r}"
41
+
42
+
43
+ def test_type_escape_module_has_no_loop_variable_naming_violations() -> None:
44
+ source = TYPE_ESCAPE_MODULE_PATH.read_text(encoding="utf-8")
45
+ issues = check_loop_variable_naming(source, str(TYPE_ESCAPE_MODULE_PATH))
46
+ assert issues == [], f"Loop-variable naming must be clean, got: {issues!r}"
47
+
48
+
49
+ def test_should_flag_self_object_with_attribute_access() -> None:
50
+ source = (
51
+ "async def _fill_basic_info(self: object) -> None:\n"
52
+ " assert self.automation.actions is not None\n"
53
+ )
54
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
55
+ assert any("object" in each_issue and "self" in each_issue for each_issue in issues), (
56
+ f"Expected self: object with attribute access to be flagged, got: {issues!r}"
57
+ )
58
+
59
+
60
+ def test_should_flag_object_parameter_other_than_self() -> None:
61
+ source = "def render(node: object) -> str:\n return node.text\n"
62
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
63
+ assert any("object" in each_issue and "node" in each_issue for each_issue in issues), (
64
+ f"Expected object-typed dereferenced parameter to be flagged, got: {issues!r}"
65
+ )
66
+
67
+
68
+ def test_should_not_flag_object_parameter_without_attribute_access() -> None:
69
+ source = "def register(handler: object) -> list[object]:\n return [handler]\n"
70
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
71
+ assert issues == [], f"Identity-only object parameter must not be flagged, got: {issues!r}"
72
+
73
+
74
+ def test_should_not_flag_concrete_typed_parameter_with_attribute_access() -> None:
75
+ source = "def fill(self: 'AppInfoProcessor') -> None:\n self.automation.run()\n"
76
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
77
+ assert issues == [], f"Concrete-typed parameter must not be flagged, got: {issues!r}"
78
+
79
+
80
+ def test_should_not_flag_object_parameter_in_test_file() -> None:
81
+ source = "def fill(self: object) -> None:\n self.automation.run()\n"
82
+ issues = check_type_escape_hatches(source, TEST_FILE_PATH)
83
+ assert issues == [], f"Test files must be exempt, got: {issues!r}"
84
+
85
+
86
+ def test_should_flag_each_distinct_object_parameter() -> None:
87
+ source = (
88
+ "def first(self: object) -> None:\n"
89
+ " self.run()\n"
90
+ "def second(node: object) -> str:\n"
91
+ " return node.text\n"
92
+ )
93
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
94
+ flagged_lines = [each_issue for each_issue in issues if "object" in each_issue]
95
+ assert len(flagged_lines) == 2, f"Expected both object parameters flagged, got: {issues!r}"
96
+
97
+
98
+ def test_should_not_flag_object_vararg_with_tuple_method_access() -> None:
99
+ source = "def f(*args: object) -> int:\n return args.count(0)\n"
100
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
101
+ assert issues == [], (
102
+ f"*args: object binds to tuple[object, ...]; tuple method access is type-safe, got: {issues!r}"
103
+ )
104
+
105
+
106
+ def test_should_not_flag_object_kwarg_with_dict_method_access() -> None:
107
+ source = "def f(**kwargs: object) -> object:\n return kwargs.get('x')\n"
108
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
109
+ assert issues == [], (
110
+ f"**kwargs: object binds to dict[str, object]; dict method access is type-safe, got: {issues!r}"
111
+ )
112
+
113
+
114
+ def test_should_not_flag_object_parameter_reassigned_before_dereference() -> None:
115
+ source = "def render(node: object) -> str:\n node = parse(node)\n return node.text\n"
116
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
117
+ assert issues == [], (
118
+ f"Dereference targets the reassigned concrete value, not the object parameter, got: {issues!r}"
119
+ )
120
+
121
+
122
+ def test_should_not_flag_outer_object_parameter_when_nested_function_shadows_name() -> None:
123
+ source = (
124
+ "def outer(node: object) -> None:\n"
125
+ " register(node)\n"
126
+ " def inner(node: Widget) -> str:\n"
127
+ " return node.text\n"
128
+ " inner(make())\n"
129
+ )
130
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
131
+ assert issues == [], (
132
+ f"Nested function rebinds node to a concrete type; outer object parameter is identity-only, got: {issues!r}"
133
+ )
134
+
135
+
136
+ def test_should_still_flag_object_parameter_dereferenced_directly() -> None:
137
+ source = "def f(client: object) -> None:\n client.connect()\n"
138
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
139
+ assert any("object" in each_issue and "client" in each_issue for each_issue in issues), (
140
+ f"Direct dereference of an object parameter must still be flagged, got: {issues!r}"
141
+ )
142
+
143
+
144
+ def test_should_still_flag_object_parameter_dereferenced_before_later_rebind() -> None:
145
+ source = "def f(client: object) -> None:\n client.connect()\n client = None\n"
146
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
147
+ assert any("object" in each_issue and "client" in each_issue for each_issue in issues), (
148
+ f"Dereference on the object parameter before a later rebind must still be flagged, got: {issues!r}"
149
+ )
150
+
151
+
152
+ def test_should_not_flag_outer_object_parameter_when_lambda_shadows_name() -> None:
153
+ source = (
154
+ "def outer(node: object) -> object:\n"
155
+ " register(node)\n"
156
+ " return lambda node: node.text\n"
157
+ )
158
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
159
+ assert issues == [], (
160
+ f"Lambda rebinds node to its own parameter; outer object parameter is identity-only, got: {issues!r}"
161
+ )
162
+
163
+
164
+ def test_should_not_flag_object_parameter_shadowed_by_comprehension_target() -> None:
165
+ source = (
166
+ "def outer(node: object) -> list:\n"
167
+ " register(node)\n"
168
+ " return [\n"
169
+ " node.text\n"
170
+ " for node in items()\n"
171
+ " ]\n"
172
+ )
173
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
174
+ assert issues == [], (
175
+ f"Comprehension rebinds node to its loop target; outer object parameter is identity-only, got: {issues!r}"
176
+ )
177
+
178
+
179
+ def test_should_flag_object_parameter_dereferenced_inside_comprehension_body() -> None:
180
+ source = "def f(node: object) -> list:\n return [node.text for each_index in range(2)]\n"
181
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
182
+ assert any("object" in each_issue and "node" in each_issue for each_issue in issues), (
183
+ f"A genuine object-parameter dereference inside a comprehension body must be flagged, got: {issues!r}"
184
+ )
185
+
186
+
187
+ def test_should_flag_object_parameter_dereferenced_inside_nested_class_method() -> None:
188
+ source = (
189
+ "def outer(node: object) -> object:\n"
190
+ " class Inner:\n"
191
+ " def m(self) -> None:\n"
192
+ " print(node.text)\n"
193
+ " return Inner\n"
194
+ )
195
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
196
+ assert any("object" in each_issue and "node" in each_issue for each_issue in issues), (
197
+ f"A class-nested method reads the outer object parameter from the enclosing scope; "
198
+ f"that dereference must be flagged, got: {issues!r}"
199
+ )
200
+
201
+
202
+ def test_should_flag_earlier_object_deref_when_later_comprehension_reuses_name() -> None:
203
+ source = (
204
+ "def f(node: object) -> list:\n"
205
+ " node.run()\n"
206
+ " return [node for node in items()]\n"
207
+ )
208
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
209
+ assert any("object" in each_issue and "node" in each_issue for each_issue in issues), (
210
+ f"A genuine top-level dereference before a later comprehension reuses the name must stay flagged, "
211
+ f"got: {issues!r}"
212
+ )
213
+
214
+
215
+ def test_should_flag_top_level_object_deref_when_nested_function_reuses_name() -> None:
216
+ source = (
217
+ "def outer(node: object) -> None:\n"
218
+ " print(node.text)\n"
219
+ " def inner(node: int) -> None:\n"
220
+ " pass\n"
221
+ )
222
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
223
+ assert any("object" in each_issue and "node" in each_issue for each_issue in issues), (
224
+ f"A top-level dereference must stay flagged even when a nested function reuses the name, got: {issues!r}"
225
+ )
226
+
227
+
228
+ def test_should_flag_top_level_object_deref_when_lambda_reuses_name() -> None:
229
+ source = (
230
+ "def outer(node: object) -> object:\n"
231
+ " print(node.text)\n"
232
+ " return lambda node: node.text\n"
233
+ )
234
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
235
+ assert any("object" in each_issue and "node" in each_issue for each_issue in issues), (
236
+ f"A top-level dereference must stay flagged even when a lambda reuses the name, got: {issues!r}"
237
+ )
238
+
239
+
240
+ def test_should_flag_both_object_parameters_when_one_name_collides_with_comprehension_target() -> None:
241
+ source = (
242
+ "def f(a: object, b: object) -> None:\n"
243
+ " print(a.x)\n"
244
+ " print(b.y)\n"
245
+ " all_squares = [a for a in range(3)]\n"
246
+ )
247
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
248
+ assert any("object" in each_issue and "'a'" in each_issue for each_issue in issues), (
249
+ f"Top-level a.x must be flagged even though a comprehension reuses 'a', got: {issues!r}"
250
+ )
251
+ assert any("object" in each_issue and "'b'" in each_issue for each_issue in issues), (
252
+ f"Top-level b.y must be flagged, got: {issues!r}"
253
+ )
254
+
255
+
256
+ def test_should_flag_object_parameter_dereferenced_on_conditional_rebind_fall_through() -> None:
257
+ source = (
258
+ "def f(client: object, cond: bool) -> None:\n"
259
+ " if cond:\n"
260
+ " client = wrap(client)\n"
261
+ " client.connect()\n"
262
+ )
263
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
264
+ assert any("object" in each_issue and "client" in each_issue for each_issue in issues), (
265
+ f"A conditional rebind does not dominate the fall-through dereference, so client.connect() "
266
+ f"must stay flagged, got: {issues!r}"
267
+ )
268
+
269
+
270
+ def test_should_not_flag_object_parameter_rebound_then_dereferenced_in_same_branch() -> None:
271
+ source = (
272
+ "def f(client: object, cond: bool) -> None:\n"
273
+ " if cond:\n"
274
+ " client = wrap(client)\n"
275
+ " client.connect()\n"
276
+ )
277
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
278
+ assert issues == [], (
279
+ f"A rebind that dominates the read within the same branch suppresses the dereference, got: {issues!r}"
280
+ )
281
+
282
+
283
+ def test_should_not_flag_object_parameter_rebound_by_for_loop_target() -> None:
284
+ source = (
285
+ "def f(node: object) -> None:\n"
286
+ " for node in items():\n"
287
+ " node.run()\n"
288
+ )
289
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
290
+ assert issues == [], (
291
+ f"A for-loop target rebinds node to the loop element; the body read targets the element, "
292
+ f"not the object parameter, got: {issues!r}"
293
+ )
294
+
295
+
296
+ def test_should_not_flag_object_parameter_rebound_by_with_as_target() -> None:
297
+ source = (
298
+ "def f(node: object) -> str:\n"
299
+ " with open_ctx() as node:\n"
300
+ " return node.read()\n"
301
+ )
302
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
303
+ assert issues == [], (
304
+ f"A with-as target rebinds node to the context object; the body read targets the context "
305
+ f"object, not the object parameter, got: {issues!r}"
306
+ )
307
+
308
+
309
+ def test_should_not_flag_object_parameter_rebound_by_walrus_in_if_test() -> None:
310
+ source = (
311
+ "def f(node: object) -> str:\n"
312
+ " if (node := wrap(node)):\n"
313
+ " return node.text\n"
314
+ )
315
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
316
+ assert issues == [], (
317
+ f"A walrus rebind in the if test rebinds node to the wrap() result; the body read targets "
318
+ f"that result, not the object parameter, got: {issues!r}"
319
+ )
320
+
321
+
322
+ def test_should_still_flag_object_parameter_dereferenced_in_loop_without_rebind() -> None:
323
+ source = (
324
+ "def f(node: object) -> None:\n"
325
+ " while True:\n"
326
+ " node.run()\n"
327
+ )
328
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
329
+ assert any("object" in each_issue and "node" in each_issue for each_issue in issues), (
330
+ f"A loop body that dereferences the object parameter without rebinding it must stay flagged, "
331
+ f"got: {issues!r}"
332
+ )
333
+
334
+
335
+ def test_should_not_flag_object_parameter_rebound_by_except_handler_name() -> None:
336
+ source = (
337
+ "def f(node: object) -> None:\n"
338
+ " try:\n"
339
+ " go()\n"
340
+ " except E as node:\n"
341
+ " node.run()\n"
342
+ )
343
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
344
+ assert issues == [], (
345
+ f"An 'except E as node' clause rebinds node to the caught exception; the handler-body read "
346
+ f"targets the exception, not the object parameter, got: {issues!r}"
347
+ )
348
+
349
+
350
+ def test_should_still_flag_object_parameter_read_in_other_handler_without_rebind() -> None:
351
+ source = (
352
+ "def f(node: object) -> None:\n"
353
+ " try:\n"
354
+ " go()\n"
355
+ " except E as node:\n"
356
+ " node.run()\n"
357
+ " except OtherError:\n"
358
+ " node.fail()\n"
359
+ )
360
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
361
+ assert any("object" in each_issue and "node" in each_issue for each_issue in issues), (
362
+ f"A read in a sibling handler that does not rebind node must stay flagged even when another "
363
+ f"handler binds node, got: {issues!r}"
364
+ )
365
+
366
+
367
+ def test_should_still_flag_object_parameter_read_in_handler_without_as_binding() -> None:
368
+ source = (
369
+ "def f(node: object) -> None:\n"
370
+ " try:\n"
371
+ " go()\n"
372
+ " except E:\n"
373
+ " node.run()\n"
374
+ )
375
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
376
+ assert any("object" in each_issue and "node" in each_issue for each_issue in issues), (
377
+ f"An 'except E' clause without an 'as' binding leaves node bound to the object parameter, so "
378
+ f"the handler-body read must stay flagged, got: {issues!r}"
379
+ )
380
+
381
+
382
+ def test_should_not_flag_object_parameter_rebound_by_match_case_capture() -> None:
383
+ source = (
384
+ "def f(node: object) -> None:\n"
385
+ " match get():\n"
386
+ " case node:\n"
387
+ " node.run()\n"
388
+ )
389
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
390
+ assert issues == [], (
391
+ f"A 'case node' capture pattern rebinds node to the matched subject; the case-body read "
392
+ f"targets that subject, not the object parameter, got: {issues!r}"
393
+ )
394
+
395
+
396
+ def test_should_not_flag_object_parameter_rebound_by_match_as_subpattern() -> None:
397
+ source = (
398
+ "def f(node: object) -> None:\n"
399
+ " match get():\n"
400
+ " case Point(x=0) as node:\n"
401
+ " node.run()\n"
402
+ )
403
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
404
+ assert issues == [], (
405
+ f"A 'case ... as node' pattern rebinds node to the matched value; the case-body read targets "
406
+ f"that value, not the object parameter, got: {issues!r}"
407
+ )
408
+
409
+
410
+ def test_should_still_flag_object_parameter_read_in_sibling_case_without_capture() -> None:
411
+ source = (
412
+ "def f(node: object) -> None:\n"
413
+ " match get():\n"
414
+ " case node:\n"
415
+ " node.run()\n"
416
+ " case 0:\n"
417
+ " node.fail()\n"
418
+ )
419
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
420
+ assert any("object" in each_issue and "node" in each_issue for each_issue in issues), (
421
+ f"A read in a sibling case that does not capture node must stay flagged even when another "
422
+ f"case captures node, got: {issues!r}"
423
+ )
424
+
425
+
426
+ def test_should_not_flag_object_parameter_narrowed_by_isinstance_guard() -> None:
427
+ source = (
428
+ "def f(value: object) -> None:\n"
429
+ " if isinstance(value, Foo):\n"
430
+ " value.bar()\n"
431
+ )
432
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
433
+ assert issues == [], (
434
+ f"An isinstance(value, Foo) guard narrows value to Foo; the guarded read is type-checked, "
435
+ f"got: {issues!r}"
436
+ )
437
+
438
+
439
+ def test_should_not_flag_eq_dunder_isinstance_narrowed_other() -> None:
440
+ source = (
441
+ "def __eq__(self, other: object) -> bool:\n"
442
+ " if not isinstance(other, C):\n"
443
+ " return NotImplemented\n"
444
+ " return other.value == self.value\n"
445
+ )
446
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
447
+ assert issues == [], (
448
+ f"The canonical __eq__ idiom narrows other via isinstance before reading other.value; that "
449
+ f"read is type-checked, got: {issues!r}"
450
+ )
451
+
452
+
453
+ def test_should_not_flag_object_parameter_narrowed_then_method_called() -> None:
454
+ source = (
455
+ "def g(convergence_summary: object) -> bool:\n"
456
+ " if not isinstance(convergence_summary, dict):\n"
457
+ " return False\n"
458
+ " return bool(convergence_summary.get('k'))\n"
459
+ )
460
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
461
+ assert issues == [], (
462
+ f"A negative isinstance early return narrows the parameter on the fall-through path; the "
463
+ f"later read is type-checked, got: {issues!r}"
464
+ )
465
+
466
+
467
+ def test_should_still_flag_object_parameter_dereferenced_without_isinstance_guard() -> None:
468
+ source = "def g(value: object) -> None:\n value.attr()\n"
469
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
470
+ assert any("object" in each_issue and "value" in each_issue for each_issue in issues), (
471
+ f"An unguarded dereference of an object parameter must still be flagged, got: {issues!r}"
472
+ )
473
+
474
+
475
+ def test_should_still_flag_object_parameter_read_before_isinstance_guard() -> None:
476
+ source = (
477
+ "def f(value: object) -> None:\n"
478
+ " value.early()\n"
479
+ " if isinstance(value, Foo):\n"
480
+ " value.bar()\n"
481
+ )
482
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
483
+ assert any("object" in each_issue and "value" in each_issue for each_issue in issues), (
484
+ f"A read that precedes the isinstance guard is not narrowed and must stay flagged, got: {issues!r}"
485
+ )
486
+
487
+
488
+ def test_should_still_flag_object_parameter_read_in_else_of_isinstance_guard() -> None:
489
+ source = (
490
+ "def f(value: object) -> None:\n"
491
+ " if isinstance(value, Foo):\n"
492
+ " return\n"
493
+ " value.attr()\n"
494
+ )
495
+ issues = check_type_escape_hatches(source, PRODUCTION_FILE_PATH)
496
+ assert any("object" in each_issue and "value" in each_issue for each_issue in issues), (
497
+ f"A positive isinstance guard does not narrow the fall-through path; the read after it must "
498
+ f"stay flagged, got: {issues!r}"
499
+ )
@@ -0,0 +1,103 @@
1
+ """Tests for check_stale_test_name_target — Category N test-name-vs-scenario drift.
2
+
3
+ A test whose name embeds a function that has been renamed away — the body calls
4
+ the new same-shape name but the test identifier keeps the old one — advertises a
5
+ function that exists nowhere in the file. This is the deterministic slice of
6
+ Category N (test name vs scenario): the named target is a renamed-away sibling of
7
+ the function the body actually exercises.
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_stale_test_name_target(content: str, file_path: str) -> list[str]:
31
+ return code_rules_enforcer.check_stale_test_name_target(content, file_path)
32
+
33
+
34
+ TEST_FILE_PATH = "/project/scripts/test_scan_priority_queue.py"
35
+ PRODUCTION_FILE_PATH = "/project/scripts/scan_priority_queue.py"
36
+
37
+
38
+ def test_flags_renamed_away_target_in_test_name() -> None:
39
+ content = (
40
+ "from scan_priority_queue import collect_skip_clean_names\n"
41
+ "\n\n"
42
+ "def test_collect_skip_theme_names_keeps_only_sorted_at_risk() -> None:\n"
43
+ " all_skip_names = collect_skip_clean_names([])\n"
44
+ " assert all_skip_names == []\n"
45
+ )
46
+ issues = check_stale_test_name_target(content, TEST_FILE_PATH)
47
+ assert len(issues) == 1
48
+ assert "collect_skip_theme_names" in issues[0]
49
+ assert "collect_skip_clean_names" in issues[0]
50
+
51
+
52
+ def test_flags_both_stale_names_in_renamed_producer_suite() -> None:
53
+ content = (
54
+ "from scan_priority_queue import collect_skip_clean_names\n"
55
+ "\n\n"
56
+ "def test_collect_skip_theme_names_keeps_only_sorted_at_risk() -> None:\n"
57
+ " assert collect_skip_clean_names([]) == []\n"
58
+ "\n\n"
59
+ "def test_collect_skip_theme_names_excludes_blank_names() -> None:\n"
60
+ " assert collect_skip_clean_names([]) == []\n"
61
+ )
62
+ issues = check_stale_test_name_target(content, TEST_FILE_PATH)
63
+ assert len(issues) == 2
64
+
65
+
66
+ def test_passes_when_test_name_matches_called_function() -> None:
67
+ content = (
68
+ "from scan_priority_queue import collect_skip_clean_names\n"
69
+ "\n\n"
70
+ "def test_collect_skip_clean_names_keeps_only_sorted_at_risk() -> None:\n"
71
+ " assert collect_skip_clean_names([]) == []\n"
72
+ )
73
+ assert check_stale_test_name_target(content, TEST_FILE_PATH) == []
74
+
75
+
76
+ def test_ignores_ordinary_descriptive_test_name() -> None:
77
+ content = (
78
+ "from app import compute_total\n"
79
+ "\n\n"
80
+ "def test_compute_total_sums_line_items() -> None:\n"
81
+ " assert compute_total([1, 2]) == 3\n"
82
+ )
83
+ assert check_stale_test_name_target(content, TEST_FILE_PATH) == []
84
+
85
+
86
+ def test_ignores_neutral_behavior_test_name() -> None:
87
+ content = (
88
+ "from app import compute_total\n"
89
+ "\n\n"
90
+ "def test_returns_zero_on_empty_input() -> None:\n"
91
+ " assert compute_total([]) == 0\n"
92
+ )
93
+ assert check_stale_test_name_target(content, TEST_FILE_PATH) == []
94
+
95
+
96
+ def test_production_files_are_exempt() -> None:
97
+ content = (
98
+ "from scan_priority_queue import collect_skip_clean_names\n"
99
+ "\n\n"
100
+ "def test_collect_skip_theme_names_keeps_only_sorted_at_risk() -> None:\n"
101
+ " assert collect_skip_clean_names([]) == []\n"
102
+ )
103
+ assert check_stale_test_name_target(content, PRODUCTION_FILE_PATH) == []