claude-dev-env 1.74.0 → 1.76.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.
- package/_shared/pr-loop/scripts/code_rules_gate.py +60 -5
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/inline_duplicate_body_span_constants.py +22 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +147 -3
- package/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/CLAUDE.md +3 -2
- package/hooks/blocking/claude_md_orphan_file_blocker.py +170 -20
- package/hooks/blocking/code_rules_duplicate_body.py +378 -26
- package/hooks/blocking/code_rules_enforcer.py +36 -5
- package/hooks/blocking/code_rules_imports_logging.py +679 -1
- package/hooks/blocking/code_rules_shared.py +8 -5
- package/hooks/blocking/code_rules_test_assertions.py +6 -7
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +2 -1
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +155 -0
- package/hooks/blocking/hedging_language_blocker.py +1 -13
- package/hooks/blocking/intent_only_ending_blocker.py +1 -15
- package/hooks/blocking/pre_tool_use_dispatcher.py +4 -5
- package/hooks/blocking/question_to_user_enforcer.py +1 -11
- package/hooks/blocking/session_handoff_blocker.py +1 -15
- package/hooks/blocking/test_claude_md_orphan_file_blocker.py +484 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
- package/hooks/blocking/test_code_rules_enforcer_import_block_sort.py +157 -0
- package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +466 -0
- package/hooks/blocking/test_code_rules_enforcer_split_test_assertions.py +11 -9
- package/hooks/blocking/test_code_rules_js_resume_task_enumeration.py +758 -0
- package/hooks/blocking/test_code_rules_logging_printf_tokens.py +134 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +16 -0
- package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +328 -0
- package/hooks/blocking/test_hedging_language_blocker.py +6 -0
- package/hooks/blocking/test_intent_only_ending_blocker.py +5 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +52 -5
- package/hooks/blocking/test_question_to_user_enforcer.py +6 -0
- package/hooks/blocking/test_session_handoff_blocker.py +6 -0
- package/hooks/blocking/test_verification_verdict_store.py +66 -1
- package/hooks/blocking/test_verifier_verdict_minter.py +64 -5
- package/hooks/blocking/verification_verdict_store.py +19 -5
- package/hooks/blocking/verifier_verdict_minter.py +18 -15
- package/hooks/hooks_constants/CLAUDE.md +4 -1
- package/hooks/hooks_constants/blocking_check_limits.py +30 -1
- package/hooks/hooks_constants/claude_md_orphan_file_blocker_constants.py +52 -24
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +41 -1
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +2 -1
- package/hooks/hooks_constants/duplicate_function_body_constants.py +21 -5
- package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +27 -0
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +2 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +8 -2
- package/hooks/hooks_constants/test_post_tool_use_dispatcher_constants.py +43 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +99 -0
- package/hooks/hooks_constants/test_text_stripping.py +39 -0
- package/hooks/hooks_constants/text_stripping.py +36 -0
- package/hooks/validation/CLAUDE.md +1 -0
- package/hooks/validation/post_tool_use_dispatcher.py +2 -2
- package/hooks/validation/test_mypy_validator.py +1 -1
- package/hooks/validation/test_post_tool_use_dispatcher.py +6 -0
- package/hooks/workflow/auto_formatter.py +8 -5
- package/hooks/workflow/test_auto_formatter.py +33 -0
- package/package.json +1 -1
- package/rules/claude-md-orphan-file.md +7 -8
- package/rules/docstring-prose-matches-implementation.md +1 -0
- package/rules/package-inventory-stale-entry.md +8 -0
- package/rules/windows-filesystem-safe.md +2 -0
- package/skills/anthropic-plan/CLAUDE.md +1 -1
- package/skills/anthropic-plan/SKILL.md +15 -2
- package/skills/autoconverge/SKILL.md +6 -3
- package/skills/autoconverge/reference/stop-conditions.md +7 -0
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +5 -4
- package/skills/autoconverge/workflow/converge.contract.test.mjs +306 -137
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +16 -16
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +107 -44
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -24
- package/skills/autoconverge/workflow/converge.mjs +581 -613
- package/skills/autoconverge/workflow/convergence_summary.py +1 -1
- package/skills/autoconverge/workflow/render_report.py +2 -6
- package/skills/autoconverge/workflow/test_convergence_summary.py +17 -0
- package/skills/autoconverge/workflow/test_render_report.py +1 -0
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +58 -4
- package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +9 -0
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +42 -0
|
@@ -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) == []
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Tests for the ruff-I001 import-block-sort gate in code_rules_imports_logging.
|
|
2
|
+
|
|
3
|
+
The gate delegates to ruff itself, so these tests run real ruff over real source
|
|
4
|
+
shapes (the same way the repo quality gate does) rather than mocking the
|
|
5
|
+
subprocess. A sorted block passes; the PR #749 shape — two local ``from`` imports
|
|
6
|
+
out of alphabetical order in one contiguous block — fails with an I001 issue.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import importlib.util
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from types import ModuleType
|
|
12
|
+
|
|
13
|
+
IMPORTS_MODULE_FILENAME = "code_rules_imports_logging.py"
|
|
14
|
+
IMPORTS_MODULE_NAME = "code_rules_imports_logging_under_test"
|
|
15
|
+
PRODUCTION_PYTHON_TARGET = str(Path(__file__).parent / "production_module.py")
|
|
16
|
+
|
|
17
|
+
SORTED_IMPORT_BLOCK_SOURCE = (
|
|
18
|
+
"import json\n"
|
|
19
|
+
"import os\n"
|
|
20
|
+
"\n"
|
|
21
|
+
"from package.alpha import first\n"
|
|
22
|
+
"from package.beta import second\n"
|
|
23
|
+
"from package.gamma import third\n"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
UNSORTED_IMPORT_BLOCK_SOURCE = (
|
|
27
|
+
"import json\n"
|
|
28
|
+
"import os\n"
|
|
29
|
+
"\n"
|
|
30
|
+
"from package.gamma import third\n"
|
|
31
|
+
"from package.alpha import first\n"
|
|
32
|
+
"from package.beta import second\n"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_imports_module() -> ModuleType:
|
|
37
|
+
module_path = Path(__file__).parent / IMPORTS_MODULE_FILENAME
|
|
38
|
+
module_spec = importlib.util.spec_from_file_location(IMPORTS_MODULE_NAME, module_path)
|
|
39
|
+
assert module_spec is not None
|
|
40
|
+
assert module_spec.loader is not None
|
|
41
|
+
imports_module = importlib.util.module_from_spec(module_spec)
|
|
42
|
+
module_spec.loader.exec_module(imports_module)
|
|
43
|
+
return imports_module
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
imports_logging = load_imports_module()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_should_flag_unsorted_import_block() -> None:
|
|
50
|
+
issues = imports_logging.check_import_block_sorted(
|
|
51
|
+
UNSORTED_IMPORT_BLOCK_SOURCE, PRODUCTION_PYTHON_TARGET
|
|
52
|
+
)
|
|
53
|
+
assert len(issues) == 1
|
|
54
|
+
assert "un-sorted (ruff I001)" in issues[0]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_should_allow_sorted_import_block() -> None:
|
|
58
|
+
issues = imports_logging.check_import_block_sorted(
|
|
59
|
+
SORTED_IMPORT_BLOCK_SOURCE, PRODUCTION_PYTHON_TARGET
|
|
60
|
+
)
|
|
61
|
+
assert issues == []
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_should_report_the_block_anchor_line_number() -> None:
|
|
65
|
+
issues = imports_logging.check_import_block_sorted(
|
|
66
|
+
UNSORTED_IMPORT_BLOCK_SOURCE, PRODUCTION_PYTHON_TARGET
|
|
67
|
+
)
|
|
68
|
+
assert issues[0].startswith("Line 1:")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_should_exempt_test_files() -> None:
|
|
72
|
+
issues = imports_logging.check_import_block_sorted(
|
|
73
|
+
UNSORTED_IMPORT_BLOCK_SOURCE, "test_something.py"
|
|
74
|
+
)
|
|
75
|
+
assert issues == []
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_should_exempt_non_python_files() -> None:
|
|
79
|
+
issues = imports_logging.check_import_block_sorted(UNSORTED_IMPORT_BLOCK_SOURCE, "notes.md")
|
|
80
|
+
assert issues == []
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_should_fail_open_on_unparseable_source() -> None:
|
|
84
|
+
broken_source = "def (:::\n import os\n"
|
|
85
|
+
issues = imports_logging.check_import_block_sorted(broken_source, PRODUCTION_PYTHON_TARGET)
|
|
86
|
+
assert issues == []
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_should_fail_open_when_no_ruff_config_is_discoverable(tmp_path: Path) -> None:
|
|
90
|
+
target_without_config = tmp_path / "standalone_module.py"
|
|
91
|
+
issues = imports_logging.check_import_block_sorted(
|
|
92
|
+
UNSORTED_IMPORT_BLOCK_SOURCE, str(target_without_config)
|
|
93
|
+
)
|
|
94
|
+
assert issues == []
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_should_skip_unsorted_block_when_edit_is_distant_from_it() -> None:
|
|
98
|
+
changed_lines_away_from_block = {9}
|
|
99
|
+
issues = imports_logging.check_import_block_sorted(
|
|
100
|
+
UNSORTED_IMPORT_BLOCK_SOURCE,
|
|
101
|
+
PRODUCTION_PYTHON_TARGET,
|
|
102
|
+
changed_lines_away_from_block,
|
|
103
|
+
)
|
|
104
|
+
assert issues == []
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_should_flag_unsorted_block_when_edit_touches_anchor_line() -> None:
|
|
108
|
+
changed_lines_on_block_anchor = {1}
|
|
109
|
+
issues = imports_logging.check_import_block_sorted(
|
|
110
|
+
UNSORTED_IMPORT_BLOCK_SOURCE,
|
|
111
|
+
PRODUCTION_PYTHON_TARGET,
|
|
112
|
+
changed_lines_on_block_anchor,
|
|
113
|
+
)
|
|
114
|
+
assert len(issues) == 1
|
|
115
|
+
assert "un-sorted (ruff I001)" in issues[0]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_should_flag_unsorted_block_when_edit_touches_non_anchor_block_line() -> None:
|
|
119
|
+
changed_lines_inside_block_below_anchor = {4}
|
|
120
|
+
issues = imports_logging.check_import_block_sorted(
|
|
121
|
+
UNSORTED_IMPORT_BLOCK_SOURCE,
|
|
122
|
+
PRODUCTION_PYTHON_TARGET,
|
|
123
|
+
changed_lines_inside_block_below_anchor,
|
|
124
|
+
)
|
|
125
|
+
assert len(issues) == 1
|
|
126
|
+
assert "un-sorted (ruff I001)" in issues[0]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_should_flag_unsorted_block_when_edit_touches_last_block_line() -> None:
|
|
130
|
+
changed_lines_on_block_final_line = {6}
|
|
131
|
+
issues = imports_logging.check_import_block_sorted(
|
|
132
|
+
UNSORTED_IMPORT_BLOCK_SOURCE,
|
|
133
|
+
PRODUCTION_PYTHON_TARGET,
|
|
134
|
+
changed_lines_on_block_final_line,
|
|
135
|
+
)
|
|
136
|
+
assert len(issues) == 1
|
|
137
|
+
assert "un-sorted (ruff I001)" in issues[0]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_should_flag_unsorted_block_when_changed_lines_is_none() -> None:
|
|
141
|
+
issues = imports_logging.check_import_block_sorted(
|
|
142
|
+
UNSORTED_IMPORT_BLOCK_SOURCE,
|
|
143
|
+
PRODUCTION_PYTHON_TARGET,
|
|
144
|
+
None,
|
|
145
|
+
)
|
|
146
|
+
assert len(issues) == 1
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_should_defer_unsorted_block_to_caller_scope() -> None:
|
|
150
|
+
changed_lines_away_from_block = {9}
|
|
151
|
+
issues = imports_logging.check_import_block_sorted(
|
|
152
|
+
UNSORTED_IMPORT_BLOCK_SOURCE,
|
|
153
|
+
PRODUCTION_PYTHON_TARGET,
|
|
154
|
+
changed_lines_away_from_block,
|
|
155
|
+
defer_scope_to_caller=True,
|
|
156
|
+
)
|
|
157
|
+
assert len(issues) == 1
|