claude-dev-env 7.4.1 → 7.6.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/skills/everything-search/scripts/everything_search.py +11 -0
- package/.agents/skills/everything-search/scripts/test_everything_search.py +1 -0
- package/.agents/skills/fresh-branch/SKILL.md +1 -0
- package/.agents/skills/fresh-branch/scripts/fresh_branch_git_commands.py +3 -0
- package/.agents/skills/fresh-branch/scripts/subprocess_window_access.py +43 -0
- package/.agents/skills/syncing-submodules/SKILL.md +1 -0
- package/.agents/skills/syncing-submodules/scripts/submodule_sync.py +4 -0
- package/.agents/skills/syncing-submodules/scripts/subprocess_window_access.py +43 -0
- package/.agents/skills/test-runner/SKILL.md +1 -0
- package/.agents/skills/test-runner/scripts/preflight_checks.py +5 -0
- package/.agents/skills/test-runner/scripts/run_tests.py +11 -0
- package/.agents/skills/test-runner/scripts/subprocess_window_access.py +43 -0
- package/.agents/skills/test-runner/scripts/test_run_tests.py +9 -7
- package/_shared/pr-loop/scripts/check_bugbot_ci.py +3 -0
- package/_shared/pr-loop/scripts/check_convergence_gates.py +3 -0
- package/_shared/pr-loop/scripts/check_pending_reviews.py +3 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/added_line_maps.py +3 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/baseline_import_isolation.py +3 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +5 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/git_file_sets.py +4 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +3 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +3 -0
- package/_shared/pr-loop/scripts/codex_usage_probe.py +3 -0
- package/_shared/pr-loop/scripts/copilot_quota.py +3 -0
- package/_shared/pr-loop/scripts/fetch_copilot_reviews.py +3 -0
- package/_shared/pr-loop/scripts/fix_hookspath.py +7 -0
- package/_shared/pr-loop/scripts/post_fix_reply.py +4 -0
- package/_shared/pr-loop/scripts/preflight.py +7 -0
- package/_shared/pr-loop/scripts/preflight_self_heal.py +5 -0
- package/_shared/pr-loop/scripts/run_codex_review.py +3 -0
- package/_shared/pr-loop/scripts/subprocess_window_access.py +41 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +5 -0
- package/_shared/process-tree/scripts/process_tree_kill.py +3 -0
- package/_shared/process-tree/scripts/subprocess_window_access.py +41 -0
- package/bin/install-constants.mjs +4 -2
- package/bin/install.cursor-rules.test.mjs +57 -15
- package/bin/install.mjs +10 -16
- package/bin/install.policy-lint.test.mjs +1 -2
- package/bin/install.test.mjs +0 -1
- package/bin/pstack-model-preferences.claude.json +23 -0
- package/hooks/advisory/msys_path_conversion_advisor.py +200 -0
- package/hooks/advisory/pr_done_reminder.py +14 -13
- package/hooks/advisory/refactor_guard.py +8 -0
- package/hooks/advisory/test_msys_path_conversion_advisor.py +223 -0
- package/hooks/advisory/test_pr_done_reminder.py +22 -0
- package/hooks/advisory/test_refactor_guard.py +40 -0
- package/hooks/blocking/bash_post_call_dispatcher.py +6 -11
- package/hooks/blocking/block_main_commit.py +4 -0
- package/hooks/blocking/code_rules_comments.py +39 -5
- package/hooks/blocking/msys_rev_path_rewriter.py +192 -0
- package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +3 -0
- package/hooks/blocking/precommit_code_rules_gate.py +3 -0
- package/hooks/blocking/tdd_enforcer_parts/content_analysis.py +19 -3
- package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +4 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_analysis.py +25 -0
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +16 -3
- package/hooks/blocking/test_bash_post_call_dispatcher.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +27 -0
- package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +10 -0
- package/hooks/blocking/test_msys_rev_path_rewriter.py +141 -0
- package/hooks/git-hooks/git_hooks_constants/verification_notice_constants.py +0 -2
- package/hooks/git-hooks/pull_request_handoff.py +3 -0
- package/hooks/git-hooks/subprocess_window_access.py +41 -0
- package/hooks/git-hooks/test_verification_start.py +4 -0
- package/hooks/git-hooks/verification_notice_context.py +3 -0
- package/hooks/git-hooks/verification_start.py +7 -8
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +9 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +6 -1
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +2 -0
- package/hooks/hooks_constants/msys_path_conversion_advisor_constants.py +48 -0
- package/hooks/hooks_constants/msys_rev_path_rewriter_constants.py +37 -0
- package/hooks/hooks_constants/post_tool_use_context.py +34 -0
- package/hooks/hooks_constants/pr_done_reminder_constants.py +0 -2
- package/hooks/hooks_constants/pre_tool_use_allow_output.py +48 -0
- package/hooks/hooks_constants/subprocess_window.py +141 -0
- package/hooks/hooks_constants/subprocess_window_constants.py +9 -0
- package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +9 -2
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +9 -2
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +6 -0
- package/hooks/hooks_constants/test_failure_recorder_constants.py +1 -2
- package/hooks/hooks_constants/test_post_tool_use_context.py +22 -0
- package/hooks/hooks_constants/test_pre_tool_use_allow_output.py +27 -0
- package/hooks/hooks_constants/test_subprocess_window.py +128 -0
- package/hooks/lifecycle/enter_worktree_origin_prefetch.py +3 -0
- package/hooks/observability/test_failure_recorder.py +3 -1
- package/hooks/validation/mypy_validator.py +4 -0
- package/hooks/validators/README.md +1 -0
- package/hooks/validators/git_checks.py +4 -0
- package/hooks/validators/health_check.py +3 -0
- package/hooks/validators/hook_timing_harness.py +3 -0
- package/hooks/validators/mypy_integration.py +4 -0
- package/hooks/validators/ruff_integration.py +6 -0
- package/hooks/validators/run_all_validators.py +7 -0
- package/hooks/validators/subprocess_window_access.py +41 -0
- package/hooks/workflow/auto_formatter.py +8 -0
- package/hooks/workflow/test_auto_formatter.py +39 -0
- package/package.json +1 -1
- package/rules/ask-user-question-required.md +3 -3
- package/rules/code-standards.md +3 -2
- package/rules/confirm-implementation-forks.md +2 -0
- package/rules/destructive-commands.md +19 -19
- package/rules/doc-inventory-integrity.md +4 -4
- package/rules/docstring-prose-matches-implementation.md +2 -2
- package/rules/failure-blast-radius.md +4 -2
- package/rules/filesystem-search.md +1 -1
- package/rules/git-workflow.md +34 -0
- package/rules/long-horizon-autonomy.md +2 -0
- package/rules/nas-ssh-invocation.md +3 -3
- package/rules/no-cross-skill-duplicate-helpers.md +6 -6
- package/rules/orphan-css-class.md +5 -5
- package/rules/paired-test-coverage.md +6 -6
- package/rules/plain-illustrative-docstrings.md +3 -3
- package/rules/pstack-models.md +4 -3
- package/rules/re-stage-before-commit.md +7 -7
- package/rules/retired-hook-prose.md +16 -0
- package/rules/shell-invocation.md +17 -1
- package/rules/testing.md +3 -5
- package/rules/verify-runtime-state.md +1 -1
- package/rules/windows-filesystem-safe.md +2 -2
- package/rules/workflow-substitution-slots.md +1 -1
- package/scripts/automatic_advisory/checkout.py +8 -1
- package/scripts/automatic_advisory/cli.py +9 -3
- package/scripts/automatic_advisory/config/constants.py +0 -2
- package/scripts/automatic_advisory/execution.py +7 -1
- package/scripts/automatic_advisory/git.py +9 -1
- package/scripts/automatic_advisory/process_host.py +3 -0
- package/scripts/claude_chain_runner.py +3 -0
- package/scripts/durable_post_lint_config/config/constants.py +0 -3
- package/scripts/durable_post_lint_config/config/test_constants.py +5 -0
- package/scripts/gh_artifact_upload.py +3 -0
- package/scripts/grok_headless_runner.py +3 -0
- package/scripts/grok_patch_artifacts.py +4 -0
- package/scripts/grok_worker_preflight.py +3 -0
- package/scripts/invoke_code_review.py +3 -0
- package/scripts/local_verification/command_runner.py +3 -0
- package/scripts/local_verification/git_state.py +3 -0
- package/scripts/policy_lint/adapter_configuration.py +24 -0
- package/scripts/policy_lint/adapter_detectors.py +48 -2
- package/scripts/policy_lint/adapter_pairing.py +150 -2
- package/scripts/policy_lint/adapter_retired_hook_prose.py +201 -0
- package/scripts/policy_lint/adapters.py +82 -5
- package/scripts/policy_lint/config/approved_test_pairs.py +25 -9
- package/scripts/policy_lint/config/constants.py +77 -0
- package/scripts/policy_lint/registry.py +6 -1
- package/scripts/policy_lint/selection.py +37 -18
- package/scripts/policy_lint/selection_git.py +17 -2
- package/scripts/repository_checks/config/constants.py +5 -0
- package/scripts/repository_checks/config/policy_document.py +19 -0
- package/scripts/repository_checks/email_exemptions.py +14 -71
- package/scripts/repository_checks/path_exemptions.py +35 -0
- package/scripts/repository_checks/policy_document.py +120 -0
- package/scripts/repository_checks/tracked_secrets.py +27 -4
- package/scripts/setup_project_paths.py +3 -0
- package/scripts/spawn_grok_batch.py +3 -0
- package/scripts/subprocess_window_access.py +41 -0
- package/scripts/test_dispatcher_profile_import.py +4 -0
- package/scripts/test_durable_post_lint.py +42 -9
- package/scripts/tests/test_adapter_configuration.py +8 -0
- package/scripts/tests/test_automatic_advisory_window_flags.py +17 -11
- package/scripts/tests/test_email_exemptions.py +37 -0
- package/scripts/tests/test_path_exemptions.py +164 -0
- package/scripts/tests/test_policy_document.py +104 -0
- package/scripts/tests/test_policy_lint_rules_function_length_scope.py +101 -0
- package/scripts/tests/test_policy_lint_rules_pairing.py +375 -0
- package/scripts/tests/test_policy_lint_rules_registry.py +116 -0
- package/scripts/tests/test_policy_lint_rules_retired_hook_prose.py +197 -0
- package/scripts/tests/test_policy_lint_selection.py +141 -0
- package/scripts/tests/test_subprocess_window_access.py +118 -0
- package/scripts/tests/test_tracked_secrets.py +79 -0
- package/scripts/validate_instruction_pairs.py +3 -0
- package/scripts/verify_installable_package.py +5 -0
- package/hooks/hooks_constants/convergence_branch_constants.py +0 -8
- package/hooks/hooks_constants/destructive_command_environment_constants.py +0 -18
- package/hooks/hooks_constants/test_text_stripping.py +0 -39
- package/hooks/hooks_constants/text_stripping.py +0 -36
- package/scripts/automatic_advisory/window_flags.py +0 -41
- package/scripts/repository_checks/config/email_exemptions.py +0 -10
|
@@ -30,6 +30,7 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
|
30
30
|
ALL_TOKEN_ANCHORED_EXEMPT_COMMENT_BODIES,
|
|
31
31
|
CHAINED_INLINE_COMMENT_PATTERN,
|
|
32
32
|
MAX_COMMENT_ISSUES,
|
|
33
|
+
STEALTH_KEEP_COMMENT_MARKER,
|
|
33
34
|
)
|
|
34
35
|
_javascript_comment_scanner = importlib.import_module("javascript_comment_scanner")
|
|
35
36
|
extract_javascript_comment_occurrences = (
|
|
@@ -152,10 +153,36 @@ def _python_comment_occurrences(
|
|
|
152
153
|
return all_occurrences, True
|
|
153
154
|
|
|
154
155
|
|
|
156
|
+
def _is_stealth_keep_occurrence(comment_text: str) -> bool:
|
|
157
|
+
"""Return True for a ``# STEALTH: Keep`` occurrence.
|
|
158
|
+
|
|
159
|
+
``STEALTH: Keep`` is meant to be freely added and to survive edits to
|
|
160
|
+
the line it sits on, unlike ``noqa`` or ``TODO``, which AGENTS.md
|
|
161
|
+
requires to be removed rather than added or justified. It is excluded
|
|
162
|
+
here rather than through the shared exempt-marker set that
|
|
163
|
+
``check_comment_changes`` deliberately still blocks on add.
|
|
164
|
+
"""
|
|
165
|
+
return comment_text.startswith("#") and comment_text[1:].lstrip().startswith(
|
|
166
|
+
STEALTH_KEEP_COMMENT_MARKER
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _without_stealth_keep_occurrences(
|
|
171
|
+
all_occurrences: list[tuple[str, int, bool]]
|
|
172
|
+
) -> list[tuple[str, int, bool]]:
|
|
173
|
+
return [
|
|
174
|
+
each_occurrence
|
|
175
|
+
for each_occurrence in all_occurrences
|
|
176
|
+
if not _is_stealth_keep_occurrence(each_occurrence[0])
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
|
|
155
180
|
def check_comment_changes(old_content: str, new_content: str, file_path: str) -> list[str]:
|
|
156
181
|
"""Check for comment additions or removals between old and new content.
|
|
157
182
|
|
|
158
|
-
Inline and standalone comments are blocking findings when added
|
|
183
|
+
Inline and standalone comments are blocking findings when added, except
|
|
184
|
+
``STEALTH: Keep`` occurrences, which are excluded entirely so the marker
|
|
185
|
+
can be added and can persist across edits to its line.
|
|
159
186
|
Existing comments can be removed when the touched code no longer needs them.
|
|
160
187
|
|
|
161
188
|
When the file is Python and either *old_content* or *new_content* cannot
|
|
@@ -178,6 +205,9 @@ def check_comment_changes(old_content: str, new_content: str, file_path: str) ->
|
|
|
178
205
|
old_occurrences = extract_javascript_comment_occurrences(old_content, True)
|
|
179
206
|
new_occurrences = extract_javascript_comment_occurrences(new_content, True)
|
|
180
207
|
|
|
208
|
+
old_occurrences = _without_stealth_keep_occurrences(old_occurrences)
|
|
209
|
+
new_occurrences = _without_stealth_keep_occurrences(new_occurrences)
|
|
210
|
+
|
|
181
211
|
old_occurrence_counts = Counter(
|
|
182
212
|
(each_text, is_inline)
|
|
183
213
|
for each_text, _line_number, is_inline in old_occurrences
|
|
@@ -264,6 +294,8 @@ def _retained_comment_issues(
|
|
|
264
294
|
(old_occurrences, old_tokenize_ok), (new_occurrences, new_tokenize_ok) = (_comment_occurrences(old_content, file_path, True), _comment_occurrences(new_content, file_path, True))
|
|
265
295
|
if not (old_tokenize_ok and new_tokenize_ok):
|
|
266
296
|
return []
|
|
297
|
+
old_occurrences = _without_stealth_keep_occurrences(old_occurrences)
|
|
298
|
+
new_occurrences = _without_stealth_keep_occurrences(new_occurrences)
|
|
267
299
|
all_changed_lines, all_deleted_lines, old_line_by_new_line = _line_diff_data(old_content, new_content)
|
|
268
300
|
old_line_by_key = {
|
|
269
301
|
each_key: [each_line for each_text, each_line, each_is_inline in old_occurrences if (each_text, each_is_inline) == each_key]
|
|
@@ -346,12 +378,14 @@ def _is_exempt_python_comment(comment_token: tokenize.TokenInfo) -> bool:
|
|
|
346
378
|
token-anchored directive body never legitimately carries a ``#``
|
|
347
379
|
(noqa codes, pylint symbols, and pragma directives contain none), so
|
|
348
380
|
any inner ``#`` reliably marks chained prose. Free-form markers
|
|
349
|
-
(``type:``, ``TODO``, ``FIXME``, ``HACK``, ``XXX``)
|
|
350
|
-
trailing prose:
|
|
381
|
+
(``type:``, ``TODO``, ``FIXME``, ``HACK``, ``XXX``, ``STEALTH: Keep``)
|
|
382
|
+
accept any trailing prose:
|
|
351
383
|
``# type:`` participates in the documented justification
|
|
352
384
|
convention enforced by ``check_type_escape_hatches`` (which
|
|
353
|
-
requires a trailing reason),
|
|
354
|
-
annotation text by convention
|
|
385
|
+
requires a trailing reason), the TODO-family markers carry
|
|
386
|
+
annotation text by convention, and ``STEALTH: Keep`` is the
|
|
387
|
+
acknowledged-bypass marker the CDP runtime-usage scanner reads
|
|
388
|
+
from the flagged line together with its reason.
|
|
355
389
|
"""
|
|
356
390
|
comment_string = comment_token.string
|
|
357
391
|
if comment_string.startswith("#!") and comment_token.start == (1, 0):
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse rewriter: keep Git Bash from converting a ``<rev>:<path>`` argument.
|
|
3
|
+
|
|
4
|
+
Git Bash rewrites a git argument such as ``origin/main:.claude/settings.json``
|
|
5
|
+
into a backslash-separated form joined by a semicolon before git sees it, and
|
|
6
|
+
git then fails with ``fatal: ambiguous argument``. This hook reads the command
|
|
7
|
+
before it runs, finds every token MSYS would convert, and prepends one
|
|
8
|
+
``export MSYS2_ARG_CONV_EXCL='<rev1>:;<rev2>:'; `` naming exactly those
|
|
9
|
+
prefixes. Every other argument in the command keeps converting as before.
|
|
10
|
+
|
|
11
|
+
A token is converted only when the part before its first colon carries a slash
|
|
12
|
+
and the part after that colon opens a repository-root path, so ``HEAD:.gitignore``
|
|
13
|
+
and ``origin/main:packages/app.py`` both reach git untouched and neither is
|
|
14
|
+
collected.
|
|
15
|
+
|
|
16
|
+
Quiet branches, each emitting nothing: a non-Bash tool, a command that already
|
|
17
|
+
names one of the MSYS conversion variables, and a command from which no prefix
|
|
18
|
+
was collected.
|
|
19
|
+
|
|
20
|
+
Hosted by ``blocking/bash_pre_tool_use_dispatcher.py``, which carries the
|
|
21
|
+
``updatedInput`` this hook prints through to the harness.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import shlex
|
|
27
|
+
import sys
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
_hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
32
|
+
if _hooks_root_directory not in sys.path:
|
|
33
|
+
sys.path.insert(0, _hooks_root_directory)
|
|
34
|
+
from hooks_constants.bash_pre_tool_use_dispatcher_constants import BASH_TOOL_NAME
|
|
35
|
+
from hooks_constants.msys_path_conversion_advisor_constants import (
|
|
36
|
+
ALL_MSYS_PATH_CONVERSION_VARIABLE_NAMES,
|
|
37
|
+
)
|
|
38
|
+
from hooks_constants.msys_rev_path_rewriter_constants import (
|
|
39
|
+
ALL_CONVERTED_PATH_START_CHARACTERS,
|
|
40
|
+
ALL_REVISION_FORBIDDEN_CHARACTERS,
|
|
41
|
+
ALL_UNCONVERTED_PATH_START_PREFIXES,
|
|
42
|
+
EXCLUSION_EXPORT_TEMPLATE,
|
|
43
|
+
EXCLUSION_PREFIX_JOIN_SEPARATOR,
|
|
44
|
+
GIT_PROGRAM_NAME,
|
|
45
|
+
REVISION_PATH_SEPARATOR,
|
|
46
|
+
REVISION_PATH_SPLIT_COUNT,
|
|
47
|
+
REVISION_SLASH_CHARACTER,
|
|
48
|
+
)
|
|
49
|
+
from hooks_constants.pre_tool_use_allow_output import (
|
|
50
|
+
write_pre_tool_use_allow_to_stdout,
|
|
51
|
+
)
|
|
52
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
53
|
+
from hooks_constants.shell_command_segments import (
|
|
54
|
+
effective_leading_program,
|
|
55
|
+
split_into_segments,
|
|
56
|
+
token_basename,
|
|
57
|
+
)
|
|
58
|
+
except ImportError as import_error:
|
|
59
|
+
raise ImportError(
|
|
60
|
+
"The MSYS revision-path rewriter cannot import its dependencies; "
|
|
61
|
+
"ensure the hooks directory is importable."
|
|
62
|
+
) from import_error
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _all_git_segment_tokens(command: str) -> list[list[str]]:
|
|
66
|
+
"""Return the token lists of the command's segments that run git."""
|
|
67
|
+
try:
|
|
68
|
+
all_command_tokens = shlex.split(command, posix=True)
|
|
69
|
+
except ValueError:
|
|
70
|
+
all_command_tokens = command.split()
|
|
71
|
+
all_git_segments: list[list[str]] = []
|
|
72
|
+
for each_segment in split_into_segments(all_command_tokens):
|
|
73
|
+
program_token = effective_leading_program(each_segment)
|
|
74
|
+
if program_token is None:
|
|
75
|
+
continue
|
|
76
|
+
if token_basename(program_token) == GIT_PROGRAM_NAME:
|
|
77
|
+
all_git_segments.append(each_segment)
|
|
78
|
+
return all_git_segments
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _is_revision_shaped(revision_text: str) -> bool:
|
|
82
|
+
"""Return True when the text can name a git revision.
|
|
83
|
+
|
|
84
|
+
::
|
|
85
|
+
|
|
86
|
+
origin/main -> True
|
|
87
|
+
HEAD -> False, no slash
|
|
88
|
+
don't touch a/b -> False, a quoted message, not a revision
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
revision_text: The part of a token before its first colon.
|
|
92
|
+
"""
|
|
93
|
+
if not revision_text or REVISION_SLASH_CHARACTER not in revision_text:
|
|
94
|
+
return False
|
|
95
|
+
if any(each_character.isspace() for each_character in revision_text):
|
|
96
|
+
return False
|
|
97
|
+
return not any(
|
|
98
|
+
each_character in revision_text for each_character in ALL_REVISION_FORBIDDEN_CHARACTERS
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _converted_revision_prefix(token: str) -> str | None:
|
|
103
|
+
"""Return the ``<rev>:`` prefix MSYS would convert in this token, or None.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
token: One shell token from a git segment.
|
|
107
|
+
"""
|
|
108
|
+
if REVISION_PATH_SEPARATOR not in token:
|
|
109
|
+
return None
|
|
110
|
+
revision_text, path_text = token.split(REVISION_PATH_SEPARATOR, REVISION_PATH_SPLIT_COUNT)
|
|
111
|
+
if not _is_revision_shaped(revision_text):
|
|
112
|
+
return None
|
|
113
|
+
if not path_text.startswith(ALL_CONVERTED_PATH_START_CHARACTERS):
|
|
114
|
+
return None
|
|
115
|
+
if path_text.startswith(ALL_UNCONVERTED_PATH_START_PREFIXES):
|
|
116
|
+
return None
|
|
117
|
+
return revision_text + REVISION_PATH_SEPARATOR
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _extend_with_new_prefixes(
|
|
121
|
+
all_ordered_prefixes: list[str], all_segment_tokens: list[str]
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Append each convertible prefix this segment adds, skipping repeats.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
all_ordered_prefixes: The prefixes collected so far, appended in place.
|
|
127
|
+
all_segment_tokens: One git segment's shell tokens.
|
|
128
|
+
"""
|
|
129
|
+
for each_token in all_segment_tokens:
|
|
130
|
+
revision_prefix = _converted_revision_prefix(each_token)
|
|
131
|
+
if revision_prefix is not None and revision_prefix not in all_ordered_prefixes:
|
|
132
|
+
all_ordered_prefixes.append(revision_prefix)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def all_exclusion_prefixes(command: str) -> tuple[str, ...]:
|
|
136
|
+
"""Return the ``<rev>:`` prefixes MSYS would convert, deduplicated in order.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
command: The Bash command text the agent is about to run.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Each distinct prefix in first-appearance order, empty when the command
|
|
143
|
+
carries no token Git Bash would convert.
|
|
144
|
+
"""
|
|
145
|
+
all_ordered_prefixes: list[str] = []
|
|
146
|
+
for each_segment in _all_git_segment_tokens(command):
|
|
147
|
+
_extend_with_new_prefixes(all_ordered_prefixes, each_segment)
|
|
148
|
+
return tuple(all_ordered_prefixes)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def command_with_exclusion_export(command: str) -> str:
|
|
152
|
+
"""Return the command prefixed with the surgical exclusion export.
|
|
153
|
+
|
|
154
|
+
Returns the command unchanged when it already names an MSYS conversion
|
|
155
|
+
variable or when no convertible token was found.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
command: The Bash command text the agent is about to run.
|
|
159
|
+
"""
|
|
160
|
+
if any(
|
|
161
|
+
each_variable_name in command
|
|
162
|
+
for each_variable_name in ALL_MSYS_PATH_CONVERSION_VARIABLE_NAMES
|
|
163
|
+
):
|
|
164
|
+
return command
|
|
165
|
+
all_prefixes = all_exclusion_prefixes(command)
|
|
166
|
+
if not all_prefixes:
|
|
167
|
+
return command
|
|
168
|
+
export_prefix = EXCLUSION_EXPORT_TEMPLATE.format(
|
|
169
|
+
all_prefixes=EXCLUSION_PREFIX_JOIN_SEPARATOR.join(all_prefixes)
|
|
170
|
+
)
|
|
171
|
+
return export_prefix + command
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def main() -> None:
|
|
175
|
+
"""Emit an allow carrying the exclusion-prefixed command, or stay quiet."""
|
|
176
|
+
hook_payload = read_hook_input_dictionary_from_stdin()
|
|
177
|
+
if hook_payload is None or hook_payload.get("tool_name") != BASH_TOOL_NAME:
|
|
178
|
+
return
|
|
179
|
+
tool_input = hook_payload.get("tool_input")
|
|
180
|
+
if not isinstance(tool_input, dict):
|
|
181
|
+
return
|
|
182
|
+
command = tool_input.get("command", "")
|
|
183
|
+
if not isinstance(command, str):
|
|
184
|
+
return
|
|
185
|
+
rewritten_command = command_with_exclusion_export(command)
|
|
186
|
+
if rewritten_command == command:
|
|
187
|
+
return
|
|
188
|
+
write_pre_tool_use_allow_to_stdout({**tool_input, "command": rewritten_command})
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
if __name__ == "__main__":
|
|
192
|
+
main()
|
|
@@ -16,6 +16,8 @@ import sys
|
|
|
16
16
|
from pathlib import Path
|
|
17
17
|
from urllib.parse import urlparse
|
|
18
18
|
|
|
19
|
+
from hooks_constants.subprocess_window import hidden_window_creation_flags
|
|
20
|
+
|
|
19
21
|
try:
|
|
20
22
|
_blocking_directory = str(Path(__file__).resolve().parent.parent)
|
|
21
23
|
_hooks_directory = str(Path(__file__).resolve().parent.parent.parent)
|
|
@@ -186,6 +188,7 @@ def _repository_origin_slug(repository_root: Path) -> str | None:
|
|
|
186
188
|
text=True,
|
|
187
189
|
timeout=GIT_COMMAND_TIMEOUT_SECONDS,
|
|
188
190
|
cwd=str(repository_root),
|
|
191
|
+
creationflags=hidden_window_creation_flags(),
|
|
189
192
|
)
|
|
190
193
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
191
194
|
return None
|
|
@@ -18,6 +18,8 @@ from hooks_constants.precommit_code_rules_gate_constants import ( # noqa: E402
|
|
|
18
18
|
GIT_COMMAND_TIMEOUT_SECONDS,
|
|
19
19
|
)
|
|
20
20
|
|
|
21
|
+
from hooks_constants.subprocess_window import hidden_window_creation_flags
|
|
22
|
+
|
|
21
23
|
|
|
22
24
|
def resolve_repository_root(working_directory: str | None) -> Path | None:
|
|
23
25
|
"""Resolve the Git repository root for a directory.
|
|
@@ -36,6 +38,7 @@ def resolve_repository_root(working_directory: str | None) -> Path | None:
|
|
|
36
38
|
text=True,
|
|
37
39
|
timeout=GIT_COMMAND_TIMEOUT_SECONDS,
|
|
38
40
|
cwd=working_directory,
|
|
41
|
+
creationflags=hidden_window_creation_flags(),
|
|
39
42
|
)
|
|
40
43
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
41
44
|
return None
|
|
@@ -30,6 +30,10 @@ def _safe_constant_attribute_calls() -> frozenset[tuple[str, str]]:
|
|
|
30
30
|
return frozenset({("re", "compile")})
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
def _safe_string_literal_methods() -> frozenset[str]:
|
|
34
|
+
return frozenset({"join"})
|
|
35
|
+
|
|
36
|
+
|
|
33
37
|
def _node_is_unsafe_construct(subnode: ast.AST) -> bool:
|
|
34
38
|
return isinstance(
|
|
35
39
|
subnode,
|
|
@@ -37,7 +41,18 @@ def _node_is_unsafe_construct(subnode: ast.AST) -> bool:
|
|
|
37
41
|
)
|
|
38
42
|
|
|
39
43
|
|
|
44
|
+
def _is_safe_string_literal_method_call(function_node: ast.Attribute) -> bool:
|
|
45
|
+
receiver = function_node.value
|
|
46
|
+
return (
|
|
47
|
+
isinstance(receiver, ast.Constant)
|
|
48
|
+
and isinstance(receiver.value, str)
|
|
49
|
+
and function_node.attr in _safe_string_literal_methods()
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
40
53
|
def _attribute_call_is_unsafe(function_node: ast.Attribute) -> bool:
|
|
54
|
+
if _is_safe_string_literal_method_call(function_node):
|
|
55
|
+
return False
|
|
41
56
|
if not isinstance(function_node.value, ast.Name):
|
|
42
57
|
return True
|
|
43
58
|
pair = (function_node.value.id, function_node.attr)
|
|
@@ -56,9 +71,10 @@ def _call_is_unsafe(call_node: ast.Call) -> bool:
|
|
|
56
71
|
def _rhs_has_unsafe_call(rhs_node: ast.AST) -> bool:
|
|
57
72
|
"""Return True when a constant's value contains a non-allowlisted call.
|
|
58
73
|
|
|
59
|
-
Safe calls are value constructors (``Path(...)``, ``re.compile(...)``)
|
|
60
|
-
|
|
61
|
-
|
|
74
|
+
Safe calls are value constructors (``Path(...)``, ``re.compile(...)``) and
|
|
75
|
+
a pure string-literal method call (``"|".join(...)``) that build objects
|
|
76
|
+
without side effects; any other call or a comprehension reads as
|
|
77
|
+
import-time behavior.
|
|
62
78
|
|
|
63
79
|
Args:
|
|
64
80
|
rhs_node: The assignment's right-hand-side expression node.
|
|
@@ -29,6 +29,8 @@ from tdd_enforcer_parts.config.tdd_enforcer_constants import (
|
|
|
29
29
|
GIT_PATHSPEC_SEPARATOR,
|
|
30
30
|
)
|
|
31
31
|
|
|
32
|
+
from hooks_constants.subprocess_window import hidden_window_creation_flags
|
|
33
|
+
|
|
32
34
|
|
|
33
35
|
def _git_ls_files_command(file_name: str) -> list[str]:
|
|
34
36
|
return [GIT_EXECUTABLE_NAME, GIT_LS_FILES_SUBCOMMAND, GIT_PATHSPEC_SEPARATOR, file_name]
|
|
@@ -57,6 +59,7 @@ def _git_tracks_path(path: Path) -> bool:
|
|
|
57
59
|
text=True,
|
|
58
60
|
check=False,
|
|
59
61
|
timeout=GIT_LS_FILES_TIMEOUT_SECONDS,
|
|
62
|
+
creationflags=hidden_window_creation_flags(),
|
|
60
63
|
)
|
|
61
64
|
except (OSError, subprocess.SubprocessError):
|
|
62
65
|
return False
|
|
@@ -80,6 +83,7 @@ def _working_tree_differs_from_head(path: Path) -> bool:
|
|
|
80
83
|
text=True,
|
|
81
84
|
check=False,
|
|
82
85
|
timeout=GIT_DIFF_TIMEOUT_SECONDS,
|
|
86
|
+
creationflags=hidden_window_creation_flags(),
|
|
83
87
|
)
|
|
84
88
|
except (OSError, subprocess.SubprocessError):
|
|
85
89
|
return True
|
|
@@ -12,6 +12,31 @@ def test_constants_only_rejects_function_definition() -> None:
|
|
|
12
12
|
assert content_analysis._is_constants_only_python_content("def f():\n return 1\n") is False
|
|
13
13
|
|
|
14
14
|
|
|
15
|
+
def test_constants_only_accepts_join_on_a_string_literal_inside_re_compile() -> None:
|
|
16
|
+
content = (
|
|
17
|
+
'"""doc."""\n'
|
|
18
|
+
"import re\n"
|
|
19
|
+
"NAMES = ('one', 'two')\n"
|
|
20
|
+
'PATTERN = re.compile("(" + "|".join(NAMES) + ")")\n'
|
|
21
|
+
)
|
|
22
|
+
assert content_analysis._is_constants_only_python_content(content) is True
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_constants_only_rejects_join_on_a_non_literal_receiver() -> None:
|
|
26
|
+
content = (
|
|
27
|
+
'"""doc."""\n'
|
|
28
|
+
'SEPARATOR = "|"\n'
|
|
29
|
+
'NAMES = ("one", "two")\n'
|
|
30
|
+
"PATTERN = SEPARATOR.join(NAMES)\n"
|
|
31
|
+
)
|
|
32
|
+
assert content_analysis._is_constants_only_python_content(content) is False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_constants_only_rejects_a_non_allowlisted_method_on_a_string_literal() -> None:
|
|
36
|
+
content = '"""doc."""\nSHOUT = "hi".upper()\n'
|
|
37
|
+
assert content_analysis._is_constants_only_python_content(content) is False
|
|
38
|
+
|
|
39
|
+
|
|
15
40
|
def test_post_edit_import_only_allows_import_removal() -> None:
|
|
16
41
|
existing = "import os\n\ndef run(): return 1\n"
|
|
17
42
|
tool_input = {"old_string": "import os\n", "new_string": ""}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
"""Tests for the
|
|
1
|
+
"""Tests for what the Bash and PowerShell PreToolUse chain costs to run.
|
|
2
|
+
|
|
3
|
+
A Bash tool call starts one interpreter, the dispatcher's own. Each roster
|
|
4
|
+
entry then runs in that process through runpy, so the roster's exact content is
|
|
5
|
+
the chain's cost after that single start. One allow-and-rewrite hook is the
|
|
6
|
+
whole roster; a blocking hook added here fails this test.
|
|
7
|
+
"""
|
|
2
8
|
|
|
3
9
|
import sys
|
|
4
10
|
from pathlib import Path
|
|
@@ -9,8 +15,15 @@ if str(_HOOKS_ROOT) not in sys.path:
|
|
|
9
15
|
|
|
10
16
|
from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
|
|
11
17
|
ALL_BASH_HOSTED_HOOK_ENTRIES,
|
|
18
|
+
ALL_BASH_ONLY_TOOL_NAMES,
|
|
19
|
+
BashHostedHookEntry,
|
|
12
20
|
)
|
|
13
21
|
|
|
14
22
|
|
|
15
|
-
def
|
|
16
|
-
assert ALL_BASH_HOSTED_HOOK_ENTRIES == (
|
|
23
|
+
def test_bash_roster_starts_only_the_msys_rewriter_and_no_blocking_hook() -> None:
|
|
24
|
+
assert ALL_BASH_HOSTED_HOOK_ENTRIES == (
|
|
25
|
+
BashHostedHookEntry(
|
|
26
|
+
script_relative_path="blocking/msys_rev_path_rewriter.py",
|
|
27
|
+
applicable_tool_names=ALL_BASH_ONLY_TOOL_NAMES,
|
|
28
|
+
),
|
|
29
|
+
)
|
|
@@ -28,6 +28,7 @@ from tdd_enforcer_parts import content_hash_store
|
|
|
28
28
|
_ALL_EXPECTED_ROSTER_PATHS = [
|
|
29
29
|
"observability/test_failure_recorder.py",
|
|
30
30
|
"advisory/pr_done_reminder.py",
|
|
31
|
+
"advisory/msys_path_conversion_advisor.py",
|
|
31
32
|
]
|
|
32
33
|
|
|
33
34
|
|
|
@@ -66,6 +67,7 @@ def test_dispatch_forwards_hosted_hook_additional_context_as_one_payload(
|
|
|
66
67
|
"pr_done_reminder.py": json.dumps(
|
|
67
68
|
{"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": "PR #42"}}
|
|
68
69
|
),
|
|
70
|
+
"msys_path_conversion_advisor.py": "",
|
|
69
71
|
}
|
|
70
72
|
|
|
71
73
|
def _fake_run_hook(script_path: str, payload_text: str) -> HostedHookRun:
|
|
@@ -133,6 +133,33 @@ def test_check_comment_changes_allows_comment_removal_without_advisory(
|
|
|
133
133
|
assert captured.err == ""
|
|
134
134
|
|
|
135
135
|
|
|
136
|
+
def test_check_comment_changes_does_not_flag_added_stealth_keep_marker() -> None:
|
|
137
|
+
old_content = "x = 1\n"
|
|
138
|
+
new_content = "x = 1 # STEALTH: Keep -- reason\n"
|
|
139
|
+
issues = code_rules_enforcer.check_comment_changes(old_content, new_content, "foo.py")
|
|
140
|
+
assert issues == []
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_check_comment_changes_does_not_flag_stealth_keep_retained_on_touched_line() -> None:
|
|
144
|
+
old_content = "x = 1 # STEALTH: Keep -- reason\n"
|
|
145
|
+
new_content = "x = 2 # STEALTH: Keep -- reason\n"
|
|
146
|
+
issues = code_rules_enforcer.check_comment_changes(old_content, new_content, "foo.py")
|
|
147
|
+
assert issues == []
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@pytest.mark.parametrize(
|
|
151
|
+
"each_marker",
|
|
152
|
+
("# noqa: E501", "# type: ignore[misc]", "# TODO: revisit", "# FIXME: revisit"),
|
|
153
|
+
)
|
|
154
|
+
def test_check_comment_changes_still_flags_other_added_directive_markers(
|
|
155
|
+
each_marker: str,
|
|
156
|
+
) -> None:
|
|
157
|
+
old_content = "x = 1\n"
|
|
158
|
+
new_content = f"x = 1 {each_marker}\n"
|
|
159
|
+
issues = code_rules_enforcer.check_comment_changes(old_content, new_content, "foo.py")
|
|
160
|
+
assert any("comment added" in each_issue.lower() for each_issue in issues)
|
|
161
|
+
|
|
162
|
+
|
|
136
163
|
def test_python_check_should_exempt_directive_without_space_after_hash() -> None:
|
|
137
164
|
for each_directive in ("#noqa", "#type: ignore", "#pylint: disable", "#pragma: no cover"):
|
|
138
165
|
content = f"{each_directive}\n"
|
|
@@ -156,6 +156,16 @@ def test_should_exempt_fixme_with_trailing_prose() -> None:
|
|
|
156
156
|
assert _is_exempt_python_comment(token) is True
|
|
157
157
|
|
|
158
158
|
|
|
159
|
+
def test_should_exempt_stealth_keep_with_trailing_reason() -> None:
|
|
160
|
+
token = _build_comment_token("# STEALTH: Keep bypass reviewed in incident 4021")
|
|
161
|
+
assert _is_exempt_python_comment(token) is True
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_should_flag_stealth_marker_missing_required_space_after_colon() -> None:
|
|
165
|
+
token = _build_comment_token("# STEALTH:Keep bypass reviewed in incident 4021")
|
|
166
|
+
assert _is_exempt_python_comment(token) is False
|
|
167
|
+
|
|
168
|
+
|
|
159
169
|
def test_should_exempt_shebang_at_line_one_column_zero() -> None:
|
|
160
170
|
token = tokenize.TokenInfo(
|
|
161
171
|
type=tokenize.COMMENT,
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Tests for msys_rev_path_rewriter, the MSYS <rev>:<path> exclusion rewriter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from io import StringIO
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
import msys_rev_path_rewriter as rewriter
|
|
10
|
+
|
|
11
|
+
SETTINGS_SHOW_COMMAND = "git show origin/main:.claude/settings.json"
|
|
12
|
+
SETTINGS_SHOW_REWRITTEN = (
|
|
13
|
+
"export MSYS2_ARG_CONV_EXCL='origin/main:'; git show origin/main:.claude/settings.json"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _stdout_from_main(payload: dict[str, object]) -> str:
|
|
18
|
+
"""Return what main() wrote to stdout for one hook payload."""
|
|
19
|
+
captured_stdout = StringIO()
|
|
20
|
+
with patch("sys.stdin", StringIO(json.dumps(payload))), patch("sys.stdout", captured_stdout):
|
|
21
|
+
try:
|
|
22
|
+
rewriter.main()
|
|
23
|
+
except SystemExit:
|
|
24
|
+
pass
|
|
25
|
+
return captured_stdout.getvalue()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_settings_path_after_slashed_revision_yields_one_prefix() -> None:
|
|
29
|
+
assert rewriter.all_exclusion_prefixes(SETTINGS_SHOW_COMMAND) == ("origin/main:",)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_settings_path_command_gains_the_surgical_export_prefix() -> None:
|
|
33
|
+
assert rewriter.command_with_exclusion_export(SETTINGS_SHOW_COMMAND) == SETTINGS_SHOW_REWRITTEN
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_unslashed_revision_beside_a_slashed_one_stays_out_of_the_prefixes() -> None:
|
|
37
|
+
assert rewriter.all_exclusion_prefixes(
|
|
38
|
+
"git diff origin/main:.gitignore HEAD:.gitignore"
|
|
39
|
+
) == ("origin/main:",)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_two_slashed_revisions_join_with_a_semicolon_in_first_appearance_order() -> None:
|
|
43
|
+
assert rewriter.command_with_exclusion_export(
|
|
44
|
+
"git diff origin/main:.gitignore origin/develop:.gitignore"
|
|
45
|
+
) == (
|
|
46
|
+
"export MSYS2_ARG_CONV_EXCL='origin/main:;origin/develop:'; "
|
|
47
|
+
"git diff origin/main:.gitignore origin/develop:.gitignore"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_one_slashed_revision_used_twice_yields_one_prefix() -> None:
|
|
52
|
+
assert rewriter.all_exclusion_prefixes(
|
|
53
|
+
"git diff origin/main:.gitignore origin/main:.gitattributes"
|
|
54
|
+
) == ("origin/main:",)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_dot_file_after_the_colon_is_collected() -> None:
|
|
58
|
+
assert rewriter.all_exclusion_prefixes("git show origin/main:.gitignore") == ("origin/main:",)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_plain_directory_path_after_the_colon_is_left_alone() -> None:
|
|
62
|
+
command = "git show origin/main:packages/app.py"
|
|
63
|
+
assert rewriter.all_exclusion_prefixes(command) == ()
|
|
64
|
+
assert rewriter.command_with_exclusion_export(command) == command
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_revision_without_a_slash_is_left_alone() -> None:
|
|
68
|
+
command = "git show HEAD:.claude/settings.json"
|
|
69
|
+
assert rewriter.all_exclusion_prefixes(command) == ()
|
|
70
|
+
assert rewriter.command_with_exclusion_export(command) == command
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_dot_slash_path_after_the_colon_is_left_alone() -> None:
|
|
74
|
+
assert rewriter.all_exclusion_prefixes("git show origin/main:./settings.json") == ()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_parent_directory_path_after_the_colon_is_left_alone() -> None:
|
|
78
|
+
assert rewriter.all_exclusion_prefixes("git show origin/main:../settings.json") == ()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_command_already_naming_the_exclusion_variable_is_left_alone() -> None:
|
|
82
|
+
command = "MSYS2_ARG_CONV_EXCL='*' git show origin/main:.gitignore"
|
|
83
|
+
assert rewriter.command_with_exclusion_export(command) == command
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_command_already_naming_the_path_conversion_variable_is_left_alone() -> None:
|
|
87
|
+
command = "export MSYS_NO_PATHCONV=1; git show origin/main:.gitignore"
|
|
88
|
+
assert rewriter.command_with_exclusion_export(command) == command
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_colon_token_in_a_non_git_segment_is_left_alone() -> None:
|
|
92
|
+
assert rewriter.all_exclusion_prefixes("echo origin/main:.gitignore") == ()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_quoted_message_token_carrying_a_slash_and_a_dot_is_left_alone() -> None:
|
|
96
|
+
command = "git commit -m \"don't touch a/b:.x\""
|
|
97
|
+
assert rewriter.all_exclusion_prefixes(command) == ()
|
|
98
|
+
assert rewriter.command_with_exclusion_export(command) == command
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_url_token_in_a_curl_command_is_left_alone() -> None:
|
|
102
|
+
assert rewriter.all_exclusion_prefixes("curl https://example.com/x") == ()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_git_segment_after_a_non_git_segment_still_contributes() -> None:
|
|
106
|
+
assert rewriter.all_exclusion_prefixes(
|
|
107
|
+
"echo start && git show origin/main:.gitignore"
|
|
108
|
+
) == ("origin/main:",)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_main_emits_an_allow_carrying_the_rewritten_command() -> None:
|
|
112
|
+
emitted_text = _stdout_from_main(
|
|
113
|
+
{
|
|
114
|
+
"tool_name": "Bash",
|
|
115
|
+
"tool_input": {"command": SETTINGS_SHOW_COMMAND, "description": "read settings"},
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
assert json.loads(emitted_text) == {
|
|
119
|
+
"hookSpecificOutput": {
|
|
120
|
+
"hookEventName": "PreToolUse",
|
|
121
|
+
"permissionDecision": "allow",
|
|
122
|
+
"updatedInput": {
|
|
123
|
+
"command": SETTINGS_SHOW_REWRITTEN,
|
|
124
|
+
"description": "read settings",
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_main_emits_nothing_for_a_non_bash_tool() -> None:
|
|
131
|
+
emitted_text = _stdout_from_main(
|
|
132
|
+
{"tool_name": "PowerShell", "tool_input": {"command": SETTINGS_SHOW_COMMAND}}
|
|
133
|
+
)
|
|
134
|
+
assert emitted_text == ""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_main_emits_nothing_for_a_command_with_no_collected_prefix() -> None:
|
|
138
|
+
emitted_text = _stdout_from_main(
|
|
139
|
+
{"tool_name": "Bash", "tool_input": {"command": "git show HEAD:.gitignore"}}
|
|
140
|
+
)
|
|
141
|
+
assert emitted_text == ""
|
|
@@ -111,6 +111,4 @@ ALL_RUNNER_FIELDS: frozenset[str] = frozenset(
|
|
|
111
111
|
)
|
|
112
112
|
AUTOMATIC_ADVISORY_DIRECTORY_NAME: str = "automatic_advisory"
|
|
113
113
|
AUTOMATIC_ADVISORY_CLI_FILE_NAME: str = "cli.py"
|
|
114
|
-
WINDOWS_PLATFORM: str = "win32"
|
|
115
|
-
CREATE_NO_WINDOW_ATTRIBUTE: str = "CREATE_NO_WINDOW"
|
|
116
114
|
POWERSHELL_CALL_OPERATOR: str = "& "
|
|
@@ -9,6 +9,8 @@ from git_hooks_constants import (
|
|
|
9
9
|
GH_PR_VIEW_TIMEOUT_SECONDS,
|
|
10
10
|
)
|
|
11
11
|
|
|
12
|
+
from subprocess_window_access import hidden_window_creation_flags
|
|
13
|
+
|
|
12
14
|
|
|
13
15
|
def get_pull_request_url(repo_dir: Path) -> str | None:
|
|
14
16
|
try:
|
|
@@ -19,6 +21,7 @@ def get_pull_request_url(repo_dir: Path) -> str | None:
|
|
|
19
21
|
capture_output=True,
|
|
20
22
|
text=True,
|
|
21
23
|
timeout=GH_PR_VIEW_TIMEOUT_SECONDS,
|
|
24
|
+
creationflags=hidden_window_creation_flags(),
|
|
22
25
|
)
|
|
23
26
|
except (OSError, subprocess.SubprocessError):
|
|
24
27
|
return None
|