claude-dev-env 2.31.0 → 2.32.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 (80) hide show
  1. package/bin/install.test.mjs +11 -3
  2. package/docs/CODE_RULES.md +2 -1
  3. package/hooks/blocking/bash_post_call_dispatcher.py +70 -0
  4. package/hooks/blocking/bash_pre_tool_use_dispatcher.py +8 -21
  5. package/hooks/blocking/code_rules_duplicate_body.py +280 -102
  6. package/hooks/blocking/code_rules_enforcer.py +32 -14
  7. package/hooks/blocking/code_rules_enforcer_test_support.py +39 -0
  8. package/hooks/blocking/code_rules_shared.py +64 -14
  9. package/hooks/blocking/pr_description_writer_gate.py +18 -25
  10. package/hooks/blocking/shell_substitution_blocker.py +175 -8
  11. package/hooks/blocking/stop_dispatcher.py +6 -9
  12. package/hooks/blocking/tdd_enforcer.py +11 -5
  13. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +7 -0
  14. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +141 -23
  15. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +114 -1
  16. package/hooks/blocking/test_bash_post_call_dispatcher.py +112 -0
  17. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +12 -1
  18. package/hooks/blocking/test_code_rules_enforcer_ephemeral_repository_root.py +118 -0
  19. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +251 -0
  20. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +22 -0
  21. package/hooks/blocking/test_pr_description_writer_gate.py +21 -3
  22. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +14 -2
  23. package/hooks/blocking/test_shell_substitution_blocker.py +105 -0
  24. package/hooks/blocking/test_state_description_blocker.py +84 -53
  25. package/hooks/blocking/test_tdd_enforcer.py +131 -1
  26. package/hooks/hooks.json +2 -2
  27. package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +22 -0
  28. package/hooks/hooks_constants/hosted_hook_runner.py +53 -0
  29. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +9 -2
  30. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +34 -0
  31. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +10 -0
  32. package/hooks/hooks_constants/state_file_lock.py +76 -0
  33. package/hooks/hooks_constants/subprocess_budget_completeness_content.py +2 -2
  34. package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +27 -0
  35. package/hooks/hooks_constants/test_failure_recorder_constants.py +35 -0
  36. package/hooks/hooks_constants/test_hosted_hook_runner.py +55 -1
  37. package/hooks/hooks_constants/test_multi_edit_reconstruction.py +1 -0
  38. package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +33 -0
  39. package/hooks/observability/pr_description_writer_spawn_tracker.py +4 -12
  40. package/hooks/observability/session_file_edit_tracker.py +2 -55
  41. package/hooks/observability/test_failure_recorder.py +219 -0
  42. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +6 -2
  43. package/hooks/observability/test_test_failure_recorder.py +173 -0
  44. package/hooks/validators/abbreviation_checks.py +3 -3
  45. package/hooks/validators/code_quality_checks.py +4 -4
  46. package/hooks/validators/config/baseline_identity_constants.py +12 -0
  47. package/hooks/validators/config/directory_exemption_constants.py +42 -8
  48. package/hooks/validators/config/system_temporary_roots_constants.py +15 -0
  49. package/hooks/validators/config/validator_base_constants.py +9 -0
  50. package/hooks/validators/fast_save_validators.py +23 -80
  51. package/hooks/validators/mypy_integration.py +10 -36
  52. package/hooks/validators/pr_reference_checks.py +2 -2
  53. package/hooks/validators/project_roots.py +58 -0
  54. package/hooks/validators/python_antipattern_checks.py +3 -3
  55. package/hooks/validators/python_style_checks.py +3 -2
  56. package/hooks/validators/react_checks.py +2 -9
  57. package/hooks/validators/run_all_validators.py +247 -148
  58. package/hooks/validators/security_checks.py +3 -3
  59. package/hooks/validators/system_temporary_roots.py +1 -1
  60. package/hooks/validators/test_directory_exemption_constants.py +30 -24
  61. package/hooks/validators/test_fast_save_validators.py +16 -16
  62. package/hooks/validators/test_hook_timing_harness.py +10 -1
  63. package/hooks/validators/test_run_all_validators.py +22 -0
  64. package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +249 -0
  65. package/hooks/validators/test_run_all_validators_pretooluse.py +161 -0
  66. package/hooks/validators/test_run_all_validators_reporting.py +66 -0
  67. package/hooks/validators/test_run_all_validators_repository_root.py +202 -0
  68. package/hooks/validators/test_validator_base.py +51 -2
  69. package/hooks/validators/todo_checks.py +2 -2
  70. package/hooks/validators/type_safety_checks.py +3 -3
  71. package/hooks/validators/useless_test_checks.py +3 -3
  72. package/hooks/validators/validator_base.py +73 -1
  73. package/package.json +1 -1
  74. package/rules/shell-invocation.md +1 -1
  75. package/rules/testing.md +4 -0
  76. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +19 -0
  77. package/scripts/dev_env_scripts_constants/shared_tree_constants.py +17 -0
  78. package/scripts/grok_headless_runner.py +15 -12
  79. package/scripts/shared_tree_paths.py +104 -0
  80. package/scripts/test_shared_tree_paths.py +173 -0
