claude-dev-env 2.19.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 (48) hide show
  1. package/.agents/skills/_shared/pr-loop/preflight-proposal.contract.test.mjs +31 -1
  2. package/.agents/skills/e-code-review/SKILL.md +12 -1
  3. package/.agents/skills/e-code-review/reference/fix.md +5 -1
  4. package/.agents/skills/e-code-review/reference/loop.md +4 -0
  5. package/.agents/skills/e-code-review/reference/mode-contract.test.mjs +66 -0
  6. package/.agents/skills/e-code-review/reference/preflight-proposal.md +40 -0
  7. package/.agents/skills/e-code-review/reference/runner-selection.md +1 -0
  8. package/.agents/skills/pr-cleanup/SKILL.md +109 -11
  9. package/_shared/pr-loop/scripts/code_rules_gate.py +29 -6
  10. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +15 -3
  11. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +4 -0
  12. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +47 -0
  13. package/docs/CODE_RULES.md +2 -0
  14. package/hooks/advisory/conftest.py +10 -0
  15. package/hooks/advisory/refactor_guard.py +250 -144
  16. package/hooks/advisory/refactor_guard_test_support.py +46 -0
  17. package/hooks/advisory/test_refactor_guard_advisory.py +171 -0
  18. package/hooks/advisory/test_refactor_guard_eligibility.py +166 -0
  19. package/hooks/blocking/block_main_commit.py +66 -33
  20. package/hooks/blocking/code_rules_blast_radius.py +194 -0
  21. package/hooks/blocking/code_rules_enforcer.py +12 -0
  22. package/hooks/blocking/test_block_main_commit.py +145 -0
  23. package/hooks/blocking/test_code_rules_blast_radius.py +161 -0
  24. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +1 -0
  25. package/hooks/blocking/test_destructive_command_blocker.py +154 -138
  26. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +52 -9
  27. package/hooks/blocking/test_destructive_command_blocker_patterns.py +133 -0
  28. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +71 -5
  29. package/hooks/git-hooks/AGENTS.md +1 -1
  30. package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -0
  31. package/hooks/git-hooks/post_commit.py +160 -51
  32. package/hooks/git-hooks/pre_commit.py +3 -3
  33. package/hooks/git-hooks/test_post_commit.py +203 -0
  34. package/hooks/git-hooks/test_pre_commit.py +2 -2
  35. package/hooks/hooks_constants/blast_radius_constants.py +14 -0
  36. package/hooks/hooks_constants/refactor_guard_constants.py +75 -0
  37. package/hooks/hooks_constants/test_refactor_guard_constants.py +21 -0
  38. package/hooks/observability/test_instructions_loaded_logger.py +54 -0
  39. package/hooks/session/test_plugin_data_dir_cleanup.py +70 -0
  40. package/hooks/session/test_session_edit_tracker_cleanup.py +16 -3
  41. package/hooks/validation/mypy_validator.py +213 -80
  42. package/hooks/validation/test_mypy_validator.py +288 -13
  43. package/hooks/workflow/auto_formatter.py +225 -93
  44. package/hooks/workflow/investigation_tracker_reset.py +2 -0
  45. package/hooks/workflow/test_auto_formatter.py +261 -12
  46. package/hooks/workflow/test_investigation_tracker_reset.py +90 -0
  47. package/package.json +1 -1
  48. package/rules/failure-blast-radius.md +126 -0
@@ -38,6 +38,11 @@ LOOSE_TOOL_MYPY_PYPROJECT = "[tool.mypy]\nignore_missing_imports = true\n"
38
38
  STRICT_TOOL_MYPY_PYPROJECT = (
39
39
  "[tool.mypy]\nignore_missing_imports = true\ndisallow_untyped_defs = true\n"
40
40
  )
41
+ SAME_SIZE_LOOSE_TOOL_MYPY_PYPROJECT = "[tool.mypy]\ndisallow_any_generics = false\n"
42
+ SAME_SIZE_STRICT_TOOL_MYPY_PYPROJECT = "[tool.mypy]\ndisallow_any_generics = true \n"
43
+ UNCHECKED_GENERIC_MODULE_SOURCE = "values: list = []\n"
44
+ SAME_SIZE_NON_MYPY_PYPROJECT = "[tool.ruff]\nline-length = 100\n#123456789\n"
45
+ SAME_SIZE_STRICT_MYPY_PYPROJECT = "[tool.mypy]\ndisallow_untyped_defs = true\n"
41
46
 
