claude-dev-env 1.75.0 → 1.77.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 (47) hide show
  1. package/_shared/pr-loop/scripts/code_rules_gate.py +60 -5
  2. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/inline_duplicate_body_span_constants.py +22 -0
  4. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +147 -3
  5. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -0
  6. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +8 -4
  7. package/docs/CODE_RULES.md +1 -1
  8. package/hooks/blocking/CLAUDE.md +2 -2
  9. package/hooks/blocking/claude_md_orphan_file_blocker.py +170 -20
  10. package/hooks/blocking/code_rules_docstrings.py +378 -0
  11. package/hooks/blocking/code_rules_duplicate_body.py +378 -26
  12. package/hooks/blocking/code_rules_enforcer.py +48 -5
  13. package/hooks/blocking/code_rules_imports_logging.py +679 -1
  14. package/hooks/blocking/code_rules_shared.py +8 -5
  15. package/hooks/blocking/code_rules_test_assertions.py +6 -7
  16. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +484 -0
  17. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  18. package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +174 -0
  19. package/hooks/blocking/test_code_rules_enforcer_docstring_cardinal_family.py +176 -0
  20. package/hooks/blocking/test_code_rules_enforcer_docstring_runon_sentence.py +267 -0
  21. package/hooks/blocking/test_code_rules_enforcer_import_block_sort.py +157 -0
  22. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +466 -0
  23. package/hooks/blocking/test_code_rules_enforcer_split_test_assertions.py +11 -9
  24. package/hooks/blocking/test_code_rules_js_resume_task_enumeration.py +758 -0
  25. package/hooks/blocking/test_code_rules_logging_printf_tokens.py +134 -0
  26. package/hooks/blocking/test_verification_verdict_store.py +66 -1
  27. package/hooks/blocking/test_verifier_verdict_minter.py +64 -5
  28. package/hooks/blocking/verification_verdict_store.py +19 -5
  29. package/hooks/blocking/verifier_verdict_minter.py +18 -15
  30. package/hooks/hooks_constants/blocking_check_limits.py +56 -0
  31. package/hooks/hooks_constants/claude_md_orphan_file_blocker_constants.py +52 -24
  32. package/hooks/hooks_constants/code_rules_enforcer_constants.py +76 -1
  33. package/hooks/hooks_constants/duplicate_function_body_constants.py +21 -5
  34. package/package.json +1 -1
  35. package/rules/CLAUDE.md +1 -0
  36. package/rules/claude-md-orphan-file.md +7 -8
  37. package/rules/docstring-prose-matches-implementation.md +5 -1
  38. package/rules/package-inventory-stale-entry.md +16 -0
  39. package/rules/plain-illustrative-docstrings.md +56 -0
  40. package/skills/anthropic-plan/CLAUDE.md +1 -1
  41. package/skills/anthropic-plan/SKILL.md +15 -2
  42. package/skills/autoconverge/workflow/converge.contract.test.mjs +12 -19
  43. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +71 -0
  44. package/skills/autoconverge/workflow/converge.mjs +86 -110
  45. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +58 -4
  46. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +9 -0
  47. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +42 -0
@@ -4,7 +4,7 @@ import ast
4
4
  import difflib
5
5
  import os
6
6
  import sys
7
- from collections.abc import Iterator
7
+ from collections.abc import Collection, Iterator
8
8
  from pathlib import Path
9
9
 
10
10
  _blocking_directory = str(Path(__file__).resolve().parent)
@@ -360,7 +360,7 @@ def changed_line_numbers(prior_content: str, post_edit_content: str) -> set[int]
360
360
 
361
361
 
362
362
  def _scope_violations_to_changed_lines(
363
- all_violations_in_walk_order: list[tuple[range, str]],
363
+ all_violations_in_walk_order: list[tuple[Collection[int], str]],
364
364
  all_changed_lines: set[int] | None,
365
365
  defer_scope_to_caller: bool = False,
366
366
  ) -> list[str]:
