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.
- package/_shared/advisor/CLAUDE.md +2 -2
- package/_shared/advisor/advisor-protocol.md +35 -27
- package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +3 -2
- package/_shared/advisor/scripts/model_tier_run_validator.py +23 -15
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +81 -17
- package/bin/CLAUDE.md +10 -1
- package/bin/ever-shipped-skills.mjs +70 -0
- package/bin/install.mjs +136 -6
- package/bin/install.prune.test.mjs +457 -0
- package/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/code_rules_enforcer.py +4 -0
- package/hooks/blocking/code_rules_shared.py +82 -0
- package/hooks/blocking/code_rules_test_layout.py +9 -3
- package/hooks/blocking/plain_language_blocker.py +138 -4
- package/hooks/blocking/sensitive_file_protector.py +114 -48
- package/hooks/blocking/tdd_enforcer.py +9 -2
- package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +105 -0
- package/hooks/blocking/test_code_rules_shared.py +181 -0
- package/hooks/blocking/test_plain_language_blocker_allowlist.py +184 -0
- package/hooks/blocking/test_sensitive_file_protector.py +185 -0
- package/hooks/blocking/test_tdd_enforcer_scratchpad.py +105 -0
- package/hooks/hooks_constants/CLAUDE.md +2 -0
- package/hooks/hooks_constants/harness_scratchpad_constants.py +17 -0
- package/hooks/hooks_constants/plain_language_blocker_constants.py +5 -0
- package/hooks/hooks_constants/sensitive_file_protector_constants.py +42 -0
- package/hooks/pyproject.toml +75 -4
- package/hooks/validators/CLAUDE.md +1 -1
- package/hooks/validators/README.md +2 -0
- package/hooks/validators/python_style_checks.py +114 -136
- package/hooks/validators/python_style_helpers.py +95 -0
- package/hooks/validators/test_python_style_checks.py +0 -164
- package/hooks/validators/test_python_style_checks_decorator_gap.py +119 -0
- package/hooks/validators/test_python_style_fixes.py +251 -0
- package/hooks/validators/test_python_style_helpers.py +125 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/anti-corollary-tests.md +69 -0
- package/rules/bdd.md +1 -3
- package/rules/code-reviews.md +1 -1
- package/rules/gh-paginate.md +1 -1
- package/rules/plain-language.md +2 -0
- package/skills/CLAUDE.md +4 -3
- package/skills/autoconverge/workflow/converge.mjs +2 -2
- package/skills/bugteam/reference/README.md +2 -3
- package/skills/closeout/SKILL.md +153 -0
- package/skills/closeout/reference/handoff-prompt-template.md +72 -0
- package/skills/closeout/reference/issue-body-templates.md +108 -0
- package/skills/closeout/reference/pii-redaction-checklist.md +36 -0
- package/skills/orchestrator/SKILL.md +27 -21
- package/skills/orchestrator-refresh/SKILL.md +12 -8
- package/skills/pr-converge/CLAUDE.md +1 -1
- package/skills/pr-fix-protocol/SKILL.md +65 -0
- package/skills/skill-builder/references/skill-modularity.md +1 -1
- package/skills/team-advisor/SKILL.md +15 -11
- package/system-prompts/software-engineer.xml +7 -6
- package/hooks/validators/test_verify_paths.py +0 -32
- 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
|