@@ -0,0 +1,118 @@
1
+ """Repository-root exemption tests for code_rules_enforcer and the classifier.
2
+
3
+ A path inside the session's repository root is never ephemeral, whatever
4
+ directory the repository itself is checked out under. These tests cover the
5
+ classifier directly and the enforcer's real PreToolUse Write path, plus the
6
+ guard cases a too-broad fix could break: an absent cwd, a cwd that covers no
7
+ repository, and an unrelated sibling worktree that merely shares a name
8
+ prefix under /tmp.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+
15
+ from blocking import _path_setup # noqa: F401 (pins this checkout ahead of sibling worktrees)
16
+ from blocking.code_rules_enforcer import main as enforcer_main
17
+ from blocking.code_rules_shared import is_ephemeral_script_path
18
+ from code_rules_enforcer_test_support import run_serialized_payload_entrypoint
19
+
20
+ _VIOLATING_PRODUCTION_SOURCE = "def process_data(payload: str) -> None:\n print(payload)\n"
21
+
22
+
23
+ def _build_write_payload_with_cwd(file_path: str, content: str, cwd: str) -> str:
24
+ """Build the JSON payload for a Write tool call that also carries a cwd."""
25
+ return json.dumps(
26
+ {
27
+ "tool_name": "Write",
28
+ "tool_input": {"file_path": file_path, "content": content},
29
+ "cwd": cwd,
30
+ }
31
+ )
32
+
33
+
34
+ def _run_main_with_write_payload_and_cwd(
35
+ file_path: str,
36
+ content: str,
37
+ cwd: str,
38
+ ) -> tuple[str, int]:
39
+ """Drive enforcer_main through its stdin entry point for a Write payload carrying cwd."""
40
+ captured_stdout, exit_code = run_serialized_payload_entrypoint(
41
+ enforcer_main, _build_write_payload_with_cwd(file_path, content, cwd)
42
+ )
43
+ return captured_stdout, int(exit_code or 0)
44
+
45
+
46
+ def test_should_return_false_for_file_inside_repository_root_under_tmp() -> None:
47
+ """R1: a path inside repository_root is never ephemeral, even under /tmp."""
48
+ repository_root = "/tmp/pr-alpha"
49
+ file_path = "/tmp/pr-alpha/hooks/blocking/orders.py"
50
+ assert is_ephemeral_script_path(file_path, repository_root) is False
51
+
52
+
53
+ def test_should_return_true_for_sibling_directory_outside_repository_root() -> None:
54
+ """R2: a /tmp sibling that merely shares repository_root's name prefix stays ephemeral."""
55
+ repository_root = "/tmp/pr-alpha"
56
+ sibling_file_path = "/tmp/pr-alpha-sibling/hooks/orders.py"
57
+ assert is_ephemeral_script_path(sibling_file_path, repository_root) is True
58
+
59
+
60
+ def test_should_return_true_when_repository_root_does_not_cover_target() -> None:
61
+ """R3: an unrelated cwd must not blanket-exempt a /tmp target outside it."""
62
+ unrelated_repository_root = "/tmp/scratch-workspace"
63
+ scratch_target = "/tmp/scratch-elsewhere/notes.py"
64
+ assert is_ephemeral_script_path(scratch_target, unrelated_repository_root) is True
65
+
66
+
67
+ def test_should_deny_pretooluse_target_inside_repository_root_under_tmp() -> None:
68
+ """B22: a production file inside a repository checked out under /tmp is gated for real."""
69
+ repository_root = "/tmp/pr-alpha"
70
+ file_path = f"{repository_root}/hooks/blocking/orders.py"
71
+ captured_stdout, exit_code = _run_main_with_write_payload_and_cwd(
72
+ file_path,
73
+ _VIOLATING_PRODUCTION_SOURCE,
74
+ repository_root,
75
+ )
76
+ assert "deny" in captured_stdout.lower(), (
77
+ f"enforcer must gate a repository file under /tmp, got exit_code={exit_code}, "
78
+ f"stdout={captured_stdout!r}"
79
+ )
80
+
81
+
82
+ def test_should_stay_exempt_for_scratch_outside_repository_root() -> None:
83
+ """B23: a genuine scratch file outside the repository stays exempt."""
84
+ repository_root = "/tmp/pr-alpha"
85
+ scratch_target = "/tmp/scratch/one_off.py"
86
+ captured_stdout, exit_code = _run_main_with_write_payload_and_cwd(
87
+ scratch_target,
88
+ _VIOLATING_PRODUCTION_SOURCE,
89
+ repository_root,
90
+ )
91
+ assert exit_code == 0
92
+ assert "deny" not in captured_stdout.lower()
93
+
94
+
95
+ def test_should_stay_exempt_when_working_directory_covers_no_repository() -> None:
96
+ """B24: a cwd that covers no repository must not exempt an unrelated /tmp target."""
97
+ unrelated_working_directory = "/tmp/scratch-workspace"
98
+ scratch_target = "/tmp/scratch-elsewhere/notes.py"
99
+ captured_stdout, exit_code = _run_main_with_write_payload_and_cwd(
100
+ scratch_target,
101
+ _VIOLATING_PRODUCTION_SOURCE,
102
+ unrelated_working_directory,
103
+ )
104
+ assert exit_code == 0
105
+ assert "deny" not in captured_stdout.lower()
106
+
107
+
108
+ def test_should_stay_exempt_for_unrelated_worktree_sibling_under_tmp() -> None:
109
+ """B25: a sibling worktree sharing repository_root's name prefix stays exempt."""
110
+ repository_root = "/tmp/pr-alpha"
111
+ unrelated_sibling_target = "/tmp/pr-alpha-sibling/hooks/orders.py"
112
+ captured_stdout, exit_code = _run_main_with_write_payload_and_cwd(
113
+ unrelated_sibling_target,
114
+ _VIOLATING_PRODUCTION_SOURCE,
115
+ repository_root,
116
+ )
117
+ assert exit_code == 0
118
+ assert "deny" not in captured_stdout.lower()
@@ -16,6 +16,7 @@ in-process AST scan the production enforcer runs.
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
+ import ast
19
20
  import importlib.util
