claude-dev-env 1.60.0 → 1.62.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 +12 -0
- package/audit-rubrics/category_rubrics/category-f-silent-failures.md +1 -1
- package/audit-rubrics/prompts/category-e-dead-code.md +17 -4
- package/audit-rubrics/prompts/category-f-silent-failures.md +1 -0
- package/bin/install.mjs +1 -1
- package/docs/CODE_RULES.md +2 -2
- package/hooks/blocking/code_rules_annotations_length.py +189 -10
- package/hooks/blocking/code_rules_dead_config_field.py +321 -0
- package/hooks/blocking/code_rules_enforcer.py +14 -0
- package/hooks/blocking/code_rules_orphan_css_class.py +196 -0
- package/hooks/blocking/config/verified_commit_constants.py +15 -2
- package/hooks/blocking/destructive_command_blocker.py +483 -61
- package/hooks/blocking/test_code_rules_enforcer_annotations.py +240 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +432 -0
- package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +82 -0
- package/hooks/blocking/test_code_rules_enforcer_orphan_css_class.py +196 -0
- package/hooks/blocking/test_destructive_command_blocker.py +213 -0
- package/hooks/blocking/test_verification_verdict_store.py +212 -0
- package/hooks/blocking/test_verified_commit_gate.py +159 -0
- package/hooks/blocking/test_verifier_verdict_minter.py +74 -95
- package/hooks/blocking/verification_verdict_store.py +240 -0
- package/hooks/blocking/verified_commit_gate.py +31 -9
- package/hooks/blocking/verifier_verdict_minter.py +46 -124
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
- package/hooks/hooks_constants/dead_config_field_constants.py +39 -0
- package/hooks/hooks_constants/destructive_command_segment_constants.py +15 -0
- package/hooks/hooks_constants/orphan_css_class_constants.py +40 -0
- package/hooks/validation/mypy_validator.py +59 -7
- package/hooks/validation/test_mypy_validator.py +94 -0
- package/package.json +1 -1
- package/rules/orphan-css-class.md +23 -0
- package/skills/autoconverge/reference/gotchas.md +11 -0
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +5 -1
- package/skills/autoconverge/workflow/converge.contract.test.mjs +202 -13
- package/skills/autoconverge/workflow/converge.mjs +392 -51
- package/skills/autoconverge/workflow/test_render_report.py +55 -0
- package/skills/doc-gist/SKILL.md +3 -2
- package/skills/doc-gist/references/examples/21-decision-signoff.html +546 -0
- package/skills/doc-gist/references/examples/README.md +2 -2
- package/skills/task-build/SKILL.md +31 -0
|
@@ -320,3 +320,243 @@ def test_should_flag_undefaulted_fixture_before_defaulted_one() -> None:
|
|
|
320
320
|
)
|
|
321
321
|
|
|
322
322
|
|
|
323
|
+
def test_should_flag_unused_known_fixture_parameter_in_test_file() -> None:
|
|
324
|
+
source = (
|
|
325
|
+
"from pathlib import Path\n"
|
|
326
|
+
"def test_omits_config(tmp_path: Path) -> None:\n"
|
|
327
|
+
" command = build_command('module.py', None)\n"
|
|
328
|
+
" assert command[-1] == 'module.py'\n"
|
|
329
|
+
)
|
|
330
|
+
issues = code_rules_enforcer.check_unused_known_pytest_fixture_parameters(
|
|
331
|
+
source, TEST_FILE_PATH
|
|
332
|
+
)
|
|
333
|
+
assert any(
|
|
334
|
+
"tmp_path" in each_issue for each_issue in issues
|
|
335
|
+
), f"Expected unused tmp_path fixture parameter flagged, got: {issues}"
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def test_should_not_flag_used_known_fixture_parameter() -> None:
|
|
339
|
+
source = (
|
|
340
|
+
"from pathlib import Path\n"
|
|
341
|
+
"def test_includes_config(tmp_path: Path) -> None:\n"
|
|
342
|
+
" config_file = tmp_path / 'pyproject.toml'\n"
|
|
343
|
+
" assert config_file.name == 'pyproject.toml'\n"
|
|
344
|
+
)
|
|
345
|
+
issues = code_rules_enforcer.check_unused_known_pytest_fixture_parameters(
|
|
346
|
+
source, TEST_FILE_PATH
|
|
347
|
+
)
|
|
348
|
+
assert issues == [], (
|
|
349
|
+
f"A referenced fixture parameter must not be flagged, got: {issues}"
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def test_should_not_flag_unused_ordinary_test_parameter() -> None:
|
|
354
|
+
source = "def test_thing(some_value):\n return 1\n"
|
|
355
|
+
issues = code_rules_enforcer.check_unused_known_pytest_fixture_parameters(
|
|
356
|
+
source, TEST_FILE_PATH
|
|
357
|
+
)
|
|
358
|
+
assert issues == [], (
|
|
359
|
+
f"Only known pytest fixtures are checked, not arbitrary params, got: {issues}"
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def test_should_not_flag_unused_fixture_outside_test_files() -> None:
|
|
364
|
+
source = "def build(tmp_path):\n return 1\n"
|
|
365
|
+
issues = code_rules_enforcer.check_unused_known_pytest_fixture_parameters(
|
|
366
|
+
source, PRODUCTION_FILE_PATH
|
|
367
|
+
)
|
|
368
|
+
assert issues == [], (
|
|
369
|
+
f"This check applies to test files only, got: {issues}"
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def test_should_flag_unused_monkeypatch_fixture_parameter() -> None:
|
|
374
|
+
source = (
|
|
375
|
+
"import pytest\n"
|
|
376
|
+
"def test_env(monkeypatch: pytest.MonkeyPatch) -> None:\n"
|
|
377
|
+
" assert build_command('m.py', None)[-1] == 'm.py'\n"
|
|
378
|
+
)
|
|
379
|
+
issues = code_rules_enforcer.check_unused_known_pytest_fixture_parameters(
|
|
380
|
+
source, TEST_FILE_PATH
|
|
381
|
+
)
|
|
382
|
+
assert any(
|
|
383
|
+
"monkeypatch" in each_issue for each_issue in issues
|
|
384
|
+
), f"Expected unused monkeypatch fixture parameter flagged, got: {issues}"
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def test_should_count_attribute_access_as_fixture_use() -> None:
|
|
388
|
+
source = (
|
|
389
|
+
"import pytest\n"
|
|
390
|
+
"def test_env(monkeypatch: pytest.MonkeyPatch) -> None:\n"
|
|
391
|
+
" monkeypatch.setenv('A', 'B')\n"
|
|
392
|
+
)
|
|
393
|
+
issues = code_rules_enforcer.check_unused_known_pytest_fixture_parameters(
|
|
394
|
+
source, TEST_FILE_PATH
|
|
395
|
+
)
|
|
396
|
+
assert issues == [], (
|
|
397
|
+
f"Attribute access on the fixture counts as a use, got: {issues}"
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def test_should_count_nested_function_reference_as_fixture_use() -> None:
|
|
402
|
+
source = (
|
|
403
|
+
"from pathlib import Path\n"
|
|
404
|
+
"def test_board(tmp_path: Path) -> None:\n"
|
|
405
|
+
" def inner() -> Path:\n"
|
|
406
|
+
" return tmp_path\n"
|
|
407
|
+
" assert inner().name\n"
|
|
408
|
+
)
|
|
409
|
+
issues = code_rules_enforcer.check_unused_known_pytest_fixture_parameters(
|
|
410
|
+
source, TEST_FILE_PATH
|
|
411
|
+
)
|
|
412
|
+
assert issues == [], (
|
|
413
|
+
f"A reference inside a nested function counts as a use, got: {issues}"
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def test_should_not_flag_unused_fixture_in_decorated_fixture_function() -> None:
|
|
418
|
+
source = (
|
|
419
|
+
"import pytest\n"
|
|
420
|
+
"from pathlib import Path\n"
|
|
421
|
+
"@pytest.fixture\n"
|
|
422
|
+
"def board(tmp_path: Path) -> int:\n"
|
|
423
|
+
" return 1\n"
|
|
424
|
+
)
|
|
425
|
+
issues = code_rules_enforcer.check_unused_known_pytest_fixture_parameters(
|
|
426
|
+
source, TEST_FILE_PATH
|
|
427
|
+
)
|
|
428
|
+
assert issues == [], (
|
|
429
|
+
f"A fixture composing another fixture by injection alone is intentional "
|
|
430
|
+
f"and must not be flagged, got: {issues}"
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def test_should_count_comprehension_reference_as_fixture_use() -> None:
|
|
435
|
+
source = (
|
|
436
|
+
"import pytest\n"
|
|
437
|
+
"def test_log_lines(caplog: pytest.LogCaptureFixture) -> None:\n"
|
|
438
|
+
" messages = [each_record.message for each_record in caplog.records]\n"
|
|
439
|
+
" assert messages == []\n"
|
|
440
|
+
)
|
|
441
|
+
issues = code_rules_enforcer.check_unused_known_pytest_fixture_parameters(
|
|
442
|
+
source, TEST_FILE_PATH
|
|
443
|
+
)
|
|
444
|
+
assert issues == [], (
|
|
445
|
+
f"A reference inside a comprehension counts as a use, got: {issues}"
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def test_should_not_flag_unused_fixture_in_decorated_test_named_function() -> None:
|
|
450
|
+
source = (
|
|
451
|
+
"import pytest\n"
|
|
452
|
+
"from pathlib import Path\n"
|
|
453
|
+
"@pytest.fixture\n"
|
|
454
|
+
"def test_board(tmp_path: Path) -> int:\n"
|
|
455
|
+
" return 1\n"
|
|
456
|
+
)
|
|
457
|
+
issues = code_rules_enforcer.check_unused_known_pytest_fixture_parameters(
|
|
458
|
+
source, TEST_FILE_PATH
|
|
459
|
+
)
|
|
460
|
+
assert issues == [], (
|
|
461
|
+
f"A @pytest.fixture-decorated function named test_* is a fixture, not a "
|
|
462
|
+
f"test, and must not be flagged, got: {issues}"
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def test_should_not_flag_fixture_param_on_nested_test_named_helper() -> None:
|
|
467
|
+
source = (
|
|
468
|
+
"from pathlib import Path\n"
|
|
469
|
+
"def test_outer(tmp_path: Path) -> None:\n"
|
|
470
|
+
" assert tmp_path\n"
|
|
471
|
+
" def test_inner(tmp_path) -> None:\n"
|
|
472
|
+
" return None\n"
|
|
473
|
+
" test_inner(tmp_path)\n"
|
|
474
|
+
)
|
|
475
|
+
issues = code_rules_enforcer.check_unused_known_pytest_fixture_parameters(
|
|
476
|
+
source, TEST_FILE_PATH
|
|
477
|
+
)
|
|
478
|
+
assert issues == [], (
|
|
479
|
+
f"A fixture-named parameter on a function nested inside a test body is a "
|
|
480
|
+
f"local helper argument pytest never injects, got: {issues}"
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def test_should_count_augmented_assignment_as_fixture_reference() -> None:
|
|
485
|
+
source = "def test_aug(request) -> None:\n request += 1\n"
|
|
486
|
+
issues = code_rules_enforcer.check_unused_known_pytest_fixture_parameters(
|
|
487
|
+
source, TEST_FILE_PATH
|
|
488
|
+
)
|
|
489
|
+
assert issues == [], (
|
|
490
|
+
f"An augmented assignment to the fixture references it, got: {issues}"
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def test_should_count_del_as_fixture_reference() -> None:
|
|
495
|
+
source = "def test_d(tmp_path: Path) -> None:\n del tmp_path\n"
|
|
496
|
+
issues = code_rules_enforcer.check_unused_known_pytest_fixture_parameters(
|
|
497
|
+
source, TEST_FILE_PATH
|
|
498
|
+
)
|
|
499
|
+
assert issues == [], (
|
|
500
|
+
f"A del of the fixture references it, got: {issues}"
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def test_should_flag_unused_fixture_parameter_on_class_method() -> None:
|
|
505
|
+
source = (
|
|
506
|
+
"from pathlib import Path\n"
|
|
507
|
+
"class TestThing:\n"
|
|
508
|
+
" def test_method(self, tmp_path: Path) -> None:\n"
|
|
509
|
+
" assert 1 == 1\n"
|
|
510
|
+
)
|
|
511
|
+
issues = code_rules_enforcer.check_unused_known_pytest_fixture_parameters(
|
|
512
|
+
source, TEST_FILE_PATH
|
|
513
|
+
)
|
|
514
|
+
assert any(
|
|
515
|
+
"tmp_path" in each_issue for each_issue in issues
|
|
516
|
+
), f"An unused fixture on a class-body test method must be flagged, got: {issues}"
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def test_should_flag_unused_fixture_parameter_on_nested_class_method() -> None:
|
|
520
|
+
source = (
|
|
521
|
+
"from pathlib import Path\n"
|
|
522
|
+
"class TestOuter:\n"
|
|
523
|
+
" class TestInner:\n"
|
|
524
|
+
" def test_method(self, tmp_path: Path) -> None:\n"
|
|
525
|
+
" assert 1 == 1\n"
|
|
526
|
+
)
|
|
527
|
+
issues = code_rules_enforcer.check_unused_known_pytest_fixture_parameters(
|
|
528
|
+
source, TEST_FILE_PATH
|
|
529
|
+
)
|
|
530
|
+
assert any(
|
|
531
|
+
"tmp_path" in each_issue for each_issue in issues
|
|
532
|
+
), f"An unused fixture on a nested-class test method must be flagged, got: {issues}"
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def test_should_flag_fixture_when_body_only_plain_assigns_it() -> None:
|
|
536
|
+
source = "def test_x(tmp_path: Path) -> None:\n tmp_path = 1\n"
|
|
537
|
+
issues = code_rules_enforcer.check_unused_known_pytest_fixture_parameters(
|
|
538
|
+
source, TEST_FILE_PATH
|
|
539
|
+
)
|
|
540
|
+
assert any(
|
|
541
|
+
"tmp_path" in each_issue for each_issue in issues
|
|
542
|
+
), f"A plain Store target is not a reference, so tmp_path is unused, got: {issues}"
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def test_annotation_check_skips_fixture_param_on_function_nested_in_test() -> None:
|
|
546
|
+
source = (
|
|
547
|
+
"from pathlib import Path\n"
|
|
548
|
+
"def test_outer(tmp_path: Path) -> None:\n"
|
|
549
|
+
" def inner(tmp_path) -> None:\n"
|
|
550
|
+
" return None\n"
|
|
551
|
+
" inner(tmp_path)\n"
|
|
552
|
+
)
|
|
553
|
+
issues = code_rules_enforcer.check_known_pytest_fixture_annotations(
|
|
554
|
+
source, TEST_FILE_PATH
|
|
555
|
+
)
|
|
556
|
+
assert issues == [], (
|
|
557
|
+
f"A fixture-named param on a function nested in a test body is not an "
|
|
558
|
+
f"injection site, and the correctly annotated outer param is fine, "
|
|
559
|
+
f"got: {issues}"
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
|
|
@@ -77,6 +77,7 @@ KNOWN_UNCAPPED_CHECKS_PENDING_REVIEW: frozenset[str] = frozenset(
|
|
|
77
77
|
"check_return_annotations",
|
|
78
78
|
"check_skip_decorators_in_tests",
|
|
79
79
|
"check_string_literal_magic",
|
|
80
|
+
"check_unused_known_pytest_fixture_parameters",
|
|
80
81
|
"check_unused_optional_parameters",
|
|
81
82
|
}
|
|
82
83
|
)
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import stat
|
|
7
|
+
import tempfile
|
|
8
|
+
from collections.abc import Callable, Iterator
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
ENFORCER_PATH = Path(__file__).resolve().parent / "code_rules_enforcer.py"
|
|
14
|
+
specification = importlib.util.spec_from_file_location("code_rules_enforcer", ENFORCER_PATH)
|
|
15
|
+
assert specification is not None and specification.loader is not None
|
|
16
|
+
code_rules_enforcer = importlib.util.module_from_spec(specification)
|
|
17
|
+
specification.loader.exec_module(code_rules_enforcer)
|
|
18
|
+
|
|
19
|
+
PRODUCTION_FILE_PATH = "packages/app/services/report.py"
|
|
20
|
+
TEST_FILE_PATH = "packages/app/services/test_report.py"
|
|
21
|
+
MIGRATION_FILE_PATH = "packages/app/migrations/0001_initial.py"
|
|
22
|
+
|
|
23
|
+
THEME_UPDATE_CONFIG_BODY = (
|
|
24
|
+
"from dataclasses import dataclass\n"
|
|
25
|
+
"\n"
|
|
26
|
+
"@dataclass\n"
|
|
27
|
+
"class ThemeUpdateConfig:\n"
|
|
28
|
+
" portal_url: str\n"
|
|
29
|
+
" debug_port: int\n"
|
|
30
|
+
" timeout_seconds: int\n"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _strip_read_only_and_retry(
|
|
35
|
+
removal_function: Callable[[str], object],
|
|
36
|
+
target_path: str,
|
|
37
|
+
_exc_info: BaseException,
|
|
38
|
+
) -> None:
|
|
39
|
+
try:
|
|
40
|
+
os.chmod(target_path, stat.S_IWRITE)
|
|
41
|
+
removal_function(target_path)
|
|
42
|
+
except OSError:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.fixture
|
|
47
|
+
def neutral_root() -> Iterator[Path]:
|
|
48
|
+
"""Yield a temp directory whose path carries no ``test_`` segment.
|
|
49
|
+
|
|
50
|
+
The enforcer's ``is_test_file`` keys on the full path string, and pytest's
|
|
51
|
+
own ``tmp_path`` directory name embeds the test name, which would make every
|
|
52
|
+
synthetic config path look like a test file. A neutral ``mkdtemp`` root
|
|
53
|
+
mirrors how a production config module path looks.
|
|
54
|
+
"""
|
|
55
|
+
neutral_directory = Path(tempfile.mkdtemp(prefix="deadcfg-")).resolve()
|
|
56
|
+
try:
|
|
57
|
+
yield neutral_directory
|
|
58
|
+
finally:
|
|
59
|
+
shutil.rmtree(neutral_directory, onexc=_strip_read_only_and_retry)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _check(source: str, file_path: str) -> list[str]:
|
|
63
|
+
return code_rules_enforcer.check_dead_config_dataclass_fields(source, file_path)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _build_config_package(
|
|
67
|
+
workflow_directory: Path,
|
|
68
|
+
config_body: str,
|
|
69
|
+
consumer_body: str,
|
|
70
|
+
) -> Path:
|
|
71
|
+
config_package = workflow_directory / "os_update_workflow"
|
|
72
|
+
config_package.mkdir(parents=True)
|
|
73
|
+
(config_package / "__init__.py").write_text("", encoding="utf-8")
|
|
74
|
+
config_path = config_package / "config.py"
|
|
75
|
+
config_path.write_text(config_body, encoding="utf-8")
|
|
76
|
+
(workflow_directory / "runner.py").write_text(consumer_body, encoding="utf-8")
|
|
77
|
+
return config_path
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_flags_config_field_read_by_no_production_module(neutral_root: Path) -> None:
|
|
81
|
+
consumer_body = (
|
|
82
|
+
"from os_update_workflow.config import ThemeUpdateConfig\n"
|
|
83
|
+
"\n"
|
|
84
|
+
"def run(configuration: ThemeUpdateConfig) -> None:\n"
|
|
85
|
+
" print(configuration.portal_url)\n"
|
|
86
|
+
" print(configuration.timeout_seconds)\n"
|
|
87
|
+
)
|
|
88
|
+
config_path = _build_config_package(
|
|
89
|
+
neutral_root / "workflow", THEME_UPDATE_CONFIG_BODY, consumer_body
|
|
90
|
+
)
|
|
91
|
+
issues = _check(THEME_UPDATE_CONFIG_BODY, str(config_path))
|
|
92
|
+
assert any("'debug_port'" in each_issue for each_issue in issues), (
|
|
93
|
+
f"Expected dead 'debug_port' flagged, got: {issues}"
|
|
94
|
+
)
|
|
95
|
+
assert not any(
|
|
96
|
+
"'portal_url'" in each_issue or "'timeout_seconds'" in each_issue for each_issue in issues
|
|
97
|
+
), f"Fields read in consumer must not be flagged, got: {issues}"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_does_not_flag_field_read_as_attribute_in_sibling_module(neutral_root: Path) -> None:
|
|
101
|
+
consumer_body = (
|
|
102
|
+
"from os_update_workflow.config import ThemeUpdateConfig\n"
|
|
103
|
+
"\n"
|
|
104
|
+
"def run(configuration: ThemeUpdateConfig) -> None:\n"
|
|
105
|
+
" print(configuration.portal_url)\n"
|
|
106
|
+
" print(configuration.debug_port)\n"
|
|
107
|
+
" print(configuration.timeout_seconds)\n"
|
|
108
|
+
)
|
|
109
|
+
config_path = _build_config_package(
|
|
110
|
+
neutral_root / "workflow", THEME_UPDATE_CONFIG_BODY, consumer_body
|
|
111
|
+
)
|
|
112
|
+
issues = _check(THEME_UPDATE_CONFIG_BODY, str(config_path))
|
|
113
|
+
assert issues == [], f"All fields are read in consumer, none must be flagged, got: {issues}"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_flags_field_read_only_by_test_module(neutral_root: Path) -> None:
|
|
117
|
+
workflow_directory = neutral_root / "workflow"
|
|
118
|
+
config_package = workflow_directory / "os_update_workflow"
|
|
119
|
+
config_package.mkdir(parents=True)
|
|
120
|
+
(config_package / "__init__.py").write_text("", encoding="utf-8")
|
|
121
|
+
config_path = config_package / "config.py"
|
|
122
|
+
config_path.write_text(THEME_UPDATE_CONFIG_BODY, encoding="utf-8")
|
|
123
|
+
test_body = (
|
|
124
|
+
"from os_update_workflow.config import ThemeUpdateConfig\n"
|
|
125
|
+
"\n"
|
|
126
|
+
"def test_debug_port_default() -> None:\n"
|
|
127
|
+
" cfg = ThemeUpdateConfig(portal_url='x', debug_port=9222, timeout_seconds=30)\n"
|
|
128
|
+
" assert cfg.debug_port == 9222\n"
|
|
129
|
+
" assert cfg.portal_url == 'x'\n"
|
|
130
|
+
" assert cfg.timeout_seconds == 30\n"
|
|
131
|
+
)
|
|
132
|
+
(workflow_directory / "test_config.py").write_text(test_body, encoding="utf-8")
|
|
133
|
+
issues = _check(THEME_UPDATE_CONFIG_BODY, str(config_path))
|
|
134
|
+
assert any("'debug_port'" in each_issue for each_issue in issues), (
|
|
135
|
+
f"Field read only by test code must still be flagged as dead-in-production, got: {issues}"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_ignores_non_config_named_dataclass(neutral_root: Path) -> None:
|
|
140
|
+
source = (
|
|
141
|
+
"from dataclasses import dataclass\n"
|
|
142
|
+
"\n"
|
|
143
|
+
"@dataclass\n"
|
|
144
|
+
"class ThemeMetadata:\n"
|
|
145
|
+
" title: str\n"
|
|
146
|
+
" debug_port: int\n"
|
|
147
|
+
)
|
|
148
|
+
workflow_directory = neutral_root / "workflow"
|
|
149
|
+
workflow_directory.mkdir(parents=True)
|
|
150
|
+
module_path = workflow_directory / "metadata.py"
|
|
151
|
+
module_path.write_text(source, encoding="utf-8")
|
|
152
|
+
issues = _check(source, str(module_path))
|
|
153
|
+
assert issues == [], (
|
|
154
|
+
f"Non-Config-named dataclasses are outside scope of this check, got: {issues}"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_does_not_flag_field_read_via_string_literal(neutral_root: Path) -> None:
|
|
159
|
+
consumer_body = (
|
|
160
|
+
"from os_update_workflow.config import ThemeUpdateConfig\n"
|
|
161
|
+
"\n"
|
|
162
|
+
"def read_field(configuration: ThemeUpdateConfig, field_name: str) -> object:\n"
|
|
163
|
+
" return getattr(configuration, 'debug_port')\n"
|
|
164
|
+
)
|
|
165
|
+
config_path = _build_config_package(
|
|
166
|
+
neutral_root / "workflow", THEME_UPDATE_CONFIG_BODY, consumer_body
|
|
167
|
+
)
|
|
168
|
+
issues = _check(THEME_UPDATE_CONFIG_BODY, str(config_path))
|
|
169
|
+
assert not any("'debug_port'" in each_issue for each_issue in issues), (
|
|
170
|
+
f"String literal getattr read must count as a field read, got: {issues}"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_does_not_flag_when_consumer_serializes_whole_instance_via_asdict(
|
|
175
|
+
neutral_root: Path,
|
|
176
|
+
) -> None:
|
|
177
|
+
consumer_body = (
|
|
178
|
+
"from dataclasses import asdict\n"
|
|
179
|
+
"from os_update_workflow.config import ThemeUpdateConfig\n"
|
|
180
|
+
"\n"
|
|
181
|
+
"def serialize(configuration: ThemeUpdateConfig) -> dict[str, object]:\n"
|
|
182
|
+
" return asdict(configuration)\n"
|
|
183
|
+
)
|
|
184
|
+
config_path = _build_config_package(
|
|
185
|
+
neutral_root / "workflow", THEME_UPDATE_CONFIG_BODY, consumer_body
|
|
186
|
+
)
|
|
187
|
+
issues = _check(THEME_UPDATE_CONFIG_BODY, str(config_path))
|
|
188
|
+
assert issues == [], (
|
|
189
|
+
f"asdict reads every field at once, so no field may be flagged, got: {issues}"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_does_not_flag_when_consumer_reads_instance_dict(neutral_root: Path) -> None:
|
|
194
|
+
consumer_body = (
|
|
195
|
+
"from os_update_workflow.config import ThemeUpdateConfig\n"
|
|
196
|
+
"\n"
|
|
197
|
+
"def serialize(configuration: ThemeUpdateConfig) -> dict[str, object]:\n"
|
|
198
|
+
" return dict(configuration.__dict__)\n"
|
|
199
|
+
)
|
|
200
|
+
config_path = _build_config_package(
|
|
201
|
+
neutral_root / "workflow", THEME_UPDATE_CONFIG_BODY, consumer_body
|
|
202
|
+
)
|
|
203
|
+
issues = _check(THEME_UPDATE_CONFIG_BODY, str(config_path))
|
|
204
|
+
assert issues == [], (
|
|
205
|
+
f"__dict__ read consumes every field at once, so none may be flagged, got: {issues}"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_does_not_flag_field_used_only_as_replace_keyword(neutral_root: Path) -> None:
|
|
210
|
+
consumer_body = (
|
|
211
|
+
"from dataclasses import replace\n"
|
|
212
|
+
"from os_update_workflow.config import ThemeUpdateConfig\n"
|
|
213
|
+
"\n"
|
|
214
|
+
"def repoint(configuration: ThemeUpdateConfig) -> ThemeUpdateConfig:\n"
|
|
215
|
+
" return replace(configuration, debug_port=9999)\n"
|
|
216
|
+
)
|
|
217
|
+
config_path = _build_config_package(
|
|
218
|
+
neutral_root / "workflow", THEME_UPDATE_CONFIG_BODY, consumer_body
|
|
219
|
+
)
|
|
220
|
+
issues = _check(THEME_UPDATE_CONFIG_BODY, str(config_path))
|
|
221
|
+
assert not any("'debug_port'" in each_issue for each_issue in issues), (
|
|
222
|
+
f"replace keyword usage of debug_port must count as a read, got: {issues}"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_does_not_flag_field_used_only_as_constructor_keyword(neutral_root: Path) -> None:
|
|
227
|
+
consumer_body = (
|
|
228
|
+
"from os_update_workflow.config import ThemeUpdateConfig\n"
|
|
229
|
+
"\n"
|
|
230
|
+
"def build() -> ThemeUpdateConfig:\n"
|
|
231
|
+
" return ThemeUpdateConfig(portal_url='x', debug_port=1, timeout_seconds=99)\n"
|
|
232
|
+
)
|
|
233
|
+
config_path = _build_config_package(
|
|
234
|
+
neutral_root / "workflow", THEME_UPDATE_CONFIG_BODY, consumer_body
|
|
235
|
+
)
|
|
236
|
+
issues = _check(THEME_UPDATE_CONFIG_BODY, str(config_path))
|
|
237
|
+
assert issues == [], (
|
|
238
|
+
f"Constructor keyword arguments name every field, so none may be flagged, got: {issues}"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def test_returns_empty_list_at_file_cap(
|
|
243
|
+
neutral_root: Path, monkeypatch: pytest.MonkeyPatch
|
|
244
|
+
) -> None:
|
|
245
|
+
monkeypatch.setattr("code_rules_dead_config_field.MAX_SCAN_ROOT_FILE_COUNT", 0)
|
|
246
|
+
config_path = _build_config_package(
|
|
247
|
+
neutral_root / "workflow",
|
|
248
|
+
THEME_UPDATE_CONFIG_BODY,
|
|
249
|
+
"def noop() -> None:\n pass\n",
|
|
250
|
+
)
|
|
251
|
+
issues = _check(THEME_UPDATE_CONFIG_BODY, str(config_path))
|
|
252
|
+
assert issues == [], f"File cap hit must return [] (cannot prove dead), got: {issues}"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_returns_empty_list_on_syntax_error(neutral_root: Path) -> None:
|
|
256
|
+
workflow_directory = neutral_root / "workflow"
|
|
257
|
+
workflow_directory.mkdir(parents=True)
|
|
258
|
+
broken_path = workflow_directory / "config.py"
|
|
259
|
+
broken_source = "@dataclass\nclass BrokenConfig(\n"
|
|
260
|
+
broken_path.write_text(broken_source, encoding="utf-8")
|
|
261
|
+
issues = _check(broken_source, str(broken_path))
|
|
262
|
+
assert issues == [], f"SyntaxError must return [], got: {issues}"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_is_skipped_on_test_file_destination() -> None:
|
|
266
|
+
issues = _check(THEME_UPDATE_CONFIG_BODY, TEST_FILE_PATH)
|
|
267
|
+
assert issues == [], f"Test file destinations are exempt, got: {issues}"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def test_is_skipped_on_migration_file_destination() -> None:
|
|
271
|
+
issues = _check(THEME_UPDATE_CONFIG_BODY, MIGRATION_FILE_PATH)
|
|
272
|
+
assert issues == [], f"Migration file destinations are exempt, got: {issues}"
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def test_real_world_shape_theme_update_config_with_dead_debug_port(neutral_root: Path) -> None:
|
|
276
|
+
workflow_directory = neutral_root / "workflow"
|
|
277
|
+
config_package = workflow_directory / "os_update_workflow"
|
|
278
|
+
config_package.mkdir(parents=True)
|
|
279
|
+
(config_package / "__init__.py").write_text("", encoding="utf-8")
|
|
280
|
+
config_path = config_package / "config.py"
|
|
281
|
+
config_path.write_text(THEME_UPDATE_CONFIG_BODY, encoding="utf-8")
|
|
282
|
+
orchestrator_body = (
|
|
283
|
+
"from os_update_workflow.config import ThemeUpdateConfig\n"
|
|
284
|
+
"\n"
|
|
285
|
+
"def build_session(configuration: ThemeUpdateConfig) -> None:\n"
|
|
286
|
+
" url = configuration.portal_url\n"
|
|
287
|
+
" seconds = configuration.timeout_seconds\n"
|
|
288
|
+
" print(url, seconds)\n"
|
|
289
|
+
)
|
|
290
|
+
(workflow_directory / "orchestrator.py").write_text(orchestrator_body, encoding="utf-8")
|
|
291
|
+
issues = _check(THEME_UPDATE_CONFIG_BODY, str(config_path))
|
|
292
|
+
assert any("'debug_port'" in each_issue for each_issue in issues), (
|
|
293
|
+
f"debug_port field unused in production must be flagged, got: {issues}"
|
|
294
|
+
)
|
|
295
|
+
assert not any(
|
|
296
|
+
"'portal_url'" in each_issue or "'timeout_seconds'" in each_issue for each_issue in issues
|
|
297
|
+
), f"Fields read in orchestrator must not be flagged, got: {issues}"
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def test_does_not_flag_field_used_only_via_augmented_assignment(neutral_root: Path) -> None:
|
|
301
|
+
consumer_body = (
|
|
302
|
+
"from os_update_workflow.config import ThemeUpdateConfig\n"
|
|
303
|
+
"\n"
|
|
304
|
+
"def run(configuration: ThemeUpdateConfig) -> None:\n"
|
|
305
|
+
" print(configuration.portal_url)\n"
|
|
306
|
+
" print(configuration.timeout_seconds)\n"
|
|
307
|
+
" configuration.debug_port += 1\n"
|
|
308
|
+
)
|
|
309
|
+
config_path = _build_config_package(
|
|
310
|
+
neutral_root / "workflow", THEME_UPDATE_CONFIG_BODY, consumer_body
|
|
311
|
+
)
|
|
312
|
+
issues = _check(THEME_UPDATE_CONFIG_BODY, str(config_path))
|
|
313
|
+
assert not any("'debug_port'" in each_issue for each_issue in issues), (
|
|
314
|
+
f"Augmented assignment reads debug_port before writing, so it is not dead, got: {issues}"
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def test_flags_field_read_only_via_whole_instance_comparison(neutral_root: Path) -> None:
|
|
319
|
+
"""A field read ONLY via whole-instance comparison IS flagged (accepted limitation).
|
|
320
|
+
|
|
321
|
+
Unlike the per-file dead-dataclass-field check, this cross-module check does
|
|
322
|
+
not suppress on a dataclass-dunder whole-instance read: instance comparison
|
|
323
|
+
(``left == right``) is not bound to a config instance, and tree-wide one
|
|
324
|
+
incidental ``==`` anywhere would disable the check on any realistic package.
|
|
325
|
+
A ``*Config`` field whose only production read is this whole-instance
|
|
326
|
+
comparison, and that is never read directly, is therefore flagged — a
|
|
327
|
+
documented, rare limitation.
|
|
328
|
+
"""
|
|
329
|
+
consumer_body = (
|
|
330
|
+
"from os_update_workflow.config import ThemeUpdateConfig\n"
|
|
331
|
+
"\n"
|
|
332
|
+
"def is_same(left: ThemeUpdateConfig, right: ThemeUpdateConfig) -> bool:\n"
|
|
333
|
+
" return left == right\n"
|
|
334
|
+
)
|
|
335
|
+
config_path = _build_config_package(
|
|
336
|
+
neutral_root / "workflow", THEME_UPDATE_CONFIG_BODY, consumer_body
|
|
337
|
+
)
|
|
338
|
+
issues = _check(THEME_UPDATE_CONFIG_BODY, str(config_path))
|
|
339
|
+
assert any("'debug_port'" in each_issue for each_issue in issues), (
|
|
340
|
+
f"Field read only via whole-instance comparison is flagged, got: {issues}"
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def test_flags_field_read_only_via_whole_instance_stringification(neutral_root: Path) -> None:
|
|
345
|
+
"""A field read ONLY via whole-instance stringification IS flagged (accepted limitation).
|
|
346
|
+
|
|
347
|
+
The cross-module check does not suppress on a formatted-string conversion of
|
|
348
|
+
a whole instance (``f'{configuration}'``): the f-string is not bound to a
|
|
349
|
+
config instance, and tree-wide one incidental f-string anywhere would disable
|
|
350
|
+
the check on any realistic package. A ``*Config`` field whose only production
|
|
351
|
+
read is this whole-instance stringification, and that is never read directly,
|
|
352
|
+
is therefore flagged — a documented, rare limitation.
|
|
353
|
+
"""
|
|
354
|
+
consumer_body = (
|
|
355
|
+
"from os_update_workflow.config import ThemeUpdateConfig\n"
|
|
356
|
+
"\n"
|
|
357
|
+
"def describe(configuration: ThemeUpdateConfig) -> str:\n"
|
|
358
|
+
" return f'{configuration}'\n"
|
|
359
|
+
)
|
|
360
|
+
config_path = _build_config_package(
|
|
361
|
+
neutral_root / "workflow", THEME_UPDATE_CONFIG_BODY, consumer_body
|
|
362
|
+
)
|
|
363
|
+
issues = _check(THEME_UPDATE_CONFIG_BODY, str(config_path))
|
|
364
|
+
assert any("'debug_port'" in each_issue for each_issue in issues), (
|
|
365
|
+
f"Field read only via whole-instance stringification is flagged, got: {issues}"
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def test_unrelated_string_method_does_not_suppress_so_dead_field_flagged(
|
|
370
|
+
neutral_root: Path,
|
|
371
|
+
) -> None:
|
|
372
|
+
"""An unrelated ``.replace(...)`` on a string must not suppress the tree.
|
|
373
|
+
|
|
374
|
+
``"some text".replace("a", "b")`` is a string method, not a ``dataclasses``-
|
|
375
|
+
qualified reflective consumer, so it does not make the check treat the tree
|
|
376
|
+
as a whole-instance read. A genuinely dead ``debug_port`` is still flagged.
|
|
377
|
+
"""
|
|
378
|
+
consumer_body = (
|
|
379
|
+
"from os_update_workflow.config import ThemeUpdateConfig\n"
|
|
380
|
+
"\n"
|
|
381
|
+
"def run(configuration: ThemeUpdateConfig) -> str:\n"
|
|
382
|
+
" print(configuration.portal_url)\n"
|
|
383
|
+
" print(configuration.timeout_seconds)\n"
|
|
384
|
+
" return 'some text'.replace('a', 'b')\n"
|
|
385
|
+
)
|
|
386
|
+
config_path = _build_config_package(
|
|
387
|
+
neutral_root / "workflow", THEME_UPDATE_CONFIG_BODY, consumer_body
|
|
388
|
+
)
|
|
389
|
+
issues = _check(THEME_UPDATE_CONFIG_BODY, str(config_path))
|
|
390
|
+
assert any("'debug_port'" in each_issue for each_issue in issues), (
|
|
391
|
+
f"Unrelated string .replace must not suppress, so dead debug_port is flagged, got: {issues}"
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def test_dataclasses_qualified_replace_call_suppresses_so_no_field_flagged(
|
|
396
|
+
neutral_root: Path,
|
|
397
|
+
) -> None:
|
|
398
|
+
"""A ``dataclasses.replace(cfg, ...)`` call suppresses the whole tree.
|
|
399
|
+
|
|
400
|
+
A genuine ``dataclasses``-qualified reflective consumer reads every field at
|
|
401
|
+
once, so the check is suppressed for the whole tree and no field is flagged.
|
|
402
|
+
"""
|
|
403
|
+
consumer_body = (
|
|
404
|
+
"import dataclasses\n"
|
|
405
|
+
"from os_update_workflow.config import ThemeUpdateConfig\n"
|
|
406
|
+
"\n"
|
|
407
|
+
"def repoint(configuration: ThemeUpdateConfig) -> ThemeUpdateConfig:\n"
|
|
408
|
+
" return dataclasses.replace(configuration, debug_port=9999)\n"
|
|
409
|
+
)
|
|
410
|
+
config_path = _build_config_package(
|
|
411
|
+
neutral_root / "workflow", THEME_UPDATE_CONFIG_BODY, consumer_body
|
|
412
|
+
)
|
|
413
|
+
issues = _check(THEME_UPDATE_CONFIG_BODY, str(config_path))
|
|
414
|
+
assert issues == [], (
|
|
415
|
+
f"dataclasses.replace reads every field at once, so none may be flagged, got: {issues}"
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def test_validate_content_dispatch_runs_dead_config_field_check(neutral_root: Path) -> None:
|
|
420
|
+
workflow_directory = neutral_root / "workflow"
|
|
421
|
+
config_package = workflow_directory / "os_update_workflow"
|
|
422
|
+
config_package.mkdir(parents=True)
|
|
423
|
+
(config_package / "__init__.py").write_text("", encoding="utf-8")
|
|
424
|
+
config_path = config_package / "config.py"
|
|
425
|
+
config_path.write_text(THEME_UPDATE_CONFIG_BODY, encoding="utf-8")
|
|
426
|
+
(workflow_directory / "runner.py").write_text(
|
|
427
|
+
"def noop() -> None:\n pass\n", encoding="utf-8"
|
|
428
|
+
)
|
|
429
|
+
issues = code_rules_enforcer.validate_content(THEME_UPDATE_CONFIG_BODY, str(config_path))
|
|
430
|
+
assert any("'debug_port'" in each_issue for each_issue in issues), (
|
|
431
|
+
f"Expected the enforcer dispatch to surface the dead config field, got: {issues}"
|
|
432
|
+
)
|