claude-dev-env 1.94.0 → 1.95.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 (57) hide show
  1. package/_shared/advisor/CLAUDE.md +2 -2
  2. package/_shared/advisor/advisor-protocol.md +35 -27
  3. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +3 -2
  4. package/_shared/advisor/scripts/model_tier_run_validator.py +23 -15
  5. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +81 -17
  6. package/bin/CLAUDE.md +10 -1
  7. package/bin/ever-shipped-skills.mjs +70 -0
  8. package/bin/install.mjs +136 -6
  9. package/bin/install.prune.test.mjs +457 -0
  10. package/docs/CODE_RULES.md +1 -1
  11. package/hooks/blocking/code_rules_enforcer.py +4 -0
  12. package/hooks/blocking/code_rules_shared.py +82 -0
  13. package/hooks/blocking/code_rules_test_layout.py +9 -3
  14. package/hooks/blocking/plain_language_blocker.py +138 -4
  15. package/hooks/blocking/sensitive_file_protector.py +114 -48
  16. package/hooks/blocking/tdd_enforcer.py +9 -2
  17. package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +105 -0
  18. package/hooks/blocking/test_code_rules_shared.py +181 -0
  19. package/hooks/blocking/test_plain_language_blocker_allowlist.py +184 -0
  20. package/hooks/blocking/test_sensitive_file_protector.py +185 -0
  21. package/hooks/blocking/test_tdd_enforcer_scratchpad.py +105 -0
  22. package/hooks/hooks_constants/CLAUDE.md +2 -0
  23. package/hooks/hooks_constants/harness_scratchpad_constants.py +17 -0
  24. package/hooks/hooks_constants/plain_language_blocker_constants.py +5 -0
  25. package/hooks/hooks_constants/sensitive_file_protector_constants.py +42 -0
  26. package/hooks/pyproject.toml +75 -4
  27. package/hooks/validators/CLAUDE.md +1 -1
  28. package/hooks/validators/README.md +2 -0
  29. package/hooks/validators/python_style_checks.py +114 -136
  30. package/hooks/validators/python_style_helpers.py +95 -0
  31. package/hooks/validators/test_python_style_checks.py +0 -164
  32. package/hooks/validators/test_python_style_checks_decorator_gap.py +119 -0
  33. package/hooks/validators/test_python_style_fixes.py +251 -0
  34. package/hooks/validators/test_python_style_helpers.py +125 -0
  35. package/package.json +1 -1
  36. package/rules/CLAUDE.md +1 -0
  37. package/rules/anti-corollary-tests.md +69 -0
  38. package/rules/bdd.md +1 -3
  39. package/rules/code-reviews.md +1 -1
  40. package/rules/gh-paginate.md +1 -1
  41. package/rules/plain-language.md +2 -0
  42. package/skills/CLAUDE.md +4 -3
  43. package/skills/autoconverge/workflow/converge.mjs +2 -2
  44. package/skills/bugteam/reference/README.md +2 -3
  45. package/skills/closeout/SKILL.md +153 -0
  46. package/skills/closeout/reference/handoff-prompt-template.md +72 -0
  47. package/skills/closeout/reference/issue-body-templates.md +108 -0
  48. package/skills/closeout/reference/pii-redaction-checklist.md +36 -0
  49. package/skills/orchestrator/SKILL.md +27 -21
  50. package/skills/orchestrator-refresh/SKILL.md +12 -8
  51. package/skills/pr-converge/CLAUDE.md +1 -1
  52. package/skills/pr-fix-protocol/SKILL.md +65 -0
  53. package/skills/skill-builder/references/skill-modularity.md +1 -1
  54. package/skills/team-advisor/SKILL.md +15 -11
  55. package/system-prompts/software-engineer.xml +7 -6
  56. package/hooks/validators/test_verify_paths.py +0 -32
  57. package/hooks/validators/verify_paths.py +0 -57
