claude-dev-env 1.51.0 → 1.52.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/hooks/blocking/code_rules_enforcer.py +367 -42
- package/hooks/blocking/tdd_enforcer.py +211 -19
- package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +519 -0
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +1 -1
- package/hooks/blocking/test_tdd_enforcer.py +399 -0
- package/hooks/hooks.json +0 -15
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +5 -0
- package/package.json +1 -1
- package/skills/update/SKILL.md +143 -0
|
@@ -690,3 +690,402 @@ def test_should_deny_code_rules_edit_when_split_family_sibling_is_stale(
|
|
|
690
690
|
completed = _run_hook_with_payload(payload)
|
|
691
691
|
|
|
692
692
|
assert _decision_from(completed) == "deny"
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def test_should_offer_nested_package_mirroring_candidate_for_subpackage_module(
|
|
696
|
+
tmp_path: Path,
|
|
697
|
+
) -> None:
|
|
698
|
+
sandbox = _sandbox(tmp_path)
|
|
699
|
+
package_root = sandbox / "pkg"
|
|
700
|
+
subpackage_directory = package_root / "services" / "mouse_movement"
|
|
701
|
+
subpackage_directory.mkdir(parents=True)
|
|
702
|
+
(package_root / "tests").mkdir()
|
|
703
|
+
production_module = subpackage_directory / "tremor.py"
|
|
704
|
+
|
|
705
|
+
all_candidates = _PRODUCTION_MODULE.candidate_test_paths_for(production_module)
|
|
706
|
+
|
|
707
|
+
assert package_root / "tests" / "services" / "test_tremor.py" in all_candidates
|
|
708
|
+
assert (
|
|
709
|
+
package_root / "tests" / "services" / "mouse_movement" / "test_tremor.py"
|
|
710
|
+
in all_candidates
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def test_should_offer_flat_candidate_alongside_nested_candidates(
|
|
715
|
+
tmp_path: Path,
|
|
716
|
+
) -> None:
|
|
717
|
+
sandbox = _sandbox(tmp_path)
|
|
718
|
+
package_root = sandbox / "pkg"
|
|
719
|
+
subpackage_directory = package_root / "services" / "mouse_movement"
|
|
720
|
+
subpackage_directory.mkdir(parents=True)
|
|
721
|
+
(package_root / "tests").mkdir()
|
|
722
|
+
production_module = subpackage_directory / "tremor.py"
|
|
723
|
+
|
|
724
|
+
all_candidates = _PRODUCTION_MODULE.candidate_test_paths_for(production_module)
|
|
725
|
+
|
|
726
|
+
assert package_root / "tests" / "test_tremor.py" in all_candidates
|
|
727
|
+
assert (
|
|
728
|
+
package_root / "tests" / "services" / "mouse_movement" / "test_tremor.py"
|
|
729
|
+
in all_candidates
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def test_should_allow_write_when_nested_package_mirroring_test_is_fresh(
|
|
734
|
+
tmp_path: Path,
|
|
735
|
+
) -> None:
|
|
736
|
+
sandbox = _sandbox(tmp_path)
|
|
737
|
+
package_root = sandbox / "pkg"
|
|
738
|
+
subpackage_directory = package_root / "services" / "mouse_movement"
|
|
739
|
+
subpackage_directory.mkdir(parents=True)
|
|
740
|
+
nested_tests_directory = package_root / "tests" / "services" / "mouse_movement"
|
|
741
|
+
nested_tests_directory.mkdir(parents=True)
|
|
742
|
+
production_module = subpackage_directory / "tremor.py"
|
|
743
|
+
production_module.write_text("def jitter(): pass\n")
|
|
744
|
+
nested_test = nested_tests_directory / "test_tremor.py"
|
|
745
|
+
nested_test.write_text("def test_jitter(): pass\n")
|
|
746
|
+
|
|
747
|
+
completed = _run_hook_with_payload(_make_write_payload(production_module))
|
|
748
|
+
|
|
749
|
+
assert _decision_from(completed) == "allow"
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def test_should_collect_tests_directories_from_every_ancestor_up_to_repo_boundary(
|
|
753
|
+
tmp_path: Path,
|
|
754
|
+
) -> None:
|
|
755
|
+
sandbox = _sandbox(tmp_path)
|
|
756
|
+
package_root = sandbox / "pkg"
|
|
757
|
+
subpackage_directory = package_root / "services"
|
|
758
|
+
subpackage_directory.mkdir(parents=True)
|
|
759
|
+
(package_root / "tests").mkdir()
|
|
760
|
+
(subpackage_directory / "tests").mkdir()
|
|
761
|
+
|
|
762
|
+
all_pairs = _PRODUCTION_MODULE._ancestor_tests_directories(subpackage_directory)
|
|
763
|
+
|
|
764
|
+
collected_tests_directories = [each_tests_directory for _, each_tests_directory in all_pairs]
|
|
765
|
+
assert subpackage_directory / "tests" in collected_tests_directories
|
|
766
|
+
assert package_root / "tests" in collected_tests_directories
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def test_ancestor_tests_walk_stops_at_repo_boundary(tmp_path: Path) -> None:
|
|
770
|
+
outer_tests_directory = tmp_path / "tests"
|
|
771
|
+
outer_tests_directory.mkdir()
|
|
772
|
+
sandbox = _sandbox(tmp_path)
|
|
773
|
+
package_directory = sandbox / "pkg"
|
|
774
|
+
package_directory.mkdir()
|
|
775
|
+
|
|
776
|
+
all_pairs = _PRODUCTION_MODULE._ancestor_tests_directories(package_directory)
|
|
777
|
+
|
|
778
|
+
collected_tests_directories = [each_tests_directory for _, each_tests_directory in all_pairs]
|
|
779
|
+
assert outer_tests_directory not in collected_tests_directories
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def test_ancestor_tests_walk_honors_parent_walk_limit(tmp_path: Path) -> None:
|
|
783
|
+
walk_limit = _PRODUCTION_MODULE._parent_walk_limit()
|
|
784
|
+
deep_directory = tmp_path
|
|
785
|
+
for each_level_index in range(walk_limit + 2):
|
|
786
|
+
deep_directory = deep_directory / f"level_{each_level_index}"
|
|
787
|
+
deep_directory.mkdir(parents=True)
|
|
788
|
+
top_tests_directory = tmp_path / "tests"
|
|
789
|
+
top_tests_directory.mkdir()
|
|
790
|
+
|
|
791
|
+
all_pairs = _PRODUCTION_MODULE._ancestor_tests_directories(deep_directory)
|
|
792
|
+
|
|
793
|
+
collected_tests_directories = [each_tests_directory for _, each_tests_directory in all_pairs]
|
|
794
|
+
assert top_tests_directory not in collected_tests_directories
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def test_should_deny_edit_that_swaps_an_import_target(tmp_path: Path) -> None:
|
|
798
|
+
sandbox = _sandbox(tmp_path)
|
|
799
|
+
production_module = sandbox / "orders.py"
|
|
800
|
+
production_module.write_text("import os\n\ndef fulfill(): pass\n")
|
|
801
|
+
|
|
802
|
+
completed = _run_hook_with_payload(
|
|
803
|
+
_make_edit_payload(
|
|
804
|
+
production_module,
|
|
805
|
+
old_string="import os",
|
|
806
|
+
new_string="import sys",
|
|
807
|
+
)
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
assert _decision_from(completed) == "deny"
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def test_should_allow_edit_that_removes_an_import_statement(tmp_path: Path) -> None:
|
|
814
|
+
sandbox = _sandbox(tmp_path)
|
|
815
|
+
production_module = sandbox / "orders.py"
|
|
816
|
+
production_module.write_text("import os\n\ndef fulfill(): pass\n")
|
|
817
|
+
|
|
818
|
+
completed = _run_hook_with_payload(
|
|
819
|
+
_make_edit_payload(
|
|
820
|
+
production_module,
|
|
821
|
+
old_string="import os\n",
|
|
822
|
+
new_string="",
|
|
823
|
+
)
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
assert _decision_from(completed) == "allow"
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def test_should_allow_multiedit_when_every_pair_removes_an_import(tmp_path: Path) -> None:
|
|
830
|
+
sandbox = _sandbox(tmp_path)
|
|
831
|
+
production_module = sandbox / "orders.py"
|
|
832
|
+
production_module.write_text("import os\nimport json\nimport time\n\ndef fulfill(): pass\n")
|
|
833
|
+
|
|
834
|
+
completed = _run_hook_with_payload(
|
|
835
|
+
_make_multiedit_payload(
|
|
836
|
+
production_module,
|
|
837
|
+
edits=[
|
|
838
|
+
{"old_string": "import os\n", "new_string": ""},
|
|
839
|
+
{"old_string": "import json\n", "new_string": ""},
|
|
840
|
+
],
|
|
841
|
+
)
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
assert _decision_from(completed) == "allow"
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def test_should_deny_multiedit_when_one_pair_is_not_import_only(tmp_path: Path) -> None:
|
|
848
|
+
sandbox = _sandbox(tmp_path)
|
|
849
|
+
production_module = sandbox / "orders.py"
|
|
850
|
+
production_module.write_text("import os\n\ndef fulfill(): pass\n")
|
|
851
|
+
|
|
852
|
+
completed = _run_hook_with_payload(
|
|
853
|
+
_make_multiedit_payload(
|
|
854
|
+
production_module,
|
|
855
|
+
edits=[
|
|
856
|
+
{"old_string": "import os", "new_string": "import sys"},
|
|
857
|
+
{
|
|
858
|
+
"old_string": "def fulfill(): pass",
|
|
859
|
+
"new_string": "def fulfill(): return 1",
|
|
860
|
+
},
|
|
861
|
+
],
|
|
862
|
+
)
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
assert _decision_from(completed) == "deny"
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def test_should_not_exempt_write_with_behavior_under_import_only_rule(
|
|
869
|
+
tmp_path: Path,
|
|
870
|
+
) -> None:
|
|
871
|
+
sandbox = _sandbox(tmp_path)
|
|
872
|
+
production_module = sandbox / "orders.py"
|
|
873
|
+
write_content_with_behavior = "import os\n\ndef fulfill(): return os.getpid()\n"
|
|
874
|
+
|
|
875
|
+
completed = _run_hook_with_payload(
|
|
876
|
+
_make_write_payload(production_module, write_content_with_behavior)
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
assert _decision_from(completed) == "deny"
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def test_should_deny_behavior_edit_without_a_fresh_candidate_test(tmp_path: Path) -> None:
|
|
883
|
+
sandbox = _sandbox(tmp_path)
|
|
884
|
+
production_module = sandbox / "orders.py"
|
|
885
|
+
production_module.write_text("def fulfill(): pass\n")
|
|
886
|
+
|
|
887
|
+
completed = _run_hook_with_payload(
|
|
888
|
+
_make_edit_payload(
|
|
889
|
+
production_module,
|
|
890
|
+
old_string="def fulfill(): pass",
|
|
891
|
+
new_string="def fulfill(): return 1",
|
|
892
|
+
)
|
|
893
|
+
)
|
|
894
|
+
|
|
895
|
+
assert _decision_from(completed) == "deny"
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def test_should_deny_edit_of_import_text_inside_a_string_literal(tmp_path: Path) -> None:
|
|
899
|
+
sandbox = _sandbox(tmp_path)
|
|
900
|
+
production_module = sandbox / "orders.py"
|
|
901
|
+
production_module.write_text('banner = "import os is great"\n\ndef fulfill(): return banner\n')
|
|
902
|
+
|
|
903
|
+
completed = _run_hook_with_payload(
|
|
904
|
+
_make_edit_payload(
|
|
905
|
+
production_module,
|
|
906
|
+
old_string="import os",
|
|
907
|
+
new_string="import sys",
|
|
908
|
+
)
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
assert _decision_from(completed) == "deny"
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def test_should_allow_import_reorder_when_old_string_carries_context_lines(
|
|
915
|
+
tmp_path: Path,
|
|
916
|
+
) -> None:
|
|
917
|
+
sandbox = _sandbox(tmp_path)
|
|
918
|
+
production_module = sandbox / "orders.py"
|
|
919
|
+
production_module.write_text("import os\nimport sys\n\ndef fulfill(): pass\n")
|
|
920
|
+
|
|
921
|
+
completed = _run_hook_with_payload(
|
|
922
|
+
_make_edit_payload(
|
|
923
|
+
production_module,
|
|
924
|
+
old_string="import os\nimport sys\n\ndef fulfill(): pass",
|
|
925
|
+
new_string="import sys\nimport os\n\ndef fulfill(): pass",
|
|
926
|
+
)
|
|
927
|
+
)
|
|
928
|
+
|
|
929
|
+
assert _decision_from(completed) == "allow"
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def test_should_allow_import_only_edit_after_a_constants_assignment(tmp_path: Path) -> None:
|
|
933
|
+
sandbox = _sandbox(tmp_path)
|
|
934
|
+
production_module = sandbox / "orders.py"
|
|
935
|
+
production_module.write_text("import os\nimport sys\n\nMAX_ORDERS = 5\n\ndef fulfill(): pass\n")
|
|
936
|
+
|
|
937
|
+
completed = _run_hook_with_payload(
|
|
938
|
+
_make_edit_payload(
|
|
939
|
+
production_module,
|
|
940
|
+
old_string="import os\n",
|
|
941
|
+
new_string="",
|
|
942
|
+
)
|
|
943
|
+
)
|
|
944
|
+
|
|
945
|
+
assert _decision_from(completed) == "allow"
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def test_should_deny_replace_all_edit_that_rewrites_call_sites(tmp_path: Path) -> None:
|
|
949
|
+
sandbox = _sandbox(tmp_path)
|
|
950
|
+
production_module = sandbox / "orders.py"
|
|
951
|
+
production_module.write_text("import json\n\ndef parse(line): return json.loads(line)\n")
|
|
952
|
+
|
|
953
|
+
completed = _run_hook_with_payload(
|
|
954
|
+
{
|
|
955
|
+
"tool_name": "Edit",
|
|
956
|
+
"tool_input": {
|
|
957
|
+
"file_path": str(production_module),
|
|
958
|
+
"old_string": "json",
|
|
959
|
+
"new_string": "pickle",
|
|
960
|
+
"replace_all": True,
|
|
961
|
+
},
|
|
962
|
+
}
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
assert _decision_from(completed) == "deny"
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def test_should_allow_edit_that_reorders_import_statements(tmp_path: Path) -> None:
|
|
969
|
+
sandbox = _sandbox(tmp_path)
|
|
970
|
+
production_module = sandbox / "orders.py"
|
|
971
|
+
production_module.write_text("import os\nimport sys\n\ndef fulfill(): pass\n")
|
|
972
|
+
|
|
973
|
+
completed = _run_hook_with_payload(
|
|
974
|
+
_make_edit_payload(
|
|
975
|
+
production_module,
|
|
976
|
+
old_string="import os\nimport sys",
|
|
977
|
+
new_string="import sys\nimport os",
|
|
978
|
+
)
|
|
979
|
+
)
|
|
980
|
+
|
|
981
|
+
assert _decision_from(completed) == "allow"
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def test_should_deny_edit_that_retargets_an_import_source(tmp_path: Path) -> None:
|
|
985
|
+
sandbox = _sandbox(tmp_path)
|
|
986
|
+
production_module = sandbox / "orders.py"
|
|
987
|
+
production_module.write_text("from fast import compute\n\ndef run(): return compute()\n")
|
|
988
|
+
|
|
989
|
+
completed = _run_hook_with_payload(
|
|
990
|
+
_make_edit_payload(
|
|
991
|
+
production_module,
|
|
992
|
+
old_string="from fast import compute",
|
|
993
|
+
new_string="from slow import compute",
|
|
994
|
+
)
|
|
995
|
+
)
|
|
996
|
+
|
|
997
|
+
assert _decision_from(completed) == "deny"
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def test_should_deny_edit_that_adds_a_future_import(tmp_path: Path) -> None:
|
|
1001
|
+
sandbox = _sandbox(tmp_path)
|
|
1002
|
+
production_module = sandbox / "orders.py"
|
|
1003
|
+
production_module.write_text("import os\n\ndef fulfill(): return os.getpid()\n")
|
|
1004
|
+
|
|
1005
|
+
completed = _run_hook_with_payload(
|
|
1006
|
+
_make_edit_payload(
|
|
1007
|
+
production_module,
|
|
1008
|
+
old_string="import os",
|
|
1009
|
+
new_string="from __future__ import annotations\nimport os",
|
|
1010
|
+
)
|
|
1011
|
+
)
|
|
1012
|
+
|
|
1013
|
+
assert _decision_from(completed) == "deny"
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def test_should_deny_multiedit_with_an_empty_edits_list(tmp_path: Path) -> None:
|
|
1017
|
+
sandbox = _sandbox(tmp_path)
|
|
1018
|
+
production_module = sandbox / "orders.py"
|
|
1019
|
+
production_module.write_text("import os\n\ndef fulfill(): return os.getpid()\n")
|
|
1020
|
+
|
|
1021
|
+
completed = _run_hook_with_payload(
|
|
1022
|
+
_make_multiedit_payload(production_module, edits=[])
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
assert _decision_from(completed) == "deny"
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def test_should_deny_edit_that_removes_a_future_import(tmp_path: Path) -> None:
|
|
1029
|
+
sandbox = _sandbox(tmp_path)
|
|
1030
|
+
production_module = sandbox / "orders.py"
|
|
1031
|
+
production_module.write_text(
|
|
1032
|
+
"from __future__ import annotations\nimport os\n\ndef fulfill(): return os.getpid()\n"
|
|
1033
|
+
)
|
|
1034
|
+
|
|
1035
|
+
completed = _run_hook_with_payload(
|
|
1036
|
+
_make_edit_payload(
|
|
1037
|
+
production_module,
|
|
1038
|
+
old_string="from __future__ import annotations\n",
|
|
1039
|
+
new_string="",
|
|
1040
|
+
)
|
|
1041
|
+
)
|
|
1042
|
+
|
|
1043
|
+
assert _decision_from(completed) == "deny"
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def test_should_deny_edit_that_adds_an_import(tmp_path: Path) -> None:
|
|
1047
|
+
sandbox = _sandbox(tmp_path)
|
|
1048
|
+
production_module = sandbox / "orders.py"
|
|
1049
|
+
production_module.write_text("import os\n\ndef fulfill(): pass\n")
|
|
1050
|
+
|
|
1051
|
+
completed = _run_hook_with_payload(
|
|
1052
|
+
_make_edit_payload(
|
|
1053
|
+
production_module,
|
|
1054
|
+
old_string="import os",
|
|
1055
|
+
new_string="import os\nimport sys",
|
|
1056
|
+
)
|
|
1057
|
+
)
|
|
1058
|
+
|
|
1059
|
+
assert _decision_from(completed) == "deny"
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def test_should_deny_edit_that_duplicates_an_import(tmp_path: Path) -> None:
|
|
1063
|
+
sandbox = _sandbox(tmp_path)
|
|
1064
|
+
production_module = sandbox / "orders.py"
|
|
1065
|
+
production_module.write_text("import os\n\ndef fulfill(): pass\n")
|
|
1066
|
+
|
|
1067
|
+
completed = _run_hook_with_payload(
|
|
1068
|
+
_make_edit_payload(
|
|
1069
|
+
production_module,
|
|
1070
|
+
old_string="import os",
|
|
1071
|
+
new_string="import os\nimport os",
|
|
1072
|
+
)
|
|
1073
|
+
)
|
|
1074
|
+
|
|
1075
|
+
assert _decision_from(completed) == "deny"
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def test_should_deny_changing_a_future_import_on_a_constants_only_file(tmp_path: Path) -> None:
|
|
1079
|
+
sandbox = _sandbox(tmp_path)
|
|
1080
|
+
production_module = sandbox / "orders.py"
|
|
1081
|
+
production_module.write_text("from __future__ import annotations\nMAX_ORDERS = 5\n")
|
|
1082
|
+
|
|
1083
|
+
completed = _run_hook_with_payload(
|
|
1084
|
+
_make_edit_payload(
|
|
1085
|
+
production_module,
|
|
1086
|
+
old_string="from __future__ import annotations",
|
|
1087
|
+
new_string="from __future__ import division",
|
|
1088
|
+
)
|
|
1089
|
+
)
|
|
1090
|
+
|
|
1091
|
+
assert _decision_from(completed) == "deny"
|
package/hooks/hooks.json
CHANGED
|
@@ -2,16 +2,6 @@
|
|
|
2
2
|
"description": "Code standards enforcement, safety guards, and development workflow hooks",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"PreToolUse": [
|
|
5
|
-
{
|
|
6
|
-
"matcher": "Grep|Search",
|
|
7
|
-
"hooks": [
|
|
8
|
-
{
|
|
9
|
-
"type": "command",
|
|
10
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/content_search_to_zoekt_redirector.py",
|
|
11
|
-
"timeout": 10
|
|
12
|
-
}
|
|
13
|
-
]
|
|
14
|
-
},
|
|
15
5
|
{
|
|
16
6
|
"matcher": "Write|Edit",
|
|
17
7
|
"hooks": [
|
|
@@ -130,11 +120,6 @@
|
|
|
130
120
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/convergence_gate_blocker.py",
|
|
131
121
|
"timeout": 15
|
|
132
122
|
},
|
|
133
|
-
{
|
|
134
|
-
"type": "command",
|
|
135
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/content_search_to_zoekt_redirector.py",
|
|
136
|
-
"timeout": 10
|
|
137
|
-
},
|
|
138
123
|
{
|
|
139
124
|
"type": "command",
|
|
140
125
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/windows_rmtree_blocker.py",
|
|
@@ -124,6 +124,11 @@ FUNCTION_LENGTH_BLOCKING_MESSAGE_SUFFIX: str = (
|
|
|
124
124
|
"function review hint)"
|
|
125
125
|
)
|
|
126
126
|
|
|
127
|
+
PRECHECK_USAGE_EXIT_CODE: int = 2
|
|
128
|
+
PRECHECK_USAGE_MESSAGE: str = (
|
|
129
|
+
"usage: code_rules_enforcer.py --check <candidate> [--as <target>]\n"
|
|
130
|
+
)
|
|
131
|
+
|
|
127
132
|
BANNED_NOUN_SPAN_FRAGMENT_TEMPLATE: str = (
|
|
128
133
|
"(binding span at line {definition_line}, spanning {line_span} lines)"
|
|
129
134
|
)
|
package/package.json
CHANGED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: update
|
|
3
|
+
description: Fast-forwards a local git repository's main branch to a remote's main, after confirming both the local repo path and the source remote through AskUserQuestion. Fetches the chosen remote, checks that the move is a true fast-forward (never a force, never a merge commit), and updates main whether or not main is the checked-out branch. Use when the user says "/update", "update main", "fast-forward main", "sync main from origin", "pull latest main into <path>", or "bring main up to date". Triggers on "/update", "update main", "fast-forward main", "sync main".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# update
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
Fast-forwards the local `main` branch of a given repository to a chosen remote's `main`. The move is always a true fast-forward: the skill fetches the remote, checks that local `main` is an ancestor of the remote's `main`, and advances the ref. It never forces, never creates a merge commit, and never touches any branch other than `main`.
|
|
11
|
+
|
|
12
|
+
The repository is whatever path the user gives as the `/update <path>` argument. With no argument, the default is the current repository's top level. Either way the path is confirmed before any write.
|
|
13
|
+
|
|
14
|
+
**Announce at start:** "Confirming the repo path and source remote, then fast-forwarding main."
|
|
15
|
+
|
|
16
|
+
## When this applies
|
|
17
|
+
|
|
18
|
+
Trigger on a request to bring a repo's `main` up to date from a remote: "/update", "update main", "fast-forward main", "sync main from origin", "pull latest main into <path>".
|
|
19
|
+
|
|
20
|
+
**Refusals — first match wins; respond with the quoted line exactly and stop:**
|
|
21
|
+
|
|
22
|
+
- Path is not a git repository → `<path> is not a git repository. Give me the path to a git working tree.`
|
|
23
|
+
- The chosen remote has no `main` → `<remote> has no main branch. Pick a remote whose main you want.`
|
|
24
|
+
- Local `main` has diverged from the remote's `main` (not a fast-forward) → `main has diverged from <remote>/main (ahead N, behind M). A fast-forward is not possible — use /rebase or reconcile manually.`
|
|
25
|
+
|
|
26
|
+
## Instructions
|
|
27
|
+
|
|
28
|
+
### Phase 1 — Resolve the local path
|
|
29
|
+
|
|
30
|
+
Take the path from the `/update <path>` argument. With no argument, use the current repo's top level:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
git -C "<path>" rev-parse --show-toplevel
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Confirm the path is a git working tree (`git -C "<path>" rev-parse --git-dir`). If not, give the first refusal line.
|
|
37
|
+
|
|
38
|
+
### Phase 2 — Confirm the path and the source remote
|
|
39
|
+
|
|
40
|
+
List the candidate repo's remotes and their URLs:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
git -C "<path>" remote -v
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Confirm both in **one** `AskUserQuestion` question — header "Target". Each choice pairs the resolved path with one remote, labelled `<path> ← <remote>/main` and described with that remote's fetch URL. Picking a choice confirms the path and the remote together.
|
|
47
|
+
|
|
48
|
+
Recommend `<path> ← origin/main` first; the source-of-truth remote is not always the one named `origin`. The user picks "Other" to name a different path or remote. If they switch to a different repository, re-list its remotes and ask once more — remote names are per-repo.
|
|
49
|
+
|
|
50
|
+
### Phase 3 — Fetch and fast-forward
|
|
51
|
+
|
|
52
|
+
Run every command with `git -C "<path>"`. Do not `cd` into the repo.
|
|
53
|
+
|
|
54
|
+
1. Fetch the chosen remote's `main`, which moves the remote-tracking ref:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
git -C "<path>" fetch <remote> main
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Stop and report on failure (no network, no remote). If the remote has no `main`, give the second refusal line.
|
|
61
|
+
|
|
62
|
+
2. Read the current branch and the two commits:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
git -C "<path>" branch --show-current
|
|
66
|
+
git -C "<path>" rev-parse <remote>/main
|
|
67
|
+
git -C "<path>" rev-parse --verify main # may not exist yet
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
3. Decide the case:
|
|
71
|
+
|
|
72
|
+
| Case | Condition | Action |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| Create | local `main` does not exist | `git -C "<path>" branch main <remote>/main` |
|
|
75
|
+
| Up to date | local `main` == `<remote>/main` | report, done |
|
|
76
|
+
| Diverged | `merge-base --is-ancestor main <remote>/main` is false | third refusal line, stop |
|
|
77
|
+
| Fast-forward, on main | current branch is `main` | clean check, then `merge --ff-only` |
|
|
78
|
+
| Fast-forward, off main | current branch is not `main` | `fetch <remote> main:main` |
|
|
79
|
+
|
|
80
|
+
The fast-forward gate:
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
git -C "<path>" merge-base --is-ancestor main <remote>/main
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Exit 0 means a fast-forward is possible. Non-zero means diverged — refuse, never force.
|
|
87
|
+
|
|
88
|
+
4. Apply the fast-forward for the matched case:
|
|
89
|
+
|
|
90
|
+
- **On `main`:** the working tree must be clean first — `git -C "<path>" status --porcelain` must be empty. If dirty, stop and report; never stash or discard. Then:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
git -C "<path>" merge --ff-only <remote>/main
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
- **Off `main`:** advance the ref without touching the working tree:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
git -C "<path>" fetch <remote> main:main
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
This is fast-forward-only (no leading `+`) and leaves the checked-out branch alone.
|
|
103
|
+
|
|
104
|
+
### Phase 4 — Report
|
|
105
|
+
|
|
106
|
+
State the old and new `main` SHAs and the one-line subject of the new tip:
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
git -C "<path>" log --oneline -1 main
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Report the move as `main <old> → <new>`, or "already up to date" when nothing changed.
|
|
113
|
+
|
|
114
|
+
## Constraints (non-negotiable)
|
|
115
|
+
|
|
116
|
+
- **Fast-forward only.** If the remote's `main` is not a descendant of local `main`, stop. Never `--force`, never `branch -f`, never a merge commit. Divergence is a job for `/rebase`.
|
|
117
|
+
- **Always confirm both** the path and the source remote first, even when the path is given as an argument. Skipping a confirmation is not allowed — the confirmation is the point of this skill.
|
|
118
|
+
- **Touch only `main`.** Never switch the repo's checked-out branch. The single exception is advancing `main` in place when `main` is already checked out.
|
|
119
|
+
- **Never discard local work.** A dirty tree blocks the in-place fast-forward; stop and report rather than stash or reset.
|
|
120
|
+
|
|
121
|
+
## Gotchas
|
|
122
|
+
|
|
123
|
+
- `git fetch <remote> main:main` refuses with "Refusing to fetch into branch ... checked out" when `main` is the current branch. That is why Phase 3 branches on `git branch --show-current` and uses `merge --ff-only` when on `main`.
|
|
124
|
+
- The same refusal fires when `main` is checked out in a **different worktree** of the same repo. Find it with `git -C "<path>" worktree list`, then run the fast-forward from that worktree's path, or report it and stop.
|
|
125
|
+
- `<remote>/main` only moves after an explicit `git fetch`. Fetch inside every run; never compare against a remote-tracking ref left over from an earlier fetch.
|
|
126
|
+
- `origin` is not always the source of truth. When a fork is `origin` and the canonical repo is another remote (often `upstream`), the confirmed remote should be the canonical one, not whichever is named `origin`.
|
|
127
|
+
- Quote the path on every command — `git -C "<path>"` — so paths with spaces or a NAS drive letter survive.
|
|
128
|
+
|
|
129
|
+
## What this skill does NOT do
|
|
130
|
+
|
|
131
|
+
- Does not push, open a PR, or change any branch other than `main`.
|
|
132
|
+
- Does not create or switch feature branches — that is `/fresh-branch`.
|
|
133
|
+
- Does not reconcile a diverged `main` — that is `/rebase`.
|
|
134
|
+
|
|
135
|
+
## File index
|
|
136
|
+
|
|
137
|
+
| File | Purpose |
|
|
138
|
+
|------|---------|
|
|
139
|
+
| `SKILL.md` | This hub — the complete skill. |
|
|
140
|
+
|
|
141
|
+
## Folder map
|
|
142
|
+
|
|
143
|
+
- `SKILL.md` — the whole skill. Flat by design: the operation is a short, deterministic git sequence with no scripts or reference files to load.
|