claude-dev-env 1.44.0 → 1.45.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 (34) hide show
  1. package/CLAUDE.md +9 -0
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +426 -85
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +20 -0
  4. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +625 -21
  5. package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +15 -0
  6. package/agents/clean-coder.md +7 -1
  7. package/agents/code-quality-agent.md +8 -5
  8. package/hooks/blocking/code_rules_enforcer.py +1562 -37
  9. package/hooks/blocking/open_questions_in_plans_blocker.py +249 -0
  10. package/hooks/blocking/test_code_rules_enforcer.py +1389 -0
  11. package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +292 -0
  12. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +46 -8
  13. package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +189 -0
  14. package/hooks/blocking/test_code_rules_enforcer_function_length.py +210 -0
  15. package/hooks/blocking/test_code_rules_enforcer_tests_isolate_home_temp.py +1512 -0
  16. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +9 -5
  17. package/hooks/blocking/test_open_questions_in_plans_blocker.py +790 -0
  18. package/hooks/hooks.json +10 -0
  19. package/hooks/hooks_constants/banned_identifiers_constants.py +19 -0
  20. package/hooks/hooks_constants/code_rules_enforcer_constants.py +129 -2
  21. package/hooks/hooks_constants/open_questions_in_plans_blocker_constants.py +35 -0
  22. package/hooks/hooks_constants/test_open_questions_in_plans_blocker_constants.py +125 -0
  23. package/package.json +1 -1
  24. package/skills/_shared/pr-loop/scripts/_path_resolver.py +34 -13
  25. package/skills/_shared/pr-loop/scripts/init_loop_state.py +1 -2
  26. package/skills/_shared/pr-loop/scripts/teardown_worktrees.py +1 -4
  27. package/skills/_shared/pr-loop/scripts/test__path_resolver.py +57 -0
  28. package/skills/_shared/pr-loop/scripts/test_init_loop_state.py +48 -0
  29. package/skills/_shared/pr-loop/scripts/test_teardown_worktrees.py +59 -0
  30. package/skills/bugteam/PROMPTS.md +48 -12
  31. package/skills/bugteam/reference/team-setup.md +4 -2
  32. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +487 -76
  33. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +22 -1
  34. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +597 -12
@@ -15,6 +15,8 @@ from __future__ import annotations
15
15
 
16
16
  import ast
17
17
  import importlib.util
18
+ import io
19
+ import json
18
20
  import sys
19
21
  from pathlib import Path
20
22
  from types import ModuleType