42
47
 
43
48
  def _load_validator() -> ModuleType:
@@ -83,6 +88,95 @@ def test_discover_mypy_config_returns_none_without_tool_mypy(tmp_path: Path) ->
83
88
  assert validator.discover_mypy_config(standalone_module) is None
84
89
 
85
90
 
91
+ def test_discover_mypy_config_refreshes_after_pyproject_changes(tmp_path: Path) -> None:
92
+ validator = _load_validator()
93
+ nested_module = tmp_path / "package" / "module.py"
94
+ nested_module.parent.mkdir(parents=True)
95
+ nested_module.write_text(CLEAN_MODULE_SOURCE, encoding="utf-8")
96
+
97
+ assert validator.discover_mypy_config(nested_module) is None
98
+
99
+ config_path = tmp_path / "pyproject.toml"
100
+ config_path.write_text(TOOL_MYPY_PYPROJECT, encoding="utf-8")
101
+ discovered_config = validator.discover_mypy_config(nested_module)
102
+
103
+ assert discovered_config is not None
104
+ assert discovered_config.resolve() == config_path.resolve()
105
+
106
+ config_path.unlink()
107
+
108
+ assert validator.discover_mypy_config(nested_module) is None
109
+
110
+
111
+ def test_persisted_config_cache_refreshes_after_pyproject_changes(tmp_path: Path) -> None:
112
+ nested_module = tmp_path / "package" / "module.py"
113
+ nested_module.parent.mkdir(parents=True)
114
+ nested_module.write_text(CLEAN_MODULE_SOURCE, encoding="utf-8")
115
+
116
+ first_validator = _load_validator()
117
+ assert first_validator.discover_mypy_config(nested_module) is None
118
+
119
+ config_path = tmp_path / "pyproject.toml"
120
+ config_path.write_text(TOOL_MYPY_PYPROJECT, encoding="utf-8")
121
+
122
+ second_validator = _load_validator()
123
+ discovered_config = second_validator.discover_mypy_config(nested_module)
124
+
125
+ assert discovered_config is not None
126
+ assert discovered_config.resolve() == config_path.resolve()
127
+
128
+
129
+ def test_persisted_cache_reuses_metadata_and_refreshes_once_after_config_change(
130
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
131
+ ) -> None:
132
+ project_root = tmp_path / "project"
133
+ project_root.mkdir()
134
+ config_path = project_root / "pyproject.toml"
135
+ config_path.write_text(LOOSE_TOOL_MYPY_PYPROJECT, encoding="utf-8")
136
+ target_module = project_root / "module.py"
137
+ target_module.write_text(CLEAN_MODULE_SOURCE, encoding="utf-8")
138
+
139
+ first_validator = _load_validator()
140
+ assert first_validator.discover_mypy_config(target_module) == config_path
141
+
142
+ second_validator = _load_validator()
143
+ unchanged_walk_call_count = 0
144
+ real_ancestor_directories = second_validator.ancestor_directories
145
+
146
+ def _count_unchanged_ancestor_walks(starting_file: Path) -> list[Path]:
147
+ nonlocal unchanged_walk_call_count
148
+ unchanged_walk_call_count += 1
149
+ return real_ancestor_directories(starting_file)
150
+
151
+ monkeypatch.setattr(
152
+ second_validator,
153
+ "ancestor_directories",
154
+ _count_unchanged_ancestor_walks,
155
+ )
156
+
157
+ assert second_validator.discover_mypy_config(target_module) == config_path
158
+ assert unchanged_walk_call_count == 0
159
+
160
+ config_path.write_text(STRICT_TOOL_MYPY_PYPROJECT, encoding="utf-8")
161
+ third_validator = _load_validator()
162
+ changed_walk_call_count = 0
163
+ real_changed_walk = third_validator.find_pyproject_with_mypy_config
164
+
165
+ def _count_changed_config_walks(starting_file: Path) -> Path | None:
166
+ nonlocal changed_walk_call_count
167
+ changed_walk_call_count += 1
168
+ return real_changed_walk(starting_file)
169
+
170
+ monkeypatch.setattr(
171
+ third_validator,
172
+ "find_pyproject_with_mypy_config",
173
+ _count_changed_config_walks,
174
+ )
175
+
176
+ assert third_validator.discover_mypy_config(target_module) == config_path
177
+ assert changed_walk_call_count == 1
178
+
179
+
86
180
  def test_build_mypy_command_includes_config_file_when_present(tmp_path: Path) -> None:
