claude-dev-env 8.69.0 → 8.70.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 (99) hide show
  1. package/.agents/skills/correction/SKILL.md +28 -6
  2. package/.agents/skills/correction/references/filing.md +33 -0
  3. package/.agents/skills/correction/scripts/correction_filing.py +327 -0
  4. package/.agents/skills/correction/scripts/correction_filing_constants/__init__.py +0 -0
  5. package/.agents/skills/correction/scripts/correction_filing_constants/config/__init__.py +0 -0
  6. package/.agents/skills/correction/scripts/correction_filing_constants/config/constants.py +56 -0
  7. package/.agents/skills/correction/scripts/pytest.ini +4 -0
  8. package/.agents/skills/correction/scripts/test_correction_filing.py +155 -0
  9. package/.agents/skills/pr-lifecycle/SKILL.md +3 -1
  10. package/.agents/skills/second-account-workers/SKILL.md +21 -3
  11. package/docs/rule-guides/features-start-with-an-eval.md +40 -0
  12. package/hooks/blocking/followup_pr_dedupe.py +55 -5
  13. package/hooks/blocking/image_read_size_gate.py +167 -0
  14. package/hooks/blocking/pr_lifecycle_skill_gate.py +62 -11
  15. package/hooks/blocking/pull_request_proof.py +86 -20
  16. package/hooks/blocking/test_artifact_look_gate.py +1 -0
  17. package/hooks/blocking/test_followup_pr_dedupe.py +33 -0
  18. package/hooks/blocking/test_image_read_size_gate.py +79 -0
  19. package/hooks/blocking/test_pr_lifecycle_skill_gate.py +117 -3
  20. package/hooks/blocking/test_pre_tool_use_dispatcher.py +1 -5
  21. package/hooks/blocking/test_pull_request_proof.py +124 -1
  22. package/hooks/features/README.md +1 -0
  23. package/hooks/features/conduct-gates.md +1 -1
  24. package/hooks/features/read-gates.md +20 -0
  25. package/hooks/features/write-edit-blocking.md +3 -5
  26. package/hooks/hooks.json +11 -1
  27. package/hooks/hooks_constants/artifact_look_gate_constants.py +1 -2
  28. package/hooks/hooks_constants/followup_pr_dedupe_constants.py +4 -0
  29. package/hooks/hooks_constants/image_read_size_gate_constants.py +41 -0
  30. package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +3 -0
  31. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -5
  32. package/hooks/hooks_constants/pull_request_proof_constants.py +20 -1
  33. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +4 -11
  34. package/hooks/hooks_constants/transcript_skill_scan_constants.py +1 -0
  35. package/hooks/test_transcript_skill_scan.py +75 -1
  36. package/hooks/transcript_skill_scan.py +87 -15
  37. package/package.json +1 -1
  38. package/rules/anti-corollary-tests.md +2 -2
  39. package/rules/archiving-agent-config.md +2 -2
  40. package/rules/asd-ste100-language.md +2 -2
  41. package/rules/bdd.md +1 -1
  42. package/rules/build-needs-a-user.md +1 -1
  43. package/rules/cleanup-temp-files.md +3 -3
  44. package/rules/code-standards.md +1 -1
  45. package/rules/correction-lens.md +3 -3
  46. package/rules/destructive-commands.md +3 -3
  47. package/rules/doc-inventory-integrity.md +1 -1
  48. package/rules/docstring-prose-matches-implementation.md +1 -1
  49. package/rules/explore-thoroughly.md +1 -1
  50. package/rules/failure-blast-radius.md +1 -1
  51. package/rules/falsify-before-green.md +2 -2
  52. package/rules/features-start-with-an-eval.md +9 -0
  53. package/rules/filesystem-search.md +3 -3
  54. package/rules/flag-non-breaking-findings.md +2 -2
  55. package/rules/long-horizon-autonomy.md +2 -2
  56. package/rules/memory-stores-durable-facts.md +2 -2
  57. package/rules/no-contrast-framing.md +1 -1
  58. package/rules/no-cross-skill-duplicate-helpers.md +1 -1
  59. package/rules/orphan-css-class.md +1 -1
  60. package/rules/paired-test-coverage.md +1 -1
  61. package/rules/plain-illustrative-docstrings.md +1 -1
  62. package/rules/prefer-existing-tools.md +2 -2
  63. package/rules/proof-before-pull-request.md +1 -1
  64. package/rules/request-scope.md +1 -1
  65. package/rules/research-mode.md +2 -2
  66. package/rules/retired-hook-prose.md +1 -1
  67. package/rules/session-title.md +1 -1
  68. package/rules/shell-invocation.md +2 -2
  69. package/rules/testing.md +1 -1
  70. package/rules/verify-before-asking.md +3 -3
  71. package/rules/verify-runtime-state.md +2 -2
  72. package/rules/windows-filesystem-safe.md +1 -1
  73. package/rules/workers-done-before-complete.md +2 -2
  74. package/rules/workflow-substitution-slots.md +1 -1
  75. package/scripts/account_broker.py +10 -3
  76. package/scripts/account_broker_support.py +7 -3
  77. package/scripts/agent_image_copy.py +155 -0
  78. package/scripts/claude_account_worker.py +24 -5
  79. package/scripts/claude_account_worker_process.py +5 -0
  80. package/scripts/claude_account_worker_report.py +16 -1
  81. package/scripts/dev_env_scripts_constants/agent_image_copy_constants.py +11 -0
  82. package/scripts/dev_env_scripts_constants/claude_account_worker_constants.py +12 -0
  83. package/scripts/policy_lint/config/constants.py +1 -0
  84. package/scripts/test_account_broker.py +38 -0
  85. package/scripts/test_account_broker_support.py +24 -0
  86. package/scripts/test_agent_image_copy.py +76 -0
  87. package/scripts/test_claude_account_worker.py +47 -0
  88. package/scripts/test_claude_account_worker_process.py +20 -0
  89. package/scripts/test_claude_account_worker_report.py +15 -0
  90. package/scripts/tests/test_policy_lint_rules_retired_hook_prose.py +4 -4
  91. package/scripts/tests/test_rule_load_scopes.py +1 -0
  92. package/hooks/advisory/conftest.py +0 -10
  93. package/hooks/advisory/refactor_guard.py +0 -385
  94. package/hooks/advisory/refactor_guard_test_support.py +0 -46
  95. package/hooks/advisory/test_refactor_guard.py +0 -148
  96. package/hooks/advisory/test_refactor_guard_advisory.py +0 -242
  97. package/hooks/advisory/test_refactor_guard_eligibility.py +0 -166
  98. package/hooks/hooks_constants/refactor_guard_constants.py +0 -77
  99. package/hooks/hooks_constants/test_refactor_guard_constants.py +0 -21