20
21
  import pathlib
21
22
  import sys
@@ -34,6 +35,20 @@ _hook_module = importlib.util.module_from_spec(_hook_spec)
34
35
  _hook_spec.loader.exec_module(_hook_module)
35
36
  check_same_file_inline_duplicate_body = _hook_module.check_same_file_inline_duplicate_body
36
37
 
38
+ _duplicate_body_spec = importlib.util.spec_from_file_location(
39
+ "code_rules_duplicate_body",
40
+ _HOOK_DIRECTORY / "code_rules_duplicate_body.py",
41
+ )
42
+ assert _duplicate_body_spec is not None
43
+ assert _duplicate_body_spec.loader is not None
44
+ _duplicate_body_module = importlib.util.module_from_spec(_duplicate_body_spec)
45
+ _duplicate_body_spec.loader.exec_module(_duplicate_body_module)
46
+
47
+ MEMO_CORPUS_MODULE_GLOB = "code_rules_*.py"
48
+ TEST_MODULE_NAME_PREFIX = "test_"
49
+ MINIMUM_MEMO_CORPUS_MODULE_COUNT = 20
50
+ EXTRA_DUPLICATE_PAIRS_BEYOND_THE_CEILING = 5
51
+
37
52
 
38
53
  PR_470_SHAPE_SOURCE = (
39
54
  "import asyncio\n"
@@ -464,3 +479,239 @@ def test_should_carry_both_helper_and_enclosing_spans_for_the_commit_gate_scoper
464
479
  "the union the PreToolUse path scopes on — not the helper span alone, "
465
480
  f"got: {matching_issues[0]}"
466
481
  )
