claude-dev-env 2.5.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +20 -57
- package/_shared/pr-loop/scripts/code_rules_gate.py +2 -1
- package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +12 -2
- package/_shared/pr-loop/scripts/code_rules_gate_parts/baseline_import_isolation.py +309 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +540 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +206 -70
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/__init__.py +1 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/_repo_test_helpers.py +76 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_baseline_import_isolation.py +248 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +309 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +91 -58
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +202 -0
- package/agents/CLAUDE.md +1 -1
- package/agents/code-verifier.md +36 -7
- package/bin/codex-compat.mjs +104 -0
- package/bin/codex-compat.test.mjs +51 -0
- package/codex-capability-map.json +13 -0
- package/docs/CODE_RULES.md +2 -0
- package/docs/codex-compatibility.md +25 -0
- package/docs/nas-ssh-invocation.md +96 -12
- package/docs/references/code-review-enforcement.md +31 -6
- package/hooks/blocking/CLAUDE.md +3 -0
- package/hooks/blocking/config/code_review_enforcement_constants.py +40 -10
- package/hooks/blocking/config/test_code_review_enforcement_constants.py +56 -3
- package/hooks/blocking/eli11_reply_enforcer.py +479 -0
- package/hooks/blocking/gh_body_arg_blocker.py +1 -1
- package/hooks/blocking/nas_ssh_binary_enforcer.py +8 -46
- package/hooks/blocking/shell_substitution_blocker.py +129 -0
- package/hooks/blocking/state_description_blocker.py +1 -1
- package/hooks/blocking/stop_dispatcher.py +1 -1
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +2 -3
- package/hooks/blocking/test_eli11_reply_enforcer.py +457 -0
- package/hooks/blocking/test_shell_substitution_blocker.py +124 -0
- package/hooks/blocking/test_stop_dispatcher.py +23 -0
- package/hooks/blocking/test_unscoped_search_blocker.py +102 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +4 -8
- package/hooks/blocking/unscoped_search_blocker.py +391 -0
- package/hooks/git-hooks/CLAUDE.md +3 -0
- package/hooks/git-hooks/conftest.py +30 -0
- package/hooks/git-hooks/gate_utils.py +2 -2
- package/hooks/git-hooks/git_hooks_constants/__init__.py +41 -2
- package/hooks/git-hooks/pre_push.py +75 -4
- package/hooks/git-hooks/pre_push_base_reference.py +166 -0
- package/hooks/git-hooks/test_config.py +0 -15
- package/hooks/git-hooks/test_gate_utils.py +3 -15
- package/hooks/git-hooks/test_pre_commit.py +1 -15
- package/hooks/git-hooks/test_pre_push.py +236 -27
- package/hooks/git-hooks/test_pre_push_base_reference.py +339 -0
- package/hooks/hooks.json +0 -12
- package/hooks/hooks_constants/CLAUDE.md +5 -1
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -4
- package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +101 -0
- package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +2 -8
- package/hooks/hooks_constants/shell_command_segments.py +82 -0
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +67 -0
- package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -6
- package/hooks/hooks_constants/test_stop_dispatcher_constants.py +1 -0
- package/hooks/hooks_constants/unscoped_search_blocker_constants.py +153 -0
- package/package.json +4 -2
- package/rules/CLAUDE.md +17 -23
- package/rules/agent-spawn-protocol.md +6 -6
- package/rules/anti-corollary-tests.md +1 -1
- package/rules/bdd.md +1 -1
- package/rules/cleanup-temp-files.md +10 -4
- package/rules/code-standards.md +7 -0
- package/rules/conservative-action.md +1 -5
- package/rules/context7.md +0 -4
- package/rules/destructive-commands.md +47 -0
- package/rules/doc-inventory-integrity.md +48 -0
- package/rules/doc-prose-cuts.md +58 -0
- package/rules/docstring-prose-matches-implementation.md +10 -2
- package/rules/durable-post-artifacts.md +0 -4
- package/rules/eli11-replies.md +31 -0
- package/rules/explore-thoroughly.md +4 -4
- package/rules/falsify-before-green.md +68 -0
- package/rules/file-global-constants.md +1 -1
- package/rules/filesystem-search.md +51 -0
- package/rules/gh-cli-conventions.md +27 -0
- package/rules/git-workflow.md +26 -0
- package/rules/hedging-claims.md +9 -0
- package/rules/long-horizon-autonomy.md +0 -4
- package/rules/measurement-denominators.md +48 -0
- package/rules/nas-ssh-invocation.md +23 -5
- package/rules/parallel-tools.md +2 -2
- package/rules/plain-illustrative-docstrings.md +3 -7
- package/rules/plain-language.md +2 -0
- package/rules/proof-of-work-pr-comments.md +0 -4
- package/rules/re-stage-before-commit.md +2 -0
- package/rules/research-mode.md +10 -0
- package/rules/shell-invocation.md +21 -0
- package/rules/testing.md +4 -0
- package/rules/verified-commit-gate-skip.md +3 -27
- package/rules/verify-before-asking.md +5 -0
- package/rules/windows-filesystem-safe.md +1 -1
- package/rules/workers-done-before-complete.md +4 -0
- package/scripts/Migrate-ShellPolicy.ps1 +1 -1
- package/scripts/codex_capability_bridge.py +171 -0
- package/scripts/codex_compat_materializer.py +1087 -0
- package/scripts/codex_compat_watcher.py +502 -0
- package/scripts/dev_env_scripts_constants/code_review_constants.py +37 -0
- package/scripts/invoke_code_review.py +11 -4
- package/scripts/sync_to_cursor/rules.py +0 -10
- package/scripts/test_invoke_code_review.py +143 -0
- package/scripts/test_invoke_code_review_chain.py +1 -1
- package/scripts/test_invoke_code_review_contract.py +1 -1
- package/scripts/tests/test_code_review_constants.py +80 -0
- package/scripts/tests/test_codex_capability_bridge.py +91 -0
- package/scripts/tests/test_codex_compat_materializer.py +632 -0
- package/scripts/tests/test_codex_compat_watcher.py +599 -0
- package/scripts/tests/test_sync_to_cursor.py +0 -1
- package/skills/autoconverge/workflow/converge.mjs +1 -1
- package/skills/bugteam/reference/copilot-gap-analysis.md +1 -1
- package/skills/condensing-instructions/SKILL.md +42 -51
- package/skills/fresh-branch/CLAUDE.md +1 -1
- package/skills/fresh-branch/SKILL.md +5 -6
- package/skills/fresh-branch/scripts/create_fresh_branch.py +42 -24
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -3
- package/skills/fresh-branch/scripts/test_create_fresh_branch.py +30 -126
- package/skills/orchestrator/SKILL.md +23 -9
- package/skills/orchestrator-refresh/SKILL.md +20 -1
- package/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
- package/skills/session-log/SKILL.md +1 -1
- package/rules/claude-md-orphan-file.md +0 -28
- package/rules/cleanup-command-forms.md +0 -23
- package/rules/code-reviews.md +0 -11
- package/rules/env-var-table-code-drift.md +0 -10
- package/rules/gh-body-file.md +0 -5
- package/rules/gh-paginate.md +0 -3
- package/rules/hook-prose-matches-detector.md +0 -15
- package/rules/no-historical-clutter.md +0 -26
- package/rules/no-inline-destructive-literals.md +0 -9
- package/rules/no-justification-noise.md +0 -61
- package/rules/package-inventory-stale-entry.md +0 -25
- package/rules/right-sized-engineering.md +0 -28
- package/rules/self-contained-docs.md +0 -17
- package/rules/shell-invocation-policy.md +0 -5
- package/rules/state-what-is.md +0 -25
- package/rules/tdd.md +0 -7
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Behavioral tests for the baseline_import_isolation parts module.
|
|
2
|
+
|
|
3
|
+
The import-root probe runs a real interpreter subprocess and the leak plugin is
|
|
4
|
+
written, imported, and called the way pytest calls it, so each test measures the
|
|
5
|
+
mechanism the live gate depends on rather than a stand-in for it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import importlib.util
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import types
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
from pr_loop_shared_constants.code_rules_gate_constants import (
|
|
17
|
+
BASELINE_LEAK_PLUGIN_MODULE_NAME,
|
|
18
|
+
BASELINE_LEAK_REPORT_ENV_VAR,
|
|
19
|
+
BASELINE_PRIMARY_ROOT_ENV_VAR,
|
|
20
|
+
PYTEST_PLUGINS_ENV_VAR,
|
|
21
|
+
PYTHONPATH_ENV_VAR,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from code_rules_gate_parts import baseline_import_isolation
|
|
25
|
+
|
|
26
|
+
UNREACHABLE_PROBE_TIMEOUT_SECONDS = 0.001
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def baseline_environment_for(
|
|
30
|
+
tmp_path: Path,
|
|
31
|
+
staged_environment: dict[str, str],
|
|
32
|
+
all_import_roots: list[Path],
|
|
33
|
+
) -> dict[str, str]:
|
|
34
|
+
"""Build a baseline environment against a /repo and /baseline pair under *tmp_path*."""
|
|
35
|
+
return baseline_import_isolation.baseline_pytest_environment(
|
|
36
|
+
staged_environment,
|
|
37
|
+
tmp_path / "repo",
|
|
38
|
+
tmp_path / "baseline",
|
|
39
|
+
all_import_roots,
|
|
40
|
+
tmp_path / "plugin",
|
|
41
|
+
tmp_path / "report.json",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_rebased_into_baseline_moves_a_repository_path_into_the_worktree(
|
|
46
|
+
tmp_path: Path,
|
|
47
|
+
) -> None:
|
|
48
|
+
rebased_path = baseline_import_isolation.rebased_into_baseline(
|
|
49
|
+
tmp_path / "repo" / "packages", tmp_path / "repo", tmp_path / "baseline"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
assert rebased_path == tmp_path / "baseline" / "packages"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_rebased_into_baseline_leaves_a_path_outside_the_repository_unclaimed(
|
|
56
|
+
tmp_path: Path,
|
|
57
|
+
) -> None:
|
|
58
|
+
rebased_path = baseline_import_isolation.rebased_into_baseline(
|
|
59
|
+
tmp_path / "elsewhere" / "library", tmp_path / "repo", tmp_path / "baseline"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
assert rebased_path is None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_baseline_pytest_environment_leads_with_the_rebased_import_roots(
|
|
66
|
+
tmp_path: Path,
|
|
67
|
+
) -> None:
|
|
68
|
+
baseline_environment = baseline_environment_for(
|
|
69
|
+
tmp_path, {}, [tmp_path / "repo" / "source", tmp_path / "elsewhere"]
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
all_entries = baseline_environment[PYTHONPATH_ENV_VAR].split(os.pathsep)
|
|
73
|
+
assert all_entries[0] == str(tmp_path / "baseline" / "source")
|
|
74
|
+
assert str(tmp_path / "elsewhere") not in all_entries
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_baseline_pytest_environment_rebases_a_repository_pythonpath_entry(
|
|
78
|
+
tmp_path: Path,
|
|
79
|
+
) -> None:
|
|
80
|
+
staged_environment = {PYTHONPATH_ENV_VAR: str(tmp_path / "repo" / "packages")}
|
|
81
|
+
|
|
82
|
+
baseline_environment = baseline_environment_for(tmp_path, staged_environment, [])
|
|
83
|
+
|
|
84
|
+
all_entries = baseline_environment[PYTHONPATH_ENV_VAR].split(os.pathsep)
|
|
85
|
+
assert str(tmp_path / "baseline" / "packages") in all_entries
|
|
86
|
+
assert str(tmp_path / "repo" / "packages") not in all_entries
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_baseline_pytest_environment_keeps_an_outside_pythonpath_entry(
|
|
90
|
+
tmp_path: Path,
|
|
91
|
+
) -> None:
|
|
92
|
+
staged_environment = {PYTHONPATH_ENV_VAR: str(tmp_path / "vendor")}
|
|
93
|
+
|
|
94
|
+
baseline_environment = baseline_environment_for(tmp_path, staged_environment, [])
|
|
95
|
+
|
|
96
|
+
assert str(tmp_path / "vendor") in baseline_environment[PYTHONPATH_ENV_VAR].split(
|
|
97
|
+
os.pathsep
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_baseline_pytest_environment_adds_the_leak_plugin_to_an_existing_list(
|
|
102
|
+
tmp_path: Path,
|
|
103
|
+
) -> None:
|
|
104
|
+
staged_environment = {PYTEST_PLUGINS_ENV_VAR: "project_plugin"}
|
|
105
|
+
|
|
106
|
+
baseline_environment = baseline_environment_for(tmp_path, staged_environment, [])
|
|
107
|
+
|
|
108
|
+
assert baseline_environment[PYTEST_PLUGINS_ENV_VAR] == (
|
|
109
|
+
f"{BASELINE_LEAK_PLUGIN_MODULE_NAME},project_plugin"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_discover_import_roots_reports_a_pythonpath_directory(tmp_path: Path) -> None:
|
|
114
|
+
library_directory = tmp_path / "library"
|
|
115
|
+
library_directory.mkdir()
|
|
116
|
+
probe_environment = dict(os.environ)
|
|
117
|
+
probe_environment[PYTHONPATH_ENV_VAR] = str(library_directory)
|
|
118
|
+
|
|
119
|
+
all_import_roots = baseline_import_isolation.discover_import_roots(
|
|
120
|
+
sys.executable, probe_environment, tmp_path
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
assert library_directory.resolve() in all_import_roots
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_discover_import_roots_gives_up_on_a_probe_that_runs_out_of_time(
|
|
127
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
128
|
+
) -> None:
|
|
129
|
+
monkeypatch.setattr(
|
|
130
|
+
baseline_import_isolation,
|
|
131
|
+
"BASELINE_IMPORT_PROBE_TIMEOUT_SECONDS",
|
|
132
|
+
UNREACHABLE_PROBE_TIMEOUT_SECONDS,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
all_import_roots = baseline_import_isolation.discover_import_roots(
|
|
136
|
+
sys.executable, dict(os.environ), tmp_path
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
assert all_import_roots == []
|
|
140
|
+
assert "import-root probe" in capsys.readouterr().err
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_discover_import_roots_gives_up_on_an_interpreter_that_cannot_start(
|
|
144
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
145
|
+
) -> None:
|
|
146
|
+
all_import_roots = baseline_import_isolation.discover_import_roots(
|
|
147
|
+
str(tmp_path / "no_such_interpreter"), dict(os.environ), tmp_path
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
assert all_import_roots == []
|
|
151
|
+
assert "import-root probe" in capsys.readouterr().err
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_discover_import_roots_reports_an_editable_install_package_root(
|
|
155
|
+
tmp_path: Path,
|
|
156
|
+
) -> None:
|
|
157
|
+
project_root = tmp_path / "project"
|
|
158
|
+
(project_root / "widget").mkdir(parents=True)
|
|
159
|
+
finder_directory = tmp_path / "finder"
|
|
160
|
+
finder_directory.mkdir()
|
|
161
|
+
(finder_directory / "sitecustomize.py").write_text(
|
|
162
|
+
"import sys\n"
|
|
163
|
+
f"MAPPING = {{'widget': r'{project_root / 'widget'}'}}\n\n\n"
|
|
164
|
+
"class EditableFinder:\n"
|
|
165
|
+
" @classmethod\n"
|
|
166
|
+
" def find_spec(cls, fullname, path=None, target=None):\n"
|
|
167
|
+
" return None\n\n\n"
|
|
168
|
+
"sys.meta_path.append(EditableFinder)\n",
|
|
169
|
+
encoding="utf-8",
|
|
170
|
+
)
|
|
171
|
+
probe_environment = dict(os.environ)
|
|
172
|
+
probe_environment[PYTHONPATH_ENV_VAR] = str(finder_directory)
|
|
173
|
+
|
|
174
|
+
all_import_roots = baseline_import_isolation.discover_import_roots(
|
|
175
|
+
sys.executable, probe_environment, tmp_path
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
assert project_root.resolve() in all_import_roots
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_modules_imported_from_primary_tree_is_unknown_without_a_report(
|
|
182
|
+
tmp_path: Path,
|
|
183
|
+
) -> None:
|
|
184
|
+
assert (
|
|
185
|
+
baseline_import_isolation.modules_imported_from_primary_tree(
|
|
186
|
+
tmp_path / "missing.json"
|
|
187
|
+
)
|
|
188
|
+
is None
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_modules_imported_from_primary_tree_reads_the_reported_paths(
|
|
193
|
+
tmp_path: Path,
|
|
194
|
+
) -> None:
|
|
195
|
+
report_path = tmp_path / "report.json"
|
|
196
|
+
report_path.write_text(json.dumps([str(tmp_path / "repo" / "foo.py")]), encoding="utf-8")
|
|
197
|
+
|
|
198
|
+
assert baseline_import_isolation.modules_imported_from_primary_tree(report_path) == [
|
|
199
|
+
tmp_path / "repo" / "foo.py"
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def load_installed_plugin(plugin_directory: Path) -> types.ModuleType:
|
|
204
|
+
"""Import the plugin module ``install_leak_plugin`` wrote into *plugin_directory*."""
|
|
205
|
+
plugin_path = plugin_directory / f"{BASELINE_LEAK_PLUGIN_MODULE_NAME}.py"
|
|
206
|
+
module_specification = importlib.util.spec_from_file_location(
|
|
207
|
+
BASELINE_LEAK_PLUGIN_MODULE_NAME, plugin_path
|
|
208
|
+
)
|
|
209
|
+
assert module_specification is not None and module_specification.loader is not None
|
|
210
|
+
plugin_module = importlib.util.module_from_spec(module_specification)
|
|
211
|
+
module_specification.loader.exec_module(plugin_module)
|
|
212
|
+
return plugin_module
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_installed_plugin_reports_a_module_loaded_from_the_primary_tree(
|
|
216
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
217
|
+
) -> None:
|
|
218
|
+
plugin_directory = tmp_path / "plugin"
|
|
219
|
+
baseline_import_isolation.install_leak_plugin(plugin_directory)
|
|
220
|
+
plugin_module = load_installed_plugin(plugin_directory)
|
|
221
|
+
primary_root = tmp_path / "repo"
|
|
222
|
+
leaked_module = types.ModuleType("leaked_widget")
|
|
223
|
+
leaked_module.__file__ = str(primary_root / "widget" / "__init__.py")
|
|
224
|
+
monkeypatch.setitem(sys.modules, "leaked_widget", leaked_module)
|
|
225
|
+
report_path = tmp_path / "report.json"
|
|
226
|
+
monkeypatch.setenv(BASELINE_PRIMARY_ROOT_ENV_VAR, str(primary_root))
|
|
227
|
+
monkeypatch.setenv(BASELINE_LEAK_REPORT_ENV_VAR, str(report_path))
|
|
228
|
+
|
|
229
|
+
plugin_module.pytest_sessionfinish(None, 0)
|
|
230
|
+
|
|
231
|
+
assert baseline_import_isolation.modules_imported_from_primary_tree(report_path) == [
|
|
232
|
+
primary_root / "widget" / "__init__.py"
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_installed_plugin_reports_nothing_when_no_module_came_from_the_primary_tree(
|
|
237
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
238
|
+
) -> None:
|
|
239
|
+
plugin_directory = tmp_path / "plugin"
|
|
240
|
+
baseline_import_isolation.install_leak_plugin(plugin_directory)
|
|
241
|
+
plugin_module = load_installed_plugin(plugin_directory)
|
|
242
|
+
report_path = tmp_path / "report.json"
|
|
243
|
+
monkeypatch.setenv(BASELINE_PRIMARY_ROOT_ENV_VAR, str(tmp_path / "repo"))
|
|
244
|
+
monkeypatch.setenv(BASELINE_LEAK_REPORT_ENV_VAR, str(report_path))
|
|
245
|
+
|
|
246
|
+
plugin_module.pytest_sessionfinish(None, 0)
|
|
247
|
+
|
|
248
|
+
assert baseline_import_isolation.modules_imported_from_primary_tree(report_path) == []
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Behavioral tests for the staged_test_regression parts module.
|
|
2
|
+
|
|
3
|
+
Every test drives a real git repository and a real pytest subprocess — no
|
|
4
|
+
mocked git state, no mocked pytest run — the same shape the live commit gate
|
|
5
|
+
exercises.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from code_rules_gate_parts import baseline_import_isolation, staged_test_regression
|
|
13
|
+
from code_rules_gate_parts.tests._repo_test_helpers import (
|
|
14
|
+
init_repository,
|
|
15
|
+
repository_root_without_pytest_config,
|
|
16
|
+
repository_with_root_pytest_config,
|
|
17
|
+
run_git,
|
|
18
|
+
write_and_stage,
|
|
19
|
+
write_commit_and_stage_change,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
UNREACHABLE_PROBE_TIMEOUT_SECONDS = 0.001
|
|
23
|
+
|
|
24
|
+
CONSUMER_TEST_TEXT = (
|
|
25
|
+
"import foo\n\n\ndef test_consumer_reads_head_value() -> None:\n"
|
|
26
|
+
" assert foo.VALUE == 'clean', foo.__file__\n"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
CONSUMER_TEST_TEXT_WITH_STAGED_EDIT = CONSUMER_TEST_TEXT + (
|
|
30
|
+
"\n\ndef test_consumer_extra() -> None:\n assert True\n"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
IMPORT_HOOK_SITECUSTOMIZE_TEMPLATE = (
|
|
34
|
+
"import sys\n"
|
|
35
|
+
"from importlib.machinery import PathFinder\n\n"
|
|
36
|
+
"PINNED_ROOT = r'{pinned_root}'\n\n\n"
|
|
37
|
+
"class PinnedFinder(PathFinder):\n"
|
|
38
|
+
" @classmethod\n"
|
|
39
|
+
" def find_spec(cls, fullname, path=None, target=None):\n"
|
|
40
|
+
" if fullname.split('.')[0] != 'foo':\n"
|
|
41
|
+
" return None\n"
|
|
42
|
+
" return super().find_spec(fullname, [PINNED_ROOT], target)\n\n\n"
|
|
43
|
+
"sys.meta_path.insert(0, PinnedFinder)\n"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def stage_a_change_that_breaks_a_consumer(repository_root: Path) -> None:
|
|
48
|
+
"""Commit a passing consumer of ``foo``, then stage a ``foo`` edit that breaks it.
|
|
49
|
+
|
|
50
|
+
The consumer imports ``foo`` by name, so which copy of ``foo`` the baseline
|
|
51
|
+
run loads decides whether the gate sees a regression at all.
|
|
52
|
+
"""
|
|
53
|
+
write_and_stage(repository_root, "packages/foo/__init__.py", "VALUE = 'clean'\n")
|
|
54
|
+
write_and_stage(repository_root, "pkg_a/test_alpha.py", CONSUMER_TEST_TEXT)
|
|
55
|
+
run_git(repository_root, "commit", "--no-verify", "-m", "seed consumer")
|
|
56
|
+
write_and_stage(repository_root, "packages/foo/__init__.py", "VALUE = 'dirty'\n")
|
|
57
|
+
write_and_stage(
|
|
58
|
+
repository_root, "pkg_a/test_alpha.py", CONSUMER_TEST_TEXT_WITH_STAGED_EDIT
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_pythonpath_route_into_the_working_tree_does_not_hide_a_regression(
|
|
63
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
64
|
+
) -> None:
|
|
65
|
+
repository_root = repository_with_root_pytest_config(tmp_path)
|
|
66
|
+
stage_a_change_that_breaks_a_consumer(repository_root)
|
|
67
|
+
monkeypatch.setenv("PYTHONPATH", str(repository_root / "packages"))
|
|
68
|
+
|
|
69
|
+
assert staged_test_regression.run_staged_test_files(repository_root) != 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_a_probe_that_runs_out_of_time_still_reaches_a_gate_decision(
|
|
73
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
74
|
+
) -> None:
|
|
75
|
+
repository_root = repository_with_root_pytest_config(tmp_path)
|
|
76
|
+
write_commit_and_stage_change(
|
|
77
|
+
repository_root,
|
|
78
|
+
"pkg_a/test_alpha.py",
|
|
79
|
+
(
|
|
80
|
+
"def test_already_fails() -> None:\n assert False\n"
|
|
81
|
+
"def test_passes() -> None:\n assert True\n"
|
|
82
|
+
),
|
|
83
|
+
(
|
|
84
|
+
"def test_already_fails() -> None:\n assert False\n"
|
|
85
|
+
"def test_passes() -> None:\n assert 1 == 1\n"
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
monkeypatch.setattr(
|
|
89
|
+
baseline_import_isolation,
|
|
90
|
+
"BASELINE_IMPORT_PROBE_TIMEOUT_SECONDS",
|
|
91
|
+
UNREACHABLE_PROBE_TIMEOUT_SECONDS,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
exit_code = staged_test_regression.run_staged_test_files(repository_root)
|
|
95
|
+
|
|
96
|
+
assert exit_code == 0
|
|
97
|
+
assert "import-root probe" in capsys.readouterr().err
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_import_hook_route_into_the_working_tree_is_reported_and_blocks(
|
|
101
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
102
|
+
) -> None:
|
|
103
|
+
repository_root = repository_with_root_pytest_config(tmp_path)
|
|
104
|
+
stage_a_change_that_breaks_a_consumer(repository_root)
|
|
105
|
+
import_hook_directory = tmp_path / "import_hook"
|
|
106
|
+
import_hook_directory.mkdir()
|
|
107
|
+
(import_hook_directory / "sitecustomize.py").write_text(
|
|
108
|
+
IMPORT_HOOK_SITECUSTOMIZE_TEMPLATE.format(pinned_root=repository_root / "packages"),
|
|
109
|
+
encoding="utf-8",
|
|
110
|
+
)
|
|
111
|
+
monkeypatch.setenv("PYTHONPATH", str(import_hook_directory))
|
|
112
|
+
|
|
113
|
+
exit_code = staged_test_regression.run_staged_test_files(repository_root)
|
|
114
|
+
|
|
115
|
+
assert exit_code != 0
|
|
116
|
+
assert "imported 1 module(s) from your working tree" in capsys.readouterr().err
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_pre_existing_failure_does_not_block_an_unrelated_staged_change(
|
|
120
|
+
tmp_path: Path,
|
|
121
|
+
) -> None:
|
|
122
|
+
repository_root = repository_with_root_pytest_config(tmp_path)
|
|
123
|
+
write_commit_and_stage_change(
|
|
124
|
+
repository_root,
|
|
125
|
+
"pkg_a/test_alpha.py",
|
|
126
|
+
(
|
|
127
|
+
"def test_already_fails() -> None:\n assert False\n"
|
|
128
|
+
"def test_passes() -> None:\n assert True\n"
|
|
129
|
+
),
|
|
130
|
+
(
|
|
131
|
+
"def test_already_fails() -> None:\n assert False\n"
|
|
132
|
+
"def test_passes() -> None:\n assert 1 == 1\n"
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
assert staged_test_regression.run_staged_test_files(repository_root) == 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_staged_change_that_newly_breaks_a_passing_test_blocks(tmp_path: Path) -> None:
|
|
140
|
+
repository_root = repository_with_root_pytest_config(tmp_path)
|
|
141
|
+
write_commit_and_stage_change(
|
|
142
|
+
repository_root,
|
|
143
|
+
"pkg_a/test_alpha.py",
|
|
144
|
+
"def test_alpha_passes() -> None:\n assert True\n",
|
|
145
|
+
"def test_alpha_passes() -> None:\n assert False\n",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
assert staged_test_regression.run_staged_test_files(repository_root) != 0
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_brand_new_failing_test_with_no_baseline_blocks(tmp_path: Path) -> None:
|
|
152
|
+
repository_root = repository_with_root_pytest_config(tmp_path)
|
|
153
|
+
write_and_stage(
|
|
154
|
+
repository_root,
|
|
155
|
+
"pkg_a/test_alpha.py",
|
|
156
|
+
"def test_alpha_fails() -> None:\n assert False\n",
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
assert staged_test_regression.run_staged_test_files(repository_root) != 0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_mixed_group_blocks_only_on_the_newly_broken_test(tmp_path: Path) -> None:
|
|
163
|
+
repository_root = repository_with_root_pytest_config(tmp_path)
|
|
164
|
+
write_commit_and_stage_change(
|
|
165
|
+
repository_root,
|
|
166
|
+
"pkg_a/test_alpha.py",
|
|
167
|
+
(
|
|
168
|
+
"def test_already_red() -> None:\n assert False\n"
|
|
169
|
+
"def test_stays_green() -> None:\n assert True\n"
|
|
170
|
+
),
|
|
171
|
+
(
|
|
172
|
+
"def test_already_red() -> None:\n assert False\n"
|
|
173
|
+
"def test_stays_green() -> None:\n assert False\n"
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
assert staged_test_regression.run_staged_test_files(repository_root) != 0
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_regression_gate_leaves_the_staged_index_and_worktree_list_alone(
|
|
181
|
+
tmp_path: Path,
|
|
182
|
+
) -> None:
|
|
183
|
+
repository_root = repository_with_root_pytest_config(tmp_path)
|
|
184
|
+
write_commit_and_stage_change(
|
|
185
|
+
repository_root,
|
|
186
|
+
"pkg_a/test_alpha.py",
|
|
187
|
+
(
|
|
188
|
+
"def test_already_fails() -> None:\n assert False\n"
|
|
189
|
+
"def test_passes() -> None:\n assert True\n"
|
|
190
|
+
),
|
|
191
|
+
(
|
|
192
|
+
"def test_already_fails() -> None:\n assert False\n"
|
|
193
|
+
"def test_passes() -> None:\n assert 1 == 1\n"
|
|
194
|
+
),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
staged_test_regression.run_staged_test_files(repository_root)
|
|
198
|
+
|
|
199
|
+
staged_content = run_git(
|
|
200
|
+
repository_root, "show", ":pkg_a/test_alpha.py"
|
|
201
|
+
).stdout.decode("utf-8")
|
|
202
|
+
assert "assert 1 == 1" in staged_content
|
|
203
|
+
status = run_git(repository_root, "status", "--porcelain").stdout.decode("utf-8")
|
|
204
|
+
assert "M pkg_a/test_alpha.py" in status
|
|
205
|
+
worktree_list = run_git(
|
|
206
|
+
repository_root, "worktree", "list", "--porcelain"
|
|
207
|
+
).stdout.decode("utf-8")
|
|
208
|
+
assert worktree_list.count("worktree ") == 1
|
|
209
|
+
assert "code_rules_gate_baseline_" not in worktree_list
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_run_staged_test_files_returns_zero_when_nothing_staged(tmp_path: Path) -> None:
|
|
213
|
+
repository_root = tmp_path / "repo"
|
|
214
|
+
repository_root.mkdir()
|
|
215
|
+
init_repository(repository_root)
|
|
216
|
+
|
|
217
|
+
assert staged_test_regression.run_staged_test_files(repository_root) == 0
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_run_staged_test_files_returns_zero_when_only_multiple_confests_staged(
|
|
221
|
+
tmp_path: Path,
|
|
222
|
+
) -> None:
|
|
223
|
+
repository_root = repository_with_root_pytest_config(tmp_path)
|
|
224
|
+
write_and_stage(repository_root, "pkg_a/conftest.py", "import pytest\n")
|
|
225
|
+
write_and_stage(repository_root, "pkg_b/conftest.py", "import pytest\n")
|
|
226
|
+
write_and_stage(repository_root, "pkg_c/tests/conftest.py", "import pytest\n")
|
|
227
|
+
|
|
228
|
+
assert staged_test_regression.run_staged_test_files(repository_root) == 0
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_run_staged_test_files_passes_when_confests_stage_with_passing_test(
|
|
232
|
+
tmp_path: Path,
|
|
233
|
+
) -> None:
|
|
234
|
+
repository_root = repository_with_root_pytest_config(tmp_path)
|
|
235
|
+
write_and_stage(repository_root, "pkg_a/conftest.py", "import pytest\n")
|
|
236
|
+
write_and_stage(repository_root, "pkg_b/conftest.py", "import pytest\n")
|
|
237
|
+
write_and_stage(
|
|
238
|
+
repository_root,
|
|
239
|
+
"pkg_a/test_alpha.py",
|
|
240
|
+
"def test_alpha_passes() -> None:\n assert True\n",
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
assert staged_test_regression.run_staged_test_files(repository_root) == 0
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_run_staged_test_files_fails_when_real_test_fails_alongside_confests(
|
|
247
|
+
tmp_path: Path,
|
|
248
|
+
) -> None:
|
|
249
|
+
repository_root = repository_with_root_pytest_config(tmp_path)
|
|
250
|
+
write_and_stage(repository_root, "pkg_a/conftest.py", "import pytest\n")
|
|
251
|
+
write_and_stage(repository_root, "pkg_b/conftest.py", "import pytest\n")
|
|
252
|
+
write_and_stage(
|
|
253
|
+
repository_root,
|
|
254
|
+
"pkg_a/test_alpha.py",
|
|
255
|
+
"def test_alpha_fails() -> None:\n assert False\n",
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
assert staged_test_regression.run_staged_test_files(repository_root) != 0
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def test_no_head_baseline_blocks_on_any_staged_failure(tmp_path: Path) -> None:
|
|
262
|
+
repository_root = tmp_path / "repo"
|
|
263
|
+
repository_root.mkdir()
|
|
264
|
+
run_git(repository_root, "init", "--initial-branch=main")
|
|
265
|
+
run_git(repository_root, "config", "user.email", "test@example.com")
|
|
266
|
+
run_git(repository_root, "config", "user.name", "Test")
|
|
267
|
+
run_git(repository_root, "config", "commit.gpgsign", "false")
|
|
268
|
+
write_and_stage(repository_root, "pytest.ini", "[pytest]\n")
|
|
269
|
+
write_and_stage(
|
|
270
|
+
repository_root,
|
|
271
|
+
"test_alpha.py",
|
|
272
|
+
"def test_alpha_fails() -> None:\n assert False\n",
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
assert staged_test_regression.run_staged_test_files(repository_root) != 0
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _stage_package_owning_a_bare_config(
|
|
279
|
+
repository_root: Path, package_name: str, marker_value: str
|
|
280
|
+
) -> None:
|
|
281
|
+
"""Stage a config-less top-level package whose tests import a bare ``config``.
|
|
282
|
+
|
|
283
|
+
Two such packages staged together reproduce the measured collision: one
|
|
284
|
+
pytest session binds ``config`` to whichever package imports first, so the
|
|
285
|
+
other package's test reads the wrong marker.
|
|
286
|
+
"""
|
|
287
|
+
write_and_stage(
|
|
288
|
+
repository_root,
|
|
289
|
+
f"{package_name}/config/__init__.py",
|
|
290
|
+
f'PACKAGE_MARKER = "{marker_value}"\n',
|
|
291
|
+
)
|
|
292
|
+
write_and_stage(
|
|
293
|
+
repository_root,
|
|
294
|
+
f"{package_name}/test_{package_name}.py",
|
|
295
|
+
"from config import PACKAGE_MARKER\n\n\n"
|
|
296
|
+
f"def test_{package_name}_reads_its_own_config() -> None:\n"
|
|
297
|
+
f' assert PACKAGE_MARKER == "{marker_value}"\n',
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def test_run_staged_test_files_passes_when_config_less_packages_share_a_module_name(
|
|
302
|
+
tmp_path: Path,
|
|
303
|
+
) -> None:
|
|
304
|
+
repository_root = repository_root_without_pytest_config(tmp_path)
|
|
305
|
+
init_repository(repository_root)
|
|
306
|
+
_stage_package_owning_a_bare_config(repository_root, "alpha_package", "alpha")
|
|
307
|
+
_stage_package_owning_a_bare_config(repository_root, "beta_package", "beta")
|
|
308
|
+
|
|
309
|
+
assert staged_test_regression.run_staged_test_files(repository_root) == 0
|