@@ -0,0 +1,95 @@
1
+ """Shared source-line and function-discovery helpers for the style checks.
2
+
3
+ These pure helpers underlie the style checks and the blank-line fixers:
4
+ splitting source into ast-aligned lines, locating function definitions, and
5
+ matching the source newline convention.
6
+ """
7
+
8
+ import ast
9
+ from collections.abc import Iterator
10
+
11
+ FunctionNode = ast.FunctionDef | ast.AsyncFunctionDef
12
+
13
+
14
+ def iter_function_definitions(tree: ast.AST) -> Iterator[FunctionNode]:
15
+ """Yield every function and async-function definition in the tree."""
16
+ for each_node in ast.walk(tree):
17
+ if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
18
+ yield each_node
19
+
20
+
21
+ def top_level_functions(source: str) -> list[FunctionNode]:
22
+ """Return the module's top-level function definitions, ordered by line."""
23
+ try:
24
+ tree = ast.parse(source)
25
+ except SyntaxError:
26
+ return []
27
+ if not isinstance(tree, ast.Module):
28
+ return []
29
+ functions: list[FunctionNode] = [
30
+ node for node in tree.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
31
+ ]
32
+ functions.sort(key=lambda function_node: function_node.lineno)
33
+ return functions
34
+
35
+
36
+ def function_start_line(function_node: FunctionNode) -> int:
37
+ """Return the first source line of a function, counting its decorators."""
38
+ if not function_node.decorator_list:
39
+ return function_node.lineno
40
+ return min(each_decorator.lineno for each_decorator in function_node.decorator_list)
41
+
42
+
43
+ def gap_is_blank_only(all_gap_lines: list[str]) -> bool:
44
+ """Return True when every line between two functions is blank."""
45
+ return all(each_line.strip() == "" for each_line in all_gap_lines)
46
+
47
+
48
+ def blank_line_for_source(source: str) -> str:
49
+ """Return the blank-line string matching the source newline convention.
50
+
51
+ Path.read_text() normalizes disk newlines to \\n before this runs, so the
52
+ CRLF branch serves an in-memory caller that builds a CRLF string directly.
53
+ """
54
+ if "\r\n" in source:
55
+ return "\r\n"
56
+ return "\n"
57
+
58
+
59
+ def _advance_past_newline(source: str, scan_index: int) -> int | None:
60
+ """Return the index past a CR, LF, or CRLF at scan_index, or None.
61
+
62
+ None marks a character that is not a line ending.
63
+ """
64
+ character = source[scan_index]
65
+ if character == "\r":
66
+ scan_index += 1
67
+ if scan_index < len(source) and source[scan_index] == "\n":
68
+ scan_index += 1
69
+ return scan_index
70
+ if character == "\n":
71
+ return scan_index + 1
72
+ return None
73
+
74
+
75
+ def real_newline_lines(source: str) -> list[str]:
76
+ """Split source on CR, LF, and CRLF only, keeping each line ending.
77
+
78
+ Line indices stay aligned with ast line numbers because the split
79
+ ignores form feed and other control characters ast does not count.
80
+ """
81
+ lines: list[str] = []
82
+ line_start = 0
83
+ scan_index = 0
84
+ total_length = len(source)
85
+ while scan_index < total_length:
86
+ line_end = _advance_past_newline(source, scan_index)
87
+ if line_end is None:
88
+ scan_index += 1
89
+ continue
90
+ scan_index = line_end
91
+ lines.append(source[line_start:scan_index])
92
+ line_start = scan_index
93
+ if line_start < total_length:
94
+ lines.append(source[line_start:])
95
+ return lines
@@ -1,9 +1,6 @@
1
1
  """Tests for Python style checks."""
2
2
 
3
3
  import ast
4
- import os
5
- import subprocess
6
- import sys
7
4
  from pathlib import Path
8
5
 
9
6
  import pytest
@@ -14,8 +11,6 @@ from .python_style_checks import (
14
11
  check_imports_at_top,
15
12
  check_no_empty_line_after_decorators,
16
13
  check_view_function_naming,
17
- fix_file,
18
- fix_function_spacing,
19
14
  validate_file,
20
15
  )
