claude-dev-env 1.60.0 → 1.61.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 +4 -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/docs/CODE_RULES.md +2 -2
- package/hooks/blocking/code_rules_annotations_length.py +189 -10
- package/hooks/blocking/code_rules_enforcer.py +8 -0
- package/hooks/blocking/code_rules_orphan_css_class.py +196 -0
- package/hooks/blocking/config/verified_commit_constants.py +14 -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_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 +127 -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 +20 -8
- package/hooks/blocking/verifier_verdict_minter.py +46 -124
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -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/workflow/autoconverge_report_constants/render_report_constants.py +0 -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 +30 -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,82 @@
|
|
|
1
|
+
"""Meta-test asserting every check_* function is dispatched from validate_content.
|
|
2
|
+
|
|
3
|
+
The per-check test modules each prove one ``check_*`` function flags the right
|
|
4
|
+
violation, but none proves the enforcer actually calls that function. A refactor
|
|
5
|
+
that drops a dispatch line from ``validate_content`` leaves every per-check test
|
|
6
|
+
green while the check stops firing at Write/Edit time — the precise failure mode
|
|
7
|
+
that would let a dead module-level constant (the ``MEDIUM_TEXT`` class) or an
|
|
8
|
+
orphan CSS class slip past the gate again.
|
|
9
|
+
|
|
10
|
+
This module reads ``validate_content``'s source and asserts every ``check_*``
|
|
11
|
+
attribute on the enforcer module appears in it. A check that is intentionally
|
|
12
|
+
not wired must be listed in ``KNOWN_UNDISPATCHED_CHECKS`` with a reason in this
|
|
13
|
+
docstring; no such checks exist today. The companion ``test_code_rules_enforcer_
|
|
14
|
+
cap_meta.py`` guards the payload-cap convention; this module guards the wiring.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import importlib.util
|
|
20
|
+
import inspect
|
|
21
|
+
import pathlib
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
_HOOK_DIRECTORY = pathlib.Path(__file__).parent
|
|
25
|
+
if str(_HOOK_DIRECTORY) not in sys.path:
|
|
26
|
+
sys.path.insert(0, str(_HOOK_DIRECTORY))
|
|
27
|
+
|
|
28
|
+
_hook_specification = importlib.util.spec_from_file_location(
|
|
29
|
+
"code_rules_enforcer",
|
|
30
|
+
_HOOK_DIRECTORY / "code_rules_enforcer.py",
|
|
31
|
+
)
|
|
32
|
+
assert _hook_specification is not None
|
|
33
|
+
assert _hook_specification.loader is not None
|
|
34
|
+
_hook_module = importlib.util.module_from_spec(_hook_specification)
|
|
35
|
+
_hook_specification.loader.exec_module(_hook_module)
|
|
36
|
+
|
|
37
|
+
KNOWN_UNDISPATCHED_CHECKS: frozenset[str] = frozenset()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _all_check_function_names() -> list[str]:
|
|
41
|
+
return [
|
|
42
|
+
each_attribute_name
|
|
43
|
+
for each_attribute_name in dir(_hook_module)
|
|
44
|
+
if each_attribute_name.startswith("check_")
|
|
45
|
+
and callable(getattr(_hook_module, each_attribute_name))
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _validate_content_source() -> str:
|
|
50
|
+
return inspect.getsource(_hook_module.validate_content)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_every_check_function_is_called_in_validate_content() -> None:
|
|
54
|
+
all_check_names = set(_all_check_function_names())
|
|
55
|
+
validate_content_source = _validate_content_source()
|
|
56
|
+
undispatched_check_names = {
|
|
57
|
+
each_name for each_name in all_check_names if each_name not in validate_content_source
|
|
58
|
+
}
|
|
59
|
+
unexpected_undispatched = undispatched_check_names - KNOWN_UNDISPATCHED_CHECKS
|
|
60
|
+
assert unexpected_undispatched == set(), (
|
|
61
|
+
f"check_* functions are imported but never called in validate_content: "
|
|
62
|
+
f"{sorted(unexpected_undispatched)}. Wire each into validate_content so the "
|
|
63
|
+
f"check fires at Write/Edit time, or list it in KNOWN_UNDISPATCHED_CHECKS "
|
|
64
|
+
f"with a reason in the test header docstring."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_dead_module_constant_check_stays_wired() -> None:
|
|
69
|
+
validate_content_source = _validate_content_source()
|
|
70
|
+
assert "check_dead_module_constants" in validate_content_source, (
|
|
71
|
+
"check_dead_module_constants must stay dispatched from validate_content so a "
|
|
72
|
+
"dead exported constant (the MEDIUM_TEXT class) is blocked at Write/Edit time."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_known_undispatched_set_lists_only_existing_checks() -> None:
|
|
77
|
+
all_check_names = set(_all_check_function_names())
|
|
78
|
+
stale_names = KNOWN_UNDISPATCHED_CHECKS - all_check_names
|
|
79
|
+
assert stale_names == set(), (
|
|
80
|
+
f"KNOWN_UNDISPATCHED_CHECKS lists functions that no longer exist: "
|
|
81
|
+
f"{sorted(stale_names)}. Restore the function or remove it from the set."
|
|
82
|
+
)
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Unit tests for the orphan-CSS-class check in code_rules_enforcer hook."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import pathlib
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
import textwrap
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
14
|
+
if str(_HOOK_DIR) not in sys.path:
|
|
15
|
+
sys.path.insert(0, str(_HOOK_DIR))
|
|
16
|
+
|
|
17
|
+
hook_spec = importlib.util.spec_from_file_location(
|
|
18
|
+
"code_rules_orphan_css_class",
|
|
19
|
+
_HOOK_DIR / "code_rules_orphan_css_class.py",
|
|
20
|
+
)
|
|
21
|
+
assert hook_spec is not None
|
|
22
|
+
assert hook_spec.loader is not None
|
|
23
|
+
hook_module = importlib.util.module_from_spec(hook_spec)
|
|
24
|
+
hook_spec.loader.exec_module(hook_module)
|
|
25
|
+
check_orphan_css_classes = hook_module.check_orphan_css_classes
|
|
26
|
+
|
|
27
|
+
PRODUCTION_FILE_PATH = "packages/app/render/report.py"
|
|
28
|
+
TEST_FILE_PATH = "packages/app/render/test_report.py"
|
|
29
|
+
|
|
30
|
+
MARKUP_WITH_ORPHAN = (
|
|
31
|
+
"def render() -> str:\n"
|
|
32
|
+
' style = "<style>.card { color: red; }</style>"\n'
|
|
33
|
+
' body = \'<div class="card">x</div><div class="ghost">y</div>\'\n'
|
|
34
|
+
" return style + body\n"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
MARKUP_ALL_DEFINED = (
|
|
38
|
+
"def render() -> str:\n"
|
|
39
|
+
' style = "<style>.card { color: red; } .row { margin: 0; }</style>"\n'
|
|
40
|
+
' body = \'<div class="card"><span class="row">x</span></div>\'\n'
|
|
41
|
+
" return style + body\n"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_should_flag_class_with_no_matching_selector() -> None:
|
|
46
|
+
issues = check_orphan_css_classes(MARKUP_WITH_ORPHAN, PRODUCTION_FILE_PATH)
|
|
47
|
+
assert any("'ghost'" in each_issue for each_issue in issues), (
|
|
48
|
+
f"Expected 'ghost' flagged as orphan, got: {issues}"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_should_not_flag_class_with_matching_selector() -> None:
|
|
53
|
+
issues = check_orphan_css_classes(MARKUP_WITH_ORPHAN, PRODUCTION_FILE_PATH)
|
|
54
|
+
assert not any("'card'" in each_issue for each_issue in issues), (
|
|
55
|
+
f"'card' has a .card selector and must not flag, got: {issues}"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_should_not_flag_when_every_class_is_defined() -> None:
|
|
60
|
+
issues = check_orphan_css_classes(MARKUP_ALL_DEFINED, PRODUCTION_FILE_PATH)
|
|
61
|
+
assert issues == [], f"Every class is defined; expected no issues, got: {issues}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_should_flag_each_class_in_a_multi_class_attribute() -> None:
|
|
65
|
+
content = (
|
|
66
|
+
"def render() -> str:\n"
|
|
67
|
+
' style = "<style>.pf { padding: 0; }</style>"\n'
|
|
68
|
+
" body = '<div class=\"pf problem\">x</div>'\n"
|
|
69
|
+
" return style + body\n"
|
|
70
|
+
)
|
|
71
|
+
issues = check_orphan_css_classes(content, PRODUCTION_FILE_PATH)
|
|
72
|
+
assert any("'problem'" in each_issue for each_issue in issues), (
|
|
73
|
+
f"Second class 'problem' in the attribute must flag, got: {issues}"
|
|
74
|
+
)
|
|
75
|
+
assert not any("'pf'" in each_issue for each_issue in issues), (
|
|
76
|
+
f"First class 'pf' has a selector and must not flag, got: {issues}"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_should_not_flag_when_no_style_block_present() -> None:
|
|
81
|
+
content = "def render() -> str:\n return '<div class=\"card\">x</div>'\n"
|
|
82
|
+
issues = check_orphan_css_classes(content, PRODUCTION_FILE_PATH)
|
|
83
|
+
assert issues == [], (
|
|
84
|
+
f"No <style> source nearby; the stylesheet lives outside the scan, got: {issues}"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_should_not_flag_when_no_class_attribute_present() -> None:
|
|
89
|
+
content = (
|
|
90
|
+
'def render() -> str:\n return "<style>.card { color: red; }</style><div>x</div>"\n'
|
|
91
|
+
)
|
|
92
|
+
issues = check_orphan_css_classes(content, PRODUCTION_FILE_PATH)
|
|
93
|
+
assert issues == [], f"No class attribute in markup; got: {issues}"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_should_skip_test_files() -> None:
|
|
97
|
+
issues = check_orphan_css_classes(MARKUP_WITH_ORPHAN, TEST_FILE_PATH)
|
|
98
|
+
assert issues == [], f"Test files are exempt; got: {issues}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_should_include_line_number_and_class_name() -> None:
|
|
102
|
+
issues = check_orphan_css_classes(MARKUP_WITH_ORPHAN, PRODUCTION_FILE_PATH)
|
|
103
|
+
orphan_issue = next(each for each in issues if "'ghost'" in each)
|
|
104
|
+
assert "Line 3" in orphan_issue, (
|
|
105
|
+
f"Orphan 'ghost' sits on line 3 of the markup; got: {orphan_issue}"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_should_report_each_orphan_class_once() -> None:
|
|
110
|
+
content = (
|
|
111
|
+
"def render() -> str:\n"
|
|
112
|
+
' style = "<style>.card { color: red; }</style>"\n'
|
|
113
|
+
" a = '<div class=\"ghost\">x</div>'\n"
|
|
114
|
+
" b = '<div class=\"ghost\">y</div>'\n"
|
|
115
|
+
" return style + a + b\n"
|
|
116
|
+
)
|
|
117
|
+
issues = check_orphan_css_classes(content, PRODUCTION_FILE_PATH)
|
|
118
|
+
ghost_issues = [each for each in issues if "'ghost'" in each]
|
|
119
|
+
assert len(ghost_issues) == 1, f"A repeated orphan class reports once; got: {ghost_issues}"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_should_handle_syntax_error_gracefully() -> None:
|
|
123
|
+
content = "def broken(\n this is not python\n"
|
|
124
|
+
issues = check_orphan_css_classes(content, PRODUCTION_FILE_PATH)
|
|
125
|
+
assert issues == [], f"A syntax error yields no issues; got: {issues}"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
_SIBLING_MARKUP_SOURCE = textwrap.dedent(
|
|
129
|
+
"""\
|
|
130
|
+
from report_constants import HTML_STYLE_BLOCK
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def render() -> str:
|
|
134
|
+
body = (
|
|
135
|
+
'<details class="appendix">'
|
|
136
|
+
'<div class="appendix-body">x</div></details>'
|
|
137
|
+
)
|
|
138
|
+
return HTML_STYLE_BLOCK + body
|
|
139
|
+
"""
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@pytest.fixture
|
|
144
|
+
def production_render_package() -> Iterator[Path]:
|
|
145
|
+
"""Yield a neutrally named package directory for the markup and constants modules.
|
|
146
|
+
|
|
147
|
+
pytest's ``tmp_path`` carries the test function name, so any path under it
|
|
148
|
+
holds a ``test_`` segment that the enforcer's ``is_test_file`` predicate
|
|
149
|
+
matches and exempts. A package created under the default ``tempfile`` prefix
|
|
150
|
+
keeps that segment out of the path, so the cross-module orphan-class check
|
|
151
|
+
runs against the markup module as production code.
|
|
152
|
+
|
|
153
|
+
Yields:
|
|
154
|
+
A freshly created package directory, removed when the test finishes.
|
|
155
|
+
"""
|
|
156
|
+
with tempfile.TemporaryDirectory() as base_directory:
|
|
157
|
+
package_directory = Path(base_directory) / "render"
|
|
158
|
+
package_directory.mkdir()
|
|
159
|
+
yield package_directory
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_should_resolve_selectors_from_a_sibling_module(
|
|
163
|
+
production_render_package: Path,
|
|
164
|
+
) -> None:
|
|
165
|
+
constants_module = production_render_package / "report_constants.py"
|
|
166
|
+
constants_module.write_text(
|
|
167
|
+
'HTML_STYLE_BLOCK = "<style>.appendix { margin: 0; }'
|
|
168
|
+
' .appendix-body { padding: 0; }</style>"\n',
|
|
169
|
+
encoding="utf-8",
|
|
170
|
+
)
|
|
171
|
+
markup_module = production_render_package / "report.py"
|
|
172
|
+
markup_module.write_text(_SIBLING_MARKUP_SOURCE, encoding="utf-8")
|
|
173
|
+
issues = check_orphan_css_classes(_SIBLING_MARKUP_SOURCE, str(markup_module))
|
|
174
|
+
assert issues == [], (
|
|
175
|
+
f"A sibling module defines every selector; expected no issues, got: {issues}"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_should_flag_orphan_even_when_a_sibling_defines_other_selectors(
|
|
180
|
+
production_render_package: Path,
|
|
181
|
+
) -> None:
|
|
182
|
+
constants_module = production_render_package / "report_constants.py"
|
|
183
|
+
constants_module.write_text(
|
|
184
|
+
'HTML_STYLE_BLOCK = "<style>.appendix { margin: 0; }</style>"\n',
|
|
185
|
+
encoding="utf-8",
|
|
186
|
+
)
|
|
187
|
+
markup_module = production_render_package / "report.py"
|
|
188
|
+
markup_module.write_text(_SIBLING_MARKUP_SOURCE, encoding="utf-8")
|
|
189
|
+
issues = check_orphan_css_classes(_SIBLING_MARKUP_SOURCE, str(markup_module))
|
|
190
|
+
assert any("'appendix-body'" in each_issue for each_issue in issues), (
|
|
191
|
+
f"'appendix-body' has no selector in any sibling; must flag, got: {issues}"
|
|
192
|
+
)
|
|
193
|
+
assert not any(
|
|
194
|
+
"'appendix'" in each_issue and "appendix-body" not in each_issue
|
|
195
|
+
for each_issue in issues
|
|
196
|
+
), f"'appendix' is defined in the sibling and must not flag, got: {issues}"
|