claude-dev-env 1.44.0 → 1.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +9 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +426 -85
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +20 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
- package/_shared/pr-loop/scripts/reviews_disabled.py +82 -9
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +630 -21
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +15 -0
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +57 -0
- package/agents/clean-coder.md +7 -1
- package/agents/code-quality-agent.md +8 -5
- package/hooks/blocking/code_rules_enforcer.py +1562 -37
- package/hooks/blocking/content_search_zoekt_redirect_guidance.py +19 -0
- package/hooks/blocking/open_questions_in_plans_blocker.py +249 -0
- package/hooks/blocking/test_code_rules_enforcer.py +1389 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +292 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +46 -8
- package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +189 -0
- package/hooks/blocking/test_code_rules_enforcer_function_length.py +210 -0
- package/hooks/blocking/test_code_rules_enforcer_tests_isolate_home_temp.py +1512 -0
- package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +9 -5
- package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +30 -0
- package/hooks/blocking/test_open_questions_in_plans_blocker.py +790 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/banned_identifiers_constants.py +19 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +129 -2
- package/hooks/hooks_constants/open_questions_in_plans_blocker_constants.py +35 -0
- package/hooks/hooks_constants/test_open_questions_in_plans_blocker_constants.py +125 -0
- package/package.json +1 -1
- package/skills/_shared/pr-loop/scripts/_path_resolver.py +34 -13
- package/skills/_shared/pr-loop/scripts/init_loop_state.py +1 -2
- package/skills/_shared/pr-loop/scripts/teardown_worktrees.py +1 -4
- package/skills/_shared/pr-loop/scripts/test__path_resolver.py +57 -0
- package/skills/_shared/pr-loop/scripts/test_init_loop_state.py +48 -0
- package/skills/_shared/pr-loop/scripts/test_teardown_worktrees.py +59 -0
- package/skills/bugteam/PROMPTS.md +48 -12
- package/skills/bugteam/reference/team-setup.md +4 -2
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +487 -76
- package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +22 -1
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +602 -12
- package/skills/pr-converge/SKILL.md +5 -0
- package/skills/pr-converge/reference/per-tick.md +14 -5
- package/skills/pr-converge/reference/state-schema.md +7 -3
- package/skills/pr-converge/scripts/check_convergence.py +27 -1
- package/skills/pr-converge/scripts/test_check_convergence.py +28 -0
|
@@ -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
|
+
)
|