482
+
483
+
484
+ NO_INLINE_DUPLICATE_SOURCE = (
485
+ "async def _wait_for_content_image_to_render(automation: object) -> None:\n"
486
+ " assert automation.detector is not None\n"
487
+ " await automation.detector.wait_for_element(selector)\n"
488
+ " logger.info('rendered')\n"
489
+ "\n"
490
+ "\n"
491
+ "async def _do_other_work(automation: object) -> None:\n"
492
+ " await automation.cdp.reload()\n"
493
+ " logger.info('reloaded')\n"
494
+ " return None\n"
495
+ )
496
+
497
+
498
+ def _memo_corpus_module_paths() -> list[pathlib.Path]:
499
+ """Return the production code-rules modules used as a statement corpus.
500
+
501
+ Returns:
502
+ Every non-test ``code_rules_*.py`` module in the blocking hook directory,
503
+ in sorted order, which supplies thousands of real statements of every
504
+ shape the scan meets in practice.
505
+ """
506
+ return sorted(
507
+ each_path
508
+ for each_path in _HOOK_DIRECTORY.glob(MEMO_CORPUS_MODULE_GLOB)
509
+ if not each_path.name.startswith(TEST_MODULE_NAME_PREFIX)
510
+ )
511
+
512
+
513
+ def _all_statements_in_module(module_path: pathlib.Path) -> list[ast.stmt]:
514
+ """Return every statement node parsed from a repository module.
515
+
516
+ The returned list holds the nodes, so they stay alive across both memo passes
517
+ and the second pass reads the very objects the first pass recorded.
518
+
519
+ Args:
520
+ module_path: The repository module to parse.
521
+
522
+ Returns:
523
+ Every statement node in the module, in walk order.
524
+ """
525
+ parsed_module = ast.parse(module_path.read_text(encoding="utf-8"))
526
+ return [
527
+ each_node
528
+ for each_node in ast.walk(parsed_module)
529
+ if isinstance(each_node, ast.stmt)
530
+ ]
531
+
532
+
533
+ def _memo_disagreements(
534
+ all_statements: list[ast.stmt],
535
+ dump_by_statement: dict[ast.stmt, str],
536
+ module_name: str,
537
+ ) -> list[str]:
538
+ """Return a location for every statement the memo dumps differently.
539
+
540
+ Args:
541
+ all_statements: The statement nodes to compare.
542
+ dump_by_statement: The memo under test, mutated in place.
543
+ module_name: The module name to name in a disagreement location.
544
+
545
+ Returns:
546
+ A ``module:line`` location per statement whose memoized dump differs from
547
+ the uncached dump, and an empty list when the memo is transparent.
548
+ """
549
+ return [
550
+ f"{module_name}:{each_statement.lineno}"
551
+ for each_statement in all_statements
552
+ if _duplicate_body_module._memoized_statement_dump(
553
+ each_statement, dump_by_statement
554
+ )
555
+ != _duplicate_body_module._normalized_statement_dump(each_statement)
556
+ ]
557
+
558
+
559
+ def _cold_and_warm_memo_disagreements(
560
+ all_module_paths: list[pathlib.Path],
561
+ ) -> tuple[list[str], list[str]]:
562
+ """Compare memoized against uncached dumps twice over the same statements.
563
+
564
+ The first pass over a module fills the memo and the second reads it warm, so
565
+ both the computed value and the served value are held against the uncached
566
+ dump.
567
+
568
+ Args:
569
+ all_module_paths: The corpus modules to parse and compare.
570
+
571
+ Returns:
572
+ The cold-pass disagreement locations and the warm-pass disagreement
573
+ locations.
574
+ """
575
+ dump_by_statement: dict[ast.stmt, str] = {}
576
+ all_cold_disagreements: list[str] = []
577
+ all_warm_disagreements: list[str] = []
578
+ for each_path in all_module_paths:
579
+ all_statements = _all_statements_in_module(each_path)
580
+ all_cold_disagreements += _memo_disagreements(
581
+ all_statements, dump_by_statement, each_path.name
582
+ )
583
+ all_warm_disagreements += _memo_disagreements(
584
+ all_statements, dump_by_statement, each_path.name
585
+ )
586
+ assert dump_by_statement, "the memo recorded nothing, so nothing was proven"
587
+ return all_cold_disagreements, all_warm_disagreements
588
+
589
+
590
+ def test_should_memoize_every_repository_statement_dump_transparently() -> None:
591
+ """The memo must be invisible: every dump it serves equals the uncached dump.
592
+
593
+ The inline scan's speed rests on serving each statement's dump from a memo,
594
+ which makes the memo the one place a behavior change could hide. Real
595
+ repository modules supply the statements, so the proof covers every statement
596
+ shape the scan meets in this repository.
597
+ """
598
+ all_module_paths = _memo_corpus_module_paths()
599
+ assert len(all_module_paths) >= MINIMUM_MEMO_CORPUS_MODULE_COUNT, (
600
+ "the transparency proof needs a real corpus, but the glob found "
601
+ f"only {len(all_module_paths)} modules"
602
+ )
603
+ all_cold, all_warm = _cold_and_warm_memo_disagreements(all_module_paths)
604
+ assert all_cold == [], (
605
+ "a cold memo must compute the same dump the uncached helper computes, "
606
+ f"but these statements disagreed: {all_cold[:10]}"
607
+ )
608
+ assert all_warm == [], (
609
+ "a warm memo must serve the same dump the uncached helper computes, "
610
+ f"but these statements disagreed: {all_warm[:10]}"
611
+ )
612
+
613
+
614
+ def test_should_report_the_same_verdict_when_an_earlier_scan_used_the_same_path() -> None:
615
+ """No memo may outlive one scan, so an earlier scan cannot color a later one.
616
+
617
+ A dump memo that persisted between calls — keyed on a path, or on a node slot
618
+ a later parse could reuse — would let one file's statements answer for
619
+ another's. Two different sources are scanned under one path, then the first is
620
+ scanned again, so a verdict that shifted on the repeat would expose the leak.
621
+ """
622
+ first_duplicate_issues = check_same_file_inline_duplicate_body(
623
+ PR_470_SHAPE_SOURCE, "account_switcher.py"
624
+ )
625
+ clean_issues = check_same_file_inline_duplicate_body(
626
+ NO_INLINE_DUPLICATE_SOURCE, "account_switcher.py"
627
+ )
628
+ repeat_duplicate_issues = check_same_file_inline_duplicate_body(
629
+ PR_470_SHAPE_SOURCE, "account_switcher.py"
630
+ )
631
+ assert first_duplicate_issues, "the duplicate source must flag on its own"
632
+ assert clean_issues == [], (
633
+ "the clean source must not inherit a verdict from the duplicate source "
634
+ f"scanned before it under the same path, got: {clean_issues}"
635
+ )
636
+ assert repeat_duplicate_issues == first_duplicate_issues, (
637
+ "the duplicate source must produce the same verdict on a repeat scan, "
638
+ f"got {repeat_duplicate_issues} after {first_duplicate_issues}"
639
+ )
640
+
641
+
642
+ def test_should_read_no_function_when_the_file_holds_no_qualifying_helper() -> None:
643
+ """A file with no candidate helper is answered without reading any function.
644
+
645
+ Most files carry no function that clears the helper-window filter, so the scan
646
+ leaves them after that filter and takes no per-function readings. The memo
647
+ stays empty in that case, which both proves the readings were skipped and
648
+ pins the verdict such a file gets.
649
+ """
650
+ all_module_functions = ast.parse(NO_INLINE_DUPLICATE_SOURCE).body
651
+ dump_by_statement: dict[ast.stmt, str] = {}
652
+ all_windows = [
653
+ _duplicate_body_module._helper_match_window_dumps(
654
+ each_function, dump_by_statement
655
+ )
656
+ for each_function in all_module_functions
657
+ if isinstance(each_function, ast.FunctionDef | ast.AsyncFunctionDef)
658
+ ]
659
+ assert all(each_window is None for each_window in all_windows), (
660
+ "this source must hold no qualifying helper for the skip to be exercised, "
661
+ f"got windows: {all_windows}"
662
+ )
663
+ assert dump_by_statement == {}, (
664
+ "a source with no qualifying helper must take no statement dumps, "
665
+ f"got {len(dump_by_statement)} memo entries"
666
+ )
667
+ issues = check_same_file_inline_duplicate_body(
668
+ NO_INLINE_DUPLICATE_SOURCE, "module.py"
669
+ )
670
+ assert issues == [], f"a file with no qualifying helper must not flag, got: {issues}"
671
+
672
+
673
+ def _duplicated_helper_pair_source(pair_index: int) -> str:
674
+ """Return one helper and one strictly larger function that inlines its body.
675
+
676
+ Each pair carries its own variable name, so a helper matches only the
677
+ enclosing function of its own pair and every pair contributes one report.
678
+
679
+ Args:
680
+ pair_index: The number distinguishing this pair from the others.
681
+
682
+ Returns:
683
+ Source text holding the helper and its enclosing function.
684
+ """
685
+ duplicated_block = (
686
+ " try:\n"
687
+ f" record_{pair_index} = records.load({pair_index})\n"
688
+ " except KeyError:\n"
689
+ " logger.warning('record missing')\n"
690
+ f" record_{pair_index} = None\n"
691
+ f" return record_{pair_index}\n"
692
+ )
693
+ return (
694
+ f"def load_record_{pair_index}(records):\n{duplicated_block}\n\n"
695
+ f"def refresh_record_{pair_index}(records):\n"
696
+ f" records.begin({pair_index})\n{duplicated_block}\n\n"
697
+ )
698
+
699
+
700
+ def test_should_cap_the_reported_issue_count_at_the_module_ceiling() -> None:
701
+ """A file full of inline duplicates reports the ceiling, not one per pair.
702
+
703
+ The blocking payload a single write can carry is bounded, so a file holding
704
+ more duplicated helpers than the ceiling still reports exactly the ceiling.
705
+ The source is built with more pairs than the ceiling allows, so a lost bound
706
+ would show up as a longer report.
707
+ """
708
+ ceiling = _duplicate_body_module.MAX_DUPLICATE_BODY_ISSUES
709
+ pair_count = ceiling + EXTRA_DUPLICATE_PAIRS_BEYOND_THE_CEILING
710
+ source = "".join(
711
+ _duplicated_helper_pair_source(each_index) for each_index in range(pair_count)
712
+ )
713
+ issues = check_same_file_inline_duplicate_body(source, "module.py")
714
+ assert len(issues) == ceiling, (
715
+ f"{pair_count} duplicated pairs must report the {ceiling}-issue ceiling, "
716
+ f"got {len(issues)} issues"
717
+ )
@@ -177,6 +177,28 @@ def test_unreadable_prior_yields_no_prior_and_no_reconstruction() -> None:
177
177
  assert post_edit_content is None
