claude-dev-env 2.18.0 → 2.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/.agents/skills/AGENTS.md +4 -4
  2. package/.agents/skills/_shared/AGENTS.md +1 -0
  3. package/.agents/skills/_shared/pr-loop/AGENTS.md +1 -0
  4. package/.agents/skills/_shared/pr-loop/preflight-proposal.contract.test.mjs +119 -0
  5. package/.agents/skills/_shared/pr-loop/preflight-proposal.md +5 -0
  6. package/.agents/skills/e-code-review/SKILL.md +12 -1
  7. package/.agents/skills/e-code-review/reference/fix.md +5 -1
  8. package/.agents/skills/e-code-review/reference/loop.md +4 -0
  9. package/.agents/skills/e-code-review/reference/mode-contract.test.mjs +66 -0
  10. package/.agents/skills/e-code-review/reference/preflight-proposal.md +40 -0
  11. package/.agents/skills/e-code-review/reference/runner-selection.md +1 -0
  12. package/.agents/skills/pr-cleanup/SKILL.md +105 -75
  13. package/.agents/skills/pr-cleanup/reference/process-inventory.md +15 -0
  14. package/.agents/skills/pr-cleanup/reference/task-seeds.md +20 -0
  15. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/SKILL.md +18 -12
  16. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/offense-examples.md +1 -1
  17. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/task-seeds.md +3 -2
  18. package/.agents/skills/pr-refinement/SKILL.md +31 -0
  19. package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/SKILL.md +24 -7
  20. package/.agents/skills/{small-cl → pr-small-cl}/SKILL.md +1 -1
  21. package/.agents/skills/prototype/scripts/test_probe_sandbox_safety.py +33 -5
  22. package/.agents/skills/skill-builder/SKILL.md +1 -0
  23. package/.agents/skills-archived/bugteam/SKILL.md +1 -1
  24. package/.agents/skills-archived/bugteam/test_skill_additions.py +1 -1
  25. package/.agents/skills-archived/comments/SKILL.md +1 -1
  26. package/.agents/skills-archived/descriptions/SKILL.md +1 -1
  27. package/.agents/skills-archived/reviews/SKILL.md +1 -1
  28. package/AGENTS.md +1 -1
  29. package/_shared/pr-loop/AGENTS.md +2 -1
  30. package/_shared/pr-loop/preflight-proposal.md +56 -0
  31. package/_shared/pr-loop/scripts/code_rules_gate.py +29 -6
  32. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +15 -3
  33. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +47 -4
  34. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +4 -0
  35. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +47 -0
  36. package/bin/ever-shipped-skills.mjs +14 -0
  37. package/bin/install-constants.mjs +5 -0
  38. package/bin/install.agents-home.test.mjs +23 -0
  39. package/bin/install.codex-rules.test.mjs +61 -6
  40. package/bin/install.mjs +57 -9
  41. package/bin/install.prune.test.mjs +65 -3
  42. package/bin/install.test.mjs +202 -2
  43. package/docs/CODE_RULES.md +6 -0
  44. package/hooks/advisory/conftest.py +10 -0
  45. package/hooks/advisory/migration_safety_advisor.py +14 -14
  46. package/hooks/advisory/refactor_guard.py +250 -144
  47. package/hooks/advisory/refactor_guard_test_support.py +46 -0
  48. package/hooks/advisory/test_migration_safety_advisor.py +120 -0
  49. package/hooks/advisory/test_refactor_guard_advisory.py +171 -0
  50. package/hooks/advisory/test_refactor_guard_eligibility.py +166 -0
  51. package/hooks/atomic_file_writer.py +81 -0
  52. package/hooks/blocking/AGENTS.md +2 -1
  53. package/hooks/blocking/block_main_commit.py +66 -33
  54. package/hooks/blocking/code_rules_banned_identifiers.py +55 -4
  55. package/hooks/blocking/code_rules_blast_radius.py +194 -0
  56. package/hooks/blocking/code_rules_enforcer.py +12 -0
  57. package/hooks/blocking/code_rules_enforcer_test_support.py +92 -0
  58. package/hooks/blocking/destructive_command_blocker.py +8 -7
  59. package/hooks/blocking/pre_tool_use_dispatcher.py +11 -7
  60. package/hooks/blocking/precommit_code_rules_gate.py +14 -139
  61. package/hooks/blocking/sensitive_file_protector.py +8 -6
  62. package/hooks/blocking/session_edit_stage_gate.py +31 -1
  63. package/hooks/blocking/test_block_main_commit.py +145 -0
  64. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +171 -1
  65. package/hooks/blocking/test_code_rules_blast_radius.py +161 -0
  66. package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +37 -12
  67. package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +54 -0
  68. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +15 -53
  69. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +13 -62
  70. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +309 -0
  71. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +14 -71
  72. package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +14 -16
  73. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +31 -46
  74. package/hooks/blocking/test_code_rules_enforcer_stage.py +192 -0
  75. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +13 -59
  76. package/hooks/blocking/test_destructive_command_blocker.py +154 -138
  77. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +85 -25
  78. package/hooks/blocking/test_destructive_command_blocker_no_verify.py +58 -38
  79. package/hooks/blocking/test_destructive_command_blocker_patterns.py +133 -0
  80. package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +113 -1
  81. package/hooks/blocking/test_env_var_table_code_drift_blocker.py +70 -3
  82. package/hooks/blocking/test_hook_subprocess_support.py +296 -0
  83. package/hooks/blocking/test_package_inventory_stale_blocker.py +1 -1
  84. package/hooks/blocking/test_pii_scanner.py +81 -0
  85. package/hooks/blocking/test_pre_tool_use_dispatcher.py +32 -3
  86. package/hooks/blocking/test_precommit_code_rules_gate.py +11 -16
  87. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +234 -0
  88. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +25 -18
  89. package/hooks/blocking/test_sensitive_file_protector.py +145 -5
  90. package/hooks/blocking/test_session_edit_stage_gate_staging.py +64 -0
  91. package/hooks/blocking/test_session_edit_stage_gate_support.py +204 -0
  92. package/hooks/blocking/test_session_edit_stage_gate_tracker.py +116 -0
  93. package/hooks/blocking/test_shared_stdin_adoption.py +31 -20
  94. package/hooks/blocking/test_test_preflight_check.py +80 -0
  95. package/hooks/git-hooks/AGENTS.md +1 -1
  96. package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -0
  97. package/hooks/git-hooks/post_commit.py +160 -51
  98. package/hooks/git-hooks/pre_commit.py +9 -5
  99. package/hooks/git-hooks/test_post_commit.py +203 -0
  100. package/hooks/git-hooks/test_pre_commit.py +2 -2
  101. package/hooks/git-hooks/test_pre_push.py +57 -0
  102. package/hooks/hooks_constants/AGENTS.md +3 -1
  103. package/hooks/hooks_constants/atomic_file_writer_constants.py +4 -0
  104. package/hooks/hooks_constants/banned_identifiers_constants.py +1 -0
  105. package/hooks/hooks_constants/blast_radius_constants.py +14 -0
  106. package/hooks/hooks_constants/destructive_command_environment_constants.py +18 -0
  107. package/hooks/hooks_constants/destructive_command_segment_constants.py +1 -11
  108. package/hooks/hooks_constants/hardcoded_user_path_constants.py +9 -3
  109. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +1 -1
  110. package/hooks/hooks_constants/refactor_guard_constants.py +75 -0
  111. package/hooks/hooks_constants/sensitive_file_protector_constants.py +2 -4
  112. package/hooks/hooks_constants/test_refactor_guard_constants.py +21 -0
  113. package/hooks/json_file_reader.py +22 -0
  114. package/hooks/observability/test_instructions_loaded_logger.py +54 -0
  115. package/hooks/pending_sidecars.py +33 -0
  116. package/hooks/session/test_plugin_data_dir_cleanup.py +70 -0
  117. package/hooks/session/test_session_edit_tracker_cleanup.py +16 -3
  118. package/hooks/test_atomic_file_writer.py +73 -0
  119. package/hooks/test_json_file_reader.py +24 -0
  120. package/hooks/test_pending_sidecars.py +25 -0
  121. package/hooks/validation/mypy_validator.py +213 -80
  122. package/hooks/validation/test_mypy_validator.py +288 -13
  123. package/hooks/workflow/auto_formatter.py +225 -93
  124. package/hooks/workflow/investigation_tracker_reset.py +2 -0
  125. package/hooks/workflow/test_auto_formatter.py +261 -12
  126. package/hooks/workflow/test_investigation_tracker_reset.py +90 -0
  127. package/package.json +1 -1
  128. package/rules/failure-blast-radius.md +126 -0
  129. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/fetch-commands.md +0 -0
  130. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/report-template.md +0 -0
  131. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/rule-checklist.md +0 -0
  132. /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/examples.md +0 -0
  133. /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/offense-taxonomy.md +0 -0
