claude-dev-env 2.0.1 → 2.1.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 +1 -1
- package/agents/clean-coder.md +31 -1
- package/audit-rubrics/prompts/category-e-dead-code.md +2 -2
- package/bin/install.test.mjs +2 -2
- package/docs/CLAUDE.md +6 -3
- package/docs/CODE_RULES.md +5 -1
- package/docs/agent-spawn-protocol.md +39 -0
- package/docs/nas-ssh-invocation.md +23 -0
- package/docs/worker-completion-gate.md +33 -0
- package/hooks/blocking/CLAUDE.md +1 -0
- package/hooks/blocking/code_rules_dead_module_constant.py +11 -5
- package/hooks/blocking/code_rules_shared.py +126 -47
- package/hooks/blocking/config/CLAUDE.md +2 -0
- package/hooks/blocking/config/verified_commit_constants.py +8 -1
- package/hooks/blocking/config/verified_commit_context_constants.py +21 -0
- package/hooks/blocking/config/verified_commit_gate_output_constants.py +14 -0
- package/hooks/blocking/conftest.py +36 -30
- package/hooks/blocking/convergence_gate_blocker.py +76 -8
- package/hooks/blocking/plain_language_blocker.py +8 -0
- package/hooks/blocking/state_description_blocker.py +4 -1
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +58 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_alias.py +133 -0
- package/hooks/blocking/test_code_rules_shared.py +120 -20
- package/hooks/blocking/test_convergence_gate_blocker.py +146 -0
- package/hooks/blocking/test_plain_language_blocker.py +15 -0
- package/hooks/blocking/test_state_description_blocker.py +15 -0
- package/hooks/blocking/test_verification_verdict_store.py +12 -0
- package/hooks/blocking/test_verified_commit_config_bootstrap.py +18 -0
- package/hooks/blocking/test_verified_commit_docs_delta.py +176 -0
- package/hooks/blocking/test_verified_commit_gate_additional_context.py +134 -0
- package/hooks/blocking/tests/test_pii_prevention_blocker.py +0 -1
- package/hooks/blocking/tests/test_verified_commit_gate.py +41 -0
- package/hooks/blocking/verified_commit_config_bootstrap.py +22 -10
- package/hooks/blocking/verified_commit_gate.py +113 -568
- package/hooks/blocking/verified_commit_gate_parts/CLAUDE.md +28 -0
- package/hooks/blocking/verified_commit_gate_parts/__init__.py +1 -0
- package/hooks/blocking/verified_commit_gate_parts/command_tokenization.py +174 -0
- package/hooks/blocking/verified_commit_gate_parts/deny_payload.py +53 -0
- package/hooks/blocking/verified_commit_gate_parts/deny_reason.py +80 -0
- package/hooks/blocking/verified_commit_gate_parts/directory_resolution.py +170 -0
- package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +205 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/conftest.py +10 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_command_tokenization.py +94 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_payload.py +17 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_reason.py +38 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_directory_resolution.py +71 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +61 -0
- package/hooks/hooks_constants/CLAUDE.md +4 -1
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
- package/hooks/hooks_constants/convergence_gate_blocker_constants.py +36 -0
- package/hooks/hooks_constants/dead_module_constant_constants.py +8 -0
- package/hooks/hooks_constants/harness_scratchpad_constants.py +10 -9
- package/hooks/hooks_constants/mypy_integration_constants.py +16 -0
- package/hooks/hooks_constants/pii_prevention_constants.py +1 -0
- package/hooks/validators/mypy_integration.py +145 -24
- package/hooks/validators/python_antipattern_checks.py +16 -0
- package/hooks/validators/run_all_validators.py +9 -3
- package/hooks/validators/test_mypy_integration.py +154 -0
- package/hooks/validators/test_python_antipattern_checks.py +112 -1
- package/hooks/validators/test_run_all_validators_pretooluse.py +16 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +3 -3
- package/rules/agent-spawn-protocol.md +5 -43
- package/rules/code-standards.md +1 -36
- package/rules/env-var-table-code-drift.md +2 -21
- package/rules/hook-prose-matches-detector.md +5 -16
- package/rules/nas-ssh-invocation.md +3 -15
- package/rules/no-historical-clutter.md +7 -49
- package/rules/no-inline-destructive-literals.md +3 -5
- package/rules/package-inventory-stale-entry.md +7 -32
- package/rules/re-stage-before-commit.md +6 -23
- package/rules/shell-invocation-policy.md +1 -1
- package/rules/vault-context.md +3 -3
- package/rules/verified-commit-gate-skip.md +28 -0
- package/rules/workers-done-before-complete.md +2 -30
- package/scripts/claude_chain_runner.py +39 -3
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +3 -0
- package/scripts/test_claude_chain_runner.py +112 -0
- package/scripts/test_grok_headless_runner.py +0 -1
- package/skills/auditing-claude-config/CLAUDE.md +2 -1
- package/skills/auditing-claude-config/SKILL.md +114 -176
- package/skills/auditing-claude-config/reference/probe-hook.md +74 -0
- package/skills/autoconverge/CLAUDE.md +1 -0
- package/skills/autoconverge/SKILL.md +310 -346
- package/skills/autoconverge/reference/CLAUDE.md +1 -0
- package/skills/autoconverge/reference/copilot-findings.md +51 -0
- package/skills/closeout/SKILL.md +7 -9
- package/skills/copilot-finding-triage/SKILL.md +5 -7
- package/skills/copilot-review/CLAUDE.md +3 -2
- package/skills/copilot-review/SKILL.md +119 -155
- package/skills/copilot-review/templates/subagent-prompt.md +49 -0
- package/skills/everything-search/SKILL.md +7 -1
- package/skills/fresh-branch/CLAUDE.md +6 -9
- package/skills/fresh-branch/SKILL.md +84 -33
- package/skills/fresh-branch/scripts/create_fresh_branch.py +445 -0
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/__init__.py +1 -0
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +74 -0
- package/skills/fresh-branch/scripts/test_create_fresh_branch.py +670 -0
- package/skills/orchestrator/SKILL.md +161 -147
- package/skills/pr-converge/SKILL.md +277 -422
- package/skills/pr-converge/reference/CLAUDE.md +1 -0
- package/skills/pr-converge/reference/progress-checklist.md +168 -0
- package/skills/pr-converge/test_step5_host_branch.py +8 -6
- package/skills/pr-loop-cloud-transport/SKILL.md +1 -1
- package/skills/privacy-hygiene/SKILL.md +68 -115
- package/skills/privacy-hygiene/reference/sweep-procedure.md +62 -0
- package/skills/session-log/CLAUDE.md +2 -1
- package/skills/session-log/SKILL.md +4 -26
- package/skills/session-log/templates/frontmatter.md +40 -0
- package/skills/skill-builder/CLAUDE.md +8 -7
- package/skills/skill-builder/SKILL.md +26 -11
- package/skills/skill-builder/references/CLAUDE.md +3 -1
- package/skills/skill-builder/references/delegation-map.md +21 -12
- package/skills/skill-builder/references/description-field.md +9 -11
- package/skills/skill-builder/references/deterministic-elements.md +218 -0
- package/skills/skill-builder/references/self-audit-checklist.md +62 -45
- package/skills/skill-builder/references/skill-modularity.md +8 -9
- package/skills/skill-builder/templates/CLAUDE.md +2 -2
- package/skills/skill-builder/templates/gap-analysis.md +15 -0
- package/skills/skill-builder/workflows/CLAUDE.md +5 -5
- package/skills/skill-builder/workflows/improve-skill.md +18 -9
- package/skills/skill-builder/workflows/new-skill.md +23 -15
- package/skills/skill-builder/workflows/polish-skill.md +28 -21
- package/system-prompts/CLAUDE.md +3 -3
- package/docs/agents-md-alignment-plan.md +0 -123
- package/docs/emotion-informed-prompt-design.md +0 -362
- package/rules/es-exe-file-search.md +0 -17
|
@@ -33,6 +33,27 @@ _DECOY_CONFIG_CONSTANTS_SOURCE = (
|
|
|
33
33
|
)
|
|
34
34
|
|
|
35
35
|
|
|
36
|
+
def _copy_mirrored_packages(mirrored_hooks_directory: Path, mirrored_blocking_directory: Path) -> None:
|
|
37
|
+
"""Copy the ``config``, gate-parts, and ``hooks_constants`` packages into the mirror."""
|
|
38
|
+
shutil.copytree(_BLOCKING_SOURCE_DIRECTORY / "config", mirrored_blocking_directory / "config")
|
|
39
|
+
shutil.copytree(
|
|
40
|
+
_BLOCKING_SOURCE_DIRECTORY / "verified_commit_gate_parts",
|
|
41
|
+
mirrored_blocking_directory / "verified_commit_gate_parts",
|
|
42
|
+
ignore=shutil.ignore_patterns("tests", "__pycache__"),
|
|
43
|
+
)
|
|
44
|
+
shutil.copytree(_HOOKS_CONSTANTS_SOURCE_DIRECTORY, mirrored_hooks_directory / "hooks_constants")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _write_decoy_config_package(mirrored_hooks_directory: Path) -> None:
|
|
48
|
+
"""Write the decoy ``config`` package that must never win resolution."""
|
|
49
|
+
decoy_config_directory = mirrored_hooks_directory / "config"
|
|
50
|
+
decoy_config_directory.mkdir()
|
|
51
|
+
(decoy_config_directory / "__init__.py").write_text(_DECOY_CONFIG_INIT_SOURCE, encoding="utf-8")
|
|
52
|
+
(decoy_config_directory / "verified_commit_constants.py").write_text(
|
|
53
|
+
_DECOY_CONFIG_CONSTANTS_SOURCE, encoding="utf-8"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
36
57
|
def _build_shadowed_hook_tree(tmp_path: Path) -> tuple[Path, Path]:
|
|
37
58
|
"""Mirror the hook tree with a decoy ``config`` package beside ``blocking``.
|
|
38
59
|
|
|
@@ -51,22 +72,20 @@ def _build_shadowed_hook_tree(tmp_path: Path) -> tuple[Path, Path]:
|
|
|
51
72
|
if source_file.exists():
|
|
52
73
|
shutil.copy2(source_file, mirrored_blocking_directory / each_file_name)
|
|
53
74
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
)
|
|
58
|
-
shutil.copytree(
|
|
59
|
-
_HOOKS_CONSTANTS_SOURCE_DIRECTORY,
|
|
60
|
-
mirrored_hooks_directory / "hooks_constants",
|
|
61
|
-
)
|
|
75
|
+
_copy_mirrored_packages(mirrored_hooks_directory, mirrored_blocking_directory)
|
|
76
|
+
_write_decoy_config_package(mirrored_hooks_directory)
|
|
77
|
+
return mirrored_hooks_directory, mirrored_blocking_directory
|
|
62
78
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
79
|
+
|
|
80
|
+
def _probe_command(mirrored_hooks_directory: Path, mirrored_blocking_directory: Path) -> str:
|
|
81
|
+
"""Build the ``sys.path`` preamble that shadows ``blocking`` with the decoy config."""
|
|
82
|
+
return textwrap.dedent(
|
|
83
|
+
f"""\
|
|
84
|
+
import sys
|
|
85
|
+
sys.path.insert(0, {str(mirrored_blocking_directory)!r})
|
|
86
|
+
sys.path.insert(0, {str(mirrored_hooks_directory)!r})
|
|
87
|
+
"""
|
|
68
88
|
)
|
|
69
|
-
return mirrored_hooks_directory, mirrored_blocking_directory
|
|
70
89
|
|
|
71
90
|
|
|
72
91
|
@pytest.fixture
|
|
@@ -75,15 +94,8 @@ def run_under_config_shadow(
|
|
|
75
94
|
) -> Callable[[str], subprocess.CompletedProcess[str]]:
|
|
76
95
|
"""Return a runner that executes a driver under a foreign-``config`` sys.path.
|
|
77
96
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
completed = run_under_config_shadow("import verified_commit_gate")
|
|
81
|
-
ok: completed.returncode == 0 once the bootstrap seeds the real module
|
|
82
|
-
flag: nonzero with ModuleNotFoundError while the decoy config wins
|
|
83
|
-
|
|
84
|
-
The runner prefixes the driver with a ``sys.path`` preamble that puts the
|
|
85
|
-
mirrored ``hooks`` directory (holding the decoy ``config``) ahead of
|
|
86
|
-
``blocking``, then runs the probe with the active interpreter.
|
|
97
|
+
``run_under_config_shadow("import verified_commit_gate")`` returns 0 once
|
|
98
|
+
the bootstrap seeds the real module, nonzero while the decoy config wins.
|
|
87
99
|
|
|
88
100
|
Args:
|
|
89
101
|
tmp_path: Pytest's per-test temporary directory, fixture-injected.
|
|
@@ -93,16 +105,10 @@ def run_under_config_shadow(
|
|
|
93
105
|
subprocess result.
|
|
94
106
|
"""
|
|
95
107
|
mirrored_hooks_directory, mirrored_blocking_directory = _build_shadowed_hook_tree(tmp_path)
|
|
108
|
+
sys_path_preamble = _probe_command(mirrored_hooks_directory, mirrored_blocking_directory)
|
|
96
109
|
|
|
97
110
|
def run_driver(driver_body: str) -> subprocess.CompletedProcess[str]:
|
|
98
111
|
probe_script = tmp_path / "probe.py"
|
|
99
|
-
sys_path_preamble = textwrap.dedent(
|
|
100
|
-
f"""\
|
|
101
|
-
import sys
|
|
102
|
-
sys.path.insert(0, {str(mirrored_blocking_directory)!r})
|
|
103
|
-
sys.path.insert(0, {str(mirrored_hooks_directory)!r})
|
|
104
|
-
"""
|
|
105
|
-
)
|
|
106
112
|
probe_script.write_text(sys_path_preamble + textwrap.dedent(driver_body), encoding="utf-8")
|
|
107
113
|
return subprocess.run(
|
|
108
114
|
[sys.executable, str(probe_script)],
|
|
@@ -18,9 +18,81 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
|
18
18
|
if _hooks_dir not in sys.path:
|
|
19
19
|
sys.path.insert(0, _hooks_dir)
|
|
20
20
|
|
|
21
|
+
from hooks_constants.convergence_gate_blocker_constants import ( # noqa: E402
|
|
22
|
+
COMMAND_SEPARATOR_PATTERN,
|
|
23
|
+
GH_PR_READY_ANCHOR_PATTERN,
|
|
24
|
+
PR_URL_OWNER_REPO_NUMBER_PATTERN,
|
|
25
|
+
REPO_OVERRIDE_FLAG_PATTERN,
|
|
26
|
+
)
|
|
21
27
|
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
22
28
|
|
|
23
29
|
|
|
30
|
+
def _ready_command_segment(command: str) -> str:
|
|
31
|
+
"""Return the ``gh pr ready`` invocation, clipped at the next command separator.
|
|
32
|
+
|
|
33
|
+
::
|
|
34
|
+
|
|
35
|
+
gh pr ready 161 && gh pr comment 999 --repo other-owner/other-repo
|
|
36
|
+
^^^^^^^^^^^^^^^^ clipped -> returned segment
|
|
37
|
+
|
|
38
|
+
Scanning only this segment keeps a ``--repo`` flag or PR URL that belongs to
|
|
39
|
+
a chained command from binding the gate to the wrong PR.
|
|
40
|
+
"""
|
|
41
|
+
ready_match = re.search(GH_PR_READY_ANCHOR_PATTERN, command)
|
|
42
|
+
if ready_match is None:
|
|
43
|
+
return command
|
|
44
|
+
ready_tail = command[ready_match.start() :]
|
|
45
|
+
separator_match = re.search(COMMAND_SEPARATOR_PATTERN, ready_tail)
|
|
46
|
+
if separator_match is None:
|
|
47
|
+
return ready_tail
|
|
48
|
+
return ready_tail[: separator_match.start()]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse_pr_url(command: str) -> tuple[str, str, int] | None:
|
|
52
|
+
"""Return (owner, repo, pr_number) from a full PR URL in the command."""
|
|
53
|
+
url_match = re.search(PR_URL_OWNER_REPO_NUMBER_PATTERN, command)
|
|
54
|
+
if url_match is None:
|
|
55
|
+
return None
|
|
56
|
+
return url_match.group("owner"), url_match.group("repo"), int(url_match.group("number"))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _parse_repo_flag(command: str) -> tuple[str, str] | None:
|
|
60
|
+
"""Return (owner, repo) from a --repo/-R flag in the command."""
|
|
61
|
+
flag_match = re.search(REPO_OVERRIDE_FLAG_PATTERN, command)
|
|
62
|
+
if flag_match is None:
|
|
63
|
+
return None
|
|
64
|
+
return flag_match.group("owner"), flag_match.group("repo")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _resolve_target_identity(command: str, cwd: str | None) -> tuple[str, str, int] | None:
|
|
68
|
+
"""Resolve the (owner, repo, pr_number) the gate keys its evidence to.
|
|
69
|
+
|
|
70
|
+
A full PR URL in the command yields all three. A --repo/-R flag yields
|
|
71
|
+
the repository while the PR number resolves from the command number or
|
|
72
|
+
the cwd. With neither present, both the repository and the number
|
|
73
|
+
resolve from the cwd.
|
|
74
|
+
"""
|
|
75
|
+
ready_segment = _ready_command_segment(command)
|
|
76
|
+
pr_url_identity = _parse_pr_url(ready_segment)
|
|
77
|
+
if pr_url_identity is not None:
|
|
78
|
+
return pr_url_identity
|
|
79
|
+
|
|
80
|
+
pr_number = _resolve_pr_number(ready_segment, cwd)
|
|
81
|
+
if pr_number is None:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
repo_flag_identity = _parse_repo_flag(ready_segment)
|
|
85
|
+
if repo_flag_identity is not None:
|
|
86
|
+
flag_owner, flag_repo = repo_flag_identity
|
|
87
|
+
return flag_owner, flag_repo, pr_number
|
|
88
|
+
|
|
89
|
+
cwd_identity = _resolve_owner_repo(cwd)
|
|
90
|
+
if cwd_identity is None:
|
|
91
|
+
return None
|
|
92
|
+
cwd_owner, cwd_repo = cwd_identity
|
|
93
|
+
return cwd_owner, cwd_repo, pr_number
|
|
94
|
+
|
|
95
|
+
|
|
24
96
|
def _resolve_pr_number(command: str, cwd: str | None) -> int | None:
|
|
25
97
|
direct_match = re.search(r"\bgh\s+pr\s+ready\s+(\d+)", command)
|
|
26
98
|
if direct_match:
|
|
@@ -107,19 +179,15 @@ def main() -> None:
|
|
|
107
179
|
sys.exit(0)
|
|
108
180
|
|
|
109
181
|
command = hook_input.get("tool_input", {}).get("command", "")
|
|
110
|
-
gh_pr_ready_pattern = re.compile(
|
|
182
|
+
gh_pr_ready_pattern = re.compile(GH_PR_READY_ANCHOR_PATTERN)
|
|
111
183
|
if not gh_pr_ready_pattern.search(command):
|
|
112
184
|
sys.exit(0)
|
|
113
185
|
|
|
114
186
|
cwd = hook_input.get("cwd")
|
|
115
|
-
|
|
116
|
-
if
|
|
117
|
-
sys.exit(0)
|
|
118
|
-
|
|
119
|
-
owner_repo = _resolve_owner_repo(cwd)
|
|
120
|
-
if owner_repo is None:
|
|
187
|
+
target_identity = _resolve_target_identity(command, cwd)
|
|
188
|
+
if target_identity is None:
|
|
121
189
|
sys.exit(0)
|
|
122
|
-
owner, repo =
|
|
190
|
+
owner, repo, pr_number = target_identity
|
|
123
191
|
|
|
124
192
|
completed_process = _run_convergence_check(
|
|
125
193
|
check_convergence_script, owner, repo, pr_number, cwd
|
|
@@ -19,6 +19,7 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
|
19
19
|
if _hooks_dir not in sys.path:
|
|
20
20
|
sys.path.insert(0, _hooks_dir)
|
|
21
21
|
|
|
22
|
+
from blocking.code_rules_shared import is_ephemeral_path # noqa: E402
|
|
22
23
|
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
23
24
|
from hooks_constants.plain_language_blocker_constants import ( # noqa: E402
|
|
24
25
|
ALL_SOFTWARE_TERMS,
|
|
@@ -324,6 +325,13 @@ def evaluate(payload_by_key: dict[str, object]) -> str | None:
|
|
|
324
325
|
if not isinstance(raw_tool_name, str) or not isinstance(raw_tool_input, dict):
|
|
325
326
|
return None
|
|
326
327
|
|
|
328
|
+
if raw_tool_name in ALL_WRITE_EDIT_TOOL_NAMES:
|
|
329
|
+
target_file_path = raw_tool_input.get("file_path", "")
|
|
330
|
+
if isinstance(target_file_path, str) and is_ephemeral_path(
|
|
331
|
+
target_file_path, payload_by_key
|
|
332
|
+
):
|
|
333
|
+
return None
|
|
334
|
+
|
|
327
335
|
prose_text = _collect_prose_for_tool(raw_tool_name, raw_tool_input)
|
|
328
336
|
if not prose_text:
|
|
329
337
|
return None
|
|
@@ -19,8 +19,9 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
|
19
19
|
if _hooks_dir not in sys.path:
|
|
20
20
|
sys.path.insert(0, _hooks_dir)
|
|
21
21
|
|
|
22
|
-
from
|
|
22
|
+
from blocking.code_rules_shared import is_ephemeral_path # noqa: E402
|
|
23
23
|
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
24
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin # noqa: E402
|
|
24
25
|
from hooks_constants.state_description_blocker_constants import ( # noqa: E402
|
|
25
26
|
ALL_BLOCK_COMMENT_EXTENSIONS,
|
|
26
27
|
ALL_BLOCK_COMMENT_ONLY_EXTENSIONS,
|
|
@@ -297,6 +298,8 @@ def evaluate(payload_by_key: dict[str, object]) -> str | None:
|
|
|
297
298
|
file_path = tool_input.get("file_path", "")
|
|
298
299
|
if not isinstance(file_path, str) or not file_path:
|
|
299
300
|
return None
|
|
301
|
+
if is_ephemeral_path(file_path, payload_by_key):
|
|
302
|
+
return None
|
|
300
303
|
if not (is_markdown_file(file_path) or is_comment_bearing_file(file_path)):
|
|
301
304
|
return None
|
|
302
305
|
|
|
@@ -448,3 +448,61 @@ def test_widened_scan_reads_each_file_at_most_once(
|
|
|
448
448
|
assert not over_read_paths, (
|
|
449
449
|
f"Widening must read each .py file at most once, got over-reads: {over_read_paths}"
|
|
450
450
|
)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _build_settings_package(workflow_directory: Path, constants_body: str) -> Path:
|
|
454
|
+
constants_package = workflow_directory / "app_settings"
|
|
455
|
+
constants_package.mkdir(parents=True)
|
|
456
|
+
(constants_package / "__init__.py").write_text("", encoding="utf-8")
|
|
457
|
+
constants_path = constants_package / "app_settings_constants.py"
|
|
458
|
+
constants_path.write_text(constants_body, encoding="utf-8")
|
|
459
|
+
return constants_path
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def test_deny_once_records_intent_then_retry_passes_after_consumer(
|
|
463
|
+
neutral_root: Path,
|
|
464
|
+
) -> None:
|
|
465
|
+
constants_body = 'PATH_ENV_NAME = "THEME_PATH"\n'
|
|
466
|
+
workflow_directory = neutral_root / "workflow"
|
|
467
|
+
constants_path = _build_settings_package(workflow_directory, constants_body)
|
|
468
|
+
first_issues = _check(constants_body, str(constants_path))
|
|
469
|
+
assert any("PATH_ENV_NAME" in each_issue for each_issue in first_issues), first_issues
|
|
470
|
+
assert any(
|
|
471
|
+
"app_settings_constants.py" in each_issue for each_issue in first_issues
|
|
472
|
+
), f"Deny message must record the target constants module, got: {first_issues}"
|
|
473
|
+
assert any(
|
|
474
|
+
"re-issue this write" in each_issue for each_issue in first_issues
|
|
475
|
+
), f"Deny message must instruct the retry escape, got: {first_issues}"
|
|
476
|
+
consumer_body = (
|
|
477
|
+
"from app_settings.app_settings_constants import PATH_ENV_NAME\n"
|
|
478
|
+
"\n"
|
|
479
|
+
"def resolve() -> str:\n"
|
|
480
|
+
" return PATH_ENV_NAME\n"
|
|
481
|
+
)
|
|
482
|
+
(workflow_directory / "consumer_service.py").write_text(
|
|
483
|
+
consumer_body, encoding="utf-8"
|
|
484
|
+
)
|
|
485
|
+
retry_issues = _check(constants_body, str(constants_path))
|
|
486
|
+
assert retry_issues == [], (
|
|
487
|
+
f"Once the consumer is on disk the retry passes, got: {retry_issues}"
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def test_dead_constant_stays_denied_on_every_attempt(neutral_root: Path) -> None:
|
|
492
|
+
constants_body = 'PATH_ENV_NAME = "THEME_PATH"\n'
|
|
493
|
+
workflow_directory = neutral_root / "workflow"
|
|
494
|
+
constants_path = _build_settings_package(workflow_directory, constants_body)
|
|
495
|
+
(workflow_directory / "unrelated.py").write_text(
|
|
496
|
+
"def noop() -> int:\n return 1\n", encoding="utf-8"
|
|
497
|
+
)
|
|
498
|
+
first_issues = _check(constants_body, str(constants_path))
|
|
499
|
+
second_issues = _check(constants_body, str(constants_path))
|
|
500
|
+
for each_attempt in (first_issues, second_issues):
|
|
501
|
+
assert any("PATH_ENV_NAME" in each_issue for each_issue in each_attempt), each_attempt
|
|
502
|
+
assert any(
|
|
503
|
+
"app_settings_constants.py" in each_issue for each_issue in each_attempt
|
|
504
|
+
), f"Every deny attempt records the target module, got: {each_attempt}"
|
|
505
|
+
assert any(
|
|
506
|
+
"re-issue this write" in each_issue for each_issue in each_attempt
|
|
507
|
+
), f"A dead constant stays denied with the retry escape, got: {each_attempt}"
|
|
508
|
+
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import stat
|
|
7
|
+
import tempfile
|
|
8
|
+
from collections.abc import Callable, Iterator
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
ENFORCER_PATH = Path(__file__).resolve().parent / "code_rules_enforcer.py"
|
|
14
|
+
specification = importlib.util.spec_from_file_location("code_rules_enforcer", ENFORCER_PATH)
|
|
15
|
+
assert specification is not None and specification.loader is not None
|
|
16
|
+
code_rules_enforcer = importlib.util.module_from_spec(specification)
|
|
17
|
+
specification.loader.exec_module(code_rules_enforcer)
|
|
18
|
+
|
|
19
|
+
CONSTANTS_BODY = 'MEDIUM_TERMINAL = "terminal"\nMEDIUM_CODE = "code"\nMEDIUM_TEXT = "text"\n'
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _strip_read_only_and_retry(
|
|
23
|
+
removal_function: Callable[[str], object],
|
|
24
|
+
target_path: str,
|
|
25
|
+
_exc_info: BaseException,
|
|
26
|
+
) -> None:
|
|
27
|
+
try:
|
|
28
|
+
os.chmod(target_path, stat.S_IWRITE)
|
|
29
|
+
removal_function(target_path)
|
|
30
|
+
except OSError:
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.fixture
|
|
35
|
+
def neutral_root() -> Iterator[Path]:
|
|
36
|
+
"""Yield a temp directory whose path carries no ``test_`` segment.
|
|
37
|
+
|
|
38
|
+
::
|
|
39
|
+
|
|
40
|
+
tmp_path (pytest default) -> embeds "test_..." in its name
|
|
41
|
+
-> is_test_file() misreads the path
|
|
42
|
+
mkdtemp(prefix="deadconst-") -> no "test_" segment -> reads as
|
|
43
|
+
a production path ok
|
|
44
|
+
|
|
45
|
+
A ``.git`` marker is planted at the root so the cross-tree widening
|
|
46
|
+
resolves the repository root to this synthetic tree.
|
|
47
|
+
"""
|
|
48
|
+
neutral_directory = Path(tempfile.mkdtemp(prefix="deadconst-")).resolve()
|
|
49
|
+
(neutral_directory / ".git").mkdir()
|
|
50
|
+
try:
|
|
51
|
+
yield neutral_directory
|
|
52
|
+
finally:
|
|
53
|
+
shutil.rmtree(neutral_directory, onexc=_strip_read_only_and_retry)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _check(source: str, file_path: str) -> list[str]:
|
|
57
|
+
return code_rules_enforcer.check_dead_module_constants(source, file_path)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _build_constants_package(
|
|
61
|
+
workflow_directory: Path,
|
|
62
|
+
constants_body: str,
|
|
63
|
+
consumer_body: str,
|
|
64
|
+
) -> Path:
|
|
65
|
+
constants_package = workflow_directory / "report_constants"
|
|
66
|
+
constants_package.mkdir(parents=True)
|
|
67
|
+
(constants_package / "__init__.py").write_text("", encoding="utf-8")
|
|
68
|
+
constants_path = constants_package / "render_report_constants.py"
|
|
69
|
+
constants_path.write_text(constants_body, encoding="utf-8")
|
|
70
|
+
(workflow_directory / "render_report.py").write_text(consumer_body, encoding="utf-8")
|
|
71
|
+
return constants_path
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _assert_alias_read_constants_live(issues: list[str]) -> None:
|
|
75
|
+
assert not any(
|
|
76
|
+
"MEDIUM_TERMINAL" in each_issue or "MEDIUM_CODE" in each_issue for each_issue in issues
|
|
77
|
+
), f"A constant read as attribute of an imported module stays live, got: {issues}"
|
|
78
|
+
assert any("MEDIUM_TEXT" in each_issue for each_issue in issues), (
|
|
79
|
+
f"A constant read by no module must still be flagged, got: {issues}"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_does_not_flag_constant_read_via_from_import_module_alias_attribute(
|
|
84
|
+
neutral_root: Path,
|
|
85
|
+
) -> None:
|
|
86
|
+
consumer_body = (
|
|
87
|
+
"from report_constants import render_report_constants as constants\n"
|
|
88
|
+
"\n"
|
|
89
|
+
"def panel_class(medium: str) -> str:\n"
|
|
90
|
+
" if medium == constants.MEDIUM_TERMINAL:\n"
|
|
91
|
+
" return 'terminal'\n"
|
|
92
|
+
" return 'code' if medium == constants.MEDIUM_CODE else 'other'\n"
|
|
93
|
+
)
|
|
94
|
+
constants_path = _build_constants_package(
|
|
95
|
+
neutral_root / "workflow", CONSTANTS_BODY, consumer_body
|
|
96
|
+
)
|
|
97
|
+
_assert_alias_read_constants_live(_check(CONSTANTS_BODY, str(constants_path)))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_does_not_flag_constant_read_via_import_as_alias_attribute(
|
|
101
|
+
neutral_root: Path,
|
|
102
|
+
) -> None:
|
|
103
|
+
consumer_body = (
|
|
104
|
+
"import report_constants.render_report_constants as constants\n"
|
|
105
|
+
"\n"
|
|
106
|
+
"def label(medium: str) -> str:\n"
|
|
107
|
+
" if medium == constants.MEDIUM_TERMINAL:\n"
|
|
108
|
+
" return 'terminal'\n"
|
|
109
|
+
" return 'code' if medium == constants.MEDIUM_CODE else 'other'\n"
|
|
110
|
+
)
|
|
111
|
+
constants_path = _build_constants_package(
|
|
112
|
+
neutral_root / "workflow", CONSTANTS_BODY, consumer_body
|
|
113
|
+
)
|
|
114
|
+
_assert_alias_read_constants_live(_check(CONSTANTS_BODY, str(constants_path)))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_does_not_flag_constant_read_via_plain_module_name_attribute(
|
|
118
|
+
neutral_root: Path,
|
|
119
|
+
) -> None:
|
|
120
|
+
consumer_body = (
|
|
121
|
+
"from report_constants import render_report_constants\n"
|
|
122
|
+
"\n"
|
|
123
|
+
"def label(medium: str) -> str:\n"
|
|
124
|
+
" if medium == render_report_constants.MEDIUM_TERMINAL:\n"
|
|
125
|
+
" return 'terminal'\n"
|
|
126
|
+
" if medium == render_report_constants.MEDIUM_CODE:\n"
|
|
127
|
+
" return 'code'\n"
|
|
128
|
+
" return 'other'\n"
|
|
129
|
+
)
|
|
130
|
+
constants_path = _build_constants_package(
|
|
131
|
+
neutral_root / "workflow", CONSTANTS_BODY, consumer_body
|
|
132
|
+
)
|
|
133
|
+
_assert_alias_read_constants_live(_check(CONSTANTS_BODY, str(constants_path)))
|
|
@@ -12,6 +12,9 @@ SHARED_MODULE_PATH = Path(__file__).parent / "code_rules_shared.py"
|
|
|
12
12
|
FIXED_USER_ID = 4242
|
|
13
13
|
WORKING_DIRECTORY = "/home/user/project"
|
|
14
14
|
SESSION_ID = "session-abc-123"
|
|
15
|
+
HARNESS_USER_DIRECTORY_WINDOWS = "claude"
|
|
16
|
+
WINDOWS_MANGLED_WORKING_DIRECTORY = "c--Users-dev--claude-project"
|
|
17
|
+
SESSION_ID_ENVIRONMENT_VARIABLE = "CLAUDE_CODE_SESSION_ID"
|
|
15
18
|
|
|
16
19
|
|
|
17
20
|
def _load_shared_module() -> ModuleType:
|
|
@@ -27,10 +30,6 @@ def _load_shared_module() -> ModuleType:
|
|
|
27
30
|
_SHARED_MODULE = _load_shared_module()
|
|
28
31
|
|
|
29
32
|
|
|
30
|
-
def _install_fixed_user_id(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
31
|
-
monkeypatch.setattr(os, "getuid", lambda: FIXED_USER_ID, raising=False)
|
|
32
|
-
|
|
33
|
-
|
|
34
33
|
def _point_temporary_directory_at(monkeypatch: pytest.MonkeyPatch, temporary_root: Path) -> None:
|
|
35
34
|
monkeypatch.setattr(tempfile, "gettempdir", lambda: str(temporary_root))
|
|
36
35
|
|
|
@@ -48,6 +47,24 @@ def _build_scratchpad_directory(temporary_root: Path) -> Path:
|
|
|
48
47
|
return scratchpad_directory
|
|
49
48
|
|
|
50
49
|
|
|
50
|
+
def _build_windows_scratchpad_directory(temporary_root: Path) -> Path:
|
|
51
|
+
"""Build the Windows-shaped scratchpad directory that carries no user id segment."""
|
|
52
|
+
scratchpad_directory = (
|
|
53
|
+
temporary_root
|
|
54
|
+
/ HARNESS_USER_DIRECTORY_WINDOWS
|
|
55
|
+
/ WINDOWS_MANGLED_WORKING_DIRECTORY
|
|
56
|
+
/ SESSION_ID
|
|
57
|
+
/ "scratchpad"
|
|
58
|
+
)
|
|
59
|
+
scratchpad_directory.mkdir(parents=True)
|
|
60
|
+
return scratchpad_directory
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _simulate_windows_platform(monkeypatch: pytest.MonkeyPatch, temporary_root: Path) -> None:
|
|
64
|
+
monkeypatch.delattr(os, "getuid", raising=False)
|
|
65
|
+
monkeypatch.setattr(tempfile, "gettempdir", lambda: str(temporary_root))
|
|
66
|
+
|
|
67
|
+
|
|
51
68
|
def _session_payload() -> dict[str, str]:
|
|
52
69
|
return {"cwd": WORKING_DIRECTORY, "session_id": SESSION_ID}
|
|
53
70
|
|
|
@@ -55,7 +72,6 @@ def _session_payload() -> dict[str, str]:
|
|
|
55
72
|
def test_returns_true_for_file_inside_reconstructed_scratchpad(
|
|
56
73
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
57
74
|
) -> None:
|
|
58
|
-
_install_fixed_user_id(monkeypatch)
|
|
59
75
|
_point_temporary_directory_at(monkeypatch, tmp_path)
|
|
60
76
|
scratchpad_directory = _build_scratchpad_directory(tmp_path)
|
|
61
77
|
throwaway_script = scratchpad_directory / "one_off_tool.py"
|
|
@@ -69,7 +85,6 @@ def test_returns_true_for_file_inside_reconstructed_scratchpad(
|
|
|
69
85
|
def test_returns_false_for_file_outside_scratchpad(
|
|
70
86
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
71
87
|
) -> None:
|
|
72
|
-
_install_fixed_user_id(monkeypatch)
|
|
73
88
|
_point_temporary_directory_at(monkeypatch, tmp_path)
|
|
74
89
|
_build_scratchpad_directory(tmp_path)
|
|
75
90
|
project_module = tmp_path / "elsewhere" / "orders.py"
|
|
@@ -82,7 +97,6 @@ def test_returns_false_for_file_outside_scratchpad(
|
|
|
82
97
|
def test_resolves_symlink_into_scratchpad_to_true(
|
|
83
98
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
84
99
|
) -> None:
|
|
85
|
-
_install_fixed_user_id(monkeypatch)
|
|
86
100
|
_point_temporary_directory_at(monkeypatch, tmp_path)
|
|
87
101
|
scratchpad_directory = _build_scratchpad_directory(tmp_path)
|
|
88
102
|
real_script = scratchpad_directory / "real_tool.py"
|
|
@@ -96,7 +110,6 @@ def test_resolves_symlink_into_scratchpad_to_true(
|
|
|
96
110
|
def test_symlink_resolving_outside_scratchpad_is_false(
|
|
97
111
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
98
112
|
) -> None:
|
|
99
|
-
_install_fixed_user_id(monkeypatch)
|
|
100
113
|
_point_temporary_directory_at(monkeypatch, tmp_path)
|
|
101
114
|
scratchpad_directory = _build_scratchpad_directory(tmp_path)
|
|
102
115
|
real_target_outside = tmp_path / "outside_target.py"
|
|
@@ -113,8 +126,8 @@ def test_symlink_resolving_outside_scratchpad_is_false(
|
|
|
113
126
|
def test_missing_session_id_signal_returns_false(
|
|
114
127
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
115
128
|
) -> None:
|
|
116
|
-
_install_fixed_user_id(monkeypatch)
|
|
117
129
|
_point_temporary_directory_at(monkeypatch, tmp_path)
|
|
130
|
+
monkeypatch.delenv(SESSION_ID_ENVIRONMENT_VARIABLE, raising=False)
|
|
118
131
|
scratchpad_directory = _build_scratchpad_directory(tmp_path)
|
|
119
132
|
throwaway_script = scratchpad_directory / "one_off_tool.py"
|
|
120
133
|
payload_without_session = {"cwd": WORKING_DIRECTORY}
|
|
@@ -125,10 +138,12 @@ def test_missing_session_id_signal_returns_false(
|
|
|
125
138
|
)
|
|
126
139
|
|
|
127
140
|
|
|
128
|
-
def
|
|
141
|
+
def test_exempt_without_working_directory_signal(
|
|
129
142
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
130
143
|
) -> None:
|
|
131
|
-
|
|
144
|
+
"""The platform-safe predicate keys on the session id and the temp-directory path
|
|
145
|
+
shape, so a payload carrying the session id alone still exempts a real scratchpad
|
|
146
|
+
target even when it omits the working directory."""
|
|
132
147
|
_point_temporary_directory_at(monkeypatch, tmp_path)
|
|
133
148
|
scratchpad_directory = _build_scratchpad_directory(tmp_path)
|
|
134
149
|
throwaway_script = scratchpad_directory / "one_off_tool.py"
|
|
@@ -136,27 +151,28 @@ def test_missing_working_directory_signal_returns_false(
|
|
|
136
151
|
|
|
137
152
|
assert (
|
|
138
153
|
_SHARED_MODULE.is_under_session_scratchpad(str(throwaway_script), payload_without_cwd)
|
|
139
|
-
is
|
|
154
|
+
is True
|
|
140
155
|
)
|
|
141
156
|
|
|
142
157
|
|
|
143
|
-
def
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
158
|
+
def test_exempt_on_windows_without_getuid(
|
|
159
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
160
|
+
) -> None:
|
|
161
|
+
"""On Windows the os.getuid attribute is absent, and a real scratchpad target still
|
|
162
|
+
resolves as exempt through its temp-directory path shape and session id."""
|
|
163
|
+
_simulate_windows_platform(monkeypatch, tmp_path)
|
|
164
|
+
scratchpad_directory = _build_windows_scratchpad_directory(tmp_path)
|
|
147
165
|
throwaway_script = scratchpad_directory / "one_off_tool.py"
|
|
148
|
-
monkeypatch.delattr(os, "getuid", raising=False)
|
|
149
166
|
|
|
150
167
|
assert (
|
|
151
168
|
_SHARED_MODULE.is_under_session_scratchpad(str(throwaway_script), _session_payload())
|
|
152
|
-
is
|
|
169
|
+
is True
|
|
153
170
|
)
|
|
154
171
|
|
|
155
172
|
|
|
156
173
|
def test_nonexistent_scratchpad_directory_returns_false(
|
|
157
174
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
158
175
|
) -> None:
|
|
159
|
-
_install_fixed_user_id(monkeypatch)
|
|
160
176
|
_point_temporary_directory_at(monkeypatch, tmp_path)
|
|
161
177
|
mangled_working_directory = WORKING_DIRECTORY.replace("/", "-")
|
|
162
178
|
unbuilt_target = (
|
|
@@ -174,8 +190,92 @@ def test_nonexistent_scratchpad_directory_returns_false(
|
|
|
174
190
|
|
|
175
191
|
|
|
176
192
|
def test_empty_file_path_returns_false(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
177
|
-
_install_fixed_user_id(monkeypatch)
|
|
178
193
|
_point_temporary_directory_at(monkeypatch, tmp_path)
|
|
179
194
|
_build_scratchpad_directory(tmp_path)
|
|
180
195
|
|
|
181
196
|
assert _SHARED_MODULE.is_under_session_scratchpad("", _session_payload()) is False
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_windows_shape_repository_file_is_not_exempt(
|
|
200
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
201
|
+
) -> None:
|
|
202
|
+
_simulate_windows_platform(monkeypatch, tmp_path)
|
|
203
|
+
_build_windows_scratchpad_directory(tmp_path)
|
|
204
|
+
repository_module = tmp_path / "repository" / "src" / "orders.py"
|
|
205
|
+
repository_module.parent.mkdir(parents=True)
|
|
206
|
+
|
|
207
|
+
assert (
|
|
208
|
+
_SHARED_MODULE.is_under_session_scratchpad(str(repository_module), _session_payload())
|
|
209
|
+
is False
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_temp_path_outside_scratchpad_shape_is_not_exempt(
|
|
214
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
215
|
+
) -> None:
|
|
216
|
+
"""A path under the harness user directory but off the session/scratchpad shape
|
|
217
|
+
keeps full enforcement."""
|
|
218
|
+
_simulate_windows_platform(monkeypatch, tmp_path)
|
|
219
|
+
_build_windows_scratchpad_directory(tmp_path)
|
|
220
|
+
off_shape_target = tmp_path / HARNESS_USER_DIRECTORY_WINDOWS / "unrelated" / "notes.py"
|
|
221
|
+
off_shape_target.parent.mkdir(parents=True)
|
|
222
|
+
|
|
223
|
+
assert (
|
|
224
|
+
_SHARED_MODULE.is_under_session_scratchpad(str(off_shape_target), _session_payload())
|
|
225
|
+
is False
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_repository_path_containing_scratchpad_word_is_not_exempt(
|
|
230
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
231
|
+
) -> None:
|
|
232
|
+
"""A repository path outside the temp directory that merely carries a scratchpad
|
|
233
|
+
segment keeps full enforcement."""
|
|
234
|
+
_simulate_windows_platform(monkeypatch, tmp_path)
|
|
235
|
+
_build_windows_scratchpad_directory(tmp_path)
|
|
236
|
+
repository_lookalike = tmp_path.parent / "project_repo" / "scratchpad" / "tool.py"
|
|
237
|
+
|
|
238
|
+
assert (
|
|
239
|
+
_SHARED_MODULE.is_under_session_scratchpad(str(repository_lookalike), _session_payload())
|
|
240
|
+
is False
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_is_ephemeral_path_true_for_windows_scratchpad(
|
|
245
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
246
|
+
) -> None:
|
|
247
|
+
_simulate_windows_platform(monkeypatch, tmp_path)
|
|
248
|
+
scratchpad_directory = _build_windows_scratchpad_directory(tmp_path)
|
|
249
|
+
throwaway_script = scratchpad_directory / "probe.py"
|
|
250
|
+
|
|
251
|
+
assert _SHARED_MODULE.is_ephemeral_path(str(throwaway_script), _session_payload()) is True
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_is_ephemeral_path_true_for_root_anchored_tmp(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
255
|
+
monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
|
|
256
|
+
|
|
257
|
+
assert _SHARED_MODULE.is_ephemeral_path("/tmp/scratch.py") is True
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def test_is_ephemeral_path_false_for_repository_file(
|
|
261
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
262
|
+
) -> None:
|
|
263
|
+
_simulate_windows_platform(monkeypatch, tmp_path)
|
|
264
|
+
monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
|
|
265
|
+
repository_file = tmp_path / "repository" / "orders.py"
|
|
266
|
+
repository_file.parent.mkdir(parents=True)
|
|
267
|
+
|
|
268
|
+
assert _SHARED_MODULE.is_ephemeral_path(str(repository_file), _session_payload()) is False
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def test_is_ephemeral_path_reads_session_id_from_environment(
|
|
272
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
273
|
+
) -> None:
|
|
274
|
+
"""With no payload, the session scratchpad match reads the session id from the
|
|
275
|
+
harness environment variable, so a caller that holds no payload still matches."""
|
|
276
|
+
_simulate_windows_platform(monkeypatch, tmp_path)
|
|
277
|
+
monkeypatch.setenv(SESSION_ID_ENVIRONMENT_VARIABLE, SESSION_ID)
|
|
278
|
+
scratchpad_directory = _build_windows_scratchpad_directory(tmp_path)
|
|
279
|
+
throwaway_script = scratchpad_directory / "probe.py"
|
|
280
|
+
|
|
281
|
+
assert _SHARED_MODULE.is_ephemeral_path(str(throwaway_script)) is True
|