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
|
@@ -1,13 +1,33 @@
|
|
|
1
|
-
"""Run the staged Python test files, grouped by
|
|
1
|
+
"""Run the staged Python test files, grouped by owning pytest config or top-level directory.
|
|
2
2
|
|
|
3
3
|
The commit gate runs each staged test file so a broken test blocks the commit.
|
|
4
|
-
Files are grouped by the nearest ancestor holding a pytest config,
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
Files are grouped by the nearest ancestor holding a pytest config, falling back
|
|
5
|
+
to the file's own top-level directory under the repository root when no ancestor
|
|
6
|
+
owns one. Each group runs in its own pytest session with the working directory
|
|
7
|
+
at that root, so two packages exposing a same-named top-level package never
|
|
8
|
+
shadow each other.
|
|
7
9
|
|
|
8
10
|
``conftest.py`` paths are never passed as pytest collection targets: pytest loads
|
|
9
11
|
them automatically when nearby tests run, and collecting multiple bare confests
|
|
10
12
|
in one session collides on the shared module basename.
|
|
13
|
+
|
|
14
|
+
``staged_test_regression`` is the commit gate's entry point: it discovers the
|
|
15
|
+
staged test files, groups them by owning pytest-config root, and calls back
|
|
16
|
+
into this module's group-running primitives to run each group (optionally
|
|
17
|
+
with a JUnit XML report per batch, for its staged-vs-baseline comparison).
|
|
18
|
+
|
|
19
|
+
Boundaries of the top-level fallback, which fires only in a repository whose
|
|
20
|
+
root holds no pytest config::
|
|
21
|
+
|
|
22
|
+
repository_root = /repo, holding no pytest config of its own
|
|
23
|
+
ok: /repo/alpha/test_a.py, /repo/beta/test_b.py -> two sessions
|
|
24
|
+
flag: /repo/pkgs/alpha/test_a.py, /repo/pkgs/beta/test_b.py -> one session
|
|
25
|
+
|
|
26
|
+
Config-less packages nested under one shared top-level directory run in that
|
|
27
|
+
directory's single session, so the split separates top-level neighbors alone.
|
|
28
|
+
A repository-root ``conftest.py`` stays unloaded for those groups as well: the
|
|
29
|
+
session works from the top-level directory, which leaves the root file outside
|
|
30
|
+
the tree pytest treats as its root.
|
|
11
31
|
"""
|
|
12
32
|
|
|
13
33
|
import os
|
|
@@ -24,12 +44,11 @@ from pr_loop_shared_constants.code_rules_gate_constants import (
|
|
|
24
44
|
CODE_RULES_GATE_PYTHON_ENV_VAR,
|
|
25
45
|
CODE_RULES_GATE_PYTHONPATH_ENV_VAR,
|
|
26
46
|
COMMAND_LINE_ARGUMENT_SEPARATOR_LENGTH,
|
|
47
|
+
JUNIT_XML_FLAG_PREFIX,
|
|
27
48
|
MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS,
|
|
28
49
|
PYTHON_FILE_EXTENSION,
|
|
29
50
|
PYTHONPATH_ENV_VAR,
|
|
30
51
|
STAGED_PYTEST_TIMEOUT_SECONDS,
|
|
31
|
-
STAGED_TEST_FAILURE_HEADER,
|
|
32
|
-
STAGED_TEST_GROUP_FAILURE_MESSAGE,
|
|
33
52
|
TEST_CONFTEST_FILENAME,
|
|
34
53
|
)
|
|
35
54
|
from pr_loop_shared_constants.preflight_constants import (
|
|
@@ -102,6 +121,36 @@ def _directory_holds_pytest_config(directory: Path) -> bool:
|
|
|
102
121
|
return False
|
|
103
122
|
|
|
104
123
|
|
|
124
|
+
def _walk_to_owning_test_root(
|
|
125
|
+
resolved_test_file_path: Path, resolved_repository_root: Path
|
|
126
|
+
) -> tuple[Path, bool]:
|
|
127
|
+
"""Walk up once for the owning test root and whether that root holds a config.
|
|
128
|
+
|
|
129
|
+
::
|
|
130
|
+
|
|
131
|
+
resolved_repository_root = /repo, holding no pytest config of its own
|
|
132
|
+
ok: /repo/pkg/pytest.ini above /repo/pkg/suite/test_a.py -> (/repo/pkg, True)
|
|
133
|
+
ok: /repo/tools/tests/test_b.py -> (/repo, False)
|
|
134
|
+
|
|
135
|
+
One walk answers both questions a caller has, so the repository root's
|
|
136
|
+
config is read once per staged file rather than once per question.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
resolved_test_file_path: The already-resolved staged test file.
|
|
140
|
+
resolved_repository_root: The already-resolved repository root.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
The nearest config-owning ancestor paired with True, or the repository
|
|
144
|
+
root paired with False when no ancestor up to it owns a config.
|
|
145
|
+
"""
|
|
146
|
+
for each_ancestor in resolved_test_file_path.parents:
|
|
147
|
+
if _directory_holds_pytest_config(each_ancestor):
|
|
148
|
+
return each_ancestor, True
|
|
149
|
+
if each_ancestor == resolved_repository_root:
|
|
150
|
+
return resolved_repository_root, False
|
|
151
|
+
return resolved_repository_root, False
|
|
152
|
+
|
|
153
|
+
|
|
105
154
|
def _resolve_owning_test_root(test_file_path: Path, repository_root: Path) -> Path:
|
|
106
155
|
"""Return the nearest ancestor of *test_file_path* owning a pytest config.
|
|
107
156
|
|
|
@@ -112,31 +161,107 @@ def _resolve_owning_test_root(test_file_path: Path, repository_root: Path) -> Pa
|
|
|
112
161
|
Returns:
|
|
113
162
|
The resolved owning test root directory.
|
|
114
163
|
"""
|
|
164
|
+
owning_root, _has_owning_pytest_config = _walk_to_owning_test_root(
|
|
165
|
+
test_file_path.resolve(), repository_root.resolve()
|
|
166
|
+
)
|
|
167
|
+
return owning_root
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _resolved_path_or_none(each_path: Path) -> Path | None:
|
|
171
|
+
"""Return *each_path* resolved, or None when the filesystem refuses to resolve it."""
|
|
172
|
+
try:
|
|
173
|
+
return each_path.resolve()
|
|
174
|
+
except OSError:
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _top_level_group_root(
|
|
179
|
+
resolved_test_file_path: Path, resolved_repository_root: Path
|
|
180
|
+
) -> Path:
|
|
181
|
+
"""Return the repository top-level directory *resolved_test_file_path* sits under.
|
|
182
|
+
|
|
183
|
+
::
|
|
184
|
+
|
|
185
|
+
resolved_repository_root = /repo
|
|
186
|
+
ok: /repo/tools/tests/test_a.py -> /repo/tools
|
|
187
|
+
ok: /repo/test_b.py -> /repo
|
|
188
|
+
ok: /elsewhere/test_c.py -> /repo (outside the repository)
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
resolved_test_file_path: The already-resolved staged test file to place
|
|
192
|
+
under a top-level directory.
|
|
193
|
+
resolved_repository_root: The already-resolved repository root.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
The repository root joined to the file's first repository-relative path
|
|
197
|
+
component; the repository root itself for a file sitting directly at it
|
|
198
|
+
or outside it.
|
|
199
|
+
"""
|
|
200
|
+
try:
|
|
201
|
+
repository_relative_path = resolved_test_file_path.relative_to(resolved_repository_root)
|
|
202
|
+
except ValueError:
|
|
203
|
+
return resolved_repository_root
|
|
204
|
+
all_relative_parts = repository_relative_path.parts
|
|
205
|
+
if len(all_relative_parts) <= 1:
|
|
206
|
+
return resolved_repository_root
|
|
207
|
+
return resolved_repository_root / all_relative_parts[0]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _group_root_for_test_file(test_file_path: Path, repository_root: Path) -> Path:
|
|
211
|
+
"""Return the pytest session root *test_file_path* runs under.
|
|
212
|
+
|
|
213
|
+
::
|
|
214
|
+
|
|
215
|
+
repository_root = /repo, holding no pytest config of its own
|
|
216
|
+
ok: /repo/pkg/pytest.ini next to /repo/pkg/suite/test_a.py -> /repo/pkg
|
|
217
|
+
ok: /repo/tools/tests/test_b.py -> /repo/tools
|
|
218
|
+
ok: /repo/test_c.py -> /repo
|
|
219
|
+
flag: /repo/tools/... and /repo/pipeline/... in one /repo bucket
|
|
220
|
+
|
|
221
|
+
A config-owning ancestor always wins. A file no ancestor config owns falls
|
|
222
|
+
back to its top-level directory, so two config-less packages exposing a
|
|
223
|
+
same-named top-level module run in separate pytest processes rather than
|
|
224
|
+
shadowing each other in one. A staged path the filesystem refuses to
|
|
225
|
+
resolve joins the repository-root group instead of aborting the gate.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
test_file_path: The staged test file to place in a group.
|
|
229
|
+
repository_root: The repository root that bounds the upward walk.
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
The config-owning ancestor when one exists, the repository root when the
|
|
233
|
+
root owns the config or the path fails to resolve, else the file's
|
|
234
|
+
top-level directory.
|
|
235
|
+
"""
|
|
115
236
|
resolved_repository_root = repository_root.resolve()
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
237
|
+
resolved_test_file_path = _resolved_path_or_none(test_file_path)
|
|
238
|
+
if resolved_test_file_path is None:
|
|
239
|
+
return resolved_repository_root
|
|
240
|
+
owning_root, has_owning_pytest_config = _walk_to_owning_test_root(
|
|
241
|
+
resolved_test_file_path, resolved_repository_root
|
|
242
|
+
)
|
|
243
|
+
if has_owning_pytest_config:
|
|
244
|
+
return owning_root
|
|
245
|
+
return _top_level_group_root(resolved_test_file_path, resolved_repository_root)
|
|
122
246
|
|
|
123
247
|
|
|
124
248
|
def _group_staged_tests_by_root(
|
|
125
249
|
all_test_paths: list[Path], repository_root: Path
|
|
126
250
|
) -> dict[Path, list[Path]]:
|
|
127
|
-
"""Group *all_test_paths* by
|
|
251
|
+
"""Group *all_test_paths* by the pytest session root that runs them.
|
|
128
252
|
|
|
129
253
|
Args:
|
|
130
254
|
all_test_paths: The staged test files to partition.
|
|
131
255
|
repository_root: The repository root that bounds each upward walk.
|
|
132
256
|
|
|
133
257
|
Returns:
|
|
134
|
-
A mapping from
|
|
258
|
+
A mapping from group root to the staged test files running under it.
|
|
259
|
+
Every input path lands in exactly one group.
|
|
135
260
|
"""
|
|
136
261
|
all_tests_by_root: dict[Path, list[Path]] = {}
|
|
137
262
|
for each_test_path in all_test_paths:
|
|
138
|
-
|
|
139
|
-
all_tests_by_root.setdefault(
|
|
263
|
+
group_root = _group_root_for_test_file(each_test_path, repository_root)
|
|
264
|
+
all_tests_by_root.setdefault(group_root, []).append(each_test_path)
|
|
140
265
|
return all_tests_by_root
|
|
141
266
|
|
|
142
267
|
|
|
@@ -256,23 +381,36 @@ def _batched_pytest_arguments(
|
|
|
256
381
|
return all_batches
|
|
257
382
|
|
|
258
383
|
|
|
259
|
-
def _pytest_batch_exit_code(
|
|
384
|
+
def _pytest_batch_exit_code(
|
|
385
|
+
all_batch_command: list[str],
|
|
386
|
+
group_root: Path,
|
|
387
|
+
environment: dict[str, str],
|
|
388
|
+
junit_xml_path: Path | None = None,
|
|
389
|
+
) -> int:
|
|
260
390
|
"""Run one pytest invocation and normalize its exit code.
|
|
261
391
|
|
|
262
392
|
Args:
|
|
263
393
|
all_batch_command: The full argv for this pytest invocation.
|
|
264
394
|
group_root: The owning test root used as the working directory.
|
|
395
|
+
environment: The environment the pytest subprocess runs under, which
|
|
396
|
+
decides where its imports resolve from.
|
|
397
|
+
junit_xml_path: When given, the batch also writes a JUnit XML report
|
|
398
|
+
here (via ``--junitxml``), for a caller that needs the per-test
|
|
399
|
+
pass/fail identities alongside the exit code.
|
|
265
400
|
|
|
266
401
|
Returns:
|
|
267
402
|
0 when the batch passes or collects no tests; pytest's exit code
|
|
268
403
|
otherwise.
|
|
269
404
|
"""
|
|
405
|
+
full_command = list(all_batch_command)
|
|
406
|
+
if junit_xml_path is not None:
|
|
407
|
+
full_command.append(f"{JUNIT_XML_FLAG_PREFIX}{junit_xml_path}")
|
|
270
408
|
pytest_process = subprocess.run(
|
|
271
|
-
|
|
409
|
+
full_command,
|
|
272
410
|
cwd=str(group_root),
|
|
273
411
|
timeout=STAGED_PYTEST_TIMEOUT_SECONDS,
|
|
274
412
|
check=False,
|
|
275
|
-
env=
|
|
413
|
+
env=environment,
|
|
276
414
|
)
|
|
277
415
|
if pytest_process.returncode == PYTEST_NO_TESTS_COLLECTED_EXIT_CODE:
|
|
278
416
|
return 0
|
|
@@ -288,19 +426,49 @@ def _pytest_fixed_command(repository_root: Path) -> list[str]:
|
|
|
288
426
|
|
|
289
427
|
|
|
290
428
|
def _first_failing_batch_exit_code(
|
|
291
|
-
all_fixed_command: list[str],
|
|
429
|
+
all_fixed_command: list[str],
|
|
430
|
+
all_batches: list[list[str]],
|
|
431
|
+
group_root: Path,
|
|
432
|
+
environment: dict[str, str],
|
|
433
|
+
junit_xml_dir: Path | None = None,
|
|
292
434
|
) -> int:
|
|
293
|
-
"""Run each batch and return the first non-zero pytest exit code, or zero.
|
|
435
|
+
"""Run each batch and return the first non-zero pytest exit code, or zero.
|
|
436
|
+
|
|
437
|
+
Args:
|
|
438
|
+
all_fixed_command: The interpreter-and-pytest prefix shared by every batch.
|
|
439
|
+
all_batches: The command-line-length-safe argument batches to run in order.
|
|
440
|
+
group_root: The owning test root used as the working directory.
|
|
441
|
+
environment: The environment every pytest subprocess runs under.
|
|
442
|
+
junit_xml_dir: When given, each batch also writes its own JUnit XML report
|
|
443
|
+
here (one file per batch index), for a caller that needs per-test
|
|
444
|
+
pass/fail identities alongside the exit code.
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
0 when every batch passes or collects no tests; the first non-zero batch
|
|
448
|
+
exit code otherwise.
|
|
449
|
+
"""
|
|
294
450
|
first_failing_exit_code = 0
|
|
295
|
-
for each_batch in all_batches:
|
|
296
|
-
|
|
451
|
+
for batch_index, each_batch in enumerate(all_batches):
|
|
452
|
+
junit_xml_path = (
|
|
453
|
+
junit_xml_dir / f"batch_{batch_index}.xml" if junit_xml_dir is not None else None
|
|
454
|
+
)
|
|
455
|
+
batch_exit_code = _pytest_batch_exit_code(
|
|
456
|
+
[*all_fixed_command, *each_batch],
|
|
457
|
+
group_root,
|
|
458
|
+
environment,
|
|
459
|
+
junit_xml_path=junit_xml_path,
|
|
460
|
+
)
|
|
297
461
|
if batch_exit_code != 0 and first_failing_exit_code == 0:
|
|
298
462
|
first_failing_exit_code = batch_exit_code
|
|
299
463
|
return first_failing_exit_code
|
|
300
464
|
|
|
301
465
|
|
|
302
466
|
def _run_pytest_for_group(
|
|
303
|
-
group_root: Path,
|
|
467
|
+
group_root: Path,
|
|
468
|
+
all_group_test_paths: list[Path],
|
|
469
|
+
repository_root: Path,
|
|
470
|
+
junit_xml_dir: Path | None = None,
|
|
471
|
+
environment: dict[str, str] | None = None,
|
|
304
472
|
) -> int:
|
|
305
473
|
"""Run pytest over one group's test files, split into budget-safe batches.
|
|
306
474
|
|
|
@@ -308,6 +476,13 @@ def _run_pytest_for_group(
|
|
|
308
476
|
group_root: The owning test root used as the pytest working directory.
|
|
309
477
|
all_group_test_paths: The staged test files that share *group_root*.
|
|
310
478
|
repository_root: The repository root used to resolve a project venv.
|
|
479
|
+
junit_xml_dir: When given, a JUnit XML report is written per batch under
|
|
480
|
+
this directory, for a caller that needs per-test pass/fail identities
|
|
481
|
+
(the regression gate) alongside the exit code.
|
|
482
|
+
environment: When given, the environment every pytest subprocess runs
|
|
483
|
+
under; the regression gate passes a baseline environment here so the
|
|
484
|
+
HEAD run resolves its imports inside the baseline worktree. Defaults
|
|
485
|
+
to the staged environment.
|
|
311
486
|
|
|
312
487
|
Returns:
|
|
313
488
|
0 when every batch passes or collects nothing; the first failing batch's
|
|
@@ -321,49 +496,10 @@ def _run_pytest_for_group(
|
|
|
321
496
|
[_relative_pytest_argument(each_path, group_root) for each_path in all_group_test_paths],
|
|
322
497
|
MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS - fixed_command_length,
|
|
323
498
|
)
|
|
324
|
-
return _first_failing_batch_exit_code(
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
first_failing_exit_code = 0
|
|
332
|
-
for each_group_root in sorted(all_tests_by_root):
|
|
333
|
-
group_exit_code = _run_pytest_for_group(
|
|
334
|
-
each_group_root, all_tests_by_root[each_group_root], repository_root
|
|
335
|
-
)
|
|
336
|
-
if group_exit_code == 0:
|
|
337
|
-
continue
|
|
338
|
-
sys.stderr.write(
|
|
339
|
-
STAGED_TEST_GROUP_FAILURE_MESSAGE.format(group_root=each_group_root) + "\n"
|
|
340
|
-
)
|
|
341
|
-
if first_failing_exit_code == 0:
|
|
342
|
-
first_failing_exit_code = group_exit_code
|
|
343
|
-
return first_failing_exit_code
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
def run_staged_test_files(repository_root: Path) -> int:
|
|
347
|
-
"""Run pytest over the staged test files and return the gate exit code.
|
|
348
|
-
|
|
349
|
-
``conftest.py`` files are excluded from collection targets. Pytest still
|
|
350
|
-
loads them automatically when a nearby staged test runs under the same
|
|
351
|
-
owning root.
|
|
352
|
-
|
|
353
|
-
Args:
|
|
354
|
-
repository_root: The repository root the staged test files belong to.
|
|
355
|
-
|
|
356
|
-
Returns:
|
|
357
|
-
0 when no collectable test file is staged, when every group collects no
|
|
358
|
-
tests, or when every group passes. The first failing group's exit code
|
|
359
|
-
otherwise.
|
|
360
|
-
"""
|
|
361
|
-
all_test_paths = _staged_test_file_paths(repository_root)
|
|
362
|
-
all_pytest_targets = _pytest_target_paths(all_test_paths)
|
|
363
|
-
if not all_pytest_targets:
|
|
364
|
-
return 0
|
|
365
|
-
all_tests_by_root = _group_staged_tests_by_root(all_pytest_targets, repository_root)
|
|
366
|
-
first_failing_exit_code = _run_grouped_staged_tests(all_tests_by_root, repository_root)
|
|
367
|
-
if first_failing_exit_code != 0:
|
|
368
|
-
sys.stderr.write(STAGED_TEST_FAILURE_HEADER + "\n")
|
|
369
|
-
return first_failing_exit_code
|
|
499
|
+
return _first_failing_batch_exit_code(
|
|
500
|
+
all_fixed_command,
|
|
501
|
+
all_batches,
|
|
502
|
+
group_root,
|
|
503
|
+
_staged_pytest_environment() if environment is None else environment,
|
|
504
|
+
junit_xml_dir=junit_xml_dir,
|
|
505
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Package marker so ``code_rules_gate_parts.tests`` submodules import by dotted path."""
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Shared real-git-repository builders for the code_rules_gate_parts test suite.
|
|
2
|
+
|
|
3
|
+
Every helper drives a real ``git`` subprocess against a throwaway repository
|
|
4
|
+
under ``tmp_path`` — no mocked git state — so a test proves the module works
|
|
5
|
+
against the same git plumbing the live commit hook runs against.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from code_rules_gate_parts import git_file_sets
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run_git(repository_root: Path, *arguments: str) -> subprocess.CompletedProcess[bytes]:
|
|
15
|
+
"""Run one git subcommand in *repository_root* and raise on failure."""
|
|
16
|
+
return subprocess.run(
|
|
17
|
+
["git", *arguments],
|
|
18
|
+
cwd=str(repository_root),
|
|
19
|
+
check=True,
|
|
20
|
+
capture_output=True,
|
|
21
|
+
env=git_file_sets.repository_environment(),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def init_repository(repository_root: Path) -> None:
|
|
26
|
+
"""Initialize *repository_root* as a git repo with one seed commit."""
|
|
27
|
+
run_git(repository_root, "init", "--initial-branch=main")
|
|
28
|
+
run_git(repository_root, "config", "user.email", "test@example.com")
|
|
29
|
+
run_git(repository_root, "config", "user.name", "Test")
|
|
30
|
+
run_git(repository_root, "config", "commit.gpgsign", "false")
|
|
31
|
+
(repository_root / "seed.txt").write_text("seed\n", encoding="utf-8")
|
|
32
|
+
run_git(repository_root, "add", "-A")
|
|
33
|
+
run_git(repository_root, "commit", "--no-verify", "-m", "seed")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def write_and_stage(repository_root: Path, relative_path: str, file_text: str) -> Path:
|
|
37
|
+
"""Write *file_text* to *relative_path* under *repository_root* and stage it."""
|
|
38
|
+
file_path = repository_root / relative_path
|
|
39
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
file_path.write_text(file_text, encoding="utf-8")
|
|
41
|
+
run_git(repository_root, "add", "--", relative_path)
|
|
42
|
+
return file_path
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def write_commit_and_stage_change(
|
|
46
|
+
repository_root: Path, relative_path: str, committed_text: str, staged_text: str
|
|
47
|
+
) -> Path:
|
|
48
|
+
"""Commit *committed_text* at *relative_path*, then overwrite and stage *staged_text*.
|
|
49
|
+
|
|
50
|
+
Builds the shape a regression check compares: real history at HEAD (what
|
|
51
|
+
the baseline run sees), then a staged edit on top of it (what the staged
|
|
52
|
+
run sees).
|
|
53
|
+
"""
|
|
54
|
+
file_path = write_and_stage(repository_root, relative_path, committed_text)
|
|
55
|
+
run_git(repository_root, "commit", "--no-verify", "-m", f"seed {relative_path}")
|
|
56
|
+
file_path.write_text(staged_text, encoding="utf-8")
|
|
57
|
+
run_git(repository_root, "add", "--", relative_path)
|
|
58
|
+
return file_path
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def repository_root_without_pytest_config(tmp_path: Path) -> Path:
|
|
62
|
+
"""Return a fresh repository-root directory holding no pytest configuration."""
|
|
63
|
+
repository_root = tmp_path / "repo"
|
|
64
|
+
repository_root.mkdir()
|
|
65
|
+
return repository_root
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def repository_with_root_pytest_config(tmp_path: Path) -> Path:
|
|
69
|
+
"""Return a fresh repo under *tmp_path* with a root-level ``pytest.ini`` committed."""
|
|
70
|
+
repository_root = tmp_path / "repo"
|
|
71
|
+
repository_root.mkdir()
|
|
72
|
+
init_repository(repository_root)
|
|
73
|
+
(repository_root / "pytest.ini").write_text("[pytest]\n", encoding="utf-8")
|
|
74
|
+
run_git(repository_root, "add", "-A")
|
|
75
|
+
run_git(repository_root, "commit", "--no-verify", "-m", "add pytest.ini")
|
|
76
|
+
return repository_root
|