87
181
  validator = _load_validator()
88
182
  config_file = tmp_path / "pyproject.toml"
@@ -157,6 +251,43 @@ def test_config_walk_runs_once_per_root_across_two_edits(
157
251
  assert walk_count_after_second_edit == walk_count_after_first_edit
158
252
 
159
253
 
254
+ def test_warm_config_cache_uses_metadata_and_config_digest_without_ancestor_walk(
255
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
256
+ ) -> None:
257
+ validator = _load_validator()
258
+ project_root = tmp_path / "project"
259
+ project_root.mkdir()
260
+ config_path = project_root / "pyproject.toml"
261
+ config_path.write_text(TOOL_MYPY_PYPROJECT, encoding="utf-8")
262
+ target_module = project_root / "module.py"
263
+ target_module.write_text(CLEAN_MODULE_SOURCE, encoding="utf-8")
264
+
265
+ assert validator.discover_mypy_config(target_module) == config_path
266
+
267
+ ancestor_walk_call_count = 0
268
+ config_read_call_count = 0
269
+ real_ancestor_directories = validator.ancestor_directories
270
+ real_read_bytes = Path.read_bytes
271
+
272
+ def _counting_ancestor_directories(starting_file: Path) -> list[Path]:
273
+ nonlocal ancestor_walk_call_count
274
+ ancestor_walk_call_count += 1
275
+ return real_ancestor_directories(starting_file)
276
+
277
+ def _counting_read_bytes(file_path: Path) -> bytes:
278
+ nonlocal config_read_call_count
279
+ if file_path == config_path:
280
+ config_read_call_count += 1
281
+ return real_read_bytes(file_path)
282
+
283
+ monkeypatch.setattr(validator, "ancestor_directories", _counting_ancestor_directories)
284
+ monkeypatch.setattr(Path, "read_bytes", _counting_read_bytes)
285
+
286
+ assert validator.discover_mypy_config(target_module) == config_path
287
+ assert ancestor_walk_call_count == 0
288
+ assert config_read_call_count == 1
289
+
290
+
160
291
  def test_sibling_subtrees_each_resolve_their_own_nested_config(
161
292
  tmp_path: Path,
162
293
  ) -> None:
@@ -194,15 +325,11 @@ def test_warm_cache_still_blocks_file_edited_to_introduce_type_error(
194
325
  edited_module = project_root / "edited.py"
195
326
  edited_module.write_text(CLEAN_MODULE_SOURCE, encoding="utf-8")
196
327
 
197
- clean_exit_code, _clean_output = validator.run_mypy(
198
- str(edited_module), str(project_root)
199
- )
328
+ clean_exit_code, _clean_output = validator.run_mypy(str(edited_module), str(project_root))
200
329
  assert clean_exit_code == 0
201
330
 
202
331
  edited_module.write_text(TYPE_ERROR_MODULE_SOURCE, encoding="utf-8")
203
- error_exit_code, error_output = validator.run_mypy(
204
- str(edited_module), str(project_root)
205
- )
332
+ error_exit_code, error_output = validator.run_mypy(str(edited_module), str(project_root))
206
333
 
207
334
  assert error_exit_code != 0
208
335
  assert ": error:" in error_output
@@ -230,9 +357,7 @@ def test_warm_cache_skips_mypy_run_when_content_unchanged(
230
357
 
231
358
  monkeypatch.setattr(validator.subprocess, "run", _counting_subprocess_run)
232
359
 
233
- second_exit_code, _second_output = validator.run_mypy(
234
- str(unchanged_module), str(project_root)
235
- )
360
+ second_exit_code, _second_output = validator.run_mypy(str(unchanged_module), str(project_root))
236
361
 
237
362
  assert second_exit_code == 0
238
363
  assert subprocess_run_call_count == 0
@@ -250,13 +375,10 @@ def test_content_hash_skip_invalidated_when_mypy_config_tightens(
250
375
  checked_module = project_root / "checked.py"
251
376
  checked_module.write_text(UNTYPED_DEF_MODULE_SOURCE, encoding="utf-8")
252
377
 
253
- loose_exit_code, _loose_output = validator.run_mypy(
254
- str(checked_module), str(project_root)
255
- )
378
+ loose_exit_code, _loose_output = validator.run_mypy(str(checked_module), str(project_root))
256
379
  assert loose_exit_code == 0
257
380
 
258
381
  config_path.write_text(STRICT_TOOL_MYPY_PYPROJECT, encoding="utf-8")
259
- validator.reset_session_config_cache()
260
382
 
261
383
  subprocess_run_call_count = 0
262
384
  real_subprocess_run = validator.subprocess.run
@@ -279,6 +401,159 @@ def test_content_hash_skip_invalidated_when_mypy_config_tightens(
279
401
  assert ": error:" in tightened_output
280
402
 
281
403
 
404
+ def test_identical_config_relocation_invalidates_content_hash(
405
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
406
+ ) -> None:
407
+ validator = _load_validator()
408
+ project_root = tmp_path / "project"
409
+ package_directory = project_root / "package"
410
+ package_directory.mkdir(parents=True)
411
+ nested_config_path = package_directory / "pyproject.toml"
412
+ root_config_path = project_root / "pyproject.toml"
413
+ nested_config_path.write_text(TOOL_MYPY_PYPROJECT, encoding="utf-8")
414
+ checked_module = package_directory / "checked.py"
415
+ checked_module.write_text(CLEAN_MODULE_SOURCE, encoding="utf-8")
416
+
417
+ initial_config = validator.discover_mypy_config(checked_module)
418
+ assert initial_config == nested_config_path
419
+ initial_exit_code, _initial_output = validator.run_mypy(str(checked_module), str(project_root))
420
+ assert initial_exit_code == 0
421
+
422
+ nested_config_path.replace(root_config_path)
423
+
424
+ subprocess_run_call_count = 0
425
+ real_subprocess_run = validator.subprocess.run
426
+
427
+ def _counting_subprocess_run(*positional: object, **keyword: object) -> object:
428
+ nonlocal subprocess_run_call_count
429
+ subprocess_run_call_count += 1
430
+ return real_subprocess_run(*positional, **keyword)
431
+
432
+ monkeypatch.setattr(validator.subprocess, "run", _counting_subprocess_run)
433
+
434
+ relocated_config = validator.discover_mypy_config(checked_module)
435
+ relocated_exit_code, relocated_output = validator.run_mypy(
436
+ str(checked_module), str(project_root)
437
+ )
438
+
439
+ assert relocated_config == root_config_path
440
+ assert subprocess_run_call_count == 1
441
+ assert relocated_exit_code == 0, relocated_output
442
+
443
+
444
+ def test_same_stat_config_rewrite_runs_mypy_and_reports_strict_error(
445
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
446
+ ) -> None:
447
+ assert len(SAME_SIZE_LOOSE_TOOL_MYPY_PYPROJECT) == len(SAME_SIZE_STRICT_TOOL_MYPY_PYPROJECT)
448
+ validator = _load_validator()
449
+ project_root = tmp_path / "project"
450
+ project_root.mkdir()
451
+ config_path = project_root / "pyproject.toml"
452
+ config_path.write_text(SAME_SIZE_LOOSE_TOOL_MYPY_PYPROJECT, encoding="utf-8")
453
+ checked_module = project_root / "checked.py"
454
+ checked_module.write_text(UNCHECKED_GENERIC_MODULE_SOURCE, encoding="utf-8")
455
+
456
+ initial_exit_code, _initial_output = validator.run_mypy(str(checked_module), str(project_root))
457
+ assert initial_exit_code == 0
458
+ original_stat = config_path.stat()
459
+ config_path.write_text(SAME_SIZE_STRICT_TOOL_MYPY_PYPROJECT, encoding="utf-8")
460
+ os.utime(
461
+ config_path,
462
+ ns=(original_stat.st_atime_ns, original_stat.st_mtime_ns),
463
+ )
464
+
465
+ real_path_stat = Path.stat
466
+
467
+ def _restore_original_config_stat(
468
+ file_path: Path, *, follow_symlinks: bool = True
469
+ ) -> os.stat_result:
470
+ if file_path == config_path:
471
+ return original_stat
472
+ return real_path_stat(file_path, follow_symlinks=follow_symlinks)
473
+
474
+ monkeypatch.setattr(Path, "stat", _restore_original_config_stat)
475
+ subprocess_run_call_count = 0
476
+ real_subprocess_run = validator.subprocess.run
477
+
478
+ def _counting_subprocess_run(*positional: object, **keyword: object) -> object:
479
+ nonlocal subprocess_run_call_count
480
+ subprocess_run_call_count += 1
481
+ return real_subprocess_run(*positional, **keyword)
482
+
483
+ monkeypatch.setattr(validator.subprocess, "run", _counting_subprocess_run)
484
+
485
+ strict_exit_code, strict_output = validator.run_mypy(str(checked_module), str(project_root))
486
+
487
+ assert subprocess_run_call_count == 1
488
+ assert strict_exit_code != 0
489
+ assert ": error:" in strict_output
490
+
491
+
492
+ def test_same_stat_non_mypy_rewrite_discovers_strict_mypy_config(
493
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
494
+ ) -> None:
495
+ assert len(SAME_SIZE_NON_MYPY_PYPROJECT) == len(SAME_SIZE_STRICT_MYPY_PYPROJECT)
496
+ validator = _load_validator()
497
+ project_root = tmp_path / "project"
498
+ project_root.mkdir()
499
+ config_path = project_root / "pyproject.toml"
500
+ config_path.write_text(SAME_SIZE_NON_MYPY_PYPROJECT, encoding="utf-8")
501
+ checked_module = project_root / "checked.py"
502
+ checked_module.write_text(UNTYPED_DEF_MODULE_SOURCE, encoding="utf-8")
503
+
504
+ initial_exit_code, _initial_output = validator.run_mypy(str(checked_module), str(project_root))
505
+ assert initial_exit_code == 0
506
+ original_stat = config_path.stat()
507
+ config_path.write_text(SAME_SIZE_STRICT_MYPY_PYPROJECT, encoding="utf-8")
508
+ os.utime(config_path, ns=(original_stat.st_atime_ns, original_stat.st_mtime_ns))
509
+
510
+ real_path_stat = Path.stat
511
+
512
+ def _restore_original_config_stat(
513
+ file_path: Path, *, follow_symlinks: bool = True
514
+ ) -> os.stat_result:
515
+ if file_path == config_path:
516
+ return original_stat
517
+ return real_path_stat(file_path, follow_symlinks=follow_symlinks)
518
+
519
+ monkeypatch.setattr(Path, "stat", _restore_original_config_stat)
520
+ subprocess_run_call_count = 0
521
+ real_subprocess_run = validator.subprocess.run
522
+
523
+ def _counting_subprocess_run(*positional: object, **keyword: object) -> object:
524
+ nonlocal subprocess_run_call_count
525
+ subprocess_run_call_count += 1
526
+ return real_subprocess_run(*positional, **keyword)
527
+
528
+ monkeypatch.setattr(validator.subprocess, "run", _counting_subprocess_run)
529
+
530
+ strict_exit_code, strict_output = validator.run_mypy(str(checked_module), str(project_root))
531
+
532
+ assert subprocess_run_call_count == 1
533
+ assert strict_exit_code != 0
534
+ assert ": error:" in strict_output
535
+
536
+
537
+ def test_config_signature_falls_back_when_config_resolution_hits_symlink_loop(
538
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
539
+ ) -> None:
540
+ validator = _load_validator()
541
+ config_path = tmp_path / "pyproject.toml"
542
+ config_path.write_text(TOOL_MYPY_PYPROJECT, encoding="utf-8")
543
+ real_resolve = Path.resolve
544
+
545
+ def _raise_resolution_loop(file_path: Path, *, strict: bool = False) -> Path:
546
+ if file_path == config_path:
547
+ raise RuntimeError("symlink loop")
548
+ return real_resolve(file_path, strict=strict)
549
+
550
+ monkeypatch.setattr(Path, "resolve", _raise_resolution_loop)
551
+
552
+ signature = validator._config_signature(config_path)
553
+
554
+ assert signature.startswith(str(config_path).encode(validator.CACHE_FILE_ENCODING))
555
+
556
+
282
557
  def test_project_relative_path_within_root_returns_relative() -> None:
283
558
  validator = _load_validator()
284
559
  project_root = os.path.join("base", "project")