claude-dev-env 1.92.0 → 1.93.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/CLAUDE.md +5 -39
- package/_shared/CLAUDE.md +2 -1
- package/_shared/advisor/CLAUDE.md +21 -0
- package/_shared/advisor/advisor-protocol.md +142 -0
- package/_shared/advisor/scripts/config/advisor_scripts_constants/__init__.py +1 -0
- package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +86 -0
- package/_shared/advisor/scripts/model_tier_run_validator.py +246 -0
- package/_shared/advisor/scripts/pyproject.toml +3 -0
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +302 -0
- package/_shared/advisor/scripts/tests/test_tier_model_ids.py +180 -0
- package/_shared/advisor/scripts/tier_model_ids.py +164 -0
- package/_shared/pr-loop/CLAUDE.md +1 -0
- package/_shared/pr-loop/precatch-rubric.md +65 -0
- package/_shared/pr-loop/scripts/CLAUDE.md +1 -1
- package/_shared/pr-loop/scripts/code_rules_gate.py +137 -5
- package/_shared/pr-loop/scripts/post_audit_thread.py +1 -1
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -1
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +16 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +2 -1
- package/_shared/pr-loop/scripts/reviewer_availability.py +42 -12
- package/_shared/pr-loop/scripts/reviews_disabled.py +65 -21
- package/_shared/pr-loop/scripts/terminology_sweep.py +69 -21
- package/_shared/pr-loop/scripts/tests/CLAUDE.md +1 -1
- package/_shared/pr-loop/scripts/tests/fixtures/{copilot_internal_user_jonecho.json → copilot_internal_user_example.json} +1 -1
- package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +1 -1
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +146 -2
- package/_shared/pr-loop/scripts/tests/test_copilot_quota.py +8 -8
- package/_shared/pr-loop/scripts/tests/test_reviewer_availability.py +18 -5
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +56 -5
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +104 -17
- package/agents/CLAUDE.md +3 -2
- package/agents/clasp-deployment-orchestrator.md +2 -2
- package/agents/code-advisor.md +7 -5
- package/agents/code-verifier.md +6 -2
- package/agents/session-advisor.md +27 -0
- package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +1 -1
- package/audit-rubrics/category_rubrics/category-k-codebase-conflicts.md +1 -1
- package/audit-rubrics/prompts/category-a-api-contracts.md +2 -2
- package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
- package/audit-rubrics/prompts/category-c-resource-cleanup.md +2 -2
- package/audit-rubrics/prompts/category-d-scoping-and-ordering.md +2 -2
- package/audit-rubrics/prompts/category-e-dead-code.md +2 -2
- package/audit-rubrics/prompts/category-f-silent-failures.md +2 -2
- package/audit-rubrics/prompts/category-g-bounds-and-overflow.md +2 -2
- package/audit-rubrics/prompts/category-h-security-boundaries.md +2 -2
- package/audit-rubrics/prompts/category-i-concurrency.md +2 -2
- package/audit-rubrics/prompts/category-j-code-rules-compliance.md +2 -2
- package/audit-rubrics/prompts/category-k-codebase-conflicts.md +2 -2
- package/audit-rubrics/prompts/category-l-behavior-equivalence.md +2 -2
- package/audit-rubrics/prompts/category-m-producer-consumer-cardinality.md +2 -2
- package/audit-rubrics/prompts/category-n-test-name-scenario-verifier.md +2 -2
- package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +2 -2
- package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +2 -2
- package/bin/CLAUDE.md +1 -0
- package/bin/expand_home_directory_tokens.mjs +75 -0
- package/bin/install.mjs +21 -7
- package/bin/install.test.mjs +220 -10
- package/hooks/CLAUDE.md +1 -1
- package/hooks/blocking/CLAUDE.md +4 -1
- package/hooks/blocking/_path_setup.py +13 -0
- package/hooks/blocking/bash_pre_tool_use_dispatcher.py +281 -0
- package/hooks/blocking/code_rules_dead_module_constant.py +88 -16
- package/hooks/blocking/code_rules_enforcer.py +6 -0
- package/hooks/blocking/code_rules_imports_logging.py +34 -1
- package/hooks/blocking/code_rules_paths_syspath.py +1 -1
- package/hooks/blocking/code_rules_test_layout.py +272 -0
- package/hooks/blocking/nas_ssh_binary_enforcer.py +22 -12
- package/hooks/blocking/pii_prevention_blocker.py +781 -0
- package/hooks/blocking/pii_scanner.py +340 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
- package/hooks/blocking/stop_dispatcher.py +84 -0
- package/hooks/blocking/test__path_setup.py +39 -0
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +61 -0
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +294 -0
- package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +51 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_read_cap.py +103 -0
- package/hooks/blocking/test_code_rules_enforcer_dot_test_pattern.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +11 -11
- package/hooks/blocking/test_code_rules_enforcer_naive_datetime.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +1 -1
- package/hooks/blocking/test_code_rules_enforcer_test_layout.py +111 -0
- package/hooks/blocking/test_code_rules_enforcer_type_checking_scope.py +76 -0
- package/hooks/blocking/test_destructive_command_blocker.py +29 -9
- package/hooks/blocking/test_gh_body_arg_blocker.py +3 -3
- package/hooks/blocking/test_nas_ssh_binary_enforcer.py +96 -39
- package/hooks/blocking/test_pii_prevention_blocker.py +905 -0
- package/hooks/blocking/test_pii_prevention_windows_git_path.py +41 -0
- package/hooks/blocking/test_pii_scanner.py +165 -0
- package/hooks/blocking/test_pr_description_enforcer_pr_number.py +1 -1
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +14 -13
- package/hooks/blocking/test_stop_dispatcher.py +187 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +1 -1
- package/hooks/blocking/test_volatile_path_in_post_blocker.py +3 -3
- package/hooks/blocking/volatile_path_in_post_blocker.py +54 -5
- package/hooks/diagnostic/migrations/README.md +25 -26
- package/hooks/diagnostic/test_hook_log_extractor.py +10 -10
- package/hooks/hooks.json +10 -120
- package/hooks/hooks_constants/CLAUDE.md +12 -3
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +78 -0
- package/hooks/hooks_constants/dead_module_constant_constants.py +1 -0
- package/hooks/hooks_constants/hosted_hook_runner.py +73 -0
- package/hooks/hooks_constants/local_identity.py +182 -0
- package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +4 -37
- package/hooks/hooks_constants/pii_prevention_constants.py +299 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/python_style_checks_constants.py +15 -0
- package/hooks/hooks_constants/stop_dispatcher_constants.py +26 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +87 -0
- package/hooks/hooks_constants/test_hardcoded_user_path_constants.py +2 -2
- package/hooks/hooks_constants/test_hosted_hook_runner.py +101 -0
- package/hooks/hooks_constants/test_layout_constants.py +28 -0
- package/hooks/hooks_constants/test_local_identity.py +88 -0
- package/hooks/hooks_constants/test_session_env_cleanup_constants.py +1 -1
- package/hooks/hooks_constants/test_stop_dispatcher_constants.py +32 -0
- package/hooks/validators/README.md +5 -6
- package/hooks/validators/__init__.py +2 -2
- package/hooks/validators/python_style_checks.py +298 -243
- package/hooks/validators/run_all_validators.py +6 -0
- package/hooks/validators/test_python_style_checks.py +278 -163
- package/package.json +2 -2
- package/rules/CLAUDE.md +7 -1
- package/rules/bdd.md +1 -1
- package/rules/nas-ssh-invocation.md +6 -4
- package/rules/no-justification-noise.md +61 -0
- package/rules/testing.md +0 -2
- package/scripts/test_setup_project_paths.py +1 -1
- package/skills/CLAUDE.md +6 -3
- package/skills/_shared/pr-loop/scripts/_path_resolver.py +2 -2
- package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +1 -1
- package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +1 -1
- package/skills/_shared/pr-loop/scripts/test__path_resolver.py +42 -3
- package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +11 -11
- package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +26 -3
- package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +49 -25
- package/skills/autoconverge/CLAUDE.md +3 -3
- package/skills/autoconverge/SKILL.md +113 -35
- package/skills/autoconverge/reference/CLAUDE.md +2 -2
- package/skills/autoconverge/reference/convergence.md +44 -22
- package/skills/autoconverge/reference/gotchas.md +11 -0
- package/skills/autoconverge/reference/stop-conditions.md +23 -13
- package/skills/autoconverge/workflow/CLAUDE.md +2 -1
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +15 -19
- package/skills/autoconverge/workflow/converge.contract.test.mjs +43 -42
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +141 -16
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +2 -2
- package/skills/autoconverge/workflow/converge.mjs +329 -68
- package/skills/autoconverge/workflow/converge.path-aware.test.mjs +1 -1
- package/skills/autoconverge/workflow/converge.precatch.test.mjs +152 -0
- package/skills/autoconverge/workflow/converge.run-input.test.mjs +3 -3
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +15 -15
- package/skills/autoconverge/workflow/test_convergence_summary.py +6 -6
- package/skills/bdd-protocol/SKILL.md +4 -5
- package/skills/bdd-protocol/references/anti-patterns.md +1 -1
- package/skills/bugteam/SKILL.md +8 -0
- package/skills/bugteam/reference/copilot-gap-analysis.md +20 -20
- package/skills/bugteam/reference/obstacles/audit-walk-categories.md +1 -1
- package/skills/bugteam/reference/team-setup.md +7 -5
- package/skills/bugteam/scripts/reflow_skill_md.py +1 -1
- package/skills/copilot-finding-triage/SKILL.md +124 -0
- package/skills/copilot-finding-triage/reference/tier-rubric.md +49 -0
- package/skills/copilot-finding-triage/scripts/conftest.py +8 -0
- package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/__init__.py +0 -0
- package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/config/__init__.py +0 -0
- package/skills/copilot-finding-triage/scripts/copilot_finding_triage_constants/config/notify_ntfy_constants.py +32 -0
- package/skills/copilot-finding-triage/scripts/notify_ntfy.py +192 -0
- package/skills/copilot-finding-triage/scripts/test_notify_ntfy.py +141 -0
- package/skills/copilot-finding-triage/templates/notification.md +34 -0
- package/skills/copilot-review/SKILL.md +8 -6
- package/skills/findbugs/SKILL.md +4 -0
- package/skills/fixbugs/SKILL.md +8 -7
- package/skills/gotcha/CLAUDE.md +2 -2
- package/skills/gotcha/SKILL.md +4 -4
- package/skills/log-audit/SKILL.md +4 -6
- package/skills/monitor-open-prs/CLAUDE.md +1 -1
- package/skills/monitor-open-prs/SKILL.md +6 -1
- package/skills/orchestrator/SKILL.md +184 -0
- package/skills/orchestrator-refresh/SKILL.md +37 -0
- package/skills/post-audit-findings/SKILL.md +5 -9
- package/skills/pr-consistency-audit/SKILL.md +5 -1
- package/skills/pr-converge/CLAUDE.md +1 -1
- package/skills/pr-converge/SKILL.md +86 -47
- package/skills/pr-converge/reference/CLAUDE.md +1 -1
- package/skills/pr-converge/reference/convergence-gates.md +20 -17
- package/skills/pr-converge/reference/examples.md +63 -47
- package/skills/pr-converge/reference/fix-protocol.md +15 -15
- package/skills/pr-converge/reference/ground-rules.md +11 -7
- package/skills/pr-converge/reference/multi-pr-orchestration.md +6 -5
- package/skills/pr-converge/reference/per-tick.md +129 -107
- package/skills/pr-converge/reference/state-schema.md +15 -10
- package/skills/pr-converge/scripts/check_convergence.py +5 -4
- package/skills/pr-converge/scripts/test_check_convergence.py +6 -4
- package/skills/pr-fix-protocol/SKILL.md +3 -8
- package/skills/pr-loop-cloud-transport/SKILL.md +102 -0
- package/skills/pr-loop-cloud-transport/reference/identity-and-hooks.md +40 -0
- package/skills/pr-loop-cloud-transport/reference/substitution-matrix.md +48 -0
- package/skills/pr-loop-lifecycle/SKILL.md +5 -9
- package/skills/pr-scope-resolve/SKILL.md +3 -6
- package/skills/privacy-hygiene/SKILL.md +114 -0
- package/skills/qbug/SKILL.md +8 -8
- package/skills/rebase/SKILL.md +5 -1
- package/skills/refine/SKILL.md +4 -5
- package/skills/reviewer-gates/SKILL.md +7 -11
- package/skills/session-log/SKILL.md +4 -1
- package/skills/skill-builder/SKILL.md +3 -6
- package/skills/structure-prompt/SKILL.md +4 -5
- package/skills/team-advisor/SKILL.md +32 -164
- package/skills/test_markdown_link_integrity.py +10 -6
- package/skills/update/SKILL.md +5 -1
- package/skills/usage-pause/SKILL.md +14 -5
- package/skills/usage-pause/scripts/resolve_usage_window.py +83 -5
- package/skills/usage-pause/scripts/test_resolve_usage_window.py +185 -17
- package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +4 -3
- package/skills/verified-build/SKILL.md +4 -9
- package/skills/team-advisor-refresh/SKILL.md +0 -25
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse dispatcher that hosts the Bash and PowerShell blocking hook chains.
|
|
3
|
+
|
|
4
|
+
Reads the tool payload from stdin once, selects the hosted hooks applicable to
|
|
5
|
+
the payload's tool name, runs each hook in-process via the shared hosted-hook
|
|
6
|
+
runner, aggregates deny/ask/allow decisions with deny>ask>allow precedence, and
|
|
7
|
+
emits one decision (carrying updatedInput when a rewriter allowed a rewrite, and
|
|
8
|
+
systemMessage / additionalContext / suppressOutput when hosted hooks set them).
|
|
9
|
+
|
|
10
|
+
A deny short-circuits the remaining chain: once any hosted hook denies, the
|
|
11
|
+
dispatcher aggregates what has run so far, emits that deny immediately, and
|
|
12
|
+
returns so a later process timeout cannot drop an already-known denial. Ask and
|
|
13
|
+
allow continue through the full roster because a later hook may still deny.
|
|
14
|
+
|
|
15
|
+
A single hosted hook crash fails open: it contributes no decision and does not
|
|
16
|
+
stop the remaining hooks, matching a standalone hook whose uncaught exception
|
|
17
|
+
exits nonzero without blocking the tool call.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import sys
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
import _path_setup # noqa: F401
|
|
28
|
+
|
|
29
|
+
from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
|
|
30
|
+
ALL_BASH_HOSTED_HOOK_ENTRIES,
|
|
31
|
+
ALLOW_DECISION,
|
|
32
|
+
ASK_DECISION,
|
|
33
|
+
CONTEXT_JOIN_SEPARATOR,
|
|
34
|
+
DENY_DECISION,
|
|
35
|
+
HOOK_EVENT_NAME,
|
|
36
|
+
REASON_JOIN_SEPARATOR,
|
|
37
|
+
BashHostedHookEntry,
|
|
38
|
+
)
|
|
39
|
+
from hooks_constants.hosted_hook_runner import HostedHookRun, run_hook_capturing_output
|
|
40
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
41
|
+
|
|
42
|
+
_PERMISSION_DECISION_KEY = "permissionDecision"
|
|
43
|
+
_PERMISSION_REASON_KEY = "permissionDecisionReason"
|
|
44
|
+
_HOOK_SPECIFIC_OUTPUT_KEY = "hookSpecificOutput"
|
|
45
|
+
_UPDATED_INPUT_KEY = "updatedInput"
|
|
46
|
+
_SYSTEM_MESSAGE_KEY = "systemMessage"
|
|
47
|
+
_ADDITIONAL_CONTEXT_KEY = "additionalContext"
|
|
48
|
+
_SUPPRESS_OUTPUT_KEY = "suppressOutput"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class BashDispatcherDecision:
|
|
53
|
+
"""Aggregated outcome across the Bash/PowerShell hosted-hook chain.
|
|
54
|
+
|
|
55
|
+
Attributes:
|
|
56
|
+
decision: The winning permission outcome (deny, ask, allow), or empty
|
|
57
|
+
when no hosted hook emitted an outcome.
|
|
58
|
+
reasons: Deny or ask reasons collected from deciding hooks, in run order.
|
|
59
|
+
updated_input: The rewritten tool_input from an allowing rewriter, or
|
|
60
|
+
None when no allow carried an updatedInput.
|
|
61
|
+
all_system_messages: Top-level systemMessage texts from hosted hooks.
|
|
62
|
+
all_additional_context: hookSpecificOutput.additionalContext texts.
|
|
63
|
+
should_suppress_output: True when any hosted hook set suppressOutput.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
decision: str
|
|
67
|
+
reasons: list[str] = field(default_factory=list)
|
|
68
|
+
updated_input: dict[str, object] | None = None
|
|
69
|
+
all_system_messages: list[str] = field(default_factory=list)
|
|
70
|
+
all_additional_context: list[str] = field(default_factory=list)
|
|
71
|
+
should_suppress_output: bool = False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class _ParsedHookDecision:
|
|
76
|
+
"""Fields parsed from one hosted hook's stdout."""
|
|
77
|
+
|
|
78
|
+
decision: str
|
|
79
|
+
reason: str
|
|
80
|
+
updated_input: dict[str, object] | None
|
|
81
|
+
system_message: str
|
|
82
|
+
additional_context: str
|
|
83
|
+
should_suppress_output: bool
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def select_applicable_entries(tool_name: str) -> list[BashHostedHookEntry]:
|
|
87
|
+
"""Return the ordered hosted-hook entries that apply to tool_name."""
|
|
88
|
+
return [
|
|
89
|
+
each_entry
|
|
90
|
+
for each_entry in ALL_BASH_HOSTED_HOOK_ENTRIES
|
|
91
|
+
if tool_name in each_entry.applicable_tool_names
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _empty_parsed_hook_decision() -> _ParsedHookDecision:
|
|
96
|
+
"""Return a non-deciding parse result with empty supplementary fields."""
|
|
97
|
+
return _ParsedHookDecision(
|
|
98
|
+
decision="",
|
|
99
|
+
reason="",
|
|
100
|
+
updated_input=None,
|
|
101
|
+
system_message="",
|
|
102
|
+
additional_context="",
|
|
103
|
+
should_suppress_output=False,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _parse_hook_stdout(stdout_text: str) -> _ParsedHookDecision:
|
|
108
|
+
"""Parse one hook's stdout into a decision, reason, rewrite, and context."""
|
|
109
|
+
stripped_text = stdout_text.strip()
|
|
110
|
+
if not stripped_text:
|
|
111
|
+
return _empty_parsed_hook_decision()
|
|
112
|
+
try:
|
|
113
|
+
parsed_output = json.loads(stripped_text)
|
|
114
|
+
except json.JSONDecodeError:
|
|
115
|
+
return _empty_parsed_hook_decision()
|
|
116
|
+
if not isinstance(parsed_output, dict):
|
|
117
|
+
return _empty_parsed_hook_decision()
|
|
118
|
+
hook_specific = parsed_output.get(_HOOK_SPECIFIC_OUTPUT_KEY, {})
|
|
119
|
+
if not isinstance(hook_specific, dict):
|
|
120
|
+
return _empty_parsed_hook_decision()
|
|
121
|
+
raw_decision = hook_specific.get(_PERMISSION_DECISION_KEY, "")
|
|
122
|
+
decision = raw_decision if isinstance(raw_decision, str) else ""
|
|
123
|
+
raw_reason = hook_specific.get(_PERMISSION_REASON_KEY, "")
|
|
124
|
+
reason = raw_reason if isinstance(raw_reason, str) else ""
|
|
125
|
+
raw_updated_input = hook_specific.get(_UPDATED_INPUT_KEY)
|
|
126
|
+
updated_input = raw_updated_input if isinstance(raw_updated_input, dict) else None
|
|
127
|
+
raw_system_message = parsed_output.get(_SYSTEM_MESSAGE_KEY, "")
|
|
128
|
+
system_message = raw_system_message if isinstance(raw_system_message, str) else ""
|
|
129
|
+
raw_additional_context = hook_specific.get(_ADDITIONAL_CONTEXT_KEY, "")
|
|
130
|
+
additional_context = (
|
|
131
|
+
raw_additional_context if isinstance(raw_additional_context, str) else ""
|
|
132
|
+
)
|
|
133
|
+
should_suppress_output = parsed_output.get(_SUPPRESS_OUTPUT_KEY) is True
|
|
134
|
+
return _ParsedHookDecision(
|
|
135
|
+
decision=decision,
|
|
136
|
+
reason=reason,
|
|
137
|
+
updated_input=updated_input,
|
|
138
|
+
system_message=system_message,
|
|
139
|
+
additional_context=additional_context,
|
|
140
|
+
should_suppress_output=should_suppress_output,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def aggregate_bash_hook_results(
|
|
145
|
+
all_runs: list[HostedHookRun],
|
|
146
|
+
) -> BashDispatcherDecision:
|
|
147
|
+
"""Aggregate hosted-hook runs into one deny>ask>allow decision.
|
|
148
|
+
|
|
149
|
+
Crashed hooks fail open: they contribute no decision. Reasons are collected
|
|
150
|
+
from deny and ask outputs. When the winning decision is allow, the first
|
|
151
|
+
non-empty updatedInput among allow results is carried through so rewriters
|
|
152
|
+
keep their contract. systemMessage, additionalContext, and suppressOutput
|
|
153
|
+
from every non-crashed hook are preserved so silent deny shapes (for example
|
|
154
|
+
destructive_command_blocker's gh-redirect deny) match standalone emission.
|
|
155
|
+
"""
|
|
156
|
+
all_parsed: list[_ParsedHookDecision] = []
|
|
157
|
+
for each_run in all_runs:
|
|
158
|
+
if each_run.did_crash:
|
|
159
|
+
continue
|
|
160
|
+
all_parsed.append(_parse_hook_stdout(each_run.captured_stdout))
|
|
161
|
+
|
|
162
|
+
decision_precedence = (DENY_DECISION, ASK_DECISION, ALLOW_DECISION)
|
|
163
|
+
winning_decision = ""
|
|
164
|
+
for each_candidate in decision_precedence:
|
|
165
|
+
if any(each_parsed.decision == each_candidate for each_parsed in all_parsed):
|
|
166
|
+
winning_decision = each_candidate
|
|
167
|
+
break
|
|
168
|
+
|
|
169
|
+
if not winning_decision:
|
|
170
|
+
return BashDispatcherDecision(decision="")
|
|
171
|
+
|
|
172
|
+
all_reasons = [
|
|
173
|
+
each_parsed.reason
|
|
174
|
+
for each_parsed in all_parsed
|
|
175
|
+
if each_parsed.decision in (DENY_DECISION, ASK_DECISION) and each_parsed.reason
|
|
176
|
+
]
|
|
177
|
+
updated_input: dict[str, object] | None = None
|
|
178
|
+
if winning_decision == ALLOW_DECISION:
|
|
179
|
+
for each_parsed in all_parsed:
|
|
180
|
+
if each_parsed.decision == ALLOW_DECISION and each_parsed.updated_input is not None:
|
|
181
|
+
updated_input = each_parsed.updated_input
|
|
182
|
+
break
|
|
183
|
+
|
|
184
|
+
all_system_messages = [
|
|
185
|
+
each_parsed.system_message for each_parsed in all_parsed if each_parsed.system_message
|
|
186
|
+
]
|
|
187
|
+
all_additional_context = [
|
|
188
|
+
each_parsed.additional_context
|
|
189
|
+
for each_parsed in all_parsed
|
|
190
|
+
if each_parsed.additional_context
|
|
191
|
+
]
|
|
192
|
+
should_suppress_output = any(
|
|
193
|
+
each_parsed.should_suppress_output for each_parsed in all_parsed
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
return BashDispatcherDecision(
|
|
197
|
+
decision=winning_decision,
|
|
198
|
+
reasons=all_reasons,
|
|
199
|
+
updated_input=updated_input,
|
|
200
|
+
all_system_messages=all_system_messages,
|
|
201
|
+
all_additional_context=all_additional_context,
|
|
202
|
+
should_suppress_output=should_suppress_output,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _resolve_hook_script_path(relative_path: str) -> str:
|
|
207
|
+
"""Resolve a hooks/-relative path to an absolute script path."""
|
|
208
|
+
hooks_root = Path(__file__).resolve().parent.parent
|
|
209
|
+
return str(hooks_root / relative_path)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _emit_decision(decision: BashDispatcherDecision) -> None:
|
|
213
|
+
"""Write one PreToolUse permission payload to stdout when an outcome exists."""
|
|
214
|
+
if not decision.decision:
|
|
215
|
+
return
|
|
216
|
+
hook_specific: dict[str, object] = {
|
|
217
|
+
"hookEventName": HOOK_EVENT_NAME,
|
|
218
|
+
_PERMISSION_DECISION_KEY: decision.decision,
|
|
219
|
+
}
|
|
220
|
+
if decision.reasons:
|
|
221
|
+
hook_specific[_PERMISSION_REASON_KEY] = REASON_JOIN_SEPARATOR.join(decision.reasons)
|
|
222
|
+
if decision.decision == ALLOW_DECISION and decision.updated_input is not None:
|
|
223
|
+
hook_specific[_UPDATED_INPUT_KEY] = decision.updated_input
|
|
224
|
+
if decision.all_additional_context:
|
|
225
|
+
hook_specific[_ADDITIONAL_CONTEXT_KEY] = CONTEXT_JOIN_SEPARATOR.join(
|
|
226
|
+
decision.all_additional_context
|
|
227
|
+
)
|
|
228
|
+
payload: dict[str, object] = {_HOOK_SPECIFIC_OUTPUT_KEY: hook_specific}
|
|
229
|
+
if decision.all_system_messages:
|
|
230
|
+
payload[_SYSTEM_MESSAGE_KEY] = CONTEXT_JOIN_SEPARATOR.join(decision.all_system_messages)
|
|
231
|
+
if decision.should_suppress_output:
|
|
232
|
+
payload[_SUPPRESS_OUTPUT_KEY] = True
|
|
233
|
+
sys.stdout.write(json.dumps(payload) + "\n")
|
|
234
|
+
sys.stdout.flush()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _run_is_deny(hook_run: HostedHookRun) -> bool:
|
|
238
|
+
"""Return True when a non-crashed run carries a deny decision."""
|
|
239
|
+
if hook_run.did_crash:
|
|
240
|
+
return False
|
|
241
|
+
parsed_decision = _parse_hook_stdout(hook_run.captured_stdout)
|
|
242
|
+
return parsed_decision.decision == DENY_DECISION
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def dispatch(payload_text: str, tool_name: str) -> None:
|
|
246
|
+
"""Run applicable hosted hooks and emit the aggregated decision.
|
|
247
|
+
|
|
248
|
+
Emits and returns as soon as any hosted hook denies so a process timeout
|
|
249
|
+
later in the chain cannot fail-open past an already-known denial.
|
|
250
|
+
"""
|
|
251
|
+
applicable_entries = select_applicable_entries(tool_name)
|
|
252
|
+
all_runs: list[HostedHookRun] = []
|
|
253
|
+
for each_entry in applicable_entries:
|
|
254
|
+
script_path = _resolve_hook_script_path(each_entry.script_relative_path)
|
|
255
|
+
hook_run = run_hook_capturing_output(script_path, payload_text)
|
|
256
|
+
all_runs.append(hook_run)
|
|
257
|
+
if _run_is_deny(hook_run):
|
|
258
|
+
aggregated_decision = aggregate_bash_hook_results(all_runs)
|
|
259
|
+
_emit_decision(aggregated_decision)
|
|
260
|
+
return
|
|
261
|
+
aggregated_decision = aggregate_bash_hook_results(all_runs)
|
|
262
|
+
_emit_decision(aggregated_decision)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def main() -> None:
|
|
266
|
+
"""Read stdin once and dispatch the Bash/PowerShell hosted-hook chain."""
|
|
267
|
+
payload_dictionary = read_hook_input_dictionary_from_stdin()
|
|
268
|
+
if payload_dictionary is None:
|
|
269
|
+
sys.exit(0)
|
|
270
|
+
|
|
271
|
+
payload_text = json.dumps(payload_dictionary)
|
|
272
|
+
tool_name = payload_dictionary.get("tool_name", "")
|
|
273
|
+
if not isinstance(tool_name, str) or not tool_name:
|
|
274
|
+
sys.exit(0)
|
|
275
|
+
|
|
276
|
+
dispatch(payload_text, tool_name)
|
|
277
|
+
sys.exit(0)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
if __name__ == "__main__":
|
|
281
|
+
main()
|
|
@@ -33,12 +33,20 @@ The scan is deliberately conservative to keep false positives near zero:
|
|
|
33
33
|
``from <module> import`` whose final dotted segment equals the written
|
|
34
34
|
module's filename stem, so a genuine cross-tree consumer of this constants
|
|
35
35
|
module keeps the constant live while a same-named constant exported by an
|
|
36
|
-
unrelated module never masks a dead one.
|
|
36
|
+
unrelated module never masks a dead one. The widened pass reads a repository
|
|
37
|
+
file only to test whether its text names the written module's filename stem;
|
|
38
|
+
a file that never mentions the stem cannot carry such an import, so it is
|
|
39
|
+
skipped without spending scan-cap budget, keeping the widened pass bounded to
|
|
40
|
+
the handful of candidate importer files even in a large repository. A module
|
|
41
|
+
outside any repository is
|
|
37
42
|
judged on the package-tree scan alone, and the widened pass skips the package
|
|
38
43
|
subtree the first pass already covered, so no file is read twice.
|
|
39
|
-
-
|
|
40
|
-
|
|
41
|
-
|
|
44
|
+
- Two caps bound the widened pass: a read-attempt cap bounds how many files
|
|
45
|
+
the pass opens and reads at all while testing for the stem, and a separate
|
|
46
|
+
parse cap bounds how many of those files (the ones that do name the stem)
|
|
47
|
+
get parsed and have their names collected. Either cap bounds the pass even
|
|
48
|
+
under an unexpectedly large tree; a write whose scan hits either cap is
|
|
49
|
+
treated as "cannot prove dead" and flags nothing.
|
|
42
50
|
- Test modules under the scanned tree still count as references, so a constant
|
|
43
51
|
used only by a test stays live.
|
|
44
52
|
"""
|
|
@@ -71,6 +79,7 @@ from hooks_constants.dead_module_constant_constants import ( # noqa: E402
|
|
|
71
79
|
GIT_DIRECTORY_NAME,
|
|
72
80
|
MAX_DEAD_MODULE_CONSTANT_ISSUES,
|
|
73
81
|
MAX_SCAN_ROOT_FILE_COUNT,
|
|
82
|
+
MAX_SCAN_ROOT_READ_COUNT,
|
|
74
83
|
MINIMUM_UPPER_SNAKE_LENGTH,
|
|
75
84
|
PYTHON_SOURCE_SUFFIX,
|
|
76
85
|
)
|
|
@@ -328,6 +337,42 @@ def _is_under_directory(candidate_path: Path, ancestor_directory: Path) -> bool:
|
|
|
328
337
|
return True
|
|
329
338
|
|
|
330
339
|
|
|
340
|
+
def _read_candidate_source(file_path: Path, required_substring: str | None) -> str | None:
|
|
341
|
+
"""Return a module's text, or None when it is not a reference-scan candidate.
|
|
342
|
+
|
|
343
|
+
::
|
|
344
|
+
|
|
345
|
+
required_substring = None -> read and keep every file
|
|
346
|
+
required_substring = the module stem -> keep only files naming it
|
|
347
|
+
from pkg.foo_constants import BAR -> names the stem -> candidate
|
|
348
|
+
def unrelated() -> int: ... -> no stem mention -> skipped
|
|
349
|
+
|
|
350
|
+
The widened repository pass looks only for a ``from <module> import`` whose
|
|
351
|
+
final dotted segment equals the constants module's filename stem, and such an
|
|
352
|
+
import always spells that stem in the file's text. A file whose text never
|
|
353
|
+
mentions the stem cannot carry the import, so returning None for it lets the
|
|
354
|
+
caller skip the file without spending scan-cap budget, which keeps the widened
|
|
355
|
+
pass bounded to the candidate importer files even under a large repository.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
file_path: The ``.py`` module to read.
|
|
359
|
+
required_substring: A stem every candidate file's text must contain, or
|
|
360
|
+
None to keep every readable file (the package-tree pass keeps all).
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
The file's text when it is readable and, when ``required_substring`` is
|
|
364
|
+
set, contains that stem; None when the file cannot be read or does not
|
|
365
|
+
name the stem.
|
|
366
|
+
"""
|
|
367
|
+
try:
|
|
368
|
+
source_text = file_path.read_text(encoding="utf-8")
|
|
369
|
+
except (OSError, UnicodeDecodeError):
|
|
370
|
+
return None
|
|
371
|
+
if required_substring is not None and required_substring not in source_text:
|
|
372
|
+
return None
|
|
373
|
+
return source_text
|
|
374
|
+
|
|
375
|
+
|
|
331
376
|
def _collect_names_under_root(
|
|
332
377
|
scan_root: Path,
|
|
333
378
|
written_path: Path,
|
|
@@ -335,17 +380,33 @@ def _collect_names_under_root(
|
|
|
335
380
|
extract_names: Callable[[str], set[str]],
|
|
336
381
|
already_scanned_count: int = 0,
|
|
337
382
|
excluded_subtree: Path | None = None,
|
|
383
|
+
required_substring: str | None = None,
|
|
338
384
|
) -> tuple[set[str], int, bool]:
|
|
339
385
|
"""Collect referenced names under the scan root via a per-module extractor.
|
|
340
386
|
|
|
341
387
|
Walks every ``.py`` module under ``scan_root`` (excluding the written module
|
|
342
388
|
itself, and any module under ``excluded_subtree``), applies ``extract_names``
|
|
343
|
-
to each module's text, and unions the result onto ``all_seed_names``.
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
389
|
+
to each module's text, and unions the result onto ``all_seed_names``. Two
|
|
390
|
+
caps bound the walk so a write under an unexpectedly large tree cannot
|
|
391
|
+
stall the hook:
|
|
392
|
+
|
|
393
|
+
::
|
|
394
|
+
|
|
395
|
+
read_attempt_count -> every file this call opens and reads,
|
|
396
|
+
whether or not it turns out to be a
|
|
397
|
+
candidate -> capped by MAX_SCAN_ROOT_READ_COUNT
|
|
398
|
+
scanned_file_count -> only the files that pass the candidate
|
|
399
|
+
filter and get parsed for names -> capped
|
|
400
|
+
by MAX_SCAN_ROOT_FILE_COUNT
|
|
401
|
+
|
|
402
|
+
Either cap tripping returns ``cap_was_hit=True``, which signals the caller
|
|
403
|
+
to treat the write as "cannot prove dead". The ``excluded_subtree`` skip
|
|
404
|
+
keeps the widened repository scan from re-reading a file the package-tree
|
|
405
|
+
scan already covered. When ``required_substring`` is set, a module whose
|
|
406
|
+
text never contains that stem is skipped after being read but before it is
|
|
407
|
+
counted toward ``scanned_file_count`` or parsed, so the parse-and-collect
|
|
408
|
+
work stays bounded to the candidate importer files even though the read
|
|
409
|
+
cap still bounds the raw disk reads across the whole tree.
|
|
349
410
|
|
|
350
411
|
Args:
|
|
351
412
|
scan_root: The directory tree to scan.
|
|
@@ -357,10 +418,17 @@ def _collect_names_under_root(
|
|
|
357
418
|
extract_names: Maps one module's source text to the set of names it
|
|
358
419
|
contributes — the generous reference collector for the package-tree
|
|
359
420
|
pass, the stem-bound import collector for the widened pass.
|
|
360
|
-
already_scanned_count: The file count accumulated by a prior
|
|
361
|
-
cap bounds the combined work of the
|
|
421
|
+
already_scanned_count: The parsed-file count accumulated by a prior
|
|
422
|
+
pass, so the parse cap bounds the combined work of the
|
|
423
|
+
package-tree and widened passes.
|
|
362
424
|
excluded_subtree: A resolved directory whose ``.py`` modules are skipped,
|
|
363
425
|
or None to scan every file under the root.
|
|
426
|
+
required_substring: A stem a file's text must contain to count as a
|
|
427
|
+
scan candidate, or None to scan every file. The widened pass passes
|
|
428
|
+
the written module's filename stem so a file that never names the
|
|
429
|
+
module is skipped before it is counted or parsed, spending parse
|
|
430
|
+
budget only on the candidate importer files, while the read cap
|
|
431
|
+
still bounds how many files get read looking for that stem.
|
|
364
432
|
|
|
365
433
|
Returns:
|
|
366
434
|
A (collected_names, running_count, cap_was_hit) triple. collected_names
|
|
@@ -372,6 +440,7 @@ def _collect_names_under_root(
|
|
|
372
440
|
collected_names = set(all_seed_names)
|
|
373
441
|
written_path_key = os.path.normcase(str(written_path))
|
|
374
442
|
scanned_file_count = already_scanned_count
|
|
443
|
+
read_attempt_count = 0
|
|
375
444
|
for each_path in scan_root.rglob("*" + PYTHON_SOURCE_SUFFIX):
|
|
376
445
|
if not each_path.is_file():
|
|
377
446
|
continue
|
|
@@ -380,13 +449,15 @@ def _collect_names_under_root(
|
|
|
380
449
|
continue
|
|
381
450
|
if excluded_subtree is not None and _is_under_directory(resolved_path, excluded_subtree):
|
|
382
451
|
continue
|
|
452
|
+
read_attempt_count += 1
|
|
453
|
+
if read_attempt_count > MAX_SCAN_ROOT_READ_COUNT:
|
|
454
|
+
return collected_names, scanned_file_count, True
|
|
455
|
+
sibling_source = _read_candidate_source(each_path, required_substring)
|
|
456
|
+
if sibling_source is None:
|
|
457
|
+
continue
|
|
383
458
|
scanned_file_count += 1
|
|
384
459
|
if scanned_file_count > MAX_SCAN_ROOT_FILE_COUNT:
|
|
385
460
|
return collected_names, scanned_file_count, True
|
|
386
|
-
try:
|
|
387
|
-
sibling_source = each_path.read_text(encoding="utf-8")
|
|
388
|
-
except (OSError, UnicodeDecodeError):
|
|
389
|
-
continue
|
|
390
461
|
collected_names |= extract_names(sibling_source)
|
|
391
462
|
return collected_names, scanned_file_count, False
|
|
392
463
|
|
|
@@ -550,6 +621,7 @@ def check_dead_module_constants(
|
|
|
550
621
|
collect_qualified_imports,
|
|
551
622
|
already_scanned_count=scanned_file_count,
|
|
552
623
|
excluded_subtree=scan_root,
|
|
624
|
+
required_substring=written_path.stem,
|
|
553
625
|
)
|
|
554
626
|
if widened_cap_was_hit:
|
|
555
627
|
return []
|
|
@@ -181,6 +181,10 @@ from code_rules_test_branching_except import ( # noqa: E402
|
|
|
181
181
|
from code_rules_test_isolation import ( # noqa: E402
|
|
182
182
|
check_tests_use_isolated_filesystem_paths,
|
|
183
183
|
)
|
|
184
|
+
from code_rules_test_layout import ( # noqa: E402
|
|
185
|
+
check_dead_test_module_constant,
|
|
186
|
+
check_unused_test_helper_parameter,
|
|
187
|
+
)
|
|
184
188
|
from code_rules_type_escape import ( # noqa: E402
|
|
185
189
|
check_boundary_types,
|
|
186
190
|
check_type_escape_hatches,
|
|
@@ -324,6 +328,8 @@ def validate_content(
|
|
|
324
328
|
all_issues.extend(check_stub_implementations(effective_content, file_path))
|
|
325
329
|
all_issues.extend(check_typed_dict_encode_decode(effective_content, file_path))
|
|
326
330
|
all_issues.extend(check_test_branching_in_production(effective_content, file_path))
|
|
331
|
+
all_issues.extend(check_dead_test_module_constant(effective_content, file_path))
|
|
332
|
+
all_issues.extend(check_unused_test_helper_parameter(effective_content, file_path))
|
|
327
333
|
all_issues.extend(check_bare_except(effective_content, file_path))
|
|
328
334
|
all_issues.extend(check_thin_wrapper_files(effective_content, file_path))
|
|
329
335
|
all_issues.extend(check_zero_payload_function_alias(effective_content, file_path))
|
|
@@ -115,7 +115,16 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
|
115
115
|
|
|
116
116
|
|
|
117
117
|
def check_imports_at_top(content: str) -> list[str]:
|
|
118
|
-
"""Check for imports
|
|
118
|
+
"""Check for imports below the top import block (Python only).
|
|
119
|
+
|
|
120
|
+
Two placements are flagged. First, an import inside a function body. Second,
|
|
121
|
+
a module-scope import (indent zero) that sits below the first top-level
|
|
122
|
+
``def`` or ``class`` definition — the "imports mid-file after functions"
|
|
123
|
+
drift, where the imports never enter a function body yet still break the
|
|
124
|
+
imports-at-top rule. An indented import inside a guarded block below a
|
|
125
|
+
definition (an ``if __name__ == "__main__":`` guard, a ``try`` fallback) is
|
|
126
|
+
left alone because only indent-zero module-scope imports are checked for the
|
|
127
|
+
below-definition case.
|
|
119
128
|
|
|
120
129
|
An import lexically inside an ``if TYPE_CHECKING:`` block is exempt.
|
|
121
130
|
An import inside a function body is flagged even if the file uses TYPE_CHECKING
|
|
@@ -146,7 +155,9 @@ def check_imports_at_top(content: str) -> list[str]:
|
|
|
146
155
|
issues: list[str] = []
|
|
147
156
|
lines = content.split("\n")
|
|
148
157
|
is_inside_function = False
|
|
158
|
+
has_seen_top_level_definition = False
|
|
149
159
|
function_indent = 0
|
|
160
|
+
module_scope_indent = 0
|
|
150
161
|
type_checking_block_indent = NOT_INSIDE_TYPE_CHECKING_BLOCK
|
|
151
162
|
active_triple_quote_delimiter: str | None = None
|
|
152
163
|
|
|
@@ -180,6 +191,16 @@ def check_imports_at_top(content: str) -> list[str]:
|
|
|
180
191
|
if function_match:
|
|
181
192
|
is_inside_function = True
|
|
182
193
|
function_indent = len(function_match.group(1)) if function_match.group(1) else 0
|
|
194
|
+
if function_indent == module_scope_indent:
|
|
195
|
+
has_seen_top_level_definition = True
|
|
196
|
+
active_triple_quote_delimiter = _update_triple_quote_state_for_line(
|
|
197
|
+
each_line, active_triple_quote_delimiter
|
|
198
|
+
)
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
class_match = re.match(r"^(\s*)class\s+\w+", each_line)
|
|
202
|
+
if class_match and len(class_match.group(1)) == module_scope_indent:
|
|
203
|
+
has_seen_top_level_definition = True
|
|
183
204
|
active_triple_quote_delimiter = _update_triple_quote_state_for_line(
|
|
184
205
|
each_line, active_triple_quote_delimiter
|
|
185
206
|
)
|
|
@@ -194,6 +215,18 @@ def check_imports_at_top(content: str) -> list[str]:
|
|
|
194
215
|
if stripped.startswith(ALL_IMPORT_STATEMENT_PREFIXES):
|
|
195
216
|
issues.append(f"Line {each_line_number}: Import inside function - move to top of file")
|
|
196
217
|
|
|
218
|
+
is_module_scope_import = (
|
|
219
|
+
current_indent == module_scope_indent
|
|
220
|
+
and not is_inside_function
|
|
221
|
+
and not is_inside_type_checking_block
|
|
222
|
+
and stripped.startswith(ALL_IMPORT_STATEMENT_PREFIXES)
|
|
223
|
+
)
|
|
224
|
+
if is_module_scope_import and has_seen_top_level_definition:
|
|
225
|
+
issues.append(
|
|
226
|
+
f"Line {each_line_number}: Import after top-level definition - "
|
|
227
|
+
"move imports to top of file"
|
|
228
|
+
)
|
|
229
|
+
|
|
197
230
|
active_triple_quote_delimiter = _update_triple_quote_state_for_line(
|
|
198
231
|
each_line, active_triple_quote_delimiter
|
|
199
232
|
)
|
|
@@ -40,7 +40,7 @@ from hooks_constants.sys_path_insert_constants import ( # noqa: E402
|
|
|
40
40
|
def check_hardcoded_user_paths(content: str, file_path: str) -> list[str]:
|
|
41
41
|
"""Flag string literals naming a specific user's home directory.
|
|
42
42
|
|
|
43
|
-
Catches non-portable paths like `C:/Users/
|
|
43
|
+
Catches non-portable paths like `C:/Users/example/...`, `/Users/alice/...`,
|
|
44
44
|
and `/home/bob/...` that surface in production code.
|
|
45
45
|
Test files, config/ files, workflow registry files, migration files,
|
|
46
46
|
and hook infrastructure files are exempt. Hook infrastructure exemption
|