claude-dev-env 2.31.1 → 2.33.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/.agents/agents/clean-coder.md +8 -7
- package/.agents/agents/code-quality-agent.md +4 -2
- package/.agents/agents/pr-description-writer.md +8 -3
- package/.agents/agents/reference/pr-description-illustrative-voice.md +6 -1
- package/.agents/agents/test_agent_frontmatter.py +0 -9
- package/.agents/agents/test_pr_description_writer_style.py +24 -1
- package/.agents/skills/e-simplify/SKILL.md +1 -15
- package/.agents/skills/grok-spawn/reference/worker-briefs.md +4 -0
- package/.agents/skills/pr-refinement/SKILL.md +2 -2
- package/.agents/skills/pr-shared-extraction/SKILL.md +2 -2
- package/.agents/skills/pr-title-description/SKILL.md +6 -2
- package/.agents/skills/review-tier/SKILL.md +1 -1
- package/.agents/skills/review-tier/reference/tier-policy.json +2 -2
- package/.agents/skills/review-tier/scripts/review_tier.py +31 -4
- package/.agents/skills/review-tier/scripts/test_generation.py +7 -19
- package/.agents/skills/review-tier/scripts/test_review_tier.py +11 -11
- package/AGENTS.md +83 -0
- package/_shared/pr-loop/code-rules-gate.md +5 -2
- package/_shared/pr-loop/scripts/code_rules_gate.py +7 -3
- package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +13 -1
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_arguments.py +7 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +47 -1
- package/audit-rubrics/audit-categories.json +2 -2
- package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -1
- package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +5 -3
- package/audit-rubrics/category_rubrics/category-l-behavior-equivalence.md +6 -3
- package/audit-rubrics/prompts/category-e-dead-code.md +6 -6
- package/audit-rubrics/prompts/category-j-code-rules-compliance.md +25 -19
- package/audit-rubrics/prompts/category-l-behavior-equivalence.md +28 -25
- package/bin/install.test.mjs +11 -3
- package/docs/CODE_RULES.md +8 -9
- package/hooks/blocking/bash_post_call_dispatcher.py +70 -0
- package/hooks/blocking/bash_pre_tool_use_dispatcher.py +8 -21
- package/hooks/blocking/code_rules_comments.py +118 -59
- package/hooks/blocking/code_rules_duplicate_body.py +13 -7
- package/hooks/blocking/code_rules_enforcer.py +19 -7
- package/hooks/blocking/code_rules_enforcer_test_support.py +39 -0
- package/hooks/blocking/code_rules_type_escape.py +16 -23
- package/hooks/blocking/javascript_comment_scanner.py +99 -0
- package/hooks/blocking/shell_substitution_blocker.py +175 -8
- package/hooks/blocking/stop_dispatcher.py +6 -9
- package/hooks/blocking/tdd_enforcer.py +3 -1
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +7 -0
- package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +141 -23
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +114 -1
- package/hooks/blocking/test_bash_post_call_dispatcher.py +112 -0
- package/hooks/blocking/test_code_rules_enforcer_any_type_ignore.py +24 -4
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -1
- package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +190 -3
- package/hooks/blocking/test_code_rules_enforcer_javascript_comments.py +44 -0
- package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +0 -1
- package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +8 -1
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +7 -2
- package/hooks/blocking/test_shell_substitution_blocker.py +105 -0
- package/hooks/blocking/test_state_description_blocker.py +56 -71
- package/hooks/blocking/test_tdd_enforcer.py +67 -1
- package/hooks/git-hooks/pull_request_handoff.py +1 -1
- package/hooks/git-hooks/test_post_commit.py +8 -0
- package/hooks/hooks.json +2 -2
- package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +22 -0
- package/hooks/hooks_constants/hosted_hook_runner.py +53 -0
- package/hooks/hooks_constants/piped_pytest_blocker_constants.py +9 -2
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +10 -0
- package/hooks/hooks_constants/stale_comment_reference_blocker_constants.py +3 -4
- package/hooks/hooks_constants/state_file_lock.py +76 -0
- package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +27 -0
- package/hooks/hooks_constants/test_failure_recorder_constants.py +35 -0
- package/hooks/hooks_constants/test_hosted_hook_runner.py +55 -1
- package/hooks/hooks_constants/test_unused_module_import_constants.py +9 -1
- package/hooks/hooks_constants/unused_module_import_constants.py +1 -1
- package/hooks/observability/session_file_edit_tracker.py +2 -55
- package/hooks/observability/test_failure_recorder.py +219 -0
- package/hooks/observability/test_test_failure_recorder.py +173 -0
- package/hooks/validators/comment_checks.py +2 -2
- package/hooks/validators/config/directory_exemption_constants.py +0 -9
- package/hooks/validators/config/system_temporary_roots_constants.py +15 -0
- package/hooks/validators/fast_save_validators.py +0 -9
- package/hooks/validators/git_checks.py +0 -84
- package/hooks/validators/mypy_integration.py +10 -36
- package/hooks/validators/project_roots.py +58 -0
- package/hooks/validators/run_all_validators.py +89 -42
- package/hooks/validators/system_temporary_roots.py +1 -1
- package/hooks/validators/test_fast_save_validators.py +2 -8
- package/hooks/validators/test_git_checks.py +0 -249
- package/hooks/validators/test_run_all_validators.py +22 -0
- package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +249 -0
- package/hooks/validators/test_run_all_validators_reporting.py +66 -0
- package/hooks/validators/test_todo_checks.py +1 -0
- package/hooks/validators/todo_checks.py +1 -1
- package/package.json +1 -1
- package/rules/git-workflow.md +0 -8
- package/rules/shell-invocation.md +1 -1
- package/rules/testing.md +4 -0
- package/scripts/check.ps1 +35 -1
- package/scripts/codex_compat_materializer.py +1 -0
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +9 -5
- package/scripts/dev_env_scripts_constants/shared_tree_constants.py +17 -0
- package/scripts/shared_tree_paths.py +59 -25
- package/scripts/test_shared_tree_paths.py +30 -0
- package/scripts/tests/test_rules.py +111 -0
- package/system-prompts/software-engineer.xml +6 -19
- package/.agents/skills-archived/split-pr/SKILL.md +0 -85
- package/.agents/skills-archived/split-pr/reference/path-layers.md +0 -16
- package/.agents/skills-archived/split-pr/reference/proposal-format.md +0 -15
- package/.agents/skills-archived/split-pr/reference/split-further-loop.md +0 -10
- package/.agents/skills-archived/split-pr/reference/splitting-principles.md +0 -26
- package/.agents/skills-archived/split-pr/scripts/analyze_pr.py +0 -279
- package/.agents/skills-archived/split-pr/scripts/categorize_files.py +0 -106
- package/.agents/skills-archived/split-pr/scripts/config/__init__.py +0 -1
- package/.agents/skills-archived/split-pr/scripts/config/dependency_constants.py +0 -14
- package/.agents/skills-archived/split-pr/scripts/config/git_operations_constants.py +0 -36
- package/.agents/skills-archived/split-pr/scripts/config/packing_constants.py +0 -61
- package/.agents/skills-archived/split-pr/scripts/config/plan_constants.py +0 -49
- package/.agents/skills-archived/split-pr/scripts/config/split_pr_constants.py +0 -110
- package/.agents/skills-archived/split-pr/scripts/execute_split_slices.py +0 -82
- package/.agents/skills-archived/split-pr/scripts/pack_files_into_slices.py +0 -212
- package/.agents/skills-archived/split-pr/scripts/split_pr_dependency_graph.py +0 -70
- package/.agents/skills-archived/split-pr/scripts/split_pr_git_operations.py +0 -184
- package/.agents/skills-archived/split-pr/scripts/split_pr_layer_order.py +0 -58
- package/.agents/skills-archived/split-pr/scripts/split_pr_paginate.py +0 -119
- package/.agents/skills-archived/split-pr/scripts/split_pr_process_runner.py +0 -52
- package/.agents/skills-archived/split-pr/scripts/split_pr_script_types.py +0 -126
- package/.agents/skills-archived/split-pr/scripts/split_pr_title.py +0 -41
- package/.agents/skills-archived/split-pr/scripts/test_analyze_pr.py +0 -228
- package/.agents/skills-archived/split-pr/scripts/test_categorize_files.py +0 -55
- package/.agents/skills-archived/split-pr/scripts/test_categorize_files_packing.py +0 -59
- package/.agents/skills-archived/split-pr/scripts/test_execute_split_slices.py +0 -99
- package/.agents/skills-archived/split-pr/scripts/test_split_pr_dependency_graph.py +0 -47
- package/.agents/skills-archived/split-pr/scripts/test_split_pr_git_operations.py +0 -125
- package/.agents/skills-archived/split-pr/scripts/test_split_pr_layer_order.py +0 -36
- package/.agents/skills-archived/split-pr/scripts/test_split_pr_paginate.py +0 -65
- package/.agents/skills-archived/split-pr/scripts/test_split_pr_script_types.py +0 -73
- package/.agents/skills-archived/split-pr/scripts/test_split_pr_title.py +0 -28
- package/.agents/skills-archived/split-pr/scripts/test_verify_dependency_graph.py +0 -46
- package/.agents/skills-archived/split-pr/scripts/test_verify_plan.py +0 -56
- package/.agents/skills-archived/split-pr/scripts/test_verify_plan_contract.py +0 -50
- package/.agents/skills-archived/split-pr/scripts/test_verify_plan_path_normalization.py +0 -45
- package/.agents/skills-archived/split-pr/scripts/verify_dependency_graph.py +0 -111
- package/.agents/skills-archived/split-pr/scripts/verify_plan.py +0 -139
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PostToolUse hook: record a failing single pytest run as TDD-gate evidence.
|
|
3
|
+
|
|
4
|
+
A file-content edit is not the same claim as a test that ran and failed. The
|
|
5
|
+
content-hash store in ``tdd_enforcer_parts/content_hash_store.py`` already
|
|
6
|
+
tracks whether a candidate test file's content changed; this hook adds a
|
|
7
|
+
stronger form of evidence -- a real RED -- by watching every Bash call finish
|
|
8
|
+
and, for a genuine failing pytest run, recording the command, its real exit
|
|
9
|
+
status, and the test file paths it named into that same store.
|
|
10
|
+
|
|
11
|
+
::
|
|
12
|
+
|
|
13
|
+
pytest test_orders.py (exit 1) -> record test_orders.py
|
|
14
|
+
pytest test_orders.py -k fulfill (exit 1) -> record test_orders.py
|
|
15
|
+
pytest test_orders.py::test_fulfill (exit 1) -> record test_orders.py
|
|
16
|
+
pytest -q (exit 1) -> no path named, no record
|
|
17
|
+
cd repo && pytest test_orders.py (exit 1) -> chained, no record
|
|
18
|
+
pytest test_orders.py | tee run.log (blocked by piped_pytest_blocker
|
|
19
|
+
first -- this hook never sees it)
|
|
20
|
+
npm test (exit 1) -> not pytest, no record
|
|
21
|
+
pytest test_orders.py (exit 0) -> passed, no record
|
|
22
|
+
|
|
23
|
+
What this hook matches, and why
|
|
24
|
+
--------------------------------
|
|
25
|
+
|
|
26
|
+
Only a single, unchained Bash command whose own program is ``pytest`` /
|
|
27
|
+
``py.test``, or a Python interpreter running ``-m pytest``. A chained command
|
|
28
|
+
(``&&``, ``;``, ``|``, ``||``, a newline) is rejected outright: this repo's own
|
|
29
|
+
``piped_pytest_blocker.py`` already tells the caller to "run pytest alone" for
|
|
30
|
+
exactly this reason, and a chain's reported exit status may belong to a
|
|
31
|
+
command other than pytest, or to none of them if an earlier link short-
|
|
32
|
+
circuited before pytest even ran. ``npm test``, ``node --test``, and
|
|
33
|
+
``vitest`` are deliberately not matched: ``npm test`` is a script indirection
|
|
34
|
+
whose real test runner lives in ``package.json``, not on the command line,
|
|
35
|
+
and the other two have no existing, tested classification helper in this
|
|
36
|
+
repository the way pytest does. Extending to them would mean inventing new
|
|
37
|
+
parsing rather than reusing anything proven, which is the over-reach this
|
|
38
|
+
mechanism is built to avoid.
|
|
39
|
+
|
|
40
|
+
The recognizer here is deliberately its own small, stdlib-only ``shlex``
|
|
41
|
+
check rather than a reuse of ``hooks_constants.pytest_invocation`` /
|
|
42
|
+
``shell_command_pipeline``. Those solve the harder pipe-and-wrapper problem a
|
|
43
|
+
PreToolUse blocker needs, and this repository's own style rule forbids an
|
|
44
|
+
import inside a function body, so importing that larger machinery here would
|
|
45
|
+
mean paying its full transitive import cost on every single Bash call this
|
|
46
|
+
hook sees, pytest or not -- working directly against the point of watching
|
|
47
|
+
every Bash call. The narrower, single-unchained-command scope this hook
|
|
48
|
+
accepts does not need that machinery.
|
|
49
|
+
|
|
50
|
+
Where the exit status comes from
|
|
51
|
+
---------------------------------
|
|
52
|
+
|
|
53
|
+
Not from scraping the command's output text for words like "failed" or
|
|
54
|
+
"error". The Bash tool's own PostToolUse ``tool_response`` takes two shapes
|
|
55
|
+
observed directly in this session's own transcript: a dict with ``stdout``/
|
|
56
|
+
``stderr`` keys when the command's reported exit status is 0, and the literal
|
|
57
|
+
string ``"Error: Exit code <N>\\n<output>"`` when it is not. This hook reads
|
|
58
|
+
that harness-authored ``<N>`` directly; it is a real Bash mechanism doing the
|
|
59
|
+
work correctly, not a lookalike that only mimics one.
|
|
60
|
+
|
|
61
|
+
Where the test paths come from
|
|
62
|
+
--------------------------------
|
|
63
|
+
|
|
64
|
+
The command's own arguments, not its output. A bare ``pytest`` or ``pytest
|
|
65
|
+
-q`` names no path and is left unrecorded on purpose: recording it against
|
|
66
|
+
every test file pytest might have discovered would satisfy the gate for a
|
|
67
|
+
file this run never demonstrably covered, which is the exact bypass the
|
|
68
|
+
audit calls out. Only a token that is not an option flag and resolves to a
|
|
69
|
+
real file on disk, relative to the command's own ``cwd``, counts.
|
|
70
|
+
|
|
71
|
+
What stops a bypass
|
|
72
|
+
---------------------
|
|
73
|
+
|
|
74
|
+
Three things, together:
|
|
75
|
+
|
|
76
|
+
1. ``content_hash_store.record_test_command_failure`` keys each entry
|
|
77
|
+
exactly as the gate looks candidates up, so a failure never satisfies a
|
|
78
|
+
sibling file the same command also happened to run (see
|
|
79
|
+
``test_content_hash_store.py::test_recorded_failure_does_not_satisfy_a_different_candidate``).
|
|
80
|
+
2. The gate consults a recorded failure only while the file's *current*
|
|
81
|
+
content hash still matches what actually failed (see
|
|
82
|
+
``test_recorded_failure_does_not_apply_once_content_no_longer_matches``).
|
|
83
|
+
Editing the test again clears it as evidence.
|
|
84
|
+
3. A record ages out after the same freshness window every other form of
|
|
85
|
+
evidence uses (see ``test_recorded_failure_expires_after_the_freshness_window``).
|
|
86
|
+
|
|
87
|
+
The hook never blocks a tool call. A non-Bash tool, a malformed payload, a
|
|
88
|
+
command that does not mention "pytest", a chained command, a command with no
|
|
89
|
+
real path argument, or a failed record each returns quietly.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
from __future__ import annotations
|
|
93
|
+
|
|
94
|
+
import shlex
|
|
95
|
+
import sys
|
|
96
|
+
from pathlib import Path
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
_hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
100
|
+
_blocking_directory = str(Path(_hooks_root_directory) / "blocking")
|
|
101
|
+
for each_bootstrap_directory in (_hooks_root_directory, _blocking_directory):
|
|
102
|
+
if each_bootstrap_directory not in sys.path:
|
|
103
|
+
sys.path.insert(0, each_bootstrap_directory)
|
|
104
|
+
from hooks_constants.gh_pr_author_swap_constants import BASH_TOOL_NAME
|
|
105
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
106
|
+
from hooks_constants.test_failure_recorder_constants import (
|
|
107
|
+
ALL_CHAINING_OPERATOR_SUBSTRINGS,
|
|
108
|
+
ALL_PYTEST_MENTION_SUBSTRINGS,
|
|
109
|
+
ALL_PYTEST_PROGRAM_BASENAMES,
|
|
110
|
+
ALL_REDIRECTION_TOKENS,
|
|
111
|
+
EXIT_CODE_ERROR_PREFIX,
|
|
112
|
+
MODULE_RUN_FLAG,
|
|
113
|
+
NODE_ID_FILE_PATH_SEPARATOR,
|
|
114
|
+
OPTION_TOKEN_PREFIX,
|
|
115
|
+
PYTEST_MODULE_NAME,
|
|
116
|
+
PYTHON_INTERPRETER_BASENAME_PATTERN,
|
|
117
|
+
)
|
|
118
|
+
from tdd_enforcer_parts import content_hash_store
|
|
119
|
+
except ImportError as import_error:
|
|
120
|
+
raise ImportError(
|
|
121
|
+
"test_failure_recorder: cannot import its dependencies; "
|
|
122
|
+
"ensure the hooks directory is importable."
|
|
123
|
+
) from import_error
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _mentions_pytest(command: str) -> bool:
|
|
127
|
+
lowered_command = command.lower()
|
|
128
|
+
return any(
|
|
129
|
+
each_substring in lowered_command for each_substring in ALL_PYTEST_MENTION_SUBSTRINGS
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _harness_reported_exit_status(tool_response: object) -> int | None:
|
|
134
|
+
if not isinstance(tool_response, str) or not tool_response.startswith(EXIT_CODE_ERROR_PREFIX):
|
|
135
|
+
return None
|
|
136
|
+
remainder = tool_response[len(EXIT_CODE_ERROR_PREFIX) :]
|
|
137
|
+
digit_count = 0
|
|
138
|
+
while digit_count < len(remainder) and remainder[digit_count].isdigit():
|
|
139
|
+
digit_count += 1
|
|
140
|
+
return int(remainder[:digit_count]) if digit_count else None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _is_single_unchained_command(command: str) -> bool:
|
|
144
|
+
return not any(each_operator in command for each_operator in ALL_CHAINING_OPERATOR_SUBSTRINGS)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _runs_pytest_via_module_flag(all_tokens: list[str]) -> bool:
|
|
148
|
+
if MODULE_RUN_FLAG not in all_tokens:
|
|
149
|
+
return False
|
|
150
|
+
flag_index = all_tokens.index(MODULE_RUN_FLAG)
|
|
151
|
+
return flag_index + 1 < len(all_tokens) and all_tokens[flag_index + 1] == PYTEST_MODULE_NAME
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _command_tokens_run_pytest(all_tokens: list[str]) -> bool:
|
|
155
|
+
if not all_tokens:
|
|
156
|
+
return False
|
|
157
|
+
first_basename = Path(all_tokens[0]).name
|
|
158
|
+
if first_basename in ALL_PYTEST_PROGRAM_BASENAMES:
|
|
159
|
+
return True
|
|
160
|
+
if not PYTHON_INTERPRETER_BASENAME_PATTERN.match(first_basename):
|
|
161
|
+
return False
|
|
162
|
+
return _runs_pytest_via_module_flag(all_tokens)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _existing_test_file_paths(all_tokens: list[str], cwd: str) -> list[Path]:
|
|
166
|
+
all_paths: list[Path] = []
|
|
167
|
+
for each_token in all_tokens:
|
|
168
|
+
if each_token.startswith(OPTION_TOKEN_PREFIX) or each_token in ALL_REDIRECTION_TOKENS:
|
|
169
|
+
continue
|
|
170
|
+
file_path_text = each_token.split(NODE_ID_FILE_PATH_SEPARATOR, 1)[0]
|
|
171
|
+
candidate_path = Path(cwd, file_path_text) if cwd else Path(file_path_text)
|
|
172
|
+
if candidate_path.is_file():
|
|
173
|
+
all_paths.append(candidate_path)
|
|
174
|
+
return all_paths
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _command_tokens(command: str) -> list[str]:
|
|
178
|
+
try:
|
|
179
|
+
return shlex.split(command)
|
|
180
|
+
except ValueError:
|
|
181
|
+
return []
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _record_pytest_failure(hook_payload: dict, command: str) -> None:
|
|
185
|
+
exit_status = _harness_reported_exit_status(hook_payload.get("tool_response"))
|
|
186
|
+
if exit_status is None or not _is_single_unchained_command(command):
|
|
187
|
+
return
|
|
188
|
+
all_tokens = _command_tokens(command)
|
|
189
|
+
if not _command_tokens_run_pytest(all_tokens):
|
|
190
|
+
return
|
|
191
|
+
cwd = str(hook_payload.get("cwd") or "")
|
|
192
|
+
all_test_file_paths = _existing_test_file_paths(all_tokens, cwd)
|
|
193
|
+
if not all_test_file_paths:
|
|
194
|
+
return
|
|
195
|
+
session_id = str(hook_payload.get("session_id") or "")
|
|
196
|
+
content_hash_store.record_test_command_failure(
|
|
197
|
+
all_test_file_paths, command, exit_status, session_id, cwd
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def main() -> None:
|
|
202
|
+
"""Record one failing pytest run's evidence from a PostToolUse Bash payload.
|
|
203
|
+
|
|
204
|
+
Reads the payload from stdin. Returns quietly on every branch that is not
|
|
205
|
+
a genuine, single, unchained, path-naming pytest failure, so a logging
|
|
206
|
+
problem never surfaces as a blocked or altered tool call.
|
|
207
|
+
"""
|
|
208
|
+
hook_payload = read_hook_input_dictionary_from_stdin()
|
|
209
|
+
if hook_payload is None or hook_payload.get("tool_name") != BASH_TOOL_NAME:
|
|
210
|
+
return
|
|
211
|
+
tool_input = hook_payload.get("tool_input")
|
|
212
|
+
command = tool_input.get("command", "") if isinstance(tool_input, dict) else ""
|
|
213
|
+
if not isinstance(command, str) or not _mentions_pytest(command):
|
|
214
|
+
return
|
|
215
|
+
_record_pytest_failure(hook_payload, command)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
if __name__ == "__main__":
|
|
219
|
+
main()
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Behavior tests for the test_failure_recorder PostToolUse hook.
|
|
2
|
+
|
|
3
|
+
Drives the hook's main through real stdin payloads shaped like a genuine
|
|
4
|
+
Bash PostToolUse call, then reads the shared content-hash store back to
|
|
5
|
+
confirm what -- if anything -- it recorded. The store's real state file
|
|
6
|
+
under the OS temp directory is read directly, the same way
|
|
7
|
+
test_content_hash_store.py and test_tdd_enforcer.py already do, rather than
|
|
8
|
+
mocking tempfile.gettempdir. ``hooks/conftest.py`` already puts the hooks
|
|
9
|
+
and blocking directories on sys.path for test collection, so this file needs
|
|
10
|
+
no bootstrap of its own before importing content_hash_store.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import contextlib
|
|
14
|
+
import importlib.util
|
|
15
|
+
import io
|
|
16
|
+
import json
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from unittest import mock
|
|
19
|
+
|
|
20
|
+
from tdd_enforcer_parts import content_hash_store
|
|
21
|
+
|
|
22
|
+
_HOOK_DIRECTORY = Path(__file__).parent
|
|
23
|
+
_hook_spec = importlib.util.spec_from_file_location(
|
|
24
|
+
"test_failure_recorder_under_test", _HOOK_DIRECTORY / "test_failure_recorder.py"
|
|
25
|
+
)
|
|
26
|
+
assert _hook_spec is not None
|
|
27
|
+
assert _hook_spec.loader is not None
|
|
28
|
+
_hook_module = importlib.util.module_from_spec(_hook_spec)
|
|
29
|
+
_hook_spec.loader.exec_module(_hook_module)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _run_hook_with_payload(payload: dict) -> None:
|
|
33
|
+
with (
|
|
34
|
+
mock.patch("sys.stdin", io.StringIO(json.dumps(payload))),
|
|
35
|
+
contextlib.suppress(SystemExit),
|
|
36
|
+
):
|
|
37
|
+
_hook_module.main()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _recorded_failure_entry(test_file: Path, session_id: str, repository_root: str) -> dict | None:
|
|
41
|
+
state_file = content_hash_store._state_file_path(session_id, repository_root)
|
|
42
|
+
if not state_file.exists():
|
|
43
|
+
return None
|
|
44
|
+
state = json.loads(state_file.read_text())
|
|
45
|
+
return state.get(content_hash_store._state_key_for(test_file))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _bash_payload(command: str, tool_response: object, cwd: Path, session_id: str) -> dict:
|
|
49
|
+
return {
|
|
50
|
+
"session_id": session_id,
|
|
51
|
+
"cwd": str(cwd),
|
|
52
|
+
"tool_name": "Bash",
|
|
53
|
+
"tool_input": {"command": command},
|
|
54
|
+
"tool_response": tool_response,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_FAILING_OUTPUT = (
|
|
59
|
+
"Error: Exit code 1\n"
|
|
60
|
+
"F [100%]\n"
|
|
61
|
+
"=================================== FAILURES ===================================\n"
|
|
62
|
+
"FAILED test_orders.py::test_fulfill - AssertionError\n"
|
|
63
|
+
"1 failed in 0.02s"
|
|
64
|
+
)
|
|
65
|
+
_PASSING_OUTPUT = {"stdout": "1 passed in 0.02s", "stderr": "", "interrupted": False}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_records_a_failure_for_a_bare_pytest_command_naming_a_real_file(
|
|
69
|
+
tmp_path: Path,
|
|
70
|
+
) -> None:
|
|
71
|
+
test_file = tmp_path / "test_orders.py"
|
|
72
|
+
test_file.write_text("def test_fulfill(): assert False\n")
|
|
73
|
+
session_id = "recorder-session-1"
|
|
74
|
+
|
|
75
|
+
_run_hook_with_payload(
|
|
76
|
+
_bash_payload("pytest test_orders.py", _FAILING_OUTPUT, tmp_path, session_id)
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
entry = _recorded_failure_entry(test_file, session_id, str(tmp_path))
|
|
80
|
+
assert entry is not None
|
|
81
|
+
assert entry[content_hash_store.STORED_FAILURE_EXIT_STATUS_KEY] == 1
|
|
82
|
+
assert entry[content_hash_store.STORED_FAILURE_COMMAND_KEY] == "pytest test_orders.py"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_does_not_record_for_a_passing_command(tmp_path: Path) -> None:
|
|
86
|
+
test_file = tmp_path / "test_orders.py"
|
|
87
|
+
test_file.write_text("def test_fulfill(): pass\n")
|
|
88
|
+
session_id = "recorder-session-2"
|
|
89
|
+
|
|
90
|
+
_run_hook_with_payload(
|
|
91
|
+
_bash_payload("pytest test_orders.py", _PASSING_OUTPUT, tmp_path, session_id)
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
assert _recorded_failure_entry(test_file, session_id, str(tmp_path)) is None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_does_not_record_for_a_non_pytest_command(tmp_path: Path) -> None:
|
|
98
|
+
test_file = tmp_path / "test_orders.py"
|
|
99
|
+
test_file.write_text("def test_fulfill(): assert False\n")
|
|
100
|
+
session_id = "recorder-session-3"
|
|
101
|
+
|
|
102
|
+
_run_hook_with_payload(
|
|
103
|
+
_bash_payload("npm test", "Error: Exit code 1\n1 failing", tmp_path, session_id)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
assert _recorded_failure_entry(test_file, session_id, str(tmp_path)) is None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_does_not_record_for_a_chained_command(tmp_path: Path) -> None:
|
|
110
|
+
test_file = tmp_path / "test_orders.py"
|
|
111
|
+
test_file.write_text("def test_fulfill(): assert False\n")
|
|
112
|
+
session_id = "recorder-session-4"
|
|
113
|
+
|
|
114
|
+
_run_hook_with_payload(
|
|
115
|
+
_bash_payload("cd /repo && pytest test_orders.py", _FAILING_OUTPUT, tmp_path, session_id)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
assert _recorded_failure_entry(test_file, session_id, str(tmp_path)) is None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_does_not_record_for_a_bare_pytest_run_with_no_path_argument(
|
|
122
|
+
tmp_path: Path,
|
|
123
|
+
) -> None:
|
|
124
|
+
test_file = tmp_path / "test_orders.py"
|
|
125
|
+
test_file.write_text("def test_fulfill(): assert False\n")
|
|
126
|
+
session_id = "recorder-session-5"
|
|
127
|
+
|
|
128
|
+
_run_hook_with_payload(_bash_payload("pytest -q", _FAILING_OUTPUT, tmp_path, session_id))
|
|
129
|
+
|
|
130
|
+
assert _recorded_failure_entry(test_file, session_id, str(tmp_path)) is None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_records_only_the_named_file_not_an_option_or_expression_token(
|
|
134
|
+
tmp_path: Path,
|
|
135
|
+
) -> None:
|
|
136
|
+
test_file = tmp_path / "test_orders.py"
|
|
137
|
+
test_file.write_text("def test_fulfill(): assert False\n")
|
|
138
|
+
session_id = "recorder-session-6"
|
|
139
|
+
|
|
140
|
+
_run_hook_with_payload(
|
|
141
|
+
_bash_payload("pytest test_orders.py -k fulfill", _FAILING_OUTPUT, tmp_path, session_id)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
entry = _recorded_failure_entry(test_file, session_id, str(tmp_path))
|
|
145
|
+
assert entry is not None
|
|
146
|
+
state_file = content_hash_store._state_file_path(session_id, str(tmp_path))
|
|
147
|
+
state = json.loads(state_file.read_text())
|
|
148
|
+
assert len(state) == 1
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_ignores_a_tool_other_than_bash(tmp_path: Path) -> None:
|
|
152
|
+
test_file = tmp_path / "test_orders.py"
|
|
153
|
+
test_file.write_text("def test_fulfill(): assert False\n")
|
|
154
|
+
session_id = "recorder-session-7"
|
|
155
|
+
payload = _bash_payload("pytest test_orders.py", _FAILING_OUTPUT, tmp_path, session_id)
|
|
156
|
+
payload["tool_name"] = "Write"
|
|
157
|
+
|
|
158
|
+
_run_hook_with_payload(payload)
|
|
159
|
+
|
|
160
|
+
assert _recorded_failure_entry(test_file, session_id, str(tmp_path)) is None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_does_not_record_when_the_named_file_does_not_exist_on_disk(
|
|
164
|
+
tmp_path: Path,
|
|
165
|
+
) -> None:
|
|
166
|
+
session_id = "recorder-session-8"
|
|
167
|
+
missing_file = tmp_path / "test_missing.py"
|
|
168
|
+
|
|
169
|
+
_run_hook_with_payload(
|
|
170
|
+
_bash_payload("pytest test_missing.py", _FAILING_OUTPUT, tmp_path, session_id)
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
assert _recorded_failure_entry(missing_file, session_id, str(tmp_path)) is None
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Comment detection
|
|
1
|
+
"""Comment detection helpers used by the opt-in diff-aware linter.
|
|
2
2
|
|
|
3
3
|
Implements check 26: No comments in code (self-documenting code principle).
|
|
4
4
|
|
|
@@ -12,7 +12,7 @@ Exceptions (NOT flagged):
|
|
|
12
12
|
- Docstrings (triple-quoted strings for function contracts)
|
|
13
13
|
|
|
14
14
|
Note: Docstrings documenting function Args/Returns/Raises are acceptable
|
|
15
|
-
per CODE_RULES.md.
|
|
15
|
+
per CODE_RULES.md. The save-path validator does not invoke these helpers.
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
18
|
import re
|
|
@@ -38,15 +38,6 @@ ALL_PYTEST_SANITIZED_TEST_NAME_CHARACTERS: frozenset[str] = frozenset(
|
|
|
38
38
|
LOWERCASE_ASCII_LETTER_CHARACTERS + DECIMAL_DIGIT_CHARACTERS + UNDERSCORE_CHARACTER
|
|
39
39
|
)
|
|
40
40
|
|
|
41
|
-
ALL_SYSTEM_TEMPORARY_ROOT_ENVIRONMENT_VARIABLE_NAMES: frozenset[str] = frozenset(
|
|
42
|
-
{
|
|
43
|
-
"TEMP",
|
|
44
|
-
"TMP",
|
|
45
|
-
"TMPDIR",
|
|
46
|
-
"RUNNER_TEMP",
|
|
47
|
-
}
|
|
48
|
-
)
|
|
49
|
-
|
|
50
41
|
ALL_AUTHORITATIVE_DIRECTORY_PATH_PATTERN_SETS: tuple[Iterable[str], ...] = (
|
|
51
42
|
TEST_PATH_PATTERNS,
|
|
52
43
|
WORKFLOW_REGISTRY_PATTERNS,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Constants naming where the system keeps temporary directories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
# A run reaches its temp root through any of these, so a walk that stops at
|
|
6
|
+
# that boundary checks all four rather than gettempdir() alone. RUNNER_TEMP
|
|
7
|
+
# is the hosted-CI case: pytest writes there while gettempdir() stays /tmp.
|
|
8
|
+
ALL_SYSTEM_TEMPORARY_ROOT_ENVIRONMENT_VARIABLE_NAMES: frozenset[str] = frozenset(
|
|
9
|
+
{
|
|
10
|
+
"TEMP",
|
|
11
|
+
"TMP",
|
|
12
|
+
"TMPDIR",
|
|
13
|
+
"RUNNER_TEMP",
|
|
14
|
+
}
|
|
15
|
+
)
|
|
@@ -42,7 +42,6 @@ from .test_safety_checks import (
|
|
|
42
42
|
check_debug_guard_in_dev_scripts,
|
|
43
43
|
check_no_skip_decorators,
|
|
44
44
|
)
|
|
45
|
-
from .todo_checks import validate_file as validate_todo_file
|
|
46
45
|
from .type_safety_checks import validate_file as validate_type_safety_file
|
|
47
46
|
from .useless_test_checks import validate_file as validate_useless_test_file
|
|
48
47
|
from .validator_base import ValidatorResult
|
|
@@ -153,14 +152,6 @@ def _all_fast_save_validator_specifications() -> (
|
|
|
153
152
|
_violation_lines_from(validate_magic_literal_file),
|
|
154
153
|
NO_PYTHON_FILES_MESSAGE,
|
|
155
154
|
),
|
|
156
|
-
# Python code quality and TODO tracking.
|
|
157
|
-
_FastSaveValidatorSpecification(
|
|
158
|
-
"TODO Tracking",
|
|
159
|
-
"36",
|
|
160
|
-
_is_python_file,
|
|
161
|
-
_violation_lines_from(validate_todo_file),
|
|
162
|
-
NO_PYTHON_FILES_MESSAGE,
|
|
163
|
-
),
|
|
164
155
|
_FastSaveValidatorSpecification(
|
|
165
156
|
"Security",
|
|
166
157
|
"27,28,29",
|
|
@@ -6,12 +6,6 @@ import sys
|
|
|
6
6
|
from dataclasses import dataclass
|
|
7
7
|
from typing import List
|
|
8
8
|
|
|
9
|
-
try:
|
|
10
|
-
from .validator_defaults import DEFAULT_BASE_BRANCH_WHEN_UNKNOWN
|
|
11
|
-
except ImportError:
|
|
12
|
-
from validator_defaults import DEFAULT_BASE_BRANCH_WHEN_UNKNOWN
|
|
13
|
-
|
|
14
|
-
|
|
15
9
|
SUBPROCESS_TIMEOUT_SECONDS = 30
|
|
16
10
|
|
|
17
11
|
|
|
@@ -38,83 +32,6 @@ def get_current_branch() -> str:
|
|
|
38
32
|
return ""
|
|
39
33
|
|
|
40
34
|
|
|
41
|
-
def check_single_commit_when_pr_exists() -> List[Violation]:
|
|
42
|
-
"""
|
|
43
|
-
Check that a PR branch has exactly 1 commit ahead of its base.
|
|
44
|
-
|
|
45
|
-
Returns empty list if:
|
|
46
|
-
- No PR exists for current branch
|
|
47
|
-
- gh CLI or git is not available
|
|
48
|
-
- gh/git times out
|
|
49
|
-
- Branch is exactly 1 commit ahead of base
|
|
50
|
-
- git rev-list output is non-numeric
|
|
51
|
-
|
|
52
|
-
Returns violation if:
|
|
53
|
-
- Branch is 0 commits ahead of base
|
|
54
|
-
- Branch is more than 1 commit ahead of base
|
|
55
|
-
"""
|
|
56
|
-
branch = get_current_branch()
|
|
57
|
-
if not branch:
|
|
58
|
-
return []
|
|
59
|
-
|
|
60
|
-
try:
|
|
61
|
-
pr_info = subprocess.run(
|
|
62
|
-
["gh", "pr", "list", "--head", branch, "--json", "baseRefName,number"],
|
|
63
|
-
capture_output=True,
|
|
64
|
-
text=True,
|
|
65
|
-
check=True,
|
|
66
|
-
timeout=SUBPROCESS_TIMEOUT_SECONDS,
|
|
67
|
-
)
|
|
68
|
-
except FileNotFoundError:
|
|
69
|
-
return []
|
|
70
|
-
except subprocess.CalledProcessError:
|
|
71
|
-
return []
|
|
72
|
-
except subprocess.TimeoutExpired:
|
|
73
|
-
return []
|
|
74
|
-
|
|
75
|
-
try:
|
|
76
|
-
pr_data = json.loads(pr_info.stdout)
|
|
77
|
-
except json.JSONDecodeError:
|
|
78
|
-
return []
|
|
79
|
-
|
|
80
|
-
if not pr_data:
|
|
81
|
-
return []
|
|
82
|
-
|
|
83
|
-
base_branch = pr_data[0].get("baseRefName", DEFAULT_BASE_BRANCH_WHEN_UNKNOWN)
|
|
84
|
-
|
|
85
|
-
try:
|
|
86
|
-
rev_list = subprocess.run(
|
|
87
|
-
["git", "rev-list", "--count", f"{base_branch}..HEAD"],
|
|
88
|
-
capture_output=True,
|
|
89
|
-
text=True,
|
|
90
|
-
check=True,
|
|
91
|
-
timeout=SUBPROCESS_TIMEOUT_SECONDS,
|
|
92
|
-
)
|
|
93
|
-
except FileNotFoundError:
|
|
94
|
-
return []
|
|
95
|
-
except subprocess.CalledProcessError:
|
|
96
|
-
return []
|
|
97
|
-
except subprocess.TimeoutExpired:
|
|
98
|
-
return []
|
|
99
|
-
|
|
100
|
-
commit_count_text = rev_list.stdout.strip()
|
|
101
|
-
try:
|
|
102
|
-
commit_count = int(commit_count_text)
|
|
103
|
-
except ValueError:
|
|
104
|
-
return []
|
|
105
|
-
|
|
106
|
-
if commit_count == 1:
|
|
107
|
-
return []
|
|
108
|
-
|
|
109
|
-
return [
|
|
110
|
-
Violation(
|
|
111
|
-
file="",
|
|
112
|
-
line=0,
|
|
113
|
-
message=f"Branch must have exactly 1 commit ahead of {base_branch}, found {commit_count} commits",
|
|
114
|
-
)
|
|
115
|
-
]
|
|
116
|
-
|
|
117
|
-
|
|
118
35
|
def check_draft_pr_state() -> List[Violation]:
|
|
119
36
|
"""
|
|
120
37
|
Check that PR is in draft state.
|
|
@@ -175,7 +92,6 @@ def main() -> None:
|
|
|
175
92
|
"""Run all git checks and exit with appropriate code."""
|
|
176
93
|
violations: List[Violation] = []
|
|
177
94
|
|
|
178
|
-
violations.extend(check_single_commit_when_pr_exists())
|
|
179
95
|
violations.extend(check_draft_pr_state())
|
|
180
96
|
|
|
181
97
|
if violations:
|
|
@@ -13,28 +13,26 @@ _validators_directory = str(Path(__file__).resolve().parent)
|
|
|
13
13
|
_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
14
14
|
|
|
15
15
|
try:
|
|
16
|
+
from project_roots import enclosing_project_root
|
|
16
17
|
from pyproject_config_discovery import (
|
|
17
|
-
ancestor_directories,
|
|
18
|
+
ancestor_directories, # noqa: F401 re-exported for validation/mypy_validator
|
|
18
19
|
find_pyproject_configuring_tool,
|
|
19
20
|
)
|
|
20
|
-
from system_temporary_roots import enclosing_system_temporary_root
|
|
21
21
|
except ModuleNotFoundError:
|
|
22
22
|
if _validators_directory not in sys.path:
|
|
23
23
|
sys.path.insert(0, _validators_directory)
|
|
24
|
+
from project_roots import enclosing_project_root
|
|
24
25
|
from pyproject_config_discovery import (
|
|
25
|
-
ancestor_directories,
|
|
26
|
+
ancestor_directories, # noqa: F401 re-exported for validation/mypy_validator
|
|
26
27
|
find_pyproject_configuring_tool,
|
|
27
28
|
)
|
|
28
|
-
from system_temporary_roots import enclosing_system_temporary_root
|
|
29
29
|
|
|
30
30
|
try:
|
|
31
31
|
from hooks_constants.mypy_integration_constants import (
|
|
32
32
|
FOLLOW_IMPORTS_FLAG,
|
|
33
33
|
FOLLOW_IMPORTS_SKIP_VALUE,
|
|
34
|
-
GIT_DIRECTORY_NAME,
|
|
35
34
|
MYPY_DETACHED_SUBPROCESS_TIMEOUT_SECONDS,
|
|
36
35
|
MYPY_DETACHED_TIMEOUT_SKIP_MESSAGE,
|
|
37
|
-
PYPROJECT_FILENAME,
|
|
38
36
|
PYTHON_SOURCE_SUFFIX,
|
|
39
37
|
)
|
|
40
38
|
from hooks_constants.pyproject_config_discovery_constants import MYPY_TOOL_TABLE_NAME
|
|
@@ -44,10 +42,8 @@ except ModuleNotFoundError:
|
|
|
44
42
|
from hooks_constants.mypy_integration_constants import (
|
|
45
43
|
FOLLOW_IMPORTS_FLAG,
|
|
46
44
|
FOLLOW_IMPORTS_SKIP_VALUE,
|
|
47
|
-
GIT_DIRECTORY_NAME,
|
|
48
45
|
MYPY_DETACHED_SUBPROCESS_TIMEOUT_SECONDS,
|
|
49
46
|
MYPY_DETACHED_TIMEOUT_SKIP_MESSAGE,
|
|
50
|
-
PYPROJECT_FILENAME,
|
|
51
47
|
PYTHON_SOURCE_SUFFIX,
|
|
52
48
|
)
|
|
53
49
|
from hooks_constants.pyproject_config_discovery_constants import MYPY_TOOL_TABLE_NAME
|
|
@@ -97,41 +93,19 @@ def find_pyproject_with_mypy_config(starting_file: Path) -> Path | None:
|
|
|
97
93
|
|
|
98
94
|
|
|
99
95
|
def find_module_resolution_root(starting_file: Path) -> Path | None:
|
|
100
|
-
"""Return the
|
|
96
|
+
"""Return the project root mypy resolves this file's first-party imports against.
|
|
101
97
|
|
|
102
|
-
|
|
103
|
-
``
|
|
104
|
-
|
|
105
|
-
project and keeps a foreign ``config`` in the caller's directory out of scope.
|
|
106
|
-
|
|
107
|
-
The walk does not climb out of the system temp directory. A PreToolUse
|
|
108
|
-
staging copy lives under ``%TEMP%``, and a ``.git`` in the user home above
|
|
109
|
-
that temp root is not this file's project.
|
|
110
|
-
|
|
111
|
-
::
|
|
112
|
-
|
|
113
|
-
target_repo/.git + target_repo/tools/x.py -> target_repo
|
|
114
|
-
/tmp/detached/x.py (home .git above temp) -> None
|
|
115
|
-
flag: walk past %TEMP% into ~/.git -> mypy cwd=home, hook timeout
|
|
98
|
+
Mypy resolves a first-party import against its working directory, so
|
|
99
|
+
anchoring there binds ``config.*`` to the target file's own project and
|
|
100
|
+
keeps a foreign ``config`` in the caller's directory out of scope.
|
|
116
101
|
|
|
117
102
|
Args:
|
|
118
103
|
starting_file: The file (or directory) the walk begins from.
|
|
119
104
|
|
|
120
105
|
Returns:
|
|
121
|
-
The
|
|
122
|
-
or ``None`` when no such ancestor exists inside the walk limit.
|
|
106
|
+
The project root, or None when the file sits inside none.
|
|
123
107
|
"""
|
|
124
|
-
|
|
125
|
-
pyproject_filename = PYPROJECT_FILENAME
|
|
126
|
-
enclosing_temporary_root = enclosing_system_temporary_root(starting_file)
|
|
127
|
-
for each_candidate_directory in ancestor_directories(starting_file):
|
|
128
|
-
has_git_entry = (each_candidate_directory / git_entry_name).exists()
|
|
129
|
-
has_pyproject = (each_candidate_directory / pyproject_filename).is_file()
|
|
130
|
-
if has_git_entry or has_pyproject:
|
|
131
|
-
return each_candidate_directory
|
|
132
|
-
if each_candidate_directory == enclosing_temporary_root:
|
|
133
|
-
return None
|
|
134
|
-
return None
|
|
108
|
+
return enclosing_project_root(starting_file)
|
|
135
109
|
|
|
136
110
|
|
|
137
111
|
def _first_module_resolution_root(all_py_files: list[str]) -> Path | None:
|