claude-dev-env 1.25.1 → 1.26.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 (105) hide show
  1. package/CLAUDE.md +6 -0
  2. package/agents/clean-coder.md +1 -1
  3. package/docs/CODE_RULES.md +3 -1
  4. package/hooks/HOOK_SPECS_PROMPT_WORKFLOW.md +54 -0
  5. package/hooks/blocking/{code-rules-enforcer.py → code_rules_enforcer.py} +150 -5
  6. package/hooks/blocking/{destructive-command-blocker.py → destructive_command_blocker.py} +12 -4
  7. package/hooks/blocking/{tdd-enforcer.py → tdd_enforcer.py} +12 -0
  8. package/hooks/blocking/test_code_rules_enforcer_any_type_ignore.py +2 -2
  9. package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +2 -2
  10. package/hooks/blocking/test_code_rules_enforcer_conftest_anchor.py +1 -1
  11. package/hooks/blocking/test_code_rules_enforcer_dot_test_pattern.py +2 -2
  12. package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +181 -0
  13. package/hooks/blocking/test_code_rules_enforcer_fstring_scan.py +4 -4
  14. package/hooks/blocking/test_code_rules_enforcer_logger_fstring.py +1 -1
  15. package/hooks/blocking/test_code_rules_enforcer_magic_allowlist.py +1 -1
  16. package/hooks/blocking/test_code_rules_enforcer_magic_string_masking.py +104 -0
  17. package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +2 -2
  18. package/hooks/blocking/test_code_rules_enforcer_type_checking_scope.py +2 -2
  19. package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +1 -1
  20. package/hooks/blocking/test_destructive_command_blocker.py +63 -4
  21. package/hooks/blocking/test_gh_body_arg_blocker.py +1 -1
  22. package/hooks/blocking/test_pr_description_enforcer.py +8 -8
  23. package/hooks/blocking/test_tdd_enforcer.py +53 -1
  24. package/hooks/github-action/pre-push-review.yml +27 -0
  25. package/hooks/hooks.json +28 -28
  26. package/hooks/lifecycle/{config-change-guard.py → config_change_guard.py} +27 -12
  27. package/hooks/lifecycle/test_config_change_guard.py +3 -3
  28. package/hooks/notification/{attention-needed-notify.py → attention_needed_notify.py} +7 -0
  29. package/hooks/notification/{claude-notification-handler.py → claude_notification_handler.py} +8 -0
  30. package/hooks/notification/notification_utils.py +60 -2
  31. package/hooks/notification/subagent_complete_notify.py +381 -0
  32. package/hooks/notification/test_attention_needed_notify.py +47 -0
  33. package/hooks/notification/test_claude_notification_handler.py +54 -0
  34. package/hooks/notification/test_notification_utils.py +91 -0
  35. package/hooks/notification/test_subagent_complete_notify.py +72 -0
  36. package/hooks/validators/README.md +5 -1
  37. package/hooks/validators/abbreviation_checks.py +1 -1
  38. package/hooks/validators/code_quality_checks.py +1 -1
  39. package/hooks/validators/config.py +5 -0
  40. package/hooks/validators/conftest.py +10 -0
  41. package/hooks/validators/exempt_paths.py +1 -1
  42. package/hooks/validators/git_checks.py +80 -0
  43. package/hooks/validators/magic_value_checks.py +2 -2
  44. package/hooks/validators/pr_reference_checks.py +1 -1
  45. package/hooks/validators/python_antipattern_checks.py +1 -1
  46. package/hooks/validators/run_all_validators.py +53 -105
  47. package/hooks/validators/security_checks.py +1 -1
  48. package/hooks/validators/test_abbreviation_checks.py +2 -2
  49. package/hooks/validators/test_code_quality_checks.py +2 -2
  50. package/hooks/validators/test_file_structure_checks.py +1 -1
  51. package/hooks/validators/test_git_checks.py +79 -13
  52. package/hooks/validators/test_health_check.py +1 -1
  53. package/hooks/validators/test_magic_value_checks.py +2 -2
  54. package/hooks/validators/test_mypy_integration.py +1 -1
  55. package/hooks/validators/test_output_formatter.py +3 -1
  56. package/hooks/validators/test_pr_reference_checks.py +2 -2
  57. package/hooks/validators/test_python_antipattern_checks.py +2 -2
  58. package/hooks/validators/test_python_style_checks.py +2 -4
  59. package/hooks/validators/test_react_checks.py +1 -1
  60. package/hooks/validators/test_ruff_integration.py +1 -1
  61. package/hooks/validators/test_run_all_validators.py +75 -43
  62. package/hooks/validators/test_run_all_validators_integration.py +14 -37
  63. package/hooks/validators/test_security_checks.py +2 -2
  64. package/hooks/validators/test_test_safety_checks.py +1 -1
  65. package/hooks/validators/test_todo_checks.py +2 -2
  66. package/hooks/validators/test_type_safety_checks.py +2 -2
  67. package/hooks/validators/test_useless_test_checks.py +2 -2
  68. package/hooks/validators/test_validator_base.py +1 -1
  69. package/hooks/validators/test_verify_paths.py +2 -4
  70. package/hooks/validators/todo_checks.py +1 -1
  71. package/hooks/validators/type_safety_checks.py +1 -1
  72. package/hooks/validators/useless_test_checks.py +1 -1
  73. package/package.json +1 -1
  74. package/rules/file-global-constants.md +71 -0
  75. package/rules/gh-body-file.md +1 -1
  76. package/rules/prompt-workflow-context-controls.md +48 -0
  77. package/scripts/sync_to_cursor/rules.py +2 -2
  78. package/scripts/tests/test_sync_to_cursor.py +2 -2
  79. package/skills/bugteam/CONSTRAINTS.md +37 -0
  80. package/skills/bugteam/EXAMPLES.md +64 -0
  81. package/skills/bugteam/PROMPTS.md +175 -0
  82. package/skills/bugteam/SKILL.md +204 -295
  83. package/skills/bugteam/SKILL_EVALS.md +346 -0
  84. package/skills/bugteam/scripts/README.md +37 -0
  85. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +334 -0
  86. package/skills/bugteam/scripts/bugteam_preflight.py +135 -0
  87. package/skills/rule-audit/SKILL.md +4 -4
  88. /package/hooks/advisory/{migration-safety-advisor.py → migration_safety_advisor.py} +0 -0
  89. /package/hooks/advisory/{refactor-guard.py → refactor_guard.py} +0 -0
  90. /package/hooks/blocking/{block-main-commit.py → block_main_commit.py} +0 -0
  91. /package/hooks/blocking/{content-search-to-zoekt-redirector.py → content_search_to_zoekt_redirector.py} +0 -0
  92. /package/hooks/blocking/{gh-body-arg-blocker.py → gh_body_arg_blocker.py} +0 -0
  93. /package/hooks/blocking/{hedging-language-blocker.py → hedging_language_blocker.py} +0 -0
  94. /package/hooks/blocking/{pr-description-enforcer.py → pr_description_enforcer.py} +0 -0
  95. /package/hooks/blocking/{sensitive-file-protector.py → sensitive_file_protector.py} +0 -0
  96. /package/hooks/blocking/{test-preflight-check.py → test_preflight_check.py} +0 -0
  97. /package/hooks/blocking/{write-existing-file-blocker.py → write_existing_file_blocker.py} +0 -0
  98. /package/hooks/git-hooks/{post-commit.py → post_commit.py} +0 -0
  99. /package/hooks/lifecycle/{session-end-cleanup.py → session_end_cleanup.py} +0 -0
  100. /package/hooks/{rewrite-plugin-paths.py → rewrite_plugin_paths.py} +0 -0
  101. /package/hooks/session/{plugin-data-dir-cleanup.py → plugin_data_dir_cleanup.py} +0 -0
  102. /package/hooks/validation/{hook-format-validator.py → hook_format_validator.py} +0 -0
  103. /package/hooks/workflow/{auto-formatter.py → auto_formatter.py} +0 -0
  104. /package/hooks/workflow/{investigation-tracker-reset.py → investigation_tracker_reset.py} +0 -0
  105. /package/scripts/{sync-to-cursor.py → sync_to_cursor.py} +0 -0
