claude-dev-env 1.76.0 → 1.77.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/code_rules_docstrings.py +378 -0
- package/hooks/blocking/code_rules_enforcer.py +12 -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_runon_sentence.py +267 -0
- package/hooks/hooks_constants/blocking_check_limits.py +27 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +35 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +4 -1
- package/rules/package-inventory-stale-entry.md +8 -0
- package/rules/plain-illustrative-docstrings.md +56 -0
|
@@ -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
|
+
)
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Tests for check_docstring_runon_sentence — the plain-illustrative-docstrings backstop.
|
|
2
|
+
|
|
3
|
+
A readable docstring breaks its narrative into short sentences a general developer
|
|
4
|
+
follows on first read. The one mechanical mark of a dense wall is a single run-on
|
|
5
|
+
sentence: many words strung together with an em-dash or a semicolon. This check
|
|
6
|
+
flags that mark in module, class, and public-function docstring narrative prose,
|
|
7
|
+
and leaves the "is it illustrative" judgment to the audit lane.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib.util
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from types import ModuleType
|
|
16
|
+
|
|
17
|
+
_HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
|
|
18
|
+
if _HOOKS_DIRECTORY not in sys.path:
|
|
19
|
+
sys.path.insert(0, _HOOKS_DIRECTORY)
|
|
20
|
+
|
|
21
|
+
from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
22
|
+
ALL_DOCSTRING_ARGS_SECTION_HEADERS,
|
|
23
|
+
ALL_DOCSTRING_TERMINATING_SECTION_HEADERS,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _load_enforcer_module() -> ModuleType:
|
|
28
|
+
module_path = Path(__file__).parent / "code_rules_enforcer.py"
|
|
29
|
+
spec = importlib.util.spec_from_file_location("code_rules_enforcer", module_path)
|
|
30
|
+
assert spec is not None
|
|
31
|
+
assert spec.loader is not None
|
|
32
|
+
module = importlib.util.module_from_spec(spec)
|
|
33
|
+
spec.loader.exec_module(module)
|
|
34
|
+
return module
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
code_rules_enforcer = _load_enforcer_module()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def check_docstring_runon_sentence(content: str, file_path: str) -> list[str]:
|
|
41
|
+
return code_rules_enforcer.check_docstring_runon_sentence(content, file_path)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def validate_content(content: str, file_path: str, old_content: str) -> list[str]:
|
|
45
|
+
return code_rules_enforcer.validate_content(content, file_path, old_content)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
PRODUCTION_FILE_PATH = "/project/src/run_lifecycle.py"
|
|
49
|
+
TEST_FILE_PATH = "/project/src/test_run_lifecycle.py"
|
|
50
|
+
HOOK_INFRASTRUCTURE_PATH = "/home/user/.claude/hooks/blocking/example.py"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _run_lifecycle_module() -> str:
|
|
54
|
+
return (
|
|
55
|
+
'"""Generic run-lifecycle plumbing for the STP version promoter run.\n'
|
|
56
|
+
"\n"
|
|
57
|
+
"Owns the SIGINT install/restore/installability check, the atexit terminal-record\n"
|
|
58
|
+
"registration, and the interrupted-run finalizer — the non-promoter-specific\n"
|
|
59
|
+
"machinery that brackets a run so the JSONL artifact always carries a terminal\n"
|
|
60
|
+
"record and an in-flight theme record on interrupt.\n"
|
|
61
|
+
'"""\n'
|
|
62
|
+
"\n"
|
|
63
|
+
"def install_signal_handler() -> None:\n"
|
|
64
|
+
" return None\n"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _approved_bar_module() -> str:
|
|
69
|
+
return (
|
|
70
|
+
'"""Make sure a run\'s log always records how it ended.\n'
|
|
71
|
+
"\n"
|
|
72
|
+
"So when you reopen the report, the last line tells you the truth: the run\n"
|
|
73
|
+
"finished cleanly, or you hit Ctrl-C while theme 42 was processing, or it died\n"
|
|
74
|
+
"on an unexpected error. Without this, a killed run looks identical to a clean\n"
|
|
75
|
+
"one — and you're debugging blind.\n"
|
|
76
|
+
'"""\n'
|
|
77
|
+
"\n"
|
|
78
|
+
"def describe_outcome() -> None:\n"
|
|
79
|
+
" return None\n"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _class_docstring_runon() -> str:
|
|
84
|
+
return (
|
|
85
|
+
"class RunRecorder:\n"
|
|
86
|
+
' """Owns the SIGINT install and restore step, the atexit record hook, and the\n'
|
|
87
|
+
" interrupted-run finalizer — the plumbing that brackets a run so the artifact\n"
|
|
88
|
+
" always carries a terminal record and an in-flight theme record on interrupt.\n"
|
|
89
|
+
' """\n'
|
|
90
|
+
"\n"
|
|
91
|
+
" def record(self) -> None:\n"
|
|
92
|
+
" return None\n"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _normal_short_docstring() -> str:
|
|
97
|
+
return (
|
|
98
|
+
"def summarize_run() -> str:\n"
|
|
99
|
+
' """Write the run summary.\n'
|
|
100
|
+
"\n"
|
|
101
|
+
" Each line names one theme and its final outcome.\n"
|
|
102
|
+
' """\n'
|
|
103
|
+
' return ""\n'
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _long_args_line_outside_narrative() -> str:
|
|
108
|
+
return (
|
|
109
|
+
"def configure_run(option_name: str) -> None:\n"
|
|
110
|
+
' """Set one option for the run.\n'
|
|
111
|
+
"\n"
|
|
112
|
+
" Args:\n"
|
|
113
|
+
" option_name: the name of the option to set, described here in a needlessly\n"
|
|
114
|
+
" long sentence that runs far past thirty words and even carries an\n"
|
|
115
|
+
" em-dash — yet the check stays silent because every word here sits after\n"
|
|
116
|
+
" the Args header and therefore outside the inspected narrative entirely.\n"
|
|
117
|
+
' """\n'
|
|
118
|
+
" return None\n"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _private_function_runon() -> str:
|
|
123
|
+
return (
|
|
124
|
+
"def _install_handlers() -> None:\n"
|
|
125
|
+
' """Owns the SIGINT install and restore step, the atexit record hook, and the\n'
|
|
126
|
+
" interrupted-run finalizer — the plumbing that brackets a run so the artifact\n"
|
|
127
|
+
" always carries a terminal record and an in-flight theme record on interrupt.\n"
|
|
128
|
+
' """\n'
|
|
129
|
+
" return None\n"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _property_method_runon() -> str:
|
|
134
|
+
return (
|
|
135
|
+
"class Widget:\n"
|
|
136
|
+
" @property\n"
|
|
137
|
+
" def label(self) -> str:\n"
|
|
138
|
+
' """Owns the SIGINT install and restore step, the atexit record hook, and the\n'
|
|
139
|
+
" interrupted-run finalizer — the plumbing that brackets a run so the artifact\n"
|
|
140
|
+
" always carries a terminal record and an in-flight theme record on interrupt.\n"
|
|
141
|
+
' """\n'
|
|
142
|
+
' return "label"\n'
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_should_flag_run_lifecycle_module_docstring_wall() -> None:
|
|
147
|
+
issues = check_docstring_runon_sentence(_run_lifecycle_module(), PRODUCTION_FILE_PATH)
|
|
148
|
+
assert any("run-on" in each for each in issues), (
|
|
149
|
+
f"Expected the module-docstring wall to flag, got: {issues!r}"
|
|
150
|
+
)
|
|
151
|
+
assert any("module" in each for each in issues)
|
|
152
|
+
assert len(issues) == 1
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_should_not_flag_approved_bar_module_docstring() -> None:
|
|
156
|
+
issues = check_docstring_runon_sentence(_approved_bar_module(), PRODUCTION_FILE_PATH)
|
|
157
|
+
assert issues == [], f"The approved bar must pass clean, got: {issues!r}"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_should_flag_class_docstring_wall() -> None:
|
|
161
|
+
issues = check_docstring_runon_sentence(_class_docstring_runon(), PRODUCTION_FILE_PATH)
|
|
162
|
+
assert any("RunRecorder" in each for each in issues), (
|
|
163
|
+
f"Expected the class-docstring wall to flag, got: {issues!r}"
|
|
164
|
+
)
|
|
165
|
+
assert len(issues) == 1
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_should_not_flag_normal_short_docstring() -> None:
|
|
169
|
+
issues = check_docstring_runon_sentence(_normal_short_docstring(), PRODUCTION_FILE_PATH)
|
|
170
|
+
assert issues == [], f"A short multi-sentence docstring must not flag, got: {issues!r}"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_should_not_flag_long_args_line_outside_narrative() -> None:
|
|
174
|
+
issues = check_docstring_runon_sentence(
|
|
175
|
+
_long_args_line_outside_narrative(), PRODUCTION_FILE_PATH
|
|
176
|
+
)
|
|
177
|
+
assert issues == [], f"The Args section is outside the narrative, got: {issues!r}"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_should_skip_private_function() -> None:
|
|
181
|
+
issues = check_docstring_runon_sentence(_private_function_runon(), PRODUCTION_FILE_PATH)
|
|
182
|
+
assert issues == [], f"Private functions are out of scope, got: {issues!r}"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_should_skip_property_method() -> None:
|
|
186
|
+
issues = check_docstring_runon_sentence(_property_method_runon(), PRODUCTION_FILE_PATH)
|
|
187
|
+
assert issues == [], f"@property methods are exempt, got: {issues!r}"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_should_skip_test_file() -> None:
|
|
191
|
+
issues = check_docstring_runon_sentence(_run_lifecycle_module(), TEST_FILE_PATH)
|
|
192
|
+
assert issues == [], f"Test files exempt, got: {issues!r}"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_should_skip_hook_infrastructure() -> None:
|
|
196
|
+
issues = check_docstring_runon_sentence(_run_lifecycle_module(), HOOK_INFRASTRUCTURE_PATH)
|
|
197
|
+
assert issues == [], f"Hook infrastructure exempt, got: {issues!r}"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_should_handle_syntax_error_gracefully() -> None:
|
|
201
|
+
issues = check_docstring_runon_sentence("def broken(\n", PRODUCTION_FILE_PATH)
|
|
202
|
+
assert issues == [], f"Syntax error must yield no issues, got: {issues!r}"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_validate_content_surfaces_docstring_runon_wall() -> None:
|
|
206
|
+
issues = validate_content(_run_lifecycle_module(), PRODUCTION_FILE_PATH, old_content="")
|
|
207
|
+
matching_issues = [each for each in issues if "run-on" in each]
|
|
208
|
+
assert matching_issues, f"Expected validate_content to surface the run-on wall, got: {issues!r}"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _attached_cli_flag_double_hyphen() -> str:
|
|
212
|
+
return (
|
|
213
|
+
"def gather_remote_reviews() -> None:\n"
|
|
214
|
+
' """Read every review the bot left across all the pages it produced and\n'
|
|
215
|
+
" gather them into one ordered list by paging through the endpoint with the\n"
|
|
216
|
+
" --paginate --slurp flags so the newest review across the whole set rises to\n"
|
|
217
|
+
" the top of the report for the reader.\n"
|
|
218
|
+
' """\n'
|
|
219
|
+
" return None\n"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _spaced_double_hyphen_joiner() -> str:
|
|
224
|
+
return (
|
|
225
|
+
"def gather_remote_reviews() -> None:\n"
|
|
226
|
+
' """Read every review the bot left across all the pages it produced and\n'
|
|
227
|
+
" gather them into one ordered list so the newest review rises to the top of\n"
|
|
228
|
+
" the report -- the single line a tired reader scans first to learn how the\n"
|
|
229
|
+
" whole run actually ended today.\n"
|
|
230
|
+
' """\n'
|
|
231
|
+
" return None\n"
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_should_not_flag_attached_cli_flag_double_hyphen() -> None:
|
|
236
|
+
issues = check_docstring_runon_sentence(
|
|
237
|
+
_attached_cli_flag_double_hyphen(), PRODUCTION_FILE_PATH
|
|
238
|
+
)
|
|
239
|
+
assert issues == [], (
|
|
240
|
+
"An attached --flag token is not a clause joiner and must not flag, "
|
|
241
|
+
f"got: {issues!r}"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_should_flag_spaced_double_hyphen_joiner() -> None:
|
|
246
|
+
issues = check_docstring_runon_sentence(
|
|
247
|
+
_spaced_double_hyphen_joiner(), PRODUCTION_FILE_PATH
|
|
248
|
+
)
|
|
249
|
+
assert any("run-on" in each for each in issues), (
|
|
250
|
+
f"A spaced double-hyphen joiner past the word limit must flag, got: {issues!r}"
|
|
251
|
+
)
|
|
252
|
+
assert len(issues) == 1
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_docstring_names_every_inspected_section_header() -> None:
|
|
256
|
+
docstring = code_rules_enforcer.check_docstring_runon_sentence.__doc__
|
|
257
|
+
assert docstring is not None
|
|
258
|
+
inspected_headers = set(ALL_DOCSTRING_ARGS_SECTION_HEADERS) | set(
|
|
259
|
+
ALL_DOCSTRING_TERMINATING_SECTION_HEADERS
|
|
260
|
+
)
|
|
261
|
+
missing_headers = sorted(
|
|
262
|
+
each_header for each_header in inspected_headers if each_header not in docstring
|
|
263
|
+
)
|
|
264
|
+
assert missing_headers == [], (
|
|
265
|
+
"The docstring must name every section header the body cuts the narrative on; "
|
|
266
|
+
f"missing: {missing_headers!r}"
|
|
267
|
+
)
|
|
@@ -7,6 +7,8 @@ under the file-global-constants use-count rule (CODE_RULES §file-global-constan
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import re
|
|
11
|
+
|
|
10
12
|
MAX_BANNED_PREFIX_ISSUES: int = 3
|
|
11
13
|
MAX_STUB_IMPLEMENTATION_ISSUES: int = 3
|
|
12
14
|
MAX_TYPED_DICT_PAIR_ISSUES: int = 3
|
|
@@ -61,6 +63,27 @@ MINIMUM_TOKENS_FOR_DISPATCH_CALLEE: int = 2
|
|
|
61
63
|
MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES: int = 3
|
|
62
64
|
MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES: int = 5
|
|
63
65
|
SINGLE_DICT_KEY_COUNT_FOR_PLURAL_CARDINALITY_DRIFT: int = 1
|
|
66
|
+
MAX_DOCSTRING_ARGS_SPAN_SCOPE_ISSUES: int = 3
|
|
67
|
+
ALL_DOCSTRING_SINGLE_LINE_SCOPE_PHRASES: tuple[str, ...] = (
|
|
68
|
+
"anchor line is among the changed lines",
|
|
69
|
+
"anchor line is among the edited lines",
|
|
70
|
+
"anchor line is among the changed",
|
|
71
|
+
"first line is among the changed lines",
|
|
72
|
+
"start line is among the changed lines",
|
|
73
|
+
)
|
|
74
|
+
ALL_DOCSTRING_SPAN_SCOPE_OVERRIDE_PHRASES: tuple[str, ...] = (
|
|
75
|
+
"any line of",
|
|
76
|
+
"any line in",
|
|
77
|
+
"any of its lines",
|
|
78
|
+
"any span line",
|
|
79
|
+
)
|
|
80
|
+
ALL_DOCSTRING_SPAN_RANGE_BODY_CALLEE_NAMES: tuple[str, ...] = (
|
|
81
|
+
"_scope_violations_to_changed_lines",
|
|
82
|
+
"_scope_violations",
|
|
83
|
+
)
|
|
84
|
+
MAX_DOCSTRING_CARDINAL_FAMILY_ISSUES: int = 5
|
|
85
|
+
MINIMUM_CONSTANT_FAMILY_MEMBERS_FOR_CARDINAL_CHECK: int = 2
|
|
86
|
+
MINIMUM_DOCSTRING_FAMILY_OVERLAP_FOR_CARDINAL_CHECK: int = 2
|
|
64
87
|
ALL_NAMING_CONVENTION_DESCRIPTOR_TOKENS: frozenset[str] = frozenset(
|
|
65
88
|
{
|
|
66
89
|
"UPPER_SNAKE_CASE",
|
|
@@ -114,6 +137,10 @@ ALL_FORMAT_LOGGER_FUNCTION_NAMES: frozenset[str] = frozenset(
|
|
|
114
137
|
"log_background",
|
|
115
138
|
}
|
|
116
139
|
)
|
|
140
|
+
DOCSTRING_RUNON_SENTENCE_WORD_LIMIT: int = 30
|
|
141
|
+
MAX_DOCSTRING_RUNON_SENTENCE_ISSUES: int = 5
|
|
142
|
+
ALL_DOCSTRING_RUNON_JOINER_MARKERS: tuple[str, ...] = ("—", " -- ", ";")
|
|
143
|
+
DOCSTRING_RUNON_SENTENCE_BOUNDARY_PATTERN: re.Pattern[str] = re.compile(r"(?<=[.!?])\s+")
|
|
117
144
|
|
|
118
145
|
ALL_DOCSTRING_NO_CONSUMER_CLAIM_PHRASES: tuple[str, ...] = (
|
|
119
146
|
"no consumer reads",
|