@@ -59,6 +61,28 @@ from hooks_constants.stuttering_check_config import ( # noqa: E402
59
61
  PRODUCTION_FILE_PATH = "packages/claude-dev-env/hooks/blocking/example_production.py"
60
62
 
61
63
 
64
+ def test_should_treat_repo_relative_hook_path_as_hook_infrastructure() -> None:
65
+ relative_hook_path = "packages/claude-dev-env/hooks/blocking/code_rules_enforcer.py"
66
+ assert code_rules_enforcer.is_hook_infrastructure(relative_hook_path) is True
67
+
68
+
69
+ def test_should_treat_backslash_repo_relative_hook_path_as_hook_infrastructure() -> None:
70
+ relative_hook_path = "packages\\claude-dev-env\\hooks\\blocking\\code_rules_enforcer.py"
71
+ assert code_rules_enforcer.is_hook_infrastructure(relative_hook_path) is True
72
+
73
+
74
+ def test_should_not_treat_unrelated_repo_relative_path_as_hook_infrastructure() -> None:
75
+ relative_source_path = "packages/claude-dev-env/skills/bugteam/scripts/runner.py"
76
+ assert code_rules_enforcer.is_hook_infrastructure(relative_source_path) is False
77
+
78
+
79
+ def test_should_exempt_repo_relative_hook_file_from_function_length() -> None:
80
+ body_lines = "\n".join(f" bound_{each_index} = {each_index}" for each_index in range(70))
81
+ grown_function_source = "def grown_function() -> None:\n" + body_lines + "\n"
82
+ relative_hook_path = "packages/claude-dev-env/hooks/blocking/code_rules_enforcer.py"
83
+ assert code_rules_enforcer.check_function_length(grown_function_source, relative_hook_path) == []
84
+
85
+
62
86
  def test_should_expose_all_banned_identifiers_from_config() -> None:
63
87
  expected_banned_identifiers = frozenset({
64
88
  "result", "data", "output", "response", "value", "item", "temp",
@@ -1213,3 +1237,1368 @@ def test_validate_content_honors_empty_full_file_content_for_thin_wrapper_check(
1213
1237
  assert not any("thin wrapper" in each.lower() for each in issues), (
1214
1238
  f"empty post-edit file must not be flagged as a thin wrapper, got: {issues!r}"
1215
1239
  )
1240
+
1241
+
1242
+ def test_isolation_check_does_not_flag_expanduser_without_tilde_argument() -> None:
1243
+ """expanduser of a tilde-free string does not probe HOME and must not fire."""
1244
+ source = (
1245
+ "import os\n"
1246
+ "def test_resolves_relative() -> None:\n"
1247
+ " target = os.path.expanduser('relative/path')\n"
1248
+ " assert target\n"
1249
+ )
1250
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
1251
+ source, "/project/src/test_module.py"
1252
+ )
1253
+ assert issues == [], f"tilde-free expanduser must not be flagged, got: {issues!r}"
1254
+
1255
+
1256
+ def test_isolation_check_flags_expanduser_with_tilde_argument() -> None:
1257
+ """expanduser of a leading-tilde string resolves HOME and must fire."""
1258
+ source = (
1259
+ "import os\n"
1260
+ "def test_reads_home() -> None:\n"
1261
+ " target = os.path.expanduser('~/.config/x')\n"
1262
+ " assert target\n"
1263
+ )
1264
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
1265
+ source, "/project/src/test_module.py"
1266
+ )
1267
+ assert any("expanduser" in each_issue for each_issue in issues)
1268
+
1269
+
1270
+ def test_isolation_check_flags_path_constructor_expanduser_method() -> None:
1271
+ """`Path('~/x').expanduser()` expands the home directory through the bound
1272
+ Path object and must fire even though it bypasses the static probe chain."""
1273
+ source = (
1274
+ "from pathlib import Path\n"
1275
+ "def test_reads_dotfile() -> None:\n"
1276
+ " target = Path('~/x').expanduser()\n"
1277
+ " target.read_text()\n"
1278
+ )
1279
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
1280
+ source, "/project/src/test_module.py"
1281
+ )
1282
+ assert any("expanduser" in each_issue for each_issue in issues)
1283
+
1284
+
1285
+ def test_isolation_check_flags_aliased_path_constructor_expanduser_method() -> None:
1286
+ """`from pathlib import Path as P` then `P('~/x').expanduser()` resolves the
1287
+ constructor through alias canonicalization and must fire."""
1288
+ source = (
1289
+ "from pathlib import Path as P\n"
1290
+ "def test_reads_dotfile() -> None:\n"
1291
+ " target = P('~/x').expanduser()\n"
1292
+ " target.read_text()\n"
1293
+ )
1294
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
1295
+ source, "/project/src/test_module.py"
1296
+ )
1297
+ assert any("expanduser" in each_issue for each_issue in issues)
1298
+
1299
+
1300
+ def test_isolation_check_flags_tempfile_named_temporary_file() -> None:
1301
+ """`tempfile.NamedTemporaryFile()` allocates in the shared temp dir and must
1302
+ fire as a temp-isolation probe."""
1303
+ source = (
1304
+ "import tempfile\n"
1305
+ "def test_writes_named_temp() -> None:\n"
1306
+ " handle = tempfile.NamedTemporaryFile()\n"
1307
+ " handle.write(b'x')\n"
1308
+ )
1309
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
1310
+ source, "/project/src/test_module.py"
1311
+ )
1312
+ assert any("NamedTemporaryFile" in each_issue for each_issue in issues)
1313
+
1314
+
1315
+ def test_isolation_check_exempts_tempfile_factory_with_explicit_dir() -> None:
1316
+ """A tempfile factory given an explicit `dir=` argument allocates under the
1317
+ supplied sandbox, so it must not fire as a shared-temp isolation probe."""
1318
+ source = (
1319
+ "import tempfile\n"
1320
+ "def test_writes_named_temp(tmp_path) -> None:\n"
1321
+ " handle = tempfile.NamedTemporaryFile(dir=tmp_path)\n"
1322
+ " handle.write(b'x')\n"
1323
+ )
1324
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
1325
+ source, "/project/src/test_module.py"
1326
+ )
1327
+ assert issues == []
1328
+
1329
+
1330
+ def test_isolation_check_flags_tempfile_factory_with_dir_constant_none() -> None:
1331
+ """`dir=None` selects the default shared temp directory, so the factory
1332
+ still allocates from shared temp and must fire."""
1333
+ source = (
1334
+ "import tempfile\n"
1335
+ "def test_writes_named_temp() -> None:\n"
1336
+ " handle = tempfile.NamedTemporaryFile(dir=None)\n"
1337
+ " handle.write(b'x')\n"
1338
+ )
1339
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
1340
+ source, "/project/src/test_module.py"
1341
+ )
1342
+ assert any("NamedTemporaryFile" in each_issue for each_issue in issues)
1343
+
1344
+
1345
+ def test_isolation_check_flags_tempfile_factory_with_dir_getenv_tmpdir() -> None:
1346
+ """`dir=os.getenv('TMPDIR')` resolves to a shared-temp env source, so the
1347
+ factory still allocates from shared temp and must fire."""
1348
+ source = (
1349
+ "import os\n"
1350
+ "import tempfile\n"
1351
+ "def test_makes_temp_dir() -> None:\n"
1352
+ " holder = tempfile.mkdtemp(dir=os.getenv('TMPDIR'))\n"
1353
+ " print(holder)\n"
1354
+ )
1355
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
1356
+ source, "/project/src/test_module.py"
1357
+ )
1358
+ assert any("mkdtemp" in each_issue for each_issue in issues)
1359
+
1360
+
1361
+ def test_isolation_check_exempts_tempfile_factory_with_dir_tmp_path() -> None:
1362
+ """`dir=tmp_path` allocates under the pytest sandbox, so the factory is
1363
+ isolated and must not fire."""
1364
+ source = (
1365
+ "import tempfile\n"
1366
+ "def test_makes_temp_dir(tmp_path) -> None:\n"
1367
+ " holder = tempfile.mkdtemp(dir=tmp_path)\n"
1368
+ " print(holder)\n"
1369
+ )
1370
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
1371
+ source, "/project/src/test_module.py"
1372
+ )
1373
+ assert issues == []
1374
+
1375
+
1376
+ def test_isolation_check_flags_class_level_probe_in_nested_class_body() -> None:
1377
+ """A Path.home() initializer in a nested class body runs at class-creation
1378
+ time during the test, so it must fire."""
1379
+ source = (
1380
+ "from pathlib import Path\n"
1381
+ "def test_defines_inner_class() -> None:\n"
1382
+ " class Inner:\n"
1383
+ " root = Path.home()\n"
1384
+ " assert Inner is not None\n"
1385
+ )
1386
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
1387
+ source, "/project/src/test_module.py"
1388
+ )
1389
+ assert any("Path.home" in each_issue for each_issue in issues)
1390
+
1391
+
1392
+ def test_isolation_check_flags_from_os_import_path_expanduser() -> None:
1393
+ """`from os import path` binds `path` to `os.path`, so `path.expanduser`
1394
+ must resolve to the canonical `os.path.expanduser` probe and fire."""
1395
+ source = (
1396
+ "from os import path\n"
1397
+ "def test_reads_dotfile() -> None:\n"
1398
+ " target = path.expanduser('~/.config/x')\n"
1399
+ " open(target).read()\n"
1400
+ )
1401
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
1402
+ source, "/project/src/test_module.py"
1403
+ )
1404
+ assert any("expanduser" in each_issue for each_issue in issues)
1405
+
1406
+
1407
+ def test_isolation_check_flags_expandvars_with_windows_percent_userprofile() -> None:
1408
+ """expandvars expands Windows `%USERPROFILE%` percent syntax, so a percent
1409
+ reference to a home env var must fire."""
1410
+ source = (
1411
+ "import os\n"
1412
+ "def test_expands_userprofile() -> None:\n"
1413
+ " target = os.path.expandvars('%USERPROFILE%\\\\.cfg')\n"
1414
+ " open(target).read()\n"
1415
+ )
1416
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
1417
+ source, "/project/src/test_module.py"
1418
+ )
1419
+ assert any("expandvars" in each_issue for each_issue in issues)
1420
+
1421
+
1422
+ def test_isolation_check_ignores_expandvars_with_unrelated_windows_percent_var() -> None:
1423
+ """A percent reference to an unrelated env var does not probe HOME/TMP and
1424
+ must not fire."""
1425
+ source = (
1426
+ "import os\n"
1427
+ "def test_expands_unrelated() -> None:\n"
1428
+ " token = os.path.expandvars('%MY_APP_TOKEN%')\n"
1429
+ " print(token)\n"
1430
+ )
1431
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
1432
+ source, "/project/src/test_module.py"
1433
+ )
1434
+ assert issues == []
1435
+
1436
+
1437
+ def test_isolation_check_flags_environ_get_via_local_binding() -> None:
1438
+ """`e = os.environ` then `e.get('HOME')` reads HOME through a local alias
1439
+ and must fire just like the subscript `e['HOME']` form."""
1440
+ source = (
1441
+ "import os\n"
1442
+ "def test_resolves_home() -> None:\n"
1443
+ " e = os.environ\n"
1444
+ " home = e.get('HOME')\n"
1445
+ " print(home)\n"
1446
+ )
1447
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
1448
+ source, "/project/src/test_module.py"
1449
+ )
1450
+ assert any("HOME" in each_issue for each_issue in issues)
1451
+
1452
+
1453
+ def test_isolation_check_scopes_path_bindings_to_their_own_test() -> None:
1454
+ """A `p = Path('~/x')` binding in one test must not make an unrelated
1455
+ `p.expanduser()` in a sibling test a finding; bindings are per-test."""
1456
+ source = (
1457
+ "from pathlib import Path\n"
1458
+ "def test_a() -> None:\n"
1459
+ " p = Path('~/x')\n"
1460
+ " p.expanduser()\n"
1461
+ "def test_b(p) -> None:\n"
1462
+ " p.expanduser()\n"
1463
+ )
1464
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
1465
+ source, "/project/src/test_module.py"
1466
+ )
1467
+ assert any("test_a" in each_issue for each_issue in issues)
1468
+ assert not any("test_b" in each_issue for each_issue in issues)
1469
+
1470
+
1471
+ def test_isolation_check_scopes_environ_bindings_to_their_own_test() -> None:
1472
+ """An `e = os.environ` binding in one test must not make an unrelated
1473
+ `e['HOME']` in a sibling test a finding; bindings are per-test."""
1474
+ source = (
1475
+ "import os\n"
1476
+ "def test_a() -> None:\n"
1477
+ " e = os.environ\n"
1478
+ " home = e['HOME']\n"
1479
+ " print(home)\n"
1480
+ "def test_b(e) -> None:\n"
1481
+ " home = e['HOME']\n"
1482
+ " print(home)\n"
1483
+ )
1484
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
1485
+ source, "/project/src/test_module.py"
1486
+ )
1487
+ assert any("test_a" in each_issue for each_issue in issues)
1488
+ assert not any("test_b" in each_issue for each_issue in issues)
1489
+
1490
+
1491
+ def test_isolation_check_ignores_path_constructor_expanduser_with_tilde_free_argument() -> None:
1492
+ """`Path('/tmp/x').expanduser()` carries no leading tilde, so it expands no
1493
+ home directory and must stay symmetric with `os.path.expanduser` of a
1494
+ tilde-free literal — neither fires."""
1495
+ source = (
1496
+ "from pathlib import Path\n"
1497
+ "def test_resolves_absolute() -> None:\n"
1498
+ " target = Path('/tmp/x').expanduser()\n"
1499
+ " target.read_text()\n"
1500
+ )
1501
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
1502
+ source, "/project/src/test_module.py"
1503
+ )
1504
+ assert issues == []
1505
+
1506
+
1507
+ def test_isolation_check_ignores_static_pathlib_expanduser_with_dynamic_argument() -> None:
1508
+ """`pathlib.Path.expanduser(some_path)` with a non-constant argument cannot
1509
+ be inspected for a leading tilde, so it follows the conservative rule and
1510
+ does not fire — symmetric with `os.path.expanduser(some_path)`."""
1511
+ source = (
1512
+ "import pathlib\n"
1513
+ "def test_resolves_dynamic(some_path) -> None:\n"
1514
+ " target = pathlib.Path.expanduser(some_path)\n"
1515
+ " target.read_text()\n"
1516
+ )
1517
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
1518
+ source, "/project/src/test_module.py"
1519
+ )
1520
+ assert issues == []
1521
+
1522
+
1523
+ def test_isolation_check_flags_path_home_via_function_local_class_alias() -> None:
1524
+ """`path_class = Path` then `path_class.home()` reaches the real home
1525
+ directory through a per-test class alias and must fire just like the bare
1526
+ `Path.home()` form."""
1527
+ source = (
1528
+ "from pathlib import Path\n"
1529
+ "def test_reads_home() -> None:\n"
1530
+ " path_class = Path\n"
1531
+ " home_dir = path_class.home()\n"
1532
+ " (home_dir / '.myapp').write_text('x')\n"
1533
+ )
1534
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
1535
+ source, "/project/src/test_module.py"
1536
+ )
1537
+ assert any("home" in each_issue.lower() for each_issue in issues)
1538
+
1539
+
1540
+ def test_isolation_check_flags_getenv_via_function_local_callable_alias() -> None:
1541
+ """`read_env = os.getenv` then `read_env('HOME')` reads HOME through a
1542
+ per-test callable alias and must fire just like the bare `os.getenv('HOME')`
1543
+ form."""
1544
+ source = (
1545
+ "import os\n"
1546
+ "def test_reads_home() -> None:\n"
1547
+ " read_env = os.getenv\n"
1548
+ " home = read_env('HOME')\n"
1549
+ " print(home)\n"
1550
+ )
1551
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
1552
+ source, "/project/src/test_module.py"
1553
+ )
1554
+ assert any("HOME" in each_issue for each_issue in issues)
1555
+
1556
+
1557
+ def test_isolation_check_flags_tempfile_spooled_temporary_file() -> None:
1558
+ """`tempfile.SpooledTemporaryFile()` allocates in the shared temp dir and
1559
+ must fire as a temp-isolation probe alongside the other tempfile factories."""
1560
+ source = (
1561
+ "import tempfile\n"
1562
+ "def test_writes_spooled_temp() -> None:\n"
1563
+ " handle = tempfile.SpooledTemporaryFile()\n"
1564
+ " handle.write(b'x')\n"
1565
+ )
1566
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
1567
+ source, "/project/src/test_module.py"
1568
+ )
1569
+ assert any("SpooledTemporaryFile" in each_issue for each_issue in issues)
1570
+
1571
+
1572
+ def test_isolation_check_flags_tempfile_gettempdirb() -> None:
1573
+ """`tempfile.gettempdirb()` returns the shared temp dir as bytes and must
1574
+ fire just like the string-returning `tempfile.gettempdir()`."""
1575
+ source = (
1576
+ "import tempfile\n"
1577
+ "def test_resolves_temp_bytes() -> None:\n"
1578
+ " base = tempfile.gettempdirb()\n"
1579
+ " print(base)\n"
1580
+ )
1581
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
1582
+ source, "/project/src/test_module.py"
1583
+ )
1584
+ assert any("gettempdirb" in each_issue for each_issue in issues)
1585
+
1586
+
1587
+ def test_isolation_check_flags_module_level_from_os_import_environ_subscript() -> None:
1588
+ """A module-level `from os import environ` binds `environ` to `os.environ`,
1589
+ so `environ['HOME']` inside a test must fire even without a per-test
1590
+ local binding."""
1591
+ source = (
1592
+ "from os import environ\n"
1593
+ "def test_resolves_home() -> None:\n"
1594
+ " home = environ['HOME']\n"
1595
+ " print(home)\n"
1596
+ )
1597
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
1598
+ source, "/project/src/test_module.py"
1599
+ )
1600
+ assert any("HOME" in each_issue for each_issue in issues)
1601
+
1602
+
1603
+ def test_isolation_check_reports_probes_in_source_order_on_new_file() -> None:
1604
+ """On a new file (``all_changed_lines is None``) every probe is in scope and
1605
+ reported in source order — none dropped by the cap, which now trims only
1606
+ out-of-scope advisory noise."""
1607
+ probe_count = 20
1608
+ repeated_probes = "\n".join(
1609
+ f" p{each_index} = Path.home()" for each_index in range(probe_count)
1610
+ )
1611
+ source = (
1612
+ f"from pathlib import Path\ndef test_many_probes() -> None:\n{repeated_probes}\n"
1613
+ )
1614
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
1615
+ source, "/project/src/test_module.py"
1616
+ )
1617
+ first_probe_line_number = 3
1618
+ reported_line_numbers = [
1619
+ int(each_issue.split(":", maxsplit=1)[0].removeprefix("Line ").strip())
1620
+ for each_issue in issues
1621
+ ]
1622
+ expected_line_numbers = [
1623
+ first_probe_line_number + each_offset for each_offset in range(probe_count)
1624
+ ]
1625
+ assert reported_line_numbers == expected_line_numbers
1626
+
1627
+
1628
+ def test_exempt_comment_rejects_noqa_prefixed_prose_lacking_boundary() -> None:
1629
+ """A comment body that merely starts with `noqa` followed by non-boundary
1630
+ characters is not a real noqa directive and must stay subject to the
1631
+ no-new-comments rule."""
1632
+ source = "x = compute() # noqa-but-not-really: explanation\n"
1633
+ issues = code_rules_enforcer.check_comments_python(source)
1634
+ assert issues
1635
+
1636
+
1637
+ def test_exempt_comment_keeps_bare_and_coded_noqa_exempt() -> None:
1638
+ """A bare `# noqa` and a coded `# noqa: E501` remain exempt under the
1639
+ tightened boundary rule."""
1640
+ bare_source = "x = compute() # noqa\n"
1641
+ coded_source = "x = compute() # noqa: E501\n"
1642
+ assert code_rules_enforcer.check_comments_python(bare_source) == []
1643
+ assert code_rules_enforcer.check_comments_python(coded_source) == []
1644
+
1645
+
1646
+ def test_exempt_comment_keeps_colon_terminated_markers_without_trailing_space() -> None:
1647
+ """A colon-terminated marker (`pylint:`, `type:`, `pragma:`) is self-bounded
1648
+ by its own colon, so the directive stays exempt even when the next character
1649
+ follows the colon immediately."""
1650
+ pylint_source = "import os # pylint:disable=unused-import\n"
1651
+ type_ignore_source = "x = compute() # type:ignore\n"
1652
+ pragma_source = "x = compute() # pragma:no-cover\n"
1653
+ assert code_rules_enforcer.check_comments_python(pylint_source) == []
1654
+ assert code_rules_enforcer.check_comments_python(type_ignore_source) == []
1655
+ assert code_rules_enforcer.check_comments_python(pragma_source) == []
1656
+
1657
+
1658
+ def test_exempt_comment_still_flags_noqa_glued_to_prose_without_boundary() -> None:
1659
+ """The colon-terminated allowance must not loosen the boundary rule for
1660
+ markers that do not end in a colon: `# noqaFOO` still lacks a real boundary
1661
+ after `noqa` and stays subject to the no-new-comments rule."""
1662
+ source = "x = compute() # noqaFOO\n"
1663
+ assert code_rules_enforcer.check_comments_python(source)
1664
+
1665
+
1666
+ def test_banned_noun_word_skips_non_aliased_upstream_import() -> None:
1667
+ """A non-aliased upstream import the author cannot rename
1668
+ (`from typing import ItemsView`) must not be flagged, while an
1669
+ author-coined alias still is."""
1670
+ production_path = "packages/myapp/services/customer_pipeline.py"
1671
+ upstream_issues = code_rules_enforcer.check_banned_noun_word_boundary(
1672
+ "from typing import ItemsView\n", production_path
1673
+ )
1674
+ aliased_issues = code_rules_enforcer.check_banned_noun_word_boundary(
1675
+ "import legacy_helper as cached_response\n", production_path
1676
+ )
1677
+ assert upstream_issues == []
1678
+ assert any("cached_response" in each_issue for each_issue in aliased_issues)
1679
+
1680
+
1681
+ def test_function_length_message_does_not_cite_file_length_section() -> None:
1682
+ """The blocking message must cite a function-length basis, not the
1683
+ advisory file-length section (CODE_RULES §6.5)."""
1684
+ assert "6.5" not in code_rules_enforcer.FUNCTION_LENGTH_BLOCKING_MESSAGE_SUFFIX
1685
+ assert "Clean Code" in code_rules_enforcer.FUNCTION_LENGTH_BLOCKING_MESSAGE_SUFFIX
1686
+
1687
+
1688
+ def _function_node_named(source: str, function_name: str) -> ast.FunctionDef:
1689
+ syntax_tree = ast.parse(source)
1690
+ for each_node in syntax_tree.body:
1691
+ if isinstance(each_node, ast.FunctionDef) and each_node.name == function_name:
1692
+ return each_node
1693
+ raise AssertionError(f"no function named {function_name!r} in source")
1694
+
1695
+
1696
+ def test_collect_pathlib_path_bindings_only_sees_the_scope_node_function() -> None:
1697
+ """The Path-binding collector must scope its walk to the function node it
1698
+ is given. A `p = Path('~/x')` binding in test_a must not appear when the
1699
+ collector is handed test_b's node (test_b never binds `p` to a Path)."""
1700
+ source = (
1701
+ "from pathlib import Path\n"
1702
+ "def test_a() -> None:\n"
1703
+ " p = Path('~/x')\n"
1704
+ " p.expanduser()\n"
1705
+ "def test_b(p) -> None:\n"
1706
+ " p.expanduser()\n"
1707
+ )
1708
+ syntax_tree = ast.parse(source)
1709
+ alias_map = code_rules_enforcer._build_alias_canonicalization_map(syntax_tree)
1710
+ test_a_node = _function_node_named(source, "test_a")
1711
+ test_b_node = _function_node_named(source, "test_b")
1712
+
1713
+ test_a_bindings = code_rules_enforcer._collect_pathlib_path_local_binding_names(
1714
+ test_a_node, alias_map
1715
+ )
1716
+ test_b_bindings = code_rules_enforcer._collect_pathlib_path_local_binding_names(
1717
+ test_b_node, alias_map
1718
+ )
1719
+
1720
+ assert "p" in test_a_bindings
1721
+ assert "p" not in test_b_bindings
1722
+
1723
+
1724
+ def test_collect_os_environ_bindings_only_sees_the_scope_node_function() -> None:
1725
+ """The environ-binding collector must scope its walk to the function node
1726
+ it is given. An `e = os.environ` binding in test_a must not appear when the
1727
+ collector is handed test_b's node (test_b never binds `e`)."""
1728
+ source = (
1729
+ "import os\n"
1730
+ "def test_a() -> None:\n"
1731
+ " e = os.environ\n"
1732
+ " home = e['HOME']\n"
1733
+ " print(home)\n"
1734
+ "def test_b(e) -> None:\n"
1735
+ " home = e['HOME']\n"
1736
+ " print(home)\n"
1737
+ )
1738
+ syntax_tree = ast.parse(source)
1739
+ alias_map = code_rules_enforcer._build_alias_canonicalization_map(syntax_tree)
1740
+ test_a_node = _function_node_named(source, "test_a")
1741
+ test_b_node = _function_node_named(source, "test_b")
1742
+
1743
+ test_a_bindings = code_rules_enforcer._collect_os_environ_local_binding_names(
1744
+ test_a_node, alias_map
1745
+ )
1746
+ test_b_bindings = code_rules_enforcer._collect_os_environ_local_binding_names(
1747
+ test_b_node, alias_map
1748
+ )
1749
+
1750
+ assert "e" in test_a_bindings
1751
+ assert "e" not in test_b_bindings
1752
+
1753
+
1754
+ def test_function_local_from_os_import_environ_does_not_leak_into_sibling_test() -> None:
1755
+ """bugbot-1: a function-local `from os import environ` in test_a binds
1756
+ `environ` only for test_a's runtime. A sibling test_b that references the
1757
+ bare name `environ` without importing it must not be flagged, while the
1758
+ test that actually imports and probes HOME (test_a) must be flagged."""
1759
+ source = (
1760
+ "def test_a() -> None:\n"
1761
+ " from os import environ\n"
1762
+ " home = environ['HOME']\n"
1763
+ " print(home)\n"
1764
+ "def test_b() -> None:\n"
1765
+ " home = environ['HOME']\n"
1766
+ " print(home)\n"
1767
+ )
1768
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
1769
+ source, "/project/src/test_module.py"
1770
+ )
1771
+ assert any("test_a" in each_issue for each_issue in issues), (
1772
+ f"test_a's own function-local environ import must be flagged, got: {issues!r}"
1773
+ )
1774
+ assert not any("test_b" in each_issue for each_issue in issues), (
1775
+ "test_b references bare `environ` it never imports, so the function-local "
1776
+ f"import in test_a must not leak into it, got: {issues!r}"
1777
+ )
1778
+
1779
+
1780
+ def test_function_local_aliased_module_import_does_not_leak_into_sibling_test() -> None:
1781
+ """bugbot-1 sibling: a function-local `import os as o` in test_a aliases
1782
+ `o` only for test_a. test_b referencing `o.getenv('HOME')` without its own
1783
+ import must not be flagged; test_a's own probe must be flagged."""
1784
+ source = (
1785
+ "def test_a() -> None:\n"
1786
+ " import os as o\n"
1787
+ " home = o.getenv('HOME')\n"
1788
+ " print(home)\n"
1789
+ "def test_b() -> None:\n"
1790
+ " home = o.getenv('HOME')\n"
1791
+ " print(home)\n"
1792
+ )
1793
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
1794
+ source, "/project/src/test_module.py"
1795
+ )
1796
+ assert any("test_a" in each_issue for each_issue in issues), (
1797
+ f"test_a's own function-local aliased import must be flagged, got: {issues!r}"
1798
+ )
1799
+ assert not any("test_b" in each_issue for each_issue in issues), (
1800
+ "test_b references alias `o` it never bound, so the function-local "
1801
+ f"import in test_a must not leak into it, got: {issues!r}"
1802
+ )
1803
+
1804
+
1805
+ def test_build_alias_map_excludes_function_local_imports() -> None:
1806
+ """bugbot-1: the module-wide alias canonicalization map must be built only
1807
+ from top-level imports. A function-local `import os as o` and a
1808
+ function-local `from os import environ` must not appear in the shared map."""
1809
+ source = (
1810
+ "import tempfile as module_temp\n"
1811
+ "def test_a() -> None:\n"
1812
+ " import os as o\n"
1813
+ " from os import environ\n"
1814
+ " print(o, environ)\n"
1815
+ )
1816
+ syntax_tree = ast.parse(source)
1817
+ alias_map = code_rules_enforcer._build_alias_canonicalization_map(syntax_tree)
1818
+ assert alias_map.get("module_temp") == "tempfile", (
1819
+ f"top-level alias must be recorded, got: {alias_map!r}"
1820
+ )
1821
+ assert "o" not in alias_map, (
1822
+ f"function-local `import os as o` must not leak into the module map, got: {alias_map!r}"
1823
+ )
1824
+ assert "environ" not in alias_map, (
1825
+ f"function-local `from os import environ` must not leak into the module map, got: {alias_map!r}"
1826
+ )
1827
+
1828
+
1829
+ def test_module_level_from_os_import_environ_still_flags_every_referencing_test() -> None:
1830
+ """bugbot-1 guard: a genuine module-level `from os import environ` binds the
1831
+ name for the whole module, so every test that probes HOME through it must
1832
+ still be flagged. The per-function scoping must not suppress this case."""
1833
+ source = (
1834
+ "from os import environ\n"
1835
+ "def test_a() -> None:\n"
1836
+ " print(environ['HOME'])\n"
1837
+ "def test_b() -> None:\n"
1838
+ " print(environ['HOME'])\n"
1839
+ )
1840
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
1841
+ source, "/project/src/test_module.py"
1842
+ )
1843
+ assert any("test_a" in each_issue for each_issue in issues)
1844
+ assert any("test_b" in each_issue for each_issue in issues), (
1845
+ f"module-level import must flag every probing test, got: {issues!r}"
1846
+ )
1847
+
1848
+
1849
+ def test_build_alias_map_excludes_class_body_imports() -> None:
1850
+ """A probe alias imported inside a class body binds only inside that class
1851
+ scope, so it must not enter the module-wide alias canonicalization map. A
1852
+ genuine module-level alias in the same source must still be recorded."""
1853
+ source = (
1854
+ "import tempfile as module_temp\n"
1855
+ "class TestAlpha:\n"
1856
+ " import tempfile as t\n"
1857
+ " def test_alpha_probe(self) -> None:\n"
1858
+ " assert self.t is not None\n"
1859
+ )
1860
+ syntax_tree = ast.parse(source)
1861
+ alias_map = code_rules_enforcer._build_alias_canonicalization_map(syntax_tree)
1862
+ assert alias_map.get("module_temp") == "tempfile", (
1863
+ f"top-level alias must be recorded, got: {alias_map!r}"
1864
+ )
1865
+ assert "t" not in alias_map, (
1866
+ f"class-body `import tempfile as t` must not leak into the module map, got: {alias_map!r}"
1867
+ )
1868
+
1869
+
1870
+ def test_class_body_aliased_import_does_not_leak_into_sibling_test() -> None:
1871
+ """A class-body `import tempfile as t` aliases `t` only inside that class.
1872
+ A sibling top-level test taking `t` as a parameter and calling `t.mkdtemp()`
1873
+ must not be flagged, since the class-scoped alias never enters the
1874
+ module-wide map."""
1875
+ source = (
1876
+ "class TestAlpha:\n"
1877
+ " import tempfile as t\n"
1878
+ " def test_alpha_probe(self) -> None:\n"
1879
+ " assert self.t is not None\n"
1880
+ "def test_sibling(t) -> None:\n"
1881
+ " t.mkdtemp()\n"
1882
+ )
1883
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
1884
+ source, "/project/src/test_module.py"
1885
+ )
1886
+ assert not any("test_sibling" in each_issue for each_issue in issues), (
1887
+ "class-body alias must not leak into a sibling test through the "
1888
+ f"module-wide map, got: {issues!r}"
1889
+ )
1890
+
1891
+
1892
+ def test_build_alias_map_records_module_top_level_but_excludes_function_and_class_imports() -> None:
1893
+ """Only true module-top-level imports enter the alias map. Imports lexically
1894
+ inside a function body or a class body are excluded, while a module-level
1895
+ try-guarded optional import is still recorded module-wide."""
1896
+ source = (
1897
+ "try:\n"
1898
+ " import tempfile as guarded_temp\n"
1899
+ "except ImportError:\n"
1900
+ " guarded_temp = None\n"
1901
+ "def test_function_local() -> None:\n"
1902
+ " import tempfile as function_temp\n"
1903
+ " assert function_temp is not None\n"
1904
+ "class TestBeta:\n"
1905
+ " import tempfile as class_temp\n"
1906
+ " def test_beta_probe(self) -> None:\n"
1907
+ " assert self.class_temp is not None\n"
1908
+ )
1909
+ syntax_tree = ast.parse(source)
1910
+ alias_map = code_rules_enforcer._build_alias_canonicalization_map(syntax_tree)
1911
+ assert alias_map.get("guarded_temp") == "tempfile", (
1912
+ f"module-level try-guarded alias must be recorded, got: {alias_map!r}"
1913
+ )
1914
+ assert "function_temp" not in alias_map, (
1915
+ f"function-local alias must not enter the module map, got: {alias_map!r}"
1916
+ )
1917
+ assert "class_temp" not in alias_map, (
1918
+ f"class-body alias must not enter the module map, got: {alias_map!r}"
1919
+ )
1920
+
1921
+
1922
+ def _oversized_function_source(name: str) -> str:
1923
+ body_line_count = code_rules_enforcer.FUNCTION_LENGTH_BLOCKING_THRESHOLD - 1
1924
+ body_lines = [
1925
+ f" bound_{each_index} = {each_index}" for each_index in range(body_line_count)
1926
+ ]
1927
+ return f"def {name}() -> None:\n" + "\n".join(body_lines) + "\n"
1928
+
1929
+
1930
+ def test_function_length_edit_does_not_block_untouched_long_function() -> None:
1931
+ """loop5-1: editing a short region of a file that already contains an
1932
+ untouched oversized function must not produce a blocking function-length
1933
+ violation at the PreToolUse layer."""
1934
+ untouched_long_function = _oversized_function_source("untouched_long")
1935
+ short_helper_before = "def short_helper() -> int:\n return 1\n"
1936
+ short_helper_after = "def short_helper() -> int:\n return 2\n"
1937
+ prior_full_file = untouched_long_function + "\n" + short_helper_before
1938
+ post_edit_full_file = untouched_long_function + "\n" + short_helper_after
1939
+ issues = code_rules_enforcer.validate_content(
1940
+ short_helper_after,
1941
+ "/project/src/edited_module.py",
1942
+ old_content=short_helper_before,
1943
+ full_file_content=post_edit_full_file,
1944
+ prior_full_file_content=prior_full_file,
1945
+ )
1946
+ assert not any(
1947
+ "untouched_long" in each_issue for each_issue in issues
1948
+ ), f"untouched long function must not block on an unrelated edit, got: {issues!r}"
1949
+
1950
+
1951
+ def test_function_length_edit_blocks_function_grown_on_changed_lines() -> None:
1952
+ """loop5-1: when the edit itself grows a function past the threshold, the
1953
+ function-length violation must still block at the PreToolUse layer."""
1954
+ short_function_before = "def grows_now() -> int:\n return 1\n"
1955
+ grown_function_after = _oversized_function_source("grows_now")
1956
+ prior_full_file = short_function_before
1957
+ post_edit_full_file = grown_function_after
1958
+ issues = code_rules_enforcer.validate_content(
1959
+ grown_function_after,
1960
+ "/project/src/edited_module.py",
1961
+ old_content=short_function_before,
1962
+ full_file_content=post_edit_full_file,
1963
+ prior_full_file_content=prior_full_file,
1964
+ )
1965
+ assert any(
1966
+ "grows_now" in each_issue for each_issue in issues
1967
+ ), f"function grown past threshold on changed lines must block, got: {issues!r}"
1968
+
1969
+
1970
+ def test_isolation_edit_does_not_block_untouched_probe() -> None:
1971
+ """loop5-3: editing a short region of a test file that already contains an
1972
+ untouched HOME probe must not block at the PreToolUse layer."""
1973
+ untouched_probe_function = (
1974
+ "def test_reads_home() -> None:\n"
1975
+ " target_path = Path.home()\n"
1976
+ " assert target_path\n"
1977
+ )
1978
+ short_test_before = "def test_addition() -> None:\n assert 1 + 1 == 2\n"
1979
+ short_test_after = "def test_addition() -> None:\n assert 2 + 2 == 4\n"
1980
+ header = "from pathlib import Path\n"
1981
+ prior_full_file = header + untouched_probe_function + "\n" + short_test_before
1982
+ post_edit_full_file = header + untouched_probe_function + "\n" + short_test_after
1983
+ issues = code_rules_enforcer.validate_content(
1984
+ short_test_after,
1985
+ "/project/src/test_edited_module.py",
1986
+ old_content=short_test_before,
1987
+ full_file_content=post_edit_full_file,
1988
+ prior_full_file_content=prior_full_file,
1989
+ )
1990
+ assert not any(
1991
+ "test_reads_home" in each_issue for each_issue in issues
1992
+ ), f"untouched isolation probe must not block on an unrelated edit, got: {issues!r}"
1993
+
1994
+
1995
+ def test_isolation_edit_blocks_probe_added_on_changed_lines() -> None:
1996
+ """loop5-3: when the edit introduces a HOME probe, the isolation violation
1997
+ must still block at the PreToolUse layer."""
1998
+ test_before = "def test_writes() -> None:\n assert True\n"
1999
+ test_after = (
2000
+ "def test_writes() -> None:\n"
2001
+ " target_path = Path.home()\n"
2002
+ " assert target_path\n"
2003
+ )
2004
+ header = "from pathlib import Path\n"
2005
+ prior_full_file = header + test_before
2006
+ post_edit_full_file = header + test_after
2007
+ issues = code_rules_enforcer.validate_content(
2008
+ test_after,
2009
+ "/project/src/test_edited_module.py",
2010
+ old_content=test_before,
2011
+ full_file_content=post_edit_full_file,
2012
+ prior_full_file_content=prior_full_file,
2013
+ )
2014
+ assert any(
2015
+ "test_writes" in each_issue and "Path.home" in each_issue
2016
+ for each_issue in issues
2017
+ ), f"isolation probe added on changed lines must block, got: {issues!r}"
2018
+
2019
+
2020
+ def test_isolation_edit_blocks_probe_unisolated_by_signature_line_change() -> None:
2021
+ """Removing the ``monkeypatch`` fixture from a test's signature line
2022
+ un-isolates a HOME probe in its unchanged body; the violation must block
2023
+ because the enclosing function's span covers the changed signature line."""
2024
+ test_before = (
2025
+ "def test_reads_home(monkeypatch) -> None:\n"
2026
+ " target_path = Path.home()\n"
2027
+ " assert target_path\n"
2028
+ )
2029
+ test_after = (
2030
+ "def test_reads_home() -> None:\n"
2031
+ " target_path = Path.home()\n"
2032
+ " assert target_path\n"
2033
+ )
2034
+ header = "from pathlib import Path\n"
2035
+ prior_full_file = header + test_before
2036
+ post_edit_full_file = header + test_after
2037
+ issues = code_rules_enforcer.validate_content(
2038
+ test_after,
2039
+ "/project/src/test_edited_module.py",
2040
+ old_content=test_before,
2041
+ full_file_content=post_edit_full_file,
2042
+ prior_full_file_content=prior_full_file,
2043
+ )
2044
+ assert any(
2045
+ "test_reads_home" in each_issue and "Path.home" in each_issue
2046
+ for each_issue in issues
2047
+ ), f"signature-line change that un-isolates a probe must block, got: {issues!r}"
2048
+
2049
+
2050
+ def test_isolation_message_carries_enclosing_function_definition_span() -> None:
2051
+ """The isolation message must carry the enclosing test's definition line
2052
+ and line span so the commit gate can scope by the same function span the
2053
+ enforcer uses, while keeping the ``Line N:`` probe-line prefix intact."""
2054
+ header = "from pathlib import Path\n"
2055
+ test_body = (
2056
+ "def test_reads_home() -> None:\n"
2057
+ " target_path = Path.home()\n"
2058
+ " assert target_path\n"
2059
+ )
2060
+ source = header + test_body
2061
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
2062
+ source, "/project/src/test_module.py"
2063
+ )
2064
+ definition_line = 2
2065
+ function_span = 3
2066
+ expected_span_fragment = (
2067
+ f"(defined at line {definition_line}, spanning {function_span} lines)"
2068
+ )
2069
+ assert any(
2070
+ each_issue.startswith("Line ") and expected_span_fragment in each_issue
2071
+ for each_issue in issues
2072
+ ), f"isolation message must carry the def-line + span fragment, got: {issues!r}"
2073
+
2074
+
2075
+ def test_function_length_reports_only_in_scope_violation_on_terminal_edit() -> None:
2076
+ """A terminal diff-scoped Edit reports only the function whose changed-line
2077
+ span grew past the threshold; untouched oversized functions earlier in the
2078
+ file are out of scope and dropped, regardless of how many precede it."""
2079
+ leading_function_count = 6
2080
+ leading_functions = "\n".join(
2081
+ _oversized_function_source(f"leading_long_{each_index}")
2082
+ for each_index in range(leading_function_count)
2083
+ )
2084
+ short_target_before = "def target_function() -> int:\n return 1\n"
2085
+ grown_target_after = _oversized_function_source("target_function")
2086
+ prior_full_file = leading_functions + "\n" + short_target_before
2087
+ post_edit_full_file = leading_functions + "\n" + grown_target_after
2088
+ issues = code_rules_enforcer.validate_content(
2089
+ grown_target_after,
2090
+ "/project/src/many_functions.py",
2091
+ old_content=short_target_before,
2092
+ full_file_content=post_edit_full_file,
2093
+ prior_full_file_content=prior_full_file,
2094
+ )
2095
+ function_length_issues = [
2096
+ each_issue for each_issue in issues if "defined at line" in each_issue
2097
+ ]
2098
+ assert any(
2099
+ "target_function" in each_issue for each_issue in function_length_issues
2100
+ ), f"in-scope grown function must still block, got: {issues!r}"
2101
+ assert not any(
2102
+ "leading_long_" in each_issue for each_issue in function_length_issues
2103
+ ), f"untouched functions must stay out of scope, got: {function_length_issues!r}"
2104
+
2105
+
2106
+ def test_new_file_write_reports_every_in_scope_long_function_uncapped() -> None:
2107
+ """loop7-bugbot: a new-file Write passes ``all_changed_lines is None``; every
2108
+ line was just authored and is in scope, so every long function is reported
2109
+ with no ceiling on the count."""
2110
+ function_count = 6
2111
+ all_functions = "\n".join(
2112
+ _oversized_function_source(f"new_long_{each_index}")
2113
+ for each_index in range(function_count)
2114
+ )
2115
+ issues = code_rules_enforcer.validate_content(
2116
+ all_functions,
2117
+ "/project/src/freshly_written_module.py",
2118
+ old_content="",
2119
+ )
2120
+ function_length_issues = [
2121
+ each_issue for each_issue in issues if "defined at line" in each_issue
2122
+ ]
2123
+ assert len(function_length_issues) == function_count, (
2124
+ "every long function in a new file is in scope and must be reported, "
2125
+ f"got: {function_length_issues!r}"
2126
+ )
2127
+
2128
+
2129
+ def test_new_file_write_reports_every_in_scope_isolation_probe_uncapped() -> None:
2130
+ """loop7-bugbot: a new test file Write passes ``all_changed_lines is None``;
2131
+ every HOME probe is in scope, so each one is reported with no count ceiling."""
2132
+ probe_count = 6
2133
+ probing_tests = "".join(
2134
+ f"def test_probe_{each_index}() -> None:\n"
2135
+ f" home_dir_{each_index} = Path.home()\n"
2136
+ f" assert home_dir_{each_index}\n"
2137
+ for each_index in range(probe_count)
2138
+ )
2139
+ source = "from pathlib import Path\n" + probing_tests
2140
+ issues = code_rules_enforcer.validate_content(
2141
+ source,
2142
+ "/project/src/test_freshly_written_module.py",
2143
+ old_content="",
2144
+ )
2145
+ home_probe_issues = [
2146
+ each_issue for each_issue in issues if "Path.home" in each_issue
2147
+ ]
2148
+ assert len(home_probe_issues) == probe_count, (
2149
+ "every HOME probe in a new test file is in scope and must be reported, "
2150
+ f"got: {home_probe_issues!r}"
2151
+ )
2152
+
2153
+
2154
+ def test_banned_noun_word_defers_scope_to_caller_when_requested() -> None:
2155
+ """loop7-P1: when the gate sets the deferral flag, the banned-noun check must
2156
+ return every violation so ``split_violations_by_scope`` can scope by added
2157
+ line before reporting the in-scope set."""
2158
+ binding_count = 5
2159
+ source = "".join(
2160
+ f"BINDING_{each_index}_RESULT_PATH = {each_index}\n"
2161
+ for each_index in range(binding_count)
2162
+ )
2163
+ issues = code_rules_enforcer.check_banned_noun_word_boundary(
2164
+ source,
2165
+ "/project/src/many_nouns.py",
2166
+ defer_scope_to_caller=True,
2167
+ )
2168
+ assert len(issues) == binding_count, (
2169
+ "deferral must return every banned-noun violation, "
2170
+ f"got: {issues!r}"
2171
+ )
2172
+
2173
+
2174
+ def test_banned_noun_word_keeps_in_scope_binding_among_untouched_ones() -> None:
2175
+ """loop7-P1: an Edit whose changed line introduces a banned-noun identifier
2176
+ among several pre-existing untouched ones must still report the new in-scope
2177
+ binding while leaving the untouched bindings out of scope."""
2178
+ leading_count = 5
2179
+ leading_bindings = "".join(
2180
+ f"LEADING_{each_index}_RESULT_PATH = {each_index}\n"
2181
+ for each_index in range(leading_count)
2182
+ )
2183
+ target_before = "PLACEHOLDER_NAME = 0\n"
2184
+ target_after = "INTRODUCED_RESULT_PATH = 0\n"
2185
+ prior_full_file = leading_bindings + target_before
2186
+ post_edit_full_file = leading_bindings + target_after
2187
+ issues = code_rules_enforcer.validate_content(
2188
+ target_after,
2189
+ "/project/src/many_nouns.py",
2190
+ old_content=target_before,
2191
+ full_file_content=post_edit_full_file,
2192
+ prior_full_file_content=prior_full_file,
2193
+ )
2194
+ assert any(
2195
+ "INTRODUCED_RESULT_PATH" in each_issue for each_issue in issues
2196
+ ), f"in-scope banned-noun past the cap window must still block, got: {issues!r}"
2197
+
2198
+
2199
+ def test_module_import_inside_top_level_try_is_retained_in_alias_map() -> None:
2200
+ """loop7-P2 (2566): a module-level ``try: import os as o`` is genuinely
2201
+ module-scoped; its alias must enter the shared canonicalization map so a
2202
+ later ``o.path.expanduser('~')`` inside a test is flagged."""
2203
+ source = (
2204
+ "try:\n"
2205
+ " import os as o\n"
2206
+ "except ImportError:\n"
2207
+ " o = None\n"
2208
+ "def test_reads_home() -> None:\n"
2209
+ " discovered = o.path.expanduser('~')\n"
2210
+ " assert discovered\n"
2211
+ )
2212
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
2213
+ source, "/project/src/test_optional_import.py"
2214
+ )
2215
+ assert any(
2216
+ "test_reads_home" in each_issue for each_issue in issues
2217
+ ), f"module import nested in top-level try must be retained, got: {issues!r}"
2218
+
2219
+
2220
+ def test_direct_module_aliased_import_is_retained_in_alias_map() -> None:
2221
+ """loop7-P2 (2566): a plain top-level ``import os as o`` must still resolve so
2222
+ ``o.path.expanduser('~')`` inside a test is flagged."""
2223
+ source = (
2224
+ "import os as o\n"
2225
+ "def test_reads_home() -> None:\n"
2226
+ " discovered = o.path.expanduser('~')\n"
2227
+ " assert discovered\n"
2228
+ )
2229
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
2230
+ source, "/project/src/test_direct_import.py"
2231
+ )
2232
+ assert any(
2233
+ "test_reads_home" in each_issue for each_issue in issues
2234
+ ), f"direct module aliased import must resolve, got: {issues!r}"
2235
+
2236
+
2237
+ def test_function_local_import_does_not_enter_shared_alias_map() -> None:
2238
+ """loop7-P2 (2566): an import inside one test must not canonicalize a
2239
+ same-named reference in a sibling test that never imported it."""
2240
+ source = (
2241
+ "def test_imports_locally() -> None:\n"
2242
+ " import os as o\n"
2243
+ " assert o\n"
2244
+ "def test_sibling_uses_o() -> None:\n"
2245
+ " o = make_unrelated_object()\n"
2246
+ " discovered = o.path.expanduser('~')\n"
2247
+ " assert discovered\n"
2248
+ )
2249
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
2250
+ source, "/project/src/test_local_import_scope.py"
2251
+ )
2252
+ assert not any(
2253
+ "test_sibling_uses_o" in each_issue for each_issue in issues
2254
+ ), f"function-local import must not leak to a sibling test, got: {issues!r}"
2255
+
2256
+
2257
+ def test_import_inside_nested_helper_does_not_leak_to_outer_test_overlay() -> None:
2258
+ """loop7-P2 (2690): an import inside a standalone nested helper runs in its own
2259
+ callable scope; its alias must not enter the outer test's overlay and flag a
2260
+ sibling reference in the outer body."""
2261
+ source = (
2262
+ "def test_outer() -> None:\n"
2263
+ " def nested_helper() -> None:\n"
2264
+ " import os as o\n"
2265
+ " assert o\n"
2266
+ " o = make_unrelated_object()\n"
2267
+ " discovered = o.path.expanduser('~')\n"
2268
+ " assert discovered\n"
2269
+ )
2270
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
2271
+ source, "/project/src/test_nested_helper_scope.py"
2272
+ )
2273
+ assert not any(
2274
+ "test_outer" in each_issue for each_issue in issues
2275
+ ), f"nested-helper import must not leak to the outer test, got: {issues!r}"
2276
+
2277
+
2278
+ def test_environ_binding_inside_nested_helper_does_not_leak_to_outer_test() -> None:
2279
+ """loop7-P2 (2690 sibling): an ``os.environ`` binding inside a standalone
2280
+ nested helper runs in its own scope; a same-named outer reference must not be
2281
+ attributed to that binding."""
2282
+ source = (
2283
+ "import os\n"
2284
+ "def test_outer() -> None:\n"
2285
+ " def nested_helper() -> None:\n"
2286
+ " captured = os.environ\n"
2287
+ " assert captured\n"
2288
+ " captured = make_unrelated_mapping()\n"
2289
+ " discovered = captured['HOME']\n"
2290
+ " assert discovered\n"
2291
+ )
2292
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
2293
+ source, "/project/src/test_environ_nested_scope.py"
2294
+ )
2295
+ assert not any(
2296
+ "test_outer" in each_issue for each_issue in issues
2297
+ ), f"nested-helper environ binding must not leak to the outer test, got: {issues!r}"
2298
+
2299
+
2300
+ def test_pathlib_binding_inside_nested_helper_does_not_leak_to_outer_test() -> None:
2301
+ """loop7-P2 (2690 sibling): a home-tilde ``Path('~')`` binding inside a
2302
+ standalone nested helper runs in its own scope; a same-named outer
2303
+ ``.expanduser()`` call must not be attributed to that binding."""
2304
+ source = (
2305
+ "from pathlib import Path\n"
2306
+ "def test_outer() -> None:\n"
2307
+ " def nested_helper() -> None:\n"
2308
+ " candidate = Path('~/config')\n"
2309
+ " assert candidate\n"
2310
+ " candidate = make_unrelated_path()\n"
2311
+ " discovered = candidate.expanduser()\n"
2312
+ " assert discovered\n"
2313
+ )
2314
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
2315
+ source, "/project/src/test_pathlib_nested_scope.py"
2316
+ )
2317
+ assert not any(
2318
+ "test_outer" in each_issue for each_issue in issues
2319
+ ), f"nested-helper pathlib binding must not leak to the outer test, got: {issues!r}"
2320
+
2321
+
2322
+ def test_banned_noun_edit_drops_untouched_out_of_scope_binding() -> None:
2323
+ """An Edit that touches none of the banned-noun bindings reports nothing —
2324
+ the check now routes through the reconstructed effective content and the
2325
+ edit's changed lines, exactly like check_function_length, so an untouched
2326
+ binding outside the edit hunk must not block."""
2327
+ leading = "".join(
2328
+ f"LEADING_{each_index}_RESULT_PATH = {each_index}\n" for each_index in range(5)
2329
+ )
2330
+ edited_tail = "def compute_total() -> int:\n running_sum = 0\n return running_sum\n"
2331
+ prior_full_file = leading + "def compute_total() -> int:\n running_sum = 0\n return 0\n"
2332
+ post_edit_full_file = leading + edited_tail
2333
+ issues = code_rules_enforcer.validate_content(
2334
+ edited_tail,
2335
+ "/project/src/many_nouns.py",
2336
+ old_content="def compute_total() -> int:\n running_sum = 0\n return 0\n",
2337
+ full_file_content=post_edit_full_file,
2338
+ prior_full_file_content=prior_full_file,
2339
+ )
2340
+ assert not any(
2341
+ "RESULT_PATH" in each_issue for each_issue in issues
2342
+ ), f"untouched banned-noun bindings must stay out of scope, got: {issues!r}"
2343
+
2344
+
2345
+ def test_banned_noun_edit_keeps_touched_binding_in_scope() -> None:
2346
+ """An Edit whose changed line introduces a banned-noun binding reports it,
2347
+ using the reconstructed effective content and the edit's changed lines."""
2348
+ leading = "".join(
2349
+ f"LEADING_{each_index}_VALUE_PATH = {each_index}\n" for each_index in range(5)
2350
+ )
2351
+ prior_tail = "PLACEHOLDER_NAME = 0\n"
2352
+ edited_tail = "INTRODUCED_RESULT_PATH = 0\n"
2353
+ prior_full_file = leading + prior_tail
2354
+ post_edit_full_file = leading + edited_tail
2355
+ issues = code_rules_enforcer.validate_content(
2356
+ edited_tail,
2357
+ "/project/src/introduces_noun.py",
2358
+ old_content=prior_tail,
2359
+ full_file_content=post_edit_full_file,
2360
+ prior_full_file_content=prior_full_file,
2361
+ )
2362
+ assert any(
2363
+ "INTRODUCED_RESULT_PATH" in each_issue for each_issue in issues
2364
+ ), f"introduced banned-noun binding must block, got: {issues!r}"
2365
+
2366
+
2367
+ def test_banned_noun_message_carries_binding_line_span() -> None:
2368
+ """A banned-noun binding carries its own binding line as a one-line span so
2369
+ the commit gate reconstructs it through the same shared span mechanism the
2370
+ other diff-scoped checks use, while keeping the Line N: prefix intact. The
2371
+ binding-line granularity matches the companion exact-match
2372
+ check_banned_identifiers and avoids re-flagging a pre-existing binding when
2373
+ an unrelated line of its enclosing function is edited."""
2374
+ source = (
2375
+ "def aggregate() -> list[int]:\n"
2376
+ " canned_results = [1, 2, 3]\n"
2377
+ " return canned_results\n"
2378
+ )
2379
+ issues = code_rules_enforcer.check_banned_noun_word_boundary(
2380
+ source, "/project/src/has_noun.py"
2381
+ )
2382
+ binding_line = 2
2383
+ expected_fragment = f"(binding span at line {binding_line}, spanning 1 lines)"
2384
+ assert any(
2385
+ each_issue.startswith(f"Line {binding_line}:") and expected_fragment in each_issue
2386
+ for each_issue in issues
2387
+ ), f"banned-noun message must carry the binding-line span fragment, got: {issues!r}"
2388
+
2389
+
2390
+ def test_banned_noun_message_module_level_binding_spans_one_line() -> None:
2391
+ """A module-level banned-noun binding spans its own binding line alone
2392
+ (span 1)."""
2393
+ source = "SAFE_OUTPUT_PATH = '/var/run/x'\n"
2394
+ issues = code_rules_enforcer.check_banned_noun_word_boundary(
2395
+ source, "/project/src/module_noun.py"
2396
+ )
2397
+ expected_fragment = "(binding span at line 1, spanning 1 lines)"
2398
+ assert any(expected_fragment in each_issue for each_issue in issues), (
2399
+ f"module-level banned-noun span must be one line, got: {issues!r}"
2400
+ )
2401
+
2402
+
2403
+ def test_banned_noun_edit_does_not_reflag_param_when_unrelated_body_line_changes() -> None:
2404
+ """Editing a body line of a function that already has a banned-noun
2405
+ parameter must not re-flag that pre-existing parameter: the binding-line
2406
+ span keeps the parameter out of scope unless its own declaration line is in
2407
+ the changed set."""
2408
+ prior_full_file = (
2409
+ "def transform(canned_results: int) -> int:\n"
2410
+ " midpoint = canned_results\n"
2411
+ " return midpoint\n"
2412
+ )
2413
+ post_edit_full_file = (
2414
+ "def transform(canned_results: int) -> int:\n"
2415
+ " midpoint = canned_results + 1\n"
2416
+ " return midpoint\n"
2417
+ )
2418
+ issues = code_rules_enforcer.validate_content(
2419
+ " midpoint = canned_results + 1\n",
2420
+ "/project/src/has_param.py",
2421
+ old_content=" midpoint = canned_results\n",
2422
+ full_file_content=post_edit_full_file,
2423
+ prior_full_file_content=prior_full_file,
2424
+ )
2425
+ assert not any(
2426
+ "canned_results" in each_issue for each_issue in issues
2427
+ ), f"pre-existing param must not re-flag on unrelated body edit, got: {issues!r}"
2428
+
2429
+
2430
+ def test_unreadable_prior_yields_no_prior_and_no_reconstruction() -> None:
2431
+ """When the on-disk prior cannot be read for an Edit, the prior/post helper
2432
+ returns (None, None): a missing prior must not be fabricated as an empty
2433
+ string that would diff every line as changed and defeat edit scoping."""
2434
+ missing_path = "/project/src/does_not_exist_anywhere.py"
2435
+ prior_content, post_edit_content = code_rules_enforcer.prior_and_post_edit_content(
2436
+ missing_path,
2437
+ old_string="placeholder = 0\n",
2438
+ new_string="placeholder = 1\n",
2439
+ )
2440
+ assert prior_content is None
2441
+ assert post_edit_content is None
2442
+
2443
+
2444
+ def test_readable_prior_yields_consistent_prior_and_reconstruction(tmp_path) -> None:
2445
+ """When the prior reads cleanly, the helper returns the same prior content it
2446
+ reconstructed the post-edit view from, so the two never diverge across two
2447
+ independent reads."""
2448
+ source_file = tmp_path / "module.py"
2449
+ original = "alpha = 1\nbeta = 2\n"
2450
+ source_file.write_text(original, encoding="utf-8")
2451
+ prior_content, post_edit_content = code_rules_enforcer.prior_and_post_edit_content(
2452
+ str(source_file),
2453
+ old_string="beta = 2\n",
2454
+ new_string="beta = 3\n",
2455
+ )
2456
+ assert prior_content == original
2457
+ assert post_edit_content == "alpha = 1\nbeta = 3\n"
2458
+ changed = code_rules_enforcer.changed_line_numbers(prior_content, post_edit_content)
2459
+ assert changed == {2}
2460
+
2461
+
2462
+ def _run_main_with_edit_payload(
2463
+ file_path: str,
2464
+ old_string: str,
2465
+ new_string: str,
2466
+ monkeypatch: object,
2467
+ capsys: object,
2468
+ ) -> str:
2469
+ """Drive ``main()`` through its stdin entry point for an Edit and return stdout.
2470
+
2471
+ Args:
2472
+ file_path: The on-disk path the Edit targets.
2473
+ old_string: The Edit's ``old_string`` fragment.
2474
+ new_string: The Edit's ``new_string`` fragment.
2475
+ monkeypatch: The pytest fixture used to redirect ``sys.stdin``.
2476
+ capsys: The pytest fixture used to capture the deny payload on stdout.
2477
+
2478
+ Returns:
2479
+ The captured stdout, which holds the deny payload when violations fire.
2480
+ """
2481
+ edit_payload = json.dumps(
2482
+ {
2483
+ "tool_name": "Edit",
2484
+ "tool_input": {
2485
+ "file_path": file_path,
2486
+ "old_string": old_string,
2487
+ "new_string": new_string,
2488
+ },
2489
+ }
2490
+ )
2491
+ getattr(monkeypatch, "setattr")(code_rules_enforcer.sys, "stdin", io.StringIO(edit_payload))
2492
+ try:
2493
+ code_rules_enforcer.main()
2494
+ except SystemExit:
2495
+ pass
2496
+ captured = getattr(capsys, "readouterr")()
2497
+ return captured.out
2498
+
2499
+
2500
+ def test_edit_with_missing_old_string_runs_whole_file_against_on_disk_content(
2501
+ tmp_path_factory: object, monkeypatch: object, capsys: object,
2502
+ ) -> None:
2503
+ """When an Edit's old_string is absent from the file, ``prior_and_post_edit_content``
2504
+ yields ``(None, None)``; ``main()`` must analyze the real on-disk file whole-file
2505
+ rather than the new_string fragment, so an oversized function elsewhere in the
2506
+ file is still reported with its true line numbers."""
2507
+ production_directory = getattr(tmp_path_factory, "mktemp")("production_pkg")
2508
+ untouched_long_function = _oversized_function_source("untouched_long")
2509
+ short_helper = "def short_helper() -> int:\n return 1\n"
2510
+ on_disk_content = untouched_long_function + "\n" + short_helper
2511
+ source_file = production_directory / "edited_module.py"
2512
+ source_file.write_text(on_disk_content, encoding="utf-8")
2513
+ absent_fragment_old = "def absent_function() -> int:\n return 0\n"
2514
+ short_fragment_new = "def absent_function() -> int:\n return 2\n"
2515
+ stdout = _run_main_with_edit_payload(
2516
+ str(source_file), absent_fragment_old, short_fragment_new, monkeypatch, capsys,
2517
+ )
2518
+ assert "untouched_long" in stdout, (
2519
+ "an unreconstructable Edit must fall back to whole-file on-disk analysis, "
2520
+ f"so the oversized function is still reported; got stdout: {stdout!r}"
2521
+ )
2522
+
2523
+
2524
+ def test_edit_with_unreadable_file_does_not_analyze_fragment_as_whole_file(
2525
+ tmp_path_factory: object, monkeypatch: object, capsys: object,
2526
+ ) -> None:
2527
+ """When the on-disk file cannot be read, no well-defined post-edit content
2528
+ exists; ``main()`` must exit cleanly rather than analyze the new_string
2529
+ fragment as if it were the whole file, so the fragment's own function-length
2530
+ violation does not surface as a deny payload."""
2531
+ production_directory = getattr(tmp_path_factory, "mktemp")("production_pkg")
2532
+ missing_path = str(production_directory / "never_created.py")
2533
+ oversized_fragment_old = "def grows() -> int:\n return 0\n"
2534
+ oversized_fragment_new = _oversized_function_source("grows")
2535
+ stdout = _run_main_with_edit_payload(
2536
+ missing_path,
2537
+ oversized_fragment_old,
2538
+ oversized_fragment_new,
2539
+ monkeypatch,
2540
+ capsys,
2541
+ )
2542
+ assert stdout == "", (
2543
+ "an unreadable Edit target has no well-defined whole-file content, so the "
2544
+ f"fragment must not be analyzed as the whole file; got stdout: {stdout!r}"
2545
+ )
2546
+
2547
+
2548
+ def test_isolation_check_exempts_usefixtures_monkeypatch_decorator() -> None:
2549
+ """A test isolated via ``@pytest.mark.usefixtures("monkeypatch")`` injects the
2550
+ monkeypatch fixture without a signature parameter and must be exempt from the
2551
+ HOME/TMP probe, mirroring the signature-parameter suppression."""
2552
+ source = (
2553
+ "import os\n"
2554
+ "import pytest\n"
2555
+ "@pytest.mark.usefixtures('monkeypatch')\n"
2556
+ "def test_reads_home() -> None:\n"
2557
+ " home = os.environ['HOME']\n"
2558
+ " print(home)\n"
2559
+ )
2560
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
2561
+ source, "/project/src/test_module.py"
2562
+ )
2563
+ assert issues == [], (
2564
+ "a test decorated with usefixtures('monkeypatch') is isolated and must "
2565
+ f"not be flagged; got: {issues!r}"
2566
+ )
2567
+
2568
+
2569
+ def test_isolation_check_still_flags_usefixtures_without_monkeypatch() -> None:
2570
+ """``@pytest.mark.usefixtures("tmp_path")`` does not inject monkeypatch, so a
2571
+ HOME probe in its body must still be flagged."""
2572
+ source = (
2573
+ "import os\n"
2574
+ "import pytest\n"
2575
+ "@pytest.mark.usefixtures('tmp_path')\n"
2576
+ "def test_reads_home() -> None:\n"
2577
+ " home = os.environ['HOME']\n"
2578
+ " print(home)\n"
2579
+ )
2580
+ issues = code_rules_enforcer.check_tests_use_isolated_filesystem_paths(
2581
+ source, "/project/src/test_module.py"
2582
+ )
2583
+ assert any("HOME" in each_issue for each_issue in issues), (
2584
+ "usefixtures('tmp_path') does not intercept env reads, so the HOME probe "
2585
+ f"must still be flagged; got: {issues!r}"
2586
+ )
2587
+
2588
+
2589
+ def test_banned_noun_word_boundary_flags_plural_results_identifier() -> None:
2590
+ """A plural banned noun ('results') embedded in an identifier must flag.
2591
+
2592
+ ``ALL_BANNED_NOUN_WORDS`` contains plural forms (results, outputs,
2593
+ responses, values, items) in addition to the singular nouns, so an
2594
+ identifier such as ``canned_results`` is flagged even though no singular
2595
+ exact-match identifier appears.
2596
+ """
2597
+ source = "canned_results = []\n"
2598
+ issues = code_rules_enforcer.check_banned_noun_word_boundary(
2599
+ source, "/project/src/pipeline.py"
2600
+ )
2601
+ assert any("canned_results" in each_issue for each_issue in issues), (
2602
+ "a plural banned-noun identifier must be flagged by the word-boundary "
2603
+ f"check; got: {issues!r}"
2604
+ )