@@ -3,6 +3,7 @@
3
3
  This script orchestrates all automated validators and produces a unified report.
4
4
  Exit code 0 = all checks pass, 1 = violations found.
5
5
  """
6
+ # pragma: no-tdd-gate
6
7
 
7
8
  import argparse
8
9
  import subprocess
@@ -13,13 +14,31 @@ from datetime import datetime
13
14
  from pathlib import Path
14
15
  from typing import Any, Callable, Dict, List, Optional, Tuple
15
16
 
16
- from health_check import get_validator_version
17
- from mypy_integration import check_mypy_available, run_mypy_check
18
- from output_formatter import OutputFormatter, OutputMode, ValidatorResultDict
19
- from ruff_integration import check_ruff_available, run_ruff_check
17
+ from .health_check import get_system_health, get_validator_version, print_health_report
18
+ from .mypy_integration import check_mypy_available, run_mypy_check
19
+ from .output_formatter import OutputFormatter, OutputMode, ValidatorResultDict
20
+ from .python_style_checks import fix_file
21
+ from .ruff_integration import check_ruff_available, run_ruff_check
20
22
 
21
23
 
22
24
  VALIDATORS_DIR = Path(__file__).parent
25
+ hooks_dir = VALIDATORS_DIR.parent
26
+ package_name = VALIDATORS_DIR.name
27
+
28
+
29
+ def invoke_validator_module(module_stem: str, forwarded_file_paths: List[str]) -> subprocess.CompletedProcess[str]: # pragma: no-tdd-gate
30
+ """Run a sibling validator as ``python -m validators.<module_stem>``.
31
+
32
+ The subprocess is launched with ``cwd`` set to the hooks directory so the
33
+ ``validators`` package qualifier resolves without requiring PYTHONPATH.
34
+ """
35
+ qualified_module = ".".join([package_name, module_stem])
36
+ return subprocess.run(
37
+ [sys.executable, "-m", qualified_module, *forwarded_file_paths],
38
+ capture_output=True,
39
+ text=True,
40
+ cwd=str(hooks_dir),
41
+ )
23
42
 
24
43
 
25
44
  @dataclass(frozen=True)
@@ -169,18 +188,13 @@ def run_python_style_checks(files: List[Path]) -> ValidatorResult:
169
188
  output="No Python files to check",
170
189
  )
171
190
 
172
- result = subprocess.run(
173
- [sys.executable, str(VALIDATORS_DIR / "python_style_checks.py")]
174
- + [str(f) for f in py_files],
175
- capture_output=True,
176
- text=True,
177
- )
191
+ result = invoke_validator_module("python_style_checks", [str(f) for f in py_files])
178
192
 
179
193
  return ValidatorResult(
180
194
  name="Python Style",
181
195
  checks="1,2,3,4",
182
196
  passed=result.returncode == 0,
183
- output=result.stdout or "All checks passed",
197
+ output=result.stdout or result.stderr or "All checks passed",
184
198
  )
185
199
 
186
200
 
@@ -195,18 +209,13 @@ def run_test_safety_checks(files: List[Path]) -> ValidatorResult:
195
209
  output="No test files to check",
196
210
  )
197
211
 
198
- result = subprocess.run(
199
- [sys.executable, str(VALIDATORS_DIR / "test_safety_checks.py")]
200
- + [str(f) for f in test_files],
201
- capture_output=True,
202
- text=True,
203
- )
212
+ result = invoke_validator_module("test_safety_checks", [str(f) for f in test_files])
204
213
 
205
214
  return ValidatorResult(
206
215
  name="Test Safety",
207
216
  checks="11,21",
208
217
  passed=result.returncode == 0,
209
- output=result.stdout or "All checks passed",
218
+ output=result.stdout or result.stderr or "All checks passed",
210
219
  )
211
220
 
212
221
 
@@ -235,17 +244,13 @@ def run_file_structure_checks(project_root: Optional[Path] = None) -> ValidatorR
235
244
  output="Not in a git repository - skipping",
236
245
  )
237
246
 
238
- result = subprocess.run(
239
- [sys.executable, str(VALIDATORS_DIR / "file_structure_checks.py"), str(project_root)],
240
- capture_output=True,
241
- text=True,
242
- )
247
+ result = invoke_validator_module("file_structure_checks", [str(project_root)])
243
248
 
244
249
  return ValidatorResult(
245
250
  name="File Structure",
246
251
  checks="14,15",
247
252
  passed=result.returncode == 0,
248
- output=result.stdout or "All checks passed",
253
+ output=result.stdout or result.stderr or "All checks passed",
249
254
  )
250
255
 
251
256
 
@@ -260,39 +265,30 @@ def run_react_checks(files: List[Path]) -> ValidatorResult:
260
265
  output="No React files to check",
261
266
  )
262
267
 
263
- result = subprocess.run(
264
- [sys.executable, str(VALIDATORS_DIR / "react_checks.py")]
265
- + [str(f) for f in react_files],
266
- capture_output=True,
267
- text=True,
268
- )
268
+ result = invoke_validator_module("react_checks", [str(f) for f in react_files])
269
269
 
270
270
  return ValidatorResult(
271
271
  name="React",
272
272
  checks="17",
273
273
  passed=result.returncode == 0,
274
- output=result.stdout or "All checks passed",
274
+ output=result.stdout or result.stderr or "All checks passed",
275
275
  )
276
276
 
277
277
 
278
278
  def run_git_checks() -> ValidatorResult:
279
279
  """Run git/GitHub checks."""
280
- result = subprocess.run(
281
- [sys.executable, str(VALIDATORS_DIR / "git_checks.py")],
282
- capture_output=True,
283
- text=True,
284
- )
280
+ result = invoke_validator_module("git_checks", [])
285
281
 
286
282
  return ValidatorResult(
287
283
  name="Git/PR Workflow",
288
284
  checks="23,24",
289
285
  passed=result.returncode == 0,
290
- output=result.stdout or "All checks passed",
286
+ output=result.stdout or result.stderr or "All checks passed",
291
287
  )
292
288
 
293
289
 
294
290
  def run_comment_checks(files: List[Path]) -> ValidatorResult:
295
- """Comment preservation is enforced by code-rules-enforcer hook.
291
+ """Comment preservation is enforced by code_rules_enforcer hook.
296
292
 
297
293
  The hook compares old vs new content to block NEW comments and
298
294
  block DELETION of existing comments. This standalone validator
@@ -303,7 +299,7 @@ def run_comment_checks(files: List[Path]) -> ValidatorResult:
303
299
  name="No Comments",
304
300
  checks="26",
305
301
  passed=True,
306
- output="Handled by code-rules-enforcer hook (old vs new comparison)",
302
+ output="Handled by code_rules_enforcer hook (old vs new comparison)",
307
303
  )
308
304
 
309
305
 
@@ -358,18 +354,13 @@ def run_abbreviation_checks(files: List[Path]) -> ValidatorResult:
358
354
  output="No Python files to check",
359
355
  )
360
356
 
361
- result = subprocess.run(
362
- [sys.executable, str(VALIDATORS_DIR / "abbreviation_checks.py")]
363
- + [str(f) for f in py_files],
364
- capture_output=True,
365
- text=True,
366
- )
357
+ result = invoke_validator_module("abbreviation_checks", [str(f) for f in py_files])
367
358
 
368
359
  return ValidatorResult(
369
360
  name="Abbreviations",
370
361
  checks="5",
371
362
  passed=result.returncode == 0,
372
- output=result.stdout or "All checks passed",
363
+ output=result.stdout or result.stderr or "All checks passed",
373
364
  )
374
365
 
375
366
 
@@ -384,18 +375,13 @@ def run_pr_reference_checks(files: List[Path]) -> ValidatorResult:
384
375
  output="No code files to check",
385
376
  )
386
377
 
387
- result = subprocess.run(
388
- [sys.executable, str(VALIDATORS_DIR / "pr_reference_checks.py")]
389
- + [str(f) for f in code_files],
390
- capture_output=True,
391
- text=True,
392
- )
378
+ result = invoke_validator_module("pr_reference_checks", [str(f) for f in code_files])
393
379
 
394
380
  return ValidatorResult(
395
381
  name="PR References",
396
382
  checks="6",
397
383
  passed=result.returncode == 0,
398
- output=result.stdout or "All checks passed",
384
+ output=result.stdout or result.stderr or "All checks passed",
399
385
  )
400
386
 
401
387
 
@@ -410,18 +396,13 @@ def run_magic_value_checks(files: List[Path]) -> ValidatorResult:
410
396
  output="No Python files to check",
411
397
  )
412
398
 
413
- result = subprocess.run(
414
- [sys.executable, str(VALIDATORS_DIR / "magic_value_checks.py")]
415
- + [str(f) for f in py_files],
416
- capture_output=True,
417
- text=True,
418
- )
399
+ result = invoke_validator_module("magic_value_checks", [str(f) for f in py_files])
419
400
 
420
401
  return ValidatorResult(
421
402
  name="Magic Values",
422
403
  checks="7",
423
404
  passed=result.returncode == 0,
424
- output=result.stdout or "All checks passed",
405
+ output=result.stdout or result.stderr or "All checks passed",
425
406
  )
426
407
 
427
408
 
@@ -436,18 +417,13 @@ def run_useless_test_checks(files: List[Path]) -> ValidatorResult:
436
417
  output="No test files to check",
437
418
  )
438
419
 
439
- result = subprocess.run(
440
- [sys.executable, str(VALIDATORS_DIR / "useless_test_checks.py")]
441
- + [str(f) for f in test_files],
442
- capture_output=True,
443
- text=True,
444
- )
420
+ result = invoke_validator_module("useless_test_checks", [str(f) for f in test_files])
445
421
 
446
422
  return ValidatorResult(
447
423
  name="Useless Tests",
448
424
  checks="12",
449
425
  passed=result.returncode == 0,
450
- output=result.stdout or "All checks passed",
426
+ output=result.stdout or result.stderr or "All checks passed",
451
427
  )
452
428
 
453
429
 
@@ -462,18 +438,13 @@ def run_security_checks(files: List[Path]) -> ValidatorResult:
462
438
  output="No Python files to check",
463
439
  )
464
440
 
465
- result = subprocess.run(
466
- [sys.executable, str(VALIDATORS_DIR / "security_checks.py")]
467
- + [str(f) for f in py_files],
468
- capture_output=True,
469
- text=True,
470
- )
441
+ result = invoke_validator_module("security_checks", [str(f) for f in py_files])
471
442
 
472
443
  return ValidatorResult(
473
444
  name="Security",
474
445
  checks="27,28,29",
475
446
  passed=result.returncode == 0,
476
- output=result.stdout or "All checks passed",
447
+ output=result.stdout or result.stderr or "All checks passed",
477
448
  )
478
449
 
479
450
 
@@ -488,18 +459,13 @@ def run_code_quality_checks(files: List[Path]) -> ValidatorResult:
488
459
  output="No Python files to check",
489
460
  )
490
461
 
491
- result = subprocess.run(
492
- [sys.executable, str(VALIDATORS_DIR / "code_quality_checks.py")]
493
- + [str(f) for f in py_files],
494
- capture_output=True,
495
- text=True,
496
- )
462
+ result = invoke_validator_module("code_quality_checks", [str(f) for f in py_files])
497
463
 
498
464
  return ValidatorResult(
499
465
  name="Code Quality",
500
466
  checks="30,31,32",
501
467
  passed=result.returncode == 0,
502
- output=result.stdout or "All checks passed",
468
+ output=result.stdout or result.stderr or "All checks passed",
503
469
  )
504
470
 
505
471
 
@@ -514,18 +480,13 @@ def run_python_antipattern_checks(files: List[Path]) -> ValidatorResult:
514
480
  output="No Python files to check",
515
481
  )
516
482
 
517
- result = subprocess.run(
518
- [sys.executable, str(VALIDATORS_DIR / "python_antipattern_checks.py")]
519
- + [str(f) for f in py_files],
520
- capture_output=True,
521
- text=True,
522
- )
483
+ result = invoke_validator_module("python_antipattern_checks", [str(f) for f in py_files])
523
484
 
524
485
  return ValidatorResult(
525
486
  name="Python Anti-patterns",
526
487
  checks="33,34,35",
527
488
  passed=result.returncode == 0,
528
- output=result.stdout or "All checks passed",
489
+ output=result.stdout or result.stderr or "All checks passed",
529
490
  )
530
491
 
531
492
 
@@ -540,18 +501,13 @@ def run_todo_checks(files: List[Path]) -> ValidatorResult:
540
501
  output="No Python files to check",
541
502
  )
542
503
 
543
- result = subprocess.run(
544
- [sys.executable, str(VALIDATORS_DIR / "todo_checks.py")]
545
- + [str(f) for f in py_files],
546
- capture_output=True,
547
- text=True,
548
- )
504
+ result = invoke_validator_module("todo_checks", [str(f) for f in py_files])
549
505
 
550
506
  return ValidatorResult(
551
507
  name="TODO Tracking",
552
508
  checks="36",
553
509
  passed=result.returncode == 0,
554
- output=result.stdout or "All checks passed",
510
+ output=result.stdout or result.stderr or "All checks passed",
555
511
  )
556
512
 
557
513
 
@@ -566,18 +522,13 @@ def run_type_safety_checks(files: List[Path]) -> ValidatorResult:
566
522
  output="No Python files to check",
567
523
  )
568
524
 
569
- result = subprocess.run(
570
- [sys.executable, str(VALIDATORS_DIR / "type_safety_checks.py")]
571
- + [str(f) for f in py_files],
572
- capture_output=True,
573
- text=True,
574
- )
525
+ result = invoke_validator_module("type_safety_checks", [str(f) for f in py_files])
575
526
 
576
527
  return ValidatorResult(
577
528
  name="Type Safety",
578
529
  checks="39,40",
579
530
  passed=result.returncode == 0,
580
- output=result.stdout or "All checks passed",
531
+ output=result.stdout or result.stderr or "All checks passed",
581
532
  )
582
533
 
583
534
 
@@ -590,8 +541,6 @@ def fix_python_style(files: List[Path]) -> List[str]:
590
541
  Returns:
591
542
  List of files that were fixed
592
543
  """