@@ -4,9 +4,15 @@ import re
4
4
 
5
5
  HARDCODED_USER_PATH_PATTERN: re.Pattern[str] = re.compile(
6
6
  r"(?:"
7
- r"[A-Za-z]:[\\/](?i:users)[\\/](?!(?i:Public|Shared|All Users)(?:[\\/]|$))[^\\/]+(?=[\\/]|$)"
8
- r"|(?<![A-Za-z:])/Users/(?!(?i:Shared|Public)(?:/|$))[^/]+(?=/|$)"
9
- r"|/home/[^/]+(?=/|$))"
7
+ r"[A-Za-z]:[\\/]+(?i:users)[\\/]+(?!(?i:Public|Shared|All Users)(?:[\\/]|$))[^\\/]+(?=[\\/]|$)"
8
+ r"|\\{2,}(?:[^\\/\r\n]+[\\/]+)+(?i:users)[\\/]+(?!(?i:Public|Shared|All Users)(?:[\\/]|$))[^\\/]+(?=[\\/]|$)"
9
+ r"|(?<!:)//(?:[^\\/\r\n]+/)+(?i:users)/(?!(?i:Public|Shared|All Users)(?:/|$))[^/]+(?=/|$)"
10
+ r"|(?i:file):///Users/(?!(?i:Shared|Public)(?:/|$))[^/]+(?=/|$)"
11
+ r"|(?i:file)://(?:[^/\r\n]+/)+(?i:Users)/(?!(?i:Shared|Public)(?:/|$))[^/]+(?=/|$)"
12
+ r"|(?i:file):///home/[^/]+(?=/|$)"
13
+ r"|(?i:file)://(?:[^/\r\n]+/)+(?i:home)/[^/]+(?=/|$)"
14
+ r"|(?<![A-Za-z0-9._:/\-\]])/Users/(?!(?i:Shared|Public)(?:/|$))[^/]+(?=/|$)"
15
+ r"|(?<![A-Za-z0-9._:/\-\]])/home/[^/]+(?=/|$))"
10
16
  )
