claude-dev-env 1.58.0 → 1.59.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.
Files changed (52) hide show
  1. package/CLAUDE.md +2 -2
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +36 -3
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +6 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
  5. package/_shared/pr-loop/scripts/reviews_disabled.py +12 -0
  6. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +265 -0
  7. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +29 -0
  8. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  9. package/bin/install.mjs +100 -27
  10. package/bin/install.test.mjs +133 -1
  11. package/docs/CODE_RULES.md +3 -3
  12. package/hooks/blocking/code_rules_annotations_length.py +153 -0
  13. package/hooks/blocking/code_rules_dead_dataclass_field.py +319 -0
  14. package/hooks/blocking/code_rules_duplicate_body.py +287 -0
  15. package/hooks/blocking/code_rules_enforcer.py +175 -21
  16. package/hooks/blocking/code_rules_magic_values.py +98 -0
  17. package/hooks/blocking/code_rules_shared.py +41 -0
  18. package/hooks/blocking/destructive_command_blocker.py +1027 -12
  19. package/hooks/blocking/hook_prose_detector_consistency.py +150 -0
  20. package/hooks/blocking/subprocess_budget_completeness.py +380 -0
  21. package/hooks/blocking/test_code_rules_enforcer_annotations.py +225 -0
  22. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  23. package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +467 -0
  24. package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +330 -0
  25. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +179 -0
  26. package/hooks/blocking/test_code_rules_enforcer_magic_slice_bounds.py +133 -0
  27. package/hooks/blocking/test_destructive_command_blocker.py +622 -3
  28. package/hooks/blocking/test_hook_prose_detector_consistency.py +265 -0
  29. package/hooks/blocking/test_subprocess_budget_completeness.py +588 -0
  30. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +242 -0
  31. package/hooks/blocking/workflow_substitution_slot_blocker.py +159 -0
  32. package/hooks/hooks.json +15 -0
  33. package/hooks/hooks_constants/code_rules_enforcer_constants.py +16 -0
  34. package/hooks/hooks_constants/dead_dataclass_field_constants.py +25 -0
  35. package/hooks/hooks_constants/destructive_command_segment_constants.py +178 -0
  36. package/hooks/hooks_constants/duplicate_function_body_constants.py +17 -0
  37. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +30 -0
  38. package/hooks/hooks_constants/subprocess_budget_completeness_constants.py +5 -0
  39. package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +22 -0
  40. package/package.json +1 -1
  41. package/rules/docstring-prose-matches-implementation.md +43 -0
  42. package/rules/hook-prose-matches-detector.md +26 -0
  43. package/rules/no-inline-destructive-literals.md +11 -0
  44. package/rules/workflow-substitution-slots.md +7 -0
  45. package/skills/autoconverge/SKILL.md +13 -2
  46. package/skills/autoconverge/reference/convergence.md +7 -3
  47. package/skills/autoconverge/reference/stop-conditions.md +7 -2
  48. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +265 -0
  49. package/skills/autoconverge/workflow/converge.mjs +106 -36
  50. package/skills/pr-converge/scripts/check_convergence.py +195 -64
  51. package/skills/pr-converge/scripts/test_check_convergence.py +173 -2
  52. package/skills/update/SKILL.md +37 -5
@@ -1,14 +1,18 @@
1
1
  import { test } from 'node:test';
2
2
  import { strict as assert } from 'node:assert';
3
3
  import { execFileSync } from 'node:child_process';
4
- import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
4
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, symlinkSync } from 'node:fs';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { join } from 'node:path';
7
+ import { pathToFileURL } from 'node:url';
7
8
 
8
9
  import {
9
10
  collectPackageSourceConflicts,
10
11
  CONTENT_DIRECTORIES,
11
12
  pythonCandidatesForPlatform,
13
+ isWindowsStorePythonStub,
14
+ interpreterCommandFromPath,
15
+ invokedAsEntryPoint,
12
16
  managedHookScriptRelativePaths,
13
17
  managedHookScriptRelativePathsFromSourceRoots,
14
18
  commandReferencesManagedHook,
@@ -202,6 +206,134 @@ test('pythonCandidatesForPlatform still offers python as a win32 fallback when p
202
206
  });
203
207
 
204
208
 