@@ -1,385 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Warn about refactors that reach beyond the current Edit change surface."""
3
-
4
- from __future__ import annotations
5
-
6
- import json
7
- import os
8
- import re
9
- import subprocess
10
- import sys
11
- from collections import Counter
12
- from pathlib import Path
13
-
14
- _subprocess_window_hooks_directory = str(Path(__file__).resolve().parents[2] / "hooks")
15
- if _subprocess_window_hooks_directory not in sys.path:
16
- sys.path.append(_subprocess_window_hooks_directory)
17
-
18
- from hooks_constants.subprocess_window import hidden_window_creation_flags
19
-
20
- try:
21
- hooks_root_directory = str(Path(__file__).resolve().parent.parent)
22
- if hooks_root_directory not in sys.path:
23
- sys.path.insert(0, hooks_root_directory)
24
-
25
- from hooks_constants.multi_edit_reconstruction import edits_for_tool
26
- from hooks_constants.refactor_guard_constants import (
27
- ALL_PYTHON_KEYWORDS,
28
- CHANGED_SURFACE_MATCH_RATIO,
29
- GIT_COMMAND_TIMEOUT_SECONDS,
30
- GIT_EXECUTABLE_TOKEN,
31
- MAXIMUM_REFACTOR_LINE_DELTA,
32
- REFACTOR_LINE_DELTA_DIVISOR,
33
- )
34
- except ImportError as import_error:
35
- raise ImportError(
36
- "refactor_guard: cannot import its sibling modules; "
37
- "ensure the hooks directory is importable."
38
- ) from import_error
39
-
40
-
41
- REFACTOR_BYPASS_TOKEN_PATH = Path.home() / ".claude" / ".refactor-bypass-token"
42
- identifier_join_separator = ", "
43
-
44
-
45
- def _git_query_context(file_path: str) -> tuple[str, str]:
46
- """Keep lexical path ownership while Git resolves repository metadata."""
47
- target_path = Path(file_path).absolute()
48
- return str(target_path.parent), str(target_path)
49
-
50
-
51
- def _git_environment() -> dict[str, str]:
52
- return {
53
- each_environment_name: each_environment_value
54
- for each_environment_name, each_environment_value in os.environ.items()
55
- if not each_environment_name.startswith("GIT_")
56
- }
57
-
58
-
59
- def _read_added_lines_from_git(all_git_arguments: tuple[str, ...], file_path: str) -> list[str]:
60
- """Return added lines from one Git diff command."""
61
- working_directory, absolute_file_path = _git_query_context(file_path)
62
- try:
63
- completed_process = subprocess.run(
64
- [*all_git_arguments, "--", absolute_file_path],
65
- check=False,
66
- capture_output=True,
67
- text=True,
68
- timeout=GIT_COMMAND_TIMEOUT_SECONDS,
69
- cwd=working_directory,
70
- env=_git_environment(),
71
- creationflags=hidden_window_creation_flags(),
72
- )
73
- except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
74
- return []
75
- if completed_process.returncode != 0:
76
- return []
77
- return [
78
- each_line[1:].strip()
79
- for each_line in completed_process.stdout.splitlines()
80
- if each_line.startswith("+") and not each_line.startswith("+++")
81
- ]
82
-
83
-
84
- def _get_added_line_occurrences(file_path: str) -> list[str]:
85
- all_added_lines = _read_added_lines_from_git((GIT_EXECUTABLE_TOKEN, "diff"), file_path)
86
- all_added_lines.extend(
87
- _read_added_lines_from_git((GIT_EXECUTABLE_TOKEN, "diff", "--cached"), file_path)
88
- )
89
- return all_added_lines
90
-
91
-
92
- def get_git_diff_added_lines(file_path: str) -> set[str]:
93
- """Return staged and unstaged added lines for a tracked file."""
94
- return set(_get_added_line_occurrences(file_path))
95
-
96
-
97
- def is_new_file(file_path: str) -> bool:
98
- """Return whether Git reports the file as untracked."""
99
- working_directory, absolute_file_path = _git_query_context(file_path)
100
- try:
101
- completed_process = subprocess.run(
102
- [
103
- GIT_EXECUTABLE_TOKEN,
104
- "ls-files",
105
- "--others",
106
- "--exclude-standard",
107
- "--",
108
- absolute_file_path,
109
- ],
110
- check=False,
111
- capture_output=True,
112
- text=True,
113
- timeout=GIT_COMMAND_TIMEOUT_SECONDS,
114
- cwd=working_directory,
115
- env=_git_environment(),
116
- creationflags=hidden_window_creation_flags(),
117
- )
118
- except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
119
- return False
120
- return completed_process.returncode == 0 and bool(completed_process.stdout.strip())
121
-
122
-
123
- def is_hook_infrastructure(file_path: str) -> bool:
124
- """Return whether a path belongs to the installed Claude hook tree."""
125
- normalized_path = file_path.lower().replace("\\", "/")
126
- return "/.claude/" in normalized_path
127
-
128
-
129
- def extract_identifiers(code: str) -> set[str]:
130
- """Return meaningful Python identifiers from a code fragment."""
131
- all_identifiers = set(re.findall(r"\b([a-zA-Z_][a-zA-Z0-9_]{2,})\b", code))
132
- return all_identifiers - ALL_PYTHON_KEYWORDS
133
-
134
-
135
- def _have_similar_words(old_identifier: str, new_identifier: str) -> bool:
136
- all_old_words = set(re.findall(r"[a-z]+|[A-Z][a-z]*", old_identifier))
137
- all_new_words = set(re.findall(r"[a-z]+|[A-Z][a-z]*", new_identifier))
138
- return (
139
- bool(all_old_words and all_new_words)
140
- and len(all_old_words & all_new_words) >= len(all_old_words) * CHANGED_SURFACE_MATCH_RATIO
141
- )
142
-
143
-
144
- def _describe_identifier_changes(
145
- all_removed_identifiers: set[str], all_added_identifiers: set[str]
146
- ) -> str | None:
147
- all_renamed_identifiers: list[str] = []
148
- for each_old_identifier in sorted(all_removed_identifiers):
149
- for each_new_identifier in sorted(all_added_identifiers):
150
- is_same_spelling = each_old_identifier.lower().replace(
151
- "_", ""
152
- ) == each_new_identifier.lower().replace("_", "")
153
- if is_same_spelling or _have_similar_words(each_old_identifier, each_new_identifier):
154
- all_renamed_identifiers.append(f"{each_old_identifier} -> {each_new_identifier}")
155
- break
156
- if all_renamed_identifiers:
157
- return f"Renaming detected: {identifier_join_separator.join(all_renamed_identifiers[:3])}"
158
- if len(all_removed_identifiers) > 1 and len(all_added_identifiers) > 1:
159
- return (
160
- "Multiple identifiers changed with same structure: "
161
- f"removed {sorted(all_removed_identifiers)[:3]}, "
162
- f"added {sorted(all_added_identifiers)[:3]}"
163
- )
164
- return None
165
-
166
-
167
- def is_refactor_edit(old_string: str, new_string: str) -> str | None:
168
- """Return a description when an edit preserves structure and changes names."""
169
- all_old_lines = [
170
- each_line.strip() for each_line in old_string.strip().splitlines() if each_line.strip()
171
- ]
172
- all_new_lines = [
173
- each_line.strip() for each_line in new_string.strip().splitlines() if each_line.strip()
174
- ]
175
- if not all_old_lines or not all_new_lines:
176
- return None
177
- if abs(len(all_old_lines) - len(all_new_lines)) > max(
178
- len(all_old_lines) // REFACTOR_LINE_DELTA_DIVISOR, MAXIMUM_REFACTOR_LINE_DELTA
179
- ):
180
- return None
181
- all_old_identifiers = extract_identifiers(old_string)
182
- all_new_identifiers = extract_identifiers(new_string)
183
- all_removed_identifiers = all_old_identifiers - all_new_identifiers
184
- all_added_identifiers = all_new_identifiers - all_old_identifiers
185
- if not all_removed_identifiers or not all_added_identifiers:
186
- return None
187
- all_identifiers = all_old_identifiers | all_new_identifiers
188
- old_structure = re.sub(r"\s+", " ", _replace_identifiers(old_string, all_identifiers).strip())
189
- new_structure = re.sub(r"\s+", " ", _replace_identifiers(new_string, all_identifiers).strip())
190
- if old_structure != new_structure:
191
- return None
192
- return _describe_identifier_changes(all_removed_identifiers, all_added_identifiers)
193
-
194
-
195
- def _replace_identifiers(code: str, all_identifiers: set[str]) -> str:
196
- normalized_code = code
197
- for each_identifier in all_identifiers:
198
- normalized_code = normalized_code.replace(each_identifier, "ID")
199
- return normalized_code
200
-
201
-
202
- def _nonempty_stripped_lines(text: str) -> list[str]:
203
- return [each_line.strip() for each_line in text.splitlines() if each_line.strip()]
204
-
205
-
206
- def _lines_within_changed_surface(old_string: str, all_added_lines: list[str]) -> bool:
207
- """Return whether at least half of the edited lines are current additions."""
208
- all_old_lines = _nonempty_stripped_lines(old_string)
209
- if not all_old_lines or not all_added_lines:
210
- return False
211
- all_old_line_counts = Counter(all_old_lines)
212
- all_added_line_counts = Counter(all_added_lines)
213
- matched_occurrence_count = sum(
214
- min(each_count, all_added_line_counts[each_line])
215
- for each_line, each_count in all_old_line_counts.items()
216
- )
217
- edited_occurrence_count = sum(all_old_line_counts.values())
218
- return matched_occurrence_count / edited_occurrence_count >= CHANGED_SURFACE_MATCH_RATIO
219
-
220
-
221
- def is_edit_within_changed_surface(file_path: str, old_string: str) -> bool:
222
- """Return whether at least half of the edited lines are current additions."""
223
- all_added_lines = _get_added_line_occurrences(file_path)
224
- return _lines_within_changed_surface(old_string, all_added_lines)
225
-
226
-
227
- def _is_existing_edit_target(file_path: str) -> bool:
228
- if not file_path or is_hook_infrastructure(file_path):
229
- return False
230
- return not is_new_file(file_path)
231
-
232
-
233
- def find_refactor_advisory_description(
234
- file_path: str, old_string: str, new_string: str
235
- ) -> str | None:
236
- """Return an advisory description for a refactor outside changed lines."""
237
- if not _is_existing_edit_target(file_path):
238
- return None
239
- refactor_description = is_refactor_edit(old_string, new_string)
240
- if refactor_description is None:
241
- return None
242
- if is_edit_within_changed_surface(file_path, old_string):
243
- return None
244
- return refactor_description
245
-
246
-
247
- def is_refactor_eligible(file_path: str, old_string: str, new_string: str) -> bool:
248
- """Return whether an Edit is eligible for the refactor advisory."""
249
- return find_refactor_advisory_description(file_path, old_string, new_string) is not None
250
-
251
-
252
- def is_bypass_approved() -> bool:
253
- """Consume the one-use bypass token when the user approved a refactor."""
254
- if not REFACTOR_BYPASS_TOKEN_PATH.exists():
255
- return False
256
- try:
257
- REFACTOR_BYPASS_TOKEN_PATH.unlink()
258
- except OSError:
259
- return False
260
- return True
261
-
262
-
263
- def build_refactor_advisory_context(refactor_description: str, file_path: str) -> str:
264
- """Build guidance that names the Edit stage and changed-surface rule."""
265
- return (
266
- f"[HOOK ADVISORY] Refactor guard — {refactor_description} in {file_path}. "
267
- "Edit-stage guidance: This edit went through, and it changes lines outside the "
268
- "current git diff. Keep further edits to lines the task already changes, and ask "
269
- "the user before refactoring code outside them."
270
- )
271
-
272
-
273
- def build_refactor_advisory_payload(refactor_description: str, file_path: str) -> dict[str, object]:
274
- """Build the standalone allow payload used by the Edit advisory hook."""
275
- advisory_context = build_refactor_advisory_context(refactor_description, file_path)
276
- return {
277
- "systemMessage": advisory_context,
278
- "hookSpecificOutput": {
279
- "hookEventName": "PreToolUse",
280
- "permissionDecision": "allow",
281
- "additionalContext": advisory_context,
282
- },
283
- }
284
-
285
-
286
- def _read_hook_input() -> dict[str, object] | None:
287
- try:
288
- parsed_input = json.load(sys.stdin)
289
- except (json.JSONDecodeError, TypeError):
290
- return None
291
- if not isinstance(parsed_input, dict):
292
- return None
293
- return parsed_input
294
-
295
-
296
- def _read_edit_fields(payload_by_key: dict[str, object]) -> tuple[str, str, str, str]:
297
- raw_tool_name = payload_by_key.get("tool_name")
298
- tool_name = raw_tool_name if isinstance(raw_tool_name, str) else ""
299
- raw_tool_input = payload_by_key.get("tool_input")
300
- if not isinstance(raw_tool_input, dict):
301
- return tool_name, "", "", ""
302
- file_path_field = raw_tool_input.get("file_path")
303
- old_string_field = raw_tool_input.get("old_string")
304
- new_string_field = raw_tool_input.get("new_string")
305
- if not isinstance(file_path_field, str):
306
- return tool_name, "", "", ""
307
- if not isinstance(old_string_field, str):
308
- return tool_name, "", "", ""
309
- if not isinstance(new_string_field, str):
310
- return tool_name, "", "", ""
311
- return tool_name, file_path_field, old_string_field, new_string_field
312
-
313
-
314
- def _refactor_advisory_description_within_surface(
315
- all_added_lines: list[str],
316
- old_string: str,
317
- new_string: str,
318
- ) -> str | None:
319
- """Return an advisory description for one edit, given the file's added lines."""
320
- refactor_description = is_refactor_edit(old_string, new_string)
321
- if refactor_description is None:
322
- return None
323
- if _lines_within_changed_surface(old_string, all_added_lines):
324
- return None
325
- return refactor_description
326
-
327
-
328
- def _multi_edit_refactor_advisory_description(
329
- payload_by_key: dict[str, object],
330
- ) -> tuple[str, str] | None:
331
- """Return the (file_path, description) pair for the first eligible edit in a MultiEdit."""
332
- raw_tool_input = payload_by_key.get("tool_input")
333
- if not isinstance(raw_tool_input, dict):
334
- return None
335
- file_path_field = raw_tool_input.get("file_path")
336
- file_path = file_path_field if isinstance(file_path_field, str) else ""
337
- if not file_path:
338
- return None
339
- if not _is_existing_edit_target(file_path):
340
- return None
341
- all_added_lines = _get_added_line_occurrences(file_path)
342
- for each_edit in edits_for_tool("MultiEdit", raw_tool_input):
343
- if not isinstance(each_edit, dict):
344
- continue
345
- old_string_field = each_edit.get("old_string")
346
- new_string_field = each_edit.get("new_string")
347
- if not isinstance(old_string_field, str) or not isinstance(new_string_field, str):
348
- continue
349
- refactor_description = _refactor_advisory_description_within_surface(
350
- all_added_lines, old_string_field, new_string_field
351
- )
352
- if refactor_description is not None:
353
- return file_path, refactor_description
354
- return None
355
-
356
-
357
- def _emit_advisory(file_path: str, refactor_description: str) -> None:
358
- advisory_payload = build_refactor_advisory_payload(refactor_description, file_path)
359
- sys.stdout.write(json.dumps(advisory_payload))
360
- sys.stdout.flush()
361
-
362
-
363
- def main() -> None:
364
- """Emit an Edit-stage advisory for eligible out-of-surface refactors."""
365
- payload_by_key = _read_hook_input()
366
- if payload_by_key is None:
367
- return
368
- tool_name, file_path, old_string, new_string = _read_edit_fields(payload_by_key)
369
- if is_bypass_approved():
370
- return
371
- if tool_name == "MultiEdit":
372
- multi_edit_result = _multi_edit_refactor_advisory_description(payload_by_key)
373
- if multi_edit_result is not None:
374
- _emit_advisory(*multi_edit_result)
375
- return
376
- if tool_name != "Edit":
377
- return
378
- refactor_description = find_refactor_advisory_description(file_path, old_string, new_string)
379
- if refactor_description is None:
380
- return
381
- _emit_advisory(file_path, refactor_description)
382
-
383
-
384
- if __name__ == "__main__":
385
- main()
@@ -1,46 +0,0 @@
1
- """Shared temporary-repository support for refactor guard tests."""
2
-
3
- from __future__ import annotations
4
-
5
- import subprocess
6
- from collections.abc import Generator
7
- from pathlib import Path
8
-
9
- import pytest
10
-
11
-
12
- @pytest.fixture
13
- def git_repository(tmp_path: Path) -> Generator[Path]:
14
- """Create a committed temporary repository for refactor guard tests."""
15
- repository_path = tmp_path / "repository"
16
- repository_path.mkdir()
17
- subprocess.run(["git", "init", "-q"], cwd=repository_path, check=True)
18
- yield repository_path
19
-
20
-
21
- def commit_file(repository_path: Path, file_path: Path, file_content: str) -> None:
22
- """Write and commit one file in a temporary repository."""
23
- file_path.write_text(file_content, encoding="utf-8")
24
- subprocess.run(["git", "add", str(file_path)], cwd=repository_path, check=True)
25
- subprocess.run(
26
- [
27
- "git",
28
- "-c",
29
- "user.name=Refactor Guard Test",
30
- "-c",
31
- "user.email=refactor-guard@example.invalid",
32
- "commit",
33
- "-q",
34
- "-m",
35
- "baseline",
36
- "--no-verify",
37
- ],
38
- cwd=repository_path,
39
- check=True,
40
- )
41
-
42
-
43
- def stage_file(repository_path: Path, file_path: Path, file_content: str) -> None:
44
- """Write and stage one file in a temporary repository."""
45
- file_path.write_text(file_content, encoding="utf-8")
46
- subprocess.run(["git", "add", str(file_path)], cwd=repository_path, check=True)
@@ -1,148 +0,0 @@
1
- """Unit tests for refactor_guard's MultiEdit reconstruction, run in-process.
2
-
3
- test_refactor_guard_advisory.py and test_refactor_guard_eligibility.py already
4
- cover this hook end to end through real git fixtures; these tests exercise
5
- ``_multi_edit_refactor_advisory_description`` directly against a target path
6
- outside any git repository, where the underlying git commands fail closed and
7
- return no added lines, so a qualifying rename is eligible without fixture setup.
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- import subprocess
13
- import sys
14
- from pathlib import Path
15
-
16
- import pytest
17
-
18
- try:
19
- _ADVISORY_DIRECTORY = Path(__file__).resolve().parent
20
- if str(_ADVISORY_DIRECTORY) not in sys.path:
21
- sys.path.insert(0, str(_ADVISORY_DIRECTORY))
22
-
23
- import refactor_guard
24
- except ImportError as import_error:
25
- raise ImportError(
26
- "test_refactor_guard: cannot import its sibling modules; "
27
- "ensure the advisory directory is importable."
28
- ) from import_error
29
-
30
-
31
- WINDOWS_NO_WINDOW_FLAG = 0x08000000
32
- EXPECTED_HIDDEN_WINDOW_FLAGS = WINDOWS_NO_WINDOW_FLAG if sys.platform == "win32" else 0
33
-
34
-
35
- def _multi_edit_payload(file_path: str, all_edits: list[dict[str, str]]) -> dict[str, object]:
36
- return {
37
- "tool_name": "MultiEdit",
38
- "tool_input": {"file_path": file_path, "edits": all_edits},
39
- }
40
-
41
-
42
- def test_multi_edit_reports_the_first_eligible_rename(tmp_path: Path) -> None:
43
- """A rename in the second edit is reported, even though the first edit is ordinary."""
44
- target_path = str(tmp_path / "module.py")
45
- payload = _multi_edit_payload(
46
- target_path,
47
- [
48
- {"old_string": "return amount", "new_string": "return amount + 1"},
49
- {
50
- "old_string": "def calculate_total(amount):\n return amount",
51
- "new_string": "def compute_total(amount):\n return amount",
52
- },
53
- ],
54
- )
55
-
56
- result = refactor_guard._multi_edit_refactor_advisory_description(payload)
57
-
58
- assert result is not None
59
- reported_path, description = result
60
- assert reported_path == target_path
61
- assert "calculate_total" in description
62
- assert "compute_total" in description
63
-
64
-
65
- def test_multi_edit_scans_past_two_ordinary_edits_to_the_third(tmp_path: Path) -> None:
66
- """The loop over edit pairs reaches the third pair, not only the first two."""
67
- target_path = str(tmp_path / "module.py")
68
- payload = _multi_edit_payload(
69
- target_path,
70
- [
71
- {"old_string": "return amount", "new_string": "return amount + 1"},
72
- {"old_string": "return amount + 1", "new_string": "return amount + 2"},
73
- {
74
- "old_string": "def calculate_total(amount):\n return amount + 2",
75
- "new_string": "def compute_total(amount):\n return amount + 2",
76
- },
77
- ],
78
- )
79
-
80
- result = refactor_guard._multi_edit_refactor_advisory_description(payload)
81
-
82
- assert result is not None
83
- _reported_path, description = result
84
- assert "compute_total" in description
85
-
86
-
87
- def test_multi_edit_returns_none_when_no_edit_qualifies(tmp_path: Path) -> None:
88
- target_path = str(tmp_path / "module.py")
89
- payload = _multi_edit_payload(
90
- target_path,
91
- [{"old_string": "return amount", "new_string": "return amount + 1"}],
92
- )
93
-
94
- assert refactor_guard._multi_edit_refactor_advisory_description(payload) is None
95
-
96
-
97
- def _record_creation_flags(
98
- monkeypatch: pytest.MonkeyPatch, standard_output: str
99
- ) -> dict[str, object]:
100
- all_recorded_keyword_arguments: dict[str, object] = {}
101
-
102
- def _record(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]:
103
- del args
104
- all_recorded_keyword_arguments.update(kwargs)
105
- return subprocess.CompletedProcess([], 0, stdout=standard_output, stderr="")
106
-
107
- monkeypatch.setattr(refactor_guard.subprocess, "run", _record)
108
- return all_recorded_keyword_arguments
109
-
110
-
111
- def test_git_diff_should_start_without_a_console_window(
112
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
113
- ) -> None:
114
- all_recorded_keyword_arguments = _record_creation_flags(monkeypatch, "+added line\n")
115
-
116
- refactor_guard.get_git_diff_added_lines(str(tmp_path / "module.py"))
117
-
118
- assert all_recorded_keyword_arguments["creationflags"] == EXPECTED_HIDDEN_WINDOW_FLAGS
119
-
120
-
121
- def test_untracked_probe_should_start_without_a_console_window(
122
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
123
- ) -> None:
124
- all_recorded_keyword_arguments = _record_creation_flags(monkeypatch, "module.py\n")
125
-
126
- assert refactor_guard.is_new_file(str(tmp_path / "module.py")) is True
127
- assert all_recorded_keyword_arguments["creationflags"] == EXPECTED_HIDDEN_WINDOW_FLAGS
128
-
129
-
130
- def test_the_git_query_carries_the_advisory_s_own_executable_and_timeout(
131
- tmp_path: Path, monkeypatch: pytest.MonkeyPatch
132
- ) -> None:
133
- """refactor_guard_constants supplies both names the git call is built from."""
134
- all_calls: list[tuple[list[str], object]] = []
135
-
136
- def record_call(
137
- command: list[str], **keyword_arguments: object
138
- ) -> subprocess.CompletedProcess[str]:
139
- all_calls.append((command, keyword_arguments["timeout"]))
140
- return subprocess.CompletedProcess(command, 1, "", "")
141
-
142
- monkeypatch.setattr(refactor_guard.subprocess, "run", record_call)
143
-
144
- refactor_guard.is_new_file(str(tmp_path / "module.py"))
145
-
146
- command, timeout = all_calls[0]
147
- assert command[:2] == ["git", "ls-files"]
148
- assert timeout == refactor_guard.GIT_COMMAND_TIMEOUT_SECONDS