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.
Files changed (139) hide show
  1. package/CLAUDE.md +20 -57
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +2 -1
  3. package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +12 -2
  4. package/_shared/pr-loop/scripts/code_rules_gate_parts/baseline_import_isolation.py +309 -0
  5. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +540 -0
  6. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +206 -70
  7. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/__init__.py +1 -0
  8. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/_repo_test_helpers.py +76 -0
  9. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_baseline_import_isolation.py +248 -0
  10. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +309 -0
  11. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +91 -58
  12. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +202 -0
  13. package/agents/CLAUDE.md +1 -1
  14. package/agents/code-verifier.md +36 -7
  15. package/bin/codex-compat.mjs +104 -0
  16. package/bin/codex-compat.test.mjs +51 -0
  17. package/codex-capability-map.json +13 -0
  18. package/docs/CODE_RULES.md +2 -0
  19. package/docs/codex-compatibility.md +25 -0
  20. package/docs/nas-ssh-invocation.md +96 -12
  21. package/docs/references/code-review-enforcement.md +31 -6
  22. package/hooks/blocking/CLAUDE.md +3 -0
  23. package/hooks/blocking/config/code_review_enforcement_constants.py +40 -10
  24. package/hooks/blocking/config/test_code_review_enforcement_constants.py +56 -3
  25. package/hooks/blocking/eli11_reply_enforcer.py +479 -0
  26. package/hooks/blocking/gh_body_arg_blocker.py +1 -1
  27. package/hooks/blocking/nas_ssh_binary_enforcer.py +8 -46
  28. package/hooks/blocking/shell_substitution_blocker.py +129 -0
  29. package/hooks/blocking/state_description_blocker.py +1 -1
  30. package/hooks/blocking/stop_dispatcher.py +1 -1
  31. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +2 -3
  32. package/hooks/blocking/test_eli11_reply_enforcer.py +457 -0
  33. package/hooks/blocking/test_shell_substitution_blocker.py +124 -0
  34. package/hooks/blocking/test_stop_dispatcher.py +23 -0
  35. package/hooks/blocking/test_unscoped_search_blocker.py +102 -0
  36. package/hooks/blocking/test_verdict_directory_write_blocker.py +4 -8
  37. package/hooks/blocking/unscoped_search_blocker.py +391 -0
  38. package/hooks/git-hooks/CLAUDE.md +3 -0
  39. package/hooks/git-hooks/conftest.py +30 -0
  40. package/hooks/git-hooks/gate_utils.py +2 -2
  41. package/hooks/git-hooks/git_hooks_constants/__init__.py +41 -2
  42. package/hooks/git-hooks/pre_push.py +75 -4
  43. package/hooks/git-hooks/pre_push_base_reference.py +166 -0
  44. package/hooks/git-hooks/test_config.py +0 -15
  45. package/hooks/git-hooks/test_gate_utils.py +3 -15
  46. package/hooks/git-hooks/test_pre_commit.py +1 -15
  47. package/hooks/git-hooks/test_pre_push.py +236 -27
  48. package/hooks/git-hooks/test_pre_push_base_reference.py +339 -0
  49. package/hooks/hooks.json +0 -12
  50. package/hooks/hooks_constants/CLAUDE.md +5 -1
  51. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -4
  52. package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +101 -0
  53. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +2 -8
  54. package/hooks/hooks_constants/shell_command_segments.py +82 -0
  55. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +67 -0
  56. package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -0
  57. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -6
  58. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +1 -0
  59. package/hooks/hooks_constants/unscoped_search_blocker_constants.py +153 -0
  60. package/package.json +4 -2
  61. package/rules/CLAUDE.md +17 -23
  62. package/rules/agent-spawn-protocol.md +6 -6
  63. package/rules/anti-corollary-tests.md +1 -1
  64. package/rules/bdd.md +1 -1
  65. package/rules/cleanup-temp-files.md +10 -4
  66. package/rules/code-standards.md +7 -0
  67. package/rules/conservative-action.md +1 -5
  68. package/rules/context7.md +0 -4
  69. package/rules/destructive-commands.md +47 -0
  70. package/rules/doc-inventory-integrity.md +48 -0
  71. package/rules/doc-prose-cuts.md +58 -0
  72. package/rules/docstring-prose-matches-implementation.md +10 -2
  73. package/rules/durable-post-artifacts.md +0 -4
  74. package/rules/eli11-replies.md +31 -0
  75. package/rules/explore-thoroughly.md +4 -4
  76. package/rules/falsify-before-green.md +68 -0
  77. package/rules/file-global-constants.md +1 -1
  78. package/rules/filesystem-search.md +51 -0
  79. package/rules/gh-cli-conventions.md +27 -0
  80. package/rules/git-workflow.md +26 -0
  81. package/rules/hedging-claims.md +9 -0
  82. package/rules/long-horizon-autonomy.md +0 -4
  83. package/rules/measurement-denominators.md +48 -0
  84. package/rules/nas-ssh-invocation.md +23 -5
  85. package/rules/parallel-tools.md +2 -2
  86. package/rules/plain-illustrative-docstrings.md +3 -7
  87. package/rules/plain-language.md +2 -0
  88. package/rules/proof-of-work-pr-comments.md +0 -4
  89. package/rules/re-stage-before-commit.md +2 -0
  90. package/rules/research-mode.md +10 -0
  91. package/rules/shell-invocation.md +21 -0
  92. package/rules/testing.md +4 -0
  93. package/rules/verified-commit-gate-skip.md +3 -27
  94. package/rules/verify-before-asking.md +5 -0
  95. package/rules/windows-filesystem-safe.md +1 -1
  96. package/rules/workers-done-before-complete.md +4 -0
  97. package/scripts/Migrate-ShellPolicy.ps1 +1 -1
  98. package/scripts/codex_capability_bridge.py +171 -0
  99. package/scripts/codex_compat_materializer.py +1087 -0
  100. package/scripts/codex_compat_watcher.py +502 -0
  101. package/scripts/dev_env_scripts_constants/code_review_constants.py +37 -0
  102. package/scripts/invoke_code_review.py +11 -4
  103. package/scripts/sync_to_cursor/rules.py +0 -10
  104. package/scripts/test_invoke_code_review.py +143 -0
  105. package/scripts/test_invoke_code_review_chain.py +1 -1
  106. package/scripts/test_invoke_code_review_contract.py +1 -1
  107. package/scripts/tests/test_code_review_constants.py +80 -0
  108. package/scripts/tests/test_codex_capability_bridge.py +91 -0
  109. package/scripts/tests/test_codex_compat_materializer.py +632 -0
  110. package/scripts/tests/test_codex_compat_watcher.py +599 -0
  111. package/scripts/tests/test_sync_to_cursor.py +0 -1
  112. package/skills/autoconverge/workflow/converge.mjs +1 -1
  113. package/skills/bugteam/reference/copilot-gap-analysis.md +1 -1
  114. package/skills/condensing-instructions/SKILL.md +42 -51
  115. package/skills/fresh-branch/CLAUDE.md +1 -1
  116. package/skills/fresh-branch/SKILL.md +5 -6
  117. package/skills/fresh-branch/scripts/create_fresh_branch.py +42 -24
  118. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -3
  119. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +30 -126
  120. package/skills/orchestrator/SKILL.md +23 -9
  121. package/skills/orchestrator-refresh/SKILL.md +20 -1
  122. package/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
  123. package/skills/session-log/SKILL.md +1 -1
  124. package/rules/claude-md-orphan-file.md +0 -28
  125. package/rules/cleanup-command-forms.md +0 -23
  126. package/rules/code-reviews.md +0 -11
  127. package/rules/env-var-table-code-drift.md +0 -10
  128. package/rules/gh-body-file.md +0 -5
  129. package/rules/gh-paginate.md +0 -3
  130. package/rules/hook-prose-matches-detector.md +0 -15
  131. package/rules/no-historical-clutter.md +0 -26
  132. package/rules/no-inline-destructive-literals.md +0 -9
  133. package/rules/no-justification-noise.md +0 -61
  134. package/rules/package-inventory-stale-entry.md +0 -25
  135. package/rules/right-sized-engineering.md +0 -28
  136. package/rules/self-contained-docs.md +0 -17
  137. package/rules/shell-invocation-policy.md +0 -5
  138. package/rules/state-what-is.md +0 -25
  139. 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