209
+ test('isWindowsStorePythonStub flags the Microsoft Store WindowsApps alias paths', () => {
210
+ assert.equal(
211
+ isWindowsStorePythonStub('C:\\Program Files\\WindowsApps\\PythonSoftwareFoundation.Python.3.13_3.13.3824.0_x64__qbz5n2kfra8p0\\python3.13.exe'),
212
+ true,
213
+ );
214
+ assert.equal(
215
+ isWindowsStorePythonStub('C:/Users/jon/AppData/Local/Microsoft/WindowsApps/python.exe'),
216
+ true,
217
+ );
218
+ });
219
+
220
+
221
+ test('isWindowsStorePythonStub does not flag a real interpreter install path', () => {
222
+ assert.equal(isWindowsStorePythonStub('C:\\Python313\\python.exe'), false);
223
+ assert.equal(isWindowsStorePythonStub('/usr/bin/python3'), false);
224
+ });
225
+
226
+
227
+ test('interpreterCommandFromPath forward-slashes a Windows interpreter path and leaves a space-free path unquoted', () => {
228
+ assert.equal(interpreterCommandFromPath('C:\\Python313\\python.exe'), 'C:/Python313/python.exe');
229
+ });
230
+
231
+
232
+ test('interpreterCommandFromPath quotes an interpreter path that contains a space', () => {
233
+ assert.equal(
234
+ interpreterCommandFromPath('C:\\Program Files\\Python313\\python.exe'),
235
+ '"C:/Program Files/Python313/python.exe"',
236
+ );
237
+ });
238
+
239
+
240
+ test('mergeHooksIntoSettings substitutes a quoted absolute interpreter path for the python3 prefix', () => {
241
+ const hooksConfig = {
242
+ hooks: {
243
+ PostToolUse: [
244
+ {
245
+ matcher: 'Edit',
246
+ hooks: [{ type: 'command', command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/workflow/auto_formatter.py' }],
247
+ },
248
+ ],
249
+ },
250
+ };
251
+ const settings = {};
252
+ mergeHooksIntoSettings(settings, hooksConfig, 'C:/Users/x/.claude', '"C:/Program Files/Python313/python.exe"');
253
+ assert.equal(
254
+ settings.hooks.PostToolUse[0].hooks[0].command,
255
+ '"C:/Program Files/Python313/python.exe" C:/Users/x/.claude/hooks/workflow/auto_formatter.py',
256
+ );
257
+ });
258
+
259
+
260
+ test('mergeHooksIntoSettings prunes a prior py -3 managed hook when reinstalling with an absolute interpreter path', () => {
261
+ const hooksConfig = {
262
+ hooks: {
263
+ PostToolUse: [
264
+ {
265
+ matcher: 'Edit',
266
+ hooks: [{ type: 'command', command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/workflow/auto_formatter.py' }],
267
+ },
268
+ ],
269
+ },
270
+ };
271
+ const settings = {
272
+ hooks: {
273
+ PostToolUse: [
274
+ {
275
+ matcher: 'Edit',
276
+ hooks: [{ type: 'command', command: 'py -3 C:/Users/x/.claude/hooks/workflow/auto_formatter.py' }],
277
+ },
278
+ ],
279
+ },
280
+ };
281
+ mergeHooksIntoSettings(settings, hooksConfig, 'C:/Users/x/.claude', 'C:/Python313/python.exe');
282
+ assert.deepEqual(
283
+ settings.hooks.PostToolUse[0].hooks.map(hook => hook.command),
284
+ ['C:/Python313/python.exe C:/Users/x/.claude/hooks/workflow/auto_formatter.py'],
285
+ );
286
+ });
287
+
288
+
289
+ test('invokedAsEntryPoint is true when the module url matches the invoked script path', () => {
290
+ const scriptPath = process.platform === 'win32' ? 'C:\\pkg\\bin\\install.mjs' : '/pkg/bin/install.mjs';
291
+ assert.equal(invokedAsEntryPoint(pathToFileURL(scriptPath).href, scriptPath), true);
292
+ });
293
+
294
+
295
+ test('invokedAsEntryPoint is false when the module is imported by another script', () => {
296
+ const modulePath = process.platform === 'win32' ? 'C:\\pkg\\bin\\install.mjs' : '/pkg/bin/install.mjs';
297
+ const entryScriptPath = process.platform === 'win32' ? 'C:\\pkg\\bin\\install.test.mjs' : '/pkg/bin/install.test.mjs';
298
+ assert.equal(invokedAsEntryPoint(pathToFileURL(modulePath).href, entryScriptPath), false);
299
+ });
300
+
301
+
302
+ test('invokedAsEntryPoint is false when there is no invoked script path', () => {
303
+ assert.equal(invokedAsEntryPoint('file:///pkg/bin/install.mjs', undefined), false);
304
+ });
305
+
306
+
307
+ test('invokedAsEntryPoint is true when the module is reached through a bin symlink', () => {
308
+ const linkRoot = mkdtempSync(join(tmpdir(), 'cdev-bin-symlink-'));
309
+ try {
310
+ const realModulePath = join(linkRoot, 'install.mjs');
311
+ const symlinkLauncherPath = join(linkRoot, 'claude-dev-env');
312
+ writeFileSync(realModulePath, 'export const sentinel = true;\n');
313
+ symlinkSync(realModulePath, symlinkLauncherPath);
314
+ const realModuleUrl = pathToFileURL(realModulePath).href;
315
+ assert.equal(invokedAsEntryPoint(realModuleUrl, symlinkLauncherPath), true);
316
+ } finally {
317
+ rmSync(linkRoot, { recursive: true, force: true });
318
+ }
319
+ });
320
+
321
+
322
+ test('invokedAsEntryPoint is false when a sibling script imports the real module', () => {
323
+ const importerRoot = mkdtempSync(join(tmpdir(), 'cdev-bin-importer-'));
324
+ try {
325
+ const realModulePath = join(importerRoot, 'install.mjs');
326
+ const importerScriptPath = join(importerRoot, 'install.test.mjs');
327
+ writeFileSync(realModulePath, 'export const sentinel = true;\n');
328
+ writeFileSync(importerScriptPath, 'import "./install.mjs";\n');
329
+ const realModuleUrl = pathToFileURL(realModulePath).href;
330
+ assert.equal(invokedAsEntryPoint(realModuleUrl, importerScriptPath), false);
331
+ } finally {
332
+ rmSync(importerRoot, { recursive: true, force: true });
333
+ }
334
+ });
335
+
336
+
205
337
  const SAMPLE_HOOKS_CONFIG = {
206
338
  hooks: {
207
339
  Stop: [
@@ -23,9 +23,9 @@ Compact reference for agents. ⚡ marks rules enforced by `code_rules_enforcer.p
23
23
 
24
24
  `code_rules_enforcer.py` blocks each of these at Write/Edit and explains the specific violation when it fires; exact patterns and exemption lists live in the hook:
25
25
 
26
- no new comments · imports at top · logging format args (`log_*("...", arg)`) · no magic values in production bodies (0, 1, -1 exempt) · UPPER_SNAKE constants only in `config/` (exempt: `config/*`, `/migrations/`, workflow registries `/workflow/` + `_tab.py` + `/states.py` + `/modules.py`, test files) · no hardcoded user home paths · guarded `sys.path.insert` · no unused module-level imports · banned identifiers (`ctx`, `cfg`, `msg`, `btn`, `idx`, `cnt`, `tmp`, `elem`, `val`) · banned function prefixes (`handle_`, `process_`, `manage_`, `do_`) · no type escape hatches (`Any` import, `cast()`, inline `Any`) outside boundary files · no bare/broad `except` · no `Any` in signatures or class attributes · no stub bodies (`pass`/`...`/`raise NotImplementedError`) outside abstract/Protocol · TypedDict `_encode_*`/`_decode_*` companions in the same module · no test-mode branching in production (use dependency injection) · no thin wrapper modules · Google-style docstrings on public functions with `Args:` matching the signature · boolean names prefixed `is_`/`has_`/`should_`/`can_`/`was_`/`did_` (assignments AND bool-typed parameters) · must-check returns (`find_and_click`, `write_outcome`) assigned and checked
26
+ no new comments · imports at top · logging format args (`log_*("...", arg)`) · no magic values in production bodies (0, 1, -1 exempt) · UPPER_SNAKE constants only in `config/` (exempt: `config/*`, `/migrations/`, workflow registries `/workflow/` + `_tab.py` + `/states.py` + `/modules.py`, test files) · no hardcoded user home paths · guarded `sys.path.insert` · no unused module-level imports · banned identifiers (`ctx`, `cfg`, `msg`, `btn`, `idx`, `cnt`, `tmp`, `elem`, `val`) · banned function prefixes (`handle_`, `process_`, `manage_`, `do_`) · no type escape hatches (`Any` import, `cast()`, inline `Any`) outside boundary files · no bare/broad `except` · no `Any` in signatures or class attributes · no stub bodies (`pass`/`...`/`raise NotImplementedError`) outside abstract/Protocol · TypedDict `_encode_*`/`_decode_*` companions in the same module · no test-mode branching in production (use dependency injection) · no thin wrapper modules · Google-style docstrings on public functions with `Args:` matching the signature · boolean names prefixed `is_`/`has_`/`should_`/`can_`/`was_`/`did_` (assignments AND bool-typed parameters) · must-check returns (`find_and_click`, `write_outcome`) assigned and checked · known pytest fixture parameters in test files annotated with their single documented type (`tmp_path: Path`, `monkeypatch: pytest.MonkeyPatch`, `capsys`, `caplog`, `request`, …)
27
27
 
28
- Test files are exempt from most checks. See also the file-global constants use-count rule: [`rules/file-global-constants.md`](../rules/file-global-constants.md).
28
+ Test files are exempt from most checks. The one annotation the test-file exemption does NOT cover is a known pytest builtin fixture parameter: `tmp_path`, `monkeypatch`, `capsys`, `capfd`, `caplog`, `request`, and `tmp_path_factory` each have a single documented injected type, so the gate requires that annotation (`tmp_path: Path`) even inside a test file. Ordinary test parameters stay exempt. See also the file-global constants use-count rule: [`rules/file-global-constants.md`](../rules/file-global-constants.md).
29
29
 
30
30
  ---
31
31
 
@@ -94,4 +94,4 @@ If you already have the data, don't fetch it again.
94
94
 
95
95
  ## 11. ENFORCEMENT SURFACES
96
96
 
97
- ⚡ **Hooks** block pattern-matchable violations at Write/Edit time. 🤖 **Prompt context** carries judgment principles (SRP, Right-Sized Engineering, conservative-action, BDD discovery; the `/code` skill prepends strict mode for a session: no `Any`/`cast()`, immutable TypedDicts with `_encode_*`/`_decode_*` + `require_*` validation, per-module `_test_hooks.py` DI, 100% statement + branch coverage, zero mocks). 👥 **Audit rubrics** (`/check`, `packages/claude-dev-env/audit-rubrics/` categories A–P) cover cross-file architectural concerns. Rules with documented-but-pending hook coverage live in `~/.claude/rules/*.md` and `skills/code/SKILL.md`; each names its own promotion path.
97
+ ⚡ **Hooks** block pattern-matchable violations at Write/Edit time. 🤖 **Prompt context** carries judgment principles (SRP, Right-Sized Engineering, conservative-action, BDD discovery, docstring-prose-matches-implementation; the `/code` skill prepends strict mode for a session: no `Any`/`cast()`, immutable TypedDicts with `_encode_*`/`_decode_*` + `require_*` validation, per-module `_test_hooks.py` DI, 100% statement + branch coverage, zero mocks). 👥 **Audit rubrics** (`/check`, `packages/claude-dev-env/audit-rubrics/` categories A–P) cover cross-file architectural concerns. Rules with documented-but-pending hook coverage live in `~/.claude/rules/*.md` and `skills/code/SKILL.md`; each names its own promotion path. The docstring-prose standard (free-form enumerations match the body) lives in `packages/claude-dev-env/rules/docstring-prose-matches-implementation.md`, enforced via Category O6 audit.
@@ -13,6 +13,7 @@ if _hooks_directory not in sys.path:
13
13
 
14
14
  from code_rules_shared import ( # noqa: E402
15
15
  _collect_annotated_arguments,
16
+ _collect_fixture_injection_arguments,
16
17
  _definition_docstring_line_span,
17
18
  _function_definition_line_span,
18
19
  _scope_violations_to_changed_lines,
@@ -24,8 +25,10 @@ from code_rules_shared import ( # noqa: E402
24
25
 
25
26
  from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
26
27
  ALL_SELF_AND_CLS_PARAMETER_NAMES,
28
+ ANNOTATION_BY_PYTEST_FIXTURE,
27
29
  FUNCTION_LENGTH_BLOCKING_MESSAGE_SUFFIX,
28
30
  FUNCTION_LENGTH_BLOCKING_THRESHOLD,
31
+ KNOWN_PYTEST_FIXTURE_ANNOTATION_MESSAGE_SUFFIX,
29
32
  )
30
33
 
31
34
 
@@ -52,6 +55,156 @@ def check_parameter_annotations(content: str, file_path: str) -> list[str]:
52
55
  return issues
53
56
 
54
57
 
58
+ def _is_pytest_fixture_injection_site(
59
+ node: ast.FunctionDef | ast.AsyncFunctionDef,
60
+ ) -> bool:
61
+ """Return True when a function node is a valid pytest fixture injection site.
62
+
63
+ A function qualifies as a fixture injection site when either its name begins
64
+ with the ``test`` prefix (matching pytest's default ``python_functions = test*``
65
+ collection rule) or it carries a ``@pytest.fixture`` / ``@fixture`` decorator,
66
+ with or without call arguments. Ordinary helper functions that happen to share
67
+ a parameter name with a known pytest fixture are excluded by this predicate so
68
+ that ``check_known_pytest_fixture_annotations`` only enforces annotation
69
+ requirements on the functions where pytest actually performs fixture injection.
70
+
71
+ Args:
72
+ node: The function definition AST node to inspect.
73
+
74
+ Returns:
75
+ True when the node is a pytest test function or a fixture-decorated
76
+ function; False otherwise.
77
+ """
78
+ if node.name.startswith("test"):
79
+ return True
80
+ for each_decorator in node.decorator_list:
81
+ unwrapped = each_decorator.func if isinstance(each_decorator, ast.Call) else each_decorator
82
+ if isinstance(unwrapped, ast.Name) and unwrapped.id == "fixture":
83
+ return True
84
+ if isinstance(unwrapped, ast.Attribute) and unwrapped.attr == "fixture":
85
+ return True
86
+ return False
87
+
88
+
89
+ def _normalize_fixture_annotation_text(annotation_text: str) -> str:
90
+ """Strip forward-reference string quoting from an unparsed annotation.
91
+
92
+ ``ast.unparse`` renders a forward-reference annotation such as
93
+ ``tmp_path: "Path"`` as the quoted literal ``'Path'``. Removing the
94
+ surrounding quotes recovers the bare type name so the quoted spelling
95
+ compares equal to its unquoted form.
96
+
97
+ Args:
98
+ annotation_text: The annotation as rendered by ``ast.unparse``.
99
+
100
+ Returns:
101
+ The annotation text with any single surrounding quote pair removed.
102
+ """
103
+ if len(annotation_text) >= 2 and annotation_text[0] in {'"', "'"}:
104
+ if annotation_text[-1] == annotation_text[0]:
105
+ return annotation_text[1:-1]
106
+ return annotation_text
107
+
108
+
109
+ def _fixture_annotation_matches_expected(
110
+ actual_annotation: str, expected_annotation: str
111
+ ) -> bool:
112
+ """Return True when an annotation matches its fixture's documented type.
113
+
114
+ The match accepts every equally-correct spelling of the documented type:
115
+ the exact text, a forward-reference string form, and either the bare
116
+ attribute tail or the fully-qualified dotted form. Both ``tmp_path: Path``
117
+ and ``tmp_path: pathlib.Path`` satisfy an expected ``Path``, and both
118
+ ``monkeypatch: pytest.MonkeyPatch`` and ``monkeypatch: MonkeyPatch``
119
+ satisfy an expected ``pytest.MonkeyPatch``.
120
+
121
+ Args:
122
+ actual_annotation: The annotation as rendered by ``ast.unparse``.
123
+ expected_annotation: The fixture's single documented type spelling.
124
+
125
+ Returns:
126
+ True when the actual annotation is an accepted spelling of the
127
+ expected type; False otherwise.
128
+ """
129
+ normalized_actual = _normalize_fixture_annotation_text(actual_annotation)
130
+ if normalized_actual == expected_annotation:
131
+ return True
132
+ return normalized_actual.rsplit(".", 1)[-1] == expected_annotation.rsplit(
133
+ ".", 1
134
+ )[-1]
135
+
136
+
137
+ def check_known_pytest_fixture_annotations(content: str, file_path: str) -> list[str]:
138
+ """Flag well-known pytest fixture parameters lacking their type annotation.
139
+
140
+ The broad parameter-annotation rule exempts test files, so an ordinary
141
+ test parameter never needs a type hint. This narrower check restores
142
+ enforcement for exactly the pytest builtin fixtures whose injected type is
143
+ fixed and documented — ``tmp_path: Path``, ``monkeypatch:
144
+ pytest.MonkeyPatch``, and the rest of
145
+ ``ANNOTATION_BY_PYTEST_FIXTURE``. For these names the
146
+ correct annotation is unambiguous, so requiring it costs the author one
147
+ token and removes a recurring class of reviewer noise on test fixtures.
148
+ A non-test file produces no findings here: the broad check already covers
149
+ every parameter outside test files.
150
+
151
+ A known fixture parameter is flagged both when it carries no annotation and
152
+ when its annotation source differs from the fixture's single documented
153
+ type, so ``tmp_path: str`` is flagged exactly like ``tmp_path``. Only the
154
+ named injection slots pytest actually fills — undefaulted
155
+ positional-or-keyword and keyword-only parameters — are inspected. A
156
+ positional-only parameter is skipped because pytest passes fixtures by
157
+ keyword and can never bind one positionally; a defaulted parameter is
158
+ skipped because pytest leaves its default in place rather than injecting a
159
+ fixture; and a ``*args`` or ``**kwargs`` parameter that happens to share a
160
+ fixture name is never a fixture injection.
161
+
162
+ Args:
163
+ content: The Python source to analyze.
164
+ file_path: The path of the file being checked.
165
+
166
+ Returns:
167
+ One blocking issue per known fixture injection parameter whose
168
+ annotation is missing or differs from its single documented type,
169
+ naming the parameter and its expected type.
170
+ """
171
+ if not is_test_file(file_path):
172
+ return []
173
+ if is_workflow_registry_file(file_path) or is_migration_file(file_path):
174
+ return []
175
+ try:
176
+ tree = ast.parse(content)
177
+ except SyntaxError:
178
+ return []
179
+ issues: list[str] = []
180
+ for each_node in ast.walk(tree):
181
+ if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
182
+ continue
183
+ if not _is_pytest_fixture_injection_site(each_node):
184
+ continue
185
+ for each_arg in _collect_fixture_injection_arguments(each_node):
186
+ expected_annotation = ANNOTATION_BY_PYTEST_FIXTURE.get(
187
+ each_arg.arg
188
+ )
189
+ if expected_annotation is None:
190
+ continue
191
+ actual_annotation = (
192
+ ast.unparse(each_arg.annotation)
193
+ if each_arg.annotation is not None
194
+ else None
195
+ )
196
+ if actual_annotation is not None and _fixture_annotation_matches_expected(
197
+ actual_annotation, expected_annotation
198
+ ):
199
+ continue
200
+ issues.append(
201
+ f"Line {each_arg.lineno}: parameter {each_arg.arg!r} on "
202
+ f"{each_node.name!r} - {KNOWN_PYTEST_FIXTURE_ANNOTATION_MESSAGE_SUFFIX} "
203
+ f"(annotate as {expected_annotation!r})"
204
+ )
205
+ return issues
206
+
207
+
55
208
  def check_return_annotations(content: str, file_path: str) -> list[str]:
56
209
  if is_test_file(file_path):
57
210
  return []
@@ -0,0 +1,319 @@
1
+ """Dead dataclass-field check: a @dataclass field assigned but never read in the same file."""
2
+
3
+ import ast
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ _blocking_directory = str(Path(__file__).resolve().parent)
8
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
9
+ if _blocking_directory not in sys.path:
10
+ sys.path.insert(0, _blocking_directory)
11
+ if _hooks_directory not in sys.path:
12
+ sys.path.insert(0, _hooks_directory)
13
+
14
+ from code_rules_shared import ( # noqa: E402
15
+ is_migration_file,
16
+ is_test_file,
17
+ )
18
+
19
+ from hooks_constants.dead_dataclass_field_constants import ( # noqa: E402
20
+ ALL_DATACLASS_DECORATOR_NAMES,
21
+ ALL_WHOLE_INSTANCE_STRINGIFY_NAMES,
22
+ ATTRGETTER_FUNCTION_NAME,
23
+ CLASSVAR_ANNOTATION_NAME,
24
+ DEAD_DATACLASS_FIELD_GUIDANCE,
25
+ GETATTR_FUNCTION_NAME,
26
+ GETATTR_NAME_ARGUMENT_MINIMUM,
27
+ MAX_DEAD_DATACLASS_FIELD_ISSUES,
28
+ ALL_REFLECTIVE_FIELD_CONSUMER_NAMES,
29
+ WHOLE_INSTANCE_DICT_ATTRIBUTE_NAME,
30
+ )
31
+
32
+
33
+ def _decorator_calls_dataclass(decorator_node: ast.expr) -> bool:
34
+ """Return whether a decorator expression applies @dataclass (bare or called)."""
35
+ target_node = (
36
+ decorator_node.func if isinstance(decorator_node, ast.Call) else decorator_node
37
+ )
38
+ if isinstance(target_node, ast.Name):
39
+ return target_node.id in ALL_DATACLASS_DECORATOR_NAMES
40
+ if isinstance(target_node, ast.Attribute):
41
+ return target_node.attr in ALL_DATACLASS_DECORATOR_NAMES
42
+ return False
43
+
44
+
45
+ def _is_dataclass(class_node: ast.ClassDef) -> bool:
46
+ return any(
47
+ _decorator_calls_dataclass(each_decorator)
48
+ for each_decorator in class_node.decorator_list
49
+ )
50
+
51
+
52
+ def _annotation_is_classvar(annotation_node: ast.expr | None) -> bool:
53
+ if annotation_node is None:
54
+ return False
55
+ if isinstance(annotation_node, ast.Name):
56
+ return annotation_node.id == CLASSVAR_ANNOTATION_NAME
57
+ if isinstance(annotation_node, ast.Attribute):
58
+ return annotation_node.attr == CLASSVAR_ANNOTATION_NAME
59
+ if isinstance(annotation_node, ast.Subscript):
60
+ return _annotation_is_classvar(annotation_node.value)
61
+ return False
62
+
63
+
64
+ def _dataclass_field_definitions(class_node: ast.ClassDef) -> list[tuple[str, int]]:
65
+ """Return (field_name, line) for each instance field declared in a dataclass body."""
66
+ fields: list[tuple[str, int]] = []
67
+ for each_statement in class_node.body:
68
+ if not isinstance(each_statement, ast.AnnAssign):
69
+ continue
70
+ if not isinstance(each_statement.target, ast.Name):
71
+ continue
72
+ if _annotation_is_classvar(each_statement.annotation):
73
+ continue
74
+ fields.append((each_statement.target.id, each_statement.lineno))
75
+ return fields
76
+
77
+
78
+ def _string_constant_literal(node: ast.expr) -> str | None:
79
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
80
+ return node.value
81
+ return None
82
+
83
+
84
+ def _dynamic_access_names(tree: ast.Module) -> tuple[set[str], bool]:
85
+ """Return literal attribute-name reads and whether the check must be suppressed.
86
+
87
+ Walks every ``getattr(obj, "name")`` and ``operator.attrgetter("a", "b")``
88
+ call, contributing each literal string name as a read attribute name —
89
+ ``getattr`` names its attribute in the second positional argument while
90
+ ``attrgetter`` names one attribute per positional argument. A non-literal
91
+ name argument, or any reflective whole-instance consumer (``asdict``,
92
+ ``astuple``, ``fields``, ``replace``, ``vars``) that reads every field at
93
+ once, means a field cannot be proven unread, so the boolean signals the
94
+ caller to suppress the check for the whole file.
95
+ """
96
+ literal_names: set[str] = set()
97
+ should_suppress_check = False
98
+ for each_node in ast.walk(tree):
99
+ if not isinstance(each_node, ast.Call):
100
+ continue
101
+ function_node = each_node.func
102
+ function_name = None
103
+ if isinstance(function_node, ast.Name):
104
+ function_name = function_node.id
105
+ elif isinstance(function_node, ast.Attribute):
106
+ function_name = function_node.attr
107
+ if function_name in ALL_REFLECTIVE_FIELD_CONSUMER_NAMES:
108
+ should_suppress_check = True
109
+ continue
110
+ if function_name not in {GETATTR_FUNCTION_NAME, ATTRGETTER_FUNCTION_NAME}:
111
+ continue
112
+ string_arguments = [
113
+ argument
114
+ for argument in each_node.args
115
+ if not isinstance(argument, ast.Starred)
116
+ ]
117
+ if function_name == GETATTR_FUNCTION_NAME:
118
+ name_arguments = (
119
+ [string_arguments[1]]
120
+ if len(string_arguments) >= GETATTR_NAME_ARGUMENT_MINIMUM
121
+ else []
122
+ )
123
+ else:
124
+ name_arguments = string_arguments
125
+ if not name_arguments:
126
+ should_suppress_check = True
127
+ continue
128
+ for each_name_argument in name_arguments:
129
+ literal_name = _string_constant_literal(each_name_argument)
130
+ if literal_name is None:
131
+ should_suppress_check = True
132
+ else:
133
+ literal_names.add(literal_name)
134
+ return literal_names, should_suppress_check
135
+
136
+
137
+ def _augmented_assignment_attribute_names(tree: ast.Module) -> set[str]:
138
+ """Return attribute names that an augmented assignment reads before writing.
139
+
140
+ ``obj.field += value`` parses to an ``ast.Attribute`` target in Store
141
+ context, yet ``+=`` reads the current attribute value before storing the
142
+ result, so the target attribute counts as a read.
143
+ """
144
+ augmented_read_names: set[str] = set()
145
+ for each_node in ast.walk(tree):
146
+ if not isinstance(each_node, ast.AugAssign):
147
+ continue
148
+ if isinstance(each_node.target, ast.Attribute):
149
+ augmented_read_names.add(each_node.target.attr)
150
+ return augmented_read_names
151
+
152
+
153
+ def _attribute_read_names(tree: ast.Module) -> tuple[set[str], bool]:
154
+ """Return literal attribute-name reads and whether the check must be suppressed.
155
+
156
+ Walks every attribute read (Load context) and every augmented-assignment
157
+ target in the module, contributing each attribute name as a read name —
158
+ ``obj.field += value`` reads ``field`` before writing it. A read of
159
+ ``__dict__`` consumes every field of an instance at once, so it cannot prove
160
+ any single field unread and the boolean signals the caller to suppress the
161
+ check for the whole file.
162
+ """
163
+ read_names: set[str] = _augmented_assignment_attribute_names(tree)
164
+ should_suppress_check = False
165
+ for each_node in ast.walk(tree):
166
+ if not isinstance(each_node, ast.Attribute) or not isinstance(
167
+ each_node.ctx, ast.Load
168
+ ):
169
+ continue
170
+ if each_node.attr == WHOLE_INSTANCE_DICT_ATTRIBUTE_NAME:
171
+ should_suppress_check = True
172
+ continue
173
+ read_names.add(each_node.attr)
174
+ return read_names, should_suppress_check
175
+
176
+
177
+ def _match_pattern_attribute_names(tree: ast.Module) -> set[str]:
178
+ """Return field names that a class pattern reads in a ``match`` statement.
179
+
180
+ A class pattern ``case Row(url=found):`` reads the ``url`` field through
181
+ ``ast.MatchClass.kwd_attrs``, so each keyword-pattern attribute name counts
182
+ as a read name even though it never appears as an attribute access.
183
+ """
184
+ matched_names: set[str] = set()
185
+ for each_node in ast.walk(tree):
186
+ if isinstance(each_node, ast.MatchClass):
187
+ matched_names.update(each_node.kwd_attrs)
188
+ return matched_names
189
+
190
+
191
+ def _exported_names(tree: ast.Module) -> set[str]:
192
+ """Return names listed in a module-level ``__all__`` literal."""
193
+ exported: set[str] = set()
194
+ for each_node in tree.body:
195
+ if not isinstance(each_node, ast.Assign):
196
+ continue
197
+ targets_all = any(
198
+ isinstance(each_target, ast.Name) and each_target.id == "__all__"
199
+ for each_target in each_node.targets
200
+ )
201
+ if not targets_all:
202
+ continue
203
+ if isinstance(each_node.value, (ast.List, ast.Tuple, ast.Set)):
204
+ for each_element in each_node.value.elts:
205
+ literal_name = _string_constant_literal(each_element)
206
+ if literal_name is not None:
207
+ exported.add(literal_name)
208
+ return exported
209
+
210
+
211
+ def _constructed_class_names(tree: ast.Module) -> set[str]:
212
+ """Return names of classes instantiated by a direct call anywhere in the module."""
213
+ constructed: set[str] = set()
214
+ for each_node in ast.walk(tree):
215
+ if isinstance(each_node, ast.Call) and isinstance(each_node.func, ast.Name):
216
+ constructed.add(each_node.func.id)
217
+ return constructed
218
+
219
+
220
+ def _is_whole_instance_stringify_call(node: ast.AST) -> bool:
221
+ """Return whether a call stringifies a whole instance via ``str``/``repr``/``format``."""
222
+ if not isinstance(node, ast.Call):
223
+ return False
224
+ function_node = node.func
225
+ if isinstance(function_node, ast.Name):
226
+ return function_node.id in ALL_WHOLE_INSTANCE_STRINGIFY_NAMES
227
+ if isinstance(function_node, ast.Attribute):
228
+ return function_node.attr in ALL_WHOLE_INSTANCE_STRINGIFY_NAMES
229
+ return False
230
+
231
+
232
+ def _uses_dataclass_dunder_field_reads(tree: ast.Module) -> bool:
233
+ """Return whether the file relies on auto-generated dataclass dunders to read fields.
234
+
235
+ ``@dataclass`` synthesizes ``__eq__`` (and ``__lt__``/``__hash__`` under
236
+ ``order``/``frozen``) plus the always-present ``__repr__``, each of which
237
+ reads every field without naming it as an attribute access. Comparing two
238
+ instances, placing instances in a set or dict, formatted-string conversion,
239
+ or stringifying a whole instance therefore reads fields the static scan
240
+ cannot otherwise observe, so the check is suppressed for the whole file.
241
+ """
242
+ for each_node in ast.walk(tree):
243
+ if isinstance(each_node, ast.Compare):
244
+ return True
245
+ if isinstance(each_node, (ast.Set, ast.SetComp, ast.Dict, ast.DictComp)):
246
+ return True
247
+ if isinstance(each_node, ast.FormattedValue):
248
+ return True
249
+ if _is_whole_instance_stringify_call(each_node):
250
+ return True
251
+ return False
252
+
253
+
254
+ def check_dead_dataclass_fields(
255
+ content: str, file_path: str, full_file_content: str | None = None
256
+ ) -> list[str]:
257
+ """Flag a @dataclass field that the same file constructs but never reads.
258
+
259
+ A field is dead when its dataclass is instantiated somewhere in the file
260
+ (so the class is live), the field name never appears as an attribute read,
261
+ an augmented-assignment target, a class-pattern keyword, or a literal
262
+ ``getattr``/``attrgetter`` access anywhere in the file, and the file contains
263
+ no non-literal dynamic access, reflective whole-instance consumer
264
+ (``asdict``, ``astuple``, ``fields``, ``replace``, ``vars``), ``__dict__``
265
+ read, or auto-generated dataclass dunder field read (comparison, set/dict
266
+ membership, or whole-instance stringification) that could read it
267
+ indirectly. Whole-file analysis runs against ``full_file_content`` when
268
+ supplied so an Edit fragment is judged against the reconstructed post-edit
269
+ file.
270
+
271
+ Args:
272
+ content: The new content under validation (Edit fragment or whole file).
273
+ file_path: The destination path, used for the test/registry exemptions.
274
+ full_file_content: The reconstructed post-edit whole-file content for an
275
+ Edit, or None for a Write where ``content`` is already the whole file.
276
+
277
+ Returns:
278
+ One violation message per dead dataclass field, capped at the configured
279
+ maximum.
280
+ """
281
+ if is_test_file(file_path):
282
+ return []
283
+ if is_migration_file(file_path):
284
+ return []
285
+ effective_content = content if full_file_content is None else full_file_content
286
+ try:
287
+ tree = ast.parse(effective_content)
288
+ except SyntaxError:
289
+ return []
290
+ if _uses_dataclass_dunder_field_reads(tree):
291
+ return []
292
+ dynamic_literal_names, dynamic_access_suppresses_check = _dynamic_access_names(tree)
293
+ attribute_read_names, instance_dict_suppresses_check = _attribute_read_names(tree)
294
+ if dynamic_access_suppresses_check or instance_dict_suppresses_check:
295
+ return []
296
+ read_names = (
297
+ attribute_read_names
298
+ | dynamic_literal_names
299
+ | _match_pattern_attribute_names(tree)
300
+ | _exported_names(tree)
301
+ )
302
+ constructed_class_names = _constructed_class_names(tree)
303
+ issues: list[str] = []
304
+ for each_node in ast.walk(tree):
305
+ if not isinstance(each_node, ast.ClassDef) or not _is_dataclass(each_node):
306
+ continue
307
+ if each_node.name not in constructed_class_names:
308
+ continue
309
+ for each_field_definition in _dataclass_field_definitions(each_node):
310
+ field_name, field_line = each_field_definition
311
+ if field_name in read_names:
312
+ continue
313
+ issues.append(
314
+ f"Line {field_line}: dataclass field {field_name!r} on {each_node.name}"
315
+ f" - {DEAD_DATACLASS_FIELD_GUIDANCE}"
316
+ )
317
+ if len(issues) >= MAX_DEAD_DATACLASS_FIELD_ISSUES:
318
+ return issues
319
+ return issues