claude-dev-env 2.5.0 → 2.7.1
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/_shared/pr-loop/worker-spawn.md +3 -1
- 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/CLAUDE.md +3 -3
- 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/CLAUDE.md +1 -1
- package/scripts/dev_env_scripts_constants/code_review_constants.py +37 -0
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +79 -13
- package/scripts/grok_headless_runner.py +213 -16
- package/scripts/invoke_code_review.py +11 -4
- package/scripts/resolve_worker_spawn.py +56 -10
- package/scripts/spawn_grok_batch.py +43 -22
- package/scripts/sync_to_cursor/rules.py +0 -10
- package/scripts/test_grok_headless_runner.py +592 -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/test_resolve_worker_spawn.py +179 -15
- package/scripts/test_spawn_grok_batch.py +225 -22
- 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.contract.test.mjs +28 -6
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +73 -0
- package/skills/autoconverge/workflow/converge.mjs +64 -15
- 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/grok-spawn/SKILL.md +5 -3
- package/skills/grok-spawn/reference/flag-profiles.md +3 -1
- 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
package/CLAUDE.md
CHANGED
|
@@ -1,88 +1,51 @@
|
|
|
1
1
|
# Development Assistant
|
|
2
2
|
|
|
3
|
-
## Advisor consultation
|
|
4
|
-
|
|
5
|
-
When the `advisor()` tool is available, reference `~/.claude/docs/references/advisor-tool.md`. For complex tasks, reference `~/.claude/docs/references/team-advisor-skill.md` and use the `/team-advisor` skill.
|
|
6
|
-
|
|
7
3
|
## Communication
|
|
8
4
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
Write concise, ADHD-friendly responses.
|
|
12
|
-
|
|
13
|
-
- Always say what is, rather than what is not.
|
|
14
|
-
- Lead with the outcome.
|
|
15
|
-
- Use short, active sentences with one idea each.
|
|
16
|
-
- Put meaning before mechanism.
|
|
17
|
-
- Explain jargon on first use.
|
|
18
|
-
- Use plain-claim headings and bold leads.
|
|
19
|
-
- Limit bullets to two sentences and paragraphs to three.
|
|
20
|
-
- Omit repetition, narration, unnecessary options, and trailing notes.
|
|
21
|
-
- End with what the reader must know or decide.
|
|
22
|
-
|
|
23
|
-
## Execution and security
|
|
24
|
-
|
|
25
|
-
For code tasks, execute available steps directly and minimize manual work.
|
|
5
|
+
Reply shape and length: follow `~/.claude/rules/eli11-replies.md`. Word choice: follow `~/.claude/rules/plain-language.md`. State claims affirmatively.
|
|
26
6
|
|
|
27
|
-
|
|
7
|
+
## Security
|
|
28
8
|
|
|
29
|
-
|
|
9
|
+
Collect credentials through secure UI only; never request secrets in chat.
|
|
30
10
|
|
|
31
11
|
A runtime value that is itself private — a host, an SSH user or port, an owner scope, an account ID — lives in git-ignored local configuration with a committed placeholder in its place. Source files never carry the real value.
|
|
32
12
|
|
|
33
|
-
##
|
|
13
|
+
## Advisors
|
|
34
14
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
Follow:
|
|
38
|
-
|
|
39
|
-
- `~/.claude/rules/no-historical-clutter.md`
|
|
40
|
-
- `~/.claude/rules/self-contained-docs.md`
|
|
41
|
-
- `~/.claude/skills/condensing-instructions/SKILL.md`
|
|
42
|
-
|
|
43
|
-
## File edits
|
|
44
|
-
|
|
45
|
-
`Edit` changes an existing file. `Write` creates a new file. Default to `Edit`; reach for `Write` only when the path is genuinely new.
|
|
15
|
+
When the `advisor()` tool is available, read `~/.claude/docs/references/advisor-tool.md`. For complex tasks use the `/team-advisor` skill.
|
|
46
16
|
|
|
47
17
|
## Files and workspaces
|
|
48
18
|
|
|
49
|
-
Put all work in an isolated
|
|
50
|
-
|
|
51
|
-
### Code and tests
|
|
19
|
+
Put all work in an isolated worktree under the repo's `.claude/worktrees/`.
|
|
52
20
|
|
|
53
|
-
|
|
21
|
+
Default to Edit for existing files; reach for Write only when the path is genuinely new.
|
|
54
22
|
|
|
55
|
-
|
|
23
|
+
## Code and tests
|
|
56
24
|
|
|
57
|
-
|
|
58
|
-
- A coder consults a tool-less advisor agent when blocked.
|
|
59
|
-
- A fresh-context verifier agent runs named gates, baseline checks, and a two-way task-to-diff review.
|
|
60
|
-
- Repair only reported findings, then re-verify after every repair.
|
|
25
|
+
Tests must exercise real behavior, real data, and production paths.
|
|
61
26
|
|
|
62
|
-
|
|
27
|
+
Keep changes within scope. Prefer durable systemic fixes for reusable behavior.
|
|
63
28
|
|
|
64
|
-
|
|
29
|
+
Do not rewrite entire files or rename public parameters without need.
|
|
65
30
|
|
|
66
|
-
|
|
31
|
+
## Reviews
|
|
67
32
|
|
|
68
|
-
|
|
33
|
+
Verify every sub-agent file list, count, description, and finding against the repository and diff.
|
|
69
34
|
|
|
70
35
|
Do not commit untracked files unless explicitly instructed.
|
|
71
36
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
Delegate fact extraction when multiple files or search patterns are required. Request precise file-and-line answers.
|
|
37
|
+
## Delegation
|
|
75
38
|
|
|
76
|
-
|
|
39
|
+
Request precise file-and-line answers from research subagents.
|
|
77
40
|
|
|
78
|
-
|
|
41
|
+
## Task tracking
|
|
79
42
|
|
|
80
|
-
|
|
43
|
+
Track multi-step work with the `task-build` skill.
|
|
81
44
|
|
|
82
|
-
|
|
45
|
+
## Repository rule
|
|
83
46
|
|
|
84
|
-
|
|
47
|
+
Before changing skill, rule, or hook installation in the claude-dev-env repo, read `docs/references/skill-install-system.md`.
|
|
85
48
|
|
|
86
49
|
## Definitions
|
|
87
50
|
|
|
88
|
-
Warm agent:
|
|
51
|
+
Warm agent: active within the past 59 minutes.
|
|
@@ -32,6 +32,7 @@ try:
|
|
|
32
32
|
gate_running,
|
|
33
33
|
git_blob_readers,
|
|
34
34
|
git_file_sets,
|
|
35
|
+
staged_test_regression,
|
|
35
36
|
staged_test_running,
|
|
36
37
|
violation_scoping,
|
|
37
38
|
wrapper_plumb_check,
|
|
@@ -136,7 +137,7 @@ def _report_partitioned_violations(
|
|
|
136
137
|
is_whole_file_scope,
|
|
137
138
|
)
|
|
138
139
|
|
|
139
|
-
run_staged_test_files =
|
|
140
|
+
run_staged_test_files = staged_test_regression.run_staged_test_files
|
|
140
141
|
_staged_test_file_paths = staged_test_running._staged_test_file_paths
|
|
141
142
|
_resolve_owning_test_root = staged_test_running._resolve_owning_test_root
|
|
142
143
|
_group_staged_tests_by_root = staged_test_running._group_staged_tests_by_root
|
|
@@ -15,15 +15,25 @@ surface for the test suite.
|
|
|
15
15
|
| `violation_scoping.py` | Recovers a violation's line span from the enforcer message and partitions violations into blocking versus advisory |
|
|
16
16
|
| `wrapper_plumb_check.py` | Flags a public wrapper that drops a same-file delegate's optional keyword arguments; holds the code-path and test-path classifiers |
|
|
17
17
|
| `gate_running.py` | Validates the eligible file set, reports the inspected-file count, and prints the partitioned violation report |
|
|
18
|
-
| `staged_test_running.py` | Runs
|
|
18
|
+
| `staged_test_running.py` | Runs one staged-test group, grouped by its owning pytest config and by top-level directory when no config owns it, in command-line-length-safe pytest batches, optionally writing a JUnit XML report per batch |
|
|
19
|
+
| `staged_test_regression.py` | Discovers the staged test files, runs every group, and blocks only on a failure the staged change introduces — a group that fails is re-run against the HEAD baseline (a throwaway detached checkout made with `git worktree add --detach` under the OS temp root, deleted with `git worktree remove --force`) and only a failure absent there blocks |
|
|
20
|
+
| `baseline_import_isolation.py` | Points the baseline run's imports at the baseline worktree — rebases every repository import root onto the front of its `PYTHONPATH` — and installs the pytest plugin that reports which modules the run loaded out of the user's own tree, so a route that survives the rebase is measured and the baseline discarded |
|
|
19
21
|
| `gate_arguments.py` | Parses the gate's command-line arguments |
|
|
20
22
|
| `__init__.py` | Package marker |
|
|
21
23
|
|
|
24
|
+
## Staged-test grouping boundaries
|
|
25
|
+
|
|
26
|
+
The top-level fallback in `staged_test_running.py` fires only in a repository
|
|
27
|
+
whose root holds no pytest config, and it splits top-level neighbors alone:
|
|
28
|
+
|
|
29
|
+
- Config-less packages that sit deeper under one shared top-level directory, such as `pkgs/alpha` and `pkgs/beta`, share that directory's single pytest session, so a module name they both expose still shadows.
|
|
30
|
+
- A `conftest.py` at the repository root stays unloaded for those groups, because each session works from the top-level directory and the root file falls outside the tree pytest treats as its root.
|
|
31
|
+
|
|
22
32
|
## Subdirectory
|
|
23
33
|
|
|
24
34
|
| Entry | Description |
|
|
25
35
|
|---|---|
|
|
26
|
-
| `tests/` | pytest suite with one test module per module above |
|
|
36
|
+
| `tests/` | pytest suite with one test module per module above, plus `_repo_test_helpers.py` (shared real-git-repository builders) |
|
|
27
37
|
|
|
28
38
|
## Running tests
|
|
29
39
|
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Make the HEAD baseline run import from the baseline worktree, and prove that it did.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
repository_root = /repo (working tree, staged edits)
|
|
6
|
+
baseline_worktree = /tmp/base/tree (detached HEAD, clean)
|
|
7
|
+
|
|
8
|
+
ok: PYTHONPATH=/repo/packages -> PYTHONPATH=/tmp/base/tree/packages
|
|
9
|
+
ok: editable root /repo/src -> /tmp/base/tree/src prepended
|
|
10
|
+
flag: import hook ahead of the path scan still serving /repo/src/foo.py
|
|
11
|
+
-> reported by the leak plugin, baseline discarded
|
|
12
|
+
|
|
13
|
+
Rebasing the working directory alone does not move imports. An absolute route
|
|
14
|
+
into the user's own checkout keeps serving staged code to the baseline run.
|
|
15
|
+
Two shapes reach it: a ``PYTHONPATH`` entry under the repository root, and an
|
|
16
|
+
editable install pointing at it. The baseline then fails the way the staged run
|
|
17
|
+
failed, and the two failures cancel a real regression out.
|
|
18
|
+
|
|
19
|
+
Two halves close that. Every import root inside the repository is rewritten to
|
|
20
|
+
its baseline-worktree equivalent and put at the front of ``PYTHONPATH``. That
|
|
21
|
+
beats every ``.pth`` entry and every setuptools editable style. Then the
|
|
22
|
+
baseline pytest session loads a plugin that records which modules it imported
|
|
23
|
+
out of the user's tree. A route that survives the rewrite is measured, never
|
|
24
|
+
assumed.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import subprocess
|
|
32
|
+
import sys
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
from pr_loop_shared_constants.code_rules_gate_constants import (
|
|
36
|
+
BASELINE_IMPORT_PROBE_REJECTED_MESSAGE,
|
|
37
|
+
BASELINE_IMPORT_PROBE_TIMED_OUT_MESSAGE,
|
|
38
|
+
BASELINE_IMPORT_PROBE_TIMEOUT_SECONDS,
|
|
39
|
+
BASELINE_IMPORT_PROBE_UNSTARTABLE_MESSAGE,
|
|
40
|
+
BASELINE_IMPORT_ROOT_PROBE_SOURCE,
|
|
41
|
+
BASELINE_LEAK_PLUGIN_MODULE_NAME,
|
|
42
|
+
BASELINE_LEAK_PLUGIN_SOURCE,
|
|
43
|
+
BASELINE_LEAK_REPORT_ENV_VAR,
|
|
44
|
+
BASELINE_PRIMARY_ROOT_ENV_VAR,
|
|
45
|
+
PYTEST_PLUGINS_ENV_VAR,
|
|
46
|
+
PYTEST_PLUGINS_SEPARATOR,
|
|
47
|
+
PYTHON_FILE_EXTENSION,
|
|
48
|
+
PYTHON_INTERPRETER_COMMAND_FLAG,
|
|
49
|
+
PYTHONPATH_ENV_VAR,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _resolved_directory(path_text: str) -> Path | None:
|
|
54
|
+
"""Return *path_text* resolved to an absolute path, or None when it is unusable."""
|
|
55
|
+
if not path_text:
|
|
56
|
+
return None
|
|
57
|
+
try:
|
|
58
|
+
return Path(path_text).resolve()
|
|
59
|
+
except OSError:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _is_under(candidate: Path, root: Path) -> bool:
|
|
64
|
+
"""Return True when *candidate* is *root* itself or sits anywhere beneath it."""
|
|
65
|
+
return candidate == root or root in candidate.parents
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def rebased_into_baseline(
|
|
69
|
+
original_path: Path, repository_root: Path, baseline_worktree: Path
|
|
70
|
+
) -> Path | None:
|
|
71
|
+
"""Return *original_path* moved into the baseline worktree, or None when outside the repo.
|
|
72
|
+
|
|
73
|
+
::
|
|
74
|
+
|
|
75
|
+
repository_root = /repo
|
|
76
|
+
ok: /repo/packages -> /tmp/base/tree/packages
|
|
77
|
+
ok: /repo -> /tmp/base/tree
|
|
78
|
+
flag: /elsewhere/lib -> None (nothing in the baseline tree answers for it)
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
original_path: An already-resolved filesystem path.
|
|
82
|
+
repository_root: The user's own repository root.
|
|
83
|
+
baseline_worktree: The detached-HEAD worktree the baseline run uses.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
The same repository-relative location inside *baseline_worktree*, or
|
|
87
|
+
None when *original_path* lies outside the repository.
|
|
88
|
+
"""
|
|
89
|
+
if not _is_under(original_path, repository_root):
|
|
90
|
+
return None
|
|
91
|
+
return baseline_worktree / original_path.relative_to(repository_root)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def discover_import_roots(
|
|
95
|
+
python_executable: str,
|
|
96
|
+
all_environment_settings: dict[str, str],
|
|
97
|
+
working_directory: Path,
|
|
98
|
+
) -> list[Path]:
|
|
99
|
+
"""Return every directory the interpreter would resolve imported packages from.
|
|
100
|
+
|
|
101
|
+
Covers ``sys.path`` — which carries ``PYTHONPATH`` and every ``.pth``-added
|
|
102
|
+
entry — plus the package roots an editable install registers through an
|
|
103
|
+
import hook, which never appear on ``sys.path`` at all.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
python_executable: The interpreter the staged and baseline runs use.
|
|
107
|
+
all_environment_settings: The environment the probe runs under.
|
|
108
|
+
working_directory: A directory outside the repository, so the probe's
|
|
109
|
+
own working directory never reads as a repository import root.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
The resolved import roots, or an empty list when the probe times out,
|
|
113
|
+
cannot start, or exits non-zero. Each of those says so on stderr, and
|
|
114
|
+
the run continues into the baseline with its leak report still armed.
|
|
115
|
+
"""
|
|
116
|
+
probe = _completed_import_root_probe(
|
|
117
|
+
python_executable, all_environment_settings, working_directory
|
|
118
|
+
)
|
|
119
|
+
if probe is None:
|
|
120
|
+
return []
|
|
121
|
+
if probe.returncode != 0:
|
|
122
|
+
sys.stderr.write(
|
|
123
|
+
BASELINE_IMPORT_PROBE_REJECTED_MESSAGE.format(status=probe.returncode) + "\n"
|
|
124
|
+
)
|
|
125
|
+
return []
|
|
126
|
+
return _probed_roots(probe.stdout)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _completed_import_root_probe(
|
|
130
|
+
python_executable: str,
|
|
131
|
+
all_environment_settings: dict[str, str],
|
|
132
|
+
working_directory: Path,
|
|
133
|
+
) -> subprocess.CompletedProcess[str] | None:
|
|
134
|
+
"""Run the probe interpreter to completion, or report on stderr why it produced nothing.
|
|
135
|
+
|
|
136
|
+
::
|
|
137
|
+
|
|
138
|
+
ok: probe exits 0 -> CompletedProcess carrying its output
|
|
139
|
+
flag: probe hangs -> None, "did not finish within 120 seconds"
|
|
140
|
+
flag: interpreter missing -> None, "did not start (...)"
|
|
141
|
+
|
|
142
|
+
A probe that outlives its limit is the same situation as a probe that fails:
|
|
143
|
+
no roots were learned, so the caller carries on without them rather than
|
|
144
|
+
raising a traceback out of a pre-commit hook.
|
|
145
|
+
"""
|
|
146
|
+
try:
|
|
147
|
+
return subprocess.run(
|
|
148
|
+
[python_executable, PYTHON_INTERPRETER_COMMAND_FLAG, BASELINE_IMPORT_ROOT_PROBE_SOURCE],
|
|
149
|
+
cwd=str(working_directory),
|
|
150
|
+
env=all_environment_settings,
|
|
151
|
+
capture_output=True,
|
|
152
|
+
text=True,
|
|
153
|
+
check=False,
|
|
154
|
+
timeout=BASELINE_IMPORT_PROBE_TIMEOUT_SECONDS,
|
|
155
|
+
)
|
|
156
|
+
except subprocess.TimeoutExpired:
|
|
157
|
+
sys.stderr.write(
|
|
158
|
+
BASELINE_IMPORT_PROBE_TIMED_OUT_MESSAGE.format(
|
|
159
|
+
seconds=BASELINE_IMPORT_PROBE_TIMEOUT_SECONDS
|
|
160
|
+
)
|
|
161
|
+
+ "\n"
|
|
162
|
+
)
|
|
163
|
+
return None
|
|
164
|
+
except OSError as probe_failure:
|
|
165
|
+
sys.stderr.write(
|
|
166
|
+
BASELINE_IMPORT_PROBE_UNSTARTABLE_MESSAGE.format(reason=probe_failure) + "\n"
|
|
167
|
+
)
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _probed_roots(probe_text: str) -> list[Path]:
|
|
172
|
+
"""Return the resolved directories named on the probe's final output line."""
|
|
173
|
+
all_probe_lines = probe_text.strip().splitlines()
|
|
174
|
+
if not all_probe_lines:
|
|
175
|
+
return []
|
|
176
|
+
try:
|
|
177
|
+
all_root_texts = json.loads(all_probe_lines[-1])
|
|
178
|
+
except ValueError:
|
|
179
|
+
return []
|
|
180
|
+
all_roots = [_resolved_directory(str(each_text)) for each_text in all_root_texts]
|
|
181
|
+
return [each_root for each_root in all_roots if each_root is not None]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _baseline_path_entries(
|
|
185
|
+
all_import_roots: list[Path], repository_root: Path, baseline_worktree: Path
|
|
186
|
+
) -> list[str]:
|
|
187
|
+
"""Return the baseline-worktree equivalent of every import root inside the repository."""
|
|
188
|
+
all_entries: list[str] = []
|
|
189
|
+
for each_root in all_import_roots:
|
|
190
|
+
rebased_root = rebased_into_baseline(each_root, repository_root, baseline_worktree)
|
|
191
|
+
if rebased_root is not None:
|
|
192
|
+
all_entries.append(str(rebased_root))
|
|
193
|
+
return all_entries
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _rebased_pythonpath_entries(
|
|
197
|
+
all_staged_environment_settings: dict[str, str],
|
|
198
|
+
repository_root: Path,
|
|
199
|
+
baseline_worktree: Path,
|
|
200
|
+
) -> list[str]:
|
|
201
|
+
"""Return the staged ``PYTHONPATH`` with every repository entry moved into the baseline."""
|
|
202
|
+
all_entries: list[str] = []
|
|
203
|
+
staged_pythonpath = all_staged_environment_settings.get(PYTHONPATH_ENV_VAR, "")
|
|
204
|
+
for each_entry in staged_pythonpath.split(os.pathsep):
|
|
205
|
+
resolved_entry = _resolved_directory(each_entry)
|
|
206
|
+
rebased_entry = (
|
|
207
|
+
None
|
|
208
|
+
if resolved_entry is None
|
|
209
|
+
else rebased_into_baseline(resolved_entry, repository_root, baseline_worktree)
|
|
210
|
+
)
|
|
211
|
+
all_entries.append(each_entry if rebased_entry is None else str(rebased_entry))
|
|
212
|
+
return [each_entry for each_entry in all_entries if each_entry]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def install_leak_plugin(plugin_directory: Path) -> None:
|
|
216
|
+
"""Write the import-origin reporting pytest plugin into its own directory.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
plugin_directory: The directory the plugin module is written to, which
|
|
220
|
+
the baseline run puts on its ``PYTHONPATH`` so pytest loads it.
|
|
221
|
+
"""
|
|
222
|
+
plugin_directory.mkdir(parents=True, exist_ok=True)
|
|
223
|
+
plugin_path = plugin_directory / f"{BASELINE_LEAK_PLUGIN_MODULE_NAME}{PYTHON_FILE_EXTENSION}"
|
|
224
|
+
plugin_path.write_text(
|
|
225
|
+
BASELINE_LEAK_PLUGIN_SOURCE.format(
|
|
226
|
+
primary_root_env_var=BASELINE_PRIMARY_ROOT_ENV_VAR,
|
|
227
|
+
report_path_env_var=BASELINE_LEAK_REPORT_ENV_VAR,
|
|
228
|
+
),
|
|
229
|
+
encoding="utf-8",
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _pytest_plugins_setting(all_staged_environment_settings: dict[str, str]) -> str:
|
|
234
|
+
"""Return the ``PYTEST_PLUGINS`` value with the leak plugin added to any existing list."""
|
|
235
|
+
existing_setting = all_staged_environment_settings.get(PYTEST_PLUGINS_ENV_VAR, "")
|
|
236
|
+
if not existing_setting:
|
|
237
|
+
return BASELINE_LEAK_PLUGIN_MODULE_NAME
|
|
238
|
+
return PYTEST_PLUGINS_SEPARATOR.join([BASELINE_LEAK_PLUGIN_MODULE_NAME, existing_setting])
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def baseline_pytest_environment(
|
|
242
|
+
all_staged_environment_settings: dict[str, str],
|
|
243
|
+
repository_root: Path,
|
|
244
|
+
baseline_worktree: Path,
|
|
245
|
+
all_import_roots: list[Path],
|
|
246
|
+
plugin_directory: Path,
|
|
247
|
+
leak_report_path: Path,
|
|
248
|
+
) -> dict[str, str]:
|
|
249
|
+
"""Return the environment the baseline pytest run uses.
|
|
250
|
+
|
|
251
|
+
Every repository import root leads with its baseline-worktree equivalent,
|
|
252
|
+
the plugin directory follows, and the staged ``PYTHONPATH`` (itself rebased
|
|
253
|
+
entry by entry) trails behind, so a module that exists at HEAD always
|
|
254
|
+
resolves out of the baseline tree.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
all_staged_environment_settings: The environment the staged run used.
|
|
258
|
+
repository_root: The user's own repository root.
|
|
259
|
+
baseline_worktree: The detached-HEAD worktree the baseline run uses.
|
|
260
|
+
all_import_roots: The import roots ``discover_import_roots`` found.
|
|
261
|
+
plugin_directory: The directory holding the leak-reporting plugin.
|
|
262
|
+
leak_report_path: Where the plugin writes its import-origin report.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
A copy of *all_staged_environment_settings* carrying the baseline
|
|
266
|
+
import path and the leak plugin's three settings.
|
|
267
|
+
"""
|
|
268
|
+
baseline_environment = dict(all_staged_environment_settings)
|
|
269
|
+
all_entries = [
|
|
270
|
+
*_baseline_path_entries(all_import_roots, repository_root, baseline_worktree),
|
|
271
|
+
str(plugin_directory),
|
|
272
|
+
*_rebased_pythonpath_entries(
|
|
273
|
+
all_staged_environment_settings, repository_root, baseline_worktree
|
|
274
|
+
),
|
|
275
|
+
]
|
|
276
|
+
baseline_environment[PYTHONPATH_ENV_VAR] = os.pathsep.join(dict.fromkeys(all_entries))
|
|
277
|
+
baseline_environment[PYTEST_PLUGINS_ENV_VAR] = _pytest_plugins_setting(
|
|
278
|
+
all_staged_environment_settings
|
|
279
|
+
)
|
|
280
|
+
baseline_environment[BASELINE_PRIMARY_ROOT_ENV_VAR] = str(repository_root)
|
|
281
|
+
baseline_environment[BASELINE_LEAK_REPORT_ENV_VAR] = str(leak_report_path)
|
|
282
|
+
return baseline_environment
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def modules_imported_from_primary_tree(leak_report_path: Path) -> list[Path] | None:
|
|
286
|
+
"""Return the user's-tree modules the baseline run imported, or None when unreported.
|
|
287
|
+
|
|
288
|
+
::
|
|
289
|
+
|
|
290
|
+
ok: [] -> the baseline measured HEAD alone
|
|
291
|
+
flag: [/repo/packages/foo.py] -> the baseline read staged code
|
|
292
|
+
flag: None -> the run left no report; nothing is proven
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
leak_report_path: The report path the baseline run was given.
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
The resolved module paths, empty when the baseline stayed clean, or None
|
|
299
|
+
when the report is missing or unreadable.
|
|
300
|
+
"""
|
|
301
|
+
try:
|
|
302
|
+
report_text = leak_report_path.read_text(encoding="utf-8")
|
|
303
|
+
except OSError:
|
|
304
|
+
return None
|
|
305
|
+
try:
|
|
306
|
+
all_module_texts = json.loads(report_text)
|
|
307
|
+
except ValueError:
|
|
308
|
+
return None
|
|
309
|
+
return [Path(str(each_text)) for each_text in all_module_texts]
|