178
178
 
179
179
 
180
+ def test_prior_and_post_edit_content_honors_replace_all(
181
+ tmp_path_factory: pytest.TempPathFactory,
182
+ ) -> None:
183
+ """An Edit carrying replace_all rewrites every occurrence in the post-edit view.
184
+
185
+ The MultiEdit lane reconstructs through apply_edits and has honored the flag
186
+ since it landed there. The Edit lane must agree, or the enforcer judges a
187
+ replace_all Edit against a file that never exists on disk.
188
+ """
189
+ production_directory = tmp_path_factory.mktemp("replace_all_pkg")
190
+ source_file = production_directory / "edited_module.py"
191
+ source_file.write_text("value = 1\nvalue = 1\n", encoding="utf-8")
192
+ prior_content, post_edit_content = code_rules_enforcer.prior_and_post_edit_content(
193
+ str(source_file),
194
+ old_string="value = 1",
195
+ new_string="value = 2",
196
+ is_replace_all=True,
197
+ )
198
+ assert prior_content == "value = 1\nvalue = 1\n"
199
+ assert post_edit_content == "value = 2\nvalue = 2\n"
200
+
201
+
180
202
  def test_edit_with_missing_old_string_runs_whole_file_against_on_disk_content(
181
203
  tmp_path_factory: pytest.TempPathFactory,
182
204
  ) -> None:
