claude-dev-env 1.58.0 → 1.60.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 +2 -2
- package/_shared/pr-loop/scripts/code_rules_gate.py +36 -3
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +6 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
- package/_shared/pr-loop/scripts/reviews_disabled.py +12 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +265 -0
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +29 -0
- package/audit-rubrics/category_rubrics/category-b-selector-engine-compat.md +1 -1
- package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
- package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
- package/bin/install.mjs +100 -27
- package/bin/install.test.mjs +133 -1
- package/docs/CODE_RULES.md +3 -3
- package/hooks/blocking/code_rules_annotations_length.py +153 -0
- package/hooks/blocking/code_rules_dead_dataclass_field.py +319 -0
- package/hooks/blocking/code_rules_dead_module_constant.py +321 -0
- package/hooks/blocking/code_rules_duplicate_body.py +439 -0
- package/hooks/blocking/code_rules_enforcer.py +190 -21
- package/hooks/blocking/code_rules_magic_values.py +98 -0
- package/hooks/blocking/code_rules_shared.py +41 -0
- package/hooks/blocking/code_rules_typeddict_stub.py +172 -0
- package/hooks/blocking/config/__init__.py +5 -0
- package/hooks/blocking/config/verified_commit_constants.py +106 -0
- package/hooks/blocking/destructive_command_blocker.py +1027 -12
- package/hooks/blocking/hook_prose_detector_consistency.py +150 -0
- package/hooks/blocking/subprocess_budget_completeness.py +380 -0
- package/hooks/blocking/test_code_rules_enforcer_annotations.py +225 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
- package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +146 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +467 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +188 -0
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +330 -0
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +179 -0
- package/hooks/blocking/test_code_rules_enforcer_magic_slice_bounds.py +133 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias.py +415 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +156 -0
- package/hooks/blocking/test_destructive_command_blocker.py +622 -3
- package/hooks/blocking/test_hook_prose_detector_consistency.py +265 -0
- package/hooks/blocking/test_subprocess_budget_completeness.py +588 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +720 -0
- package/hooks/blocking/test_verification_verdict_store.py +278 -0
- package/hooks/blocking/test_verified_commit_gate.py +368 -0
- package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -0
- package/hooks/blocking/test_verifier_verdict_minter.py +214 -0
- package/hooks/blocking/test_workflow_substitution_slot_blocker.py +242 -0
- package/hooks/blocking/verdict_directory_write_blocker.py +667 -0
- package/hooks/blocking/verification_verdict_store.py +446 -0
- package/hooks/blocking/verified_commit_gate.py +523 -0
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +152 -0
- package/hooks/blocking/verifier_verdict_minter.py +299 -0
- package/hooks/blocking/workflow_substitution_slot_blocker.py +159 -0
- package/hooks/diagnostic/test_hook_log_extractor.py +3 -3
- package/hooks/hooks.json +58 -1
- package/hooks/hooks_constants/blocking_check_limits.py +1 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +16 -0
- package/hooks/hooks_constants/dead_dataclass_field_constants.py +25 -0
- package/hooks/hooks_constants/dead_module_constant_constants.py +20 -0
- package/hooks/hooks_constants/destructive_command_segment_constants.py +178 -0
- package/hooks/hooks_constants/duplicate_function_body_constants.py +34 -0
- package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +30 -0
- package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
- package/hooks/hooks_constants/subprocess_budget_completeness_constants.py +5 -0
- package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +22 -0
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +43 -0
- package/rules/file-global-constants.md +7 -1
- package/rules/hook-prose-matches-detector.md +26 -0
- package/rules/no-cross-skill-duplicate-helpers.md +29 -0
- package/rules/no-inline-destructive-literals.md +11 -0
- package/rules/workflow-substitution-slots.md +7 -0
- package/skills/_shared/pr-loop/scripts/preflight_worktree.py +392 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/preflight_constants.py +70 -0
- package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +263 -0
- package/skills/autoconverge/SKILL.md +67 -19
- package/skills/autoconverge/reference/closing-report.md +59 -17
- package/skills/autoconverge/reference/convergence.md +7 -3
- package/skills/autoconverge/reference/stop-conditions.md +7 -2
- package/skills/autoconverge/workflow/aggregate_runs.py +371 -0
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +193 -76
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +76 -0
- package/skills/autoconverge/workflow/converge.contract.test.mjs +206 -206
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +265 -0
- package/skills/autoconverge/workflow/converge.mjs +234 -42
- package/skills/autoconverge/workflow/convergence_summary.py +110 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ab1c2d3e4f5a6b7c8.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +7 -0
- package/skills/autoconverge/workflow/render_report.py +488 -397
- package/skills/autoconverge/workflow/test_aggregate_runs.py +134 -0
- package/skills/autoconverge/workflow/test_convergence_summary.py +132 -0
- package/skills/autoconverge/workflow/test_render_report.py +488 -259
- package/skills/pr-converge/reference/per-tick.md +28 -8
- package/skills/pr-converge/scripts/check_convergence.py +195 -64
- package/skills/pr-converge/scripts/test_check_convergence.py +173 -2
- package/skills/rebase/SKILL.md +2 -4
- package/skills/update/SKILL.md +37 -5
- package/system-prompts/software-engineer.xml +2 -6
- package/hooks/blocking/content_search_to_zoekt_redirector.py +0 -59
- package/hooks/blocking/content_search_zoekt_bash_block_reason.py +0 -25
- package/hooks/blocking/content_search_zoekt_block_payload.py +0 -21
- package/hooks/blocking/content_search_zoekt_indexed_paths.py +0 -24
- package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +0 -131
- package/hooks/blocking/content_search_zoekt_redirect_guidance.py +0 -52
- package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +0 -61
- package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +0 -92
- package/hooks/blocking/test_content_search_zoekt_indexed_roots_config.py +0 -102
|
@@ -0,0 +1,467 @@
|
|
|
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(
|
|
8
|
+
"code_rules_enforcer", ENFORCER_PATH
|
|
9
|
+
)
|
|
10
|
+
assert specification is not None and specification.loader is not None
|
|
11
|
+
code_rules_enforcer = importlib.util.module_from_spec(specification)
|
|
12
|
+
specification.loader.exec_module(code_rules_enforcer)
|
|
13
|
+
|
|
14
|
+
PRODUCTION_FILE_PATH = "packages/app/services/report.py"
|
|
15
|
+
TEST_FILE_PATH = "packages/app/services/test_report.py"
|
|
16
|
+
MIGRATION_FILE_PATH = "packages/app/migrations/0001_initial.py"
|
|
17
|
+
WORKFLOW_FILE_PATH = "packages/app/skills/thing/workflow/render_report.py"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _check(source: str, file_path: str) -> list[str]:
|
|
21
|
+
return code_rules_enforcer.check_dead_dataclass_fields(source, file_path)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_should_flag_dataclass_field_assigned_but_never_read() -> None:
|
|
25
|
+
source = (
|
|
26
|
+
"from dataclasses import dataclass\n"
|
|
27
|
+
"\n"
|
|
28
|
+
"@dataclass\n"
|
|
29
|
+
"class PrMetadata:\n"
|
|
30
|
+
" number: int\n"
|
|
31
|
+
" url: str\n"
|
|
32
|
+
"\n"
|
|
33
|
+
"def build() -> PrMetadata:\n"
|
|
34
|
+
" return PrMetadata(number=1, url='x')\n"
|
|
35
|
+
"\n"
|
|
36
|
+
"def render(metadata: PrMetadata) -> int:\n"
|
|
37
|
+
" return metadata.number\n"
|
|
38
|
+
)
|
|
39
|
+
issues = _check(source, PRODUCTION_FILE_PATH)
|
|
40
|
+
assert any(
|
|
41
|
+
"'url'" in each_issue and "PrMetadata" in each_issue for each_issue in issues
|
|
42
|
+
), f"Expected dead 'url' field flagged, got: {issues}"
|
|
43
|
+
assert not any(
|
|
44
|
+
"'number'" in each_issue for each_issue in issues
|
|
45
|
+
), f"Read field 'number' must not be flagged, got: {issues}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_should_not_flag_field_read_via_attribute_access() -> None:
|
|
49
|
+
source = (
|
|
50
|
+
"from dataclasses import dataclass\n"
|
|
51
|
+
"\n"
|
|
52
|
+
"@dataclass\n"
|
|
53
|
+
"class PrMetadata:\n"
|
|
54
|
+
" url: str\n"
|
|
55
|
+
"\n"
|
|
56
|
+
"def build() -> PrMetadata:\n"
|
|
57
|
+
" return PrMetadata(url='x')\n"
|
|
58
|
+
"\n"
|
|
59
|
+
"def render(metadata: PrMetadata) -> str:\n"
|
|
60
|
+
" return metadata.url\n"
|
|
61
|
+
)
|
|
62
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_should_not_flag_when_class_never_constructed_in_file() -> None:
|
|
66
|
+
source = (
|
|
67
|
+
"from dataclasses import dataclass\n"
|
|
68
|
+
"\n"
|
|
69
|
+
"@dataclass\n"
|
|
70
|
+
"class PublicConfig:\n"
|
|
71
|
+
" url: str\n"
|
|
72
|
+
" number: int\n"
|
|
73
|
+
)
|
|
74
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_should_not_flag_non_dataclass_class() -> None:
|
|
78
|
+
source = "class Plain:\n url: str\n\ndef build() -> Plain:\n return Plain()\n"
|
|
79
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_should_not_flag_classvar_field() -> None:
|
|
83
|
+
source = (
|
|
84
|
+
"from dataclasses import dataclass\n"
|
|
85
|
+
"from typing import ClassVar\n"
|
|
86
|
+
"\n"
|
|
87
|
+
"@dataclass\n"
|
|
88
|
+
"class Counter:\n"
|
|
89
|
+
" label: ClassVar[str] = 'count'\n"
|
|
90
|
+
" total: int\n"
|
|
91
|
+
"\n"
|
|
92
|
+
"def build() -> Counter:\n"
|
|
93
|
+
" return Counter(total=1)\n"
|
|
94
|
+
"\n"
|
|
95
|
+
"def read(counter: Counter) -> int:\n"
|
|
96
|
+
" return counter.total\n"
|
|
97
|
+
)
|
|
98
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_should_not_flag_field_read_via_literal_getattr() -> None:
|
|
102
|
+
source = (
|
|
103
|
+
"from dataclasses import dataclass\n"
|
|
104
|
+
"\n"
|
|
105
|
+
"@dataclass\n"
|
|
106
|
+
"class Row:\n"
|
|
107
|
+
" url: str\n"
|
|
108
|
+
"\n"
|
|
109
|
+
"def build() -> Row:\n"
|
|
110
|
+
" return Row(url='x')\n"
|
|
111
|
+
"\n"
|
|
112
|
+
"def read(row: Row) -> str:\n"
|
|
113
|
+
" return getattr(row, 'url')\n"
|
|
114
|
+
)
|
|
115
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_should_suppress_check_when_non_literal_getattr_present() -> None:
|
|
119
|
+
source = (
|
|
120
|
+
"from dataclasses import dataclass\n"
|
|
121
|
+
"\n"
|
|
122
|
+
"@dataclass\n"
|
|
123
|
+
"class Row:\n"
|
|
124
|
+
" url: str\n"
|
|
125
|
+
"\n"
|
|
126
|
+
"def build() -> Row:\n"
|
|
127
|
+
" return Row(url='x')\n"
|
|
128
|
+
"\n"
|
|
129
|
+
"def read(row: Row, field_name: str) -> str:\n"
|
|
130
|
+
" return getattr(row, field_name)\n"
|
|
131
|
+
)
|
|
132
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_should_handle_called_dataclass_decorator() -> None:
|
|
136
|
+
source = (
|
|
137
|
+
"from dataclasses import dataclass\n"
|
|
138
|
+
"\n"
|
|
139
|
+
"@dataclass(frozen=True)\n"
|
|
140
|
+
"class PrMetadata:\n"
|
|
141
|
+
" url: str\n"
|
|
142
|
+
"\n"
|
|
143
|
+
"def build() -> PrMetadata:\n"
|
|
144
|
+
" return PrMetadata(url='x')\n"
|
|
145
|
+
)
|
|
146
|
+
issues = _check(source, PRODUCTION_FILE_PATH)
|
|
147
|
+
assert any(
|
|
148
|
+
"'url'" in each_issue for each_issue in issues
|
|
149
|
+
), f"Expected dead field flagged on called-decorator dataclass, got: {issues}"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_should_handle_dotted_dataclasses_decorator() -> None:
|
|
153
|
+
source = (
|
|
154
|
+
"import dataclasses\n"
|
|
155
|
+
"\n"
|
|
156
|
+
"@dataclasses.dataclass\n"
|
|
157
|
+
"class PrMetadata:\n"
|
|
158
|
+
" url: str\n"
|
|
159
|
+
"\n"
|
|
160
|
+
"def build() -> PrMetadata:\n"
|
|
161
|
+
" return PrMetadata(url='x')\n"
|
|
162
|
+
)
|
|
163
|
+
issues = _check(source, PRODUCTION_FILE_PATH)
|
|
164
|
+
assert any(
|
|
165
|
+
"'url'" in each_issue for each_issue in issues
|
|
166
|
+
), f"Expected dead field flagged on dotted-decorator dataclass, got: {issues}"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_should_be_silent_for_test_files() -> None:
|
|
170
|
+
source = (
|
|
171
|
+
"from dataclasses import dataclass\n"
|
|
172
|
+
"\n"
|
|
173
|
+
"@dataclass\n"
|
|
174
|
+
"class PrMetadata:\n"
|
|
175
|
+
" url: str\n"
|
|
176
|
+
"\n"
|
|
177
|
+
"def build() -> PrMetadata:\n"
|
|
178
|
+
" return PrMetadata(url='x')\n"
|
|
179
|
+
)
|
|
180
|
+
assert _check(source, TEST_FILE_PATH) == []
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_should_be_silent_for_migration_files() -> None:
|
|
184
|
+
source = (
|
|
185
|
+
"from dataclasses import dataclass\n"
|
|
186
|
+
"\n"
|
|
187
|
+
"@dataclass\n"
|
|
188
|
+
"class PrMetadata:\n"
|
|
189
|
+
" url: str\n"
|
|
190
|
+
"\n"
|
|
191
|
+
"def build() -> PrMetadata:\n"
|
|
192
|
+
" return PrMetadata(url='x')\n"
|
|
193
|
+
)
|
|
194
|
+
assert _check(source, MIGRATION_FILE_PATH) == []
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_should_flag_dead_field_in_workflow_path() -> None:
|
|
198
|
+
source = (
|
|
199
|
+
"from dataclasses import dataclass\n"
|
|
200
|
+
"\n"
|
|
201
|
+
"@dataclass\n"
|
|
202
|
+
"class PrMetadata:\n"
|
|
203
|
+
" url: str\n"
|
|
204
|
+
"\n"
|
|
205
|
+
"def build() -> PrMetadata:\n"
|
|
206
|
+
" return PrMetadata(url='x')\n"
|
|
207
|
+
)
|
|
208
|
+
issues = _check(source, WORKFLOW_FILE_PATH)
|
|
209
|
+
assert any(
|
|
210
|
+
"'url'" in each_issue for each_issue in issues
|
|
211
|
+
), f"Workflow-path files are subject to dead-code detection, got: {issues}"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_should_tolerate_syntax_error() -> None:
|
|
215
|
+
assert _check("@dataclass\nclass Broken(:\n", PRODUCTION_FILE_PATH) == []
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_validate_content_runs_dead_field_check() -> None:
|
|
219
|
+
source = (
|
|
220
|
+
"from dataclasses import dataclass\n"
|
|
221
|
+
"\n"
|
|
222
|
+
"@dataclass\n"
|
|
223
|
+
"class PrMetadata:\n"
|
|
224
|
+
" url: str\n"
|
|
225
|
+
"\n"
|
|
226
|
+
"def build() -> PrMetadata:\n"
|
|
227
|
+
" return PrMetadata(url='x')\n"
|
|
228
|
+
)
|
|
229
|
+
issues = code_rules_enforcer.validate_content(source, PRODUCTION_FILE_PATH)
|
|
230
|
+
assert any(
|
|
231
|
+
"'url'" in each_issue for each_issue in issues
|
|
232
|
+
), f"Expected the enforcer dispatch to surface the dead field, got: {issues}"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_should_suppress_check_when_asdict_consumes_instance() -> None:
|
|
236
|
+
source = (
|
|
237
|
+
"from dataclasses import dataclass, asdict\n"
|
|
238
|
+
"\n"
|
|
239
|
+
"@dataclass\n"
|
|
240
|
+
"class Row:\n"
|
|
241
|
+
" url: str\n"
|
|
242
|
+
"\n"
|
|
243
|
+
"def build() -> dict:\n"
|
|
244
|
+
" return asdict(Row(url='x'))\n"
|
|
245
|
+
)
|
|
246
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def test_should_suppress_check_when_astuple_consumes_instance() -> None:
|
|
250
|
+
source = (
|
|
251
|
+
"from dataclasses import dataclass, astuple\n"
|
|
252
|
+
"\n"
|
|
253
|
+
"@dataclass\n"
|
|
254
|
+
"class Row:\n"
|
|
255
|
+
" url: str\n"
|
|
256
|
+
"\n"
|
|
257
|
+
"def build() -> tuple:\n"
|
|
258
|
+
" return astuple(Row(url='x'))\n"
|
|
259
|
+
)
|
|
260
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def test_should_suppress_check_when_vars_consumes_instance() -> None:
|
|
264
|
+
source = (
|
|
265
|
+
"from dataclasses import dataclass\n"
|
|
266
|
+
"\n"
|
|
267
|
+
"@dataclass\n"
|
|
268
|
+
"class Row:\n"
|
|
269
|
+
" url: str\n"
|
|
270
|
+
"\n"
|
|
271
|
+
"def build() -> dict:\n"
|
|
272
|
+
" return vars(Row(url='x'))\n"
|
|
273
|
+
)
|
|
274
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def test_should_suppress_check_when_replace_consumes_instance() -> None:
|
|
278
|
+
source = (
|
|
279
|
+
"from dataclasses import dataclass, replace\n"
|
|
280
|
+
"\n"
|
|
281
|
+
"@dataclass(frozen=True)\n"
|
|
282
|
+
"class Row:\n"
|
|
283
|
+
" url: str\n"
|
|
284
|
+
"\n"
|
|
285
|
+
"def build() -> Row:\n"
|
|
286
|
+
" return replace(Row(url='x'), url='y')\n"
|
|
287
|
+
)
|
|
288
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def test_should_suppress_check_when_dotted_replace_consumes_instance() -> None:
|
|
292
|
+
source = (
|
|
293
|
+
"import dataclasses\n"
|
|
294
|
+
"\n"
|
|
295
|
+
"@dataclasses.dataclass(frozen=True)\n"
|
|
296
|
+
"class Row:\n"
|
|
297
|
+
" url: str\n"
|
|
298
|
+
"\n"
|
|
299
|
+
"def build() -> Row:\n"
|
|
300
|
+
" return dataclasses.replace(Row(url='x'), url='y')\n"
|
|
301
|
+
)
|
|
302
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def test_should_suppress_check_when_instance_dict_consumes_instance() -> None:
|
|
306
|
+
source = (
|
|
307
|
+
"from dataclasses import dataclass\n"
|
|
308
|
+
"\n"
|
|
309
|
+
"@dataclass\n"
|
|
310
|
+
"class Row:\n"
|
|
311
|
+
" url: str\n"
|
|
312
|
+
"\n"
|
|
313
|
+
"def build() -> dict:\n"
|
|
314
|
+
" return Row(url='x').__dict__\n"
|
|
315
|
+
)
|
|
316
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def test_should_not_flag_field_read_via_multi_argument_attrgetter() -> None:
|
|
320
|
+
source = (
|
|
321
|
+
"from dataclasses import dataclass\n"
|
|
322
|
+
"from operator import attrgetter\n"
|
|
323
|
+
"\n"
|
|
324
|
+
"@dataclass\n"
|
|
325
|
+
"class Row:\n"
|
|
326
|
+
" first: int\n"
|
|
327
|
+
" second: int\n"
|
|
328
|
+
"\n"
|
|
329
|
+
"def build() -> tuple:\n"
|
|
330
|
+
" return attrgetter('first', 'second')(Row(first=1, second=2))\n"
|
|
331
|
+
)
|
|
332
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def test_should_suppress_check_when_two_instances_compared_for_equality() -> None:
|
|
336
|
+
source = (
|
|
337
|
+
"from dataclasses import dataclass\n"
|
|
338
|
+
"\n"
|
|
339
|
+
"@dataclass\n"
|
|
340
|
+
"class Row:\n"
|
|
341
|
+
" url: str\n"
|
|
342
|
+
"\n"
|
|
343
|
+
"def are_same(left: Row, right: Row) -> bool:\n"
|
|
344
|
+
" return Row(url='a') == Row(url='b')\n"
|
|
345
|
+
)
|
|
346
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def test_should_suppress_check_when_ordered_instances_compared() -> None:
|
|
350
|
+
source = (
|
|
351
|
+
"from dataclasses import dataclass\n"
|
|
352
|
+
"\n"
|
|
353
|
+
"@dataclass(order=True)\n"
|
|
354
|
+
"class Row:\n"
|
|
355
|
+
" priority: int\n"
|
|
356
|
+
"\n"
|
|
357
|
+
"def is_earlier() -> bool:\n"
|
|
358
|
+
" return Row(priority=1) < Row(priority=2)\n"
|
|
359
|
+
)
|
|
360
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def test_should_suppress_check_when_instances_placed_in_set() -> None:
|
|
364
|
+
source = (
|
|
365
|
+
"from dataclasses import dataclass\n"
|
|
366
|
+
"\n"
|
|
367
|
+
"@dataclass(frozen=True)\n"
|
|
368
|
+
"class Row:\n"
|
|
369
|
+
" url: str\n"
|
|
370
|
+
"\n"
|
|
371
|
+
"def unique_rows() -> set:\n"
|
|
372
|
+
" return {Row(url='a'), Row(url='b')}\n"
|
|
373
|
+
)
|
|
374
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def test_should_suppress_check_when_whole_instance_stringified() -> None:
|
|
378
|
+
source = (
|
|
379
|
+
"from dataclasses import dataclass\n"
|
|
380
|
+
"\n"
|
|
381
|
+
"@dataclass\n"
|
|
382
|
+
"class Row:\n"
|
|
383
|
+
" url: str\n"
|
|
384
|
+
"\n"
|
|
385
|
+
"def describe() -> str:\n"
|
|
386
|
+
" return str(Row(url='x'))\n"
|
|
387
|
+
)
|
|
388
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def test_should_not_flag_field_read_via_match_class_pattern() -> None:
|
|
392
|
+
source = (
|
|
393
|
+
"from dataclasses import dataclass\n"
|
|
394
|
+
"\n"
|
|
395
|
+
"@dataclass\n"
|
|
396
|
+
"class Row:\n"
|
|
397
|
+
" url: str\n"
|
|
398
|
+
"\n"
|
|
399
|
+
"def read(row: Row) -> str:\n"
|
|
400
|
+
" match row:\n"
|
|
401
|
+
" case Row(url=found):\n"
|
|
402
|
+
" return found\n"
|
|
403
|
+
" return ''\n"
|
|
404
|
+
"\n"
|
|
405
|
+
"def build() -> Row:\n"
|
|
406
|
+
" return Row(url='x')\n"
|
|
407
|
+
)
|
|
408
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def test_should_suppress_check_when_fields_reflection_consumes_instance() -> None:
|
|
412
|
+
source = (
|
|
413
|
+
"import dataclasses\n"
|
|
414
|
+
"from dataclasses import dataclass\n"
|
|
415
|
+
"\n"
|
|
416
|
+
"@dataclass\n"
|
|
417
|
+
"class Row:\n"
|
|
418
|
+
" url: str\n"
|
|
419
|
+
"\n"
|
|
420
|
+
"def field_names() -> list:\n"
|
|
421
|
+
" return [each_field.name for each_field in dataclasses.fields(Row(url='x'))]\n"
|
|
422
|
+
)
|
|
423
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def test_should_not_flag_field_read_via_augmented_assignment() -> None:
|
|
427
|
+
source = (
|
|
428
|
+
"from dataclasses import dataclass\n"
|
|
429
|
+
"\n"
|
|
430
|
+
"@dataclass\n"
|
|
431
|
+
"class Row:\n"
|
|
432
|
+
" counter: int\n"
|
|
433
|
+
"\n"
|
|
434
|
+
"def bump(row: Row) -> None:\n"
|
|
435
|
+
" row.counter += 1\n"
|
|
436
|
+
"\n"
|
|
437
|
+
"def build() -> Row:\n"
|
|
438
|
+
" return Row(counter=0)\n"
|
|
439
|
+
)
|
|
440
|
+
assert _check(source, PRODUCTION_FILE_PATH) == []
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def test_should_evaluate_full_file_content_when_supplied() -> None:
|
|
444
|
+
fragment = " return metadata.number\n"
|
|
445
|
+
full_file = (
|
|
446
|
+
"from dataclasses import dataclass\n"
|
|
447
|
+
"\n"
|
|
448
|
+
"@dataclass\n"
|
|
449
|
+
"class PrMetadata:\n"
|
|
450
|
+
" number: int\n"
|
|
451
|
+
" url: str\n"
|
|
452
|
+
"\n"
|
|
453
|
+
"def build() -> PrMetadata:\n"
|
|
454
|
+
" return PrMetadata(number=1, url='x')\n"
|
|
455
|
+
"\n"
|
|
456
|
+
"def render(metadata: PrMetadata) -> int:\n"
|
|
457
|
+
" return metadata.number\n"
|
|
458
|
+
)
|
|
459
|
+
issues = code_rules_enforcer.check_dead_dataclass_fields(
|
|
460
|
+
fragment, PRODUCTION_FILE_PATH, full_file
|
|
461
|
+
)
|
|
462
|
+
assert any(
|
|
463
|
+
"'url'" in each_issue for each_issue in issues
|
|
464
|
+
), f"Expected the reconstructed whole-file content to govern, got: {issues}"
|
|
465
|
+
assert not any(
|
|
466
|
+
"'number'" in each_issue for each_issue in issues
|
|
467
|
+
), f"Read field 'number' must not be flagged, got: {issues}"
|
|
@@ -0,0 +1,188 @@
|
|
|
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
|
+
CONSTANTS_BODY = 'MEDIUM_TERMINAL = "terminal"\nMEDIUM_CODE = "code"\nMEDIUM_TEXT = "text"\n'
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _strip_read_only_and_retry(
|
|
23
|
+
removal_function: Callable[[str], object],
|
|
24
|
+
target_path: str,
|
|
25
|
+
_exc_info: BaseException,
|
|
26
|
+
) -> None:
|
|
27
|
+
try:
|
|
28
|
+
os.chmod(target_path, stat.S_IWRITE)
|
|
29
|
+
removal_function(target_path)
|
|
30
|
+
except OSError:
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.fixture
|
|
35
|
+
def neutral_root() -> Iterator[Path]:
|
|
36
|
+
"""Yield a temp directory whose path carries no ``test_`` segment.
|
|
37
|
+
|
|
38
|
+
The enforcer's ``is_test_file`` keys on the full path string, and pytest's
|
|
39
|
+
own ``tmp_path`` directory name embeds the test name, which would make every
|
|
40
|
+
synthetic constants path look like a test file. A neutral ``mkdtemp`` root
|
|
41
|
+
mirrors how a production constants module path looks.
|
|
42
|
+
"""
|
|
43
|
+
neutral_directory = Path(tempfile.mkdtemp(prefix="deadconst-")).resolve()
|
|
44
|
+
try:
|
|
45
|
+
yield neutral_directory
|
|
46
|
+
finally:
|
|
47
|
+
shutil.rmtree(neutral_directory, onexc=_strip_read_only_and_retry)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _check(source: str, file_path: str) -> list[str]:
|
|
51
|
+
return code_rules_enforcer.check_dead_module_constants(source, file_path)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _build_constants_package(
|
|
55
|
+
workflow_directory: Path,
|
|
56
|
+
constants_body: str,
|
|
57
|
+
consumer_body: str,
|
|
58
|
+
) -> Path:
|
|
59
|
+
constants_package = workflow_directory / "report_constants"
|
|
60
|
+
constants_package.mkdir(parents=True)
|
|
61
|
+
(constants_package / "__init__.py").write_text("", encoding="utf-8")
|
|
62
|
+
constants_path = constants_package / "render_report_constants.py"
|
|
63
|
+
constants_path.write_text(constants_body, encoding="utf-8")
|
|
64
|
+
(workflow_directory / "render_report.py").write_text(consumer_body, encoding="utf-8")
|
|
65
|
+
return constants_path
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_flags_constant_imported_by_no_module_in_the_tree(neutral_root: Path) -> None:
|
|
69
|
+
consumer_body = (
|
|
70
|
+
"from report_constants.render_report_constants import (\n"
|
|
71
|
+
" MEDIUM_CODE,\n"
|
|
72
|
+
" MEDIUM_TERMINAL,\n"
|
|
73
|
+
")\n"
|
|
74
|
+
"\n"
|
|
75
|
+
"def panel_class(medium: str) -> str:\n"
|
|
76
|
+
" if medium == MEDIUM_TERMINAL:\n"
|
|
77
|
+
" return 'terminal'\n"
|
|
78
|
+
" return 'code-panel' if medium == MEDIUM_CODE else 'text-panel'\n"
|
|
79
|
+
)
|
|
80
|
+
constants_path = _build_constants_package(
|
|
81
|
+
neutral_root / "workflow", CONSTANTS_BODY, consumer_body
|
|
82
|
+
)
|
|
83
|
+
issues = _check(CONSTANTS_BODY, str(constants_path))
|
|
84
|
+
assert any("MEDIUM_TEXT" in each_issue for each_issue in issues), (
|
|
85
|
+
f"Expected dead MEDIUM_TEXT flagged, got: {issues}"
|
|
86
|
+
)
|
|
87
|
+
assert not any(
|
|
88
|
+
"MEDIUM_TERMINAL" in each_issue or "MEDIUM_CODE" in each_issue for each_issue in issues
|
|
89
|
+
), f"Imported constants must not be flagged, got: {issues}"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_does_not_flag_constant_imported_one_directory_up(neutral_root: Path) -> None:
|
|
93
|
+
consumer_uses_text = (
|
|
94
|
+
"from report_constants.render_report_constants import (\n"
|
|
95
|
+
" MEDIUM_CODE,\n"
|
|
96
|
+
" MEDIUM_TERMINAL,\n"
|
|
97
|
+
" MEDIUM_TEXT,\n"
|
|
98
|
+
")\n"
|
|
99
|
+
"\n"
|
|
100
|
+
"def panel_class(medium: str) -> str:\n"
|
|
101
|
+
" if medium == MEDIUM_TERMINAL:\n"
|
|
102
|
+
" return 'terminal'\n"
|
|
103
|
+
" if medium == MEDIUM_TEXT:\n"
|
|
104
|
+
" return 'text-panel'\n"
|
|
105
|
+
" return 'code-panel' if medium == MEDIUM_CODE else 'text-panel'\n"
|
|
106
|
+
)
|
|
107
|
+
constants_path = _build_constants_package(
|
|
108
|
+
neutral_root / "workflow", CONSTANTS_BODY, consumer_uses_text
|
|
109
|
+
)
|
|
110
|
+
issues = _check(CONSTANTS_BODY, str(constants_path))
|
|
111
|
+
assert issues == [], f"No constant is dead when all are imported, got: {issues}"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_does_not_flag_when_module_declares_dunder_all(neutral_root: Path) -> None:
|
|
115
|
+
constants_body = CONSTANTS_BODY + '__all__ = ["MEDIUM_TERMINAL"]\n'
|
|
116
|
+
consumer_body = (
|
|
117
|
+
"from report_constants.render_report_constants import MEDIUM_TERMINAL\n"
|
|
118
|
+
"\n"
|
|
119
|
+
"def label() -> str:\n"
|
|
120
|
+
" return MEDIUM_TERMINAL\n"
|
|
121
|
+
)
|
|
122
|
+
constants_path = _build_constants_package(
|
|
123
|
+
neutral_root / "workflow", constants_body, consumer_body
|
|
124
|
+
)
|
|
125
|
+
issues = _check(constants_body, str(constants_path))
|
|
126
|
+
assert issues == [], f"__all__ surface suppresses the check, got: {issues}"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_does_not_run_on_ordinary_production_module(neutral_root: Path) -> None:
|
|
130
|
+
workflow_directory = neutral_root / "workflow"
|
|
131
|
+
workflow_directory.mkdir(parents=True)
|
|
132
|
+
ordinary_path = workflow_directory / "render_report.py"
|
|
133
|
+
body = "WIDGET_LIMIT = 5\n\ndef widgets() -> int:\n return WIDGET_LIMIT\n"
|
|
134
|
+
ordinary_path.write_text(body, encoding="utf-8")
|
|
135
|
+
issues = _check(body, str(ordinary_path))
|
|
136
|
+
assert issues == [], (
|
|
137
|
+
f"The dead-constant check runs only on dedicated constants modules, got: {issues}"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_runs_on_config_directory_module(neutral_root: Path) -> None:
|
|
142
|
+
package_directory = neutral_root / "app"
|
|
143
|
+
config_directory = package_directory / "config"
|
|
144
|
+
config_directory.mkdir(parents=True)
|
|
145
|
+
constants_body = "TIMEOUT_SECONDS = 30\nUNUSED_THRESHOLD = 99\n"
|
|
146
|
+
constants_path = config_directory / "timing.py"
|
|
147
|
+
constants_path.write_text(constants_body, encoding="utf-8")
|
|
148
|
+
consumer_body = (
|
|
149
|
+
"from config.timing import TIMEOUT_SECONDS\n"
|
|
150
|
+
"\n"
|
|
151
|
+
"def deadline() -> int:\n"
|
|
152
|
+
" return TIMEOUT_SECONDS\n"
|
|
153
|
+
)
|
|
154
|
+
(package_directory / "service.py").write_text(consumer_body, encoding="utf-8")
|
|
155
|
+
issues = _check(constants_body, str(constants_path))
|
|
156
|
+
assert any("UNUSED_THRESHOLD" in each_issue for each_issue in issues), (
|
|
157
|
+
f"Expected dead UNUSED_THRESHOLD flagged in config module, got: {issues}"
|
|
158
|
+
)
|
|
159
|
+
assert not any("TIMEOUT_SECONDS" in each_issue for each_issue in issues), (
|
|
160
|
+
f"Consumed config constant must not be flagged, got: {issues}"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_counts_a_reference_from_a_test_module(neutral_root: Path) -> None:
|
|
165
|
+
workflow_directory = neutral_root / "workflow"
|
|
166
|
+
constants_body = 'ONLY_TESTS_USE_THIS = "x"\n'
|
|
167
|
+
constants_path = workflow_directory / "render_report_constants.py"
|
|
168
|
+
workflow_directory.mkdir(parents=True)
|
|
169
|
+
constants_path.write_text(constants_body, encoding="utf-8")
|
|
170
|
+
test_body = (
|
|
171
|
+
"from render_report_constants import ONLY_TESTS_USE_THIS\n"
|
|
172
|
+
"\n"
|
|
173
|
+
"def test_value() -> None:\n"
|
|
174
|
+
" assert ONLY_TESTS_USE_THIS == 'x'\n"
|
|
175
|
+
)
|
|
176
|
+
(workflow_directory / "test_render_report.py").write_text(test_body, encoding="utf-8")
|
|
177
|
+
issues = _check(constants_body, str(constants_path))
|
|
178
|
+
assert issues == [], f"A constant used only by a test under the tree stays live, got: {issues}"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_is_skipped_on_a_constants_test_file(neutral_root: Path) -> None:
|
|
182
|
+
workflow_directory = neutral_root / "workflow"
|
|
183
|
+
workflow_directory.mkdir(parents=True)
|
|
184
|
+
test_constants_path = workflow_directory / "test_render_report_constants.py"
|
|
185
|
+
body = 'UNREFERENCED = "y"\n'
|
|
186
|
+
test_constants_path.write_text(body, encoding="utf-8")
|
|
187
|
+
issues = _check(body, str(test_constants_path))
|
|
188
|
+
assert issues == [], f"Test files are exempt, got: {issues}"
|