claude-dev-env 1.93.1 → 1.94.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/pr-loop/CLAUDE.md +1 -0
- package/_shared/pr-loop/audit-contract.md +1 -1
- package/_shared/pr-loop/gh-payloads.md +3 -3
- package/_shared/pr-loop/post-audit-thread-contract.md +51 -0
- package/_shared/pr-loop/scripts/README.md +2 -2
- package/_shared/pr-loop/scripts/fix_hookspath.py +47 -12
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +0 -69
- package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -374
- package/_shared/pr-loop/state-schema.md +3 -22
- package/bin/CLAUDE.md +1 -1
- package/bin/install.mjs +2 -7
- package/bin/install.test.mjs +10 -4
- package/hooks/blocking/CLAUDE.md +2 -0
- package/hooks/blocking/pii_commit_command.py +398 -0
- package/hooks/blocking/pii_payload_scan.py +133 -0
- package/hooks/blocking/pii_prevention_blocker.py +151 -449
- package/hooks/blocking/test_pii_prevention_blocker.py +299 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +42 -7
- package/hooks/hooks_constants/CLAUDE.md +2 -2
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +0 -2
- package/hooks/hooks_constants/local_identity.py +65 -15
- package/hooks/hooks_constants/pii_prevention_constants.py +16 -0
- package/hooks/hooks_constants/pr_description_enforcer_constants.py +1 -1
- package/hooks/hooks_constants/test_local_identity.py +68 -0
- package/package.json +1 -1
- package/scripts/CLAUDE.md +10 -1
- package/scripts/Get-SessionAccount.ps1 +210 -0
- package/scripts/tests/CLAUDE.md +15 -1
- package/scripts/tests/Get-SessionAccount.Tests.ps1 +350 -0
- package/skills/CLAUDE.md +3 -25
- package/skills/anthropic-plan/SKILL.md +1 -13
- package/skills/autoconverge/CLAUDE.md +5 -2
- package/skills/autoconverge/SKILL.md +112 -387
- package/skills/autoconverge/reference/CLAUDE.md +4 -1
- package/skills/autoconverge/reference/closing-report.md +9 -2
- package/skills/autoconverge/reference/convergence.md +26 -22
- package/skills/autoconverge/reference/headless-safety.md +44 -0
- package/skills/autoconverge/reference/multi-pr.md +88 -0
- package/skills/autoconverge/reference/self-closing-loop.md +84 -0
- package/skills/autoconverge/reference/stop-conditions.md +21 -13
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +87 -27
- package/skills/autoconverge/workflow/converge.contract.test.mjs +40 -10
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +179 -47
- package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
- package/skills/autoconverge/workflow/converge.mjs +284 -94
- package/skills/bugteam/CLAUDE.md +1 -1
- package/skills/bugteam/CONSTRAINTS.md +3 -3
- package/skills/bugteam/PROMPTS.md +8 -19
- package/skills/bugteam/SKILL.md +13 -14
- package/skills/bugteam/reference/CLAUDE.md +1 -1
- package/skills/bugteam/reference/audit-and-teammates.md +2 -2
- package/skills/bugteam/reference/copilot-gap-analysis.md +2 -0
- package/skills/bugteam/reference/github-pr-reviews.md +56 -38
- package/skills/bugteam/reference/team-setup.md +11 -11
- package/skills/bugteam/scripts/CLAUDE.md +6 -6
- package/skills/bugteam/scripts/README.md +20 -8
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +13 -1463
- package/skills/bugteam/scripts/bugteam_fix_hookspath.py +23 -358
- package/skills/bugteam/scripts/bugteam_preflight.py +13 -315
- package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +2 -3
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +34 -1053
- package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +30 -347
- package/skills/bugteam/scripts/test_bugteam_preflight.py +30 -305
- package/skills/copilot-finding-triage/SKILL.md +80 -28
- package/skills/copilot-finding-triage/reference/tier-rubric.md +70 -11
- package/skills/copilot-finding-triage/templates/notification.md +8 -3
- package/skills/everything-search/SKILL.md +1 -2
- package/skills/grokify/SKILL.md +58 -0
- package/skills/grokify/templates/handoff-template.md +92 -0
- package/skills/orchestrator/SKILL.md +12 -8
- package/skills/orchestrator-refresh/SKILL.md +3 -3
- package/skills/pr-converge/SKILL.md +132 -233
- package/skills/pr-converge/reference/convergence-gates.md +102 -66
- package/skills/pr-converge/reference/examples.md +37 -25
- package/skills/pr-converge/reference/fix-protocol.md +7 -7
- package/skills/pr-converge/reference/multi-pr-orchestration.md +10 -4
- package/skills/pr-converge/reference/per-tick.md +64 -41
- package/skills/pr-converge/reference/state-schema.md +27 -2
- package/skills/pr-converge/scripts/CLAUDE.md +8 -0
- package/skills/pr-converge/scripts/README.md +4 -3
- package/skills/pr-converge/scripts/_pr_converge_path_setup.py +23 -0
- package/skills/pr-converge/scripts/check_convergence.py +189 -645
- package/skills/pr-converge/scripts/check_convergence_gates.py +235 -0
- package/skills/pr-converge/scripts/check_convergence_thread_gates.py +219 -0
- package/skills/pr-converge/scripts/conftest.py +8 -0
- package/skills/pr-converge/scripts/pr_converge_scripts_constants/CLAUDE.md +1 -0
- package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -0
- package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +26 -0
- package/skills/pr-converge/scripts/test_check_convergence.py +73 -228
- package/skills/pr-converge/scripts/test_check_convergence_contract.py +321 -0
- package/skills/pr-converge/scripts/test_check_convergence_gates.py +68 -0
- package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +75 -0
- package/skills/privacy-hygiene/SKILL.md +4 -3
- package/skills/reviewer-gates/SKILL.md +26 -8
- package/skills/session-log/SKILL.md +11 -12
- package/skills/session-tidy/SKILL.md +40 -30
- package/skills/skill-builder/CLAUDE.md +13 -10
- package/skills/skill-builder/SKILL.md +31 -31
- package/skills/skill-builder/references/CLAUDE.md +10 -6
- package/skills/skill-builder/references/delegation-map.md +13 -4
- package/skills/skill-builder/references/description-field.md +113 -0
- package/skills/skill-builder/references/self-audit-checklist.md +21 -4
- package/skills/skill-builder/references/skill-modularity.md +113 -0
- package/skills/skill-builder/references/skill-types.md +4 -0
- package/skills/skill-builder/templates/CLAUDE.md +4 -4
- package/skills/skill-builder/templates/gap-analysis.md +25 -1
- package/skills/skill-builder/workflows/CLAUDE.md +6 -4
- package/skills/skill-builder/workflows/improve-skill.md +17 -16
- package/skills/skill-builder/workflows/new-skill.md +34 -10
- package/skills/skill-builder/workflows/polish-skill.md +59 -43
- package/skills/usage-pause/SKILL.md +10 -14
- package/skills/bdd-protocol/CLAUDE.md +0 -26
- package/skills/bdd-protocol/SKILL.md +0 -30
- package/skills/bdd-protocol/references/CLAUDE.md +0 -10
- package/skills/bdd-protocol/references/anti-patterns.md +0 -26
- package/skills/bdd-protocol/references/example-mapping.md +0 -23
- package/skills/bg-agent/CLAUDE.md +0 -17
- package/skills/bg-agent/SKILL.md +0 -69
- package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +0 -55
- package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_fix_hookspath_constants.py +0 -26
- package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_preflight_constants.py +0 -35
- package/skills/caveman/CLAUDE.md +0 -15
- package/skills/caveman/SKILL.md +0 -38
- package/skills/code/CLAUDE.md +0 -17
- package/skills/code/SKILL.md +0 -178
- package/skills/deep-research/CLAUDE.md +0 -17
- package/skills/deep-research/SKILL.md +0 -80
- package/skills/findbugs/CLAUDE.md +0 -20
- package/skills/findbugs/SKILL.md +0 -241
- package/skills/fixbugs/CLAUDE.md +0 -19
- package/skills/fixbugs/SKILL.md +0 -142
- package/skills/gh-paginate/CLAUDE.md +0 -18
- package/skills/gh-paginate/SKILL.md +0 -84
- package/skills/gotcha/CLAUDE.md +0 -33
- package/skills/gotcha/SKILL.md +0 -73
- package/skills/implement/CLAUDE.md +0 -27
- package/skills/implement/SKILL.md +0 -66
- package/skills/implement/scripts/CLAUDE.md +0 -22
- package/skills/implement/scripts/append_note.py +0 -133
- package/skills/implement/scripts/implement_scripts_constants/CLAUDE.md +0 -22
- package/skills/implement/scripts/implement_scripts_constants/__init__.py +0 -0
- package/skills/implement/scripts/implement_scripts_constants/notes_constants.py +0 -12
- package/skills/implement/scripts/test_append_note.py +0 -191
- package/skills/log-audit/CLAUDE.md +0 -20
- package/skills/log-audit/SKILL.md +0 -66
- package/skills/log-audit/reference/CLAUDE.md +0 -9
- package/skills/log-audit/reference/charter.md +0 -52
- package/skills/log-audit/scripts/CLAUDE.md +0 -27
- package/skills/log-audit/scripts/cluster_recurrences.py +0 -261
- package/skills/log-audit/scripts/collect_log_window.py +0 -199
- package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +0 -12
- package/skills/log-audit/scripts/log_audit_constants/__init__.py +0 -0
- package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +0 -23
- package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +0 -24
- package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +0 -49
- package/skills/log-audit/scripts/mine_copilot_findings.py +0 -302
- package/skills/log-audit/scripts/test_cluster_recurrences.py +0 -160
- package/skills/log-audit/scripts/test_collect_log_window.py +0 -111
- package/skills/log-audit/scripts/test_mine_copilot_findings.py +0 -126
- package/skills/logifix/CLAUDE.md +0 -36
- package/skills/logifix/SKILL.md +0 -69
- package/skills/logifix/scripts/CLAUDE.md +0 -16
- package/skills/logifix/scripts/logifix.ps1 +0 -205
- package/skills/monitor-open-prs/CLAUDE.md +0 -34
- package/skills/monitor-open-prs/SKILL.md +0 -88
- package/skills/monitor-open-prs/scripts/CLAUDE.md +0 -17
- package/skills/monitor-open-prs/scripts/discover_open_prs.py +0 -69
- package/skills/monitor-open-prs/scripts/test_discover_open_prs.py +0 -149
- package/skills/monitor-open-prs/test_skill_contract.py +0 -27
- package/skills/post-audit-findings/SKILL.md +0 -80
- package/skills/pr-consistency-audit/CLAUDE.md +0 -34
- package/skills/pr-consistency-audit/SKILL.md +0 -116
- package/skills/pr-consistency-audit/reference/CLAUDE.md +0 -16
- package/skills/pr-consistency-audit/reference/detection-rules.md +0 -96
- package/skills/pr-consistency-audit/reference/illustrations.md +0 -78
- package/skills/pr-fix-protocol/SKILL.md +0 -65
- package/skills/pr-review-responder/CLAUDE.md +0 -35
- package/skills/pr-review-responder/EXAMPLES.md +0 -590
- package/skills/pr-review-responder/PRINCIPLES.md +0 -533
- package/skills/pr-review-responder/README.md +0 -168
- package/skills/pr-review-responder/SKILL.md +0 -203
- package/skills/pr-review-responder/TESTING.md +0 -350
- package/skills/pr-scope-resolve/SKILL.md +0 -45
- package/skills/pre-compact/CLAUDE.md +0 -24
- package/skills/pre-compact/SKILL.md +0 -134
- package/skills/qbug/CLAUDE.md +0 -40
- package/skills/qbug/SKILL.md +0 -387
- package/skills/qbug/test_qbug_skill_audit_schema.py +0 -152
- package/skills/qbug/test_qbug_skill_post_fix_audit.py +0 -103
- package/skills/refine/CLAUDE.md +0 -44
- package/skills/refine/SKILL.md +0 -262
- package/skills/refine/templates/CLAUDE.md +0 -17
- package/skills/refine/templates/implementation-notes-template.html +0 -56
- package/skills/refine/templates/plan-template.md +0 -60
- package/skills/research-mode/CLAUDE.md +0 -35
- package/skills/research-mode/SKILL.md +0 -53
- package/skills/structure-prompt/CLAUDE.md +0 -42
- package/skills/structure-prompt/SKILL.md +0 -44
- package/skills/structure-prompt/reference/CLAUDE.md +0 -28
- package/skills/structure-prompt/reference/adversarial-tuning.md +0 -62
- package/skills/structure-prompt/reference/block-classification.md +0 -27
- package/skills/structure-prompt/reference/canonical-case.md +0 -48
- package/skills/structure-prompt/reference/citation-depth.md +0 -70
- package/skills/structure-prompt/reference/cleanup.md +0 -33
- package/skills/structure-prompt/reference/constraints.md +0 -33
- package/skills/structure-prompt/reference/directives.md +0 -37
- package/skills/structure-prompt/reference/examples.md +0 -72
- package/skills/structure-prompt/reference/instantiation.md +0 -51
- package/skills/structure-prompt/reference/output-contract.md +0 -72
- package/skills/structure-prompt/reference/per-category.md +0 -23
- package/skills/structure-prompt/reference/persona.md +0 -38
- package/skills/structure-prompt/reference/research.md +0 -33
- package/skills/structure-prompt/reference/structure.md +0 -28
- package/skills/verified-build/CLAUDE.md +0 -33
- package/skills/verified-build/SKILL.md +0 -33
|
@@ -1,1066 +1,47 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import inspect
|
|
4
|
-
import subprocess
|
|
5
|
-
import sys
|
|
6
|
-
import unittest.mock
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
import pytest
|
|
9
|
-
|
|
10
|
-
SCRIPT_DIRECTORY = Path(__file__).resolve().parent
|
|
11
|
-
if str(SCRIPT_DIRECTORY) not in sys.path:
|
|
12
|
-
sys.path.insert(0, str(SCRIPT_DIRECTORY))
|
|
13
|
-
|
|
14
|
-
import bugteam_code_rules_gate as gate_module
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def run_git_in_repository(repository_root: Path, *arguments: str) -> str:
|
|
18
|
-
completion = subprocess.run(
|
|
19
|
-
["git", *arguments],
|
|
20
|
-
cwd=str(repository_root),
|
|
21
|
-
capture_output=True,
|
|
22
|
-
text=True,
|
|
23
|
-
encoding="utf-8",
|
|
24
|
-
errors="replace",
|
|
25
|
-
check=True,
|
|
26
|
-
)
|
|
27
|
-
return completion.stdout
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def initialize_git_repository(repository_root: Path) -> None:
|
|
31
|
-
run_git_in_repository(repository_root, "init")
|
|
32
|
-
run_git_in_repository(repository_root, "symbolic-ref", "HEAD", "refs/heads/main")
|
|
33
|
-
run_git_in_repository(repository_root, "config", "user.email", "test@example.com")
|
|
34
|
-
run_git_in_repository(repository_root, "config", "user.name", "Test")
|
|
35
|
-
run_git_in_repository(repository_root, "config", "commit.gpgsign", "false")
|
|
36
|
-
disabled_hooks_directory = repository_root / "disabled-git-hooks"
|
|
37
|
-
disabled_hooks_directory.mkdir()
|
|
38
|
-
run_git_in_repository(
|
|
39
|
-
repository_root, "config", "core.hooksPath", str(disabled_hooks_directory)
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def commit_all_files(repository_root: Path, commit_message: str) -> None:
|
|
44
|
-
run_git_in_repository(repository_root, "add", "-A")
|
|
45
|
-
run_git_in_repository(repository_root, "commit", "-m", commit_message)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def write_file(file_path: Path, content: str) -> None:
|
|
49
|
-
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
-
file_path.write_text(content, encoding="utf-8")
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def stage_file(repository_root: Path, relative_path: str) -> None:
|
|
54
|
-
run_git_in_repository(repository_root, "add", "--", relative_path)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
@pytest.fixture()
|
|
58
|
-
def temporary_git_repository(tmp_path: Path) -> Path:
|
|
59
|
-
repository_root = tmp_path / "repository_under_test"
|
|
60
|
-
repository_root.mkdir()
|
|
61
|
-
initialize_git_repository(repository_root)
|
|
62
|
-
return repository_root
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def test_paths_from_git_staged_returns_staged_files(
|
|
66
|
-
temporary_git_repository: Path,
|
|
67
|
-
) -> None:
|
|
68
|
-
write_file(temporary_git_repository / "committed_file.py", "one = 1\n")
|
|
69
|
-
commit_all_files(temporary_git_repository, "initial")
|
|
70
|
-
write_file(temporary_git_repository / "newly_staged_file.py", "two = 2\n")
|
|
71
|
-
write_file(temporary_git_repository / "unstaged_file.py", "three = 3\n")
|
|
72
|
-
stage_file(temporary_git_repository, "newly_staged_file.py")
|
|
73
|
-
|
|
74
|
-
staged_paths = gate_module.paths_from_git_staged(temporary_git_repository)
|
|
75
|
-
|
|
76
|
-
staged_names = {path.name for path in staged_paths}
|
|
77
|
-
assert "newly_staged_file.py" in staged_names
|
|
78
|
-
assert "unstaged_file.py" not in staged_names
|
|
79
|
-
assert "committed_file.py" not in staged_names
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def test_added_lines_for_staged_file_reports_new_lines(
|
|
83
|
-
temporary_git_repository: Path,
|
|
84
|
-
) -> None:
|
|
85
|
-
write_file(temporary_git_repository / "target.py", "first = 1\nsecond = 2\n")
|
|
86
|
-
commit_all_files(temporary_git_repository, "baseline")
|
|
87
|
-
write_file(
|
|
88
|
-
temporary_git_repository / "target.py",
|
|
89
|
-
"first = 1\nsecond = 2\nthird = 3\nfourth = 4\n",
|
|
90
|
-
)
|
|
91
|
-
stage_file(temporary_git_repository, "target.py")
|
|
92
|
-
|
|
93
|
-
added_line_numbers = gate_module.added_lines_for_staged_file(
|
|
94
|
-
temporary_git_repository,
|
|
95
|
-
"target.py",
|
|
96
|
-
)
|
|
97
|
-
|
|
98
|
-
assert 3 in added_line_numbers
|
|
99
|
-
assert 4 in added_line_numbers
|
|
100
|
-
assert 1 not in added_line_numbers
|
|
101
|
-
assert 2 not in added_line_numbers
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def test_added_lines_for_staged_file_treats_new_file_as_fully_added(
|
|
105
|
-
temporary_git_repository: Path,
|
|
106
|
-
) -> None:
|
|
107
|
-
write_file(temporary_git_repository / "existing.py", "ignored = 0\n")
|
|
108
|
-
commit_all_files(temporary_git_repository, "baseline")
|
|
109
|
-
write_file(
|
|
110
|
-
temporary_git_repository / "brand_new.py",
|
|
111
|
-
"alpha = 1\nbeta = 2\ngamma = 3\n",
|
|
112
|
-
)
|
|
113
|
-
stage_file(temporary_git_repository, "brand_new.py")
|
|
114
|
-
|
|
115
|
-
added_line_numbers = gate_module.added_lines_for_staged_file(
|
|
116
|
-
temporary_git_repository,
|
|
117
|
-
"brand_new.py",
|
|
118
|
-
)
|
|
119
|
-
|
|
120
|
-
assert added_line_numbers == {1, 2, 3}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
def test_paths_from_git_staged_uses_null_delimiter(
|
|
124
|
-
temporary_git_repository: Path,
|
|
125
|
-
) -> None:
|
|
126
|
-
write_file(temporary_git_repository / "first.py", "a = 1\n")
|
|
127
|
-
write_file(temporary_git_repository / "second.py", "b = 2\n")
|
|
128
|
-
commit_all_files(temporary_git_repository, "baseline")
|
|
129
|
-
write_file(temporary_git_repository / "first.py", "a = 10\n")
|
|
130
|
-
write_file(temporary_git_repository / "second.py", "b = 20\n")
|
|
131
|
-
stage_file(temporary_git_repository, "first.py")
|
|
132
|
-
stage_file(temporary_git_repository, "second.py")
|
|
133
|
-
|
|
134
|
-
staged_paths = gate_module.paths_from_git_staged(temporary_git_repository)
|
|
135
|
-
|
|
136
|
-
staged_names = {path.name for path in staged_paths}
|
|
137
|
-
assert staged_names == {"first.py", "second.py"}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
def test_paths_from_git_staged_warns_and_skips_non_utf8_filename(
|
|
141
|
-
tmp_path: Path,
|
|
142
|
-
capsys: pytest.CaptureFixture[str],
|
|
143
|
-
) -> None:
|
|
144
|
-
non_utf8_raw = b"valid.py\x00\xff\xfe_bad.py\x00"
|
|
145
|
-
mock_completed = unittest.mock.MagicMock()
|
|
146
|
-
mock_completed.returncode = 0
|
|
147
|
-
mock_completed.stdout = non_utf8_raw
|
|
148
|
-
|
|
149
|
-
with unittest.mock.patch("subprocess.run", return_value=mock_completed):
|
|
150
|
-
result_paths = gate_module.paths_from_git_staged(tmp_path)
|
|
151
|
-
|
|
152
|
-
captured = capsys.readouterr()
|
|
153
|
-
assert "non-UTF-8" in captured.err
|
|
154
|
-
assert len(result_paths) == 1
|
|
155
|
-
assert result_paths[0].name == "valid.py"
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
def test_staged_added_lines_by_file_maps_every_staged_code_file(
|
|
159
|
-
temporary_git_repository: Path,
|
|
160
|
-
) -> None:
|
|
161
|
-
write_file(temporary_git_repository / "already_committed.py", "zero = 0\n")
|
|
162
|
-
commit_all_files(temporary_git_repository, "initial")
|
|
163
|
-
write_file(
|
|
164
|
-
temporary_git_repository / "already_committed.py",
|
|
165
|
-
"zero = 0\nappended = 1\n",
|
|
166
|
-
)
|
|
167
|
-
write_file(temporary_git_repository / "added_file.py", "only = 1\n")
|
|
168
|
-
stage_file(temporary_git_repository, "already_committed.py")
|
|
169
|
-
stage_file(temporary_git_repository, "added_file.py")
|
|
170
|
-
|
|
171
|
-
staged_paths = gate_module.paths_from_git_staged(temporary_git_repository)
|
|
172
|
-
added_lines_map = gate_module.added_lines_by_file_staged(
|
|
173
|
-
temporary_git_repository,
|
|
174
|
-
staged_paths,
|
|
175
|
-
)
|
|
176
|
-
|
|
177
|
-
resolved_repository_root = temporary_git_repository.resolve()
|
|
178
|
-
assert added_lines_map[resolved_repository_root / "already_committed.py"] == {2}
|
|
179
|
-
assert added_lines_map[resolved_repository_root / "added_file.py"] == {1}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
def test_main_staged_mode_blocks_when_staged_lines_introduce_violations(
|
|
183
|
-
temporary_git_repository: Path,
|
|
184
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
185
|
-
) -> None:
|
|
186
|
-
write_file(temporary_git_repository / "module.py", "first_count = 1\n")
|
|
187
|
-
commit_all_files(temporary_git_repository, "initial")
|
|
188
|
-
staged_content_with_banned_identifier = (
|
|
189
|
-
"first_count = 1\n"
|
|
190
|
-
"def compute_total(operand):\n"
|
|
191
|
-
" result = operand + 1\n"
|
|
192
|
-
" return result\n"
|
|
193
|
-
)
|
|
194
|
-
write_file(
|
|
195
|
-
temporary_git_repository / "module.py",
|
|
196
|
-
staged_content_with_banned_identifier,
|
|
197
|
-
)
|
|
198
|
-
stage_file(temporary_git_repository, "module.py")
|
|
199
|
-
|
|
200
|
-
monkeypatch.chdir(temporary_git_repository)
|
|
201
|
-
exit_code = gate_module.main(["--staged"])
|
|
202
|
-
|
|
203
|
-
assert exit_code == 1
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
def test_main_staged_mode_passes_when_no_staged_violations(
|
|
207
|
-
temporary_git_repository: Path,
|
|
208
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
209
|
-
) -> None:
|
|
210
|
-
write_file(temporary_git_repository / "module.py", "first_count = 1\n")
|
|
211
|
-
commit_all_files(temporary_git_repository, "initial")
|
|
212
|
-
write_file(
|
|
213
|
-
temporary_git_repository / "module.py", "first_count = 1\nsecond_count = 2\n"
|
|
214
|
-
)
|
|
215
|
-
stage_file(temporary_git_repository, "module.py")
|
|
216
|
-
|
|
217
|
-
monkeypatch.chdir(temporary_git_repository)
|
|
218
|
-
exit_code = gate_module.main(["--staged"])
|
|
219
|
-
|
|
220
|
-
assert exit_code == 0
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
def test_main_staged_mode_exits_zero_when_nothing_staged(
|
|
224
|
-
temporary_git_repository: Path,
|
|
225
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
226
|
-
) -> None:
|
|
227
|
-
write_file(temporary_git_repository / "module.py", "first_count = 1\n")
|
|
228
|
-
commit_all_files(temporary_git_repository, "initial")
|
|
229
|
-
|
|
230
|
-
monkeypatch.chdir(temporary_git_repository)
|
|
231
|
-
exit_code = gate_module.main(["--staged"])
|
|
232
|
-
|
|
233
|
-
assert exit_code == 0
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
def test_added_lines_for_staged_file_returns_empty_for_modified_file_with_no_additions(
|
|
237
|
-
temporary_git_repository: Path,
|
|
238
|
-
) -> None:
|
|
239
|
-
write_file(
|
|
240
|
-
temporary_git_repository / "existing.py",
|
|
241
|
-
"alpha = 1\nbeta = 2\ngamma = 3\n",
|
|
242
|
-
)
|
|
243
|
-
commit_all_files(temporary_git_repository, "baseline")
|
|
244
|
-
write_file(temporary_git_repository / "existing.py", "alpha = 1\nbeta = 2\n")
|
|
245
|
-
stage_file(temporary_git_repository, "existing.py")
|
|
246
|
-
|
|
247
|
-
added_line_numbers = gate_module.added_lines_for_staged_file(
|
|
248
|
-
temporary_git_repository,
|
|
249
|
-
"existing.py",
|
|
250
|
-
)
|
|
251
|
-
|
|
252
|
-
assert added_line_numbers == set()
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
def test_is_file_absent_in_index_head_does_not_exist_in_module() -> None:
|
|
256
|
-
assert not hasattr(gate_module, "is_file_absent_in_index_head")
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
def test_staged_file_line_count_raises_on_git_show_failure(
|
|
260
|
-
tmp_path: Path,
|
|
261
|
-
capsys: pytest.CaptureFixture[str],
|
|
262
|
-
) -> None:
|
|
263
|
-
"""git show failure must surface as SystemExit + stderr, never silent 0."""
|
|
264
|
-
failing_completed = unittest.mock.MagicMock()
|
|
265
|
-
failing_completed.returncode = 128
|
|
266
|
-
failing_completed.stdout = ""
|
|
267
|
-
failing_completed.stderr = "fatal: bad object :missing\n"
|
|
268
|
-
with unittest.mock.patch("subprocess.run", return_value=failing_completed):
|
|
269
|
-
with pytest.raises(SystemExit):
|
|
270
|
-
gate_module.staged_file_line_count(tmp_path, "missing.py")
|
|
271
|
-
captured = capsys.readouterr()
|
|
272
|
-
assert "git show" in captured.err
|
|
273
|
-
assert "fatal: bad object" in captured.err
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
def test_is_staged_file_newly_added_raises_on_git_failure(
|
|
277
|
-
tmp_path: Path,
|
|
278
|
-
capsys: pytest.CaptureFixture[str],
|
|
279
|
-
) -> None:
|
|
280
|
-
"""git diff --name-status failure must surface as SystemExit + stderr."""
|
|
281
|
-
failing_completed = unittest.mock.MagicMock()
|
|
282
|
-
failing_completed.returncode = 128
|
|
283
|
-
failing_completed.stdout = ""
|
|
284
|
-
failing_completed.stderr = "fatal: not a git repository\n"
|
|
285
|
-
with unittest.mock.patch("subprocess.run", return_value=failing_completed):
|
|
286
|
-
with pytest.raises(SystemExit):
|
|
287
|
-
gate_module.is_staged_file_newly_added(tmp_path, "anything.py")
|
|
288
|
-
captured = capsys.readouterr()
|
|
289
|
-
assert "git diff --cached --name-status" in captured.err
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
def test_whole_file_line_set_raises_system_exit_on_oserror(
|
|
293
|
-
tmp_path: Path,
|
|
294
|
-
capsys: pytest.CaptureFixture[str],
|
|
295
|
-
) -> None:
|
|
296
|
-
"""OSError reading a file must propagate as SystemExit, not silently return ``set()``.
|
|
297
|
-
|
|
298
|
-
Regression for loop1-7: returning an empty set on OSError caused the gate
|
|
299
|
-
to route every violation to the advisory bucket and exit 0 — silently
|
|
300
|
-
downgrading blocking violations to non-blocking on a read failure.
|
|
301
|
-
"""
|
|
302
|
-
unreadable_path = tmp_path / "broken.py"
|
|
303
|
-
with unittest.mock.patch.object(
|
|
304
|
-
Path, "read_text", side_effect=PermissionError("denied")
|
|
305
|
-
):
|
|
306
|
-
with pytest.raises(SystemExit):
|
|
307
|
-
gate_module.whole_file_line_set(unreadable_path)
|
|
308
|
-
captured = capsys.readouterr()
|
|
309
|
-
assert str(unreadable_path) in captured.err
|
|
310
|
-
assert "denied" in captured.err or "PermissionError" in captured.err
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
def test_whole_file_line_set_raises_system_exit_on_non_utf8_file(
|
|
314
|
-
tmp_path: Path,
|
|
315
|
-
capsys: pytest.CaptureFixture[str],
|
|
316
|
-
) -> None:
|
|
317
|
-
"""A genuine non-UTF-8 file must fail closed as SystemExit, never crash.
|
|
318
|
-
|
|
319
|
-
``read_text(encoding="utf-8")`` on undecodable bytes raises
|
|
320
|
-
UnicodeDecodeError, a ValueError subclass that ``OSError`` does not catch.
|
|
321
|
-
The fail-closed contract that holds for read failures holds equally here:
|
|
322
|
-
returning an empty set would route every violation to the advisory bucket,
|
|
323
|
-
so an undecodable file must propagate as SystemExit rather than escape as
|
|
324
|
-
an unhandled UnicodeDecodeError.
|
|
325
|
-
"""
|
|
326
|
-
non_utf8_path = tmp_path / "garbled.py"
|
|
327
|
-
non_utf8_path.write_bytes(b"\xff\xfe\x00bad")
|
|
328
|
-
with pytest.raises(SystemExit):
|
|
329
|
-
gate_module.whole_file_line_set(non_utf8_path)
|
|
330
|
-
captured = capsys.readouterr()
|
|
331
|
-
assert str(non_utf8_path) in captured.err
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
def test_check_database_column_string_magic_signals_cap_exit(
|
|
335
|
-
capsys: pytest.CaptureFixture[str],
|
|
336
|
-
) -> None:
|
|
337
|
-
"""When the issue cap is hit, a 'cap reached' note must reach stderr."""
|
|
338
|
-
source_with_many_column_tuples = "\n".join(
|
|
339
|
-
[
|
|
340
|
-
"def write_rows():",
|
|
341
|
-
" rows = [",
|
|
342
|
-
*[
|
|
343
|
-
f" ('column_name_{each_index}', {each_index}),"
|
|
344
|
-
for each_index in range(10)
|
|
345
|
-
],
|
|
346
|
-
" ]",
|
|
347
|
-
" return rows",
|
|
348
|
-
]
|
|
349
|
-
)
|
|
350
|
-
issues = gate_module.check_database_column_string_magic(
|
|
351
|
-
source_with_many_column_tuples,
|
|
352
|
-
"production/file.py",
|
|
353
|
-
)
|
|
354
|
-
assert len(issues) == 3
|
|
355
|
-
captured = capsys.readouterr()
|
|
356
|
-
assert "cap reached" in captured.err.lower()
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
def test_check_wrapper_plumb_through_caps_findings_at_max_per_check() -> None:
|
|
360
|
-
"""check_wrapper_plumb_through stops emitting at MAX_VIOLATIONS_PER_CHECK
|
|
361
|
-
findings — the cap bounds the blocking payload silently, matching the
|
|
362
|
-
_shared gate copy."""
|
|
363
|
-
delegate_definition = (
|
|
364
|
-
"def delegate(*, optional_one=1, optional_two=2, optional_three=3,"
|
|
365
|
-
" optional_four=4): return 0\n"
|
|
366
|
-
)
|
|
367
|
-
wrappers_block = "\n".join(
|
|
368
|
-
f"def wrapper_{each_index}():\n return self.delegate()"
|
|
369
|
-
for each_index in range(5)
|
|
370
|
-
)
|
|
371
|
-
source_with_many_wrappers = delegate_definition + wrappers_block + "\n"
|
|
372
|
-
issues = gate_module.check_wrapper_plumb_through(
|
|
373
|
-
source_with_many_wrappers,
|
|
374
|
-
"production/wrappers.py",
|
|
375
|
-
)
|
|
376
|
-
assert len(issues) == gate_module.MAX_VIOLATIONS_PER_CHECK
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
def _bugteam_banned_noun_parameter_issues() -> list[str]:
|
|
380
|
-
validate_content = gate_module.load_validate_content()
|
|
381
|
-
source = (
|
|
382
|
-
"def aggregate(canned_results: int) -> int:\n"
|
|
383
|
-
" doubled = canned_results * 2\n"
|
|
384
|
-
" return doubled\n"
|
|
385
|
-
)
|
|
386
|
-
issues = validate_content(source, "src/module.py", "")
|
|
387
|
-
return [each_issue for each_issue in issues if "banned noun" in each_issue]
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
def test_bugteam_split_violations_blocks_banned_noun_when_binding_line_is_added() -> None:
|
|
391
|
-
"""The bugteam gate reconstructs a banned-noun binding's one-line span the
|
|
392
|
-
same way the _shared gate does: the violation is blocking when its own
|
|
393
|
-
binding line is among the added lines."""
|
|
394
|
-
banned_noun_issues = _bugteam_banned_noun_parameter_issues()
|
|
395
|
-
assert banned_noun_issues, "expected a banned-noun parameter issue"
|
|
396
|
-
parameter_binding_line = 1
|
|
397
|
-
blocking, advisory = gate_module.split_violations_by_scope(
|
|
398
|
-
banned_noun_issues,
|
|
399
|
-
{parameter_binding_line},
|
|
400
|
-
)
|
|
401
|
-
assert blocking == banned_noun_issues
|
|
402
|
-
assert advisory == []
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
def test_bugteam_split_violations_advises_banned_noun_when_binding_line_untouched() -> None:
|
|
406
|
-
"""A banned-noun binding whose own line is untouched is advisory at the
|
|
407
|
-
bugteam gate, so editing an unrelated body line does not pull a pre-existing
|
|
408
|
-
binding into scope."""
|
|
409
|
-
banned_noun_issues = _bugteam_banned_noun_parameter_issues()
|
|
410
|
-
assert banned_noun_issues, "expected a banned-noun parameter issue"
|
|
411
|
-
unrelated_body_line = 2
|
|
412
|
-
blocking, advisory = gate_module.split_violations_by_scope(
|
|
413
|
-
banned_noun_issues,
|
|
414
|
-
{unrelated_body_line},
|
|
415
|
-
)
|
|
416
|
-
assert advisory == banned_noun_issues
|
|
417
|
-
assert blocking == []
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
def test_bugteam_banned_noun_span_range_covers_only_the_binding_line() -> None:
|
|
421
|
-
"""The reconstructed span is the binding line alone — one line, never the
|
|
422
|
-
enclosing function span. A parameter declared on a ``def`` line yields a
|
|
423
|
-
range covering only that line, so an unrelated body edit cannot pull the
|
|
424
|
-
pre-existing binding into scope."""
|
|
425
|
-
banned_noun_issues = _bugteam_banned_noun_parameter_issues()
|
|
426
|
-
assert banned_noun_issues, "expected a banned-noun parameter issue"
|
|
427
|
-
parameter_binding_line = 1
|
|
428
|
-
span = gate_module.banned_noun_span_range(banned_noun_issues[0])
|
|
429
|
-
assert span == range(parameter_binding_line, parameter_binding_line + 1)
|
|
430
|
-
assert len(span) == 1
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
def test_run_gate_exits_nonzero_when_a_file_is_unreadable(
|
|
434
|
-
tmp_path: Path,
|
|
435
|
-
capsys: pytest.CaptureFixture[str],
|
|
436
|
-
) -> None:
|
|
437
|
-
"""Skipping an unreadable file during run_gate must cause a non-zero exit."""
|
|
438
|
-
target_file = tmp_path / "sample.py"
|
|
439
|
-
target_file.write_text("clean = 1\n", encoding="utf-8")
|
|
440
|
-
|
|
441
|
-
def fake_validate(_content: str, _path: str, **_kwargs: object) -> list[str]:
|
|
442
|
-
return []
|
|
443
|
-
|
|
444
|
-
with unittest.mock.patch.object(
|
|
445
|
-
Path, "read_text", side_effect=PermissionError("denied")
|
|
446
|
-
):
|
|
447
|
-
exit_code = gate_module.run_gate(
|
|
448
|
-
fake_validate,
|
|
449
|
-
[target_file],
|
|
450
|
-
tmp_path,
|
|
451
|
-
all_added_lines_map=None,
|
|
452
|
-
)
|
|
453
|
-
captured = capsys.readouterr()
|
|
454
|
-
assert exit_code != 0, (
|
|
455
|
-
"Files skipped due to read errors must produce a non-zero gate exit"
|
|
456
|
-
)
|
|
457
|
-
assert "skip unreadable" in captured.err
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
def test_run_gate_skips_non_utf8_file_without_crashing(
|
|
461
|
-
tmp_path: Path,
|
|
462
|
-
capsys: pytest.CaptureFixture[str],
|
|
463
|
-
) -> None:
|
|
464
|
-
"""A non-UTF-8 code file is skipped, not crashed on, and forces a non-zero exit."""
|
|
465
|
-
non_utf8_file = tmp_path / "garbled.py"
|
|
466
|
-
non_utf8_file.write_bytes(b"\xff\xfe\x00bad bytes")
|
|
467
|
-
|
|
468
|
-
def fake_validate(_content: str, _path: str, **_kwargs: object) -> list[str]:
|
|
469
|
-
return []
|
|
470
|
-
|
|
471
|
-
exit_code = gate_module.run_gate(
|
|
472
|
-
fake_validate,
|
|
473
|
-
[non_utf8_file],
|
|
474
|
-
tmp_path,
|
|
475
|
-
all_added_lines_map=None,
|
|
476
|
-
)
|
|
477
|
-
|
|
478
|
-
captured = capsys.readouterr()
|
|
479
|
-
assert exit_code != 0, (
|
|
480
|
-
"A file skipped for non-UTF-8 content must produce a non-zero gate exit"
|
|
481
|
-
)
|
|
482
|
-
assert "skip unreadable" in captured.err
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
def test_added_lines_for_staged_file_returns_parsed_result_when_diff_is_non_empty_even_if_parse_returns_empty(
|
|
486
|
-
temporary_git_repository: Path,
|
|
487
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
488
|
-
) -> None:
|
|
489
|
-
write_file(
|
|
490
|
-
temporary_git_repository / "sample.py",
|
|
491
|
-
"alpha = 1\nbeta = 2\n",
|
|
492
|
-
)
|
|
493
|
-
commit_all_files(temporary_git_repository, "baseline")
|
|
494
|
-
write_file(temporary_git_repository / "sample.py", "alpha = 1\nbeta = 2\ngamma = 3\n")
|
|
495
|
-
stage_file(temporary_git_repository, "sample.py")
|
|
496
|
-
|
|
497
|
-
monkeypatch.setattr(gate_module, "parse_added_line_numbers", lambda _text: set())
|
|
498
|
-
|
|
499
|
-
added_line_numbers = gate_module.added_lines_for_staged_file(
|
|
500
|
-
temporary_git_repository,
|
|
501
|
-
"sample.py",
|
|
502
|
-
)
|
|
503
|
-
|
|
504
|
-
assert added_line_numbers == set()
|
|
505
|
-
|
|
1
|
+
"""Smoke tests for the skill-path code_rules_gate wrap.
|
|
506
2
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
preamble = "".join("anchor_name\n" for _ in range(leading_lines))
|
|
511
|
-
body = "\n".join(" keep_alive_name" for _ in range(body_line_count))
|
|
512
|
-
return f"{preamble}def {function_name}() -> None:\n{body}\n"
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
def test_split_violations_blocks_function_length_when_span_intersects_added_lines() -> None:
|
|
516
|
-
"""A function-length issue whose declared span overlaps the diff's added
|
|
517
|
-
lines is blocking — the body grew, which is the regression intent."""
|
|
518
|
-
validate_content = gate_module.load_validate_content()
|
|
519
|
-
long_function = _build_function_module(
|
|
520
|
-
"oversized", body_line_count=70, leading_lines=3
|
|
521
|
-
)
|
|
522
|
-
issues = validate_content(long_function, "src/long_module.py", "")
|
|
523
|
-
function_length_issues = [
|
|
524
|
-
each_issue for each_issue in issues if "blocking threshold" in each_issue
|
|
525
|
-
]
|
|
526
|
-
assert function_length_issues, f"expected a function-length issue, got {issues!r}"
|
|
527
|
-
span_def_line = 4
|
|
528
|
-
inside_span_line = span_def_line + 10
|
|
529
|
-
blocking, advisory = gate_module.split_violations_by_scope(
|
|
530
|
-
function_length_issues,
|
|
531
|
-
all_added_line_numbers={inside_span_line},
|
|
532
|
-
)
|
|
533
|
-
assert blocking == function_length_issues
|
|
534
|
-
assert advisory == []
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
def test_split_violations_advises_function_length_when_span_misses_added_lines() -> None:
|
|
538
|
-
"""A function-length issue for an untouched pre-existing function — whose
|
|
539
|
-
declared span does not overlap any added line — is advisory, not blocking.
|
|
540
|
-
Prevents the over-block regression where every pre-existing long function
|
|
541
|
-
in a touched file was forced into the blocking payload."""
|
|
542
|
-
validate_content = gate_module.load_validate_content()
|
|
543
|
-
long_function = _build_function_module(
|
|
544
|
-
"oversized", body_line_count=70, leading_lines=3
|
|
545
|
-
)
|
|
546
|
-
issues = validate_content(long_function, "src/long_module.py", "")
|
|
547
|
-
function_length_issues = [
|
|
548
|
-
each_issue for each_issue in issues if "blocking threshold" in each_issue
|
|
549
|
-
]
|
|
550
|
-
assert function_length_issues, f"expected a function-length issue, got {issues!r}"
|
|
551
|
-
line_far_outside_span = 5000
|
|
552
|
-
blocking, advisory = gate_module.split_violations_by_scope(
|
|
553
|
-
function_length_issues,
|
|
554
|
-
all_added_line_numbers={line_far_outside_span},
|
|
555
|
-
)
|
|
556
|
-
assert advisory == function_length_issues
|
|
557
|
-
assert blocking == []
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
def _isolation_issues_for_home_probe_test() -> list[str]:
|
|
561
|
-
validate_content = gate_module.load_validate_content()
|
|
562
|
-
header = "from pathlib import Path\n"
|
|
563
|
-
test_body = (
|
|
564
|
-
"def test_reads_home() -> None:\n"
|
|
565
|
-
" target_path = Path.home()\n"
|
|
566
|
-
" assert target_path\n"
|
|
567
|
-
)
|
|
568
|
-
issues = validate_content(header + test_body, "src/test_module.py", "")
|
|
569
|
-
return [each_issue for each_issue in issues if "probes" in each_issue]
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
def test_split_violations_blocks_isolation_when_function_span_intersects_added_lines() -> None:
|
|
573
|
-
"""An isolation issue whose enclosing test-function span overlaps the diff's
|
|
574
|
-
added lines is blocking — a signature-line change that un-isolates an
|
|
575
|
-
unchanged-body probe must block, matching the enforcer's terminal path."""
|
|
576
|
-
isolation_issues = _isolation_issues_for_home_probe_test()
|
|
577
|
-
assert isolation_issues, "expected an isolation issue from the HOME probe test"
|
|
578
|
-
signature_line = 2
|
|
579
|
-
blocking, advisory = gate_module.split_violations_by_scope(
|
|
580
|
-
isolation_issues,
|
|
581
|
-
all_added_line_numbers={signature_line},
|
|
582
|
-
)
|
|
583
|
-
assert blocking == isolation_issues
|
|
584
|
-
assert advisory == []
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
def test_split_violations_advises_isolation_when_function_span_misses_added_lines() -> None:
|
|
588
|
-
"""An isolation issue for an untouched pre-existing probe — whose enclosing
|
|
589
|
-
test-function span does not overlap any added line — is advisory, not
|
|
590
|
-
blocking, mirroring the function-length scope contract."""
|
|
591
|
-
isolation_issues = _isolation_issues_for_home_probe_test()
|
|
592
|
-
assert isolation_issues, "expected an isolation issue from the HOME probe test"
|
|
593
|
-
line_far_outside_span = 5000
|
|
594
|
-
blocking, advisory = gate_module.split_violations_by_scope(
|
|
595
|
-
isolation_issues,
|
|
596
|
-
all_added_line_numbers={line_far_outside_span},
|
|
597
|
-
)
|
|
598
|
-
assert advisory == isolation_issues
|
|
599
|
-
assert blocking == []
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
def _oversized_function_text(function_name: str) -> str:
|
|
603
|
-
body = "\n".join(" keep_alive_name" for _ in range(70))
|
|
604
|
-
return f"def {function_name}() -> None:\n{body}\n"
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
def _short_function_text(function_name: str) -> str:
|
|
608
|
-
return f"def {function_name}() -> None:\n keep_alive_name\n"
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
def test_main_blocks_sixth_long_function_on_added_lines_past_document_order(
|
|
612
|
-
temporary_git_repository: Path,
|
|
613
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
614
|
-
) -> None:
|
|
615
|
-
"""bugbot-2: with five pre-existing untouched long functions ahead of it in
|
|
616
|
-
document order, growing the sixth function past the threshold on staged
|
|
617
|
-
lines must still block at the bugteam gate. The gate scopes by added lines,
|
|
618
|
-
so the in-scope sixth violation blocks regardless of how many untouched
|
|
619
|
-
ones precede it."""
|
|
620
|
-
leading_long_functions = "".join(
|
|
621
|
-
_oversized_function_text(f"leading_long_{each_index}")
|
|
622
|
-
for each_index in range(5)
|
|
623
|
-
)
|
|
624
|
-
baseline = leading_long_functions + _short_function_text("target_function")
|
|
625
|
-
write_file(temporary_git_repository / "module.py", baseline)
|
|
626
|
-
commit_all_files(temporary_git_repository, "five long functions plus a short sixth")
|
|
627
|
-
|
|
628
|
-
grown = leading_long_functions + _oversized_function_text("target_function")
|
|
629
|
-
write_file(temporary_git_repository / "module.py", grown)
|
|
630
|
-
stage_file(temporary_git_repository, "module.py")
|
|
631
|
-
|
|
632
|
-
monkeypatch.chdir(temporary_git_repository)
|
|
633
|
-
exit_code = gate_module.main(["--staged"])
|
|
634
|
-
|
|
635
|
-
assert exit_code == 1, (
|
|
636
|
-
"the sixth long function — the only one on staged lines — must block "
|
|
637
|
-
"even though five untouched long functions precede it in document order"
|
|
638
|
-
)
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
def _home_probe_test_text(test_name: str) -> str:
|
|
642
|
-
return (
|
|
643
|
-
f"def {test_name}() -> None:\n"
|
|
644
|
-
" target_path = Path.home()\n"
|
|
645
|
-
" assert target_path\n"
|
|
646
|
-
)
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
def _clean_test_text(test_name: str) -> str:
|
|
650
|
-
return f"def {test_name}() -> None:\n assert 1 + 1 == 2\n"
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
def test_main_blocks_sixth_isolation_probe_on_added_lines_past_document_order(
|
|
654
|
-
temporary_git_repository: Path,
|
|
655
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
656
|
-
) -> None:
|
|
657
|
-
"""bugbot-2 mirror: with five pre-existing untouched HOME probes ahead of it
|
|
658
|
-
in document order, adding a HOME probe to the sixth test on staged lines
|
|
659
|
-
must still block at the bugteam gate. The gate scopes by added lines, so the
|
|
660
|
-
in-scope sixth probe blocks regardless of how many untouched ones precede
|
|
661
|
-
it."""
|
|
662
|
-
header = "from pathlib import Path\n"
|
|
663
|
-
leading_probe_tests = "".join(
|
|
664
|
-
_home_probe_test_text(f"test_leading_probe_{each_index}")
|
|
665
|
-
for each_index in range(5)
|
|
666
|
-
)
|
|
667
|
-
baseline = header + leading_probe_tests + _clean_test_text("test_target_probe")
|
|
668
|
-
write_file(temporary_git_repository / "test_module.py", baseline)
|
|
669
|
-
commit_all_files(temporary_git_repository, "five probe tests plus a clean sixth")
|
|
670
|
-
|
|
671
|
-
grown = header + leading_probe_tests + _home_probe_test_text("test_target_probe")
|
|
672
|
-
write_file(temporary_git_repository / "test_module.py", grown)
|
|
673
|
-
stage_file(temporary_git_repository, "test_module.py")
|
|
674
|
-
|
|
675
|
-
monkeypatch.chdir(temporary_git_repository)
|
|
676
|
-
exit_code = gate_module.main(["--staged"])
|
|
677
|
-
|
|
678
|
-
assert exit_code == 1, (
|
|
679
|
-
"the sixth HOME probe — the only one on staged lines — must block even "
|
|
680
|
-
"though five untouched probes precede it in document order"
|
|
681
|
-
)
|
|
3
|
+
Behavioral coverage lives at
|
|
4
|
+
``_shared/pr-loop/scripts/tests/test_code_rules_gate.py``.
|
|
5
|
+
"""
|
|
682
6
|
|
|
7
|
+
from __future__ import annotations
|
|
683
8
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
f" return canned_results\n"
|
|
688
|
-
)
|
|
9
|
+
import importlib.util
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from types import ModuleType
|
|
689
12
|
|
|
690
13
|
|
|
691
|
-
def
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
of it in document order, introducing a fourth banned-noun on a staged line
|
|
697
|
-
must still block at the bugteam gate. The gate scopes by added lines, so the
|
|
698
|
-
in-scope identifier blocks regardless of how many untouched ones precede
|
|
699
|
-
it."""
|
|
700
|
-
leading_count = 3
|
|
701
|
-
leading_functions = "".join(
|
|
702
|
-
_banned_noun_function_text(each_index) for each_index in range(leading_count)
|
|
14
|
+
def _load_wrap_module() -> ModuleType:
|
|
15
|
+
module_path = Path(__file__).parent / "bugteam_code_rules_gate.py"
|
|
16
|
+
specification = importlib.util.spec_from_file_location(
|
|
17
|
+
"bugteam_code_rules_gate",
|
|
18
|
+
module_path,
|
|
703
19
|
)
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
write_file(temporary_git_repository / "module.py", grown)
|
|
710
|
-
stage_file(temporary_git_repository, "module.py")
|
|
20
|
+
assert specification is not None
|
|
21
|
+
assert specification.loader is not None
|
|
22
|
+
wrap_module = importlib.util.module_from_spec(specification)
|
|
23
|
+
specification.loader.exec_module(wrap_module)
|
|
24
|
+
return wrap_module
|
|
711
25
|
|
|
712
|
-
monkeypatch.chdir(temporary_git_repository)
|
|
713
|
-
exit_code = gate_module.main(["--staged"])
|
|
714
26
|
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
27
|
+
def test_wrap_main_passes_clean_python_file(tmp_path: Path) -> None:
|
|
28
|
+
clean_module_path = tmp_path / "clean_module.py"
|
|
29
|
+
clean_module_path.write_text(
|
|
30
|
+
"def greet() -> str:\n return 'hi'\n",
|
|
31
|
+
encoding="utf-8",
|
|
718
32
|
)
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
"""No blocking violations and no skipped files yields a zero exit code."""
|
|
723
|
-
exit_code = gate_module._report_partitioned_violations(
|
|
724
|
-
blocking_by_file={},
|
|
725
|
-
advisory_by_file={tmp_path / "a.py": ["Line 1: advisory only"]},
|
|
726
|
-
repository_root=tmp_path,
|
|
727
|
-
is_whole_file_scope=False,
|
|
728
|
-
skipped_unreadable_count=0,
|
|
33
|
+
wrap_module = _load_wrap_module()
|
|
34
|
+
exit_code = wrap_module.main(
|
|
35
|
+
["--repo-root", str(tmp_path), str(clean_module_path.name)],
|
|
729
36
|
)
|
|
730
37
|
assert exit_code == 0
|
|
731
38
|
|
|
732
39
|
|
|
733
|
-
def
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
)
|
|
742
|
-
assert exit_code == 1
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
def test_report_partitioned_violations_returns_one_when_file_skipped(tmp_path: Path) -> None:
|
|
746
|
-
"""A skipped unreadable file forces a non-zero exit even with no blocking
|
|
747
|
-
violations, because the gate cannot vouch for the file it could not read."""
|
|
748
|
-
exit_code = gate_module._report_partitioned_violations(
|
|
749
|
-
blocking_by_file={},
|
|
750
|
-
advisory_by_file={},
|
|
751
|
-
repository_root=tmp_path,
|
|
752
|
-
is_whole_file_scope=False,
|
|
753
|
-
skipped_unreadable_count=1,
|
|
754
|
-
)
|
|
755
|
-
assert exit_code == 1
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
def test_check_wrapper_plumb_through_skips_class_methods_calling_module_delegate() -> None:
|
|
759
|
-
"""A class method calling a module-level delegate is not a wrapper; its
|
|
760
|
-
signature is unrelated to the delegate's keyword surface, so it must not be
|
|
761
|
-
flagged — matching the _shared gate copy."""
|
|
762
|
-
source = (
|
|
763
|
-
"def fetch(target, *, retries=3):\n"
|
|
764
|
-
" return target\n"
|
|
765
|
-
"\n"
|
|
766
|
-
"class MyService:\n"
|
|
767
|
-
" def public_method(self, target):\n"
|
|
768
|
-
" return fetch(target)\n"
|
|
769
|
-
)
|
|
770
|
-
issues = gate_module.check_wrapper_plumb_through(source, "module.py")
|
|
771
|
-
assert issues == [], (
|
|
772
|
-
f"class methods must not be treated as module-level wrappers; got {issues!r}"
|
|
773
|
-
)
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
def test_check_wrapper_plumb_through_flags_name_call_dropping_kwarg() -> None:
|
|
777
|
-
"""A bare-name call (``delegate(value)``) to a same-file delegate that
|
|
778
|
-
exposes an optional kwarg the public wrapper omits must be flagged — the
|
|
779
|
-
bugteam copy must handle ``ast.Name`` targets, not only ``ast.Attribute``."""
|
|
780
|
-
source = (
|
|
781
|
-
"def delegate(value, *, retries=3):\n"
|
|
782
|
-
" return value\n"
|
|
783
|
-
"\n"
|
|
784
|
-
"def public_wrapper(value):\n"
|
|
785
|
-
" return delegate(value)\n"
|
|
786
|
-
)
|
|
787
|
-
issues = gate_module.check_wrapper_plumb_through(source, "module.py")
|
|
788
|
-
assert any("retries" in each_issue for each_issue in issues), (
|
|
789
|
-
f"a bare-name delegate call dropping an optional kwarg must flag; got {issues!r}"
|
|
790
|
-
)
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
def test_check_wrapper_plumb_through_exempts_test_files() -> None:
|
|
794
|
-
"""A test_* function in a test-file path that calls a module-level helper
|
|
795
|
-
exposing an optional kwarg is an ordinary pytest case, not a wrapper; the
|
|
796
|
-
bugteam gate must exempt test files and emit zero findings."""
|
|
797
|
-
source = (
|
|
798
|
-
"def _helper(name, *, clean_name=None):\n"
|
|
799
|
-
" return (name, clean_name)\n"
|
|
800
|
-
"\n"
|
|
801
|
-
"def test_uses_helper():\n"
|
|
802
|
-
" return _helper('a', clean_name='b')\n"
|
|
803
|
-
)
|
|
804
|
-
issues = gate_module.check_wrapper_plumb_through(source, "pkg/test_thing.py")
|
|
805
|
-
assert issues == [], (
|
|
806
|
-
f"a wrapper shape in a test file must yield no findings; got {issues!r}"
|
|
807
|
-
)
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
def test_check_wrapper_plumb_through_still_flags_non_test_path_with_test_shape() -> None:
|
|
811
|
-
"""The test-file exemption is scoped to test paths only; the same wrapper
|
|
812
|
-
shape on a non-test path must still be flagged."""
|
|
813
|
-
source = (
|
|
814
|
-
"def _helper(name, *, clean_name=None):\n"
|
|
815
|
-
" return (name, clean_name)\n"
|
|
816
|
-
"\n"
|
|
817
|
-
"def test_uses_helper():\n"
|
|
818
|
-
" return _helper('a', clean_name='b')\n"
|
|
819
|
-
)
|
|
820
|
-
issues = gate_module.check_wrapper_plumb_through(source, "pkg/module.py")
|
|
821
|
-
assert any(
|
|
822
|
-
"test_uses_helper" in each_issue and "clean_name" in each_issue
|
|
823
|
-
for each_issue in issues
|
|
824
|
-
), f"the same wrapper shape on a non-test path must still flag; got {issues!r}"
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
def test_check_wrapper_plumb_through_ignores_calls_nested_inside_delegate_arguments() -> None:
|
|
828
|
-
"""A callee nested as an argument (``delegate(helper(x))``) is not a
|
|
829
|
-
separate call site; only the enclosing call is inspected, matching the
|
|
830
|
-
_shared gate copy."""
|
|
831
|
-
source = (
|
|
832
|
-
"def delegate(value, *, retries=3):\n"
|
|
833
|
-
" return value\n"
|
|
834
|
-
"\n"
|
|
835
|
-
"def helper(value):\n"
|
|
836
|
-
" return value\n"
|
|
837
|
-
"\n"
|
|
838
|
-
"def public_caller(value):\n"
|
|
839
|
-
" return delegate(helper(value))\n"
|
|
840
|
-
)
|
|
841
|
-
issues = gate_module.check_wrapper_plumb_through(source, "module.py")
|
|
842
|
-
assert all("helper" not in each_issue for each_issue in issues), (
|
|
843
|
-
f"nested-argument callee must not be a separate call site; got {issues!r}"
|
|
844
|
-
)
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
def test_main_staged_mode_blocks_newly_staged_inline_comment(
|
|
848
|
-
temporary_git_repository: Path,
|
|
849
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
850
|
-
) -> None:
|
|
851
|
-
"""A NEWLY STAGED inline comment must be detected as a new comment in
|
|
852
|
-
``--staged`` mode. This proves the gate passes the HEAD-committed content as
|
|
853
|
-
``old_content`` to the comparison validators (the prior base), not the
|
|
854
|
-
current working-tree content. When the current file content is reused as
|
|
855
|
-
``old_content``, ``check_comment_changes`` sees identical old/new text and
|
|
856
|
-
misses the staged comment entirely, so the gate exits 0 — the regression."""
|
|
857
|
-
write_file(
|
|
858
|
-
temporary_git_repository / "module.py",
|
|
859
|
-
'def describe_state() -> str:\n return "ready"\n',
|
|
860
|
-
)
|
|
861
|
-
commit_all_files(temporary_git_repository, "initial without comment")
|
|
862
|
-
staged_content_with_new_comment = (
|
|
863
|
-
"def describe_state() -> str:\n"
|
|
864
|
-
' label = "ready" # newly staged inline comment\n'
|
|
865
|
-
" return label\n"
|
|
866
|
-
)
|
|
867
|
-
write_file(
|
|
868
|
-
temporary_git_repository / "module.py",
|
|
869
|
-
staged_content_with_new_comment,
|
|
870
|
-
)
|
|
871
|
-
stage_file(temporary_git_repository, "module.py")
|
|
872
|
-
|
|
873
|
-
monkeypatch.chdir(temporary_git_repository)
|
|
874
|
-
exit_code = gate_module.main(["--staged"])
|
|
875
|
-
|
|
876
|
-
assert exit_code == 1, (
|
|
877
|
-
"a newly staged inline comment must block; if it does not, the gate is "
|
|
878
|
-
"passing the current file content as old_content instead of the "
|
|
879
|
-
"HEAD-committed prior base, so check_comment_changes sees identical "
|
|
880
|
-
"old/new text and misses the staged comment"
|
|
881
|
-
)
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
def test_check_wrapper_plumb_through_stays_under_function_length_threshold() -> None:
|
|
885
|
-
"""check_wrapper_plumb_through must stay under the enforcer's function-length
|
|
886
|
-
blocking threshold so its signature-index, class-method-id, and per-wrapper
|
|
887
|
-
finding logic live in extracted helpers, matching the _shared gate copy."""
|
|
888
|
-
declared_line_count = len(
|
|
889
|
-
inspect.getsource(gate_module.check_wrapper_plumb_through).splitlines()
|
|
890
|
-
)
|
|
891
|
-
blocking_threshold = 60
|
|
892
|
-
assert declared_line_count < blocking_threshold, (
|
|
893
|
-
f"check_wrapper_plumb_through is {declared_line_count} lines; extract "
|
|
894
|
-
"helpers to keep it under the function-length blocking threshold"
|
|
895
|
-
)
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
def test_read_prior_committed_content_returns_head_content_for_tracked_path(
|
|
899
|
-
temporary_git_repository: Path,
|
|
900
|
-
) -> None:
|
|
901
|
-
"""A tracked path returns its HEAD-committed content, not the working copy."""
|
|
902
|
-
committed_text = "alpha = 1\nbeta = 2\n"
|
|
903
|
-
write_file(temporary_git_repository / "tracked.py", committed_text)
|
|
904
|
-
commit_all_files(temporary_git_repository, "commit tracked file")
|
|
905
|
-
write_file(
|
|
906
|
-
temporary_git_repository / "tracked.py",
|
|
907
|
-
committed_text + "gamma = 3\n",
|
|
908
|
-
)
|
|
909
|
-
|
|
910
|
-
prior_content = gate_module.read_prior_committed_content(
|
|
911
|
-
temporary_git_repository.resolve(), "tracked.py"
|
|
912
|
-
)
|
|
913
|
-
|
|
914
|
-
assert prior_content == committed_text
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
def test_read_prior_committed_content_returns_empty_for_untracked_path(
|
|
918
|
-
temporary_git_repository: Path,
|
|
919
|
-
) -> None:
|
|
920
|
-
"""An untracked path yields an empty string because git show returns non-zero."""
|
|
921
|
-
write_file(temporary_git_repository / "anchor.py", "anchor = 1\n")
|
|
922
|
-
commit_all_files(temporary_git_repository, "anchor commit")
|
|
923
|
-
|
|
924
|
-
prior_content = gate_module.read_prior_committed_content(
|
|
925
|
-
temporary_git_repository.resolve(), "never_committed.py"
|
|
926
|
-
)
|
|
927
|
-
|
|
928
|
-
assert prior_content == ""
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
def test_main_staged_mode_validates_staged_blob_not_working_tree(
|
|
932
|
-
temporary_git_repository: Path,
|
|
933
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
934
|
-
) -> None:
|
|
935
|
-
"""Staged mode validates the staged blob, not the working tree.
|
|
936
|
-
|
|
937
|
-
A blocking violation lives in the staged blob, but the working tree has
|
|
938
|
-
been edited afterward to remove it. The gate must still block because it
|
|
939
|
-
scopes added lines from the staged index and must read its content from
|
|
940
|
-
the same staged source rather than the diverged working tree.
|
|
941
|
-
"""
|
|
942
|
-
write_file(temporary_git_repository / "module.py", "first_count = 1\n")
|
|
943
|
-
commit_all_files(temporary_git_repository, "initial")
|
|
944
|
-
staged_content_with_banned_identifier = (
|
|
945
|
-
"first_count = 1\n"
|
|
946
|
-
"def compute_total(operand):\n"
|
|
947
|
-
" result = operand + 1\n"
|
|
948
|
-
" return result\n"
|
|
949
|
-
)
|
|
950
|
-
write_file(
|
|
951
|
-
temporary_git_repository / "module.py",
|
|
952
|
-
staged_content_with_banned_identifier,
|
|
953
|
-
)
|
|
954
|
-
stage_file(temporary_git_repository, "module.py")
|
|
955
|
-
clean_working_tree_content = (
|
|
956
|
-
"first_count = 1\n"
|
|
957
|
-
"def compute_total(operand: int) -> int:\n"
|
|
958
|
-
" return operand + 1\n"
|
|
959
|
-
)
|
|
960
|
-
write_file(
|
|
961
|
-
temporary_git_repository / "module.py",
|
|
962
|
-
clean_working_tree_content,
|
|
963
|
-
)
|
|
964
|
-
|
|
965
|
-
monkeypatch.chdir(temporary_git_repository)
|
|
966
|
-
exit_code = gate_module.main(["--staged"])
|
|
967
|
-
|
|
968
|
-
assert exit_code == 1, (
|
|
969
|
-
"the staged blob carries a blocking violation; the gate must block "
|
|
970
|
-
"even though the working tree was edited clean afterward"
|
|
971
|
-
)
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
def test_main_staged_mode_blocks_when_staged_file_absent_from_working_tree(
|
|
975
|
-
temporary_git_repository: Path,
|
|
976
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
977
|
-
) -> None:
|
|
978
|
-
"""A staged blocking violation must block even when the working tree file
|
|
979
|
-
is gone. Staging a violating file and then deleting it from the working
|
|
980
|
-
tree leaves the violation only in the staged blob; the gate must validate
|
|
981
|
-
that blob rather than skip the path for failing a working-tree existence
|
|
982
|
-
check."""
|
|
983
|
-
write_file(temporary_git_repository / "baseline.py", "first_count = 1\n")
|
|
984
|
-
commit_all_files(temporary_git_repository, "initial")
|
|
985
|
-
staged_content_with_banned_identifier = (
|
|
986
|
-
"def compute_total(operand):\n"
|
|
987
|
-
" result = operand + 1\n"
|
|
988
|
-
" return result\n"
|
|
989
|
-
)
|
|
990
|
-
write_file(
|
|
991
|
-
temporary_git_repository / "module.py",
|
|
992
|
-
staged_content_with_banned_identifier,
|
|
993
|
-
)
|
|
994
|
-
stage_file(temporary_git_repository, "module.py")
|
|
995
|
-
(temporary_git_repository / "module.py").unlink()
|
|
996
|
-
|
|
997
|
-
monkeypatch.chdir(temporary_git_repository)
|
|
998
|
-
exit_code = gate_module.main(["--staged"])
|
|
999
|
-
|
|
1000
|
-
assert exit_code == 1, (
|
|
1001
|
-
"the staged blob carries a blocking violation; the gate must block "
|
|
1002
|
-
"even though the file was deleted from the working tree after staging"
|
|
1003
|
-
)
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
def test_main_staged_mode_passes_on_staged_deletion_of_clean_file(
|
|
1007
|
-
temporary_git_repository: Path,
|
|
1008
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
1009
|
-
) -> None:
|
|
1010
|
-
"""A staged deletion is not in the index, so its staged blob cannot be read.
|
|
1011
|
-
The gate must skip such a path cleanly rather than counting it as an
|
|
1012
|
-
unreadable file and failing closed. With no other staged violation, the
|
|
1013
|
-
gate must exit zero."""
|
|
1014
|
-
write_file(temporary_git_repository / "removable.py", "first_count = 1\n")
|
|
1015
|
-
commit_all_files(temporary_git_repository, "initial")
|
|
1016
|
-
run_git_in_repository(temporary_git_repository, "rm", "--", "removable.py")
|
|
1017
|
-
|
|
1018
|
-
monkeypatch.chdir(temporary_git_repository)
|
|
1019
|
-
exit_code = gate_module.main(["--staged"])
|
|
1020
|
-
|
|
1021
|
-
assert exit_code == 0, (
|
|
1022
|
-
"a staged deletion has no staged blob; the gate must skip it cleanly "
|
|
1023
|
-
"rather than fail closed as if the file were unreadable"
|
|
1024
|
-
)
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
_INLINE_DUPLICATE_MESSAGE = (
|
|
1028
|
-
"Function '_wait_for_render' duplicates an inline block in '_navigate_then_wait'"
|
|
1029
|
-
" — this function body is also present inline (Reuse before create / DRY) "
|
|
1030
|
-
"(inline duplicate body spans: helper at line 4 spanning 10 lines, "
|
|
1031
|
-
"enclosing at line 16 spanning 11 lines)"
|
|
1032
|
-
)
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
def test_inline_duplicate_body_span_lines_unions_helper_and_enclosing_spans() -> None:
|
|
1036
|
-
"""The same-file inline-duplicate message carries both spans, and the gate
|
|
1037
|
-
recovers their union as a line-number set so a touch of either function blocks —
|
|
1038
|
-
mirroring the live Write/Edit hook's union scoping."""
|
|
1039
|
-
span_lines = gate_module.inline_duplicate_body_span_lines(_INLINE_DUPLICATE_MESSAGE)
|
|
1040
|
-
assert span_lines == frozenset(range(4, 14)) | frozenset(range(16, 27))
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
def test_inline_duplicate_blocks_when_only_enclosing_copy_added() -> None:
|
|
1044
|
-
"""An added line in the enclosing span alone blocks, because the live hook scopes
|
|
1045
|
-
by the union of both spans and blocks the same edit — the common shape where a
|
|
1046
|
-
block is copied INTO a growing enclosing function, leaving the helper untouched."""
|
|
1047
|
-
added_line_in_enclosing_only = 18
|
|
1048
|
-
blocking, advisory = gate_module.split_violations_by_scope(
|
|
1049
|
-
[_INLINE_DUPLICATE_MESSAGE],
|
|
1050
|
-
all_added_line_numbers={added_line_in_enclosing_only},
|
|
1051
|
-
)
|
|
1052
|
-
assert blocking == [_INLINE_DUPLICATE_MESSAGE]
|
|
1053
|
-
assert advisory == []
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
def test_inline_duplicate_advises_when_gap_line_added() -> None:
|
|
1057
|
-
"""An edit confined to the gap between the helper span (4-13) and the enclosing
|
|
1058
|
-
span (16-26) must not block, matching the live hook. The union set keeps the gap
|
|
1059
|
-
out of scope where a single contiguous range would wrongly block it."""
|
|
1060
|
-
gap_line_between_spans = 14
|
|
1061
|
-
blocking, advisory = gate_module.split_violations_by_scope(
|
|
1062
|
-
[_INLINE_DUPLICATE_MESSAGE],
|
|
1063
|
-
all_added_line_numbers={gap_line_between_spans},
|
|
1064
|
-
)
|
|
1065
|
-
assert advisory == [_INLINE_DUPLICATE_MESSAGE]
|
|
1066
|
-
assert blocking == []
|
|
40
|
+
def test_wrap_main_help_exits_cleanly() -> None:
|
|
41
|
+
wrap_module = _load_wrap_module()
|
|
42
|
+
try:
|
|
43
|
+
wrap_module.main(["--help"])
|
|
44
|
+
except SystemExit as exit_signal:
|
|
45
|
+
assert exit_signal.code in (0, None)
|
|
46
|
+
return
|
|
47
|
+
raise AssertionError("main(['--help']) must raise SystemExit")
|