@@ -68,7 +68,7 @@ def _printed_output_for(command: str, temp_directory: Path, tool_name: str = "Ba
68
68
  with (
69
69
  mock.patch("sys.stdin", io.StringIO(payload_text)),
70
70
  mock.patch("sys.stdout", captured_stdout),
71
- mock.patch.object(gate_module.tempfile, "gettempdir", return_value=str(temp_directory)),
71
+ mock.patch.object(_GATE_CONSTANTS.tempfile, "gettempdir", return_value=str(temp_directory)),
72
72
  pytest.raises(SystemExit),
73
73
  ):
74
74
  gate_module.main()
@@ -100,7 +100,8 @@ def _record_spawn(temp_directory: Path, session_id: str = _SESSION_ID) -> None:
100
100
  temp_directory: The directory standing in for the system temp root.
101
101
  """
102
102
  marker_name = (
103
- f"{gate_module.SPAWN_MARKER_FILE_PREFIX}{session_id}{gate_module.SPAWN_MARKER_FILE_SUFFIX}"
103
+ f"{_GATE_CONSTANTS.SPAWN_MARKER_FILE_PREFIX}{session_id}"
104
+ f"{_GATE_CONSTANTS.SPAWN_MARKER_FILE_SUFFIX}"
104
105
  )
105
106
  (temp_directory / marker_name).touch()
106
107
 
@@ -195,7 +196,7 @@ def should_deny_when_the_payload_names_no_session(tmp_path: Path) -> None:
195
196
  with (
196
197
  mock.patch("sys.stdin", io.StringIO(json.dumps(payload))),
197
198
  mock.patch("sys.stdout", captured_stdout),
198
- mock.patch.object(gate_module.tempfile, "gettempdir", return_value=str(tmp_path)),
199
+ mock.patch.object(_GATE_CONSTANTS.tempfile, "gettempdir", return_value=str(tmp_path)),
199
200
  pytest.raises(SystemExit),
200
201
  ):
201
202
  gate_module.main()
@@ -214,3 +215,20 @@ def should_name_both_ways_forward_in_the_denial(tmp_path: Path) -> None:
214
215
  denial_reason = json.loads(printed_text)["hookSpecificOutput"]["permissionDecisionReason"]
215
216
  assert _PR_DESCRIPTION_WRITER_SUBAGENT_TYPE in denial_reason
216
217
  assert gate_module.SPAWN_BYPASS_MARKER in denial_reason
218
+
219
+
220
+ def should_read_the_marker_path_the_shared_builder_produces(tmp_path: Path) -> None:
221
+ """The gate reads exactly the file the shared path builder names.
222
+
223
+ The tracker writes through `spawn_marker_path` and the gate reads through it,
224
+ so neither side owns a private copy of the marker file name.
225
+ """
226
+ marker_file = _GATE_CONSTANTS.spawn_marker_path(_SESSION_ID, temp_directory=tmp_path)
227
+ assert marker_file is not None
228
+ marker_file.touch()
229
+ assert _decision_for(_PLAIN_CREATE_COMMAND, tmp_path) == ""
230
+
231
+
232
+ def should_deny_when_the_session_id_names_no_marker(tmp_path: Path) -> None:
233
+ """A session id that sanitizes to nothing yields no path, so the gate denies."""
234
+ assert _GATE_CONSTANTS.spawn_marker_path("", temp_directory=tmp_path) is None
@@ -7,7 +7,10 @@ text. It also asserts the dispatcher reaches the same decision through its nativ
7
7
  path.
8
8
 
9
9
  The corpus pairs allowing payloads with denying payloads for each hook so the
10
- equivalence holds across both outcomes.
10
+ equivalence holds across both outcomes. Each payload names an absolute file
11
+ under this repository and carries the repository root as its ``cwd``, so the
12
+ decision belongs to this checkout rather than to the directory pytest runs
13
+ from.
11
14
  """
12
15
 
13
16
  from __future__ import annotations
@@ -43,7 +46,13 @@ import state_description_blocker # noqa: E402, I001
43
46
  _STATE_DESCRIPTION_SCRIPT = str(_BLOCKING_DIR / "state_description_blocker.py")
44
47
  _DISPATCHER_SCRIPT = str(_BLOCKING_DIR / "pre_tool_use_dispatcher.py")
45
48
 
46
- _MARKDOWN_PATH = "docs/native_equivalence_probe.md"
49
+ from code_rules_enforcer_test_support import ( # noqa: E402
50
+ repository_file_path,
51
+ repository_root,
52
+ )
53
+
54
+ _REPOSITORY_ROOT = str(repository_root())
55
+ _MARKDOWN_PATH = repository_file_path("docs/native_equivalence_probe.md")
47
56
  _STATE_DESCRIPTION_ALLOW_CONTENT = "# Guide\n\nThe API uses port 8080.\n"
48
57
  _STATE_DESCRIPTION_DENY_CONTENT = "# Guide\n\nPreviously the system used port 8080.\n"
49
58
 
@@ -74,6 +83,7 @@ def _write_payload_dictionary(file_path: str, content: str) -> dict[str, object]
74
83
  return {
75
84
  "tool_name": WRITE_TOOL_NAME,
76
85
  "tool_input": {"file_path": file_path, "content": content},
86
+ "cwd": _REPOSITORY_ROOT,
77
87
  }
78
88
 
79
89
  def _edit_payload_dictionary(file_path: str, new_string: str) -> dict[str, object]:
@@ -93,6 +103,7 @@ def _edit_payload_dictionary(file_path: str, new_string: str) -> dict[str, objec
93
103
  "old_string": "old line",
94
104
  "new_string": new_string,
95
105
  },
106
+ "cwd": _REPOSITORY_ROOT,
96
107
  }