593
- from python_style_checks import fix_file
594
-
595
544
  fixed_files: List[str] = []
596
545
  py_files = [f for f in files if f.suffix == ".py"]
597
546
 
@@ -657,7 +606,6 @@ def main() -> int:
657
606
  args = parser.parse_args()
658
607
 
659
608
  if args.health:
660
- from health_check import get_system_health, print_health_report
661
609
  health = get_system_health()
662
610
  print_health_report(health)
663
611
  return 0 if health.all_healthy else 1
@@ -12,7 +12,7 @@ import sys
12
12
  from pathlib import Path
13
13
  from typing import List
14
14
 
15
- from validator_base import Violation
15
+ from .validator_base import Violation
16
16
 
17
17
 
18
18
  SECRET_PATTERNS: frozenset[str] = frozenset({
@@ -5,11 +5,11 @@ from pathlib import Path
5
5
 
6
6
  import pytest
7
7
 
8
- from abbreviation_checks import (
8
+ from .abbreviation_checks import (
9
9
  check_single_letter_variables,
10
10
  validate_file,
11
11
  )
12
- from validator_base import Violation
12
+ from .validator_base import Violation
13
13
 
14
14
 
15
15
  GOOD_DESCRIPTIVE_NAMES = '''
@@ -6,12 +6,12 @@ from pathlib import Path
6
6
 
7
7
  import pytest
8
8
 
9
- from code_quality_checks import (
9
+ from .code_quality_checks import (
10
10
  check_function_length,
11
11
  check_nesting_depth,
12
12
  check_file_length,
13
13
  )
14
- from validator_base import Violation
14
+ from .validator_base import Violation
15
15
 
16
16
 
17
17
  GOOD_SHORT_FUNCTION = '''
@@ -6,7 +6,7 @@ from typing import List
6
6
 
7
7
  import pytest
8
8
 
9
- from file_structure_checks import (
9
+ from .file_structure_checks import (
10
10
  Violation,
11
11
  check_multiple_requirements_txt,
12
12
  check_empty_init_files,
@@ -8,7 +8,7 @@ from unittest.mock import MagicMock, patch
8
8
 
9
9
  import pytest
10
10
 
11
- from git_checks import (
11
+ from .git_checks import (
12
12
  Violation,
13
13
  check_single_commit_when_pr_exists,
14
14
  check_draft_pr_state,
@@ -22,7 +22,10 @@ class TestSingleCommitWhenPrExists:
22
22
  @patch("git_checks.subprocess.run")
23
23
  def test_no_pr_returns_empty(self, mock_run: MagicMock) -> None:
24
24
  """When no PR exists, check should return empty list."""
25
- mock_run.return_value = MagicMock(returncode=0, stdout="[]", stderr="")
25
+ mock_run.side_effect = [
26
+ MagicMock(returncode=0, stdout="feature/my-branch\n", stderr=""),
27
+ MagicMock(returncode=0, stdout="[]", stderr=""),
28
+ ]
26
29
 
27
30
  violations = check_single_commit_when_pr_exists()
28
31
 
@@ -32,6 +35,7 @@ class TestSingleCommitWhenPrExists:
32
35
  def test_single_commit_ahead_passes(self, mock_run: MagicMock) -> None:
33
36
  """Exactly 1 commit ahead should pass."""
34
37
  mock_run.side_effect = [
38
+ MagicMock(returncode=0, stdout="feature/my-branch\n", stderr=""),
35
39
  MagicMock(returncode=0, stdout='[{"baseRefName": "main", "number": 123}]', stderr=""),
36
40
  MagicMock(returncode=0, stdout="1", stderr=""),
37
41
  ]
@@ -44,6 +48,7 @@ class TestSingleCommitWhenPrExists:
44
48
  def test_zero_commits_ahead_fails(self, mock_run: MagicMock) -> None:
45
49
  """Zero commits ahead should fail."""
46
50
  mock_run.side_effect = [
51
+ MagicMock(returncode=0, stdout="feature/my-branch\n", stderr=""),
47
52
  MagicMock(returncode=0, stdout='[{"baseRefName": "main", "number": 123}]', stderr=""),
48
53
  MagicMock(returncode=0, stdout="0", stderr=""),
49
54
  ]
@@ -60,6 +65,7 @@ class TestSingleCommitWhenPrExists:
60
65
  def test_multiple_commits_ahead_fails(self, mock_run: MagicMock) -> None:
61
66
  """More than 1 commit ahead should fail."""
62
67
  mock_run.side_effect = [
68
+ MagicMock(returncode=0, stdout="feature/my-branch\n", stderr=""),
63
69
  MagicMock(returncode=0, stdout='[{"baseRefName": "main", "number": 123}]', stderr=""),
64
70
  MagicMock(returncode=0, stdout="3", stderr=""),
65
71
  ]
@@ -73,7 +79,10 @@ class TestSingleCommitWhenPrExists:
73
79
  @patch("git_checks.subprocess.run")
74
80
  def test_gh_cli_not_available_returns_empty(self, mock_run: MagicMock) -> None:
75
81
  """When gh CLI not available, should return empty (warning, not failure)."""
76
- mock_run.side_effect = FileNotFoundError("gh not found")
82
+ mock_run.side_effect = [
83
+ MagicMock(returncode=0, stdout="feature/my-branch\n", stderr=""),
84
+ FileNotFoundError("gh not found"),
85
+ ]
77
86
 
78
87
  violations = check_single_commit_when_pr_exists()
79
88
 
@@ -83,6 +92,7 @@ class TestSingleCommitWhenPrExists:
83
92
  def test_git_not_available_returns_empty(self, mock_run: MagicMock) -> None:
84
93
  """When git not available, should return empty."""
85
94
  mock_run.side_effect = [
95
+ MagicMock(returncode=0, stdout="feature/my-branch\n", stderr=""),
86
96
  MagicMock(returncode=0, stdout='[{"baseRefName": "main", "number": 123}]', stderr=""),
87
97
  FileNotFoundError("git not found"),
88
98
  ]
@@ -93,8 +103,9 @@ class TestSingleCommitWhenPrExists:
93
103
 
94
104
  @patch("git_checks.subprocess.run")
95
105
  def test_extracts_base_branch_from_pr_info(self, mock_run: MagicMock) -> None:
96
- """Should extract base branch name from gh pr list JSON output."""
106
+ """Should extract base branch name from gh pr list JSON output, falling back to main when absent."""
97
107
  mock_run.side_effect = [
108
+ MagicMock(returncode=0, stdout="feature/my-branch\n", stderr=""),
98
109
  MagicMock(returncode=0, stdout='[{"baseRefName": "develop", "number": 123}]', stderr=""),
99
110
  MagicMock(returncode=0, stdout="2", stderr=""),
100
111
  ]
@@ -110,10 +121,30 @@ class TestSingleCommitWhenPrExists:
110
121
  timeout=30,
111
122
  )
112
123
 
124
+ mock_run.reset_mock()
125
+ mock_run.side_effect = [
126
+ MagicMock(returncode=0, stdout="feature/my-branch\n", stderr=""),
127
+ MagicMock(returncode=0, stdout='[{"number": 123}]', stderr=""),
128
+ MagicMock(returncode=0, stdout="2", stderr=""),
129
+ ]
130
+
131
+ fallback_violations = check_single_commit_when_pr_exists()
132
+
133
+ assert len(fallback_violations) == 1
134
+ assert "main" in fallback_violations[0].message
135
+ mock_run.assert_any_call(
136
+ ["git", "rev-list", "--count", "main..HEAD"],
137
+ capture_output=True,
138
+ text=True,
139
+ check=True,
140
+ timeout=30,
141
+ )
142
+
113
143
  @patch("git_checks.subprocess.run")
114
144
  def test_non_numeric_commit_count_returns_empty(self, mock_run: MagicMock) -> None:
115
145
  """When git rev-list returns non-numeric output, should return empty."""
116
146
  mock_run.side_effect = [
147
+ MagicMock(returncode=0, stdout="feature/my-branch\n", stderr=""),
117
148
  MagicMock(returncode=0, stdout='[{"baseRefName": "main", "number": 123}]', stderr=""),
118
149
  MagicMock(returncode=0, stdout="not a number\n", stderr=""),
119
150
  ]
@@ -125,7 +156,10 @@ class TestSingleCommitWhenPrExists:
125
156
  @patch("git_checks.subprocess.run")
126
157
  def test_gh_timeout_returns_empty(self, mock_run: MagicMock) -> None:
127
158
  """When gh CLI times out, should return empty (warning, not failure)."""
128
- mock_run.side_effect = subprocess.TimeoutExpired(cmd=["gh", "pr", "list"], timeout=30)
159
+ mock_run.side_effect = [
160
+ MagicMock(returncode=0, stdout="feature/my-branch\n", stderr=""),
161
+ subprocess.TimeoutExpired(cmd=["gh", "pr", "list"], timeout=30),
162
+ ]
129
163
 
130
164
  violations = check_single_commit_when_pr_exists()
131
165
 
@@ -135,6 +169,7 @@ class TestSingleCommitWhenPrExists:
135
169
  def test_git_timeout_returns_empty(self, mock_run: MagicMock) -> None:
136
170
  """When git times out, should return empty (warning, not failure)."""
137
171
  mock_run.side_effect = [
172
+ MagicMock(returncode=0, stdout="feature/my-branch\n", stderr=""),
138
173
  MagicMock(returncode=0, stdout='[{"baseRefName": "main", "number": 123}]', stderr=""),
139
174
  subprocess.TimeoutExpired(cmd=["git", "rev-list"], timeout=30),
140
175
  ]
@@ -143,6 +178,37 @@ class TestSingleCommitWhenPrExists:
143
178
 
144
179
  assert violations == []
145
180
 
181
+ @patch("git_checks.subprocess.run")
182
+ def test_passes_resolved_branch_name_to_gh(self, mock_run: MagicMock) -> None:
183
+ """gh pr list must receive the resolved branch name, never the literal 'HEAD'."""
184
+ mock_run.side_effect = [
185
+ MagicMock(returncode=0, stdout="feature/my-branch\n", stderr=""),
186
+ MagicMock(returncode=0, stdout='[{"baseRefName": "main", "number": 123}]', stderr=""),
187
+ MagicMock(returncode=0, stdout="1", stderr=""),
188
+ ]
189
+
190
+ check_single_commit_when_pr_exists()
191
+
192
+ mock_run.assert_any_call(
193
+ ["gh", "pr", "list", "--head", "feature/my-branch", "--json", "baseRefName,number"],
194
+ capture_output=True,
195
+ text=True,
196
+ check=True,
197
+ timeout=30,
198
+ )
199
+
200
+ @patch("git_checks.subprocess.run")
201
+ def test_unresolved_branch_returns_empty(self, mock_run: MagicMock) -> None:
202
+ """When current branch cannot be resolved, should return empty."""
203
+ mock_run.side_effect = [
204
+ MagicMock(returncode=0, stdout="\n", stderr=""),
205
+ ]
206
+
207
+ violations = check_single_commit_when_pr_exists()
208
+
209
+ assert violations == []
210
+ mock_run.assert_called_once()
211
+
146
212
 
147
213
  class TestDraftPrState:
148
214
  """Test that PR is in draft state when pushing review fixes."""
@@ -208,8 +274,8 @@ class TestDraftPrState:
208
274
  class TestMain:
209
275
  """Test main function integration."""
210
276
 
211
- @patch("git_checks.check_single_commit_when_pr_exists")
212
- @patch("git_checks.check_draft_pr_state")
277
+ @patch("validators.git_checks.check_single_commit_when_pr_exists")
278
+ @patch("validators.git_checks.check_draft_pr_state")
213
279
  def test_main_no_violations_exits_zero(
214
280
  self,
215
281
  mock_draft: MagicMock,
@@ -227,8 +293,8 @@ class TestMain:
227
293
  captured = capsys.readouterr()
228
294
  assert captured.out == ""
229
295
 
230
- @patch("git_checks.check_single_commit_when_pr_exists")
231
- @patch("git_checks.check_draft_pr_state")
296
+ @patch("validators.git_checks.check_single_commit_when_pr_exists")
297
+ @patch("validators.git_checks.check_draft_pr_state")
232
298
  def test_main_with_violations_exits_one(
233
299
  self,
234
300
  mock_draft: MagicMock,
@@ -248,8 +314,8 @@ class TestMain:
248
314
  captured = capsys.readouterr()
249
315
  assert "Branch has 3 commits ahead" in captured.out
250
316
 
251
- @patch("git_checks.check_single_commit_when_pr_exists")
252
- @patch("git_checks.check_draft_pr_state")
317
+ @patch("validators.git_checks.check_single_commit_when_pr_exists")
318
+ @patch("validators.git_checks.check_draft_pr_state")
253
319
  def test_main_prints_violations_without_file_line(
254
320
  self,
255
321
  mock_draft: MagicMock,
@@ -270,8 +336,8 @@ class TestMain:
270
336
  assert captured.out == "PR must be in draft state\n"
271
337
  assert ":0:" not in captured.out
272
338
 
273
- @patch("git_checks.check_single_commit_when_pr_exists")
274
- @patch("git_checks.check_draft_pr_state")
339
+ @patch("validators.git_checks.check_single_commit_when_pr_exists")
340
+ @patch("validators.git_checks.check_draft_pr_state")
275
341
  def test_main_prints_all_violations(
276
342
  self,
277
343
  mock_draft: MagicMock,
@@ -5,7 +5,7 @@ from pathlib import Path
5
5
 
6
6
  import pytest
7
7
 
8
- from health_check import (
8
+ from .health_check import (
9
9
  ValidatorHealth,
10
10
  check_validator_exists,
11
11
  check_all_validators,
@@ -6,11 +6,11 @@ from pathlib import Path
6
6
 
7
7
  import pytest
8
8
 
9
- from magic_value_checks import (
9
+ from .magic_value_checks import (
10
10
  check_magic_values,
11
11
  validate_file,
12
12
  )
13
- from validator_base import Violation
13
+ from .validator_base import Violation
14
14
 
15
15
 
16
16
  MAGIC_NUMBER_SOURCE = "x = 42\n"
@@ -3,7 +3,7 @@
3
3
  from pathlib import Path
4
4
  from unittest.mock import patch
5
5
 
6
- from mypy_integration import MypyResult, check_mypy_available, run_mypy_check
6
+ from .mypy_integration import MypyResult, check_mypy_available, run_mypy_check
7
7
 
8
8
 
9
9
  def test_mypy_result_dataclass() -> None:
@@ -1,8 +1,10 @@
1
1
  """Tests for output formatting."""
2
2
 
3
+ import os
4
+
3
5
  import pytest
4
6
 
5
- from output_formatter import (
7
+ from .output_formatter import (
6
8
  OutputFormatter,
7
9
  OutputMode,
8
10
  format_violation_with_context,
@@ -4,11 +4,11 @@ from pathlib import Path
4
4
 
5
5
  import pytest
6
6
 
7
- from pr_reference_checks import (
7
+ from .pr_reference_checks import (
8
8
  check_pr_references,
9
9
  validate_file,
10
10
  )
11
- from validator_base import Violation
11
+ from .validator_base import Violation
12
12
 
13
13
 
14
14
  GOOD_NO_REFERENCES = '''