21
16
 
@@ -138,14 +133,6 @@ def bar() -> None:
138
133
  pass
139
134
  '''
140
135
 
141
- ONE_BLANK_BEFORE_DECORATED = '''def foo() -> None:
142
- pass
143
-
144
- @decorator
145
- def bar() -> None:
146
- pass
147
- '''
148
-
149
136
  COMMENT_BETWEEN_FUNCTIONS = '''def foo() -> None:
150
137
  pass
151
138
 
@@ -178,8 +165,6 @@ def bar() -> None:
178
165
  pass
179
166
  '''
180
167
 
181
- FORM_FEED_BETWEEN_FUNCTIONS = "def foo():\n pass\n\x0c\n\ndef bar():\n pass\n"
182
-
183
168
 
184
169
  class TestImportsAtTop:
185
170
  """Test import positioning validation."""
@@ -401,152 +386,3 @@ class TestViolationClass:
401
386
  """Violation should format as file:line: message."""
402
387
  violation = Violation("test.py", 42, "Test message")
403
388
  assert str(violation) == "test.py:42: Test message"
404
-
405
-
406
- class TestAutoFix:
407
- """Test auto-fix capabilities."""
408
-
409
- def test_fix_empty_line_after_decorator(
410
- self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
411
- ) -> None:
412
- """Auto-fix should remove blank line between decorator and function."""
413
- monkeypatch.setenv("HOME", str(tmp_path))
414
- monkeypatch.setenv("TMPDIR", str(tmp_path))
415
- code = '''@decorator
416
-
417
- def foo():
418
- pass
419
- '''
420
- expected = '''@decorator
421
- def foo():
422
- pass
423
- '''
424
- temp_path = tmp_path / "decorator_module.py"
425
- temp_path.write_text(code, encoding="utf-8")
426
- fixed = fix_file(temp_path)
427
- assert fixed is True
428
- result_text = temp_path.read_text()
429
- assert result_text.strip() == expected.strip()
430
-
431
- def test_fix_collapses_three_blank_lines_to_two(
432
- self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
433
- ) -> None:
434
- """Auto-fix should collapse three or more blank lines down to two."""
435
- monkeypatch.setenv("HOME", str(tmp_path))
436
- monkeypatch.setenv("TMPDIR", str(tmp_path))
437
- code = '''def foo():
438
- pass
439
-
440
-
441
-
442
- def bar():
443
- pass
444
- '''
445
- expected = '''def foo():
446
- pass
447
-
448
-
449
- def bar():
450
- pass
451
- '''
452
- temp_path = tmp_path / "spacing_module.py"
453
- temp_path.write_text(code, encoding="utf-8")
454
- fixed = fix_file(temp_path)
455
- assert fixed is True
456
- result_text = temp_path.read_text()
457
- assert result_text.strip() == expected.strip()
458
-
459
- def test_fix_inserts_missing_blank_lines(
460
- self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
461
- ) -> None:
462
- """Auto-fix should insert blank lines for under-spaced functions."""
463
- monkeypatch.setenv("HOME", str(tmp_path))
464
- monkeypatch.setenv("TMPDIR", str(tmp_path))
465
- temp_path = tmp_path / "underspaced_module.py"
466
- temp_path.write_text(BAD_ONE_LINE_BETWEEN_FUNCTIONS, encoding="utf-8")
467
- fixed = fix_file(temp_path)
468
- assert fixed is True
469
- assert temp_path.read_text() == GOOD_TWO_LINES_BETWEEN_FUNCTIONS
470
-
471
- def test_no_fix_needed_returns_false(
472
- self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
473
- ) -> None:
474
- """Auto-fix should return False when the file already uses two blank lines."""
475
- monkeypatch.setenv("HOME", str(tmp_path))
476
- monkeypatch.setenv("TMPDIR", str(tmp_path))
477
- code = '''def foo():
478
- pass
479
-
480
-
481
- def bar():
482
- pass
483
- '''
484
- temp_path = tmp_path / "clean_module.py"
485
- temp_path.write_text(code, encoding="utf-8")
486
- fixed = fix_file(temp_path)
487
- assert fixed is False
488
-
489
- def test_form_feed_gap_stays_check_clean_after_fix(
490
- self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
491
- ) -> None:
492
- """A form feed in a blank gap converges without corrupting the file."""
493
- monkeypatch.setenv("HOME", str(tmp_path))
494
- monkeypatch.setenv("TMPDIR", str(tmp_path))
495
- assert (
496
- check_blank_lines_between_functions(FORM_FEED_BETWEEN_FUNCTIONS, "test.py")
497
- == []
498
- )
499
- temp_path = tmp_path / "form_feed_module.py"
500
- temp_path.write_text(FORM_FEED_BETWEEN_FUNCTIONS, encoding="utf-8")
501
- fix_file(temp_path)
502
- fixed_text = temp_path.read_text()
503
- assert check_blank_lines_between_functions(fixed_text, "test.py") == []
504
- assert fix_file(temp_path) is False
505
-
506
-
507
- class TestFixFunctionSpacing:
508
- """Test blank-line normalization between top-level functions."""
509
-
510
- def test_inserts_two_blank_lines_when_adjacent(self) -> None:
511
- """Adjacent functions gain exactly two blank lines."""
512
- fixed_source = fix_function_spacing(BAD_NO_LINE_BETWEEN_FUNCTIONS)
513
- assert fixed_source == GOOD_TWO_LINES_BETWEEN_FUNCTIONS
514
-
515
- def test_inserts_second_blank_line_when_one(self) -> None:
516
- """A single blank line grows to exactly two."""
517
- fixed_source = fix_function_spacing(BAD_ONE_LINE_BETWEEN_FUNCTIONS)
518
- assert fixed_source == GOOD_TWO_LINES_BETWEEN_FUNCTIONS
519
-
520
- def test_collapses_three_blank_lines_to_two(self) -> None:
521
- """Three blank lines collapse to exactly two."""
522
- fixed_source = fix_function_spacing(BAD_THREE_LINES_BETWEEN_FUNCTIONS)
523
- assert fixed_source == GOOD_TWO_LINES_BETWEEN_FUNCTIONS
524
-
525
- def test_normalizes_before_decorated_function(self) -> None:
526
- """Blank lines before a decorated function normalize to two."""
527
- fixed_source = fix_function_spacing(ONE_BLANK_BEFORE_DECORATED)
528
- assert fixed_source == DECORATED_NEXT_TWO_LINES
529
-
530
-
531
- class TestDirectInvocation:
532
- """Test running the checker as a standalone script."""
533
-
534
- def test_direct_invocation_resolves_hooks_constants(
535
- self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
536
- ) -> None:
537
- """Direct invocation bootstraps hooks_constants without PYTHONPATH."""
538
- monkeypatch.setenv("HOME", str(tmp_path))
539
- monkeypatch.setenv("TMPDIR", str(tmp_path))
540
- script_path = Path(__file__).resolve().parent / "python_style_checks.py"
541
- target_path = tmp_path / "clean_sample.py"
542
- target_path.write_text(GOOD_IMPORTS, encoding="utf-8")
543
- scrubbed_environment = dict(os.environ)
544
- scrubbed_environment.pop("PYTHONPATH", None)
545
- completed_process = subprocess.run(
546
- [sys.executable, "-S", str(script_path), str(target_path)],
547
- capture_output=True,
548
- text=True,
549
- env=scrubbed_environment,
550
- )
551
- assert "ModuleNotFoundError" not in completed_process.stderr
552
- assert completed_process.returncode == 0
@@ -0,0 +1,119 @@
1
+ """Tests for multi-line decorator gap detection and repair.
2
+
3
+ These cover the case where a decorator spans several source lines: the
4
+ validator measures the gap from the decorator's last line to the def, and
5
+ the fixer removes any blank line that falls in that gap.
6
+ """
7
+
8
+ import ast
9
+
10
+ from .python_style_checks import (
11
+ check_no_empty_line_after_decorators,
12
+ fix_empty_lines_after_decorators,
13
+ )
14
+
15
+ MULTILINE_DECORATOR_NO_BLANK = """@parametrize(
16
+ "value",
17
+ [1, 2, 3],
18
+ )
19
+ def check_values() -> None:
20
+ pass
21
+ """
22
+
23
+ MULTILINE_DECORATOR_WITH_BLANK = """@parametrize(
24
+ "value",
25
+ [1, 2, 3],
26
+ )
27
+
28
+ def check_values() -> None:
29
+ pass
30
+ """
31
+
32
+ SINGLE_DECORATOR_NO_BLANK = """@decorator
33
+ def foo() -> None:
34
+ pass
35
+ """
36
+
37
+ SINGLE_DECORATOR_WITH_BLANK = """@decorator
38
+
39
+ def foo() -> None:
40
+ pass
41
+ """
42
+
43
+ STACKED_DECORATORS_WITH_INNER_BLANK = """@first
44
+
45
+ @second
46
+ def foo() -> None:
47
+ pass
48
+ """
49
+
50
+ STACKED_DECORATORS_NO_BLANK = """@first
51
+ @second
52
+ def foo() -> None:
53
+ pass
54
+ """
55
+
56
+
57
+ class TestMultilineDecoratorValidator:
58
+ """Validate gap detection for decorators spanning several lines."""
59
+
60
+ def test_multiline_decorator_no_blank_valid(self) -> None:
61
+ """A multi-line decorator directly above its def passes."""
62
+ violations = check_no_empty_line_after_decorators(MULTILINE_DECORATOR_NO_BLANK, "test.py")
63
+ assert violations == []
64
+
65
+ def test_multiline_decorator_with_blank_fails(self) -> None:
66
+ """A blank line after a multi-line decorator is flagged."""
67
+ violations = check_no_empty_line_after_decorators(MULTILINE_DECORATOR_WITH_BLANK, "test.py")
68
+ assert len(violations) == 1
69
+ assert "decorator" in violations[0].message.lower()
70
+
71
+ def test_single_decorator_no_blank_valid(self) -> None:
72
+ """A single-line decorator directly above its def passes."""
73
+ violations = check_no_empty_line_after_decorators(SINGLE_DECORATOR_NO_BLANK, "test.py")
74
+ assert violations == []
75
+
76
+ def test_single_decorator_with_blank_fails(self) -> None:
77
+ """A blank line after a single-line decorator is flagged."""
78
+ violations = check_no_empty_line_after_decorators(SINGLE_DECORATOR_WITH_BLANK, "test.py")
79
+ assert len(violations) == 1
80
+ assert violations[0].line == 1
81
+ assert "decorator" in violations[0].message.lower()
82
+
83
+
84
+ class TestMultilineDecoratorFixer:
85
+ """Validate blank-line removal around decorators spanning several lines."""
86
+
87
+ def test_fixer_removes_multiline_decorator_gap(self) -> None:
88
+ """The fixer removes the blank between a multi-line decorator and its def."""
89
+ fixed_source = fix_empty_lines_after_decorators(MULTILINE_DECORATOR_WITH_BLANK)
90
+ ast.parse(fixed_source)
91
+ assert check_no_empty_line_after_decorators(fixed_source, "test.py") == []
92
+ assert fixed_source == MULTILINE_DECORATOR_NO_BLANK
93
+
94
+ def test_fixer_removes_single_decorator_gap(self) -> None:
95
+ """The fixer removes the blank after a single-line decorator."""
96
+ fixed_source = fix_empty_lines_after_decorators(SINGLE_DECORATOR_WITH_BLANK)
97
+ assert fixed_source == SINGLE_DECORATOR_NO_BLANK
98
+
99
+ def test_fixer_removes_blank_between_stacked_decorators(self) -> None:
100
+ """The fixer removes a blank line separating stacked decorators."""
101
+ fixed_source = fix_empty_lines_after_decorators(STACKED_DECORATORS_WITH_INNER_BLANK)
102
+ ast.parse(fixed_source)
103
+ assert fixed_source == STACKED_DECORATORS_NO_BLANK
104
+
105
+ def test_fixer_leaves_clean_multiline_decorator_untouched(self) -> None:
106
+ """A multi-line decorator with no gap survives the fixer unchanged."""
107
+ fixed_source = fix_empty_lines_after_decorators(MULTILINE_DECORATOR_NO_BLANK)
108
+ assert fixed_source == MULTILINE_DECORATOR_NO_BLANK
109
+
110
+ def test_fixer_is_idempotent(self) -> None:
111
+ """Applying the fixer twice matches applying it once."""
112
+ once = fix_empty_lines_after_decorators(MULTILINE_DECORATOR_WITH_BLANK)
113
+ twice = fix_empty_lines_after_decorators(once)
114
+ assert twice == once
115
+
116
+ def test_fixer_returns_source_unchanged_on_syntax_error(self) -> None:
117
+ """Unparseable source is returned unchanged rather than corrupted."""
118
+ broken_source = "@decorator\n\ndef foo(\n"
119
+ assert fix_empty_lines_after_decorators(broken_source) == broken_source
@@ -0,0 +1,251 @@
1
+ """Tests for the blank-line auto-fixers and standalone invocation."""
2
+
3
+ import ast
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ from .python_style_checks import (
12
+ check_blank_lines_between_functions,
13
+ fix_file,
14
+ fix_function_spacing,
15
+ )
16
+
17
+ GOOD_IMPORTS = """import os
18
+ import sys
19
+
20
+ def foo() -> None:
21
+ pass
22
+ """
23
+
24
+ GOOD_TWO_LINES_BETWEEN_FUNCTIONS = """def foo() -> None:
25
+ pass
26
+
27
+
28
+ def bar() -> None:
29
+ pass
30
+ """
31
+
32
+ BAD_NO_LINE_BETWEEN_FUNCTIONS = """def foo() -> None:
33
+ pass
34
+ def bar() -> None:
35
+ pass
36
+ """
37
+
38
+ BAD_ONE_LINE_BETWEEN_FUNCTIONS = """def foo() -> None:
39
+ pass
40
+
41
+ def bar() -> None:
42
+ pass
43
+ """
44
+
45
+ BAD_THREE_LINES_BETWEEN_FUNCTIONS = """def foo() -> None:
46
+ pass
47
+
48
+
49
+
50
+ def bar() -> None:
51
+ pass
52
+ """
53
+
54
+ DECORATED_NEXT_TWO_LINES = """def foo() -> None:
55
+ pass
56
+
57
+
58
+ @decorator
59
+ def bar() -> None:
60
+ pass
61
+ """
62
+
63
+ ONE_BLANK_BEFORE_DECORATED = """def foo() -> None:
64
+ pass
65
+
66
+ @decorator
67
+ def bar() -> None:
68
+ pass
69
+ """
70
+
71
+ FORM_FEED_BETWEEN_FUNCTIONS = "def foo():\n pass\n\x0c\n\ndef bar():\n pass\n"
72
+
73
+
74
+ class TestAutoFix:
75
+ """Test auto-fix capabilities."""
76
+
77
+ def test_fix_empty_line_after_decorator(
78
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
79
+ ) -> None:
80
+ """Auto-fix should remove blank line between decorator and function."""
81
+ monkeypatch.setenv("HOME", str(tmp_path))
82
+ monkeypatch.setenv("TMPDIR", str(tmp_path))
83
+ code = """@decorator
84
+
85
+ def foo():
86
+ pass
87
+ """
88
+ expected = """@decorator
89
+ def foo():
90
+ pass
91
+ """
92
+ temp_path = tmp_path / "decorator_module.py"
93
+ temp_path.write_text(code, encoding="utf-8")
94
+ fixed = fix_file(temp_path)
95
+ assert fixed is True
96
+ result_text = temp_path.read_text()
97
+ assert result_text.strip() == expected.strip()
98
+
99
+ def test_fix_collapses_three_blank_lines_to_two(
100
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
101
+ ) -> None:
102
+ """Auto-fix should collapse three or more blank lines down to two."""
103
+ monkeypatch.setenv("HOME", str(tmp_path))
104
+ monkeypatch.setenv("TMPDIR", str(tmp_path))
105
+ code = """def foo():
106
+ pass
107
+
108
+
109
+
110
+ def bar():
111
+ pass
112
+ """
113
+ expected = """def foo():
114
+ pass
115
+
116
+
117
+ def bar():
118
+ pass
119
+ """
120
+ temp_path = tmp_path / "spacing_module.py"
121
+ temp_path.write_text(code, encoding="utf-8")
122
+ fixed = fix_file(temp_path)
123
+ assert fixed is True
124
+ result_text = temp_path.read_text()
125
+ assert result_text.strip() == expected.strip()
126
+
127
+ def test_fix_inserts_missing_blank_lines(
128
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
129
+ ) -> None:
130
+ """Auto-fix should insert blank lines for under-spaced functions."""
131
+ monkeypatch.setenv("HOME", str(tmp_path))
132
+ monkeypatch.setenv("TMPDIR", str(tmp_path))
133
+ temp_path = tmp_path / "underspaced_module.py"
134
+ temp_path.write_text(BAD_ONE_LINE_BETWEEN_FUNCTIONS, encoding="utf-8")
135
+ fixed = fix_file(temp_path)
136
+ assert fixed is True
137
+ assert temp_path.read_text() == GOOD_TWO_LINES_BETWEEN_FUNCTIONS
138
+
139
+ def test_no_fix_needed_returns_false(
140
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
141
+ ) -> None:
142
+ """Auto-fix should return False when the file already uses two blank lines."""
143
+ monkeypatch.setenv("HOME", str(tmp_path))
144
+ monkeypatch.setenv("TMPDIR", str(tmp_path))
145
+ code = """def foo():
146
+ pass
147
+
148
+
149
+ def bar():
150
+ pass
151
+ """
152
+ temp_path = tmp_path / "clean_module.py"
153
+ temp_path.write_text(code, encoding="utf-8")
154
+ fixed = fix_file(temp_path)
155
+ assert fixed is False
156
+
157
+ def test_form_feed_gap_stays_check_clean_after_fix(
158
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
159
+ ) -> None:
160
+ """A form feed in a blank gap converges without corrupting the file."""
161
+ monkeypatch.setenv("HOME", str(tmp_path))
162
+ monkeypatch.setenv("TMPDIR", str(tmp_path))
163
+ assert check_blank_lines_between_functions(FORM_FEED_BETWEEN_FUNCTIONS, "test.py") == []
164
+ temp_path = tmp_path / "form_feed_module.py"
165
+ temp_path.write_text(FORM_FEED_BETWEEN_FUNCTIONS, encoding="utf-8")
166
+ fix_file(temp_path)
167
+ fixed_text = temp_path.read_text()
168
+ assert check_blank_lines_between_functions(fixed_text, "test.py") == []
169
+ assert fix_file(temp_path) is False
170
+
171
+
172
+ class TestFixFunctionSpacing:
173
+ """Test blank-line normalization between top-level functions."""
174
+
175
+ def test_inserts_two_blank_lines_when_adjacent(self) -> None:
176
+ """Adjacent functions gain exactly two blank lines."""
177
+ fixed_source = fix_function_spacing(BAD_NO_LINE_BETWEEN_FUNCTIONS)
178
+ assert fixed_source == GOOD_TWO_LINES_BETWEEN_FUNCTIONS
179
+
180
+ def test_inserts_second_blank_line_when_one(self) -> None:
181
+ """A single blank line grows to exactly two."""
182
+ fixed_source = fix_function_spacing(BAD_ONE_LINE_BETWEEN_FUNCTIONS)
183
+ assert fixed_source == GOOD_TWO_LINES_BETWEEN_FUNCTIONS
184
+
185
+ def test_collapses_three_blank_lines_to_two(self) -> None:
186
+ """Three blank lines collapse to exactly two."""
187
+ fixed_source = fix_function_spacing(BAD_THREE_LINES_BETWEEN_FUNCTIONS)
188
+ assert fixed_source == GOOD_TWO_LINES_BETWEEN_FUNCTIONS
189
+
190
+ def test_normalizes_before_decorated_function(self) -> None:
191
+ """Blank lines before a decorated function normalize to two."""
192
+ fixed_source = fix_function_spacing(ONE_BLANK_BEFORE_DECORATED)
193
+ assert fixed_source == DECORATED_NEXT_TWO_LINES
194
+
195
+
196
+ class TestDirectInvocation:
197
+ """Test running the checker as a standalone script."""
198
+
199
+ def test_direct_invocation_resolves_hooks_constants(
200
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
201
+ ) -> None:
202
+ """Direct invocation bootstraps hooks_constants without PYTHONPATH."""
203
+ monkeypatch.setenv("HOME", str(tmp_path))
204
+ monkeypatch.setenv("TMPDIR", str(tmp_path))
205
+ script_path = Path(__file__).resolve().parent / "python_style_checks.py"
206
+ target_path = tmp_path / "clean_sample.py"
207
+ target_path.write_text(GOOD_IMPORTS, encoding="utf-8")
208
+ scrubbed_environment = dict(os.environ)
209
+ scrubbed_environment.pop("PYTHONPATH", None)
210
+ completed_process = subprocess.run(
211
+ [sys.executable, "-S", str(script_path), str(target_path)],
212
+ capture_output=True,
213
+ text=True,
214
+ env=scrubbed_environment,
215
+ check=False,
216
+ )
217
+ assert "ModuleNotFoundError" not in completed_process.stderr
218
+ assert completed_process.returncode == 0
219
+
220
+ def test_direct_invocation_with_hooks_constants_resolvable_off_path(
221
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
222
+ ) -> None:
223
+ """First import succeeds while the validators package stays off sys.path."""
224
+ monkeypatch.setenv("HOME", str(tmp_path))
225
+ monkeypatch.setenv("TMPDIR", str(tmp_path))
226
+ synthetic_constants_root = tmp_path / "synthetic_root"
227
+ constants_package = synthetic_constants_root / "hooks_constants"
228
+ constants_package.mkdir(parents=True)
229
+ (constants_package / "__init__.py").write_text("", encoding="utf-8")
230
+ (constants_package / "python_style_checks_constants.py").write_text(
231
+ "EXPECTED_BLANK_LINES_BETWEEN_FUNCTIONS = 2\nMINIMUM_ARGUMENT_COUNT = 1\n",
232
+ encoding="utf-8",
233
+ )
234
+ script_path = Path(__file__).resolve().parent / "python_style_checks.py"
235
+ target_path = tmp_path / "clean_sample.py"
236
+ target_path.write_text(GOOD_IMPORTS, encoding="utf-8")
237
+ isolated_environment = dict(os.environ)
238
+ isolated_environment["PYTHONPATH"] = str(synthetic_constants_root)
239
+ completed_process = subprocess.run(
240
+ [sys.executable, str(script_path), str(target_path)],
241
+ capture_output=True,
242
+ text=True,
243
+ env=isolated_environment,
244
+ check=False,
245
+ )
246
+ assert "NameError" not in completed_process.stderr
247
+ assert completed_process.returncode == 0
248
+
249
+ def test_ast_module_import_available(self) -> None:
250
+ """The ast module the checker depends on parses a trivial module."""
251
+ assert ast.parse("x = 1").body