- Use direct affirmative framing that states the desired action clearly and positively. Contrastive negation is banned.
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
- Always execute as many parallel workers as you can, when tasks do not overlap or conflict.
7
+ ## Security
28
8
 
29
- Ask when ambiguity materially changes scope or implementation. Collect credentials through secure UI only; never request secrets in chat.
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
- ## Documentation
13
+ ## Advisors
34
14
 
35
- Describe only the current system state. Keep documentation self-contained and free of historical, transitional, conversational, or version-transition language. Never use negative prose or antipatterns. Always state what to do, specifically.
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 git worktree, created outside the primary checkout.
50
-
51
- ### Code and tests
19
+ Put all work in an isolated worktree under the repo's `.claude/worktrees/`.
52
20
 
53
- Tests exercise real behavior, real data, and production paths.
21
+ Default to Edit for existing files; reach for Write only when the path is genuinely new.
54
22
 
55
- For multi-step code tasks:
23
+ ## Code and tests
56
24
 
57
- - Assign each scope to its own coder agent.
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
- Do not commit, push, or open a PR until verification is clean and the verified-commit gate covers the current diff. The verification requirement is waived only for a non-code diff, or when the Python AST is unchanged after removing docstrings.
27
+ Keep changes within scope. Prefer durable systemic fixes for reusable behavior.
63
28
 
64
- Keep changes within scope. Prefer durable systemic fixes for reusable behavior. Do not rewrite entire files or rename public parameters without need.
29
+ Do not rewrite entire files or rename public parameters without need.
65
30
 
66
- ### Reviews and convergence
31
+ ## Reviews
67
32
 
68
- Report only findings verified against the code. Verify every sub-agent file list, count, description, and finding against the repository and the diff before using it.
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
- ### Research and delegation
73
-
74
- Delegate fact extraction when multiple files or search patterns are required. Request precise file-and-line answers.
37
+ ## Delegation
75
38
 
76
- Use fresh parallel subagents, each named for its task, for unrelated questions at whatever effort level the task needs.
39
+ Request precise file-and-line answers from research subagents.
77
40
 
78
- Read or search directly only in files you will actually modify this turn. Delegate broader fact-finding to a subagent instead of reading widely yourself.
41
+ ## Task tracking
79
42
 
80
- For code navigation, prefer a semantic code-navigation tool (an MCP server such as Serena, when available) or a fast file-search tool (such as Everything's `es.exe` on Windows, when available), then fall back to content search or globbing. Scope every search to a project directory. Never scan an entire drive or network share.
43
+ Track multi-step work with the `task-build` skill.
81
44
 
82
- ### Task tracking
45
+ ## Repository rule
83
46
 
84
- Track every task with the harness's task tool (TaskCreate/TaskUpdate) or the `task-build` skill: `~/.claude/skills/task-build/SKILL.md`.
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: any agent who has acted within the past 30 minutes.
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 = staged_test_running.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 the staged Python test files, grouped by their owning pytest config, in command-line-length-safe batches |
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]