@@ -381,9 +381,12 @@ def _scope_violations_to_changed_lines(
381
381
  block a single-file edit.
382
382
 
383
383
  Args:
384
- all_violations_in_walk_order: ``(span_range, issue_message)`` pairs in
385
- ``ast.walk`` traversal order, where ``span_range`` covers the
386
- violation's source lines.
384
+ all_violations_in_walk_order: ``(line_collection, issue_message)`` pairs
385
+ in ``ast.walk`` traversal order, where ``line_collection`` holds the
386
+ violation's source lines. A whole-function violation passes a
387
+ contiguous ``range``; a two-function violation passes the union of
388
+ both functions' lines, so the lines between the two functions stay
389
+ out of scope and an edit to only that gap does not block.
387
390
  all_changed_lines: Post-edit line numbers the current edit touched, or
388
391
  None to treat every violation as in-scope.
389
392
  defer_scope_to_caller: When True, return every violation message in walk
@@ -286,7 +286,7 @@ def _name_encodes_scenario(test_name: str) -> bool:
286
286
  return any(each_clause in test_name for each_clause in _SCENARIO_NAME_CLAUSES)
287
287
 
288
288
 
289
- def check_flag_gated_scenario_test_naming(content: str, file_path: str) -> list[str]:
289
+ def check_flag_gated_scenario_test_naming(content: str, file_path: str) -> None:
290
290
  """Flag a scenario-named test that omits a flag its siblings establish.
291
291
 
292
292
  When two or more sibling tests in a file monkeypatch the same module-level
@@ -302,15 +302,16 @@ def check_flag_gated_scenario_test_naming(content: str, file_path: str) -> list[
302
302
  file_path: Path to the file, used for the test-file gate.
303
303
 
304
304
  Returns:
305
- An empty list; advisories print to stderr so the write proceeds.
305
+ None; advisories print to stderr and this check never contributes to
306
+ the blocking issue list, so the write proceeds.
306
307
  """
307
308
  if not is_test_file(file_path):
308
- return []
309
+ return
309
310
 
310
311
  try:
311
312
  syntax_tree = ast.parse(content)
312
313
  except SyntaxError:
313
- return []
314
+ return
314
315
 
315
316
  test_functions = [
316
317
  each_node
@@ -333,7 +334,7 @@ def check_flag_gated_scenario_test_naming(content: str, file_path: str) -> list[
333
334
  if patch_count >= _MINIMUM_SIBLING_PATCH_COUNT
334
335
  }
335
336
  if not established_flags:
336
- return []
337
+ return
337
338
 
338
339
  for each_test in test_functions:
339
340
  if not _name_encodes_scenario(each_test.name):
@@ -349,8 +350,6 @@ def check_flag_gated_scenario_test_naming(content: str, file_path: str) -> list[
349
350
  file=sys.stderr,
350
351
  )
351
352
 
352
- return []
353
-
354
353
 
355
354
  def _called_function_names(function_node: ast.FunctionDef | ast.AsyncFunctionDef) -> set[str]:
356
355
  """Return the bare names of every function the test body calls."""
@@ -4,6 +4,7 @@ import json
4
4
  import os
5
5
  import subprocess
6
6
  import sys
7
+ import time
7
8
  from pathlib import Path
8
9
 
9
10
  import pytest
@@ -14,6 +15,7 @@ import claude_md_orphan_file_blocker as blocker_module
14
15
  from claude_md_orphan_file_blocker import (
15
16
  find_missing_filenames,
16
17
  find_referenced_filenames,
18
+ find_run_command_filenames,
17
19
  )
18
20
  from code_rules_annotations_length import check_unused_known_pytest_fixture_parameters
19
21
  from code_rules_naming_collection import check_collection_prefix
@@ -27,6 +29,8 @@ HOOK_SCRIPT_PATH = os.path.join(os.path.dirname(__file__), "claude_md_orphan_fil
27
29
 
28
30
  REPO_ROOT = Path(__file__).resolve().parents[4]
29
31
 
32
+ ONE_SECOND = 1.0
33
+
30
34
  TABLE_WITH_PRESENT_FILE = (
31
35
  "# example\n\n| File | What it does |\n|---|---|\n| `present_module.py` | Does a thing |\n"
32
36
  )
@@ -612,6 +616,486 @@ def test_noise_directories_are_excluded_from_the_walk(tmp_path: Path):
612
616
  assert missing_filenames == ["buried_target.py"]
613
617
 
614
618
 
619
+ def test_blocks_write_when_run_command_invokes_absent_script(tmp_path: Path):
620
+ claude_md_path = _isolated_claude_md_path(tmp_path)
621
+ content = (
622
+ "# example\n\n"
623
+ "## Running / testing\n\n"
624
+ "Check environment readiness before a run:\n\n"
625
+ "```\n"
626
+ "C:\\Python313\\python.exe test_verification_ready.py\n"
627
+ "```\n"
628
+ )
629
+ result = _run_hook(
630
+ "Write",
631
+ {
632
+ "file_path": str(claude_md_path),
633
+ "content": content,
634
+ },
635
+ )
636
+ assert result.returncode == 0
637
+ output = json.loads(result.stdout)
638
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
639
+ assert (
640
+ "test_verification_ready.py"
641
+ in output["hookSpecificOutput"]["permissionDecisionReason"]
642
+ )
643
+
644
+
645
+ def test_allows_run_command_invoking_present_script(tmp_path: Path):
646
+ package_directory = tmp_path / "package_directory"
647
+ package_directory.mkdir()
648
+ (package_directory / "verify_ready.py").write_text("x = 1\n", encoding="utf-8")
649
+ claude_md_path = package_directory / "CLAUDE.md"
650
+ content = (
651
+ "# example\n\n"
652
+ "## Running / testing\n\n"
653
+ "```\n"
654
+ "C:\\Python313\\python.exe verify_ready.py\n"
655
+ "```\n"
656
+ )
657
+ result = _run_hook(
658
+ "Write",
659
+ {
660
+ "file_path": str(claude_md_path),
661
+ "content": content,
662
+ },
663
+ )
664
+ assert result.returncode == 0
665
+ assert result.stdout == ""
666
+
667
+
668
+ def test_run_command_invoking_path_qualified_present_script_is_allowed(tmp_path: Path):
669
+ tools_directory = tmp_path / "tools"
670
+ tools_directory.mkdir()
671
+ (tools_directory / "verify_themes.py").write_text("x = 1\n", encoding="utf-8")
672
+ claude_md_path = tools_directory / "CLAUDE.md"
673
+ content = (
674
+ "# example\n\n"
675
+ "## Running / testing\n\n"
676
+ "```\n"
677
+ 'C:\\Python313\\python.exe "tools/verify_themes.py" path/to/theme.apk\n'
678
+ "```\n"
679
+ )
680
+ result = _run_hook(
681
+ "Write",
682
+ {
683
+ "file_path": str(claude_md_path),
684
+ "content": content,
685
+ },
686
+ )
687
+ assert result.returncode == 0
688
+ assert result.stdout == ""
689
+
690
+
691
+ def test_find_run_command_filenames_reads_only_fenced_interpreter_lines():
692
+ content = (
693
+ "# example\n\n"
694
+ "Run `python live_module.py` inline — this prose line is not a fence.\n\n"
695
+ "```\n"
696
+ "C:\\Python313\\python.exe absent_script.py --flag value\n"
697
+ "node tools/build_bundle.mjs\n"
698
+ "echo not-a-script\n"
699
+ "```\n"
700
+ )
701
+ assert find_run_command_filenames(content) == [
702
+ "absent_script.py",
703
+ "build_bundle.mjs",
704
+ ]
705
+
706
+
707
+ def test_run_command_outside_a_fence_is_not_inspected(tmp_path: Path):
708
+ claude_md_path = _isolated_claude_md_path(tmp_path)
709
+ content = (
710
+ "# example\n\n"
711
+ "Run `python absent_script.py` to check readiness.\n"
712
+ )
713
+ result = _run_hook(
714
+ "Write",
715
+ {
716
+ "file_path": str(claude_md_path),
717
+ "content": content,
718
+ },
719
+ )
720
+ assert result.returncode == 0
721
+ assert result.stdout == ""
722
+
723
+
724
+ def test_run_command_orphan_reported_via_edit(tmp_path: Path):
725
+ claude_md_path = _isolated_claude_md_path(tmp_path)
726
+ (claude_md_path.parent / "kept.py").write_text("x = 1\n", encoding="utf-8")
727
+ claude_md_path.write_text(
728
+ "# example\n\n"
729
+ "## Running / testing\n\n"
730
+ "```\n"
731
+ "C:\\Python313\\python.exe kept.py\n"
732
+ "```\n",
733
+ encoding="utf-8",
734
+ )
735
+ result = _run_hook(
736
+ "Edit",
737
+ {
738
+ "file_path": str(claude_md_path),
739
+ "old_string": "C:\\Python313\\python.exe kept.py",
740
+ "new_string": "C:\\Python313\\python.exe removed_script.py",
741
+ },
742
+ )
743
+ assert result.returncode == 0
744
+ output = json.loads(result.stdout)
745
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
746
+ assert (
747
+ "removed_script.py"
748
+ in output["hookSpecificOutput"]["permissionDecisionReason"]
749
+ )
750
+
751
+
752
+ def test_run_command_interpreter_substring_in_word_is_not_matched():
753
+ content = (
754
+ "# example\n\n"
755
+ "```\n"
756
+ "git stash # WIP on parse.py\n"
757
+ "rush build # then run verify.py\n"
758
+ "dash run.py\n"
759
+ "ssh deploy@host ./remote_install.sh\n"
760
+ "git stash list build.sh\n"
761
+ "rebash deploy.sh\n"
762
+ "fish shell run.sh\n"
763
+ "```\n"
764
+ )
765
+ assert find_run_command_filenames(content) == []
766
+
767
+
768
+ def test_run_command_standalone_interpreter_still_matches():
769
+ content = (
770
+ "# example\n\n"
771
+ "```\n"
772
+ "python deploy.py\n"
773
+ "node build.mjs\n"
774
+ "C:\\Python313\\python.exe verify_ready.py\n"
775
+ "bash setup.sh\n"
776
+ "sh install.sh\n"
777
+ "node tools/build_bundle.mjs\n"
778
+ "```\n"
779
+ )
780
+ assert find_run_command_filenames(content) == [
781
+ "deploy.py",
782
+ "build.mjs",
783
+ "verify_ready.py",
784
+ "setup.sh",
785
+ "install.sh",
786
+ "build_bundle.mjs",
787
+ ]
788
+
789
+
790
+ def test_run_command_prose_mentioning_interpreter_and_script_yields_nothing():
791
+ content = (
792
+ "# example\n\n"
793
+ "```\n"
794
+ "python entrypoint crashed inside order_handler.py during startup\n"
795
+ "python: see config.py for details\n"
796
+ "node --version # build.mjs is the entry\n"
797
+ "python is great; edit build.py later\n"
798
+ "```\n"
799
+ )
800
+ assert find_run_command_filenames(content) == []
801
+
802
+
803
+ def test_run_command_invokes_absent_script_is_not_blocked_when_prose(tmp_path: Path):
804
+ claude_md_path = _isolated_claude_md_path(tmp_path)
805
+ content = (
806
+ "# example\n\n"
807
+ "## Running / testing\n\n"
808
+ "```\n"
809
+ "python entrypoint crashed inside order_handler.py during startup\n"
810
+ "```\n"
811
+ )
812
+ result = _run_hook(
813
+ "Write",
814
+ {
815
+ "file_path": str(claude_md_path),
816
+ "content": content,
817
+ },
818
+ )
819
+ assert result.returncode == 0
820
+ assert result.stdout == ""
821
+
822
+
823
+ def test_run_command_with_module_flag_and_value_still_matches():
824
+ content = (
825
+ "# example\n\n"
826
+ "```\n"
827
+ "python -m pytest tests/test_foo.py\n"
828
+ "python script.py --flag value\n"
829
+ "C:\\Python313\\python.exe \"tools/verify_themes.py\" path/to/theme.apk\n"
830
+ "node tools/build_bundle.mjs\n"
831
+ "pwsh build.ps1\n"
832
+ "```\n"
833
+ )
834
+ assert find_run_command_filenames(content) == [
835
+ "test_foo.py",
836
+ "script.py",
837
+ "verify_themes.py",
838
+ "build_bundle.mjs",
839
+ "build.ps1",
840
+ ]
841
+
842
+
843
+ def test_run_command_single_valueless_flag_before_path_captures_full_basename():
844
+ content = (
845
+ "# example\n\n"
846
+ "```\n"
847
+ "pwsh -File scripts/check.ps1\n"
848
+ "python3 -u app.py\n"
849
+ "python -u verify_ready.py\n"
850
+ "bash -c deploy.sh\n"
851
+ "node --experimental-vm-modules tools/build.mjs\n"
852
+ "```\n"
853
+ )
854
+ assert find_run_command_filenames(content) == [
855
+ "check.ps1",
856
+ "app.py",
857
+ "verify_ready.py",
858
+ "deploy.sh",
859
+ "build.mjs",
860
+ ]
861
+
862
+
863
+ def test_allows_run_command_with_valueless_flag_invoking_present_script(tmp_path: Path):
864
+ scripts_directory = tmp_path / "scripts"
865
+ scripts_directory.mkdir()
866
+ (scripts_directory / "check.ps1").write_text("Write-Host ok\n", encoding="utf-8")
867
+ claude_md_path = scripts_directory / "CLAUDE.md"
868
+ content = (
869
+ "# example\n\n"
870
+ "## Running / testing\n\n"
871
+ "```\n"
872
+ "pwsh -File scripts/check.ps1\n"
873
+ "```\n"
874
+ )
875
+ result = _run_hook(
876
+ "Write",
877
+ {
878
+ "file_path": str(claude_md_path),
879
+ "content": content,
880
+ },
881
+ )
882
+ assert result.returncode == 0
883
+ assert result.stdout == ""
884
+
885
+
886
+ def test_run_command_script_valued_flag_does_not_shadow_real_script():
887
+ content = (
888
+ "# example\n\n"
889
+ "```\n"
890
+ "node -r ./reg.js app.js\n"
891
+ "node --require setup.js main.js\n"
892
+ "node --import ./loader.mjs entry.mjs\n"
893
+ "node --loader ts-node/esm runner.ts\n"
894
+ "```\n"
895
+ )
896
+ assert find_run_command_filenames(content) == [
897
+ "app.js",
898
+ "main.js",
899
+ "entry.mjs",
900
+ "runner.ts",
901
+ ]
902
+
903
+
904
+ def test_allows_run_command_with_script_valued_flag_invoking_present_script(tmp_path: Path):
905
+ claude_md_path = _isolated_claude_md_path(tmp_path)
906
+ (claude_md_path.parent / "app.js").write_text("console.log('ok');\n", encoding="utf-8")
907
+ content = (
908
+ "# example\n\n"
909
+ "## Running / testing\n\n"
910
+ "```\n"
911
+ "node -r ./reg.js app.js\n"
912
+ "```\n"
913
+ )
914
+ result = _run_hook(
915
+ "Write",
916
+ {
917
+ "file_path": str(claude_md_path),
918
+ "content": content,
919
+ },
920
+ )
921
+ assert result.returncode == 0
922
+ assert result.stdout == ""
923
+
924
+
925
+ def test_run_command_chained_commands_each_contribute_their_basename():
926
+ content = (
927
+ "# example\n\n"
928
+ "```\n"
929
+ "python deploy.py && node build.mjs\n"
930
+ "python first.py; python second.py\n"
931
+ "python upstream.py | python downstream.py\n"
932
+ "```\n"
933
+ )
934
+ assert find_run_command_filenames(content) == [
935
+ "deploy.py",
936
+ "build.mjs",
937
+ "first.py",
938
+ "second.py",
939
+ "upstream.py",
940
+ "downstream.py",
941
+ ]
942
+
943
+
944
+ def test_run_command_commented_line_inside_fence_is_skipped():
945
+ content = (
946
+ "# example\n\n"
947
+ "```\n"
948
+ "# python deploy.py (commented out)\n"
949
+ " # node build.mjs\n"
950
+ "```\n"
951
+ )
952
+ assert find_run_command_filenames(content) == []
953
+
954
+
955
+ def test_run_command_with_relative_path_source_is_exempt():
956
+ content = (
957
+ "# example\n\n"
958
+ "```\n"
959
+ "python ../shared/preflight.py --check\n"
960
+ "python ../../tools/deploy.py\n"
961
+ "```\n"
962
+ )
963
+ assert find_run_command_filenames(content) == []
964
+
965
+
966
+ def test_run_command_relative_prose_region_exempts_bare_basename_command():
967
+ content = (
968
+ "# example\n\n"
969
+ "## Shared scripts (referenced from `../_shared/scripts/`)\n\n"
970
+ "```\n"
971
+ "python preflight.py\n"
972
+ "```\n"
973
+ )
974
+ assert find_run_command_filenames(content) == []
975
+
976
+
977
+ def test_run_command_relative_prose_region_exempts_run_command_via_write(tmp_path: Path):
978
+ claude_md_path = _isolated_claude_md_path(tmp_path)
979
+ content = (
980
+ "# example\n\n"
981
+ "## Shared scripts\n\n"
982
+ "Referenced from `../_shared/scripts/`:\n\n"
983
+ "```\n"
984
+ "python preflight.py\n"
985
+ "```\n"
986
+ )
987
+ result = _run_hook(
988
+ "Write",
989
+ {
990
+ "file_path": str(claude_md_path),
991
+ "content": content,
992
+ },
993
+ )
994
+ assert result.returncode == 0
995
+ assert result.stdout == ""
996
+
997
+
998
+ def test_run_command_relative_prose_does_not_exempt_distant_later_fence():
999
+ content = (
1000
+ "# example\n\n"
1001
+ "## Shared scripts\n\n"
1002
+ "Referenced from `../_shared/scripts/`:\n\n"
1003
+ "```\n"
1004
+ "python preflight.py\n"
1005
+ "```\n\n"
1006
+ "## Local scripts\n\n"
1007
+ "```\n"
1008
+ "python local_orphan.py\n"
1009
+ "```\n"
1010
+ )
1011
+ assert find_run_command_filenames(content) == ["local_orphan.py"]
1012
+
1013
+
1014
+ def test_run_command_relative_prose_does_not_exempt_second_fence_under_same_heading():
1015
+ content = (
1016
+ "# example\n\n"
1017
+ "## Shared scripts\n\n"
1018
+ "Referenced from `../_shared/scripts/`:\n\n"
1019
+ "```\n"
1020
+ "python preflight.py\n"
1021
+ "```\n\n"
1022
+ "Run the local check below:\n\n"
1023
+ "```\n"
1024
+ "python local_orphan.py\n"
1025
+ "```\n"
1026
+ )
1027
+ assert find_run_command_filenames(content) == ["local_orphan.py"]
1028
+
1029
+
1030
+ def test_run_command_long_multi_flag_no_script_line_returns_quickly():
1031
+ fenced_flags = " ".join(f"-x{each_index}" for each_index in range(200))
1032
+ content = "# example\n\n```\n" f"python {fenced_flags} endingword" "\n```\n"
1033
+ started_at = time.perf_counter()
1034
+ found_filenames = find_run_command_filenames(content)
1035
+ elapsed_seconds = time.perf_counter() - started_at
1036
+ assert found_filenames == []
1037
+ assert elapsed_seconds < ONE_SECOND
1038
+
1039
+
1040
+ def test_run_command_inline_trailing_comment_is_not_scanned():
1041
+ content = (
1042
+ "# example\n\n"
1043
+ "```\n"
1044
+ "python real.py # was: python old_build.py\n"
1045
+ "```\n"
1046
+ )
1047
+ assert find_run_command_filenames(content) == ["real.py"]
1048
+
1049
+
1050
+ def test_run_command_relative_path_exempt_while_bare_orphan_still_blocks(tmp_path: Path):
1051
+ claude_md_path = _isolated_claude_md_path(tmp_path)
1052
+ content = (
1053
+ "# example\n\n"
1054
+ "## Running / testing\n\n"
1055
+ "```\n"
1056
+ "python ../shared/preflight.py --check\n"
1057
+ "python absent_script.py\n"
1058
+ "```\n"
1059
+ )
1060
+ result = _run_hook(
1061
+ "Write",
1062
+ {
1063
+ "file_path": str(claude_md_path),
1064
+ "content": content,
1065
+ },
1066
+ )
1067
+ assert result.returncode == 0
1068
+ output = json.loads(result.stdout)
1069
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
1070
+ assert "absent_script.py" in output["hookSpecificOutput"]["permissionDecisionReason"]
1071
+ assert "preflight.py" not in output["hookSpecificOutput"]["permissionDecisionReason"]
1072
+
1073
+
1074
+ def test_run_command_orphan_preexisting_on_untouched_line_is_allowed(tmp_path: Path):
1075
+ claude_md_path = _isolated_claude_md_path(tmp_path)
1076
+ (claude_md_path.parent / "kept.py").write_text("x = 1\n", encoding="utf-8")
1077
+ claude_md_path.write_text(
1078
+ "# example\n\n"
1079
+ "A prose paragraph with a typoo to fix.\n\n"
1080
+ "## Running / testing\n\n"
1081
+ "```\n"
1082
+ "C:\\Python313\\python.exe kept.py\n"
1083
+ "C:\\Python313\\python.exe already_orphan_script.py\n"
1084
+ "```\n",
1085
+ encoding="utf-8",
1086
+ )
1087
+ result = _run_hook(
1088
+ "Edit",
1089
+ {
1090
+ "file_path": str(claude_md_path),
1091
+ "old_string": "A prose paragraph with a typoo to fix.",
1092
+ "new_string": "A prose paragraph with a typo fixed.",
1093
+ },
1094
+ )
1095
+ assert result.returncode == 0
1096
+ assert result.stdout == ""
1097
+
1098
+
615
1099
  def test_blocker_module_has_no_collection_parameter_naming_violations():
616
1100
  blocker_source = Path(HOOK_SCRIPT_PATH).read_text(encoding="utf-8")
617
1101
  assert check_collection_prefix(blocker_source, HOOK_SCRIPT_PATH) == []
@@ -46,6 +46,7 @@ _hook_spec.loader.exec_module(_hook_module)
46
46
  VOID_ADVISORY_CHECK_FUNCTION_NAMES: frozenset[str] = frozenset(
47
47
  {
48
48
  "check_duplicated_format_patterns",
49
+ "check_flag_gated_scenario_test_naming",
49
50
  "check_incomplete_mocks",
50
51
  }
51
52
  )