11
17
  MAX_HARDCODED_USER_PATH_ISSUES: int = 25
12
18
  HARDCODED_USER_PATH_GUIDANCE: str = "use pathlib.Path.home() or os.path.expanduser('~') instead of a hardcoded user directory"
@@ -82,7 +82,7 @@ ALL_HOSTED_HOOK_ENTRIES: tuple[HostedHookEntry, ...] = (
82
82
  ),
83
83
  HostedHookEntry(
84
84
  script_relative_path="blocking/sensitive_file_protector.py",
85
- applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
85
+ applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
86
86
  ),
87
87
  HostedHookEntry(
88
88
  script_relative_path="blocking/pii_prevention_blocker.py",
@@ -0,0 +1,75 @@
1
+ """Constants for the changed-surface refactor advisory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ MAXIMUM_REFACTOR_LINE_DELTA: int = 3
6
+ REFACTOR_LINE_DELTA_DIVISOR: int = 2
7
+ CHANGED_SURFACE_MATCH_RATIO: float = 0.5
8
+ ALL_PYTHON_KEYWORDS: frozenset[str] = frozenset(
9
+ {
10
+ "def",
11
+ "class",
12
+ "return",
13
+ "import",
14
+ "from",
15
+ "if",
16
+ "elif",
17
+ "else",
18
+ "for",
19
+ "while",
20
+ "try",
21
+ "except",
22
+ "finally",
23
+ "with",
24
+ "as",
25
+ "yield",
26
+ "raise",
27
+ "pass",
28
+ "break",
29
+ "continue",
30
+ "and",
31
+ "or",
32
+ "not",
33
+ "in",
34
+ "is",
35
+ "lambda",
36
+ "None",
37
+ "True",
38
+ "False",
39
+ "self",
40
+ "cls",
41
+ "async",
42
+ "await",
43
+ "global",
44
+ "nonlocal",
45
+ "assert",
46
+ "del",
47
+ "print",
48
+ "len",
49
+ "range",
50
+ "list",
51
+ "dict",
52
+ "set",
53
+ "str",
54
+ "int",
55
+ "float",
56
+ "bool",
57
+ "type",
58
+ "isinstance",
59
+ "hasattr",
60
+ "getattr",
61
+ "setattr",
62
+ "super",
63
+ "property",
64
+ "staticmethod",
65
+ "classmethod",
66
+ "abstractmethod",
67
+ "Optional",
68
+ "Union",
69
+ "List",
70
+ "Dict",
71
+ "Set",
72
+ "Tuple",
73
+ "Any",
74
+ }
75
+ )
@@ -1,7 +1,7 @@
1
1
  """Constants for the ``sensitive_file_protector`` PreToolUse hook.
2
2
 
3
- The hook denies a Write or Edit whose target basename names a live secret or a
4
- lock file, and steps aside for a placeholders-only committed template.
3
+ The hook denies a Write, Edit, or MultiEdit whose target basename names a live
4
+ secret or a lock file, and steps aside for a placeholders-only committed template.
5
5
  """
6
6
 
7
7
  from __future__ import annotations
@@ -28,8 +28,6 @@ ALL_SENSITIVE_PATTERNS = (
28
28
 
29
29
  ALL_TEMPLATE_SUFFIXES = (".example", ".sample", ".template")
30
30
 
31
- ALL_WRITE_EDIT_TOOLS = ("Write", "Edit")
32
-
33
31
  HOOK_SCRIPT_NAME = "sensitive_file_protector.py"
34
32
 
35
33
  HOOK_EVENT_NAME = "PreToolUse"
@@ -0,0 +1,21 @@
1
+ """Tests for refactor advisory constants."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ _HOOKS_ROOT = Path(__file__).resolve().parent.parent
7
+ if str(_HOOKS_ROOT) not in sys.path:
8
+ sys.path.insert(0, str(_HOOKS_ROOT))
9
+
10
+ from hooks_constants.refactor_guard_constants import (
11
+ ALL_PYTHON_KEYWORDS,
12
+ CHANGED_SURFACE_MATCH_RATIO,
13
+ MAXIMUM_REFACTOR_LINE_DELTA,
14
+ REFACTOR_LINE_DELTA_DIVISOR,
15
+ )
16
+
17
+
18
+ def test_refactor_threshold_constants_define_a_half_boundary() -> None:
19
+ assert CHANGED_SURFACE_MATCH_RATIO * REFACTOR_LINE_DELTA_DIVISOR == 1
20
+ assert MAXIMUM_REFACTOR_LINE_DELTA > REFACTOR_LINE_DELTA_DIVISOR
21
+ assert "return" in ALL_PYTHON_KEYWORDS
@@ -0,0 +1,22 @@
1
+ """Safe JSON-object reads for hook-state files."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+
7
+ def read_json_object(file_path: Path, encoding: str) -> dict[str, object] | None:
8
+ """Return one decoded JSON object.
9
+
10
+ Args:
11
+ file_path: The JSON file path.
12
+ encoding: The text encoding.
13
+
14
+ Returns:
15
+ The decoded object, or ``None`` for missing, unreadable, or malformed data.
16
+ """
17
+ try:
18
+ raw_text = file_path.read_text(encoding=encoding)
19
+ parsed_payload = json.loads(raw_text)
20
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
21
+ return None
22
+ return parsed_payload if isinstance(parsed_payload, dict) else None
@@ -48,6 +48,60 @@ def test_should_write_record_with_known_payload_fields_to_jsonl_log() -> None:
48
48
  assert "timestamp" in record
49
49
 
50
50
 
51
+ def test_should_exclude_instruction_body_and_unrelated_secret_like_fields(
52
+ tmp_path: Path,
53
+ ) -> None:
54
+ fake_home = tmp_path / "home"
55
+ fake_home.mkdir()
56
+ payload = {
57
+ "file_path": "/tmp/CLAUDE.md",
58
+ "instruction_body": "private instruction text",
59
+ "api_key": "secret-api-key",
60
+ "authorization": "Bearer secret-token",
61
+ "metadata": {
62
+ "instruction_body": "nested private instruction text",
63
+ "api_key": "nested-secret-api-key",
64
+ "authorization": "Nested bearer secret-token",
65
+ "unrelated_secret_like_field": "nested private payload",
66
+ },
67
+ }
68
+
69
+ completed = _run_hook(payload, fake_home)
70
+
71
+ assert completed.returncode == 0, completed.stderr
72
+ log_path = fake_home / ".claude" / "logs" / "instructions_loaded.jsonl"
73
+ serialized_record = log_path.read_text(encoding="utf-8").strip()
74
+ record = json.loads(serialized_record)
75
+ assert set(record) == {
76
+ "timestamp",
77
+ "file_path",
78
+ "load_reason",
79
+ "memory_type",
80
+ "trigger_file_path",
81
+ "parent_file_path",
82
+ "globs",
83
+ "session_id",
84
+ }
85
+ sensitive_field_names = {
86
+ "instruction_body",
87
+ "api_key",
88
+ "authorization",
89
+ "metadata",
90
+ "unrelated_secret_like_field",
91
+ }
92
+ sensitive_field_values = {
93
+ "private instruction text",
94
+ "secret-api-key",
95
+ "Bearer secret-token",
96
+ "nested private instruction text",
97
+ "nested-secret-api-key",
98
+ "Nested bearer secret-token",
99
+ "nested private payload",
100
+ }
101
+ for each_sensitive_text in sensitive_field_names | sensitive_field_values:
102
+ assert each_sensitive_text not in serialized_record
103
+
104
+
51
105
  def test_should_exit_zero_and_record_error_when_stdin_payload_is_invalid_json() -> None:
52
106
  with tempfile.TemporaryDirectory() as fake_home_string:
53
107
  fake_home = Path(fake_home_string)
@@ -0,0 +1,33 @@
1
+ """Pending-sidecar discovery and cleanup for hook state."""
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ def pending_sidecar_paths(
7
+ main_file: Path, pending_marker: str, pending_suffix: str
8
+ ) -> tuple[Path, ...]:
9
+ """Return pending sidecars for one main state file.
10
+
11
+ Args:
12
+ main_file: The primary state file.
13
+ pending_marker: The marker before the unique sidecar token.
14
+ pending_suffix: The sidecar filename suffix.
15
+
16
+ Returns:
17
+ The matching pending sidecar paths.
18
+ """
19
+ pending_pattern = f"{main_file.name}{pending_marker}*{pending_suffix}"
20
+ return tuple(main_file.parent.glob(pending_pattern))
21
+
22
+
23
+ def remove_pending_sidecars(all_pending_files: tuple[Path, ...]) -> None:
24
+ """Remove a captured pending-sidecar set.
25
+
26
+ Args:
27
+ all_pending_files: The exact sidecars already merged or consumed.
28
+ """
29
+ for each_pending_file in all_pending_files:
30
+ try:
31
+ each_pending_file.unlink(missing_ok=True)
32
+ except OSError:
33
+ continue
@@ -0,0 +1,70 @@
1
+ """Behavior tests for plugin data directory cleanup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import stat
7
+ from pathlib import Path
8
+
9
+
10
+ HOOK_DIRECTORY = Path(__file__).parent
11
+ HOOK_SPEC = importlib.util.spec_from_file_location(
12
+ "plugin_data_dir_cleanup",
13
+ HOOK_DIRECTORY / "plugin_data_dir_cleanup.py",
14
+ )
15
+ assert HOOK_SPEC is not None
16
+ assert HOOK_SPEC.loader is not None
17
+ HOOK_MODULE = importlib.util.module_from_spec(HOOK_SPEC)
18
+ HOOK_SPEC.loader.exec_module(HOOK_MODULE)
19
+
20
+
21
+ def _configure_plugin_data_directory(plugin_data_directory: Path) -> None:
22
+ HOOK_MODULE.__dict__["PLUGINS_DATA_DIRECTORY"] = str(plugin_data_directory)
23
+ HOOK_MODULE.__dict__["AFFECTED_PLUGIN_DIRECTORIES"] = ["sample-plugin"]
24
+
25
+
26
+ def test_main_removes_empty_plugin_data_directory(tmp_path: Path) -> None:
27
+ plugin_data_directory = tmp_path / "plugin-data"
28
+ plugin_data_directory.mkdir()
29
+ empty_plugin_directory = plugin_data_directory / "sample-plugin"
30
+ empty_plugin_directory.mkdir()
31
+ _configure_plugin_data_directory(plugin_data_directory)
32
+
33
+ HOOK_MODULE.main()
34
+
35
+ assert not empty_plugin_directory.exists()
36
+
37
+
38
+ def test_main_keeps_plugin_data_directory_with_files(tmp_path: Path) -> None:
39
+ plugin_data_directory = tmp_path / "plugin-data"
40
+ plugin_data_directory.mkdir()
41
+ retained_plugin_directory = plugin_data_directory / "sample-plugin"
42
+ retained_plugin_directory.mkdir()
43
+ (retained_plugin_directory / "state.json").write_text("{}", encoding="utf-8")
44
+ _configure_plugin_data_directory(plugin_data_directory)
45
+
46
+ HOOK_MODULE.main()
47
+
48
+ assert retained_plugin_directory.is_dir()
49
+
50
+
51
+ def test_main_keeps_read_only_plugin_data_directory_with_files(
52
+ tmp_path: Path,
53
+ ) -> None:
54
+ plugin_data_directory = tmp_path / "plugin-data"
55
+ plugin_data_directory.mkdir()
56
+ retained_plugin_directory = plugin_data_directory / "sample-plugin"
57
+ retained_plugin_directory.mkdir()
58
+ state_file = retained_plugin_directory / "state.json"
59
+ state_file.write_text("{}", encoding="utf-8")
60
+ state_file.chmod(stat.S_IREAD)
61
+ _configure_plugin_data_directory(plugin_data_directory)
62
+
63
+ try:
64
+ HOOK_MODULE.main()
65
+
66
+ assert retained_plugin_directory.is_dir()
67
+ assert state_file.is_file()
68
+ assert state_file.read_text(encoding="utf-8") == "{}"
69
+ finally:
70
+ state_file.chmod(stat.S_IREAD | stat.S_IWRITE)
@@ -127,12 +127,25 @@ def test_should_keep_current_session_tracker_on_continuation(
127
127
  redirected_temp_directory: pathlib.Path, continuation_source: str
128
128
  ) -> None:
129
129
  current_file = _seed_edit_file(redirected_temp_directory, "currentsession")
130
- _run_main_with_stdin(
131
- _session_continuation_payload("currentsession", continuation_source)
132
- )
130
+ _run_main_with_stdin(_session_continuation_payload("currentsession", continuation_source))
133
131
  assert current_file.exists()
134
132
 
135
133
 
134
+ def test_resume_preserves_edit_record(
135
+ redirected_temp_directory: pathlib.Path,
136
+ ) -> None:
137
+ current_file = _seed_edit_file(redirected_temp_directory, "currentsession")
138
+ current_file.write_text(
139
+ json.dumps({ALL_EDITED_FILE_PATHS_KEY: ["packages/example.py"]}),
140
+ encoding="utf-8",
141
+ )
142
+ _run_main_with_stdin(_session_continuation_payload("currentsession", "resume"))
143
+
144
+ assert json.loads(current_file.read_text(encoding="utf-8")) == {
145
+ ALL_EDITED_FILE_PATHS_KEY: ["packages/example.py"]
146
+ }
147
+
148
+
136
149
  def test_should_remove_current_session_tracker_on_session_end(
137
150
  redirected_temp_directory: pathlib.Path,
138
151
  ) -> None:
@@ -0,0 +1,73 @@
1
+ """Behavior tests for atomic hook-state text replacement."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ _hooks_directory = str(Path(__file__).resolve().parent)
9
+ if _hooks_directory not in sys.path:
10
+ sys.path.insert(0, _hooks_directory)
11
+
12
+ import atomic_file_writer
13
+ from atomic_file_writer import write_text_atomically
14
+
15
+
16
+ def test_write_text_atomically_creates_parent_and_replaces_complete_text(
17
+ tmp_path: Path,
18
+ ) -> None:
19
+ target_path = tmp_path / "state" / "cache.json"
20
+
21
+ write_text_atomically(
22
+ target_path,
23
+ '{"complete": true}',
24
+ encoding="utf-8",
25
+ temporary_prefix=".cache-",
26
+ temporary_suffix=".tmp",
27
+ should_reap_orphans=False,
28
+ )
29
+
30
+ assert target_path.read_text(encoding="utf-8") == '{"complete": true}'
31
+
32
+
33
+ def test_write_text_atomically_preserves_target_and_cleans_temp_after_replace_error(
34
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
35
+ ) -> None:
36
+ target_path = tmp_path / "cache.json"
37
+ target_path.write_text("stable", encoding="utf-8")
38
+
39
+ def _raise_replace_error(source_path: object, destination_path: object) -> None:
40
+ del source_path, destination_path
41
+ raise OSError("replace failed")
42
+
43
+ monkeypatch.setattr(atomic_file_writer.os, "replace", _raise_replace_error)
44
+
45
+ with pytest.raises(OSError, match="replace failed"):
46
+ write_text_atomically(
47
+ target_path,
48
+ "replacement",
49
+ encoding="utf-8",
50
+ temporary_prefix=".cache-",
51
+ temporary_suffix=".tmp",
52
+ should_reap_orphans=False,
53
+ )
54
+
55
+ assert target_path.read_text(encoding="utf-8") == "stable"
56
+ assert list(tmp_path.glob(".cache-*.tmp")) == []
57
+
58
+
59
+ def test_write_text_atomically_reaps_orphans(tmp_path: Path) -> None:
60
+ target_path = tmp_path / "cache.json"
61
+ orphan_path = tmp_path / ".cache-orphan.tmp"
62
+ orphan_path.write_text("orphan", encoding="utf-8")
63
+
64
+ write_text_atomically(
65
+ target_path,
66
+ "replacement",
67
+ encoding="utf-8",
68
+ temporary_prefix=".cache-",
69
+ temporary_suffix=".tmp",
70
+ should_reap_orphans=True,
71
+ )
72
+
73
+ assert not orphan_path.exists()
@@ -0,0 +1,24 @@
1
+ """Behavior tests for safe JSON-object reads."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ _hooks_directory = str(Path(__file__).resolve().parent)
7
+ if _hooks_directory not in sys.path:
8
+ sys.path.insert(0, _hooks_directory)
9
+
10
+ from json_file_reader import read_json_object
11
+
12
+
13
+ def test_read_json_object_returns_decoded_mapping(tmp_path: Path) -> None:
14
+ file_path = tmp_path / "state.json"
15
+ file_path.write_text('{"status": "ready"}', encoding="utf-8")
16
+
17
+ assert read_json_object(file_path, "utf-8") == {"status": "ready"}
18
+
19
+
20
+ def test_read_json_object_returns_none_for_invalid_bytes(tmp_path: Path) -> None:
21
+ file_path = tmp_path / "state.json"
22
+ file_path.write_bytes(b"\xff")
23
+
24
+ assert read_json_object(file_path, "utf-8") is None
@@ -0,0 +1,25 @@
1
+ """Behavior tests for pending-sidecar discovery and cleanup."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ _hooks_directory = str(Path(__file__).resolve().parent)
7
+ if _hooks_directory not in sys.path:
8
+ sys.path.insert(0, _hooks_directory)
9
+
10
+ from pending_sidecars import pending_sidecar_paths, remove_pending_sidecars
11
+
12
+
13
+ def test_pending_sidecar_paths_and_remove_pending_sidecars(tmp_path: Path) -> None:
14
+ main_file = tmp_path / "state.json"
15
+ first_pending = tmp_path / "state.json.pending-first.json"
16
+ second_pending = tmp_path / "state.json.pending-second.json"
17
+ unrelated_file = tmp_path / "other.json"
18
+ for each_file in (first_pending, second_pending, unrelated_file):
19
+ each_file.write_text("state", encoding="utf-8")
20
+
21
+ all_pending_files = pending_sidecar_paths(main_file, ".pending-", ".json")
22
+ remove_pending_sidecars(all_pending_files)
23
+
24
+ assert sorted(all_pending_files) == sorted((first_pending, second_pending))
25
+ assert unrelated_file.is_file()