claude-dev-env 1.76.0 → 1.78.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/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -0
- package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +8 -4
- package/hooks/blocking/CLAUDE.md +3 -1
- package/hooks/blocking/code_rules_dead_module_constant.py +215 -59
- package/hooks/blocking/code_rules_dead_split_branch.py +225 -0
- package/hooks/blocking/code_rules_docstrings.py +730 -0
- package/hooks/blocking/code_rules_enforcer.py +39 -0
- package/hooks/blocking/code_rules_paired_test.py +299 -0
- package/hooks/blocking/code_rules_string_magic.py +71 -1
- package/hooks/blocking/convergence_gate_blocker.py +24 -15
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +89 -2
- package/hooks/blocking/test_code_rules_enforcer_dead_split_branch.py +105 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +174 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_cardinal_family.py +176 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_mark_glyph_enumeration.py +262 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_raises_largezipfile.py +226 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +267 -0
- package/hooks/blocking/test_code_rules_enforcer_paired_test.py +149 -0
- package/hooks/blocking/test_code_rules_enforcer_whitespace_indentation_magic.py +74 -0
- package/hooks/blocking/test_convergence_gate_blocker.py +71 -0
- package/hooks/hooks_constants/CLAUDE.md +1 -0
- package/hooks/hooks_constants/blocking_check_limits.py +51 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +49 -0
- package/hooks/hooks_constants/paired_test_coverage_constants.py +27 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +2 -0
- package/rules/docstring-prose-matches-implementation.md +5 -1
- package/rules/file-global-constants.md +2 -2
- package/rules/package-inventory-stale-entry.md +8 -0
- package/rules/paired-test-coverage.md +28 -0
- package/rules/plain-illustrative-docstrings.md +56 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
ENFORCER_PATH = Path(__file__).resolve().parent / "code_rules_enforcer.py"
|
|
7
|
+
specification = importlib.util.spec_from_file_location("code_rules_enforcer", ENFORCER_PATH)
|
|
8
|
+
assert specification is not None and specification.loader is not None
|
|
9
|
+
code_rules_enforcer = importlib.util.module_from_spec(specification)
|
|
10
|
+
specification.loader.exec_module(code_rules_enforcer)
|
|
11
|
+
|
|
12
|
+
PRODUCTION_PATH = "C:/project/pkg/menu_info_colors.py"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _check(source: str) -> list[str]:
|
|
16
|
+
return code_rules_enforcer.check_dead_split_truthiness_branch(source, PRODUCTION_PATH)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_flags_split_truthiness_else_arm() -> None:
|
|
20
|
+
source = (
|
|
21
|
+
"def _extract_uid_prefix(uid: str) -> str:\n"
|
|
22
|
+
' all_parts = uid.split("_")\n'
|
|
23
|
+
" if len(all_parts) > 2:\n"
|
|
24
|
+
' return "_".join(all_parts[:3])\n'
|
|
25
|
+
" return all_parts[0] if all_parts else uid\n"
|
|
26
|
+
)
|
|
27
|
+
issues = _check(source)
|
|
28
|
+
assert len(issues) == 1
|
|
29
|
+
assert "all_parts" in issues[0]
|
|
30
|
+
assert "Line 5" in issues[0]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_flags_negated_guard_dead_body() -> None:
|
|
34
|
+
source = (
|
|
35
|
+
"def first_segment(path: str) -> str:\n"
|
|
36
|
+
' all_segments = path.split("/")\n'
|
|
37
|
+
" if not all_segments:\n"
|
|
38
|
+
" return path\n"
|
|
39
|
+
" return all_segments[0]\n"
|
|
40
|
+
)
|
|
41
|
+
issues = _check(source)
|
|
42
|
+
assert len(issues) == 1
|
|
43
|
+
assert "all_segments" in issues[0]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_does_not_flag_split_without_separator() -> None:
|
|
47
|
+
source = (
|
|
48
|
+
"def first_token(text: str) -> str:\n"
|
|
49
|
+
" all_parts = text.split()\n"
|
|
50
|
+
" return all_parts[0] if all_parts else text\n"
|
|
51
|
+
)
|
|
52
|
+
assert _check(source) == []
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_does_not_flag_length_comparison_guard() -> None:
|
|
56
|
+
source = (
|
|
57
|
+
"def prefix(uid: str) -> str:\n"
|
|
58
|
+
' all_parts = uid.split("_")\n'
|
|
59
|
+
" if len(all_parts) > 1:\n"
|
|
60
|
+
" return all_parts[0]\n"
|
|
61
|
+
" return uid\n"
|
|
62
|
+
)
|
|
63
|
+
assert _check(source) == []
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_does_not_flag_when_name_reassigned() -> None:
|
|
67
|
+
source = (
|
|
68
|
+
"def normalize(uid: str) -> str:\n"
|
|
69
|
+
' all_parts = uid.split("_")\n'
|
|
70
|
+
" all_parts = [each_part.strip() for each_part in all_parts]\n"
|
|
71
|
+
" return all_parts[0] if all_parts else uid\n"
|
|
72
|
+
)
|
|
73
|
+
assert _check(source) == []
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_does_not_flag_when_name_is_parameter() -> None:
|
|
77
|
+
source = (
|
|
78
|
+
"def shape(all_parts: list[str]) -> str:\n"
|
|
79
|
+
' all_parts = "x".split("_")\n'
|
|
80
|
+
' return all_parts[0] if all_parts else ""\n'
|
|
81
|
+
)
|
|
82
|
+
assert _check(source) == []
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_does_not_flag_attribute_receiver_split() -> None:
|
|
86
|
+
source = (
|
|
87
|
+
"def first(s: object) -> object:\n"
|
|
88
|
+
' parts = s.str.split(",")\n'
|
|
89
|
+
" return parts[0] if parts else 0\n"
|
|
90
|
+
)
|
|
91
|
+
assert _check(source) == []
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_does_not_flag_in_test_file() -> None:
|
|
95
|
+
source = (
|
|
96
|
+
"def helper(uid: str) -> str:\n"
|
|
97
|
+
' all_parts = uid.split("_")\n'
|
|
98
|
+
" return all_parts[0] if all_parts else uid\n"
|
|
99
|
+
)
|
|
100
|
+
assert (
|
|
101
|
+
code_rules_enforcer.check_dead_split_truthiness_branch(
|
|
102
|
+
source, "C:/project/pkg/test_thing.py"
|
|
103
|
+
)
|
|
104
|
+
== []
|
|
105
|
+
)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Tests for check_docstring_args_single_line_scope_vs_span — Category O6 drift.
|
|
2
|
+
|
|
3
|
+
A docstring Args: entry that scopes a finding to one named line ("only when its
|
|
4
|
+
block-anchor line is among the changed lines") while the body builds a range()
|
|
5
|
+
span and routes it through a span-intersection scoper claims a narrower scope
|
|
6
|
+
than the code applies. The body blocks when ANY line of the span is among the
|
|
7
|
+
changed lines, so an edit touching a non-anchor line of the span still blocks —
|
|
8
|
+
contradicting the single-line Args sentence. This is the deterministic slice of
|
|
9
|
+
Category O6 (free-form docstring-vs-implementation drift) for an Args entry whose
|
|
10
|
+
single-line scope claim disagrees with a span-intersection body.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import importlib.util
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from types import ModuleType
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _load_enforcer_module() -> ModuleType:
|
|
21
|
+
module_path = Path(__file__).parent / "code_rules_enforcer.py"
|
|
22
|
+
spec = importlib.util.spec_from_file_location("code_rules_enforcer", module_path)
|
|
23
|
+
assert spec is not None
|
|
24
|
+
assert spec.loader is not None
|
|
25
|
+
module = importlib.util.module_from_spec(spec)
|
|
26
|
+
spec.loader.exec_module(module)
|
|
27
|
+
return module
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
code_rules_enforcer = _load_enforcer_module()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def check_docstring_args_single_line_scope_vs_span(content: str, file_path: str) -> list[str]:
|
|
34
|
+
return code_rules_enforcer.check_docstring_args_single_line_scope_vs_span(content, file_path)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
PRODUCTION_FILE_PATH = "/project/scripts/check_import_block_sorted.py"
|
|
38
|
+
TEST_FILE_PATH = "/project/scripts/test_check_import_block_sorted.py"
|
|
39
|
+
HOOK_INFRASTRUCTURE_PATH = "/home/user/.claude/hooks/blocking/example.py"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
SPAN_SCOPING_BODY = (
|
|
43
|
+
" span_range = range(line_number, block_end_line_number + 1)\n"
|
|
44
|
+
" all_violations.append((span_range, message))\n"
|
|
45
|
+
" return _scope_violations_to_changed_lines(\n"
|
|
46
|
+
" all_violations, all_changed_lines, defer_scope_to_caller\n"
|
|
47
|
+
" )\n"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_flags_anchor_line_scope_against_span_intersection_body() -> None:
|
|
52
|
+
content = (
|
|
53
|
+
"def check_import_block_sorted(\n"
|
|
54
|
+
" content: str, file_path: str, all_changed_lines: set[int] | None\n"
|
|
55
|
+
") -> list[str]:\n"
|
|
56
|
+
' """Flag an unsorted import block scoped to the changed lines.\n'
|
|
57
|
+
"\n"
|
|
58
|
+
" A finding is returned when any line in that block span is among\n"
|
|
59
|
+
" all_changed_lines.\n"
|
|
60
|
+
"\n"
|
|
61
|
+
" Args:\n"
|
|
62
|
+
" content: The full file content the write would leave on disk.\n"
|
|
63
|
+
" file_path: The destination path used to gate by extension.\n"
|
|
64
|
+
" all_changed_lines: Post-edit line numbers the current edit touched, or\n"
|
|
65
|
+
" None to treat the whole file as in scope. When provided, a finding\n"
|
|
66
|
+
" blocks only when its block-anchor line is among the changed lines.\n"
|
|
67
|
+
"\n"
|
|
68
|
+
" Returns:\n"
|
|
69
|
+
" One issue string per detected finding.\n"
|
|
70
|
+
' """\n' + SPAN_SCOPING_BODY
|
|
71
|
+
)
|
|
72
|
+
issues = check_docstring_args_single_line_scope_vs_span(content, PRODUCTION_FILE_PATH)
|
|
73
|
+
assert len(issues) == 1
|
|
74
|
+
assert "check_import_block_sorted" in issues[0]
|
|
75
|
+
assert "all_changed_lines" in issues[0]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_flags_the_line_is_among_phrasing() -> None:
|
|
79
|
+
content = (
|
|
80
|
+
"def check_block(\n"
|
|
81
|
+
" content: str, file_path: str, all_changed_lines: set[int] | None\n"
|
|
82
|
+
") -> list[str]:\n"
|
|
83
|
+
' """Flag an unsorted block scoped to the diff.\n'
|
|
84
|
+
"\n"
|
|
85
|
+
" Args:\n"
|
|
86
|
+
" content: The file content.\n"
|
|
87
|
+
" all_changed_lines: When provided, a finding blocks only when the\n"
|
|
88
|
+
" anchor line is among the changed lines.\n"
|
|
89
|
+
' """\n' + SPAN_SCOPING_BODY
|
|
90
|
+
)
|
|
91
|
+
assert len(check_docstring_args_single_line_scope_vs_span(content, PRODUCTION_FILE_PATH)) == 1
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_passes_when_args_says_any_line_of_the_span() -> None:
|
|
95
|
+
content = (
|
|
96
|
+
"def check_import_block_sorted(\n"
|
|
97
|
+
" content: str, file_path: str, all_changed_lines: set[int] | None\n"
|
|
98
|
+
") -> list[str]:\n"
|
|
99
|
+
' """Flag an unsorted import block scoped to the changed lines.\n'
|
|
100
|
+
"\n"
|
|
101
|
+
" Args:\n"
|
|
102
|
+
" content: The full file content.\n"
|
|
103
|
+
" all_changed_lines: Post-edit line numbers the current edit touched, or\n"
|
|
104
|
+
" None to treat the whole file as in scope. When provided, a finding\n"
|
|
105
|
+
" blocks only when any line of its block span is among the changed lines.\n"
|
|
106
|
+
' """\n' + SPAN_SCOPING_BODY
|
|
107
|
+
)
|
|
108
|
+
assert check_docstring_args_single_line_scope_vs_span(content, PRODUCTION_FILE_PATH) == []
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_passes_when_body_scopes_by_single_line_not_a_span() -> None:
|
|
112
|
+
content = (
|
|
113
|
+
"def check_one_line(\n"
|
|
114
|
+
" content: str, file_path: str, all_changed_lines: set[int] | None\n"
|
|
115
|
+
") -> list[str]:\n"
|
|
116
|
+
' """Flag a violation on a single line scoped to the diff.\n'
|
|
117
|
+
"\n"
|
|
118
|
+
" Args:\n"
|
|
119
|
+
" content: The file content.\n"
|
|
120
|
+
" all_changed_lines: When provided, a finding blocks only when its\n"
|
|
121
|
+
" anchor line is among the changed lines.\n"
|
|
122
|
+
' """\n'
|
|
123
|
+
" if all_changed_lines is not None and line_number not in all_changed_lines:\n"
|
|
124
|
+
" return []\n"
|
|
125
|
+
" return [message]\n"
|
|
126
|
+
)
|
|
127
|
+
assert check_docstring_args_single_line_scope_vs_span(content, PRODUCTION_FILE_PATH) == []
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_passes_when_no_single_line_scope_phrase() -> None:
|
|
131
|
+
content = (
|
|
132
|
+
"def check_import_block_sorted(\n"
|
|
133
|
+
" content: str, file_path: str, all_changed_lines: set[int] | None\n"
|
|
134
|
+
") -> list[str]:\n"
|
|
135
|
+
' """Flag an unsorted import block scoped to the changed lines.\n'
|
|
136
|
+
"\n"
|
|
137
|
+
" Args:\n"
|
|
138
|
+
" content: The full file content.\n"
|
|
139
|
+
" all_changed_lines: Post-edit line numbers the current edit touched.\n"
|
|
140
|
+
' """\n' + SPAN_SCOPING_BODY
|
|
141
|
+
)
|
|
142
|
+
assert check_docstring_args_single_line_scope_vs_span(content, PRODUCTION_FILE_PATH) == []
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_test_files_are_exempt() -> None:
|
|
146
|
+
content = (
|
|
147
|
+
"def check_import_block_sorted(\n"
|
|
148
|
+
" content: str, file_path: str, all_changed_lines: set[int] | None\n"
|
|
149
|
+
") -> list[str]:\n"
|
|
150
|
+
' """Flag an unsorted import block.\n'
|
|
151
|
+
"\n"
|
|
152
|
+
" Args:\n"
|
|
153
|
+
" all_changed_lines: A finding blocks only when its anchor line is\n"
|
|
154
|
+
" among the changed lines.\n"
|
|
155
|
+
' """\n' + SPAN_SCOPING_BODY
|
|
156
|
+
)
|
|
157
|
+
assert check_docstring_args_single_line_scope_vs_span(content, TEST_FILE_PATH) == []
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_hook_infrastructure_is_not_exempt() -> None:
|
|
161
|
+
content = (
|
|
162
|
+
"def check_import_block_sorted(\n"
|
|
163
|
+
" content: str, file_path: str, all_changed_lines: set[int] | None\n"
|
|
164
|
+
") -> list[str]:\n"
|
|
165
|
+
' """Flag an unsorted import block.\n'
|
|
166
|
+
"\n"
|
|
167
|
+
" Args:\n"
|
|
168
|
+
" all_changed_lines: A finding blocks only when its block-anchor line is\n"
|
|
169
|
+
" among the changed lines.\n"
|
|
170
|
+
' """\n' + SPAN_SCOPING_BODY
|
|
171
|
+
)
|
|
172
|
+
assert (
|
|
173
|
+
len(check_docstring_args_single_line_scope_vs_span(content, HOOK_INFRASTRUCTURE_PATH)) == 1
|
|
174
|
+
)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Tests for check_docstring_cardinal_count_matches_constant_family — O6 drift.
|
|
2
|
+
|
|
3
|
+
A docstring states a cardinal count of an outcome family ("Covers the four
|
|
4
|
+
outcome branches: ...") and enumerates the family members in prose, while the
|
|
5
|
+
module references more members of that constant family than the count names. The
|
|
6
|
+
prose under-describes the code: a reader trusts "four" and the list to be the
|
|
7
|
+
full set, but the module imports and exercises a fifth outcome. This is the
|
|
8
|
+
deterministic cardinal-count slice of Category O6 docstring-prose-vs-
|
|
9
|
+
implementation drift, the shape that appears when a producer adds an outcome
|
|
10
|
+
branch but leaves the test-module summary at the old count. The check runs on
|
|
11
|
+
test files because the drift class lives in a test-module docstring.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import importlib.util
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from types import ModuleType
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _load_enforcer_module() -> ModuleType:
|
|
22
|
+
module_path = Path(__file__).parent / "code_rules_enforcer.py"
|
|
23
|
+
spec = importlib.util.spec_from_file_location("code_rules_enforcer", module_path)
|
|
24
|
+
assert spec is not None
|
|
25
|
+
assert spec.loader is not None
|
|
26
|
+
module = importlib.util.module_from_spec(spec)
|
|
27
|
+
spec.loader.exec_module(module)
|
|
28
|
+
return module
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
code_rules_enforcer = _load_enforcer_module()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def check_docstring_cardinal_count_matches_constant_family(
|
|
35
|
+
content: str, file_path: str
|
|
36
|
+
) -> list[str]:
|
|
37
|
+
return code_rules_enforcer.check_docstring_cardinal_count_matches_constant_family(
|
|
38
|
+
content, file_path
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def validate_content(content: str, file_path: str, old_content: str) -> list[str]:
|
|
43
|
+
return code_rules_enforcer.validate_content(content, file_path, old_content)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
PRODUCTION_FILE_PATH = "/project/src/dropdown_fix_rule.py"
|
|
47
|
+
TEST_FILE_PATH = "/project/src/tests/test_dropdown_fix_rule.py"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _test_module(summary_line: str, enumeration_line: str) -> str:
|
|
51
|
+
return (
|
|
52
|
+
'"""Behavioral tests for classify_dropdown_fix.\n'
|
|
53
|
+
"\n"
|
|
54
|
+
f"{summary_line}\n"
|
|
55
|
+
f"{enumeration_line}\n"
|
|
56
|
+
'"""\n'
|
|
57
|
+
"\n"
|
|
58
|
+
"from stp_dropdown_color_fix.config.status_codes import (\n"
|
|
59
|
+
" OUTCOME_ALREADY_OK,\n"
|
|
60
|
+
" OUTCOME_OFFENDER_LOW_CONTRAST,\n"
|
|
61
|
+
" OUTCOME_OFFENDER_MISSING,\n"
|
|
62
|
+
" OUTCOME_OFFENDER_UNREADABLE,\n"
|
|
63
|
+
" OUTCOME_SKIPPED_NO_SAFE_SOURCE,\n"
|
|
64
|
+
")\n"
|
|
65
|
+
"\n"
|
|
66
|
+
"def test_skipped() -> None:\n"
|
|
67
|
+
" assert classify() == OUTCOME_SKIPPED_NO_SAFE_SOURCE\n"
|
|
68
|
+
"\n"
|
|
69
|
+
"def test_missing() -> None:\n"
|
|
70
|
+
" assert classify() == OUTCOME_OFFENDER_MISSING\n"
|
|
71
|
+
"\n"
|
|
72
|
+
"def test_low_contrast() -> None:\n"
|
|
73
|
+
" assert classify() == OUTCOME_OFFENDER_LOW_CONTRAST\n"
|
|
74
|
+
"\n"
|
|
75
|
+
"def test_already_ok() -> None:\n"
|
|
76
|
+
" assert classify() == OUTCOME_ALREADY_OK\n"
|
|
77
|
+
"\n"
|
|
78
|
+
"def test_unreadable() -> None:\n"
|
|
79
|
+
" assert classify() == OUTCOME_OFFENDER_UNREADABLE\n"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _drifted_module() -> str:
|
|
84
|
+
return _test_module(
|
|
85
|
+
"Covers the four outcome branches: skipped_no_safe_source,",
|
|
86
|
+
"offender_missing (case A), offender_low_contrast (case B), already_ok.",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _matching_module() -> str:
|
|
91
|
+
return _test_module(
|
|
92
|
+
"Covers the five outcome branches: skipped_no_safe_source, offender_missing,",
|
|
93
|
+
"offender_low_contrast, already_ok, offender_unreadable.",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_should_flag_test_module_omitting_an_outcome() -> None:
|
|
98
|
+
issues = check_docstring_cardinal_count_matches_constant_family(
|
|
99
|
+
_drifted_module(), TEST_FILE_PATH
|
|
100
|
+
)
|
|
101
|
+
assert any("offender_unreadable" in each for each in issues), (
|
|
102
|
+
"A test-module docstring claiming four outcome branches while the module "
|
|
103
|
+
f"references five OUTCOME_ constants must be flagged, got: {issues!r}"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_should_report_category_o6_in_the_message() -> None:
|
|
108
|
+
issues = check_docstring_cardinal_count_matches_constant_family(
|
|
109
|
+
_drifted_module(), TEST_FILE_PATH
|
|
110
|
+
)
|
|
111
|
+
assert any("O6" in each for each in issues), (
|
|
112
|
+
f"Expected the Category O6 label in the message, got: {issues!r}"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_should_also_flag_production_module() -> None:
|
|
117
|
+
issues = check_docstring_cardinal_count_matches_constant_family(
|
|
118
|
+
_drifted_module(), PRODUCTION_FILE_PATH
|
|
119
|
+
)
|
|
120
|
+
assert issues != [], (
|
|
121
|
+
f"The cardinal drift must be flagged on a production file too, got: {issues!r}"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_should_not_flag_when_count_and_enumeration_match_family() -> None:
|
|
126
|
+
issues = check_docstring_cardinal_count_matches_constant_family(
|
|
127
|
+
_matching_module(), TEST_FILE_PATH
|
|
128
|
+
)
|
|
129
|
+
assert issues == [], (
|
|
130
|
+
f"A docstring naming all five referenced outcomes must not be flagged, got: {issues!r}"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_should_not_flag_without_a_cardinal_phrase() -> None:
|
|
135
|
+
source = _test_module(
|
|
136
|
+
"Covers these outcome branches: skipped_no_safe_source,",
|
|
137
|
+
"offender_missing, offender_low_contrast, already_ok.",
|
|
138
|
+
)
|
|
139
|
+
issues = check_docstring_cardinal_count_matches_constant_family(source, TEST_FILE_PATH)
|
|
140
|
+
assert issues == [], (
|
|
141
|
+
f"An enumeration with no cardinal count word must not bind, got: {issues!r}"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_should_not_flag_single_passing_mention() -> None:
|
|
146
|
+
source = _test_module(
|
|
147
|
+
"Asserts the four guard paths reach already_ok when the dropdown is",
|
|
148
|
+
"absent and the dialer keeps its painted swatch.",
|
|
149
|
+
)
|
|
150
|
+
issues = check_docstring_cardinal_count_matches_constant_family(source, TEST_FILE_PATH)
|
|
151
|
+
assert issues == [], f"A single overlapping token must not bind the family, got: {issues!r}"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_should_not_flag_when_family_has_one_referenced_member() -> None:
|
|
155
|
+
source = (
|
|
156
|
+
'"""Covers the four outcome branches: already_ok, more, words, here."""\n'
|
|
157
|
+
"from status_codes import OUTCOME_ALREADY_OK\n"
|
|
158
|
+
"\n"
|
|
159
|
+
"def test_only() -> None:\n"
|
|
160
|
+
" assert classify() == OUTCOME_ALREADY_OK\n"
|
|
161
|
+
)
|
|
162
|
+
issues = check_docstring_cardinal_count_matches_constant_family(source, TEST_FILE_PATH)
|
|
163
|
+
assert issues == [], f"A family with one referenced member must not bind, got: {issues!r}"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_should_handle_syntax_error_gracefully() -> None:
|
|
167
|
+
issues = check_docstring_cardinal_count_matches_constant_family("def fetch(\n", TEST_FILE_PATH)
|
|
168
|
+
assert issues == [], f"Syntax error must yield no issues, got: {issues!r}"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_validate_content_surfaces_cardinal_family_drift() -> None:
|
|
172
|
+
issues = validate_content(_drifted_module(), TEST_FILE_PATH, old_content="")
|
|
173
|
+
matching_issues = [each for each in issues if "offender_unreadable" in each and "O6" in each]
|
|
174
|
+
assert matching_issues, (
|
|
175
|
+
f"Expected validate_content to surface the O6 cardinal-family drift, got: {issues!r}"
|
|
176
|
+
)
|