claude-dev-env 1.93.0 → 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/code_rules_gate.py +127 -13
- package/_shared/pr-loop/scripts/fix_hookspath.py +47 -12
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +4 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +146 -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/pii_scanner.py +26 -1
- package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +2 -2
- package/hooks/blocking/test_pii_prevention_blocker.py +299 -0
- package/hooks/blocking/test_pii_scanner.py +27 -2
- 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 +20 -8
- 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 +8 -7
- 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
|
@@ -8,6 +8,7 @@ Runtime documents and scripts shared by every PR-loop skill. Changes here affect
|
|
|
8
8
|
|---|---|
|
|
9
9
|
| `audit-contract.md` | Canonical finding schema (Shape A / Shape B) and loop contract; defines the JSON shapes every audit skill must emit |
|
|
10
10
|
| `audit-reply-template.md` | Canonical reply skeleton Claude posts to each unresolved review thread; single source of truth for reply structure |
|
|
11
|
+
| `post-audit-thread-contract.md` | Single source of truth for the `post_audit_thread.py` invocation string, exit-code table, and per-caller policy (bugteam exit 2 = hard blocker; autoconverge clean-audit failed post = recorded bypass) |
|
|
11
12
|
| `fix-protocol.md` | Ordered sequence a fix lens follows: read, capture SHA, TDD, apply, validate, self-audit, commit, push, reply + resolve |
|
|
12
13
|
| `gh-payloads.md` | How to build GitHub review and reply payloads via MCP tools; describes the one-review-per-loop pattern |
|
|
13
14
|
| `state-schema.md` | Fields each PR-loop workflow tracks across iterations; documents common fields and per-skill extensions |
|
|
@@ -120,7 +120,7 @@ Sequence:
|
|
|
120
120
|
2. Apply edits.
|
|
121
121
|
3. Run `py_compile` (or language-equivalent) on each modified file.
|
|
122
122
|
4. Compute `fix_diff` against pre-fix contents for the modified set.
|
|
123
|
-
5. Run `
|
|
123
|
+
5. Run `_shared/pr-loop/scripts/code_rules_gate.py` with explicit paths for every modified file.
|
|
124
124
|
6. Spawn a scoped audit of `fix_diff` with full A–P rigor, Shape A/B contract, adversarial pass, AND Haiku secondary in parallel (paranoid mode on post-fix).
|
|
125
125
|
7. Read the previous loop's outcome XML (`<worktree_path>/.bugteam-pr<N>-loop<L-1>.outcomes.xml`) and obtain its total finding count. If this is the first loop (L <= 1) or the file does not exist, skip this comparison. Compute the post-fix total: previous total minus bugs fixed in this round plus new violations found in the post-fix audit (step 6). If the post-fix total exceeds the previous total, flag all new findings as same-loop fix-targets and revise. An increase in total findings across loop transitions is a regression.
|
|
126
126
|
8. Any new findings become same-loop fix-targets. Internal iteration count increments by one.
|
|
@@ -55,9 +55,9 @@ user-facing output (chat reply to the user) rather than in the PR review body.
|
|
|
55
55
|
|
|
56
56
|
## Review POST failure
|
|
57
57
|
|
|
58
|
-
There is no fallback
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
There is no fallback payload to build. On retry exhaustion
|
|
59
|
+
`post_audit_thread.py` exits `2` and the orchestrator halts — see
|
|
60
|
+
[`./post-audit-thread-contract.md`](./post-audit-thread-contract.md) § Exit codes.
|
|
61
61
|
A hard blocker on the audit-posting path is a halt condition, not a degraded
|
|
62
62
|
flat-issue-comment fall-through.
|
|
63
63
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# post_audit_thread.py invocation contract
|
|
2
|
+
|
|
3
|
+
Single source of truth for how every PR-loop caller runs
|
|
4
|
+
[`scripts/post_audit_thread.py`](scripts/post_audit_thread.py) to post one
|
|
5
|
+
audit review per pass. The script posts an `APPROVE` review on `CLEAN` (an
|
|
6
|
+
empty findings list, zero inline comments) and a `REQUEST_CHANGES` review on
|
|
7
|
+
`DIRTY` (one inline anchored comment per finding). It reads its body skeleton
|
|
8
|
+
from [`audit-reply-template.md`](audit-reply-template.md) at runtime.
|
|
9
|
+
|
|
10
|
+
## Invocation
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
python "<pr-loop-scripts>/post_audit_thread.py" \
|
|
14
|
+
--skill <caller> \
|
|
15
|
+
--owner <owner> \
|
|
16
|
+
--repo <repo> \
|
|
17
|
+
--pr-number <N> \
|
|
18
|
+
--commit <head_sha> \
|
|
19
|
+
--state <CLEAN|DIRTY> \
|
|
20
|
+
--findings-json <path>
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Capture `<head_sha>` with `git rev-parse HEAD` in the caller's cwd right before
|
|
24
|
+
the call, so the review attaches to the commit the audit scoped. `--findings-json`
|
|
25
|
+
points to a JSON file whose root is a list; on `CLEAN` the list is empty (`[]`).
|
|
26
|
+
|
|
27
|
+
## Exit codes
|
|
28
|
+
|
|
29
|
+
The script retries on its own with 1s / 4s / 16s backoff across four tries (one
|
|
30
|
+
plus three retries). It then exits:
|
|
31
|
+
|
|
32
|
+
| Exit | Meaning |
|
|
33
|
+
|---|---|
|
|
34
|
+
| `0` | Review posted. The new review's `html_url` is on stdout. |
|
|
35
|
+
| `1` | User input error: bad arguments, malformed findings JSON, or a missing template. |
|
|
36
|
+
| `2` | Retry exhaustion: every try failed. |
|
|
37
|
+
|
|
38
|
+
## Per-caller policy
|
|
39
|
+
|
|
40
|
+
The same exit codes carry different weight per caller:
|
|
41
|
+
|
|
42
|
+
- **bugteam** — an exit `2` is a hard blocker. The lead exits with
|
|
43
|
+
`error: post_audit_thread retry exhausted`, and does not retry or fall back to
|
|
44
|
+
a flat issue comment.
|
|
45
|
+
- **autoconverge clean-audit** — any post that does not land (a denied post, an
|
|
46
|
+
error, or an agent that never ran) is a recorded bypass, not a blocker. Every
|
|
47
|
+
review lens already cleared the HEAD, so the CLEAN post is a record-keeping
|
|
48
|
+
artifact. The run records a bypass note, sets the convergence check's
|
|
49
|
+
`--bugteam-post-blocked` flag, and proceeds to the terminal Bugbot gate. Full
|
|
50
|
+
policy: [`../../skills/autoconverge/reference/stop-conditions.md`](../../skills/autoconverge/reference/stop-conditions.md)
|
|
51
|
+
§ "Clean-audit post bypassed".
|
|
@@ -9,11 +9,11 @@ Runnable helpers used by **bugteam**, **qbug**, **pr-converge**, and related ski
|
|
|
9
9
|
| `preflight.py` | Local checks before a PR-loop run (pytest discovery, optional pre-commit, hooksPath sanity). |
|
|
10
10
|
| `reviewer_availability.py` | Unified availability check for Copilot and Bugbot; reuses `copilot_quota.py` and `reviews_disabled.py` and exits 0 when the named `--reviewer` may be spawned. |
|
|
11
11
|
| `code_rules_gate.py` | CODE_RULES gate over PR-scoped diffs (`--base`, staged-only, path filters). |
|
|
12
|
-
| `fix_hookspath.py` | Repair `core.hooksPath` when it does not point at the packaged git-hooks tree. |
|
|
12
|
+
| `fix_hookspath.py` | Repair `core.hooksPath` when it does not point at the packaged git-hooks tree. Single home for unexpected non-empty git stderr (hard-fail `RuntimeError`); skill wrappers may call it later. |
|
|
13
13
|
| `grant_project_claude_permissions.py` / `revoke_project_claude_permissions.py` | Claude Code permission JSON helpers used during publish-style flows. |
|
|
14
14
|
| `_claude_permissions_common.py` | Shared implementation for the permission scripts. |
|
|
15
15
|
|
|
16
|
-
Configuration lives under `
|
|
16
|
+
Configuration lives under `pr_loop_shared_constants/` next to these scripts (for example `preflight_constants.py`, `code_rules_gate_constants.py`, `fix_hookspath_constants.py`).
|
|
17
17
|
|
|
18
18
|
## Gate semantics
|
|
19
19
|
|
|
@@ -28,6 +28,7 @@ from pr_loop_shared_constants.code_rules_gate_constants import ( # noqa: E402
|
|
|
28
28
|
BANNED_NOUN_VIOLATION_PATTERN,
|
|
29
29
|
CODE_RULES_GATE_PYTHON_ENV_VAR,
|
|
30
30
|
CODE_RULES_GATE_PYTHONPATH_ENV_VAR,
|
|
31
|
+
COMMAND_LINE_ARGUMENT_SEPARATOR_LENGTH,
|
|
31
32
|
DUPLICATE_BODY_DEFINITION_LINE_GROUP_INDEX,
|
|
32
33
|
DUPLICATE_BODY_SPAN_GROUP_INDEX,
|
|
33
34
|
DUPLICATE_BODY_VIOLATION_PATTERN,
|
|
@@ -41,6 +42,7 @@ from pr_loop_shared_constants.code_rules_gate_constants import ( # noqa: E402
|
|
|
41
42
|
ISOLATION_DEFINITION_LINE_GROUP_INDEX,
|
|
42
43
|
ISOLATION_SPAN_GROUP_INDEX,
|
|
43
44
|
ISOLATION_VIOLATION_PATTERN,
|
|
45
|
+
MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS,
|
|
44
46
|
MAX_VIOLATIONS_PER_CHECK,
|
|
45
47
|
MINIMUM_STAGED_PYTEST_PYTHON_MAJOR,
|
|
46
48
|
MINIMUM_STAGED_PYTEST_PYTHON_MINOR,
|
|
@@ -1710,11 +1712,114 @@ def _staged_pytest_environment() -> dict[str, str]:
|
|
|
1710
1712
|
return environment
|
|
1711
1713
|
|
|
1712
1714
|
|
|
1715
|
+
def _relative_pytest_argument(test_path: Path, group_root: Path) -> str:
|
|
1716
|
+
"""Express *test_path* as a pytest argument relative to *group_root*.
|
|
1717
|
+
|
|
1718
|
+
*group_root* is already the pytest working directory, so the relative form
|
|
1719
|
+
names the same file in far fewer characters.
|
|
1720
|
+
|
|
1721
|
+
Example::
|
|
1722
|
+
|
|
1723
|
+
group_root = C:/repo/package
|
|
1724
|
+
ok: C:/repo/package/tests/test_a.py -> "tests/test_a.py"
|
|
1725
|
+
flag: C:/elsewhere/test_b.py -> no relative form; passed absolute
|
|
1726
|
+
|
|
1727
|
+
Args:
|
|
1728
|
+
test_path: The staged test file to express as a pytest argument.
|
|
1729
|
+
group_root: The pytest working directory for the group.
|
|
1730
|
+
|
|
1731
|
+
Returns:
|
|
1732
|
+
The path relative to *group_root* when *test_path* sits under it,
|
|
1733
|
+
otherwise the absolute path unchanged.
|
|
1734
|
+
"""
|
|
1735
|
+
try:
|
|
1736
|
+
return str(test_path.relative_to(group_root))
|
|
1737
|
+
except ValueError:
|
|
1738
|
+
return str(test_path)
|
|
1739
|
+
|
|
1740
|
+
|
|
1741
|
+
def _batched_pytest_arguments(
|
|
1742
|
+
all_pytest_arguments: list[str], character_budget: int
|
|
1743
|
+
) -> list[list[str]]:
|
|
1744
|
+
"""Split pytest path arguments into command-line-length-safe batches.
|
|
1745
|
+
|
|
1746
|
+
Windows rejects a command line past 32767 characters with WinError 206, so a
|
|
1747
|
+
large staged set passed in one invocation crashes the gate and denies the
|
|
1748
|
+
commit. Each batch takes arguments until the next one would push its joined
|
|
1749
|
+
length past *character_budget*.
|
|
1750
|
+
|
|
1751
|
+
Example::
|
|
1752
|
+
|
|
1753
|
+
character_budget = 10
|
|
1754
|
+
arguments = ["aaaa", "bbbb", "cccc"]
|
|
1755
|
+
ok: [["aaaa", "bbbb"], ["cccc"]] - every batch within budget
|
|
1756
|
+
flag: [["aaaa", "bbbb", "cccc"]] - 14 characters, over budget
|
|
1757
|
+
|
|
1758
|
+
Args:
|
|
1759
|
+
all_pytest_arguments: The path arguments to distribute, in order.
|
|
1760
|
+
character_budget: The joined length each batch must stay within. An
|
|
1761
|
+
argument wider than the budget on its own still lands in a batch
|
|
1762
|
+
by itself, so no argument is ever dropped.
|
|
1763
|
+
|
|
1764
|
+
Returns:
|
|
1765
|
+
One argument list per pytest invocation, preserving input order.
|
|
1766
|
+
"""
|
|
1767
|
+
all_batches: list[list[str]] = []
|
|
1768
|
+
current_batch: list[str] = []
|
|
1769
|
+
current_length = 0
|
|
1770
|
+
for each_argument in all_pytest_arguments:
|
|
1771
|
+
argument_length = len(each_argument) + COMMAND_LINE_ARGUMENT_SEPARATOR_LENGTH
|
|
1772
|
+
if current_batch and current_length + argument_length > character_budget:
|
|
1773
|
+
all_batches.append(current_batch)
|
|
1774
|
+
current_batch = []
|
|
1775
|
+
current_length = 0
|
|
1776
|
+
current_batch.append(each_argument)
|
|
1777
|
+
current_length += argument_length
|
|
1778
|
+
if current_batch:
|
|
1779
|
+
all_batches.append(current_batch)
|
|
1780
|
+
return all_batches
|
|
1781
|
+
|
|
1782
|
+
|
|
1783
|
+
def _pytest_batch_exit_code(all_batch_command: list[str], group_root: Path) -> int:
|
|
1784
|
+
"""Run one pytest invocation and normalize its exit code.
|
|
1785
|
+
|
|
1786
|
+
Args:
|
|
1787
|
+
all_batch_command: The full argv for this pytest invocation.
|
|
1788
|
+
group_root: The owning test root used as the working directory.
|
|
1789
|
+
|
|
1790
|
+
Returns:
|
|
1791
|
+
0 when the batch passes or collects no tests; pytest's non-zero exit
|
|
1792
|
+
code otherwise.
|
|
1793
|
+
"""
|
|
1794
|
+
pytest_process = subprocess.run(
|
|
1795
|
+
all_batch_command,
|
|
1796
|
+
cwd=str(group_root),
|
|
1797
|
+
timeout=STAGED_PYTEST_TIMEOUT_SECONDS,
|
|
1798
|
+
check=False,
|
|
1799
|
+
env=_staged_pytest_environment(),
|
|
1800
|
+
)
|
|
1801
|
+
if pytest_process.returncode == PYTEST_NO_TESTS_COLLECTED_EXIT_CODE:
|
|
1802
|
+
return 0
|
|
1803
|
+
return pytest_process.returncode
|
|
1804
|
+
|
|
1805
|
+
|
|
1713
1806
|
def _run_pytest_for_group(
|
|
1714
1807
|
group_root: Path, all_group_test_paths: list[Path], repository_root: Path
|
|
1715
1808
|
) -> int:
|
|
1716
1809
|
"""Run pytest over one group's test files with the working directory at its root.
|
|
1717
1810
|
|
|
1811
|
+
Paths are passed relative to *group_root* (the subprocess working directory)
|
|
1812
|
+
and split into batches whose joined command line stays within
|
|
1813
|
+
``MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS``, so a large staged set never
|
|
1814
|
+
trips the Windows 32767-character limit. A staged set that fits the budget runs
|
|
1815
|
+
as one invocation, exactly as an unbatched run did.
|
|
1816
|
+
|
|
1817
|
+
Example::
|
|
1818
|
+
|
|
1819
|
+
ok: 3 staged tests -> 1 pytest invocation, argv well under the budget
|
|
1820
|
+
flag: 800 staged tests -> 1 pytest invocation, argv over the Windows limit
|
|
1821
|
+
(WinError 206); batching splits it across several
|
|
1822
|
+
|
|
1718
1823
|
Args:
|
|
1719
1824
|
group_root: The owning test root used as the pytest working directory.
|
|
1720
1825
|
all_group_test_paths: The staged test files that share *group_root*.
|
|
@@ -1722,23 +1827,32 @@ def _run_pytest_for_group(
|
|
|
1722
1827
|
interpreter when ``CODE_RULES_GATE_PYTHON`` is not set.
|
|
1723
1828
|
|
|
1724
1829
|
Returns:
|
|
1725
|
-
0 when
|
|
1726
|
-
exit code otherwise.
|
|
1830
|
+
0 when every batch passes or collects nothing; the first failing batch's
|
|
1831
|
+
non-zero pytest exit code otherwise.
|
|
1727
1832
|
"""
|
|
1728
|
-
|
|
1833
|
+
all_fixed_command = [
|
|
1834
|
+
_resolve_gate_python_executable(repository_root),
|
|
1835
|
+
*ALL_PYTEST_MODULE_INVOCATION,
|
|
1836
|
+
]
|
|
1837
|
+
fixed_command_length = sum(
|
|
1838
|
+
len(each_part) + COMMAND_LINE_ARGUMENT_SEPARATOR_LENGTH
|
|
1839
|
+
for each_part in all_fixed_command
|
|
1840
|
+
)
|
|
1841
|
+
all_batches = _batched_pytest_arguments(
|
|
1729
1842
|
[
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
*[str(each_path) for each_path in all_group_test_paths],
|
|
1843
|
+
_relative_pytest_argument(each_path, group_root)
|
|
1844
|
+
for each_path in all_group_test_paths
|
|
1733
1845
|
],
|
|
1734
|
-
|
|
1735
|
-
timeout=STAGED_PYTEST_TIMEOUT_SECONDS,
|
|
1736
|
-
check=False,
|
|
1737
|
-
env=_staged_pytest_environment(),
|
|
1846
|
+
MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS - fixed_command_length,
|
|
1738
1847
|
)
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1848
|
+
first_failing_exit_code = 0
|
|
1849
|
+
for each_batch in all_batches:
|
|
1850
|
+
batch_exit_code = _pytest_batch_exit_code(
|
|
1851
|
+
[*all_fixed_command, *each_batch], group_root
|
|
1852
|
+
)
|
|
1853
|
+
if batch_exit_code != 0 and first_failing_exit_code == 0:
|
|
1854
|
+
first_failing_exit_code = batch_exit_code
|
|
1855
|
+
return first_failing_exit_code
|
|
1742
1856
|
|
|
1743
1857
|
|
|
1744
1858
|
def run_staged_test_files(repository_root: Path) -> int:
|
|
@@ -53,6 +53,9 @@ def list_local_core_hooks_path_values(
|
|
|
53
53
|
Returns:
|
|
54
54
|
Non-empty stripped values from ``git config --local --get-all``, or
|
|
55
55
|
an empty list when no values are configured.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
RuntimeError: When git exits non-zero with a non-empty stderr diagnostic.
|
|
56
59
|
"""
|
|
57
60
|
git_command = [
|
|
58
61
|
"git",
|
|
@@ -75,11 +78,9 @@ def list_local_core_hooks_path_values(
|
|
|
75
78
|
if completed_process.returncode != 0:
|
|
76
79
|
diagnostic_stderr = completed_process.stderr.strip()
|
|
77
80
|
if diagnostic_stderr:
|
|
78
|
-
|
|
79
|
-
"
|
|
80
|
-
f"
|
|
81
|
-
f"{diagnostic_stderr}",
|
|
82
|
-
file=sys.stderr,
|
|
81
|
+
raise RuntimeError(
|
|
82
|
+
f"git read of local core.hooksPath on {repository_root} "
|
|
83
|
+
f"exited {completed_process.returncode}: {diagnostic_stderr}"
|
|
83
84
|
)
|
|
84
85
|
return []
|
|
85
86
|
return [
|
|
@@ -99,8 +100,11 @@ def read_global_core_hooks_path(
|
|
|
99
100
|
forwarded to ``subprocess.run``.
|
|
100
101
|
|
|
101
102
|
Returns:
|
|
102
|
-
The stripped global value, or an empty string when
|
|
103
|
-
|
|
103
|
+
The stripped global value, or an empty string when the key is unset
|
|
104
|
+
(git exits non-zero with empty stderr).
|
|
105
|
+
|
|
106
|
+
Raises:
|
|
107
|
+
RuntimeError: When git exits non-zero with a non-empty stderr diagnostic.
|
|
104
108
|
"""
|
|
105
109
|
git_command = list(ALL_GIT_GLOBAL_GET_CORE_HOOKS_PATH_COMMAND)
|
|
106
110
|
completed_process = subprocess.run(
|
|
@@ -115,10 +119,9 @@ def read_global_core_hooks_path(
|
|
|
115
119
|
if completed_process.returncode != 0:
|
|
116
120
|
diagnostic_stderr = completed_process.stderr.strip()
|
|
117
121
|
if diagnostic_stderr:
|
|
118
|
-
|
|
119
|
-
"
|
|
120
|
-
f"{completed_process.returncode}: {diagnostic_stderr}"
|
|
121
|
-
file=sys.stderr,
|
|
122
|
+
raise RuntimeError(
|
|
123
|
+
f"git read of global core.hooksPath exited "
|
|
124
|
+
f"{completed_process.returncode}: {diagnostic_stderr}"
|
|
122
125
|
)
|
|
123
126
|
return ""
|
|
124
127
|
return completed_process.stdout.strip()
|
|
@@ -280,7 +283,8 @@ def main(
|
|
|
280
283
|
forwarded to every git invocation and to the preflight rerun.
|
|
281
284
|
|
|
282
285
|
Returns:
|
|
283
|
-
Zero on success. Non-zero on the first failing git command
|
|
286
|
+
Zero on success. Non-zero on the first failing git command, on a
|
|
287
|
+
hard-fail git stderr diagnostic (caught ``RuntimeError``), or on a
|
|
284
288
|
non-zero preflight rerun exit code.
|
|
285
289
|
"""
|
|
286
290
|
arguments = parse_arguments(all_arguments)
|
|
@@ -302,6 +306,37 @@ def main(
|
|
|
302
306
|
file=sys.stderr,
|
|
303
307
|
)
|
|
304
308
|
return 1
|
|
309
|
+
try:
|
|
310
|
+
return _repair_hooks_path_then_rerun_preflight(
|
|
311
|
+
repository_root=repository_root,
|
|
312
|
+
canonical_hooks_directory=canonical_hooks_directory,
|
|
313
|
+
all_environment_overrides=all_environment_overrides,
|
|
314
|
+
)
|
|
315
|
+
except RuntimeError as runtime_error:
|
|
316
|
+
print(f"fix_hookspath: {runtime_error}", file=sys.stderr)
|
|
317
|
+
return 1
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _repair_hooks_path_then_rerun_preflight(
|
|
321
|
+
repository_root: Path,
|
|
322
|
+
canonical_hooks_directory: Path,
|
|
323
|
+
all_environment_overrides: dict[str, str] | None,
|
|
324
|
+
) -> int:
|
|
325
|
+
"""Apply local/global hooksPath repairs, then re-run preflight.
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
repository_root: Repository root used for local git config and preflight.
|
|
329
|
+
canonical_hooks_directory: Absolute path to the packaged git-hooks tree.
|
|
330
|
+
all_environment_overrides: Optional environment variable mapping
|
|
331
|
+
forwarded to every git invocation and to the preflight rerun.
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
Zero on success. Non-zero on the first failing git write or on a
|
|
335
|
+
non-zero preflight rerun exit code.
|
|
336
|
+
|
|
337
|
+
Raises:
|
|
338
|
+
RuntimeError: When a git read exits non-zero with non-empty stderr.
|
|
339
|
+
"""
|
|
305
340
|
local_hooks_path_values = list_local_core_hooks_path_values(
|
|
306
341
|
repository_root,
|
|
307
342
|
all_environment_overrides,
|
|
@@ -102,6 +102,10 @@ ALL_POSIX_VENV_PYTHON_RELATIVE_PATH_SEGMENTS: tuple[str, ...] = ("bin", "python"
|
|
|
102
102
|
|
|
103
103
|
STAGED_PYTEST_TIMEOUT_SECONDS: int = 600
|
|
104
104
|
|
|
105
|
+
MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS: int = 24000
|
|
106
|
+
|
|
107
|
+
COMMAND_LINE_ARGUMENT_SEPARATOR_LENGTH: int = 1
|
|
108
|
+
|
|
105
109
|
STAGED_TEST_FAILURE_HEADER: str = (
|
|
106
110
|
"code_rules_gate: staged test file(s) failed under pytest; commit blocked."
|
|
107
111
|
)
|
|
@@ -483,16 +483,11 @@ def test_check_wrapper_plumb_through_exempts_test_files() -> None:
|
|
|
483
483
|
" return _helper('a', clean_name='b')\n"
|
|
484
484
|
)
|
|
485
485
|
shared_issues = gate_module.check_wrapper_plumb_through(source, "pkg/test_thing.py")
|
|
486
|
-
bugteam_gate = _load_bugteam_gate_module()
|
|
487
|
-
bugteam_issues = bugteam_gate.check_wrapper_plumb_through(source, "pkg/test_thing.py")
|
|
488
486
|
assert shared_issues == [], (
|
|
489
487
|
"a test_* function in a test-file path that calls a module-level helper "
|
|
490
488
|
"exposing an optional kwarg is not a wrapper; the shared gate must exempt "
|
|
491
489
|
"test files and emit zero findings"
|
|
492
490
|
)
|
|
493
|
-
assert bugteam_issues == [], (
|
|
494
|
-
"the bugteam gate copy must apply the identical test-file exemption"
|
|
495
|
-
)
|
|
496
491
|
|
|
497
492
|
|
|
498
493
|
def test_check_wrapper_plumb_through_still_flags_non_test_path_with_test_shape() -> None:
|
|
@@ -1245,8 +1240,6 @@ def test_check_wrapper_plumb_through_dedupes_nested_public_function_calls() -> N
|
|
|
1245
1240
|
assert len(issues) == 1, f"expected 1 finding, got {len(issues)}: {issues!r}"
|
|
1246
1241
|
|
|
1247
1242
|
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
1243
|
def test_check_wrapper_plumb_through_ignores_calls_nested_inside_delegate_arguments() -> None:
|
|
1251
1244
|
"""Regression: nested callees inside another call's arguments are not wrapper sites.
|
|
1252
1245
|
|
|
@@ -1766,68 +1759,6 @@ def test_main_blocks_banned_noun_on_added_lines_past_document_order(
|
|
|
1766
1759
|
)
|
|
1767
1760
|
|
|
1768
1761
|
|
|
1769
|
-
def _load_bugteam_gate_module() -> ModuleType:
|
|
1770
|
-
bugteam_scripts_dir = (
|
|
1771
|
-
Path(__file__).resolve().parents[4]
|
|
1772
|
-
/ "skills"
|
|
1773
|
-
/ "bugteam"
|
|
1774
|
-
/ "scripts"
|
|
1775
|
-
)
|
|
1776
|
-
if str(bugteam_scripts_dir) not in sys.path:
|
|
1777
|
-
sys.path.insert(0, str(bugteam_scripts_dir))
|
|
1778
|
-
module_path = bugteam_scripts_dir / "bugteam_code_rules_gate.py"
|
|
1779
|
-
spec = importlib.util.spec_from_file_location("bugteam_code_rules_gate", module_path)
|
|
1780
|
-
assert spec is not None
|
|
1781
|
-
assert spec.loader is not None
|
|
1782
|
-
module = importlib.util.module_from_spec(spec)
|
|
1783
|
-
spec.loader.exec_module(module)
|
|
1784
|
-
return module
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
def test_both_gates_classify_wrapper_plumb_through_identically() -> None:
|
|
1788
|
-
"""The bugteam and _shared gate copies of check_wrapper_plumb_through must
|
|
1789
|
-
return identical findings. A class method calling a module-level delegate
|
|
1790
|
-
is not a wrapper; both gates must exclude it rather than one emitting a
|
|
1791
|
-
false positive the other does not."""
|
|
1792
|
-
bugteam_gate = _load_bugteam_gate_module()
|
|
1793
|
-
class_method_calling_delegate = (
|
|
1794
|
-
"def fetch(target, *, retries=3):\n"
|
|
1795
|
-
" return target\n"
|
|
1796
|
-
"\n"
|
|
1797
|
-
"class MyService:\n"
|
|
1798
|
-
" def public_method(self, target):\n"
|
|
1799
|
-
" return fetch(target)\n"
|
|
1800
|
-
)
|
|
1801
|
-
nested_call_inside_delegate_argument = (
|
|
1802
|
-
"def delegate(value, *, retries=3):\n"
|
|
1803
|
-
" return value\n"
|
|
1804
|
-
"\n"
|
|
1805
|
-
"def helper(value):\n"
|
|
1806
|
-
" return value\n"
|
|
1807
|
-
"\n"
|
|
1808
|
-
"def public_caller(value):\n"
|
|
1809
|
-
" return delegate(helper(value))\n"
|
|
1810
|
-
)
|
|
1811
|
-
name_call_dropping_kwarg = (
|
|
1812
|
-
"def delegate(value, *, retries=3):\n"
|
|
1813
|
-
" return value\n"
|
|
1814
|
-
"\n"
|
|
1815
|
-
"def public_wrapper(value):\n"
|
|
1816
|
-
" return delegate(value)\n"
|
|
1817
|
-
)
|
|
1818
|
-
for each_source in (
|
|
1819
|
-
class_method_calling_delegate,
|
|
1820
|
-
nested_call_inside_delegate_argument,
|
|
1821
|
-
name_call_dropping_kwarg,
|
|
1822
|
-
):
|
|
1823
|
-
shared_issues = gate_module.check_wrapper_plumb_through(each_source, "module.py")
|
|
1824
|
-
bugteam_issues = bugteam_gate.check_wrapper_plumb_through(each_source, "module.py")
|
|
1825
|
-
assert shared_issues == bugteam_issues, (
|
|
1826
|
-
"both gate copies of check_wrapper_plumb_through must classify "
|
|
1827
|
-
f"identically; shared={shared_issues!r} bugteam={bugteam_issues!r}"
|
|
1828
|
-
)
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
1762
|
def test_check_wrapper_plumb_through_stays_under_function_length_threshold() -> None:
|
|
1832
1763
|
"""check_wrapper_plumb_through must stay under the enforcer's function-length
|
|
1833
1764
|
blocking threshold so editing it (e.g. aligning the two gate copies) does
|
|
@@ -2722,3 +2653,149 @@ def test_staged_pytest_environment_prepends_configured_pythonpath(
|
|
|
2722
2653
|
assert environment["PYTHONPATH"] == os.pathsep.join(
|
|
2723
2654
|
["/configured/site-packages", "/already/on/path"]
|
|
2724
2655
|
)
|
|
2656
|
+
|
|
2657
|
+
|
|
2658
|
+
def _record_pytest_invocations(
|
|
2659
|
+
monkeypatch: pytest.MonkeyPatch, *, all_exit_codes: list[int]
|
|
2660
|
+
) -> list[list[str]]:
|
|
2661
|
+
"""Patch subprocess.run to record each pytest argv and return staged exit codes."""
|
|
2662
|
+
all_recorded_commands: list[list[str]] = []
|
|
2663
|
+
all_remaining_exit_codes = list(all_exit_codes)
|
|
2664
|
+
|
|
2665
|
+
def _fake_subprocess_run(
|
|
2666
|
+
all_command: list[str], **_keyword_arguments: object
|
|
2667
|
+
) -> subprocess.CompletedProcess[bytes]:
|
|
2668
|
+
all_recorded_commands.append(list(all_command))
|
|
2669
|
+
exit_code = all_remaining_exit_codes.pop(0) if all_remaining_exit_codes else 0
|
|
2670
|
+
return subprocess.CompletedProcess(all_command, exit_code, b"", b"")
|
|
2671
|
+
|
|
2672
|
+
monkeypatch.setattr(gate_module.subprocess, "run", _fake_subprocess_run)
|
|
2673
|
+
return all_recorded_commands
|
|
2674
|
+
|
|
2675
|
+
|
|
2676
|
+
def _staged_test_paths_under(group_root: Path, path_count: int) -> list[Path]:
|
|
2677
|
+
return [
|
|
2678
|
+
group_root
|
|
2679
|
+
/ "tests"
|
|
2680
|
+
/ "subpackage_with_a_realistically_long_directory_name"
|
|
2681
|
+
/ f"test_module_with_a_realistically_long_name_{each_index:04d}.py"
|
|
2682
|
+
for each_index in range(path_count)
|
|
2683
|
+
]
|
|
2684
|
+
|
|
2685
|
+
|
|
2686
|
+
def _path_arguments_of(all_pytest_command: list[str]) -> list[str]:
|
|
2687
|
+
fixed_prefix_length = len(gate_module.ALL_PYTEST_MODULE_INVOCATION) + 1
|
|
2688
|
+
return all_pytest_command[fixed_prefix_length:]
|
|
2689
|
+
|
|
2690
|
+
|
|
2691
|
+
def test_run_pytest_for_group_batches_a_large_staged_set_under_the_budget(
|
|
2692
|
+
tmp_path: Path,
|
|
2693
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
2694
|
+
) -> None:
|
|
2695
|
+
"""WinError 206: a staged set whose joined argv passes Windows' 32767-character
|
|
2696
|
+
command-line limit crashed the gate and denied the commit. The paths must be
|
|
2697
|
+
split across several pytest invocations, none of them over the budget.
|
|
2698
|
+
"""
|
|
2699
|
+
all_recorded_commands = _record_pytest_invocations(monkeypatch, all_exit_codes=[])
|
|
2700
|
+
all_test_paths = _staged_test_paths_under(tmp_path, 800)
|
|
2701
|
+
|
|
2702
|
+
exit_code = gate_module._run_pytest_for_group(tmp_path, all_test_paths, tmp_path)
|
|
2703
|
+
|
|
2704
|
+
assert exit_code == 0
|
|
2705
|
+
assert len(all_recorded_commands) > 1, (
|
|
2706
|
+
"a staged set wider than the command-line budget must be split across "
|
|
2707
|
+
"several pytest invocations rather than passed in one oversized argv"
|
|
2708
|
+
)
|
|
2709
|
+
for each_command in all_recorded_commands:
|
|
2710
|
+
assert (
|
|
2711
|
+
len(" ".join(each_command))
|
|
2712
|
+
<= gate_module.MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS
|
|
2713
|
+
)
|
|
2714
|
+
all_batched_arguments = [
|
|
2715
|
+
each_argument
|
|
2716
|
+
for each_command in all_recorded_commands
|
|
2717
|
+
for each_argument in _path_arguments_of(each_command)
|
|
2718
|
+
]
|
|
2719
|
+
assert len(all_batched_arguments) == len(all_test_paths)
|
|
2720
|
+
|
|
2721
|
+
|
|
2722
|
+
def test_run_pytest_for_group_passes_paths_relative_to_the_group_root(
|
|
2723
|
+
tmp_path: Path,
|
|
2724
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
2725
|
+
) -> None:
|
|
2726
|
+
"""group_root is already the subprocess cwd, so each path argument is passed
|
|
2727
|
+
relative to it - the shortening that keeps a large staged set under the limit.
|
|
2728
|
+
"""
|
|
2729
|
+
all_recorded_commands = _record_pytest_invocations(monkeypatch, all_exit_codes=[])
|
|
2730
|
+
test_path = tmp_path / "tests" / "test_relative.py"
|
|
2731
|
+
|
|
2732
|
+
gate_module._run_pytest_for_group(tmp_path, [test_path], tmp_path)
|
|
2733
|
+
|
|
2734
|
+
assert _path_arguments_of(all_recorded_commands[0]) == [
|
|
2735
|
+
str(Path("tests") / "test_relative.py")
|
|
2736
|
+
]
|
|
2737
|
+
|
|
2738
|
+
|
|
2739
|
+
def test_run_pytest_for_group_passes_an_outside_path_absolute(
|
|
2740
|
+
tmp_path: Path,
|
|
2741
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
2742
|
+
) -> None:
|
|
2743
|
+
all_recorded_commands = _record_pytest_invocations(monkeypatch, all_exit_codes=[])
|
|
2744
|
+
group_root = tmp_path / "group"
|
|
2745
|
+
outside_path = tmp_path / "elsewhere" / "test_outside.py"
|
|
2746
|
+
|
|
2747
|
+
gate_module._run_pytest_for_group(group_root, [outside_path], tmp_path)
|
|
2748
|
+
|
|
2749
|
+
assert _path_arguments_of(all_recorded_commands[0]) == [str(outside_path)]
|
|
2750
|
+
|
|
2751
|
+
|
|
2752
|
+
def test_run_pytest_for_group_runs_one_invocation_for_a_small_staged_set(
|
|
2753
|
+
tmp_path: Path,
|
|
2754
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
2755
|
+
) -> None:
|
|
2756
|
+
all_recorded_commands = _record_pytest_invocations(monkeypatch, all_exit_codes=[])
|
|
2757
|
+
all_test_paths = _staged_test_paths_under(tmp_path, 3)
|
|
2758
|
+
|
|
2759
|
+
exit_code = gate_module._run_pytest_for_group(tmp_path, all_test_paths, tmp_path)
|
|
2760
|
+
|
|
2761
|
+
assert exit_code == 0
|
|
2762
|
+
assert len(all_recorded_commands) == 1, (
|
|
2763
|
+
"a staged set that fits the budget must behave exactly as before: "
|
|
2764
|
+
"one pytest invocation carrying every path"
|
|
2765
|
+
)
|
|
2766
|
+
|
|
2767
|
+
|
|
2768
|
+
def test_run_pytest_for_group_surfaces_the_first_failing_batch_exit_code(
|
|
2769
|
+
tmp_path: Path,
|
|
2770
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
2771
|
+
) -> None:
|
|
2772
|
+
"""A failure in any batch must surface, and every batch must still run so the
|
|
2773
|
+
operator sees the whole staged-test picture rather than the first failure alone.
|
|
2774
|
+
"""
|
|
2775
|
+
all_recorded_commands = _record_pytest_invocations(
|
|
2776
|
+
monkeypatch, all_exit_codes=[0, 1, 2]
|
|
2777
|
+
)
|
|
2778
|
+
all_test_paths = _staged_test_paths_under(tmp_path, 800)
|
|
2779
|
+
|
|
2780
|
+
exit_code = gate_module._run_pytest_for_group(tmp_path, all_test_paths, tmp_path)
|
|
2781
|
+
|
|
2782
|
+
assert exit_code == 1
|
|
2783
|
+
assert len(all_recorded_commands) >= 3
|
|
2784
|
+
|
|
2785
|
+
|
|
2786
|
+
def test_run_pytest_for_group_treats_no_tests_collected_as_a_pass_per_batch(
|
|
2787
|
+
tmp_path: Path,
|
|
2788
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
2789
|
+
) -> None:
|
|
2790
|
+
_record_pytest_invocations(
|
|
2791
|
+
monkeypatch,
|
|
2792
|
+
all_exit_codes=[gate_module.PYTEST_NO_TESTS_COLLECTED_EXIT_CODE, 0],
|
|
2793
|
+
)
|
|
2794
|
+
all_test_paths = _staged_test_paths_under(tmp_path, 800)
|
|
2795
|
+
|
|
2796
|
+
exit_code = gate_module._run_pytest_for_group(tmp_path, all_test_paths, tmp_path)
|
|
2797
|
+
|
|
2798
|
+
assert exit_code == 0, (
|
|
2799
|
+
"a batch that collects no tests is not a failure; only a real pytest "
|
|
2800
|
+
"failure exit code may block the commit"
|
|
2801
|
+
)
|