97
108
 
98
109
  def _multi_edit_payload_dictionary(file_path: str, new_string: str) -> dict[str, object]:
@@ -111,6 +122,7 @@ def _multi_edit_payload_dictionary(file_path: str, new_string: str) -> dict[str,
111
122
  "file_path": file_path,
112
123
  "edits": [{"old_string": "old line", "new_string": new_string}],
113
124
  },
125
+ "cwd": _REPOSITORY_ROOT,
114
126
  }
115
127
 
116
128
 
@@ -191,3 +191,108 @@ def test_denies_nested_command_substitution_without_arithmetic() -> None:
191
191
  }
192
192
  response = json.loads(_run_hook(payload).stdout)
193
193
  assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
194
+
195
+
196
+ def test_allows_backtick_inside_single_quoted_heredoc_body() -> None:
197
+ payload = {
198
+ "tool_name": "Bash",
199
+ "tool_input": {
200
+ "command": (
201
+ "cat > notes.md <<'EOF'\n"
202
+ "reStructuredText markup: ``inert literal``\n"
203
+ "EOF"
204
+ )
205
+ },
206
+ }
207
+ assert _run_hook(payload).stdout == ""
208
+
209
+
210
+ def test_allows_backtick_inside_tab_stripped_quoted_heredoc_body() -> None:
211
+ """A ``<<-`` opener closes on a tab-indented terminator, and stays inert.
212
+
213
+ The tab-strip rule decides which line ends the body. Getting it wrong
214
+ swallows the rest of the command or ends the body early, so the branch is
215
+ pinned here as well as in the shared pipeline that owns the rule.
216
+ """
217
+ payload = {
218
+ "tool_name": "Bash",
219
+ "tool_input": {
220
+ "command": (
221
+ "cat > notes.md <<-'EOF'\n"
222
+ "reStructuredText markup: ``inert literal``\n"
223
+ "\tEOF\n"
224
+ "echo done"
225
+ )
226
+ },
227
+ }
228
+ assert _run_hook(payload).stdout == ""
229
+
230
+
231
+ def test_denies_backtick_inside_bare_heredoc_body() -> None:
232
+ payload = {
233
+ "tool_name": "Bash",
234
+ "tool_input": {"command": "cat > notes.md <<EOF\nnow: `date`\nEOF"},
235
+ }
236
+ response = json.loads(_run_hook(payload).stdout)
237
+ assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
238
+
239
+
240
+ def test_denies_command_substitution_inside_bare_heredoc_body() -> None:
241
+ payload = {
242
+ "tool_name": "Bash",
243
+ "tool_input": {
244
+ "command": "cat > notes.md <<EOF\nhead: $(git rev-parse HEAD)\nEOF"
245
+ },
246
+ }
247
+ response = json.loads(_run_hook(payload).stdout)
248
+ assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
249
+
250
+
251
+ def test_denies_live_substitution_outside_a_quoted_heredoc() -> None:
252
+ payload = {
253
+ "tool_name": "Bash",
254
+ "tool_input": {
255
+ "command": (
256
+ "cat > notes.md <<'EOF'\n"
257
+ "inert ``literal``\n"
258
+ "EOF\n"
259
+ 'echo "$(git rev-parse HEAD)"'
260
+ )
261
+ },
262
+ }
263
+ response = json.loads(_run_hook(payload).stdout)
264
+ assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
265
+
266
+
267
+ def test_allows_backslash_escaped_heredoc_delimiter_body() -> None:
268
+ payload = {
269
+ "tool_name": "Bash",
270
+ "tool_input": {
271
+ "command": "cat > notes.md <<\\EOF\ninert `text`\nEOF"
272
+ },
273
+ }
274
+ assert _run_hook(payload).stdout == ""
275
+
276
+
277
+ def test_denies_a_substitution_below_an_opener_inside_a_quoted_string() -> None:
278
+ """An opener spelled inside a quoted string opens no heredoc.
279
+
280
+ Bash runs the second line here. Reading the first line as an opener and
281
+ dropping everything below it hides a live substitution from the scan.
282
+ """
283
+ payload = {
284
+ "tool_name": "Bash",
285
+ "tool_input": {"command": 'echo "<<\'EOF\'"\necho $(whoami)'},
286
+ }
287
+ response = json.loads(_run_hook(payload).stdout)
288
+ assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
289
+
290
+
291
+ def test_denies_that_bypass_even_when_a_terminator_line_follows() -> None:
292
+ """A terminator line below the substitution does not make the strip safe."""
293
+ payload = {
294
+ "tool_name": "Bash",
295
+ "tool_input": {"command": 'echo "<<\'EOF\'"\necho $(whoami)\nEOF'},
296
+ }
297
+ response = json.loads(_run_hook(payload).stdout)
298
+ assert response["hookSpecificOutput"]["permissionDecision"] == "deny"